import { Test, TestingModule } from '@nestjs/testing';
import { ZoomBulkRegistrationService } from './zoom-bulk-registration.service';
import { ZoomRegistrationRepository } from '../repositories/zoom-registration.repository';
import { ZoomGeneratedRegistrantLinkRepository } from '../repositories/zoom-generated-registrant-link.repository';
import { ZoomRegistrationService } from './zoom-registration.service';
import { ZoomFinalSessionAttendanceService } from './zoom-final-session-attendance.service';
import { OnlineSessionService } from 'src/online-session/services/online-session.service';
import { ZoomRole } from '../enums/zoom-role.enum';
import { AppLoggerService } from 'src/common/services/logger.service';
import { JobTypeEnum } from 'src/common/enum/job-type.enum';
import { ExportJobStatus } from 'src/common/enum/export-job-status.enum';
import { SessionProviderType } from 'src/common/enum/session-provider.enum';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';

jest.mock('src/common/utils/handle-error.util', () => ({
  handleKnownErrors: jest.fn((_code: string, err: unknown) => {
    throw err;
  }),
}));

// Drains several macrotask ticks so fire-and-forget `setImmediate` background
// jobs run to completion (a single tick isn't enough once batches + per-batch
// `updateBulkJob` awaits are involved).
const flush = async () => {
  for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
};

const makeReg = (id: number, activationStatus?: string) => ({ id, activationStatus });
const session = (id: number, externalId: string | null) => ({
  id,
  programId: 3,
  onlineSession: externalId ? { externalId } : null,
});

describe('ZoomBulkRegistrationService', () => {
  let service: ZoomBulkRegistrationService;

  const mockRepo = {
    findEligibleRegistrationsByProgram: jest.fn(),
    findAllRegistrationsByProgramIncludingDeleted: jest.fn().mockResolvedValue([]),
    findIneligibleRegistrationsByProgram: jest.fn().mockResolvedValue([]),
    findRegistrationsByIds: jest.fn(),
    createBulkJob: jest.fn(),
    findBulkJobById: jest.fn(),
    updateBulkJob: jest.fn().mockResolvedValue(undefined),
    updateBulkJobStatus: jest.fn().mockResolvedValue(undefined),
    findBulkJobsByProgram: jest.fn().mockResolvedValue([]),
    findActiveExtensionKeys: jest.fn().mockResolvedValue(new Set<string>()),
    findInactiveExtensionKeys: jest.fn().mockResolvedValue(new Set<string>()),
    findExtensionSnapshots: jest.fn().mockResolvedValue(new Map()),
    findActiveExtensionJoinUrls: jest.fn().mockResolvedValue(new Map()),
  };
  const mockGeneratedLinkRepo = {
    countRegisteredByProgramSessionIds: jest.fn().mockResolvedValue(new Map<number, number>()),
    listBySession: jest.fn(),
  };
  const mockRegistrationService = { registerForBulk: jest.fn() };
  // Defaults to null (not applicable) so single-session-program tests keep exercising every
  // eligible registration — the final-session absentee gate only kicks in when a test opts in.
  const mockFinalSessionAttendanceService = {
    resolveFinalSessionAbsentees: jest.fn().mockResolvedValue(null),
  };
  const mockOnlineSession = {
    findOne: jest.fn(),
    findAll: jest.fn(),
    generateGeneralLinksForSession: jest.fn(),
  };
  const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };

  beforeEach(async () => {
    jest.clearAllMocks();
    // Run the inter-batch throttle delay instantly so background jobs complete
    // within flush() instead of waiting on a real timer.
    jest.spyOn(global, 'setTimeout').mockImplementation(((fn: () => void) => {
      fn();
      return 0 as unknown as NodeJS.Timeout;
    }) as any);
    // clearAllMocks keeps queued implementations; fully reset registerForBulk so
    // leftover mockResolvedValueOnce values / defaults can't bleed between tests.
    mockRegistrationService.registerForBulk.mockReset();
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ZoomBulkRegistrationService,
        { provide: ZoomRegistrationRepository, useValue: mockRepo },
        { provide: ZoomGeneratedRegistrantLinkRepository, useValue: mockGeneratedLinkRepo },
        { provide: ZoomRegistrationService, useValue: mockRegistrationService },
        { provide: ZoomFinalSessionAttendanceService, useValue: mockFinalSessionAttendanceService },
        { provide: OnlineSessionService, useValue: mockOnlineSession },
        { provide: AppLoggerService, useValue: mockLogger },
      ],
    }).compile();
    service = module.get(ZoomBulkRegistrationService);
  });

  // Drain any fire-and-forget background job a test kicked off so it can't run
  // (and consume mocks) during the next test.
  afterEach(async () => {
    await flush();
    jest.restoreAllMocks();
  });

  describe('startBulkRegistration', () => {
    it('by sessionId: registers the program\'s registrants to that session', async () => {
      mockOnlineSession.findOne.mockResolvedValue(session(1392, 'zoom-1392'));
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1), makeReg(2)]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 7, status: ExportJobStatus.PROCESSING, total: 2 });
      mockRegistrationService.registerForBulk.mockResolvedValue('registered');

      const result = await service.startBulkRegistration({ sessionId: 1392 }, 5);

      expect(mockOnlineSession.findOne).toHaveBeenCalledWith(1392);
      expect(mockRepo.findEligibleRegistrationsByProgram).toHaveBeenCalledWith(3);
      expect(mockRepo.createBulkJob).toHaveBeenCalledWith(
        expect.objectContaining({ type: JobTypeEnum.BULK_ZOOM_REGISTRATION, programId: 3, total: 2 }),
      );
      expect(result).toEqual({ jobId: 7, status: ExportJobStatus.PROCESSING, total: 2 });
      await flush();
      expect(mockRegistrationService.registerForBulk).toHaveBeenCalledWith(
        expect.anything(),
        session(1392, 'zoom-1392'),
        expect.anything(),
        5,
      );
    });

    it('tops up each target session\'s general/staff links on every bulk-register run, not only on first creation', async () => {
      const targetSession = session(1392, 'zoom-1392');
      mockOnlineSession.findOne.mockResolvedValue(targetSession);
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1)]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 9, status: ExportJobStatus.PROCESSING, total: 1 });
      mockRegistrationService.registerForBulk.mockResolvedValue('registered');

      await service.startBulkRegistration({ sessionId: 1392 }, 5);

      expect(mockOnlineSession.generateGeneralLinksForSession).toHaveBeenCalledWith(
        targetSession,
        SessionProviderType.ZOOM,
        5,
      );
    });

    it('forwards the requested role (Panelist) to registerForBulk', async () => {
      mockOnlineSession.findOne.mockResolvedValue(session(1392, 'zoom-1392'));
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1)]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 10, status: ExportJobStatus.PROCESSING, total: 1 });
      mockRegistrationService.registerForBulk.mockResolvedValue('registered');

      await service.startBulkRegistration({ sessionId: 1392, role: ZoomRole.PANELIST }, 5);
      await flush();

      expect(mockRegistrationService.registerForBulk).toHaveBeenCalledWith(
        expect.anything(),
        expect.anything(),
        ZoomRole.PANELIST,
        5,
      );
    });

    it('by programId: resolves the program\'s single provisioned session', async () => {
      mockOnlineSession.findAll.mockResolvedValue({
        data: [session(1300, null), session(1392, 'zoom-1392')],
      });
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1)]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 8, status: ExportJobStatus.PROCESSING, total: 1 });
      mockRegistrationService.registerForBulk.mockResolvedValue('registered');

      const result = await service.startBulkRegistration({ programId: 3 }, 5);

      expect(result.total).toBe(1);
      expect(mockRepo.findEligibleRegistrationsByProgram).toHaveBeenCalledWith(3);
      await flush();
    });

    it('by programId: registers to every provisioned session (registrants × sessions)', async () => {
      mockOnlineSession.findAll.mockResolvedValue({
        data: [session(1300, null), session(1, 'a'), session(2, 'b')],
      });
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1)]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 11, status: ExportJobStatus.PROCESSING, total: 2 });
      mockRegistrationService.registerForBulk.mockResolvedValue('registered');

      const result = await service.startBulkRegistration({ programId: 3 }, 5);

      // 1 eligible registrant × 2 provisioned sessions (the unprovisioned one is skipped).
      expect(mockRepo.createBulkJob).toHaveBeenCalledWith(
        expect.objectContaining({ total: 2 }),
      );
      expect(result.total).toBe(2);
      await flush();
      expect(mockRegistrationService.registerForBulk).toHaveBeenCalledTimes(2);
    });

    it('excludes a final-session absentee from that session only, keeping them on every other target session', async () => {
      mockOnlineSession.findAll.mockResolvedValue({
        data: [session(1, 'a'), session(2, 'b')],
      });
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1), makeReg(2)]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 12, status: ExportJobStatus.PROCESSING, total: 3 });
      mockRegistrationService.registerForBulk.mockResolvedValue('registered');
      // Session 2 is the program's final session; registrant 2 missed an earlier one.
      mockFinalSessionAttendanceService.resolveFinalSessionAbsentees.mockResolvedValueOnce({
        finalSessionId: 2,
        absenteeRegistrationIds: new Set([2]),
      });

      const result = await service.startBulkRegistration({ programId: 3 }, 5);

      expect(mockFinalSessionAttendanceService.resolveFinalSessionAbsentees).toHaveBeenCalledWith(3, [1, 2]);
      // 2 registrants × 2 sessions, minus the one excluded final-session pair.
      expect(result.total).toBe(3);
      await flush();
      expect(mockRegistrationService.registerForBulk).toHaveBeenCalledTimes(3);
      expect(mockRegistrationService.registerForBulk).not.toHaveBeenCalledWith(
        makeReg(2),
        session(2, 'b'),
        expect.anything(),
        expect.anything(),
      );
    });

    it('by programId: throws when no provisioned webinars exist', async () => {
      mockOnlineSession.findAll.mockResolvedValue({
        data: [session(1300, null)], // none provisioned
      });
      await expect(service.startBulkRegistration({ programId: 3 }, 5)).rejects.toMatchObject({
        code: ERROR_CODES.ZOOM_BULK_WEBINAR_UNRESOLVED,
      });
    });
  });

  describe('runBulkRegistration (via startBulkRegistration)', () => {
    it('tallies registered/skipped/failed and completes the job', async () => {
      mockOnlineSession.findOne.mockResolvedValue(session(1392, 'zoom-1392'));
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([
        makeReg(1),
        makeReg(2),
        makeReg(3),
      ]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 9, status: ExportJobStatus.PROCESSING, total: 3 });
      mockRegistrationService.registerForBulk
        .mockResolvedValueOnce('registered')
        .mockResolvedValueOnce('skipped')
        .mockRejectedValueOnce(new Error('zoom api down'));

      await service.startBulkRegistration({ sessionId: 1392, batchSize: 10 }, 5);
      await flush();

      expect(mockRepo.updateBulkJobStatus).toHaveBeenCalledWith(
        9,
        ExportJobStatus.COMPLETED,
        expect.objectContaining({ generated: 1, skipped: 1, failed: 1 }),
      );
    });

    it('records a per-item failure reason + eligibility breakdown in metadata', async () => {
      mockOnlineSession.findOne.mockResolvedValue(session(1392, 'zoom-1392'));
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1), makeReg(2)]);
      mockRepo.findIneligibleRegistrationsByProgram.mockResolvedValueOnce([
        { registrationId: 9, registrationSeqNumber: 'S9', registrationStatus: 'rejected', seatAllocated: true },
        { registrationId: 10, registrationSeqNumber: 'S10', registrationStatus: 'completed', seatAllocated: false },
      ]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 12, status: ExportJobStatus.PROCESSING, total: 2 });
      mockRegistrationService.registerForBulk
        .mockResolvedValueOnce('registered')
        .mockRejectedValueOnce(new Error('zoom api down'));

      await service.startBulkRegistration({ sessionId: 1392, batchSize: 10 }, 5);
      await flush();

      // Eligibility breakdown captured at job creation.
      expect(mockRepo.createBulkJob).toHaveBeenCalledWith(
        expect.objectContaining({
          metadata: expect.objectContaining({
            eligibleCount: 2,
            ineligible: [
              { registrationId: 9, registrationSeqNumber: 'S9', reason: 'rejected' },
              { registrationId: 10, registrationSeqNumber: 'S10', reason: 'NO_SEAT' },
            ],
          }),
        }),
      );
      // The failed registrant is recorded with a reason in the completion metadata.
      const completion = mockRepo.updateBulkJobStatus.mock.calls.find(
        (c) => c[1] === ExportJobStatus.COMPLETED,
      );
      expect(completion?.[2].failed).toBe(1);
      expect(completion?.[2].metadata.failures).toEqual([
        expect.objectContaining({ registrationId: 2, sessionId: 1392, reason: ERROR_CODES.ZOOM_API_ERROR }),
      ]);
    });
  });

  describe('retryFailedRegistration', () => {
    const sourceJob = {
      id: 30,
      programId: 3,
      status: ExportJobStatus.COMPLETED,
      metadata: {
        sessionIds: [1392],
        role: ZoomRole.ATTENDEE,
        batchSize: 10,
        eligibleCount: 2,
        ineligible: [],
        failures: [{ registrationId: 2, sessionId: 1392, reason: ERROR_CODES.ZOOM_API_ERROR }],
      },
    };

    it('re-runs only the failed pairs as a fresh job carrying retryOfJobId', async () => {
      mockRepo.findBulkJobById.mockResolvedValue(sourceJob);
      mockRepo.findRegistrationsByIds.mockResolvedValue([makeReg(2)]);
      mockOnlineSession.findOne.mockResolvedValue(session(1392, 'zoom-1392'));
      mockRepo.createBulkJob.mockResolvedValue({ id: 31, status: ExportJobStatus.PROCESSING, total: 1 });
      mockRegistrationService.registerForBulk.mockResolvedValue('registered');

      const result = await service.retryFailedRegistration(30, 5);

      expect(mockRepo.findRegistrationsByIds).toHaveBeenCalledWith([2]);
      expect(mockRepo.createBulkJob).toHaveBeenCalledWith(
        expect.objectContaining({
          total: 1,
          programId: 3,
          metadata: expect.objectContaining({ retryOfJobId: 30 }),
        }),
      );
      expect(result).toEqual({ jobId: 31, status: ExportJobStatus.PROCESSING, total: 1 });
      await flush();
      expect(mockRegistrationService.registerForBulk).toHaveBeenCalledTimes(1);
    });

    it('throws ZOOM_BULK_NO_FAILURES_TO_RETRY when the job has no failures', async () => {
      mockRepo.findBulkJobById.mockResolvedValue({
        ...sourceJob,
        metadata: { ...sourceJob.metadata, failures: [] },
      });
      await expect(service.retryFailedRegistration(30, 5)).rejects.toMatchObject({
        code: ERROR_CODES.ZOOM_BULK_NO_FAILURES_TO_RETRY,
      });
    });
  });

  describe('getBulkJobStatus', () => {
    it('returns the job when found', async () => {
      mockRepo.findBulkJobById.mockResolvedValue({ id: 7, status: ExportJobStatus.COMPLETED });
      await expect(service.getBulkJobStatus(7)).resolves.toMatchObject({ id: 7 });
    });

    it('throws ZOOM_BULK_JOB_NOTFOUND when absent', async () => {
      mockRepo.findBulkJobById.mockResolvedValue(null);
      await expect(service.getBulkJobStatus(123)).rejects.toBeInstanceOf(InifniNotFoundException);
    });
  });

  describe('getBulkJobFailures', () => {
    const jobWithFailures = {
      id: 50,
      status: ExportJobStatus.COMPLETED,
      metadata: {
        failures: [
          { registrationId: 1, sessionId: 900, reason: 'Z_BR_001', message: 'already registered' },
          { registrationId: 2, sessionId: 900, reason: ERROR_CODES.ZOOM_API_ERROR },
        ],
      },
    };

    it('paginates and enriches each failure with registrant + session details', async () => {
      mockRepo.findBulkJobById.mockResolvedValue(jobWithFailures);
      mockRepo.findRegistrationsByIds.mockResolvedValue([
        {
          id: 1,
          registrationSeqNumber: 'R-001',
          fullName: 'Ada Lovelace',
          emailAddress: 'ada@example.com',
          mobileNumber: '999',
        },
      ]);
      mockOnlineSession.findOne.mockResolvedValue({ id: 900, name: 'Session A' });

      const result = await service.getBulkJobFailures(50, { page: 1, limit: 20 });

      expect(mockRepo.findRegistrationsByIds).toHaveBeenCalledWith([1, 2]);
      expect(result.pagination).toEqual({ page: 1, limit: 20, total: 2 });
      expect(result.data[0]).toEqual({
        registrationId: 1,
        registrationSeqNumber: 'R-001',
        fullName: 'Ada Lovelace',
        email: 'ada@example.com',
        mobile: '999',
        sessionId: 900,
        sessionName: 'Session A',
        reason: 'Z_BR_001',
        message: 'already registered',
        joinUrl: null,
        activationStatus: null,
      });
      // Registration 2 no longer resolvable -> enrichment fields null, failure kept.
      expect(result.data[1]).toMatchObject({
        registrationId: 2,
        registrationSeqNumber: null,
        fullName: null,
        sessionName: 'Session A',
        reason: ERROR_CODES.ZOOM_API_ERROR,
      });
    });

    it('only enriches the requested page', async () => {
      mockRepo.findBulkJobById.mockResolvedValue(jobWithFailures);
      mockRepo.findRegistrationsByIds.mockResolvedValue([]);
      mockOnlineSession.findOne.mockResolvedValue({ id: 900, name: 'Session A' });

      const result = await service.getBulkJobFailures(50, { page: 2, limit: 1 });

      expect(mockRepo.findRegistrationsByIds).toHaveBeenCalledWith([2]);
      expect(result.pagination).toEqual({ page: 2, limit: 1, total: 2 });
      expect(result.data).toHaveLength(1);
      expect(result.data[0].registrationId).toBe(2);
    });

    it('returns an empty page when the job has no failures', async () => {
      mockRepo.findBulkJobById.mockResolvedValue({
        id: 51,
        status: ExportJobStatus.COMPLETED,
        metadata: { failures: [] },
      });

      const result = await service.getBulkJobFailures(51, { page: 1, limit: 20 });

      expect(result.data).toEqual([]);
      expect(result.pagination.total).toBe(0);
      expect(mockRepo.findRegistrationsByIds).not.toHaveBeenCalled();
    });

    it('throws ZOOM_BULK_JOB_NOTFOUND when the job is absent', async () => {
      mockRepo.findBulkJobById.mockResolvedValue(null);
      await expect(
        service.getBulkJobFailures(999, { page: 1, limit: 20 }),
      ).rejects.toBeInstanceOf(InifniNotFoundException);
    });

    it('drops failures whose (registration, session) pair is now registered', async () => {
      mockRepo.findBulkJobById.mockResolvedValue(jobWithFailures);
      // Session 900 resolves to online session 5000; registrant 1 now holds an
      // active extension there, so its failure is stale and must be hidden.
      mockOnlineSession.findOne.mockResolvedValue({
        id: 900,
        name: 'Session A',
        onlineSession: { id: 5000 },
      });
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set(['1:5000']));
      mockRepo.findRegistrationsByIds.mockResolvedValue([]);

      const result = await service.getBulkJobFailures(50, { page: 1, limit: 20 });

      expect(result.pagination.total).toBe(1);
      expect(result.data).toHaveLength(1);
      expect(result.data[0].registrationId).toBe(2);
    });

    it('restricts the list to the RM\'s own contacts when rmContactId is given', async () => {
      mockRepo.findBulkJobById.mockResolvedValue({ ...jobWithFailures, programId: 3 });
      mockOnlineSession.findOne.mockResolvedValue({ id: 900, name: 'Session A' });
      mockRepo.findRegistrationsByIds.mockResolvedValue([]);
      // Only registration 1 belongs to this RM.
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1)]);

      const result = await service.getBulkJobFailures(50, { page: 1, limit: 20 }, 77);

      expect(mockRepo.findEligibleRegistrationsByProgram).toHaveBeenCalledWith(3, 77);
      expect(result.data).toHaveLength(1);
      expect(result.data[0].registrationId).toBe(1);
    });
  });

  describe('getProgramFailures', () => {
    it('merges failures across the program job chain, newest reason wins, de-staled', async () => {
      mockRepo.findBulkJobsByProgram.mockResolvedValue([
        // newest first
        { id: 20, metadata: { failures: [{ registrationId: 1, sessionId: 900, reason: 'NEW' }] } },
        { id: 10, metadata: { failures: [
          { registrationId: 1, sessionId: 900, reason: 'OLD' },
          { registrationId: 2, sessionId: 900, reason: 'Z_BR_002' },
        ] } },
      ]);
      mockOnlineSession.findOne.mockResolvedValue({
        id: 900,
        name: 'Session A',
        onlineSession: { id: 5000 },
      });
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set<string>());
      mockRepo.findRegistrationsByIds.mockResolvedValue([]);

      const result = await service.getProgramFailures(3, { page: 1, limit: 20 });

      expect(mockRepo.findBulkJobsByProgram).toHaveBeenCalledWith(3);
      expect(result.pagination.total).toBe(2);
      const reg1 = result.data.find((r) => r.registrationId === 1);
      expect(reg1?.reason).toBe('NEW'); // newest job's reason survives
    });

    it('narrows to a single session when sessionId is given', async () => {
      mockRepo.findBulkJobsByProgram.mockResolvedValue([
        { id: 10, metadata: { failures: [
          { registrationId: 1, sessionId: 900, reason: 'A' },
          { registrationId: 1, sessionId: 901, reason: 'B' },
        ] } },
      ]);
      mockOnlineSession.findOne.mockResolvedValue({ id: 900, name: 'S', onlineSession: { id: 5000 } });
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set<string>());
      mockRepo.findRegistrationsByIds.mockResolvedValue([]);

      const result = await service.getProgramFailures(3, { sessionId: 900, page: 1, limit: 20 });

      expect(result.pagination.total).toBe(1);
      expect(result.data[0].sessionId).toBe(900);
    });

    it('restricts the merged failures to the RM\'s own contacts when rmContactId is given', async () => {
      mockRepo.findBulkJobsByProgram.mockResolvedValue([
        { id: 10, metadata: { failures: [
          { registrationId: 1, sessionId: 900, reason: 'A' },
          { registrationId: 2, sessionId: 900, reason: 'B' },
        ] } },
      ]);
      mockOnlineSession.findOne.mockResolvedValue({ id: 900, name: 'S', onlineSession: { id: 5000 } });
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set<string>());
      mockRepo.findRegistrationsByIds.mockResolvedValue([]);
      // Only registration 2 belongs to this RM.
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(2)]);

      const result = await service.getProgramFailures(3, { page: 1, limit: 20 }, 77);

      expect(mockRepo.findEligibleRegistrationsByProgram).toHaveBeenCalledWith(3, 77);
      expect(result.pagination.total).toBe(1);
      expect(result.data[0].registrationId).toBe(2);
    });
  });

  describe('retryProgramFailures', () => {
    it('throws when the program has no outstanding failures', async () => {
      mockRepo.findBulkJobsByProgram.mockResolvedValue([
        { id: 10, metadata: { failures: [] } },
      ]);
      await expect(service.retryProgramFailures(3)).rejects.toBeDefined();
      expect(mockRepo.createBulkJob).not.toHaveBeenCalled();
    });

    it('starts a fresh job for the outstanding failures, inheriting the newest job config', async () => {
      mockRepo.findBulkJobsByProgram.mockResolvedValue([
        { id: 20, metadata: { role: ZoomRole.PANELIST, batchSize: 7, failures: [
          { registrationId: 2, sessionId: 900, reason: 'Z_BR_002' },
        ] } },
      ]);
      mockOnlineSession.findOne.mockResolvedValue({ id: 900, name: 'S', onlineSession: { id: 5000 } });
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set<string>());
      mockRepo.findRegistrationsByIds.mockResolvedValue([makeReg(2)]);
      mockRepo.createBulkJob.mockResolvedValue({ id: 30, status: ExportJobStatus.PROCESSING, total: 1 });
      mockRegistrationService.registerForBulk.mockResolvedValue('registered');

      const result = await service.retryProgramFailures(3, undefined, 5);

      expect(result).toEqual({ jobId: 30, status: ExportJobStatus.PROCESSING, total: 1 });
      expect(mockRepo.createBulkJob).toHaveBeenCalledWith(
        expect.objectContaining({
          type: JobTypeEnum.BULK_ZOOM_REGISTRATION,
          programId: 3,
          total: 1,
          metadata: expect.objectContaining({ role: ZoomRole.PANELIST, batchSize: 7, retryOfJobId: 20 }),
        }),
      );
    });
  });

  describe('getProgramProvisionStatus', () => {
    it('scopes the eligible/counted set to the RM\'s own contacts when rmContactId is given', async () => {
      mockOnlineSession.findOne.mockResolvedValue(session(1392, 'ext-555'));
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1)]);
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set<string>());
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);

      await service.getProgramProvisionStatus(3, 1392, 77);

      expect(mockRepo.findEligibleRegistrationsByProgram).toHaveBeenCalledWith(3, 77, {
        userType: undefined,
      });
    });

    it("excludes a registrant deactivated for a session from THAT session's counts, without affecting the program-wide totalEligible", async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1), makeReg(2)]);
      // reg 1 is generated (active) for session 555; reg 2 is deactivated for it.
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set(['1:555']));
      mockRepo.findInactiveExtensionKeys.mockResolvedValue(new Set(['2:555']));
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);

      const result = await service.getProgramProvisionStatus(3, 1392);

      expect(mockRepo.findInactiveExtensionKeys).toHaveBeenCalledWith([1, 2], [555]);
      // Program-wide baseline is unaffected by per-session activation.
      expect(result.totalEligible).toBe(2);
      // But this session's own counts exclude the deactivated registrant entirely.
      expect(result.sessions[0].counts).toEqual({
        totalEligible: 1,
        generated: 1,
        failed: 0,
        yetToGenerate: 0,
        generalLinks: 0,
      });
    });

    it('embeds a KPI tile array mirroring the counts, plus a tile for general links generated for the session', async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1)]);
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set(['1:555']));
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);
      mockGeneratedLinkRepo.countRegisteredByProgramSessionIds.mockResolvedValue(new Map([[1392, 4]]));

      const result = await service.getProgramProvisionStatus(3, 1392);

      expect(mockGeneratedLinkRepo.countRegisteredByProgramSessionIds).toHaveBeenCalledWith([1392]);
      expect(result.sessions[0].kpis).toEqual([
        { label: 'Total Eligible', value: 1 },
        { label: 'Generated', value: 1, filter: 'generated' },
        { label: 'Failed', value: 0, filter: 'failed' },
        { label: 'Yet To Generate', value: 0, filter: 'pending' },
        { label: 'Others (General Links)', value: 4, filter: 'generalLink' },
      ]);
    });

    it("once a session has started, drops a registrant with no extension row from that session's counts instead of counting them via program-level eligibility", async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        startsAt: new Date(Date.now() - 60 * 60 * 1000),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      // reg 1: generated (active extension); reg 2: never provisioned; reg 3:
      // deactivated (inactive extension) — the session already started, so
      // its provisioning window is closed and neither 2 nor 3 counts at all
      // (in particular, reg 3 must NOT fall into yetToGenerate).
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1), makeReg(2), makeReg(3)]);
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set(['1:555']));
      mockRepo.findInactiveExtensionKeys.mockResolvedValue(new Set(['3:555']));
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);
      mockGeneratedLinkRepo.countRegisteredByProgramSessionIds.mockResolvedValue(new Map());

      const result = await service.getProgramProvisionStatus(3, 1392);

      expect(result.sessions[0].counts).toEqual({
        totalEligible: 1,
        generated: 1,
        failed: 0,
        yetToGenerate: 0,
        generalLinks: 0,
      });
      // 'Yet To Generate' is dropped from the tile array entirely once the session
      // has started — not merely shown at 0.
      expect(result.sessions[0].kpis).toEqual([
        { label: 'Total Eligible', value: 1 },
        { label: 'Generated', value: 1, filter: 'generated' },
        { label: 'Failed', value: 0, filter: 'failed' },
        { label: 'Others (General Links)', value: 0, filter: 'generalLink' },
      ]);
    });

    it("drops a final-session absentee from that session's counts, without affecting the program-wide totalEligible", async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1), makeReg(2)]);
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set<string>());
      mockRepo.findInactiveExtensionKeys.mockResolvedValue(new Set<string>());
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);
      // Session 1392 is the program's final session; registrant 2 missed an earlier one.
      mockFinalSessionAttendanceService.resolveFinalSessionAbsentees.mockResolvedValueOnce({
        finalSessionId: 1392,
        absenteeRegistrationIds: new Set([2]),
      });

      const result = await service.getProgramProvisionStatus(3, 1392);

      expect(mockFinalSessionAttendanceService.resolveFinalSessionAbsentees).toHaveBeenCalledWith(3, [1, 2]);
      expect(result.totalEligible).toBe(2);
      expect(result.sessions[0].counts).toEqual({
        totalEligible: 1,
        generated: 0,
        failed: 0,
        yetToGenerate: 1,
        generalLinks: 0,
      });
    });

    it('leaves an already-started final session\'s counts alone even if a registrant is now flagged absent (no retroactive hiding)', async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        startsAt: new Date(Date.now() - 60 * 60 * 1000),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1), makeReg(2)]);
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set(['1:555', '2:555']));
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);
      // Even though registrant 2 is now flagged as a final-session absentee, the session has
      // already started — its counts must stay untouched (no retroactive hiding).
      mockFinalSessionAttendanceService.resolveFinalSessionAbsentees.mockResolvedValueOnce({
        finalSessionId: 1392,
        absenteeRegistrationIds: new Set([2]),
      });

      const result = await service.getProgramProvisionStatus(3, 1392);

      expect(result.sessions[0].counts).toEqual({
        totalEligible: 2,
        generated: 2,
        failed: 0,
        yetToGenerate: 0,
        generalLinks: 0,
      });
    });
  });

  describe('getSessionProvisionRegistrations', () => {
    it('scopes both the eligible set and the all-registrations set to the RM\'s own contacts when rmContactId is given', async () => {
      mockOnlineSession.findOne.mockResolvedValue(session(1392, 'ext-555'));
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([makeReg(1)]);
      mockRepo.findAllRegistrationsByProgramIncludingDeleted.mockResolvedValue([makeReg(1)]);
      mockRepo.findActiveExtensionKeys.mockResolvedValue(new Set<string>());
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);

      await service.getSessionProvisionRegistrations(1392, { page: 1, limit: 20 }, 77);

      expect(mockRepo.findEligibleRegistrationsByProgram).toHaveBeenCalledWith(3, 77, {
        userType: undefined,
      });
      expect(mockRepo.findAllRegistrationsByProgramIncludingDeleted).toHaveBeenCalledWith(3, 77, {
        userType: undefined,
      });
    });

    it('drops any registrant inactive for this session (regardless of source), falls back to the registration\'s own activation rollup when there is no session-specific row, and keeps an already-generated extension even if the registration is no longer program-eligible', async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      // reg 1: program-eligible, extension active + generated.
      // reg 2: program-eligible, extension inactive (manual toggle, no source) — dropped.
      // reg 3: program-eligible, never provisioned yet, own activation active — pending.
      // reg 4: program-eligible, extension inactive (source 'cancelled') — dropped, same as reg 2.
      // reg 5: NOT program-eligible (e.g. deleted since), but its extension is
      //   still active — stays listed as generated; the extension is authoritative.
      // reg 6: NOT program-eligible and never provisioned — dropped entirely.
      // reg 8: program-eligible, never provisioned, but its own activation
      //   rollup is inactive — dropped via the fallback check.
      const allRegs = [
        makeReg(1, 'active'),
        makeReg(2, 'active'),
        makeReg(3, 'active'),
        makeReg(4, 'active'),
        makeReg(5, 'active'),
        makeReg(6, 'active'),
        makeReg(8, 'inactive'),
      ];
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue(
        allRegs.filter((r) => [1, 2, 3, 4, 8].includes(r.id)),
      );
      mockRepo.findAllRegistrationsByProgramIncludingDeleted.mockResolvedValue(allRegs);
      mockRepo.findActiveExtensionJoinUrls.mockResolvedValue(
        new Map([
          ['1', { joinUrl: 'https://zoom/j/1', activationStatus: 'active', activationSource: 'active' }],
          ['2', { joinUrl: null, activationStatus: 'inactive', activationSource: null }],
          ['4', { joinUrl: null, activationStatus: 'inactive', activationSource: 'cancelled' }],
          ['5', { joinUrl: 'https://zoom/j/5', activationStatus: 'active', activationSource: 'active' }],
        ]),
      );
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);
      mockGeneratedLinkRepo.countRegisteredByProgramSessionIds.mockResolvedValue(new Map([[1392, 2]]));

      const result = await service.getSessionProvisionRegistrations(1392, { page: 1, limit: 20 });

      expect(result.data.map((r) => r.registrationId)).toEqual([1, 3, 5]);
      expect(mockGeneratedLinkRepo.countRegisteredByProgramSessionIds).toHaveBeenCalledWith([1392]);
      expect(result.kpis).toEqual([
        { label: 'Total Eligible', value: 3 },
        { label: 'Generated', value: 2, filter: 'generated' },
        { label: 'Failed', value: 0, filter: 'failed' },
        { label: 'Yet To Generate', value: 1, filter: 'pending' },
        { label: 'Others (General Links)', value: 2, filter: 'generalLink' },
      ]);
      const active = result.data.find((r) => r.registrationId === 1);
      const pending = result.data.find((r) => r.registrationId === 3);
      const deletedButGenerated = result.data.find((r) => r.registrationId === 5);
      expect(active).toMatchObject({ status: 'Generated', joinUrl: 'https://zoom/j/1', activationStatus: 'Active' });
      // No extension row for reg 3 — falls back to its own activation rollup ('active').
      expect(pending).toMatchObject({ status: 'Pending', joinUrl: null, activationStatus: 'Active' });
      expect(deletedButGenerated).toMatchObject({
        status: 'Generated',
        joinUrl: 'https://zoom/j/5',
        activationStatus: 'Active',
      });
      expect(result.summary).toEqual({
        totalEligible: 3,
        generated: 2,
        failed: 0,
        yetToGenerate: 1,
        generalLinks: 2,
      });
    });

    it('once a session has started, excludes an eligible registrant with no extension row entirely rather than listing them as pending', async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        startsAt: new Date(Date.now() - 60 * 60 * 1000),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      const allRegs = [makeReg(1, 'active'), makeReg(2, 'active')];
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue(allRegs);
      mockRepo.findAllRegistrationsByProgramIncludingDeleted.mockResolvedValue(allRegs);
      mockRepo.findActiveExtensionJoinUrls.mockResolvedValue(
        new Map([['1', { joinUrl: 'https://zoom/j/1', activationStatus: 'active', activationSource: 'active' }]]),
      );
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);
      mockGeneratedLinkRepo.countRegisteredByProgramSessionIds.mockResolvedValue(new Map());

      const result = await service.getSessionProvisionRegistrations(1392, { page: 1, limit: 20 });

      expect(result.data.map((r) => r.registrationId)).toEqual([1]);
      expect(result.summary).toEqual({
        totalEligible: 1,
        generated: 1,
        failed: 0,
        yetToGenerate: 0,
        generalLinks: 0,
      });
      expect(result.kpis).toEqual([
        { label: 'Total Eligible', value: 1 },
        { label: 'Generated', value: 1, filter: 'generated' },
        { label: 'Failed', value: 0, filter: 'failed' },
        { label: 'Others (General Links)', value: 0, filter: 'generalLink' },
      ]);
    });

    it('drops a final-session absentee from this session\'s list entirely, same as a deactivated registrant', async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      const allRegs = [makeReg(1, 'active'), makeReg(2, 'active')];
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue(allRegs);
      mockRepo.findAllRegistrationsByProgramIncludingDeleted.mockResolvedValue(allRegs);
      mockRepo.findActiveExtensionJoinUrls.mockResolvedValue(new Map());
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);
      mockGeneratedLinkRepo.countRegisteredByProgramSessionIds.mockResolvedValue(new Map());
      // Session 1392 is the program's final session; registrant 2 missed an earlier one.
      mockFinalSessionAttendanceService.resolveFinalSessionAbsentees.mockResolvedValueOnce({
        finalSessionId: 1392,
        absenteeRegistrationIds: new Set([2]),
      });

      const result = await service.getSessionProvisionRegistrations(1392, { page: 1, limit: 20 });

      expect(mockFinalSessionAttendanceService.resolveFinalSessionAbsentees).toHaveBeenCalledWith(3, [1, 2]);
      expect(result.data.map((r) => r.registrationId)).toEqual([1]);
      expect(result.summary).toEqual({
        totalEligible: 1,
        generated: 0,
        failed: 0,
        yetToGenerate: 1,
        generalLinks: 0,
      });
    });

    it("filters to the 'generalLink' bucket by fetching from zoom_generated_registrant_link instead of ProgramRegistration rows", async () => {
      mockOnlineSession.findOne.mockResolvedValue({
        ...session(1392, 'ext-555'),
        onlineSession: { id: 555, externalId: 'ext-555' },
      });
      mockRepo.findEligibleRegistrationsByProgram.mockResolvedValue([]);
      mockRepo.findAllRegistrationsByProgramIncludingDeleted.mockResolvedValue([]);
      mockRepo.findBulkJobsByProgram.mockResolvedValue([]);
      mockGeneratedLinkRepo.countRegisteredByProgramSessionIds.mockResolvedValue(new Map([[1392, 1]]));
      mockGeneratedLinkRepo.listBySession.mockResolvedValue({
        data: [
          {
            id: 4501,
            displayName: 'Staff One',
            registrantEmail: 'staff-one+tag@example.com',
            sourceEmail: 'staff.one@example.com',
            sourceMobile: '9999999999',
            joinUrl: 'https://zoom/j/staff-one',
          },
        ],
        total: 1,
      });

      const result = await service.getSessionProvisionRegistrations(1392, {
        status: 'generalLink',
        page: 1,
        limit: 20,
      });

      expect(mockGeneratedLinkRepo.listBySession).toHaveBeenCalledWith(1392, 1, 20, 'registered');
      expect(result.data).toEqual([
        {
          registrationId: null,
          registrationSeqNumber: null,
          fullName: 'Staff One',
          email: 'staff.one@example.com',
          mobile: '9999999999',
          status: 'GeneralLink',
          joinUrl: 'https://zoom/j/staff-one',
          activationStatus: null,
          generatedLinkId: 4501,
        },
      ]);
      expect(result.summary.generalLinks).toBe(1);
      expect(result.pagination).toEqual({ page: 1, limit: 20, total: 1 });
    });
  });
});
