import { Test } from '@nestjs/testing';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { AppLogAck } from './dto/app-messages.dto';
import { AppLogIngestService } from './app-log-ingest.service';
import { MqttClientService } from './mqtt-client.service';

// Unit coverage for the durable app-log ingest path (S2-a, SRS-SYN-2/7, §3.4.2).
// Prisma is an in-memory fake keyed on pointId so idempotency is exercised for real
// (a replayed UUID must be stored exactly once), without a database. The broker is a
// spy so we can assert the application-level ack shape and topic.
describe('AppLogIngestService', () => {
  const ORG = 'org_alpha';
  const CLIENT = 'app_client_1';
  const JOB = { id: 'job_1', orgId: ORG, ticketNumber: 'T-1' };

  let service: AppLogIngestService;
  let published: { topic: string; payload: AppLogAck }[];
  let store: Map<string, { pointId: string; jobId: string; createdAt: Date }>;

  const point = (
    pointId: string,
    createdAt: string,
    extra: Record<string, unknown> = {},
  ) => ({
    pointId,
    createdAt,
    origin: 'APP',
    uploadPath: 'APP_MQTT',
    lat: 40,
    lng: -80,
    ts: createdAt,
    qualityFlag: 'IN_SPEC',
    ...extra,
  });

  const msg = (
    points: object[],
    job: { jobId?: string; ticket?: string } = { jobId: JOB.id },
  ) => JSON.stringify({ schemaVersion: '1', ...job, points });

  const lastAck = () => published[published.length - 1].payload;

  beforeEach(async () => {
    published = [];
    store = new Map();

    const prisma = {
      organization: {
        findUnique: jest.fn(({ where }: { where: { id: string } }) =>
          Promise.resolve(where.id === ORG ? { id: ORG } : null),
        ),
      },
      job: {
        findFirst: jest.fn(
          ({ where }: { where: { id: string; orgId: string } }) =>
            Promise.resolve(
              where.id === JOB.id && where.orgId === ORG ? JOB : null,
            ),
        ),
        findUnique: jest.fn(() => Promise.resolve(null)),
        create: jest.fn(() => Promise.resolve(JOB)),
      },
      locatePoint: {
        findMany: jest.fn(
          ({ where }: { where: { pointId: { in: string[] } } }) =>
            Promise.resolve(
              where.pointId.in
                .filter((id) => store.has(id))
                .map((pointId) => ({ pointId })),
            ),
        ),
        createManyAndReturn: jest.fn(
          ({
            data,
          }: {
            data: { pointId: string; jobId: string; createdAt: Date }[];
          }) => {
            const inserted: unknown[] = [];
            for (const row of data) {
              if (store.has(row.pointId)) {
                continue; // skipDuplicates
              }
              store.set(row.pointId, row);
              inserted.push({
                ...row,
                id: BigInt(store.size),
                lat: 40,
                lng: -80,
                altitude: null,
                utilityType: 'UNKNOWN',
                sequence: null,
                fixType: 'NONE',
                hAccuracy: null,
                vAccuracy: null,
                satellites: null,
                hdop: null,
                depth: null,
                frequencyHz: null,
                currentMa: null,
                signalDb: null,
                gainDb: null,
                locateMode: null,
                phaseDeg: null,
                compassDeg: null,
                distortionPct: null,
                recordedAt: row.createdAt,
                receivedAt: row.createdAt,
                origin: 'APP',
                uploadPath: 'APP_MQTT',
                originClientId: CLIENT,
                deviceId: null,
              });
            }
            return Promise.resolve(inserted);
          },
        ),
      },
    };

    const mqtt = {
      publish: jest.fn((topic: string, payload: AppLogAck) =>
        published.push({ topic, payload }),
      ),
    };

    const moduleRef = await Test.createTestingModule({
      providers: [
        AppLogIngestService,
        { provide: PrismaService, useValue: prisma },
        { provide: RealtimeService, useValue: { publish: jest.fn() } },
        { provide: MqttClientService, useValue: mqtt },
      ],
    }).compile();

    service = moduleRef.get(AppLogIngestService);
  });

  it('ingests a fresh point once and acks it accepted on the client namespace', async () => {
    await service.handle(
      ORG,
      CLIENT,
      msg([
        point('018f1a00-0000-7000-8000-000000000001', '2026-08-21T10:00:00Z'),
      ]),
    );

    expect(store.size).toBe(1);
    expect(published[0].topic).toBe(`ul/${ORG}/app/${CLIENT}/ack`);
    const ack = lastAck();
    expect(ack.results).toEqual([
      { pointId: '018f1a00-0000-7000-8000-000000000001', outcome: 'ACCEPTED' },
    ]);
  });

  it('consumes the shared app fixture and emits the shared ack fixture', async () => {
    const fixtureRoot = resolve(
      __dirname,
      '../../../../meta/contracts/fixtures',
    );
    const publishFixture = readFileSync(
      resolve(fixtureRoot, 'app-log-points-v1.json'),
      'utf8',
    );
    const expectedAck = JSON.parse(
      readFileSync(resolve(fixtureRoot, 'app-log-ack-v1.json'), 'utf8'),
    );

    await service.handle(ORG, CLIENT, publishFixture);

    expect(store.size).toBe(1);
    expect(lastAck()).toEqual(expectedAck);
  });

  it('ingests a replayed UUID exactly once (idempotency) and acks it as duplicate', async () => {
    const p = point(
      '018f1a00-0000-7000-8000-000000000002',
      '2026-08-21T10:00:00Z',
    );
    await service.handle(ORG, CLIENT, msg([p]));
    await service.handle(ORG, CLIENT, msg([p])); // replay

    expect(store.size).toBe(1); // stored exactly once
    const ack = lastAck();
    expect(ack.results).toEqual([
      { pointId: '018f1a00-0000-7000-8000-000000000002', outcome: 'DUPLICATE' },
    ]);
  });

  it('orders a batch by event time, not array order', async () => {
    const later = point(
      '018f1a00-0000-7000-8000-00000000000a',
      '2026-08-21T10:05:00Z',
    );
    const earlier = point(
      '018f1a00-0000-7000-8000-00000000000b',
      '2026-08-21T10:01:00Z',
    );
    await service.handle(ORG, CLIENT, msg([later, earlier]));

    const rows = [...store.values()];
    expect(rows.map((r) => r.pointId)).toEqual([
      earlier.pointId,
      later.pointId,
    ]);
  });

  it('rejects a point whose jobId belongs to another org (cross-namespace backstop)', async () => {
    await service.handle(
      ORG,
      CLIENT,
      msg(
        [point('018f1a00-0000-7000-8000-00000000000c', '2026-08-21T10:00:00Z')],
        { jobId: 'job_of_org_beta' },
      ),
    );

    expect(store.size).toBe(0);
    const ack = lastAck();
    expect(ack.results).toEqual([
      {
        pointId: '018f1a00-0000-7000-8000-00000000000c',
        outcome: 'REJECTED',
        reasonCode: 'UNKNOWN_JOB_OR_WRONG_ORG',
      },
    ]);
  });

  it('drops (no ack) a message on an unknown org namespace', async () => {
    await service.handle(
      'org_ghost',
      CLIENT,
      msg([
        point('018f1a00-0000-7000-8000-00000000000d', '2026-08-21T10:00:00Z'),
      ]),
    );
    expect(published).toHaveLength(0);
    expect(store.size).toBe(0);
  });

  it('does not emit an ambiguous ack when malformed JSON has no usable pointId', async () => {
    await service.handle(ORG, CLIENT, '{ not json');
    expect(published).toHaveLength(0);
  });

  it('rejects a schema-invalid point but names its UUID in the ack', async () => {
    // Missing required createdAt + bad lat → validation fails; pointId still surfaced.
    await service.handle(
      ORG,
      CLIENT,
      msg([
        {
          pointId: '018f1a00-0000-7000-8000-00000000000e',
          lat: 999,
          lng: 0,
          ts: '2026-08-21T10:00:00Z',
        },
      ]),
    );
    const ack = lastAck();
    expect(store.size).toBe(0);
    expect(ack.results).toEqual([
      {
        pointId: '018f1a00-0000-7000-8000-00000000000e',
        outcome: 'REJECTED',
        reasonCode: 'VALIDATION_ERROR',
      },
    ]);
  });

  it('accepts a valid point while rejecting a malformed neighbor in the same batch', async () => {
    const bad = point(
      '018f1a00-0000-7000-8000-00000000000f',
      '2026-08-21T10:00:00Z',
      { lat: 999 },
    );
    const good = point(
      '018f1a00-0000-7000-8000-000000000010',
      '2026-08-21T10:00:01Z',
    );
    await service.handle(ORG, CLIENT, msg([bad, good]));

    expect(store.size).toBe(1);
    expect(lastAck().results).toEqual(
      expect.arrayContaining([
        {
          pointId: bad.pointId,
          outcome: 'REJECTED',
          reasonCode: 'VALIDATION_ERROR',
        },
        { pointId: good.pointId, outcome: 'ACCEPTED' },
      ]),
    );
  });

  it('rejects a non-v7 UUID even when it is otherwise a valid UUID', async () => {
    const uuidV4 = '550e8400-e29b-41d4-a716-446655440000';
    await service.handle(
      ORG,
      CLIENT,
      msg([point(uuidV4, '2026-08-21T10:00:00Z')]),
    );

    expect(store.size).toBe(0);
    expect(lastAck().results).toEqual([
      {
        pointId: uuidV4,
        outcome: 'REJECTED',
        reasonCode: 'POINT_ID_NOT_UUIDV7',
      },
    ]);
  });
});
