import * as crypto from 'crypto';
import { ZoomAnalyticsWebhookService } from './zoom-analytics-webhook.service';
import { ZoomAnalyticsWebhookEvent } from '../enums/zoom-analytics-webhook-event.enum';
import { ZOOM_ANALYTICS_WEBHOOK_HEADER } from '../constants/zoom-analytics.constants';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { InifniInternalServerErrorException } from 'src/common/exceptions/infini-internalservererror-exception';
import { AppLoggerService } from 'src/common/services/logger.service';

describe('ZoomAnalyticsWebhookService', () => {
  const secret = 'wh-secret';
  const config = {
    getWebhookSecret: jest.fn().mockReturnValue(secret),
    isEnabled: jest.fn().mockReturnValue(true),
  };
  const provider = {
    recordParticipantJoined: jest.fn(),
    recordParticipantLeft: jest.fn(),
    markStarted: jest.fn(),
    markEnded: jest.fn().mockResolvedValue(undefined),
    reconcile: jest.fn().mockResolvedValue(undefined),
  };
  const registry = { resolve: jest.fn().mockReturnValue(provider) };
  const webinarRepository = { findByExtId: jest.fn(), findByExtIdForOccurrence: jest.fn() };
  const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn(), setContext: jest.fn() };

  let service: ZoomAnalyticsWebhookService;

  function sign(body: unknown, timestamp: string) {
    const message = `v0:${timestamp}:${JSON.stringify(body)}`;
    return `v0=${crypto.createHmac('sha256', secret).update(message).digest('hex')}`;
  }

  function headersFor(body: unknown) {
    const timestamp = String(Math.floor(Date.now() / 1000));
    return {
      [ZOOM_ANALYTICS_WEBHOOK_HEADER.TIMESTAMP]: timestamp,
      [ZOOM_ANALYTICS_WEBHOOK_HEADER.SIGNATURE]: sign(body, timestamp),
    };
  }

  beforeAll(() => {
    // dispatch()'s catch path reaches for AppLoggerService's static singleton via handleKnownErrors.
    (AppLoggerService as any).instance = { error: jest.fn() };
  });

  beforeEach(() => {
    jest.clearAllMocks();
    config.getWebhookSecret.mockReturnValue(secret);
    config.isEnabled.mockReturnValue(true);
    registry.resolve.mockReturnValue(provider);
    service = new ZoomAnalyticsWebhookService(config as any, registry as any, webinarRepository as any, logger as any);
  });

  it('answers the URL-validation handshake without checking the signature', async () => {
    const body = { event: ZoomAnalyticsWebhookEvent.ENDPOINT_URL_VALIDATION, payload: { plainToken: 'abc' } };

    const result = await service.process(body, {});

    expect(result).toEqual({
      plainToken: 'abc',
      encryptedToken: crypto.createHmac('sha256', secret).update('abc').digest('hex'),
    });
  });

  it('still answers the URL-validation handshake when ENABLE_ZOOM_ANALYTICS is off', async () => {
    config.isEnabled.mockReturnValue(false);
    const body = { event: ZoomAnalyticsWebhookEvent.ENDPOINT_URL_VALIDATION, payload: { plainToken: 'abc' } };

    const result = await service.process(body, {});

    expect(result).toEqual({
      plainToken: 'abc',
      encryptedToken: crypto.createHmac('sha256', secret).update('abc').digest('hex'),
    });
  });

  it('ignores a real event without dispatching when ENABLE_ZOOM_ANALYTICS is off', async () => {
    config.isEnabled.mockReturnValue(false);
    const body = { event: ZoomAnalyticsWebhookEvent.PARTICIPANT_JOINED, payload: { object: {} } };

    const result = await service.process(body, headersFor(body));

    expect(result).toBeUndefined();
    expect(registry.resolve).not.toHaveBeenCalled();
  });

  it('rejects an event with a stale timestamp before checking the signature', async () => {
    const body = { event: ZoomAnalyticsWebhookEvent.PARTICIPANT_JOINED, payload: { object: {} } };
    const staleTimestamp = String(Math.floor(Date.now() / 1000) - 3600);

    await expect(
      service.process(body, {
        [ZOOM_ANALYTICS_WEBHOOK_HEADER.TIMESTAMP]: staleTimestamp,
        [ZOOM_ANALYTICS_WEBHOOK_HEADER.SIGNATURE]: sign(body, staleTimestamp),
      }),
    ).rejects.toBeInstanceOf(InifniBadRequestException);
    expect(registry.resolve).not.toHaveBeenCalled();
  });

  it('rejects an event with an invalid signature', async () => {
    const body = { event: ZoomAnalyticsWebhookEvent.PARTICIPANT_JOINED, payload: { object: {} } };
    const timestamp = String(Math.floor(Date.now() / 1000));

    await expect(
      service.process(body, {
        [ZOOM_ANALYTICS_WEBHOOK_HEADER.TIMESTAMP]: timestamp,
        [ZOOM_ANALYTICS_WEBHOOK_HEADER.SIGNATURE]: 'v0=not-the-right-signature',
      }),
    ).rejects.toBeInstanceOf(InifniBadRequestException);
    expect(provider.recordParticipantJoined).not.toHaveBeenCalled();
  });

  it('routes webinar.participant_joined to the webinar provider, passing through the reported display name', async () => {
    const body = {
      event: ZoomAnalyticsWebhookEvent.PARTICIPANT_JOINED,
      payload: {
        object: {
          id: '123',
          participant: {
            email: 'a@x.com',
            id: 'p-1',
            join_time: '2026-07-08T10:00:00Z',
            user_name: 'A From Zoom',
          },
        },
      },
    };

    await service.process(body, headersFor(body));

    expect(registry.resolve).toHaveBeenCalledWith('webinar');
    expect(provider.recordParticipantJoined).toHaveBeenCalledWith(
      '123',
      null,
      'a@x.com',
      new Date('2026-07-08T10:00:00Z'),
      'p-1',
      'A From Zoom',
      null,
    );
  });

  it('routes meeting.participant_joined to the meeting provider — resolved from the event name, not a global setting', async () => {
    const body = {
      event: ZoomAnalyticsWebhookEvent.MEETING_PARTICIPANT_JOINED,
      payload: { object: { id: '456', participant: { email: 'a@x.com', id: 'p-1', join_time: '2026-07-08T10:00:00Z' } } },
    };

    await service.process(body, headersFor(body));

    expect(registry.resolve).toHaveBeenCalledWith('meeting');
    expect(provider.recordParticipantJoined).toHaveBeenCalledWith(
      '456',
      null,
      'a@x.com',
      new Date('2026-07-08T10:00:00Z'),
      'p-1',
      null,
      null,
    );
  });

  it('routes meeting.started to markStarted, resolving the meeting provider from the event name', async () => {
    const session = { id: 9 } as any;
    webinarRepository.findByExtIdForOccurrence.mockResolvedValue(session);
    const body = {
      event: ZoomAnalyticsWebhookEvent.MEETING_STARTED,
      payload: { object: { id: '456', start_time: '2026-07-08T10:00:00Z' } },
    };

    await service.process(body, headersFor(body));

    expect(registry.resolve).toHaveBeenCalledWith('meeting');
    expect(provider.markStarted).toHaveBeenCalledWith(session, new Date('2026-07-08T10:00:00Z'));
  });

  it('passes occurrence_id through so a shared/recurring webinar sibling can be disambiguated', async () => {
    const body = {
      event: ZoomAnalyticsWebhookEvent.PARTICIPANT_JOINED,
      payload: {
        object: {
          id: '123',
          occurrence_id: 'occ-9',
          participant: { email: 'a@x.com', id: 'p-1', join_time: '2026-07-08T10:00:00Z' },
        },
      },
    };

    await service.process(body, headersFor(body));

    expect(provider.recordParticipantJoined).toHaveBeenCalledWith(
      '123',
      'occ-9',
      'a@x.com',
      new Date('2026-07-08T10:00:00Z'),
      'p-1',
      null,
      null,
    );
  });

  it('routes webinar.participant_left to the configured provider, carrying the leave reason', async () => {
    const body = {
      event: ZoomAnalyticsWebhookEvent.PARTICIPANT_LEFT,
      payload: {
        object: {
          id: '123',
          participant: {
            email: 'a@x.com',
            id: 'p-1',
            leave_time: '2026-07-08T10:30:00Z',
            leave_reason: 'left the meeting',
          },
        },
      },
    };

    await service.process(body, headersFor(body));

    expect(provider.recordParticipantLeft).toHaveBeenCalledWith(
      '123',
      null,
      'a@x.com',
      new Date('2026-07-08T10:30:00Z'),
      'p-1',
      null,
      'left the meeting',
      null,
    );
  });

  it('routes webinar.started to markStarted after resolving the session by external id (+ occurrence)', async () => {
    const session = { id: 7 } as any;
    webinarRepository.findByExtIdForOccurrence.mockResolvedValue(session);
    const body = { event: ZoomAnalyticsWebhookEvent.WEBINAR_STARTED, payload: { object: { id: '123', start_time: '2026-07-08T10:00:00Z' } } };

    await service.process(body, headersFor(body));

    expect(webinarRepository.findByExtIdForOccurrence).toHaveBeenCalledWith(
      '123',
      null,
      new Date('2026-07-08T10:00:00Z'),
      new Date('2026-07-08T10:00:00Z'),
    );
    expect(provider.markStarted).toHaveBeenCalledWith(session, new Date('2026-07-08T10:00:00Z'));
  });

  it('no-ops webinar.started for an untracked external id', async () => {
    webinarRepository.findByExtIdForOccurrence.mockResolvedValue(null);
    const body = { event: ZoomAnalyticsWebhookEvent.WEBINAR_STARTED, payload: { object: { id: 'unknown' } } };

    await service.process(body, headersFor(body));

    expect(provider.markStarted).not.toHaveBeenCalled();
  });

  it('marks the session ended synchronously, then triggers a background reconcile on webinar.ended without awaiting/blocking the response', async () => {
    const session = { id: 7 } as any;
    webinarRepository.findByExtIdForOccurrence.mockResolvedValue(session);
    const body = {
      event: ZoomAnalyticsWebhookEvent.WEBINAR_ENDED,
      payload: { object: { id: '123', end_time: '2026-07-08T11:00:00Z' } },
    };

    await service.process(body, headersFor(body));
    expect(webinarRepository.findByExtIdForOccurrence).toHaveBeenCalledWith(
      '123',
      null,
      new Date('2026-07-08T11:00:00Z'),
      null,
    );
    expect(provider.markEnded).toHaveBeenCalledWith(session, new Date('2026-07-08T11:00:00Z'));
    // reconcile is fired via setImmediate — process() must resolve before it runs.
    expect(provider.reconcile).not.toHaveBeenCalled();

    await new Promise((resolve) => setImmediate(resolve));
    expect(provider.reconcile).toHaveBeenCalledWith(session);
  });

  it('logs then rethrows a mapped error when a provider call fails mid-dispatch', async () => {
    provider.recordParticipantJoined.mockRejectedValue(new Error('boom'));
    const body = {
      event: ZoomAnalyticsWebhookEvent.PARTICIPANT_JOINED,
      payload: { object: { id: '123', participant: { email: 'a@x.com' } } },
    };

    await expect(service.process(body, headersFor(body))).rejects.toBeInstanceOf(InifniInternalServerErrorException);
    expect(logger.error).toHaveBeenCalled();
  });
});
