import { Test, TestingModule } from '@nestjs/testing';
import { ZoomRegistrationService } from './zoom-registration.service';
import { ZoomRegistrationRepository } from '../repositories/zoom-registration.repository';
import { WebinarService } from '../sessions/webinar.service';
import { MeetingService } from '../sessions/meeting.service';
import { AppLoggerService } from 'src/common/services/logger.service';
import { ExcelService } from 'src/common/services/excel.service';
import { OnlineSessionService } from 'src/online-session/services/online-session.service';
import { ZoomAnalyticsAttendeeSummaryRepository } from '../repositories/zoom-analytics-attendee-summary.repository';
import { ZoomFinalSessionAttendanceService } from './zoom-final-session-attendance.service';
import { RegistrationOnlineSessionActivationStatus } from 'src/common/enum/registration-online-session-activation-status.enum';
import { RegistrationOnlineSessionActivationSource } from 'src/common/enum/registration-online-session-activation-source.enum';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';

// Shared across every describe block below: defaults to null/not-applicable so a
// single-session-program test keeps exercising the full eligible/registered count — the
// final-session absentee gate only kicks in when a test opts in via mockResolvedValueOnce.
const mockFinalSessionAttendanceService = {
  getFinalSessionId: jest.fn().mockResolvedValue(null),
  resolveFinalSessionAbsentees: jest.fn().mockResolvedValue(null),
};

describe('ZoomRegistrationService — registration ↔ join URL view', () => {
  let service: ZoomRegistrationService;

  const mockRepo = {
    listEligibleRegistrationsByProgram: jest.fn(),
    findEligibleRegistrationRowsByProgram: jest.fn(),
    listSeatAllocatedRegistrationsByProgram: jest.fn(),
    getProgramEligibleKpis: jest.fn(),
    listAllRmContacts: jest.fn(),
    findRegistrationById: jest.fn(),
    findZoomExtension: jest.fn(),
    withTransaction: jest.fn((work) => work({})),
    updateActivationStatus: jest.fn(),
    adjustEligibleActiveCount: jest.fn(),
    updateRegistrationActivationStatus: jest.fn(),
    getEligibleActivationSummary: jest.fn(),
  };
  const mockExcel = {
    jsonToExcelAndUpload: jest.fn().mockResolvedValue('https://s3.example.com/regs.xlsx'),
  };
  // Session 1392 belongs to program 3 and holds online session 555.
  const onlineSession = { id: 555, externalId: 'ext-555' };
  const mockOnlineSession = { findOne: jest.fn() };
  const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };
  const mockMeeting = { addParticipant: jest.fn(), removeParticipant: jest.fn() };
  const mockWebinar = { addParticipant: jest.fn(), removeParticipant: jest.fn() };
  const mockAttendeeSummary = { getSessionAttendanceCounts: jest.fn() };

  beforeEach(async () => {
    jest.clearAllMocks();
    mockRepo.withTransaction.mockImplementation((work) => work({}));
    mockOnlineSession.findOne.mockResolvedValue({
      id: 1392,
      programId: 3,
      onlineSession,
      startsAt: new Date('2026-07-08T10:00:00.000Z'),
      endsAt: new Date('2026-07-08T11:00:00.000Z'),
    });
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ZoomRegistrationService,
        { provide: ZoomRegistrationRepository, useValue: mockRepo },
        { provide: WebinarService, useValue: mockWebinar },
        { provide: MeetingService, useValue: mockMeeting },
        { provide: AppLoggerService, useValue: mockLogger },
        { provide: ExcelService, useValue: mockExcel },
        { provide: OnlineSessionService, useValue: mockOnlineSession },
        { provide: ZoomAnalyticsAttendeeSummaryRepository, useValue: mockAttendeeSummary },
        { provide: ZoomFinalSessionAttendanceService, useValue: mockFinalSessionAttendanceService },
      ],
    }).compile();
    service = module.get(ZoomRegistrationService);
  });

  it('resolves the session\'s program and lists its registrations with join URL', async () => {
    mockRepo.listEligibleRegistrationsByProgram.mockResolvedValue({
      data: [{ registrationId: 1, fullName: 'A', joinUrl: 'https://zoom/j/1' }],
      total: 1,
    });

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

    expect(mockOnlineSession.findOne).toHaveBeenCalledWith(1392);
    expect(mockRepo.listEligibleRegistrationsByProgram).toHaveBeenCalledWith(
      3,
      { page: 1, limit: 20 },
      555,
      undefined,
    );
    expect(result.pagination).toEqual({ page: 1, limit: 20, total: 1 });
    expect(result.data[0].joinUrl).toBe('https://zoom/j/1');
  });

  it('lists a program\'s seat-allocated registrants directly by programId, with no session context', async () => {
    mockRepo.listSeatAllocatedRegistrationsByProgram.mockResolvedValue({
      data: [
        {
          registrationId: 1,
          registrationSeqNumber: 'SEQ1',
          fullName: 'Ram Kumar',
          email: 'ram@x.com',
          mobile: '999',
          registrationStatus: 'completed',
          seatAllocated: true,
        },
      ],
      total: 1,
    });
    const kpis = {
      totalEligible: 1,
      active: 1,
      inactive: 0,
      absentAtLeastOnce: 0,
      presentForAll: 1,
      totalSessions: 2,
      eligibleForFinalSession: 1,
      notEligibleForFinalSession: 0,
    };
    mockRepo.getProgramEligibleKpis.mockResolvedValue(kpis);

    const result = await service.listProgramEligibleRegistrations(3, { offset: 0, limit: 50, search: 'ram' });

    expect(mockOnlineSession.findOne).not.toHaveBeenCalled();
    expect(mockRepo.listSeatAllocatedRegistrationsByProgram).toHaveBeenCalledWith(3, {
      offset: 0,
      limit: 50,
      search: 'ram',
    });
    expect(mockRepo.getProgramEligibleKpis).toHaveBeenCalledWith(3, undefined);
    expect(result.pagination).toEqual({ offset: 0, limit: 50, total: 1 });
    expect(result.data[0]).not.toHaveProperty('joinUrl');
    expect(result.data[0].seatAllocated).toBe(true);
    // The flat repo-level counts are converted into the clickable v1 tile-array shape
    // (label/value/kpiCategory/kpiFilter) — see ZoomRegistrationService.buildProgramEligibleKpiTiles.
    expect(result.kpis).toEqual([
      { label: 'Total Attendees', value: 1 },
      { label: 'Active', value: 1, kpiCategory: 'eligibility', kpiFilter: 'active' },
      { label: 'Inactive', value: 0, kpiCategory: 'eligibility', kpiFilter: 'inactive' },
      {
        label: 'Not Eligible For S2',
        value: 0,
        kpiCategory: 'eligibility',
        kpiFilter: 'notEligibleForFinalSession',
      },
      {
        label: 'Eligible For S2',
        value: 1,
        kpiCategory: 'eligibility',
        kpiFilter: 'eligibleForFinalSession',
      },
    ]);
  });

  it('reports both final-session tiles as 0 when the KPI is null (program has ≤ 1 session)', async () => {
    mockRepo.listSeatAllocatedRegistrationsByProgram.mockResolvedValue({ data: [], total: 0 });
    mockRepo.getProgramEligibleKpis.mockResolvedValue({
      totalEligible: 0,
      active: 0,
      inactive: 0,
      absentAtLeastOnce: 0,
      presentForAll: 0,
      totalSessions: 1,
      eligibleForFinalSession: null,
      notEligibleForFinalSession: null,
    });

    const result = await service.listProgramEligibleRegistrations(3, { offset: 0, limit: 50 });

    expect(result.kpis.find((tile) => tile.label === 'Eligible For S1')).toEqual({
      label: 'Eligible For S1',
      value: 0,
      kpiCategory: 'eligibility',
      kpiFilter: 'eligibleForFinalSession',
    });
    expect(result.kpis.find((tile) => tile.label === 'Not Eligible For S1')).toEqual({
      label: 'Not Eligible For S1',
      value: 0,
      kpiCategory: 'eligibility',
      kpiFilter: 'notEligibleForFinalSession',
    });
  });

  it('returns every RM-role user (id + name) as the RM-contact filter options', async () => {
    mockRepo.listAllRmContacts.mockResolvedValue([
      { id: 501, name: 'Priya S' },
      { id: 42, name: 'Ravi Kumar' },
    ]);

    const result = await service.getProgramEligibleRmContacts();

    expect(mockRepo.listAllRmContacts).toHaveBeenCalledWith();
    expect(result).toEqual([
      { id: 501, name: 'Priya S' },
      { id: 42, name: 'Ravi Kumar' },
    ]);
  });

  it('exports the program\'s registrations to Excel and returns the file URL', async () => {
    mockRepo.findEligibleRegistrationRowsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationSeqNumber: 'SEQ1',
        fullName: 'A',
        email: 'a@x.com',
        mobile: '999',
        registrationStatus: 'completed',
        seatAllocated: true,
        joinUrl: 'https://zoom/j/1',
        isPanelist: false,
      },
    ]);

    const result = await service.exportSessionRegistrations(1392);

    expect(mockRepo.findEligibleRegistrationRowsByProgram).toHaveBeenCalledWith(
      3,
      undefined,
      555,
      undefined,
    );
    expect(result.fileUrl).toBe('https://s3.example.com/regs.xlsx');
    const [rows] = mockExcel.jsonToExcelAndUpload.mock.calls[0];
    expect(rows[0]['Registered to Zoom']).toBe('Yes');
    expect(rows[0]['Join URL']).toBe('https://zoom/j/1');
  });

  it('produces a header-only export when there are no registrations', async () => {
    mockRepo.findEligibleRegistrationRowsByProgram.mockResolvedValue([]);
    const result = await service.exportSessionRegistrations(1392);
    expect(result.fileUrl).toBe('https://s3.example.com/regs.xlsx');
    const [rows] = mockExcel.jsonToExcelAndUpload.mock.calls[0];
    expect(rows).toHaveLength(1);
    expect(rows[0]['Join URL']).toBe('');
  });
});

describe('ZoomRegistrationService — setRegistrationActivation', () => {
  let service: ZoomRegistrationService;

  const mockRepo = {
    findRegistrationById: jest.fn(),
    findZoomExtension: jest.fn(),
    findProvisionedExtensionsFromSession: jest.fn(),
    withTransaction: jest.fn((work) => work({})),
    updateActivationStatus: jest.fn(),
    adjustEligibleActiveCount: jest.fn(),
    updateRegistrationActivationStatus: jest.fn(),
    getEligibleActivationSummary: jest.fn(),
    hasLiveSessionForProgram: jest.fn(),
  };
  const mockOnlineSession = { findOne: jest.fn() };
  const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };
  const mockMeeting = { addParticipant: jest.fn(), removeParticipant: jest.fn(), approveParticipant: jest.fn() };
  const mockWebinar = { addParticipant: jest.fn(), removeParticipant: jest.fn(), approveParticipant: jest.fn() };
  const mockAttendeeSummary = { getSessionAttendanceCounts: jest.fn() };

  const onlineSession = { id: 555, externalId: 'ext-555' };
  // "Outside the live window" by default — before startsAt.
  const notLiveSession = {
    id: 1392,
    programId: 3,
    onlineType: 'meeting',
    onlineSession,
    startsAt: new Date('2026-07-08T10:00:00.000Z'),
    endsAt: new Date('2026-07-08T11:00:00.000Z'),
  };
  const registration = {
    id: 42,
    programId: 3,
    seatAllocated: true,
    emailAddress: 'seeker@x.com',
    fullName: 'Seeker One',
    gender: 'male',
  };
  const activeExtension = {
    id: 900,
    registrationId: 42,
    onlineSessionId: 555,
    activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
    joinUrl: 'https://zoom/j/old',
  };

  beforeEach(async () => {
    jest.clearAllMocks();
    mockRepo.withTransaction.mockImplementation((work) => work({}));
    mockOnlineSession.findOne.mockResolvedValue(notLiveSession);
    mockRepo.findRegistrationById.mockResolvedValue(registration);
    mockRepo.findZoomExtension.mockResolvedValue(activeExtension);
    mockRepo.hasLiveSessionForProgram.mockResolvedValue(false);
    // Default cascade: just the target session itself.
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: activeExtension, session: notLiveSession },
    ]);

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ZoomRegistrationService,
        { provide: ZoomRegistrationRepository, useValue: mockRepo },
        { provide: WebinarService, useValue: mockWebinar },
        { provide: MeetingService, useValue: mockMeeting },
        { provide: AppLoggerService, useValue: mockLogger },
        { provide: ExcelService, useValue: {} },
        { provide: OnlineSessionService, useValue: mockOnlineSession },
        { provide: ZoomAnalyticsAttendeeSummaryRepository, useValue: mockAttendeeSummary },
        { provide: ZoomFinalSessionAttendanceService, useValue: mockFinalSessionAttendanceService },
      ],
    }).compile();
    service = module.get(ZoomRegistrationService);
  });

  it('deactivates an active registrant: removes the Zoom link and decrements the eligible-active count', async () => {
    mockMeeting.removeParticipant.mockResolvedValue(undefined);

    const result = await service.setRegistrationActivation(
      42,
      RegistrationOnlineSessionActivationStatus.INACTIVE,
      'seat given up',
      91,
    );

    expect(result).toEqual({
      registrationId: 42,
      activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
      updatedOnlineSessionIds: [555],
    });
    expect(result).toEqual({
      registrationId: 42,
      activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
      updatedOnlineSessionIds: [555],
    });
    expect(mockMeeting.removeParticipant).toHaveBeenCalledWith(
      notLiveSession,
      activeExtension,
      expect.objectContaining({ email: expect.any(String) }),
    );
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      900,
      expect.objectContaining({
        activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
        activationSource: RegistrationOnlineSessionActivationSource.INACTIVE,
        joinUrl: null,
        activationChangedBy: 91,
        activationReason: 'seat given up',
      }),
      {},
    );
    expect(mockRepo.adjustEligibleActiveCount).toHaveBeenCalledWith(555, -1, {});
    expect(mockRepo.updateRegistrationActivationStatus).toHaveBeenCalledWith(
      42,
      RegistrationOnlineSessionActivationStatus.INACTIVE,
      {},
    );
  });

  it('reactivates an inactive registrant: re-provisions the Zoom link and increments the count', async () => {
    const inactiveExtension = {
      ...activeExtension,
      activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
      joinUrl: null,
    };
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: inactiveExtension, session: notLiveSession },
    ]);
    mockMeeting.addParticipant.mockResolvedValue({
      joinUrl: 'https://zoom/j/new',
      zoomRegistrantId: 'reg-abc',
      isPanelist: false,
    });

    await service.setRegistrationActivation(
      42,
      RegistrationOnlineSessionActivationStatus.ACTIVE,
      undefined,
      91,
    );

    expect(mockMeeting.addParticipant).toHaveBeenCalled();
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      900,
      expect.objectContaining({
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        activationSource: RegistrationOnlineSessionActivationSource.ACTIVE,
        joinUrl: 'https://zoom/j/new',
        externalRegistrantId: 'reg-abc',
      }),
      {},
    );
    expect(mockRepo.adjustEligibleActiveCount).toHaveBeenCalledWith(555, 1, {});
    expect(mockRepo.updateRegistrationActivationStatus).toHaveBeenCalledWith(
      42,
      RegistrationOnlineSessionActivationStatus.ACTIVE,
      {},
    );
  });

  it('reactivates a previously cancelled registrant by re-approving the same Zoom registrant instead of registering fresh', async () => {
    // A row deactivated by setRegistrationActivation keeps its externalRegistrantId
    // (only cleared on a full unregister/soft-delete) — Zoom cancelled, not deleted,
    // that registrant, so reactivation must re-approve it rather than re-add it.
    const cancelledExtension = {
      ...activeExtension,
      activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
      joinUrl: null,
      externalRegistrantId: 'reg-existing',
    };
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: cancelledExtension, session: notLiveSession },
    ]);
    mockMeeting.approveParticipant.mockResolvedValue({
      joinUrl: 'https://zoom/j/restored',
      zoomRegistrantId: 'reg-existing',
      isPanelist: false,
    });

    await service.setRegistrationActivation(
      42,
      RegistrationOnlineSessionActivationStatus.ACTIVE,
      undefined,
      91,
    );

    expect(mockMeeting.approveParticipant).toHaveBeenCalledWith(
      notLiveSession,
      cancelledExtension,
      expect.objectContaining({ email: expect.any(String) }),
    );
    expect(mockMeeting.addParticipant).not.toHaveBeenCalled();
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      900,
      expect.objectContaining({
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        joinUrl: 'https://zoom/j/restored',
        externalRegistrantId: 'reg-existing',
      }),
      {},
    );
  });

  it('cascades a deactivation to every upcoming session — skipping ones already in the requested state', async () => {
    const now = Date.now();
    const futureSession = {
      ...notLiveSession,
      id: 1393,
      onlineSession: { id: 556, externalId: 'ext-556' },
      startsAt: new Date(now + 24 * 60 * 60 * 1000),
      endsAt: new Date(now + 25 * 60 * 60 * 1000),
    };
    const futureExtension = { ...activeExtension, id: 901, onlineSessionId: 556 };
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: activeExtension, session: notLiveSession },
      { extension: futureExtension, session: futureSession },
    ]);
    mockMeeting.removeParticipant.mockResolvedValue(undefined);

    const result = await service.setRegistrationActivation(
      42,
      RegistrationOnlineSessionActivationStatus.INACTIVE,
      'left the program',
      91,
    );

    expect(mockRepo.hasLiveSessionForProgram).toHaveBeenCalledWith(3, expect.any(Date));
    expect(mockRepo.findProvisionedExtensionsFromSession).toHaveBeenCalledWith(
      42,
      3,
      expect.any(Date),
    );
    expect(result.updatedOnlineSessionIds).toEqual([555, 556]);
    expect(mockMeeting.removeParticipant).toHaveBeenCalledTimes(2);
    expect(mockMeeting.removeParticipant).toHaveBeenCalledWith(
      notLiveSession,
      activeExtension,
      expect.objectContaining({ email: expect.any(String) }),
    );
    expect(mockMeeting.removeParticipant).toHaveBeenCalledWith(
      futureSession,
      futureExtension,
      expect.objectContaining({ email: expect.any(String) }),
    );
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledTimes(2);
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      900,
      expect.objectContaining({ activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE }),
      {},
    );
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      901,
      expect.objectContaining({ activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE }),
      {},
    );
    expect(mockRepo.adjustEligibleActiveCount).toHaveBeenCalledWith(555, -1, {});
    expect(mockRepo.adjustEligibleActiveCount).toHaveBeenCalledWith(556, -1, {});
    expect(mockRepo.updateRegistrationActivationStatus).toHaveBeenCalledTimes(1);
  });

  it('rejects the whole toggle — for every registrant of the program — while any session of the program is currently live', async () => {
    mockRepo.hasLiveSessionForProgram.mockResolvedValue(true);

    const error = await service
      .setRegistrationActivation(42, RegistrationOnlineSessionActivationStatus.INACTIVE, 'left the program', 91)
      .catch((e) => e);

    expect(error).toBeInstanceOf(InifniBadRequestException);
    expect((error as InifniBadRequestException).code).toBe(ERROR_CODES.ZOOM_SESSION_IN_PROGRESS);
    expect(mockRepo.hasLiveSessionForProgram).toHaveBeenCalledWith(3, expect.any(Date));
    // Blocked before touching the cascade, Zoom, or any DB row.
    expect(mockRepo.findProvisionedExtensionsFromSession).not.toHaveBeenCalled();
    expect(mockMeeting.removeParticipant).not.toHaveBeenCalled();
    expect(mockRepo.updateActivationStatus).not.toHaveBeenCalled();
    expect(mockRepo.updateRegistrationActivationStatus).not.toHaveBeenCalled();
  });

  it('calls Zoom only once for two occurrences of the same recurring/shared-link meeting, but updates both DB rows', async () => {
    // Two upcoming ProgramSession occurrences (e.g. weekly recurrences) backed by
    // the SAME physical Zoom meeting (same onlineSession.externalId) — Zoom
    // registers/removes a registrant per meeting, not per occurrence, so a second
    // removal call for the same meeting would 404 ("registrant not found").
    const sameMeetingSession = {
      ...notLiveSession,
      id: 1395,
      onlineSession: { id: 558, externalId: onlineSession.externalId },
    };
    const sameMeetingExtension = { ...activeExtension, id: 903, onlineSessionId: 558 };
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: activeExtension, session: notLiveSession },
      { extension: sameMeetingExtension, session: sameMeetingSession },
    ]);
    mockMeeting.removeParticipant.mockResolvedValue(undefined);

    const result = await service.setRegistrationActivation(
      42,
      RegistrationOnlineSessionActivationStatus.INACTIVE,
      'left the program',
      91,
    );

    expect(mockMeeting.removeParticipant).toHaveBeenCalledTimes(1);
    expect(result.updatedOnlineSessionIds).toEqual([555, 558]);
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledTimes(2);
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      900,
      expect.objectContaining({ activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE }),
      {},
    );
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      903,
      expect.objectContaining({ activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE }),
      {},
    );
  });

  it('still persists the overall rollup when every upcoming session is already in the requested state', async () => {
    const result = await service.setRegistrationActivation(
      42,
      RegistrationOnlineSessionActivationStatus.ACTIVE,
      undefined,
      91,
    );

    expect(result).toEqual({
      registrationId: 42,
      activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      updatedOnlineSessionIds: [],
    });
    expect(result).toEqual({
      registrationId: 42,
      activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      updatedOnlineSessionIds: [],
    });
    expect(mockMeeting.removeParticipant).not.toHaveBeenCalled();
    expect(mockMeeting.addParticipant).not.toHaveBeenCalled();
    expect(mockRepo.updateActivationStatus).not.toHaveBeenCalled();
    // The registration-level rollup is authoritative — written even on a session-level no-op.
    expect(mockRepo.updateRegistrationActivationStatus).toHaveBeenCalledWith(
      42,
      RegistrationOnlineSessionActivationStatus.ACTIVE,
      {},
    );
  });

  it('rejects a registration that does not hold an allocated seat', async () => {
    mockRepo.findRegistrationById.mockResolvedValue({ ...registration, seatAllocated: false });

    const error = await service
      .setRegistrationActivation(42, RegistrationOnlineSessionActivationStatus.INACTIVE, undefined, 91)
      .catch((e) => e);

    expect(error).toBeInstanceOf(InifniBadRequestException);
    expect((error as InifniBadRequestException).code).toBe(ERROR_CODES.ZOOM_REGISTRATION_NOT_ELIGIBLE);
    expect(mockMeeting.removeParticipant).not.toHaveBeenCalled();
  });

  it('wraps a provider failure as a bad-request error and leaves the rows untouched', async () => {
    mockMeeting.removeParticipant.mockRejectedValue(new Error('Zoom API down'));

    const error = await service
      .setRegistrationActivation(42, RegistrationOnlineSessionActivationStatus.INACTIVE, undefined, 91)
      .catch((e) => e);

    expect(error).toBeInstanceOf(InifniBadRequestException);
    expect((error as InifniBadRequestException).code).toBe(ERROR_CODES.ZOOM_ACTIVATION_UPDATE_FAILED);
    expect(mockRepo.updateActivationStatus).not.toHaveBeenCalled();
    expect(mockRepo.adjustEligibleActiveCount).not.toHaveBeenCalled();
  });

  it('propagates a well-formed provider error as-is instead of masking it with the generic message', async () => {
    const zoomApiError = new InifniBadRequestException(
      ERROR_CODES.ZOOM_API_ERROR,
      null,
      null,
      'Registrant status not accepted',
    );
    mockMeeting.removeParticipant.mockRejectedValue(zoomApiError);

    const error = await service
      .setRegistrationActivation(42, RegistrationOnlineSessionActivationStatus.INACTIVE, undefined, 91)
      .catch((e) => e);

    expect(error).toBe(zoomApiError);
    expect((error as InifniBadRequestException).code).toBe(ERROR_CODES.ZOOM_API_ERROR);
    expect(mockRepo.updateActivationStatus).not.toHaveBeenCalled();
  });

  it('blocks reactivating a registration whose upcoming sessions were already archived/cancelled by the lifecycle cascade', async () => {
    const archivedExtension = {
      ...activeExtension,
      activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
      activationSource: RegistrationOnlineSessionActivationSource.ARCHIVED,
    };
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: archivedExtension, session: notLiveSession },
    ]);

    const error = await service
      .setRegistrationActivation(42, RegistrationOnlineSessionActivationStatus.ACTIVE, undefined, 91)
      .catch((e) => e);

    expect(error).toBeInstanceOf(InifniBadRequestException);
    expect((error as InifniBadRequestException).code).toBe(ERROR_CODES.ZOOM_ACTIVATION_STATUS_LOCKED);
    expect(mockMeeting.addParticipant).not.toHaveBeenCalled();
    expect(mockRepo.updateActivationStatus).not.toHaveBeenCalled();
    expect(mockRepo.updateRegistrationActivationStatus).not.toHaveBeenCalled();
  });

  it('blocks reactivating a registration whose upcoming sessions were already deleted by the lifecycle cascade', async () => {
    const deletedExtension = {
      ...activeExtension,
      activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
      activationSource: RegistrationOnlineSessionActivationSource.DELETED,
    };
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: deletedExtension, session: notLiveSession },
    ]);

    const error = await service
      .setRegistrationActivation(42, RegistrationOnlineSessionActivationStatus.ACTIVE, undefined, 91)
      .catch((e) => e);

    expect(error).toBeInstanceOf(InifniBadRequestException);
    expect((error as InifniBadRequestException).code).toBe(ERROR_CODES.ZOOM_ACTIVATION_STATUS_LOCKED);
    expect(mockMeeting.addParticipant).not.toHaveBeenCalled();
    expect(mockRepo.updateActivationStatus).not.toHaveBeenCalled();
    expect(mockRepo.updateRegistrationActivationStatus).not.toHaveBeenCalled();
  });
});

describe('ZoomRegistrationService — cascadeTerminalActivation', () => {
  let service: ZoomRegistrationService;

  const mockRepo = {
    findRegistrationById: jest.fn(),
    findProvisionedExtensionsFromSession: jest.fn(),
    withTransaction: jest.fn((work) => work({})),
    updateActivationStatus: jest.fn(),
    adjustEligibleActiveCount: jest.fn(),
    updateRegistrationActivationStatus: jest.fn(),
  };
  const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };
  const mockMeeting = { addParticipant: jest.fn(), removeParticipant: jest.fn() };
  const mockWebinar = { addParticipant: jest.fn(), removeParticipant: jest.fn() };

  const onlineSession = { id: 555, externalId: 'ext-555' };
  const upcomingSession = {
    id: 1392,
    programId: 3,
    onlineType: 'meeting',
    onlineSession,
    startsAt: new Date('2026-07-08T10:00:00.000Z'),
    endsAt: new Date('2026-07-08T11:00:00.000Z'),
  };
  const registration = { id: 42, programId: 3, seatAllocated: true };
  const activeExtension = {
    id: 900,
    registrationId: 42,
    onlineSessionId: 555,
    activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
    joinUrl: 'https://zoom/j/old',
  };

  beforeEach(async () => {
    jest.clearAllMocks();
    mockRepo.withTransaction.mockImplementation((work) => work({}));
    mockRepo.findRegistrationById.mockResolvedValue(registration);
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: activeExtension, session: upcomingSession },
    ]);

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ZoomRegistrationService,
        { provide: ZoomRegistrationRepository, useValue: mockRepo },
        { provide: WebinarService, useValue: mockWebinar },
        { provide: MeetingService, useValue: mockMeeting },
        { provide: AppLoggerService, useValue: mockLogger },
        { provide: ExcelService, useValue: {} },
        { provide: OnlineSessionService, useValue: {} },
        { provide: ZoomAnalyticsAttendeeSummaryRepository, useValue: {} },
        { provide: ZoomFinalSessionAttendanceService, useValue: mockFinalSessionAttendanceService },
      ],
    }).compile();
    service = module.get(ZoomRegistrationService);
  });

  it('removes Zoom access from every active upcoming session and persists the terminal status + rollup', async () => {
    mockMeeting.removeParticipant.mockResolvedValue(undefined);

    await service.cascadeTerminalActivation(
      42,
      RegistrationOnlineSessionActivationSource.CANCELLED,
      91,
    );

    expect(mockMeeting.removeParticipant).toHaveBeenCalledWith(
      upcomingSession,
      activeExtension,
      expect.objectContaining({ email: expect.any(String) }),
    );
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      900,
      expect.objectContaining({
        activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
        activationSource: RegistrationOnlineSessionActivationSource.CANCELLED,
        joinUrl: null,
        activationChangedBy: 91,
        activationReason: 'Registration cancelled',
      }),
      {},
    );
    expect(mockRepo.adjustEligibleActiveCount).toHaveBeenCalledWith(555, -1, {});
    expect(mockRepo.updateRegistrationActivationStatus).toHaveBeenCalledWith(
      42,
      RegistrationOnlineSessionActivationStatus.INACTIVE,
      {},
    );
  });

  it('skips rows that are not currently ACTIVE (already inactive/terminal) without touching Zoom or the row', async () => {
    const inactiveExtension = {
      ...activeExtension,
      activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
    };
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: inactiveExtension, session: upcomingSession },
    ]);

    await service.cascadeTerminalActivation(
      42,
      RegistrationOnlineSessionActivationSource.ARCHIVED,
      91,
    );

    expect(mockMeeting.removeParticipant).not.toHaveBeenCalled();
    expect(mockRepo.updateActivationStatus).not.toHaveBeenCalled();
    // Rollup is still always persisted — the registration itself is terminal.
    expect(mockRepo.updateRegistrationActivationStatus).toHaveBeenCalledWith(
      42,
      RegistrationOnlineSessionActivationStatus.INACTIVE,
      {},
    );
  });

  it('is best-effort: a Zoom failure on one session is logged and skipped, other sessions and the rollup still get processed', async () => {
    const otherSession = {
      ...upcomingSession,
      id: 1393,
      onlineSession: { id: 556, externalId: 'ext-556' },
    };
    const otherExtension = { ...activeExtension, id: 901, onlineSessionId: 556 };
    mockRepo.findProvisionedExtensionsFromSession.mockResolvedValue([
      { extension: activeExtension, session: upcomingSession },
      { extension: otherExtension, session: otherSession },
    ]);
    mockMeeting.removeParticipant
      .mockRejectedValueOnce(new Error('Zoom API down'))
      .mockResolvedValueOnce(undefined);

    await service.cascadeTerminalActivation(
      42,
      RegistrationOnlineSessionActivationSource.CANCELLED,
      91,
    );

    expect(mockLogger.error).toHaveBeenCalled();
    // Failed row (900) untouched, so it can be reconciled later.
    expect(mockRepo.updateActivationStatus).not.toHaveBeenCalledWith(
      900,
      expect.anything(),
      expect.anything(),
    );
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      901,
      expect.objectContaining({
        activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
        activationSource: RegistrationOnlineSessionActivationSource.CANCELLED,
      }),
      {},
    );
    expect(mockRepo.updateRegistrationActivationStatus).toHaveBeenCalledWith(
      42,
      RegistrationOnlineSessionActivationStatus.INACTIVE,
      {},
    );
  });

  it('never throws — even if the registration cannot be found', async () => {
    mockRepo.findRegistrationById.mockResolvedValue(null);

    await expect(
      service.cascadeTerminalActivation(42, RegistrationOnlineSessionActivationSource.CANCELLED, 91),
    ).resolves.toBeUndefined();
    expect(mockLogger.error).toHaveBeenCalled();
    expect(mockRepo.updateRegistrationActivationStatus).not.toHaveBeenCalled();
  });

  it('no-ops (but still resolves) when the registration has no program to cascade over', async () => {
    mockRepo.findRegistrationById.mockResolvedValue({ id: 42, programId: null, seatAllocated: true });

    await service.cascadeTerminalActivation(42, RegistrationOnlineSessionActivationSource.ARCHIVED, 91);

    expect(mockRepo.findProvisionedExtensionsFromSession).not.toHaveBeenCalled();
    expect(mockRepo.updateRegistrationActivationStatus).not.toHaveBeenCalled();
  });

  it('handles the DELETED source (soft-deleted registration) the same as archive/cancel', async () => {
    mockMeeting.removeParticipant.mockResolvedValue(undefined);

    await service.cascadeTerminalActivation(42, RegistrationOnlineSessionActivationSource.DELETED, 91);

    expect(mockMeeting.removeParticipant).toHaveBeenCalledWith(
      upcomingSession,
      activeExtension,
      expect.objectContaining({ email: expect.any(String) }),
    );
    expect(mockRepo.updateActivationStatus).toHaveBeenCalledWith(
      900,
      expect.objectContaining({
        activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
        activationSource: RegistrationOnlineSessionActivationSource.DELETED,
        joinUrl: null,
        activationChangedBy: 91,
        activationReason: 'Registration deleted',
      }),
      {},
    );
    expect(mockRepo.updateRegistrationActivationStatus).toHaveBeenCalledWith(
      42,
      RegistrationOnlineSessionActivationStatus.INACTIVE,
      {},
    );
  });
});

describe('ZoomRegistrationService — getEligibleCount', () => {
  let service: ZoomRegistrationService;

  const mockRepo = { getEligibleActivationSummary: jest.fn() };
  const mockOnlineSession = { findOne: jest.fn() };
  const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };

  beforeEach(async () => {
    jest.clearAllMocks();
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ZoomRegistrationService,
        { provide: ZoomRegistrationRepository, useValue: mockRepo },
        { provide: WebinarService, useValue: {} },
        { provide: MeetingService, useValue: {} },
        { provide: AppLoggerService, useValue: mockLogger },
        { provide: ExcelService, useValue: {} },
        { provide: OnlineSessionService, useValue: mockOnlineSession },
        { provide: ZoomAnalyticsAttendeeSummaryRepository, useValue: {} },
        { provide: ZoomFinalSessionAttendanceService, useValue: mockFinalSessionAttendanceService },
      ],
    }).compile();
    service = module.get(ZoomRegistrationService);
  });

  it("returns the session's live activation summary", async () => {
    mockOnlineSession.findOne.mockResolvedValue({
      id: 1392,
      programId: 3,
      onlineSession: { id: 555 },
    });
    mockRepo.getEligibleActivationSummary.mockResolvedValue({
      onlineSessionId: 555,
      totalEligible: 500,
      activeCount: 493,
      inactiveCount: 7,
    });

    const result = await service.getEligibleCount(1392);

    expect(mockRepo.getEligibleActivationSummary).toHaveBeenCalledWith(555);
    expect(result).toEqual({
      onlineSessionId: 555,
      totalEligible: 500,
      activeCount: 493,
      inactiveCount: 7,
    });
  });

  it('rejects a session with no provisioned online resource', async () => {
    mockOnlineSession.findOne.mockResolvedValue({ id: 1392, programId: 3, onlineSession: null });

    const error = await service.getEligibleCount(1392).catch((e) => e);
    expect(error).toBeInstanceOf(InifniBadRequestException);
    expect((error as InifniBadRequestException).code).toBe(ERROR_CODES.ZOOM_SESSION_NOT_PROVISIONED);
  });
});

describe('ZoomRegistrationService — getSessionAttendanceSummary', () => {
  let service: ZoomRegistrationService;

  // resolveFinalSessionRegisteredAdjustments' own lookups — defaulted so tests that don't touch
  // the final-session gate (mockFinalSessionAttendanceService resolves null by default) never
  // need to know about them.
  const mockRepo = {
    getEligibleActivationSummaries: jest.fn(),
    findEligibleRegistrationsByProgram: jest.fn().mockResolvedValue([]),
    findActiveExtensionKeys: jest.fn().mockResolvedValue(new Set<string>()),
  };
  const mockAttendeeSummary = { getSessionAttendanceCounts: jest.fn() };
  const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };

  beforeEach(async () => {
    jest.clearAllMocks();
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ZoomRegistrationService,
        { provide: ZoomRegistrationRepository, useValue: mockRepo },
        { provide: WebinarService, useValue: {} },
        { provide: MeetingService, useValue: {} },
        { provide: AppLoggerService, useValue: mockLogger },
        { provide: ExcelService, useValue: {} },
        { provide: OnlineSessionService, useValue: {} },
        { provide: ZoomAnalyticsAttendeeSummaryRepository, useValue: mockAttendeeSummary },
        { provide: ZoomFinalSessionAttendanceService, useValue: mockFinalSessionAttendanceService },
      ],
    }).compile();
    service = module.get(ZoomRegistrationService);
  });

  it('merges registered (by online session id) with attended/absent/duration buckets (by session id)', async () => {
    const sessions = [
      { id: 1392, programId: 3, onlineSession: { id: 555 } },
      { id: 1393, programId: 3, onlineSession: { id: 556 } },
    ] as any;
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(
      new Map([
        [1392, { attended: 4, absent: 2, durationBuckets: { under60: 1, from60to90: 2, from90to120: 1 } }],
      ]),
    );
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(
      new Map([
        [555, 6],
        [556, 3],
      ]),
    );

    const result = await service.getSessionAttendanceSummary(sessions);

    expect(mockAttendeeSummary.getSessionAttendanceCounts).toHaveBeenCalledWith([1392, 1393], undefined);
    expect(mockRepo.getEligibleActivationSummaries).toHaveBeenCalledWith(
      [
        { onlineSessionId: 555, programId: 3 },
        { onlineSessionId: 556, programId: 3 },
      ],
      undefined,
    );
    expect(result.get(1392)).toEqual({
      onlineSessionId: 555,
      registered: 6,
      attended: 4,
      absent: 2,
      durationBuckets: { under60: 1, from60to90: 2, from90to120: 1 },
    });
    // No attendee-summary row yet for this session -> zeroed counts, not undefined.
    expect(result.get(1393)).toEqual({
      onlineSessionId: 556,
      registered: 3,
      attended: 0,
      absent: 0,
      durationBuckets: { under60: 0, from60to90: 0, from90to120: 0 },
    });
  });

  it("subtracts final-session absentees from the 'registered' fallback count when the final session has no real registration yet", async () => {
    const sessions = [{ id: 1809, programId: 1535, onlineSession: { id: 900 } }] as any;
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(new Map());
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(new Map([[900, 5]]));
    mockFinalSessionAttendanceService.getFinalSessionId.mockResolvedValueOnce(1809);
    mockRepo.findEligibleRegistrationsByProgram.mockResolvedValueOnce([
      { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 },
    ]);
    mockFinalSessionAttendanceService.resolveFinalSessionAbsentees.mockResolvedValueOnce({
      finalSessionId: 1809,
      absenteeRegistrationIds: new Set([3, 4, 5]),
    });
    mockRepo.findActiveExtensionKeys.mockResolvedValueOnce(new Set<string>());

    const result = await service.getSessionAttendanceSummary(sessions);

    expect(mockFinalSessionAttendanceService.getFinalSessionId).toHaveBeenCalledWith(1535);
    expect(mockRepo.findEligibleRegistrationsByProgram).toHaveBeenCalledWith(1535, undefined);
    expect(mockFinalSessionAttendanceService.resolveFinalSessionAbsentees).toHaveBeenCalledWith(
      1535,
      [1, 2, 3, 4, 5],
    );
    expect(mockRepo.findActiveExtensionKeys).toHaveBeenCalledWith([1, 2, 3, 4, 5], [900]);
    expect(result.get(1809)?.registered).toBe(2);
  });

  it("leaves the 'registered' count alone once a real registration already exists for the final session (no retroactive hiding)", async () => {
    const sessions = [{ id: 1809, programId: 1535, onlineSession: { id: 900 } }] as any;
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(new Map());
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(new Map([[900, 2]]));
    mockFinalSessionAttendanceService.getFinalSessionId.mockResolvedValueOnce(1809);
    mockRepo.findEligibleRegistrationsByProgram.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]);
    mockFinalSessionAttendanceService.resolveFinalSessionAbsentees.mockResolvedValueOnce({
      finalSessionId: 1809,
      absenteeRegistrationIds: new Set([2]),
    });
    // A real ACTIVE extension already exists for online session 900 — the "registered" count is
    // already the real row count, not the eligible-count fallback, so it must pass through as-is.
    mockRepo.findActiveExtensionKeys.mockResolvedValueOnce(new Set(['1:900']));

    const result = await service.getSessionAttendanceSummary(sessions);

    expect(result.get(1809)?.registered).toBe(2);
  });

  it("does not adjust 'registered' for a program with only one session (no final-session concept), never even fetching eligible registrations", async () => {
    const sessions = [{ id: 1500, programId: 42, onlineSession: { id: 900 } }] as any;
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(new Map());
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(new Map([[900, 4]]));
    mockFinalSessionAttendanceService.getFinalSessionId.mockResolvedValueOnce(null);

    const result = await service.getSessionAttendanceSummary(sessions);

    expect(mockRepo.findEligibleRegistrationsByProgram).not.toHaveBeenCalled();
    expect(mockFinalSessionAttendanceService.resolveFinalSessionAbsentees).not.toHaveBeenCalled();
    expect(mockRepo.findActiveExtensionKeys).not.toHaveBeenCalled();
    expect(result.get(1500)?.registered).toBe(4);
  });

  it("skips the eligible-registrations fetch when the program IS multi-session but its final session isn't on this page", async () => {
    // Page shows only session 1808 (session 1 of a 2-session program) — the final session
    // (1809) isn't part of this request, so nothing here should be gated on it at all.
    const sessions = [{ id: 1808, programId: 1535, onlineSession: { id: 800 } }] as any;
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(new Map());
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(new Map([[800, 5]]));
    mockFinalSessionAttendanceService.getFinalSessionId.mockResolvedValueOnce(1809);

    const result = await service.getSessionAttendanceSummary(sessions);

    expect(mockRepo.findEligibleRegistrationsByProgram).not.toHaveBeenCalled();
    expect(mockFinalSessionAttendanceService.resolveFinalSessionAbsentees).not.toHaveBeenCalled();
    expect(result.get(1808)?.registered).toBe(5);
  });

  it('coerces bigint-typed online session ids (returned as strings by the driver) before the registered lookup', async () => {
    // pg/TypeORM return `bigint` columns as strings at runtime despite the `number`
    // TS type — this reproduces that shape rather than a same-type-both-ends mock.
    const sessions = [{ id: 1392, programId: 3, onlineSession: { id: '555' } }] as any;
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(new Map());
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(new Map([[555, 6]]));

    const result = await service.getSessionAttendanceSummary(sessions);

    expect(mockRepo.getEligibleActivationSummaries).toHaveBeenCalledWith(
      [{ onlineSessionId: 555, programId: 3 }],
      undefined,
    );
    expect(result.get(1392)).toEqual({
      onlineSessionId: 555,
      registered: 6,
      attended: 0,
      absent: 0,
      durationBuckets: { under60: 0, from60to90: 0, from90to120: 0 },
    });
  });

  it('threads an RM caller\'s rmContactId into both underlying queries', async () => {
    const sessions = [{ id: 1392, programId: 3, onlineSession: { id: 555 } }] as any;
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(new Map());
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(new Map());

    await service.getSessionAttendanceSummary(sessions, 77);

    expect(mockAttendeeSummary.getSessionAttendanceCounts).toHaveBeenCalledWith([1392], 77);
    expect(mockRepo.getEligibleActivationSummaries).toHaveBeenCalledWith(
      [{ onlineSessionId: 555, programId: 3 }],
      77,
    );
  });

  it('returns an empty map for an empty session list without querying', async () => {
    const result = await service.getSessionAttendanceSummary([]);

    expect(result.size).toBe(0);
    expect(mockAttendeeSummary.getSessionAttendanceCounts).not.toHaveBeenCalled();
    expect(mockRepo.getEligibleActivationSummaries).not.toHaveBeenCalled();
  });

  it('forces attended/absent/duration to 0 before the session\'s pre-join window has opened, even if the repository already returned counts', async () => {
    const sessions = [
      {
        id: 1392,
        programId: 3,
        onlineSession: { id: 555, joinOpensMinutesBefore: 15 },
        // Starts 2 hours from now — the 15-minute pre-join window hasn't opened yet.
        startsAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
        endsAt: new Date(Date.now() + 3 * 60 * 60 * 1000),
      },
    ] as any;
    // Attendee-summary rows already exist for this future session (e.g. seeded at
    // provisioning time) — the repository has no idea the session hasn't started.
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(
      new Map([[1392, { attended: 0, absent: 5, durationBuckets: { under60: 0, from60to90: 0, from90to120: 0 } }]]),
    );
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(new Map([[555, 5]]));

    const result = await service.getSessionAttendanceSummary(sessions);

    expect(result.get(1392)).toEqual({
      onlineSessionId: 555,
      registered: 5,
      attended: 0,
      absent: 0,
      durationBuckets: { under60: 0, from60to90: 0, from90to120: 0 },
    });
  });

  it('passes the real reconciled counts through once the pre-join window has opened', async () => {
    const sessions = [
      {
        id: 1392,
        programId: 3,
        onlineSession: { id: 555, joinOpensMinutesBefore: 15 },
        // Started 10 minutes ago — well past the pre-join window opening.
        startsAt: new Date(Date.now() - 10 * 60 * 1000),
        endsAt: new Date(Date.now() + 10 * 60 * 1000),
      },
    ] as any;
    mockAttendeeSummary.getSessionAttendanceCounts.mockResolvedValue(
      new Map([[1392, { attended: 3, absent: 2, durationBuckets: { under60: 1, from60to90: 1, from90to120: 1 } }]]),
    );
    mockRepo.getEligibleActivationSummaries.mockResolvedValue(new Map([[555, 5]]));

    const result = await service.getSessionAttendanceSummary(sessions);

    expect(result.get(1392)).toEqual({
      onlineSessionId: 555,
      registered: 5,
      attended: 3,
      absent: 2,
      durationBuckets: { under60: 1, from60to90: 1, from90to120: 1 },
    });
  });
});
