import { ZoomProgramAnalyticsService } from './zoom-program-analytics.service';
import { RegistrationOnlineSessionActivationStatus } from 'src/common/enum/registration-online-session-activation-status.enum';
import { SessionCommunicationPurposeEnum } from 'src/common/enum/session-communication-purpose.enum';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';

describe('ZoomProgramAnalyticsService', () => {
  const webinarRepository = { findByProgramId: jest.fn() };
  const registrationRepository = { findAllRegistrationsByProgram: jest.fn() };
  const attendeeSummaryRepository = { findAllByProgram: jest.fn() };
  const rosterRepository = {
    findPanelistRosterByOnlineSession: jest.fn(),
    findRegistrantRosterByOnlineSession: jest.fn(),
  };
  const provider = { getDashboard: jest.fn(), syncAttendeeSummaries: jest.fn() };
  const providerRegistry = { resolveForOnlineType: jest.fn().mockReturnValue(provider) };
  const onlineAttendanceService = { getAttendedRegistrationIds: jest.fn() };
  const sessionCommunicationService = { getTriggeredPurposesForSessions: jest.fn() };
  const config = { getLateJoinThresholdSeconds: jest.fn().mockReturnValue(300) };

  let service: ZoomProgramAnalyticsService;

  beforeEach(() => {
    jest.clearAllMocks();
    config.getLateJoinThresholdSeconds.mockReturnValue(300);
    rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
    rosterRepository.findRegistrantRosterByOnlineSession.mockResolvedValue([]);
    providerRegistry.resolveForOnlineType.mockReturnValue(provider);
    provider.getDashboard.mockResolvedValue({ lateComers: { total: 0, buckets: [] } });
    provider.syncAttendeeSummaries.mockResolvedValue(undefined);
    onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set());
    sessionCommunicationService.getTriggeredPurposesForSessions.mockResolvedValue(new Map());
    service = new ZoomProgramAnalyticsService(
      webinarRepository as any,
      registrationRepository as any,
      attendeeSummaryRepository as any,
      rosterRepository as any,
      providerRegistry as any,
      onlineAttendanceService as any,
      sessionCommunicationService as any,
      config as any,
    );
  });

  // onlineSession/onlineType default to a webinar session with online session id 100 —
  // the roster tests override rosterRepository's mock to return whatever roster they need.
  function session(id: number, startsAt: string, endsAt: string | null) {
    return {
      id,
      startsAt: new Date(startsAt),
      endsAt: endsAt ? new Date(endsAt) : null,
      onlineSession: { id: 100 },
      onlineType: OnlineTypeEnum.WEBINAR,
    };
  }

  it('computes Total Attendees/Active KPIs from the whole roster, independent of attendance', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        seatAllocated: true,
      },
      {
        registrationId: 2,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
        seatAllocated: true,
      },
      // No seat allocated — counted in neither "Total Attendees" nor excluded from
      // "Active" for any other reason (activationStatus alone drives Active).
      {
        registrationId: 3,
        registrationStatus: 'CANCELLED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        seatAllocated: false,
      },
    ]);
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);

    const result = await service.getOverallAnalytics(42);

    expect(result.kpis.totalAttendees).toBe(2);
    expect(result.kpis.active).toBe(2);
    // Threshold is dynamic — "sessions before the program's own final one" — and this
    // program only has 1 session total, so there's nothing before it to gate on.
    expect(result.kpis.thresholdSession).toBe(0);
  });

  it('excludes registrations with no held session from the average-attendance mean, and rounds it', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'), // held
      session(2, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'), // upcoming
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
      {
        registrationId: 2,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
    ]);
    // Registration 1 attended session 1 (100%); registration 2 did not (0%) — mean = 50.
    onlineAttendanceService.getAttendedRegistrationIds.mockImplementation((sessionId: number) =>
      Promise.resolve(sessionId === 1 ? new Set([1]) : new Set()),
    );
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([
      { registrationId: 1, sessionId: 1, joinedAt: new Date('2020-01-01T10:00:00Z') },
    ]);

    const result = await service.getOverallAnalytics(42);

    expect(result.kpis.avgAttendancePercent).toBe(50);
  });

  it('returns null avgAttendancePercent when no registration has any held session yet, and leaves eligibility undecided (not vacuously eligible)', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'), // upcoming — the gate session
      session(2, '2099-02-01T10:00:00Z', '2099-02-01T11:00:00Z'), // upcoming — the final session
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
    ]);
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);

    const result = await service.getOverallAnalytics(42);

    expect(result.kpis.avgAttendancePercent).toBeNull();
    // Threshold is session 1 (the only session before the final one), but it hasn't
    // happened yet — the gate isn't decided, so nobody is "eligible" yet either.
    expect(result.kpis.thresholdSession).toBe(1);
    expect(result.kpis.eligibleForNextSession).toBe(0);
  });

  it('labels the timeline S1..Sn in schedule order and marks status by whether the session has passed', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
      session(2, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'),
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
    ]);
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);

    const result = await service.getOverallAnalytics(42);

    expect(result.timeline).toHaveLength(2);
    expect(result.timeline[0]).toMatchObject({ label: 'S1', status: 'completed' });
    expect(result.timeline[1]).toMatchObject({ label: 'S2', status: 'upcoming' });
  });

  it('builds the Latecomers heatmap from getDashboard().lateComers, one column per HELD session, zero-filling bands with no latecomers', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'), // held
      session(2, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'), // upcoming — no column
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([]);
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);
    provider.getDashboard.mockResolvedValue({
      lateComers: {
        total: 3,
        buckets: [
          { label: '<30s', count: 2 },
          { label: '2–5 min', count: 1 },
        ],
      },
    });

    const result = await service.getOverallAnalytics(42);

    expect(result.latecomers.bandLabels).toEqual([
      '<30s',
      '30s–1 min',
      '1–2 min',
      '2–5 min',
      '5 min+',
    ]);
    expect(result.latecomers.columns).toEqual([
      { sessionId: 1, label: 'S1', countsByBand: [2, 0, 0, 1, 0] },
    ]);
  });

  it("computes a completed session's KPI tiles (totalAttendees/present/absent/joinedLate) using the same is_attended-backed source as that session's own Attendees screen (getKpis' finalPresent)", async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
      {
        registrationId: 2,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
      {
        registrationId: 3,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
      {
        registrationId: 4,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
    ]);
    // All 4 registrations are on this session's own roster.
    rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
      { registrationId: 1 },
      { registrationId: 2 },
      { registrationId: 3 },
      { registrationId: 4 },
    ]);
    // Registrations 1 and 2 are present, per the is_attended-backed source — regardless of
    // whether an attendee-summary row (used only for the late-join timestamp) exists.
    onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([1, 2]));
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([
      { registrationId: 1, sessionId: 1, joinedAt: new Date('2020-01-01T10:00:00Z') }, // on-time
      { registrationId: 2, sessionId: 1, joinedAt: new Date('2020-01-01T10:10:00Z') }, // late (past 300s threshold)
      // Registration 3 has a summary row but never actually attended (is_attended false).
      { registrationId: 3, sessionId: 1, joinedAt: null },
      // Registration 4: no summary row at all — treated the same as never-joined.
    ]);

    const result = await service.getOverallAnalytics(42);

    expect(result.timeline[0].kpis).toEqual({
      totalAttendees: 4,
      present: 2, // registrations 1 and 2
      absent: 2, // registrations 3 and 4
      joinedLate: 1, // registration 2
    });
  });

  it("still counts a session's Joined Late tile and Repeat Latecomers row for a registrant who has since been archived/deleted (on that session's own roster, which includes deleted rows, but no longer in findAllRegistrationsByProgram)", async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
    ]);
    // Only registration 1 is a CURRENT (non-deleted) registration.
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        seatAllocated: true,
      },
    ]);
    // The roster (deliberately deleted-inclusive) still has registration 2, who was
    // archived/deleted AFTER attending this session.
    rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
      { registrationId: 1, fullName: 'Still Registered' },
      { registrationId: 2, fullName: 'Archived-After-Attending' },
    ]);
    onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([1, 2]));
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([
      { registrationId: 1, sessionId: 1, joinedAt: new Date('2020-01-01T10:00:00Z') }, // on-time
      { registrationId: 2, sessionId: 1, joinedAt: new Date('2020-01-01T10:10:00Z') }, // late
    ]);

    const result = await service.getOverallAnalytics(42);

    // The program-wide "Total Attendees"/"Active" KPIs stay scoped to CURRENT registrations
    // only — registration 2 (deleted) must not inflate them.
    expect(result.kpis.totalAttendees).toBe(1);
    expect(result.kpis.active).toBe(1);
    // But the session's own Joined Late tile must still count registration 2's late join —
    // this is the exact bug: it disagreed with that session's own (roster-based) Attendees
    // screen because this tile used to loop over `registrations` (non-deleted only) instead
    // of the roster.
    expect(result.timeline[0].kpis).toEqual({
      totalAttendees: 2,
      present: 2,
      absent: 0,
      joinedLate: 1,
    });
    expect(result.repeatLatecomers.rows).toEqual([
      {
        registrationId: 2,
        seekerName: 'Archived-After-Attending',
        statuses: ['late'],
        lateCount: 1,
        avgLateMinutes: 10,
      },
    ]);
  });

  it("scopes totalAttendees/present/absent to THIS session's own roster, not every program registration", async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
    ]);
    // Total Attendees=3 for the whole program, but registration 3 was never
    // provisioned/active for this particular session — the roster below has only 1 and 2.
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        seatAllocated: true,
      },
      {
        registrationId: 2,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        seatAllocated: true,
      },
      {
        registrationId: 3,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        seatAllocated: true,
      },
    ]);
    rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
      { registrationId: 1 },
      { registrationId: 2 },
    ]);
    onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([1]));
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);

    const result = await service.getOverallAnalytics(42);

    expect(result.kpis.totalAttendees).toBe(3);
    expect(result.timeline[0].kpis).toEqual({
      totalAttendees: 2, // the roster (1, 2) — not the program's 3 registrations
      present: 1, // registration 1
      absent: 1, // registration 2
      joinedLate: 0,
    });
  });

  it("marks a registration eligible for the program's final session only once it attended every one of the sessions before it (an 11-session program's gate is its first 10, not a fixed 11)", async () => {
    const heldSessions = Array.from({ length: 11 }, (_, i) =>
      session(i + 1, `2020-01-0${(i % 9) + 1}T10:00:00Z`, `2020-01-0${(i % 9) + 1}T11:00:00Z`),
    );
    webinarRepository.findByProgramId.mockResolvedValue(heldSessions);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
      {
        registrationId: 2,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
    ]);
    // Registration 1 attended all 10 gate sessions; registration 2 missed session 5.
    onlineAttendanceService.getAttendedRegistrationIds.mockImplementation((sessionId: number) =>
      Promise.resolve(sessionId === 5 ? new Set([1]) : new Set([1, 2])),
    );
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);

    const result = await service.getOverallAnalytics(42);

    expect(result.kpis.thresholdSession).toBe(10);
    expect(result.kpis.eligibleForNextSession).toBe(1);
  });

  it('example from the eligibility rule: a 3-session program gates S3 on attending S1 and S2 — missing either one makes you ineligible', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'), // held
      session(2, '2020-01-02T10:00:00Z', '2020-01-02T11:00:00Z'), // held
      session(3, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'), // upcoming (S3 itself)
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      // Attended both S1 and S2 — eligible for S3.
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
      // Attended S1 but missed S2 — not eligible for S3.
      {
        registrationId: 2,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
    ]);
    onlineAttendanceService.getAttendedRegistrationIds.mockImplementation((sessionId: number) =>
      Promise.resolve(sessionId === 2 ? new Set([1]) : new Set([1, 2])),
    );
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);

    const result = await service.getOverallAnalytics(42);

    expect(result.kpis.thresholdSession).toBe(2);
    expect(result.kpis.eligibleForNextSession).toBe(1);
  });

  it('only calls getAttendedRegistrationIds once per HELD session, never per registration (no N+1)', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
      session(2, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'),
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
      {
        registrationId: 2,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
      {
        registrationId: 3,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
      },
    ]);
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);

    await service.getOverallAnalytics(42);

    expect(onlineAttendanceService.getAttendedRegistrationIds).toHaveBeenCalledTimes(1);
    expect(onlineAttendanceService.getAttendedRegistrationIds).toHaveBeenCalledWith(1);
  });

  it("re-syncs each held session's attendee summaries from the live event log, BEFORE reading them, so joinedLate can't disagree with that session's own (always-live) Attendees screen", async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'), // held
      session(2, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'), // upcoming — no sync needed
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([]);
    const callOrder: string[] = [];
    provider.syncAttendeeSummaries.mockImplementation(() => {
      callOrder.push('sync');
      return Promise.resolve();
    });
    attendeeSummaryRepository.findAllByProgram.mockImplementation(() => {
      callOrder.push('read');
      return Promise.resolve([]);
    });

    await service.getOverallAnalytics(42);

    // Only the held session gets synced — an upcoming session has nothing to sync yet.
    expect(provider.syncAttendeeSummaries).toHaveBeenCalledTimes(1);
    expect(provider.syncAttendeeSummaries).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
    // The sync must complete before the summary table is read, or the read could still see
    // stale data from before this call.
    expect(callOrder).toEqual(['sync', 'read']);
  });

  it("only lists comms-checklist items that actually went out, for upcoming sessions' sessionIds", async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'), // completed
      session(2, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'), // upcoming
      session(3, '2099-02-01T10:00:00Z', '2099-02-01T11:00:00Z'), // upcoming
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([]);
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([]);
    sessionCommunicationService.getTriggeredPurposesForSessions.mockResolvedValue(
      new Map([
        [2, [SessionCommunicationPurposeEnum.INVITE, SessionCommunicationPurposeEnum.VALUE_CARD]],
      ]),
    );

    const result = await service.getOverallAnalytics(42);

    expect(sessionCommunicationService.getTriggeredPurposesForSessions).toHaveBeenCalledWith(
      42,
      [2, 3],
    );
    expect(result.timeline[1]).toMatchObject({
      status: 'upcoming',
      commsChecklist: [
        { purpose: SessionCommunicationPurposeEnum.INVITE, label: 'Invite sent' },
        { purpose: SessionCommunicationPurposeEnum.VALUE_CARD, label: 'Value card sent' },
      ],
    });
    expect(result.timeline[2]).toMatchObject({ status: 'upcoming', commsChecklist: [] });
  });

  it('ranks Repeat Latecomers by lateCount descending, excludes seekers who were never late, and averages only their own late minutes', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
      session(2, '2020-01-02T10:00:00Z', '2020-01-02T11:00:00Z'),
      session(3, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'), // upcoming — no cell for it
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      // Late both sessions — highest lateCount, ranked first.
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        fullName: 'Asha Late-Twice',
      },
      // Late once, absent once — never actually "on time".
      {
        registrationId: 2,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        fullName: 'Ravi Late-Once',
      },
      // Always on time — never late, so excluded from the table entirely.
      {
        registrationId: 3,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        fullName: 'Meera On-Time',
      },
    ]);
    onlineAttendanceService.getAttendedRegistrationIds.mockImplementation((sessionId: number) =>
      Promise.resolve(sessionId === 2 ? new Set([1, 3]) : new Set([1, 2, 3])),
    );
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([
      // Registration 1: late by 10 min in session 1, late by 20 min in session 2.
      { registrationId: 1, sessionId: 1, joinedAt: new Date('2020-01-01T10:10:00Z') },
      { registrationId: 1, sessionId: 2, joinedAt: new Date('2020-01-02T10:20:00Z') },
      // Registration 2: late by 6 min in session 1; absent (not in the attended set) session 2.
      { registrationId: 2, sessionId: 1, joinedAt: new Date('2020-01-01T10:06:00Z') },
      // Registration 3: on time both sessions.
      { registrationId: 3, sessionId: 1, joinedAt: new Date('2020-01-01T10:00:00Z') },
      { registrationId: 3, sessionId: 2, joinedAt: new Date('2020-01-02T10:00:00Z') },
    ]);

    const result = await service.getOverallAnalytics(42);

    expect(result.repeatLatecomers.sessionLabels).toEqual(['S1', 'S2']);
    expect(result.repeatLatecomers.rows).toEqual([
      {
        registrationId: 1,
        seekerName: 'Asha Late-Twice',
        statuses: ['late', 'late'],
        lateCount: 2,
        avgLateMinutes: 15, // mean of 10 and 20
      },
      {
        registrationId: 2,
        seekerName: 'Ravi Late-Once',
        statuses: ['late', 'absent'],
        lateCount: 1,
        avgLateMinutes: 6,
      },
    ]);
  });

  it("keeps a seeker's Repeat Latecomers status as 'late' — not 'absent' — when they joined late but a manual override marked their final attendance absent", async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([
      {
        registrationId: 1,
        registrationStatus: 'COMPLETED',
        activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
        fullName: 'Late-Then-Overridden-Absent',
      },
    ]);
    // Final attendance excludes registration 1 — an RM/Coordinator override marked them
    // NOT present, even though the raw log shows a late join below.
    onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set());
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([
      { registrationId: 1, sessionId: 1, joinedAt: new Date('2020-01-01T10:12:00Z') }, // 12 min late
    ]);

    const result = await service.getOverallAnalytics(42);

    expect(result.repeatLatecomers.rows).toEqual([
      {
        registrationId: 1,
        seekerName: 'Late-Then-Overridden-Absent',
        statuses: ['late'],
        lateCount: 1,
        avgLateMinutes: 12,
      },
    ]);
  });

  it('counts Multiple Logins per session directly from the bulk attendee summaries, one column per HELD session', async () => {
    webinarRepository.findByProgramId.mockResolvedValue([
      session(1, '2020-01-01T10:00:00Z', '2020-01-01T11:00:00Z'),
      session(2, '2099-01-01T10:00:00Z', '2099-01-01T11:00:00Z'), // upcoming — no column
    ]);
    registrationRepository.findAllRegistrationsByProgram.mockResolvedValue([]);
    attendeeSummaryRepository.findAllByProgram.mockResolvedValue([
      { registrationId: 1, sessionId: 1, noOfDevices: 2, joinedAt: null },
      { registrationId: 2, sessionId: 1, noOfDevices: 1, joinedAt: null },
      { registrationId: 3, sessionId: 1, noOfDevices: 3, joinedAt: null },
      // Belongs to the upcoming session — must not be counted.
      { registrationId: 1, sessionId: 2, noOfDevices: 2, joinedAt: null },
    ]);

    const result = await service.getOverallAnalytics(42);

    expect(result.multipleLogins.columns).toEqual([{ sessionId: 1, label: 'S1', count: 2 }]);
  });
});
