import { BadRequestException } from '@nestjs/common';
import { HikvisionIsapiClientService } from './hikvision-isapi-client.service';

/**
 * Mock mínimo de Response (fetch nativo) para los tests.
 */
function mockResponse(opts: {
  status: number;
  body?: string;
  wwwAuthenticate?: string;
}): Response {
  const headers = new Map<string, string>();
  if (opts.wwwAuthenticate) headers.set('www-authenticate', opts.wwwAuthenticate);
  return {
    status: opts.status,
    ok: opts.status >= 200 && opts.status < 300,
    statusText: opts.status === 200 ? 'OK' : 'Unauthorized',
    headers: { get: (k: string) => headers.get(k.toLowerCase()) ?? null },
    text: async () => opts.body ?? '',
  } as unknown as Response;
}

const CHALLENGE =
  'Digest qop="auth", realm="DS-K1T343MFWX", nonce="abc123nonce", opaque="op4que", algorithm=MD5';

function acsPage(events: any[], totalMatches: number, numOfMatches = events.length) {
  return JSON.stringify({
    AcsEvent: {
      searchID: 'novasis-x',
      responseStatusStrg: events.length ? 'MORE' : 'NO MATCH',
      numOfMatches,
      totalMatches,
      InfoList: events,
    },
  });
}

const baseConfig = {
  url_base: 'http://192.168.1.64:80',
  api_key: 'admin:Secret123',
  campo_documento: 'employeeNoString',
  campo_timestamp: 'time',
};

describe('HikvisionIsapiClientService', () => {
  let service: HikvisionIsapiClientService;
  let fetchMock: jest.Mock;

  beforeEach(() => {
    service = new HikvisionIsapiClientService();
    fetchMock = jest.fn();
    (global as any).fetch = fetchMock;
  });

  afterEach(() => jest.restoreAllMocks());

  it('hace el handshake Digest y reenvía con Authorization bien formado', async () => {
    fetchMock
      .mockResolvedValueOnce(mockResponse({ status: 401, wwwAuthenticate: CHALLENGE }))
      .mockResolvedValueOnce(
        mockResponse({
          status: 200,
          body: acsPage(
            [{ employeeNoString: '1234567', time: '2026-05-20T08:00:00+00:00', attendanceStatus: 'check in' }],
            1,
          ),
        }),
      );

    const res = await service.consultarAcsEvent(baseConfig, {});

    expect(res.ok).toBe(true);
    expect(res.marcaciones).toHaveLength(1);
    expect(res.marcaciones[0].documento_raw).toBe('1234567');

    // El primer request no lleva Authorization; el segundo sí, con Digest válido.
    const [, firstInit] = fetchMock.mock.calls[0];
    expect(firstInit.headers.Authorization).toBeUndefined();

    const [, secondInit] = fetchMock.mock.calls[1];
    const auth = secondInit.headers.Authorization as string;
    expect(auth).toMatch(/^Digest /);
    expect(auth).toContain('username="admin"');
    expect(auth).toContain('realm="DS-K1T343MFWX"');
    expect(auth).toContain('nonce="abc123nonce"');
    expect(auth).toContain('uri="/ISAPI/AccessControl/AcsEvent?format=json"');
    expect(auth).toContain('qop=auth');
    expect(auth).toContain('nc=00000001');
    expect(auth).toMatch(/response="[0-9a-f]{32}"/);
    expect(auth).toContain('opaque="op4que"');
  });

  it('pagina hasta totalMatches', async () => {
    const page = (n: number) =>
      Array.from({ length: n }, (_, i) => ({
        employeeNoString: String(1000 + i),
        time: '2026-05-20T08:00:00+00:00',
        attendanceStatus: i % 2 === 0 ? 'check in' : 'check out',
      }));

    fetchMock
      .mockResolvedValueOnce(mockResponse({ status: 401, wwwAuthenticate: CHALLENGE }))
      .mockResolvedValueOnce(mockResponse({ status: 200, body: acsPage(page(100), 250) }))
      .mockResolvedValueOnce(mockResponse({ status: 200, body: acsPage(page(100), 250) }))
      .mockResolvedValueOnce(mockResponse({ status: 200, body: acsPage(page(50), 250) }));

    const res = await service.consultarAcsEvent(baseConfig, {});

    expect(res.ok).toBe(true);
    expect(res.marcaciones).toHaveLength(250);
    // 1 handshake + 3 páginas de datos
    expect(fetchMock).toHaveBeenCalledTimes(4);
    // nc se incrementa por request de datos
    expect((fetchMock.mock.calls[3][1].headers.Authorization as string)).toContain('nc=00000003');
  });

  it('descarta eventos break/overtime (solo entrada/salida)', async () => {
    fetchMock
      .mockResolvedValueOnce(mockResponse({ status: 401, wwwAuthenticate: CHALLENGE }))
      .mockResolvedValueOnce(
        mockResponse({
          status: 200,
          body: acsPage(
            [
              { employeeNoString: '111', time: '2026-05-20T08:00:00+00:00', attendanceStatus: 'check in' },
              { employeeNoString: '111', time: '2026-05-20T12:00:00+00:00', attendanceStatus: 'break out' },
              { employeeNoString: '111', time: '2026-05-20T13:00:00+00:00', attendanceStatus: 'break in' },
              { employeeNoString: '111', time: '2026-05-20T18:00:00+00:00', attendanceStatus: 'overtime in' },
              { employeeNoString: '111', time: '2026-05-20T17:00:00+00:00', attendanceStatus: 'check out' },
            ],
            5,
          ),
        }),
      );

    const res = await service.consultarAcsEvent(baseConfig, {});
    expect(res.marcaciones).toHaveLength(2);
    expect(res.marcaciones.map((m) => m.documento_raw)).toEqual(['111', '111']);
  });

  it('corta ante NO MATCH sin error', async () => {
    fetchMock
      .mockResolvedValueOnce(mockResponse({ status: 401, wwwAuthenticate: CHALLENGE }))
      .mockResolvedValueOnce(mockResponse({ status: 200, body: acsPage([], 0) }));

    const res = await service.consultarAcsEvent(baseConfig, {});
    expect(res.ok).toBe(true);
    expect(res.marcaciones).toHaveLength(0);
  });

  it('devuelve ok:false ante HTTP de error', async () => {
    fetchMock
      .mockResolvedValueOnce(mockResponse({ status: 401, wwwAuthenticate: CHALLENGE }))
      .mockResolvedValueOnce(mockResponse({ status: 500, body: 'boom' }));

    const res = await service.consultarAcsEvent(baseConfig, {});
    expect(res.ok).toBe(false);
    expect(res.http_status).toBe(500);
  });

  it('rechaza api_key sin formato usuario:password', async () => {
    await expect(
      service.consultarAcsEvent({ ...baseConfig, api_key: 'soloUsuario' }, {}),
    ).rejects.toBeInstanceOf(BadRequestException);
  });

  it('usa ventana por defecto (últimos 15 min) cuando no se pasa rango', async () => {
    fetchMock
      .mockResolvedValueOnce(mockResponse({ status: 401, wwwAuthenticate: CHALLENGE }))
      .mockResolvedValueOnce(mockResponse({ status: 200, body: acsPage([], 0) }));

    await service.consultarAcsEvent(baseConfig, {});

    const body = JSON.parse(fetchMock.mock.calls[1][1].body);
    const { startTime, endTime } = body.AcsEventCond;
    expect(startTime).toMatch(/\+00:00$/);
    expect(endTime).toMatch(/\+00:00$/);
    const diffMin = (new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000;
    expect(diffMin).toBeCloseTo(15, 0);
  });
});
