import { Injectable, Logger } from '@nestjs/common';
import { GpsFixType, Prisma } from '@prisma/client';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { toPointDto } from '../points/points.service';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import {
  AppFixType,
  AppLogAck,
  AppLogAckResult,
  AppLogPointDto,
  AppLogPointsMessageDto,
} from './dto/app-messages.dto';
import { MqttClientService } from './mqtt-client.service';

const SCHEMA_VERSION = '1';
const UUID_V7 =
  /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

// Durable app-originated ingest for ul/{orgId}/app/{clientId}/log/points. The exact v1
// publish/ack envelopes are frozen in meta/contracts/telemetry-schema.md.
@Injectable()
export class AppLogIngestService {
  private readonly logger = new Logger(AppLogIngestService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly realtime: RealtimeService,
    private readonly mqttClient: MqttClientService,
  ) {}

  async handle(
    orgId: string,
    clientId: string,
    rawPayload: string,
  ): Promise<void> {
    const org = await this.prisma.organization.findUnique({
      where: { id: orgId },
      select: { id: true },
    });
    if (!org) {
      this.logger.warn(
        `app-log for unknown org "${orgId}" (client ${clientId}) — dropped`,
      );
      return;
    }

    let parsed: unknown;
    try {
      parsed = JSON.parse(rawPayload);
    } catch {
      // There is no trustworthy pointId to acknowledge. Raw app payloads are redacted by the
      // router; retain/log on the app side rather than sending a null/ambiguous acknowledgement.
      this.logger.warn(
        `invalid JSON from app ${orgId}/${clientId} — no acknowledgement emitted`,
      );
      return;
    }

    const msg = plainToInstance(AppLogPointsMessageDto, parsed as object);
    const rawPoints = Array.isArray((parsed as { points?: unknown[] })?.points)
      ? (parsed as { points: unknown[] }).points
      : [];
    const usableIds = rawPoints
      .map((value) =>
        typeof (value as { pointId?: unknown })?.pointId === 'string'
          ? (value as { pointId: string }).pointId
          : null,
      )
      .filter((value): value is string => value !== null);

    const hasJobId = typeof msg.jobId === 'string' && msg.jobId.length > 0;
    const hasTicket = typeof msg.ticket === 'string' && msg.ticket.length > 0;
    const envelopeValid =
      msg.schemaVersion === SCHEMA_VERSION &&
      rawPoints.length > 0 &&
      rawPoints.length <= 500 &&
      hasJobId !== hasTicket;
    if (!envelopeValid) {
      this.publishAck(
        orgId,
        clientId,
        usableIds.map((pointId) => ({
          pointId,
          outcome: 'REJECTED',
          reasonCode: 'ENVELOPE_VALIDATION_ERROR',
        })),
      );
      return;
    }

    // Validate independently so one malformed capture does not discard valid captures in the
    // same batch (LOG-7). Invalid UUID strings can still be named in a rejection; missing IDs
    // cannot be safely correlated and therefore remain unacknowledged.
    const results: AppLogAckResult[] = [];
    const candidates: AppLogPointDto[] = [];
    for (const rawPoint of rawPoints) {
      const point = plainToInstance(AppLogPointDto, rawPoint as object);
      const pointId = typeof point.pointId === 'string' ? point.pointId : null;
      const errors = await validate(point, { whitelist: true });
      if (!pointId) {
        continue;
      }
      if (errors.length > 0 || !UUID_V7.test(pointId)) {
        results.push({
          pointId,
          outcome: 'REJECTED',
          reasonCode: UUID_V7.test(pointId)
            ? 'VALIDATION_ERROR'
            : 'POINT_ID_NOT_UUIDV7',
        });
        continue;
      }
      candidates.push(point);
    }

    const job = await this.resolveJob(orgId, clientId, msg);
    if (!job) {
      results.push(
        ...candidates.map((point) => ({
          pointId: point.pointId,
          outcome: 'REJECTED' as const,
          reasonCode: 'UNKNOWN_JOB_OR_WRONG_ORG',
        })),
      );
      this.publishAck(orgId, clientId, results);
      return;
    }

    // Collapse duplicates inside this delivery, then use producer event time rather than
    // arrival/array order. The first valid occurrence wins; repeated IDs are safe to release.
    const byPointId = new Map<string, AppLogPointDto>();
    for (const point of candidates) {
      if (byPointId.has(point.pointId)) {
        results.push({
          pointId: point.pointId,
          outcome: 'DUPLICATE',
          reasonCode: 'DUPLICATE_IN_BATCH',
        });
      } else {
        byPointId.set(point.pointId, point);
      }
    }
    const ordered = [...byPointId.values()].sort(
      (a, b) => +new Date(a.createdAt) - +new Date(b.createdAt),
    );

    const seen = ordered.length
      ? await this.prisma.locatePoint.findMany({
          where: { pointId: { in: ordered.map((point) => point.pointId) } },
          select: { pointId: true },
        })
      : [];
    const seenIds = new Set(seen.map((row) => row.pointId));
    const fresh = ordered.filter((point) => !seenIds.has(point.pointId));
    for (const point of ordered.filter((candidate) =>
      seenIds.has(candidate.pointId),
    )) {
      results.push({ pointId: point.pointId, outcome: 'DUPLICATE' });
    }

    let stored: Awaited<
      ReturnType<typeof this.prisma.locatePoint.createManyAndReturn>
    > = [];
    if (fresh.length > 0) {
      stored = await this.prisma.locatePoint.createManyAndReturn({
        data: fresh.map((point) => ({
          jobId: job.id,
          deviceId: null,
          pointId: point.pointId,
          origin: 'APP' as const,
          uploadPath: 'APP_MQTT' as const,
          originClientId: clientId,
          createdAt: new Date(point.createdAt),
          lat: point.lat,
          lng: point.lng,
          altitude: point.alt,
          utilityType: point.utility,
          sequence: point.seq,
          fixType: this.toStoredFix(point.fix),
          hAccuracy: point.hAcc,
          vAccuracy: point.vAcc,
          satellites: point.sats,
          hdop: point.hdop,
          depth: point.depth,
          frequencyHz: point.freqHz,
          currentMa: point.currentMa,
          signalDb: point.signalDb,
          gainDb: point.gainDb,
          locateMode: point.mode,
          phaseDeg: point.phaseDeg,
          compassDeg: point.compassDeg,
          distortionPct: point.distortionPct,
          recordedAt: new Date(point.ts),
          raw: point as unknown as Prisma.InputJsonValue,
        })),
        skipDuplicates: true,
      });
    }

    const storedIds = new Set(stored.map((row) => row.pointId));
    for (const point of fresh) {
      results.push({
        pointId: point.pointId,
        outcome: storedIds.has(point.pointId) ? 'ACCEPTED' : 'DUPLICATE',
      });
    }

    if (stored.length > 0) {
      this.realtime.publish(`job:${job.id}`, {
        type: 'points',
        jobId: job.id,
        points: stored.map(toPointDto),
      });
    }
    this.publishAck(orgId, clientId, results);
  }

  private async resolveJob(
    orgId: string,
    clientId: string,
    msg: AppLogPointsMessageDto,
  ) {
    if (msg.jobId) {
      const job = await this.prisma.job.findFirst({
        where: { id: msg.jobId, orgId },
      });
      if (!job)
        this.logger.warn(
          `app-log ${orgId}/${clientId} referenced a foreign/unknown job ${msg.jobId}`,
        );
      return job;
    }
    const existing = await this.prisma.job.findUnique({
      where: { orgId_ticketNumber: { orgId, ticketNumber: msg.ticket! } },
    });
    if (existing) return existing;
    return this.prisma.job.create({
      data: {
        orgId,
        ticketNumber: msg.ticket!,
        title: `Ticket ${msg.ticket} (app-created)`,
        status: 'IN_PROGRESS',
        source: 'WEB',
        startedAt: new Date(),
      },
    });
  }

  private toStoredFix(fix?: AppFixType): GpsFixType | undefined {
    switch (fix) {
      case 'FLOAT':
        return GpsFixType.FLOAT_RTK;
      case 'FIXED':
        return GpsFixType.FIXED_RTK;
      case 'NO_FIX':
        return GpsFixType.NONE;
      case 'AUTONOMOUS':
        return GpsFixType.AUTONOMOUS;
      case 'DGPS':
        return GpsFixType.DGPS;
      default:
        return undefined;
    }
  }

  private publishAck(
    orgId: string,
    clientId: string,
    results: AppLogAckResult[],
  ): void {
    if (results.length === 0) return;
    const ack: AppLogAck = { schemaVersion: SCHEMA_VERSION, results };
    this.mqttClient.publish(`ul/${orgId}/app/${clientId}/ack`, ack);
  }
}
