import { ZoomWebinarAnalyticsProvider } from './zoom-webinar-analytics.provider';
import { ZoomAnalyticsResourceType } from 'src/common/enum/zoom-analytics-resource-type.enum';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { ZoomLiveEventType } from 'src/common/enum/zoom-live-event-type.enum';
import { AttendanceStatus } from 'src/common/enum/attendance-status.enum';
import { AttendanceSourceEnum } from 'src/common/enum/attendance-source.enum';
import { SessionKpiFilter } from 'src/common/enum/session-kpi.enum';
import { ZOOM_ANALYTICS_DROP_OFF_REASONS } from 'src/zoom/constants/zoom-analytics.constants';

describe('ZoomWebinarAnalyticsProvider', () => {
  const registry = { register: jest.fn() };
  const webinarRepository = {
    findByExtId: jest.fn(),
    findByExtIdForOccurrence: jest.fn(),
    save: jest.fn(),
  };
  const reportApiClient = { fetchWebinarReport: jest.fn() };
  const rosterRepository = { findPanelistRosterByOnlineSession: jest.fn() };
  const sessionSummaryRepository = { upsert: jest.fn(), findBySessionId: jest.fn() };
  const attendeeSummaryRepository = {
    upsertMany: jest.fn(),
    listBySession: jest.fn(),
    findAllBySessionId: jest.fn(),
  };
  const liveEventRepository = {
    create: jest.fn((data) => data),
    insert: jest.fn(),
    findAllByOnlineSessionId: jest.fn(),
  };
  const config = { getLateJoinThresholdSeconds: jest.fn().mockReturnValue(300) };
  const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
  const onlineAttendanceService = {
    getManualMarksBySession: jest.fn(),
    getAttendedRegistrationIds: jest.fn(),
    recordZoomJoin: jest.fn(),
  };
  const registrationRepository = {
    findActiveExtensionJoinUrls: jest.fn(),
    findActivationStatusesByIds: jest.fn(),
    findProfileImagesByIds: jest.fn(),
    findRmContactsByIds: jest.fn(),
  };
  const generatedLinkRepository = { findActiveMapBySession: jest.fn() };

  let provider: ZoomWebinarAnalyticsProvider;

  const onlineSession: any = {
    id: 70,
    externalId: 'ext-webinar-1',
    occurrenceId: null,
    actualMeetingEndsAt: null,
  };
  const session = {
    id: 7,
    programId: 3,
    onlineType: OnlineTypeEnum.WEBINAR,
    onlineSession,
    startsAt: new Date('2026-07-08T10:00:00.000Z'),
  } as any;

  beforeEach(() => {
    jest.clearAllMocks();
    sessionSummaryRepository.findBySessionId.mockResolvedValue(null);
    config.getLateJoinThresholdSeconds.mockReturnValue(300);
    onlineAttendanceService.getManualMarksBySession.mockResolvedValue([]);
    onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set());
    registrationRepository.findActiveExtensionJoinUrls.mockResolvedValue(new Map());
    registrationRepository.findActivationStatusesByIds.mockResolvedValue(new Map());
    registrationRepository.findProfileImagesByIds.mockResolvedValue(new Map());
    registrationRepository.findRmContactsByIds.mockResolvedValue(new Map());
    attendeeSummaryRepository.findAllBySessionId.mockResolvedValue([]);
    generatedLinkRepository.findActiveMapBySession.mockResolvedValue(new Map());
    webinarRepository.save.mockResolvedValue(session);
    // markEnded mutates this in place — reset before every test so one test's write
    // can't leak into the next (the mock object is shared, not re-created per test).
    onlineSession.actualMeetingEndsAt = null;
    provider = new ZoomWebinarAnalyticsProvider(
      registry as any,
      webinarRepository as any,
      reportApiClient as any,
      rosterRepository as any,
      sessionSummaryRepository as any,
      attendeeSummaryRepository as any,
      liveEventRepository as any,
      config as any,
      logger as any,
      onlineAttendanceService as any,
      registrationRepository as any,
      generatedLinkRepository as any,
    );
  });

  it('self-registers under WEBINAR on module init', () => {
    provider.onModuleInit();
    expect(registry.register).toHaveBeenCalledWith(ZoomAnalyticsResourceType.WEBINAR, provider);
  });

  describe('reconcile', () => {
    const webinarStart = '2026-07-08T10:00:00.000Z';

    beforeEach(() => {
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
    });

    it('reconciles attendee data from the live event log even when the Report API has no data yet (not generated)', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue(null);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'joined+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00.000Z'),
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Joined Seeker',
          email: 'joined@x.com',
          mobile: '111',
        },
      ]);

      await provider.reconcile(session);

      const attendeeUpserts = attendeeSummaryRepository.upsertMany.mock.calls[0][0];
      expect(attendeeUpserts[0].joinedAt).toEqual(new Date('2026-07-08T10:01:00.000Z'));

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.totalJoined).toBe(1);
      // Not stamped reconciled yet — the hourly cron keeps retrying until the Report API confirms start/duration.
      expect(summary.reconciledAt).toBeUndefined();
    });

    it('stamps reconciledAt once the Report API confirms start time and duration', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.reconciledAt).toBeInstanceOf(Date);
      expect(summary.actualStartAt).toEqual(new Date(webinarStart));
      expect(summary.actualDurationMinutes).toBe(60);
    });

    it('computes "not joined" as roster minus who has ever joined — from the live event log', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'joined+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00.000Z'),
        },
        {
          email: 'joined+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:59:00.000Z'),
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Joined Seeker',
          email: 'joined@x.com',
          mobile: '111',
        },
        {
          registrationId: 2,
          userId: 12,
          fullName: 'Absent Seeker',
          email: 'absent@x.com',
          mobile: '222',
        },
      ]);

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.totalPanelists).toBe(2);
      expect(summary.totalJoined).toBe(1);
      expect(summary.notJoined).toBe(1);

      const attendeeUpserts = attendeeSummaryRepository.upsertMany.mock.calls[0][0];
      const absentUpsert = attendeeUpserts.find((u) => u.email === 'absent@x.com');
      expect(absentUpsert.joinedAt).toBeNull();
      expect(absentUpsert.isSystemAttended).toBe(false);
    });

    it("matches Zoom's reported +reg<registrationId> address to the roster instead of the seeker's own registered email", async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'sahithi.gundam+reg7600@divami.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00.000Z'),
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 7600,
          userId: 21,
          fullName: 'Sahithi',
          email: 'sahithi.gundam@divami.com',
          mobile: '111',
        },
      ]);

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.totalJoined).toBe(1);
      expect(summary.notJoined).toBe(0);

      const attendeeUpserts = attendeeSummaryRepository.upsertMany.mock.calls[0][0];
      expect(attendeeUpserts[0].joinedAt).toEqual(new Date('2026-07-08T10:01:00.000Z'));
      expect(attendeeUpserts[0].isSystemAttended).toBe(true);
    });

    it('flags a late joiner past the threshold and a normal joiner as on-time', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Joins 10 minutes after start — past the 5-minute late threshold.
        {
          email: 'late+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:10:00.000Z'),
        },
        {
          email: 'late+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T11:00:00.000Z'),
        },
        {
          email: 'ontime+reg2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:30.000Z'),
        },
        {
          email: 'ontime+reg2@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T11:00:00.000Z'),
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 11, fullName: 'Late', email: 'late@x.com', mobile: '111' },
        {
          registrationId: 2,
          userId: 12,
          fullName: 'On time',
          email: 'ontime@x.com',
          mobile: '222',
        },
      ]);

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.joinedLate).toBe(1);
    });

    it('flags a seeker who left well before the webinar ended as dropped, and records lastDropoffAt/duration from the event log', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Leaves 10 minutes before the webinar's actual end (10:00 + 60min = 11:00).
        {
          email: 'dropped+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00.000Z'),
        },
        {
          email: 'dropped+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:50:00.000Z'),
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Dropped',
          email: 'dropped@x.com',
          mobile: '111',
        },
      ]);

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.dropped).toBe(1);

      const attendeeUpserts = attendeeSummaryRepository.upsertMany.mock.calls[0][0];
      expect(attendeeUpserts[0].dropoffCount).toBe(1);
      expect(attendeeUpserts[0].lastDropoffAt).toEqual(new Date('2026-07-08T10:50:00.000Z'));
      expect(attendeeUpserts[0].durationSeconds).toBe(49 * 60); // 10:01 to 10:50
    });

    it('does not count staying connected until the webinar ends as a drop-off, even though the closing LEFT event still sets is_system_attended history', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'stayed+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00.000Z'),
        },
        // Leaves exactly when the webinar ends (10:00 + 60min = 11:00), reason from the ended webhook.
        {
          email: 'stayed+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T11:00:00.000Z'),
          leaveReason: ZOOM_ANALYTICS_DROP_OFF_REASONS.HOST_ENDED,
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Stayed',
          email: 'stayed@x.com',
          mobile: '111',
        },
      ]);

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.dropped).toBe(0);

      const attendeeUpserts = attendeeSummaryRepository.upsertMany.mock.calls[0][0];
      expect(attendeeUpserts[0].dropoffCount).toBe(0);
      expect(attendeeUpserts[0].lastDropoffAt).toBeNull();
    });

    it('counts a seeker who joined, left, and rejoined as both rejoined and dropped, since DROPOFF_GRACE_SECONDS is 0 — any leave before the exact session end counts', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'rejoined+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00.000Z'),
        },
        {
          email: 'rejoined+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00.000Z'),
        },
        {
          email: 'rejoined+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:25:00.000Z'),
        },
        {
          email: 'rejoined+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:59:30.000Z'),
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Rejoined',
          email: 'rejoined@x.com',
          mobile: '111',
        },
      ]);

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.rejoined).toBe(1);
      expect(summary.dropped).toBe(1);

      const attendeeUpserts = attendeeSummaryRepository.upsertMany.mock.calls[0][0];
      expect(attendeeUpserts[0].rejoinCount).toBe(1);
      expect(attendeeUpserts[0].joinedAt).toEqual(new Date('2026-07-08T10:01:00.000Z'));
      expect(attendeeUpserts[0].lastRejoinedAt).toEqual(new Date('2026-07-08T10:25:00.000Z'));
    });

    it('matches the +reg<registrationId> tag even with surrounding whitespace on the reported email', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: '  Mixed.Case+reg1@X.com ',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00.000Z'),
        },
        {
          email: '  Mixed.Case+reg1@X.com ',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T11:00:00.000Z'),
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Mixed',
          email: 'mixed.case@x.com',
          mobile: '111',
        },
      ]);

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.totalJoined).toBe(1);
      expect(summary.notJoined).toBe(0);
    });

    it('does not attribute a join event with no parseable +reg<registrationId> tag to anyone, even if the raw email matches a roster seeker', async () => {
      reportApiClient.fetchWebinarReport.mockResolvedValue({
        start_time: webinarStart,
        duration: 60,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'joined@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00.000Z'),
        },
      ]);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Joined Seeker',
          email: 'joined@x.com',
          mobile: '111',
        },
      ]);

      await provider.reconcile(session);

      const summary = sessionSummaryRepository.upsert.mock.calls[0][0];
      expect(summary.totalJoined).toBe(0);
      expect(summary.notJoined).toBe(1);
    });
  });

  describe('getKpis', () => {
    // session.startsAt = 2026-07-08T10:00:00.000Z in every case below.
    const roster = [
      {
        registrationId: 1,
        userId: 11,
        fullName: 'Currently Joined',
        email: 'joined@x.com',
        mobile: '111',
      },
      {
        registrationId: 2,
        userId: 12,
        fullName: 'Never Showed',
        email: 'absent@x.com',
        mobile: '222',
      },
      {
        registrationId: 3,
        userId: 13,
        fullName: 'Dropped Off',
        email: 'dropped@x.com',
        mobile: '333',
      },
      {
        registrationId: 4,
        userId: 14,
        fullName: 'Rejoined',
        email: 'rejoined@x.com',
        mobile: '444',
      },
      {
        registrationId: 5,
        userId: 15,
        fullName: 'Late Joiner',
        email: 'late@x.com',
        mobile: '555',
      },
    ];

    beforeEach(() => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue(roster);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // still joined right now — on time
        {
          email: 'joined+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        // joined once, left, never came back
        {
          email: 'dropped+reg3@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'dropped+reg3@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00Z'),
        },
        // joined, left, joined again — currently back in
        {
          email: 'rejoined+reg4@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'rejoined+reg4@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00Z'),
        },
        {
          email: 'rejoined+reg4@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:25:00Z'),
        },
        // joined 10 minutes after start — past the 5-minute late threshold
        {
          email: 'late+reg5@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:10:00Z'),
        },
        // a non-panelist showing up (and with no +reg tag at all) must never count toward any seeker KPI
        {
          email: 'gatecrasher@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
      ]);
    });

    it('computes all 8 KPIs live from the roster + event log, not from the summary row', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualDurationMinutes: 60,
        reconciledAt: null,
        actualEndAt: null,
      });

      const kpis = await provider.getKpis(session);

      expect(kpis.sessionId).toBe(7);
      expect(kpis.durationMinutes).toBe(60); // the one field still sourced from the summary row
      expect(kpis.totalPanelists).toBe(5);
      // Joined at any point, ever — not "currently connected": joined@x.com, dropped@x.com,
      // rejoined@x.com, late@x.com all showed up at least once. Only absent@x.com never did.
      expect(kpis.totalSeekersJoined).toBe(4);
      expect(kpis.seekersNotJoined).toBe(1);
      expect(kpis.seekersJoinedLate).toBe(1); // late@x.com
      expect(kpis.seekersDropped).toBe(1); // dropped@x.com
      expect(kpis.seekersRejoined).toBe(1); // rejoined@x.com
    });

    it('ignores a non-panelist join event entirely — it never counts toward totalSeekersJoined or notJoined', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);

      const kpis = await provider.getKpis(session);

      // gatecrasher@x.com isn't one of the 5 roster members, so it can't shrink notJoined or grow totalSeekersJoined.
      expect(kpis.seekersNotJoined).toBe(1);
      expect(kpis.totalPanelists).toBe(5);
    });

    it('keeps totalSeekersJoined at the "ever joined" count once the webinar has ended, not just currently-connected', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualDurationMinutes: 60,
        reconciledAt: null,
        actualEndAt: new Date('2026-07-08T11:00:00Z'),
      });
      // Zoom disconnects everyone at the natural end (joined@x.com, rejoined@x.com already show LEFT;
      // late@x.com's LEFT webhook just hasn't arrived yet, so it's still "open"). None of that should
      // shrink totalSeekersJoined — it counts who showed up, ever, not who's connected this instant.
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'joined+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'joined+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T11:00:05Z'),
        },
        {
          email: 'dropped+reg3@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'dropped+reg3@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00Z'),
        },
        {
          email: 'rejoined+reg4@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'rejoined+reg4@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T11:00:05Z'),
        },
        {
          email: 'late+reg5@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:10:00Z'),
        },
      ]);

      const kpis = await provider.getKpis(session);

      // joined, dropped, rejoined, late all showed up at least once — the same 4 as while the webinar was live.
      expect(kpis.totalSeekersJoined).toBe(4);
      expect(kpis.seekersNotJoined).toBe(1);
    });

    it("matches Zoom's reported +reg<registrationId> address to the roster, even though it differs from the seeker's own registered email", async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 7600,
          userId: 21,
          fullName: 'Sahithi',
          email: 'sahithi.gundam@divami.com',
          mobile: '111',
        },
      ]);
      // Zoom reports back the derived registrant address, not the seeker's own email on file.
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'sahithi.gundam+reg7600@divami.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
      ]);
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);

      const kpis = await provider.getKpis(session);

      expect(kpis.totalSeekersJoined).toBe(1);
      expect(kpis.seekersNotJoined).toBe(0);
    });

    it('falls back to session.startsAt for startTime and defaults duration/reconciledAt to null before any reconcile', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);

      const kpis = await provider.getKpis(session);

      expect(kpis.startTime).toEqual(session.startsAt);
      expect(kpis.durationMinutes).toBeNull();
      expect(kpis.reconciledAt).toBeNull();
    });

    it('returns all zeros when the session has no online session at all', async () => {
      const bareSession = { id: 9, startsAt: session.startsAt, onlineSession: null } as any;
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);

      const kpis = await provider.getKpis(bareSession);

      expect(kpis.totalPanelists).toBe(0);
      expect(kpis.totalSeekersJoined).toBe(0);
      expect(rosterRepository.findPanelistRosterByOnlineSession).not.toHaveBeenCalled();
    });

    it('moves a seeker from dropped to rejoined the moment they come back, and counts them as currently joined too', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Comeback',
          email: 'comeback@x.com',
          mobile: '111',
        },
      ]);
      // Joined, left (would be "dropped" in isolation), then joined again and is still in.
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'comeback+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'comeback+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00Z'),
        },
        {
          email: 'comeback+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:25:00Z'),
        },
      ]);
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);

      const kpis = await provider.getKpis(session);

      expect(kpis.seekersDropped).toBe(0); // not dropped — they came back
      expect(kpis.seekersRejoined).toBe(1);
      expect(kpis.totalSeekersJoined).toBe(1); // joined at least once, counted regardless of current status
      expect(kpis.seekersNotJoined).toBe(0);
    });

    it('counts a seeker as BOTH dropped and rejoined when they rejoin and then go offline again', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Flaky',
          email: 'flaky@x.com',
          mobile: '111',
        },
      ]);
      // Joined, left, rejoined, left again — currently offline, but has rejoined at least once ever.
      // dropped and rejoined are independent flags, not a single mutually-exclusive bucket.
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'flaky+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'flaky+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00Z'),
        },
        {
          email: 'flaky+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:25:00Z'),
        },
        {
          email: 'flaky+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:40:00Z'),
        },
      ]);
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);

      const kpis = await provider.getKpis(session);

      expect(kpis.seekersDropped).toBe(1); // currently offline
      expect(kpis.seekersRejoined).toBe(1); // came back at least once, ever
      expect(kpis.totalSeekersJoined).toBe(1);
    });

    it('uses the configurable late-join threshold instead of a hardcoded value', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Borderline',
          email: 'borderline@x.com',
          mobile: '111',
        },
      ]);
      // Joins 2 minutes after start.
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'borderline+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:02:00Z'),
        },
      ]);
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);

      config.getLateJoinThresholdSeconds.mockReturnValue(300); // 5 min — 2 min join is on time
      expect((await provider.getKpis(session)).seekersJoinedLate).toBe(0);

      config.getLateJoinThresholdSeconds.mockReturnValue(60); // 1 min — 2 min join is now late
      expect((await provider.getKpis(session)).seekersJoinedLate).toBe(1);
    });
  });

  describe('getGeneralKpis', () => {
    // session.startsAt = 2026-07-08T10:00:00.000Z, late threshold 300s (5 min), same as getKpis above.
    beforeEach(() => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // on time, still joined right now
        {
          email: 'general1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        // joined 10 minutes in (past the 5-minute threshold), left, never came back
        {
          email: 'general2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:10:00Z'),
        },
        {
          email: 'general2@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00Z'),
        },
        // joined, left, joined again — currently back in
        {
          email: 'general3@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'general3@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:05:00Z'),
        },
        {
          email: 'general3@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:06:00Z'),
        },
        // a registered seeker's own event — must never count toward any general figure
        {
          email: 'seeker+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
      ]);
    });

    it('counts general (no +reg tag) attendees only, computing joinedLate/dropped/rejoined the same way getKpis does', async () => {
      const kpis = await provider.getGeneralKpis(session);

      expect(kpis.sessionId).toBe(7);
      // general1, general2, general3 — the registered seeker's event never counts toward this.
      expect(kpis.totalGeneralAttendees).toBe(3);
      expect(kpis.knownAttendees).toBe(0); // no zoom_generated_registrant_link rows mocked for this session
      expect(kpis.unknownAttendees).toBe(3);
      expect(kpis.generalLoggedIn).toBe(3); // everyone unknown counts as logged in — they only exist because they joined
      expect(kpis.generalJoinedLate).toBe(1); // general2
      expect(kpis.generalDropped).toBe(1); // general2 — left and never came back
      expect(kpis.generalRejoined).toBe(1); // general3 — left, then rejoined
    });

    it('returns all zeroes when the session has no online resource provisioned yet', async () => {
      const bareSession = { id: 9, startsAt: session.startsAt } as any;

      const kpis = await provider.getGeneralKpis(bareSession);

      expect(kpis).toEqual({
        sessionId: 9,
        totalGeneralAttendees: 0,
        knownAttendees: 0,
        unknownAttendees: 0,
        generalLoggedIn: 0,
        generalJoinedLate: 0,
        generalDropped: 0,
        generalRejoined: 0,
      });
      expect(liveEventRepository.findAllByOnlineSessionId).not.toHaveBeenCalled();
    });
  });

  describe('getAttendeeTable', () => {
    const query = { page: 1, limit: 20 } as any;

    beforeEach(() => {
      attendeeSummaryRepository.listBySession.mockResolvedValue({ data: [], total: 0 });
    });

    it('syncs every roster member from the live event log before reading — no reconcile() required first', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 11, fullName: 'Joined', email: 'joined@x.com', mobile: '111' },
        { registrationId: 2, userId: 12, fullName: 'Absent', email: 'absent@x.com', mobile: '222' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'joined+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
        {
          email: 'joined+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00Z'),
        },
      ]);

      await provider.getAttendeeTable(session, query);

      expect(attendeeSummaryRepository.upsertMany).toHaveBeenCalledTimes(1);
      const attendeeUpserts = attendeeSummaryRepository.upsertMany.mock.calls[0][0];
      expect(attendeeUpserts).toHaveLength(2);
      const joinedUpsert = attendeeUpserts.find((u) => u.email === 'joined@x.com');
      expect(joinedUpsert.joinedAt).toEqual(new Date('2026-07-08T10:01:00Z'));
      expect(joinedUpsert.lastDropoffAt).toEqual(new Date('2026-07-08T10:20:00Z'));
      expect(joinedUpsert.isSystemAttended).toBe(true);
      // No reconciledAt in the live-sync payload — that stamp belongs only to reconcile()'s Report-API-backed run.
      expect(joinedUpsert.reconciledAt).toBeUndefined();

      const absentUpsert = attendeeUpserts.find((u) => u.email === 'absent@x.com');
      expect(absentUpsert.joinedAt).toBeNull();
      expect(absentUpsert.isSystemAttended).toBe(false);

      expect(attendeeSummaryRepository.listBySession).toHaveBeenCalledWith(session.id, query);
    });

    it("syncs a seeker whose join event carries Zoom's derived +reg<registrationId> address, not their registered email", async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 7600,
          userId: 21,
          fullName: 'Sahithi',
          email: 'sahithi.gundam@divami.com',
          mobile: '111',
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'sahithi.gundam+reg7600@divami.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
        },
      ]);

      await provider.getAttendeeTable(session, query);

      const upsert = attendeeSummaryRepository.upsertMany.mock.calls[0][0][0];
      expect(upsert.joinedAt).toEqual(new Date('2026-07-08T10:01:00Z'));
      expect(upsert.isSystemAttended).toBe(true);
    });

    it('counts distinct zoomParticipantIds across JOINED events as noOfDevices — same link, different devices', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Shared Link',
          email: 'shared@x.com',
          mobile: '111',
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'shared+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
          zoomParticipantId: 'device-a',
        },
        {
          email: 'shared+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:01:00Z'),
          zoomParticipantId: 'device-b',
        },
      ]);

      await provider.getAttendeeTable(session, query);

      expect(attendeeSummaryRepository.upsertMany.mock.calls[0][0][0].noOfDevices).toBe(2);
    });

    it('falls back to 1 device when a seeker joined but no event carries a participant id', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 11, fullName: 'No Id', email: 'noid@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'noid+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
        },
      ]);

      await provider.getAttendeeTable(session, query);

      expect(attendeeSummaryRepository.upsertMany.mock.calls[0][0][0].noOfDevices).toBe(1);
    });

    it('detects 2 concurrent devices from JOIN/LEFT interleaving alone, with no participant id on either event — the common case for unauthenticated registrants', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 11, fullName: 'Guest', email: 'guest@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Two JOINs with no LEFT in between — proves 2 connections were open, even with no ids at all.
        {
          email: 'guest+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
        },
        {
          email: 'guest+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00Z'),
        },
      ]);

      await provider.getAttendeeTable(session, query);

      expect(attendeeSummaryRepository.upsertMany.mock.calls[0][0][0].noOfDevices).toBe(2);
    });

    it('reports 0 devices for a roster member who never joined', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 2,
          userId: 12,
          fullName: 'Absent',
          email: 'absent2@x.com',
          mobile: '222',
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);

      await provider.getAttendeeTable(session, query);

      expect(attendeeSummaryRepository.upsertMany.mock.calls[0][0][0].noOfDevices).toBe(0);
    });

    it('does not count a second concurrent device leaving as a drop-off while the first device is still connected', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Concurrent',
          email: 'concurrent@x.com',
          mobile: '111',
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
          zoomParticipantId: 'device-a',
        },
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00Z'),
          zoomParticipantId: 'device-b',
        },
        // Device A leaves — device B is still connected, so this must NOT register as a full drop-off.
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:10:00Z'),
          zoomParticipantId: 'device-a',
        },
      ]);

      await provider.getAttendeeTable(session, query);

      const upsert = attendeeSummaryRepository.upsertMany.mock.calls[0][0][0];
      expect(upsert.noOfDevices).toBe(2);
      expect(upsert.dropoffCount).toBe(0);
      expect(upsert.rejoinCount).toBe(0);
      expect(upsert.lastDropoffAt).toBeNull();
    });

    it('registers a full drop-off, and a later rejoin, once every device has disconnected', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Concurrent',
          email: 'concurrent@x.com',
          mobile: '111',
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
          zoomParticipantId: 'device-a',
        },
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00Z'),
          zoomParticipantId: 'device-b',
        },
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:10:00Z'),
          zoomParticipantId: 'device-a',
        },
        // Device B (the last one open) leaves — this IS a full drop-off.
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:15:00Z'),
          zoomParticipantId: 'device-b',
        },
        // Rejoins on a single device afterwards.
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:20:00Z'),
          zoomParticipantId: 'device-c',
        },
      ]);

      await provider.getAttendeeTable(session, query);

      const upsert = attendeeSummaryRepository.upsertMany.mock.calls[0][0][0];
      expect(upsert.dropoffCount).toBe(1);
      expect(upsert.rejoinCount).toBe(1);
      expect(upsert.lastRejoinedAt).toEqual(new Date('2026-07-08T10:20:00Z'));
    });

    it('unions overlapping concurrent-device time rather than summing it for durationSeconds', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 11,
          fullName: 'Concurrent',
          email: 'concurrent@x.com',
          mobile: '111',
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Device A: 10:00–10:10 (10 min). Device B: 10:02–10:08 (fully inside A's window).
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
          zoomParticipantId: 'device-a',
        },
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:02:00Z'),
          zoomParticipantId: 'device-b',
        },
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:08:00Z'),
          zoomParticipantId: 'device-b',
        },
        {
          email: 'concurrent+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:10:00Z'),
          zoomParticipantId: 'device-a',
        },
      ]);

      await provider.getAttendeeTable(session, query);

      // Union of presence is exactly 10 minutes (10:00–10:10), not 16 (10 + 6 summed per device).
      expect(attendeeSummaryRepository.upsertMany.mock.calls[0][0][0].durationSeconds).toBe(600);
    });

    it('excludes presence before the session started from durationSeconds, reporting it as preSessionDurationSeconds instead', async () => {
      // session.startsAt = 2026-07-08T10:00:00.000Z
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 11, fullName: 'Early', email: 'early@x.com', mobile: '111' },
        {
          registrationId: 2,
          userId: 12,
          fullName: 'BeforeStart',
          email: 'before@x.com',
          mobile: '222',
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Joined 10 min early, left 15 min after start: 10 min pre-session, 15 min counted.
        {
          email: 'early+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T09:50:00Z'),
        },
        {
          email: 'early+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:15:00Z'),
        },
        // Joined and left entirely before the session started: all pre-session, nothing counted.
        {
          email: 'before+reg2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T09:40:00Z'),
        },
        {
          email: 'before+reg2@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T09:55:00Z'),
        },
      ]);

      await provider.getAttendeeTable(session, query);

      const attendeeUpserts = attendeeSummaryRepository.upsertMany.mock.calls[0][0];
      const earlyUpsert = attendeeUpserts.find((u) => u.email === 'early@x.com');
      expect(earlyUpsert.durationSeconds).toBe(15 * 60);
      expect(earlyUpsert.preSessionDurationSeconds).toBe(10 * 60);

      const beforeUpsert = attendeeUpserts.find((u) => u.email === 'before@x.com');
      expect(beforeUpsert.durationSeconds).toBe(0);
      expect(beforeUpsert.preSessionDurationSeconds).toBe(15 * 60);
    });

    it('maps the paginated summary rows into the response shape, including durationSeconds', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      attendeeSummaryRepository.listBySession.mockResolvedValue({
        data: [
          {
            id: 501,
            userId: 88,
            registrationId: 501,
            fullName: 'Ram Kumar',
            email: 'ram@x.com',
            mobile: '9876543210',
            joinedAt: new Date('2026-07-08T10:01:00Z'),
            noOfDevices: null,
            dropoffCount: 1,
            rejoinCount: 1,
            lastDropoffAt: null,
            lastRejoinedAt: new Date('2026-07-08T10:25:00Z'),
            durationSeconds: 300,
            isSystemAttended: true,
          },
        ],
        total: 1,
      });

      const result = await provider.getAttendeeTable(session, query);

      expect(result.data).toEqual([
        {
          attendeeId: 501,
          userId: 88,
          registrationId: 501,
          fullName: 'Ram Kumar',
          zoomDisplayName: undefined,
          email: 'ram@x.com',
          mobile: '9876543210',
          rmContact: null,
          profileImage: null,
          joinedAt: new Date('2026-07-08T10:01:00Z'),
          noOfDevices: null,
          dropoffCount: 1,
          rejoinCount: 1,
          lastDropoffAt: null,
          lastRejoinedAt: new Date('2026-07-08T10:25:00Z'),
          durationSeconds: 300,
          preSessionDurationSeconds: undefined,
          activationStatus: null,
          attendance: { system: true, rm: null, coordinator: null, final: null },
          joinUrl: null,
          sourceTag: null,
          generatedLinkId: null,
        },
      ]);
      expect(result.total).toBe(1);
      expect(result.page).toBe(1);
      expect(result.limit).toBe(20);
    });

    it("merges RM/Coordinator marks from OnlineAttendanceService, keyed by the row's registrationId — not stored on the summary row itself", async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      attendeeSummaryRepository.listBySession.mockResolvedValue({
        data: [
          {
            id: 501,
            userId: 88,
            registrationId: 501,
            fullName: 'Ram Kumar',
            email: 'ram@x.com',
            mobile: '9876543210',
            joinedAt: null,
            noOfDevices: null,
            dropoffCount: 0,
            rejoinCount: 0,
            lastDropoffAt: null,
            lastRejoinedAt: null,
            durationSeconds: 0,
            isSystemAttended: false,
          },
        ],
        total: 1,
      });
      onlineAttendanceService.getManualMarksBySession.mockResolvedValue([
        {
          registrationId: 501,
          rm: true,
          coordinator: false,
          attendanceStatus: AttendanceStatus.ABSENT,
          decidedBySource: AttendanceSourceEnum.MANUAL_COORDINATOR,
        },
      ]);

      const result = await provider.getAttendeeTable(session, query);

      expect(onlineAttendanceService.getManualMarksBySession).toHaveBeenCalledWith(session.id);
      // Coordinator's ABSENT outranks RM's own PRESENT mark for the resolved "Final" status.
      expect(result.data[0].attendance).toEqual({
        system: false,
        rm: true,
        coordinator: false,
        final: false,
      });
    });

    it('skips the sync (and does not touch the roster/event repositories) when there is no online session', async () => {
      const bareSession = { id: 9, startsAt: session.startsAt, onlineSession: null } as any;

      await provider.getAttendeeTable(bareSession, query);

      expect(rosterRepository.findPanelistRosterByOnlineSession).not.toHaveBeenCalled();
      expect(liveEventRepository.findAllByOnlineSessionId).not.toHaveBeenCalled();
      expect(attendeeSummaryRepository.listBySession).toHaveBeenCalledWith(9, query);
    });

    it("fills each row's joinUrl from the registrant's active extension for this online session", async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      attendeeSummaryRepository.listBySession.mockResolvedValue({
        data: [
          {
            id: 501,
            userId: 88,
            registrationId: 501,
            fullName: 'Ram Kumar',
            email: 'ram@x.com',
            mobile: '9876543210',
            joinedAt: null,
            noOfDevices: null,
            dropoffCount: 0,
            rejoinCount: 0,
            lastDropoffAt: null,
            lastRejoinedAt: null,
            durationSeconds: 0,
            isSystemAttended: false,
          },
        ],
        total: 1,
      });
      registrationRepository.findActiveExtensionJoinUrls.mockResolvedValue(
        new Map([['501', { joinUrl: 'https://zoom.us/j/501', activationStatus: 'inactive' }]]),
      );
      // activationStatus on the row comes from the registration-level rollup, NOT the session
      // extension above — the extension's 'inactive' must be ignored in favour of this 'active'.
      registrationRepository.findActivationStatusesByIds.mockResolvedValue(
        new Map([['501', 'active']]),
      );

      const result = await provider.getAttendeeTable(session, query);

      expect(registrationRepository.findActiveExtensionJoinUrls).toHaveBeenCalledWith([501], 70);
      expect(registrationRepository.findActivationStatusesByIds).toHaveBeenCalledWith([501]);
      expect(result.data[0].joinUrl).toBe('https://zoom.us/j/501');
      expect(result.data[0].activationStatus).toBe('active');
    });

    it("for a known general row, shows the matched generated link's real sourceEmail/sourceMobile — never Zoom's own tagged-email report, which has no phone number at all", async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      attendeeSummaryRepository.listBySession.mockResolvedValue({
        data: [
          {
            id: 601,
            userId: 9562,
            registrationId: null,
            fullName: null,
            email: 'vinod+gen9562p1474@yopmail.com',
            mobile: null,
            joinedAt: null,
            noOfDevices: 0,
            dropoffCount: 0,
            rejoinCount: 0,
            lastDropoffAt: null,
            lastRejoinedAt: null,
            durationSeconds: 0,
            isSystemAttended: false,
          },
        ],
        total: 1,
      });
      generatedLinkRepository.findActiveMapBySession.mockResolvedValue(
        new Map([
          [
            'vinod+gen9562p1474@yopmail.com',
            {
              id: 777,
              displayName: 'Vinod',
              sourceType: 'ROLE',
              roleKey: 'ROLE_RELATIONAL_MANAGER',
              sourceEmail: 'vinod@yopmail.com',
              sourceMobile: '+919999999999',
              joinUrl: 'https://zoom.us/w/93168575436',
            },
          ],
        ]),
      );

      const result = await provider.getAttendeeTable(session, query);

      expect(result.data[0].email).toBe('vinod@yopmail.com');
      expect(result.data[0].mobile).toBe('+919999999999');
      expect(result.data[0].sourceTag).toEqual({
        sourceType: 'ROLE',
        label: 'ROLE_RELATIONAL_MANAGER',
      });
      // The matched generated-link row's own id — what
      // /session-communication/general-link/single takes as `generatedLinkId`.
      expect(result.data[0].generatedLinkId).toBe(777);
    });

    it('pre-resolves matching registration ids from manual marks and narrows listBySession when a final filter is active — so pagination/total stay correct, not just the current page', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      onlineAttendanceService.getManualMarksBySession.mockResolvedValue([
        {
          registrationId: 501,
          rm: true,
          coordinator: null,
          attendanceStatus: AttendanceStatus.PRESENT,
          decidedBySource: AttendanceSourceEnum.MANUAL_RM,
        },
        {
          registrationId: 502,
          rm: null,
          coordinator: false,
          attendanceStatus: AttendanceStatus.ABSENT,
          decidedBySource: AttendanceSourceEnum.MANUAL_COORDINATOR,
        },
      ]);
      attendeeSummaryRepository.listBySession.mockResolvedValue({ data: [], total: 0 });

      await provider.getAttendeeTable(session, {
        ...query,
        finalAttendance: AttendanceStatus.PRESENT,
      });

      // Only registrant 501 resolved to PRESENT — the DB query is narrowed to exactly that id, not
      // filtered client-side after an unnarrowed page fetch.
      expect(attendeeSummaryRepository.listBySession).toHaveBeenCalledWith(
        session.id,
        expect.objectContaining({ registrationIds: [501] }),
      );
    });

    it('short-circuits to no results without narrowing anything when no registrant matches the source-state filter', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      onlineAttendanceService.getManualMarksBySession.mockResolvedValue([
        {
          registrationId: 501,
          rm: true,
          coordinator: null,
          attendanceStatus: AttendanceStatus.PRESENT,
          decidedBySource: AttendanceSourceEnum.MANUAL_RM,
        },
      ]);
      attendeeSummaryRepository.listBySession.mockResolvedValue({ data: [], total: 0 });

      await provider.getAttendeeTable(session, {
        ...query,
        coordinatorAttendance: AttendanceStatus.PRESENT,
      });

      expect(attendeeSummaryRepository.listBySession).toHaveBeenCalledWith(
        session.id,
        expect.objectContaining({ registrationIds: [] }),
      );
    });

    it('passes systemAttendance straight through without the manual-marks detour — it is a plain column on this table, not a cross-table lookup', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      attendeeSummaryRepository.listBySession.mockResolvedValue({ data: [], total: 0 });

      await provider.getAttendeeTable(session, {
        ...query,
        systemAttendance: AttendanceStatus.PRESENT,
      });

      // Still fetched (needed to render the System/RM/Coordinator/Final columns), but NOT awaited
      // before listBySession — no registrationIds pre-filtering happens for systemAttendance alone.
      expect(onlineAttendanceService.getManualMarksBySession).toHaveBeenCalledWith(session.id);
      expect(attendeeSummaryRepository.listBySession).toHaveBeenCalledWith(
        session.id,
        expect.objectContaining({ systemAttendance: AttendanceStatus.PRESENT }),
      );
      expect(attendeeSummaryRepository.listBySession.mock.calls[0][1]).not.toHaveProperty(
        'registrationIds',
      );
    });

    it('resolves lateCutoff and passes attendanceOutcome straight through when the checkbox side filter includes joinedLate — same as the kpiFilter tile would', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);
      attendeeSummaryRepository.listBySession.mockResolvedValue({ data: [], total: 0 });

      await provider.getAttendeeTable(session, {
        ...query,
        attendanceOutcome: [SessionKpiFilter.DROPPED, SessionKpiFilter.JOINED_LATE],
      });

      expect(attendeeSummaryRepository.listBySession).toHaveBeenCalledWith(
        session.id,
        expect.objectContaining({
          attendanceOutcome: [SessionKpiFilter.DROPPED, SessionKpiFilter.JOINED_LATE],
          lateCutoff: new Date(session.startsAt.getTime() + 300_000),
        }),
      );
    });
  });

  describe('getLiveStatus', () => {
    it('is live once started but not yet reconciled', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualStartAt: new Date(),
        reconciledAt: null,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);

      const status = await provider.getLiveStatus(session);
      expect(status.isLive).toBe(true);
    });

    it('is not live once reconciliation has completed', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualStartAt: new Date(),
        reconciledAt: new Date(),
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([]);

      const status = await provider.getLiveStatus(session);
      expect(status.isLive).toBe(false);
    });

    it('derives currentlyJoined/totalJoinedSoFar/rejoinsSoFar from the event log', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualStartAt: new Date(),
        reconciledAt: null,
      });
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'a@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
        },
        {
          email: 'a@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:10:00Z'),
        },
        {
          email: 'a@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:15:00Z'),
        },
        {
          email: 'b@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00Z'),
        },
      ]);

      const status = await provider.getLiveStatus(session);

      expect(status.totalJoinedSoFar).toBe(2); // a and b
      expect(status.rejoinsSoFar).toBe(1); // a rejoined
      expect(status.currentlyJoined).toBe(2); // both a (rejoined) and b are currently joined
    });
  });

  describe('recordParticipantJoined / recordParticipantLeft', () => {
    it('resolves the session by external id (+ occurrence) and inserts a JOINED event carrying the roster-matched user_id', async () => {
      webinarRepository.findByExtIdForOccurrence.mockResolvedValue(session);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);
      const occurredAt = new Date('2026-07-08T10:05:00.000Z');

      await provider.recordParticipantJoined(
        'ext-webinar-1',
        'occ-1',
        'a+reg1@x.com',
        occurredAt,
        'p-1',
        'A From Zoom',
      );

      expect(webinarRepository.findByExtIdForOccurrence).toHaveBeenCalledWith(
        'ext-webinar-1',
        'occ-1',
        occurredAt,
        undefined,
      );
      expect(liveEventRepository.insert).toHaveBeenCalledWith(
        expect.objectContaining({
          sessionId: 7,
          onlineSessionId: 70,
          email: 'a+reg1@x.com',
          userId: 42,
          eventType: ZoomLiveEventType.JOINED,
          occurredAt,
          zoomParticipantId: 'p-1',
          zoomDisplayName: 'A From Zoom',
        }),
      );
      expect(onlineAttendanceService.recordZoomJoin).toHaveBeenCalledWith({
        sessionId: 7,
        programId: 3,
        registrationId: 1,
        userId: 42,
        fullName: 'A',
        email: 'a@x.com',
        mobile: '111',
        occurredAt,
      });
    });

    it("resolves user_id via Zoom's derived +reg<registrationId> address even though it differs from the seeker's registered email", async () => {
      webinarRepository.findByExtIdForOccurrence.mockResolvedValue(session);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 7600,
          userId: 21,
          fullName: 'Sahithi',
          email: 'sahithi.gundam@divami.com',
          mobile: '111',
        },
      ]);

      await provider.recordParticipantJoined(
        'ext-webinar-1',
        null,
        'sahithi.gundam+reg7600@divami.com',
        new Date(),
        null,
        null,
      );

      expect(liveEventRepository.insert).toHaveBeenCalledWith(
        expect.objectContaining({ userId: 21 }),
      );
    });

    it('stores user_id as null when the joining email is not on the panelist roster', async () => {
      webinarRepository.findByExtIdForOccurrence.mockResolvedValue(session);
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);

      await provider.recordParticipantJoined(
        'ext-webinar-1',
        null,
        'gatecrasher@x.com',
        new Date(),
        null,
        null,
      );

      expect(liveEventRepository.insert).toHaveBeenCalledWith(
        expect.objectContaining({ userId: null }),
      );
      expect(onlineAttendanceService.recordZoomJoin).not.toHaveBeenCalled();
    });

    it('inserts a LEFT event for recordParticipantLeft', async () => {
      webinarRepository.findByExtIdForOccurrence.mockResolvedValue(session);
      const occurredAt = new Date('2026-07-08T10:20:00.000Z');

      await provider.recordParticipantLeft(
        'ext-webinar-1',
        null,
        'a@x.com',
        occurredAt,
        'p-1',
        null,
        null,
      );

      expect(liveEventRepository.insert).toHaveBeenCalledWith(
        expect.objectContaining({
          eventType: ZoomLiveEventType.LEFT,
          occurredAt,
          zoomParticipantId: 'p-1',
        }),
      );
      expect(onlineAttendanceService.recordZoomJoin).not.toHaveBeenCalled();
    });

    it('no-ops when the webhook references an untracked external id', async () => {
      webinarRepository.findByExtIdForOccurrence.mockResolvedValue(null);

      await provider.recordParticipantJoined(
        'unknown-ext',
        null,
        'a@x.com',
        new Date(),
        null,
        null,
      );

      expect(liveEventRepository.insert).not.toHaveBeenCalled();
    });
  });

  describe('correctLiveState', () => {
    it('inserts a self-healing LEFT event when the local log says joined but the dashboard disagrees', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: new Date() },
      ]);

      await provider.correctLiveState(session, new Map());

      // Tagged with the seeker's own registrationId — corrections must never be keyed
      // on the raw (possibly sibling-shared) email, or they'd misattribute across siblings.
      expect(liveEventRepository.insert).toHaveBeenCalledWith(
        expect.objectContaining({
          email: 'a+reg1@x.com',
          userId: 42,
          eventType: ZoomLiveEventType.LEFT,
        }),
      );
    });

    it('inserts a self-healing JOINED event when the dashboard shows presence the local log missed', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.LEFT, occurredAt: new Date() },
      ]);

      // The Dashboard API's present-email map only self-heals a seeker whose reported email
      // carries their own +reg<registrationId> tag — same rule as everywhere else.
      await provider.correctLiveState(session, new Map([['a+reg1@x.com', null]]));

      expect(liveEventRepository.insert).toHaveBeenCalledWith(
        expect.objectContaining({
          email: 'a+reg1@x.com',
          userId: 42,
          eventType: ZoomLiveEventType.JOINED,
        }),
      );
    });

    it('does nothing when the local log already agrees with the dashboard', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: new Date() },
      ]);

      await provider.correctLiveState(session, new Map([['a+reg1@x.com', null]]));

      expect(liveEventRepository.insert).not.toHaveBeenCalled();
    });

    it('attributes corrections per sibling registration instead of collapsing a shared real email', async () => {
      // Two sibling registrations (proxy/child regs) sharing one real email — each has its
      // own registrationId. The dashboard reports the bare, untagged real email for whichever
      // sibling is actually connected, so only that sibling's registrationId should be resolved
      // as "present"; the other must still be corrected independently.
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'Parent', email: 'shared@x.com', mobile: '111' },
        { registrationId: 2, userId: 43, fullName: 'Child', email: 'shared@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        { email: 'shared+reg1@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: new Date() },
      ]);

      await provider.correctLiveState(session, new Map());

      // Only registration 1 (the one the local log says is joined) gets corrected to LEFT;
      // registration 2 was never locally joined and the dashboard doesn't say it's present either,
      // so it must not be touched.
      expect(liveEventRepository.insert).toHaveBeenCalledTimes(1);
      expect(liveEventRepository.insert).toHaveBeenCalledWith(
        expect.objectContaining({
          email: 'shared+reg1@x.com',
          userId: 42,
          eventType: ZoomLiveEventType.LEFT,
        }),
      );
    });

    it('inserts one LEFT correction per currently-open device, so a seeker with 2 concurrent devices is fully cleared rather than left half-joined', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // 2 JOINs with no LEFT between them — 2 devices concurrently open.
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
        },
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00Z'),
        },
      ]);

      // Dashboard says this seeker is no longer present at all.
      await provider.correctLiveState(session, new Map());

      const leftCorrections = liveEventRepository.insert.mock.calls.filter(
        (c) => c[0].eventType === ZoomLiveEventType.LEFT,
      );
      expect(leftCorrections).toHaveLength(2);
    });

    it("inserts a RENAMED correction when a present seeker's dashboard-reported name differs from the last one recorded", async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date(),
          zoomDisplayName: 'Old Name',
        },
      ]);

      await provider.correctLiveState(session, new Map([['a+reg1@x.com', 'New Name']]));

      expect(liveEventRepository.insert).toHaveBeenCalledWith(
        expect.objectContaining({
          email: 'a+reg1@x.com',
          userId: 42,
          eventType: ZoomLiveEventType.RENAMED,
          zoomDisplayName: 'New Name',
        }),
      );
      // Presence already agreed (locally joined, dashboard present) — no JOINED/LEFT correction, only the rename.
      expect(liveEventRepository.insert).toHaveBeenCalledTimes(1);
    });

    it('does not insert a RENAMED correction when the dashboard-reported name matches what is already recorded', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date(),
          zoomDisplayName: 'Same Name',
        },
      ]);

      await provider.correctLiveState(session, new Map([['a+reg1@x.com', 'Same Name']]));

      expect(liveEventRepository.insert).not.toHaveBeenCalled();
    });

    it('a RENAMED correction does not affect presence aggregation on the next read', async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        { registrationId: 1, userId: 42, fullName: 'A', email: 'a@x.com', mobile: '111' },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:00:00Z'),
          zoomDisplayName: 'Old Name',
        },
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.RENAMED,
          occurredAt: new Date('2026-07-08T10:05:00Z'),
          zoomDisplayName: 'New Name',
        },
      ]);

      // Dashboard still reports the seeker present — the JOINED from 10:00 with no LEFT should still
      // read as currently-joined; the RENAMED row in between must not be mistaken for a device event.
      await provider.correctLiveState(session, new Map([['a+reg1@x.com', 'New Name']]));

      expect(liveEventRepository.insert).not.toHaveBeenCalled();
    });
  });

  describe('markStarted', () => {
    it('sets actualStartAt the first time', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);
      const startedAt = new Date('2026-07-08T10:00:00.000Z');

      await provider.markStarted(session, startedAt);

      expect(sessionSummaryRepository.upsert).toHaveBeenCalledWith(
        expect.objectContaining({ sessionId: 7, actualStartAt: startedAt }),
      );
    });

    it('is idempotent — does not overwrite an already-set actualStartAt', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualStartAt: new Date('2026-07-08T09:59:00.000Z'),
      });

      await provider.markStarted(session, new Date('2026-07-08T10:05:00.000Z'));

      expect(sessionSummaryRepository.upsert).not.toHaveBeenCalled();
    });
  });

  describe('markEnded', () => {
    it('sets actualEndAt the first time', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);
      const endedAt = new Date('2026-07-08T11:00:00.000Z');

      await provider.markEnded(session, endedAt);

      expect(sessionSummaryRepository.upsert).toHaveBeenCalledWith(
        expect.objectContaining({ sessionId: 7, actualEndAt: endedAt }),
      );
    });

    it('is idempotent — does not overwrite an already-set actualEndAt', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualEndAt: new Date('2026-07-08T11:00:00.000Z'),
      });

      await provider.markEnded(session, new Date('2026-07-08T11:05:00.000Z'));

      expect(sessionSummaryRepository.upsert).not.toHaveBeenCalled();
    });

    it('stamps hdb_online_session.actual_meeting_ends_at from the same ended webhook', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null);
      const endedAt = new Date('2026-07-08T11:00:00.000Z');

      await provider.markEnded(session, endedAt);

      expect(onlineSession.actualMeetingEndsAt).toEqual(endedAt);
      expect(webinarRepository.save).toHaveBeenCalledWith(session);
    });

    it('does not overwrite an already-set actual_meeting_ends_at (first webhook wins), even when the summary stamp still needs writing', async () => {
      const alreadyStamped = new Date('2026-07-08T10:58:00.000Z');
      onlineSession.actualMeetingEndsAt = alreadyStamped;
      sessionSummaryRepository.findBySessionId.mockResolvedValue(null); // summary still unset

      await provider.markEnded(session, new Date('2026-07-08T11:05:00.000Z'));

      // The summary write still happens independently...
      expect(sessionSummaryRepository.upsert).toHaveBeenCalled();
      // ...but the online-session timestamp is untouched.
      expect(onlineSession.actualMeetingEndsAt).toEqual(alreadyStamped);
      expect(webinarRepository.save).not.toHaveBeenCalled();
    });

    it('writes actual_meeting_ends_at even when the summary stamp was already set (the two are independently idempotent)', async () => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualEndAt: new Date('2026-07-08T11:00:00.000Z'),
      });
      const endedAt = new Date('2026-07-08T11:05:00.000Z');

      await provider.markEnded(session, endedAt);

      expect(sessionSummaryRepository.upsert).not.toHaveBeenCalled();
      expect(onlineSession.actualMeetingEndsAt).toEqual(endedAt);
      expect(webinarRepository.save).toHaveBeenCalledWith(session);
    });
  });

  describe('getDashboard', () => {
    const sessionStart = new Date('2026-07-08T10:00:00.000Z');
    const sessionEnd = new Date('2026-07-08T11:00:00.000Z');

    beforeEach(() => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualStartAt: sessionStart,
        actualEndAt: sessionEnd,
        actualDurationMinutes: 60,
        reconciledAt: new Date('2026-07-08T11:10:00.000Z'),
      });
      // Final attendance (Coordinator > RM > Zoom-system) — defaults to matching the two
      // seekers who actually join in the shared event log below (Stayer, Dropper); tests with
      // their own roster/event log override this to match their own scenario.
      onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([1, 2]));
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 1,
          fullName: 'Stayer',
          email: 'a@x.com',
          mobile: '1',
          rmName: 'RM A',
        },
        {
          registrationId: 2,
          userId: 2,
          fullName: 'Dropper',
          email: 'b@x.com',
          mobile: '2',
          rmName: 'RM B',
        },
        {
          registrationId: 3,
          userId: 3,
          fullName: 'Absent',
          email: 'c@x.com',
          mobile: '3',
          rmName: null,
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Stayer: joins on time, stays to the end.
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: sessionStart },
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.LEFT, occurredAt: sessionEnd },
        // Dropper: joins late (10:20), drops mid-session (10:35), never returns.
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:20:00.000Z'),
        },
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:35:00.000Z'),
          leaveReason: 'network connection error',
        },
      ]);
    });

    it('computes the pill summary from the roster + event log (attended/absent/dropped)', async () => {
      const dashboard = await provider.getDashboard(session);

      expect(dashboard.summary).toEqual({
        total: 3,
        attended: 2,
        absent: 1,
        droppedOff: 1, // Dropper left 25min before end; Stayer's leave is within the grace window
        rejoined: 0,
      });
      expect(dashboard.reconciledAt).toEqual(new Date('2026-07-08T11:10:00.000Z'));
    });

    it('counts a seeker as attended when a Coordinator manually marks them present, even though the event log shows they never joined', async () => {
      // Registration 3 (Absent) never appears in the event log, but a Coordinator override
      // marks them present — final attendance must follow that override, not the raw log.
      onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([1, 2, 3]));

      const dashboard = await provider.getDashboard(session);

      expect(dashboard.summary).toEqual({
        total: 3,
        attended: 3,
        absent: 0,
        droppedOff: 1,
        rejoined: 0,
      });
    });

    it('excludes a seeker from Final Attendance when a manual override marks them absent, but still counts them in Late Comers — final attendance and raw lateness are independent facts', async () => {
      // Registration 2 (Dropper) joined late per the event log, and an RM/Coordinator
      // override marks them NOT present — Final Attendance follows the override (they drop
      // out of `attended`), but Late Comers is a fact about the join event itself and must
      // still count them.
      onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([1]));

      const dashboard = await provider.getDashboard(session);

      expect(dashboard.summary).toEqual({
        total: 3,
        attended: 1,
        absent: 2,
        droppedOff: 0,
        rejoined: 0,
      });
      expect(dashboard.lateComers.total).toBe(1);
    });

    it('bounds the journey by scheduled end / last event when Zoom never reported an end (no ended-webhook, no report)', async () => {
      // No actualEndAt and no duration — the exact state of this account's sessions.
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualStartAt: sessionStart,
        actualEndAt: null,
        actualDurationMinutes: null,
      });
      const sessionWithScheduledEnd = {
        ...session,
        endsAt: new Date('2026-07-08T10:30:00.000Z'),
      };

      const dashboard = await provider.getDashboard(sessionWithScheduledEnd);

      // Last live event (11:00) is later than the scheduled end (10:30) — the window ends
      // there, NOT at "now" (which would render a days-wide chart with one blip).
      const lastBucket = dashboard.journey[dashboard.journey.length - 1];
      expect(dashboard.journey.length).toBeLessThanOrEqual(25);
      expect(lastBucket.bucketAt.getTime()).toBeLessThanOrEqual(
        new Date('2026-07-08T11:15:00.000Z').getTime(),
      );
    });

    it('buckets the journey adaptively with per-bucket drop-offs and late joins, starting 15 minutes before the session start', async () => {
      const dashboard = await provider.getDashboard(session);

      // Window is fixed at 15min-before-start (09:45) → sessionEnd (11:00) = 75 min → smallest
      // ladder step keeping <= 24 buckets is 5 min = 16 points.
      expect(dashboard.journey).toHaveLength(16);
      expect(dashboard.journey[0].bucketAt).toEqual(new Date('2026-07-08T09:45:00.000Z'));

      const pointAt = (iso: string) =>
        dashboard.journey.find((point) => point.bucketAt.getTime() === new Date(iso).getTime())!;

      // The 15-minute lead-in has nobody present yet — everyone still reads as absent.
      expect(pointAt('2026-07-08T09:45:00.000Z').absent).toBe(3);

      const at1030 = pointAt('2026-07-08T10:30:00.000Z');
      expect(at1030.attended).toBe(2); // both present during (10:25, 10:30]
      expect(at1030.absent).toBe(1); // Absent never joined

      // Dropper's late first-join (10:20) lands in its own bucket (10:15, 10:20].
      expect(pointAt('2026-07-08T10:20:00.000Z').lateComers).toBe(1);
      // Dropper's 10:35 full drop lands in (10:30, 10:35] — where he still counts as attended
      // (present for part of the bucket), and stops counting from the next bucket on.
      const at1035 = pointAt('2026-07-08T10:35:00.000Z');
      expect(at1035.dropOff).toBe(1);
      expect(at1035.attended).toBe(2);
      expect(pointAt('2026-07-08T10:45:00.000Z').attended).toBe(1);
    });

    it('excludes a seeker from every Journey bucket when a manual override marks them finally absent, even while the event log shows them present', async () => {
      // Dropper (registration 2) joined and was present 10:20-10:35 per the event log, but a
      // Coordinator override marks them finally absent — the Journey must never show them as
      // attended, not even during the window they were actually connected.
      onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([1]));

      const dashboard = await provider.getDashboard(session);
      const pointAt = (iso: string) =>
        dashboard.journey.find((point) => point.bucketAt.getTime() === new Date(iso).getTime())!;

      // 10:30 — Dropper is mid-connection per the raw log (joined 10:20, drops 10:35), but the
      // override means only Stayer counts.
      expect(pointAt('2026-07-08T10:30:00.000Z').attended).toBe(1);
      expect(pointAt('2026-07-08T10:30:00.000Z').absent).toBe(2);
      // No bucket anywhere on the chart should count Dropper as attended.
      expect(dashboard.journey.every((point) => point.attended <= 1)).toBe(true);
    });

    it('plots a seeker as present for the whole session, from session start, when a manual override marks them finally present despite never actually joining', async () => {
      // Absent (registration 3) never appears in the event log at all, but a Coordinator
      // override marks them finally present — the Journey must plot them as present for the
      // entire session, using session start (10:00) as their synthesized join instant.
      onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([1, 2, 3]));

      const dashboard = await provider.getDashboard(session);
      const pointAt = (iso: string) =>
        dashboard.journey.find((point) => point.bucketAt.getTime() === new Date(iso).getTime())!;

      // Lead-in window (09:45, before session start) — the synthesized join hasn't happened yet.
      expect(pointAt('2026-07-08T09:45:00.000Z').attended).toBe(0);
      // From session start (10:00) onward, Absent counts as present alongside Stayer.
      expect(pointAt('2026-07-08T10:00:00.000Z').attended).toBe(2);
      expect(pointAt('2026-07-08T10:00:00.000Z').absent).toBe(1); // only Dropper hasn't joined yet
      // Stays present through to session end — Dropper dropped for good at 10:35, so the
      // final bucket has Stayer + the synthesized Absent, not Dropper.
      expect(pointAt('2026-07-08T11:00:00.000Z').attended).toBe(2);
    });

    it('bands drop-offs per seeker with the last mid-session leave reason as the stacked segment', async () => {
      const dashboard = await provider.getDashboard(session);

      // Stayer's end-of-session leave is inside the grace window — excluded; only Dropper's
      // single mid-session drop counts, classified by his captured reason.
      expect(dashboard.dropOff).toEqual({
        total: 1,
        reasons: ['Connection Lost / Network Issue'],
        bands: [
          {
            label: '1 drop-off',
            segments: [{ reason: 'Connection Lost / Network Issue', count: 1 }],
          },
        ],
      });
    });

    it('buckets Multiple Logins by device count — concurrent connections prove a second device without any participant id', async () => {
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Stayer: two JOINs with no LEFT between them = 2 concurrent devices.
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: sessionStart },
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00.000Z'),
        },
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.LEFT, occurredAt: sessionEnd },
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.LEFT, occurredAt: sessionEnd },
        // Dropper: single device.
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:20:00.000Z'),
        },
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:35:00.000Z'),
        },
      ]);

      const dashboard = await provider.getDashboard(session);

      expect(dashboard.multipleLogins).toEqual({
        total: 1,
        buckets: [{ label: '2 devices', count: 1, percentage: 100 }],
      });
    });

    it('buckets Late Comers by how far past the late cutoff their first join fell, excluding on-time joiners', async () => {
      // All 6 registrations here actually join (see event log below) — final attendance
      // includes all of them.
      onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(
        new Set([1, 2, 3, 4, 5, 6]),
      );
      // Late cutoff = sessionStart (10:00) + 5 min default threshold = 10:05.
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 1,
          fullName: 'OnTime',
          email: 'a@x.com',
          mobile: '1',
          rmName: null,
        },
        {
          registrationId: 2,
          userId: 2,
          fullName: 'Late20s',
          email: 'b@x.com',
          mobile: '2',
          rmName: null,
        },
        {
          registrationId: 3,
          userId: 3,
          fullName: 'Late45s',
          email: 'c@x.com',
          mobile: '3',
          rmName: null,
        },
        {
          registrationId: 4,
          userId: 4,
          fullName: 'Late90s',
          email: 'd@x.com',
          mobile: '4',
          rmName: null,
        },
        {
          registrationId: 5,
          userId: 5,
          fullName: 'Late3min',
          email: 'e@x.com',
          mobile: '5',
          rmName: null,
        },
        {
          registrationId: 6,
          userId: 6,
          fullName: 'Late10min',
          email: 'f@x.com',
          mobile: '6',
          rmName: null,
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // On time — joins exactly at the cutoff instant, not counted as late at all.
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00.000Z'),
        },
        // 20s past cutoff.
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:20.000Z'),
        },
        // 45s past cutoff.
        {
          email: 'c+reg3@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:45.000Z'),
        },
        // 90s past cutoff.
        {
          email: 'd+reg4@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:06:30.000Z'),
        },
        // 3 min past cutoff.
        {
          email: 'e+reg5@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:08:00.000Z'),
        },
        // 10 min past cutoff.
        {
          email: 'f+reg6@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:15:00.000Z'),
        },
      ]);

      const dashboard = await provider.getDashboard(session);

      expect(dashboard.lateComers).toEqual({
        total: 5,
        buckets: [
          { label: '<30s', count: 1 },
          { label: '30s–1 min', count: 1 },
          { label: '1–2 min', count: 1 },
          { label: '2–5 min', count: 1 },
          { label: '5 min+', count: 1 },
        ],
      });
    });

    it('keeps every Late Comers band in the response at count 0 rather than omitting bands with no latecomers', async () => {
      // Late cutoff = sessionStart (10:00) + 5 min default threshold = 10:05.
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 1,
          fullName: 'OnTime',
          email: 'a@x.com',
          mobile: '1',
          rmName: null,
        },
        {
          registrationId: 2,
          userId: 2,
          fullName: 'Late20s',
          email: 'b@x.com',
          mobile: '2',
          rmName: null,
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // On time — not counted as late at all.
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00.000Z'),
        },
        // Only the '<30s' band has anyone in it.
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:20.000Z'),
        },
      ]);

      const dashboard = await provider.getDashboard(session);

      expect(dashboard.lateComers).toEqual({
        total: 1,
        buckets: [
          { label: '<30s', count: 1 },
          { label: '30s–1 min', count: 0 },
          { label: '1–2 min', count: 0 },
          { label: '2–5 min', count: 0 },
          { label: '5 min+', count: 0 },
        ],
      });
    });

    it("plots the Sincerity Map by THIS session's own duration (60 min here) — on-time full-stay, half-late half-stay, and a total no-show", async () => {
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 1,
          fullName: 'OnTimeFullStay',
          email: 'a@x.com',
          mobile: '1',
          rmName: null,
        },
        {
          registrationId: 2,
          userId: 2,
          fullName: 'HalfLateHalfStay',
          email: 'b@x.com',
          mobile: '2',
          rmName: null,
        },
        {
          registrationId: 3,
          userId: 3,
          fullName: 'NoShow',
          email: 'c@x.com',
          mobile: '3',
          rmName: null,
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Joins exactly on time, never leaves — punctuality 100%, endurance 100% (60 of 60 min).
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: sessionStart },
        // Joins 30 min late (half the 60-min session), stays the remaining 30 min —
        // punctuality 100*(1-30/60)=50%, endurance 100*30/60=50%.
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:30:00.000Z'),
        },
        // Registration 3 never appears in the event log at all — a total no-show.
      ]);

      const dashboard = await provider.getDashboard(session);

      expect(dashboard.sincerityMap.total).toBe(3);
      expect(dashboard.sincerityMap.punctualityBandLabels).toEqual([
        '80-100',
        '60-80',
        '40-60',
        '20-40',
        '0-20',
      ]);
      expect(dashboard.sincerityMap.enduranceBandLabels).toEqual([
        '0-20',
        '20-40',
        '40-60',
        '60-80',
        '80-100',
      ]);
      expect(dashboard.sincerityMap.counts).toEqual([
        [0, 0, 0, 0, 1], // 80-100 punctuality row — OnTimeFullStay lands in the 80-100 endurance column
        [0, 0, 0, 0, 0],
        [0, 0, 1, 0, 0], // 40-60 punctuality row — HalfLateHalfStay lands in the 40-60 endurance column
        [0, 0, 0, 0, 0],
        [1, 0, 0, 0, 0], // 0-20 punctuality row — NoShow lands in the 0-20 endurance column
      ]);
    });

    it("classifies Zoom's real sentence format on the detail after 'Reason :', banding by per-seeker drop count with the LAST mid-session reason", async () => {
      // Real payloads observed on this account 2026-07-16, plus one unknown detail that
      // must surface verbatim as its own dynamic reason instead of a lossy "Other".
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        // Stayer: drops twice mid-session — first "left the meeting", then the unknown
        // detail; the LAST reason is the one his band segment carries.
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: sessionStart },
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:10:00.000Z'),
          leaveReason: 'left the meeting. Reason : left the meeting',
        },
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:12:00.000Z'),
        },
        {
          email: 'a+reg1@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00.000Z'),
          leaveReason: 'left the meeting. Reason : Moved to breakout room.',
        },
        // Dropper: one mid-session drop with a failover reason.
        { email: 'b+reg2@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: sessionStart },
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:15:00.000Z'),
          leaveReason: 'left the meeting. Reason : left the meeting with Unknown Failover Reason',
        },
        // Absent-roster seeker 3 joins after all and drops once, host-closed.
        { email: 'c+reg3@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: sessionStart },
        {
          email: 'c+reg3@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:25:00.000Z'),
          leaveReason: 'left the meeting. Reason : Host closed the meeting.',
        },
      ]);

      const dashboard = await provider.getDashboard(session);

      // Seeker 3's disconnect was the HOST closing the meeting — that's the session ending,
      // not a drop-off, so it contributes nothing. 2 (Stayer) + 1 (Dropper) remain.
      expect(dashboard.dropOff.total).toBe(3);
      expect(dashboard.dropOff.reasons).toEqual([
        'Connection Lost / Network Issue',
        'Moved to breakout room',
      ]);
      expect(dashboard.dropOff.bands).toEqual([
        {
          label: '1 drop-off',
          segments: [{ reason: 'Connection Lost / Network Issue', count: 1 }],
        },
        {
          label: '2 drop-offs',
          // Stayer's segment carries his LAST real drop's own reason.
          segments: [{ reason: 'Moved to breakout room', count: 1 }],
        },
      ]);
    });
  });

  describe('getFollowUps', () => {
    const sessionStart = new Date('2026-07-08T10:00:00.000Z');
    const sessionEnd = new Date('2026-07-08T11:00:00.000Z');

    beforeEach(() => {
      sessionSummaryRepository.findBySessionId.mockResolvedValue({
        actualStartAt: sessionStart,
        actualEndAt: sessionEnd,
        actualDurationMinutes: 60,
      });
      rosterRepository.findPanelistRosterByOnlineSession.mockResolvedValue([
        {
          registrationId: 1,
          userId: 1,
          fullName: 'Stayer',
          email: 'a@x.com',
          mobile: '1',
          rmName: 'RM A',
        },
        {
          registrationId: 2,
          userId: 2,
          fullName: 'Dropper',
          email: 'b@x.com',
          mobile: '2',
          rmName: 'RM B',
        },
        {
          registrationId: 3,
          userId: 3,
          fullName: 'Absent',
          email: 'c@x.com',
          mobile: '3',
          rmName: 'RM C',
        },
      ]);
      liveEventRepository.findAllByOnlineSessionId.mockResolvedValue([
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.JOINED, occurredAt: sessionStart },
        { email: 'a+reg1@x.com', eventType: ZoomLiveEventType.LEFT, occurredAt: sessionEnd },
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.JOINED,
          occurredAt: new Date('2026-07-08T10:05:00.000Z'),
        },
        {
          email: 'b+reg2@x.com',
          eventType: ZoomLiveEventType.LEFT,
          occurredAt: new Date('2026-07-08T10:20:00.000Z'),
        },
      ]);
      attendeeSummaryRepository.findAllBySessionId.mockResolvedValue([
        { id: 101, registrationId: 2 },
        { id: 102, registrationId: 3 },
      ]);
    });

    it('lists never-joined seekers first, then mid-session droppers; clean attendees are excluded', async () => {
      const result = await provider.getFollowUps(session, { page: 1, limit: 10 });

      expect(result.total).toBe(2);
      expect(result.data[0]).toEqual(
        expect.objectContaining({
          registrationId: 3,
          fullName: 'Absent',
          reason: 'Did not join the session',
          engagement: 'LOW',
          rmName: 'RM C',
          attendeeId: 102,
          joinedAt: null,
        }),
      );
      expect(result.data[1]).toEqual(
        expect.objectContaining({
          registrationId: 2,
          fullName: 'Dropper',
          reason: 'Dropped off during the session',
          engagement: 'LOW',
          attendeeId: 101,
        }),
      );
    });

    it('applies search + pagination', async () => {
      const result = await provider.getFollowUps(session, {
        page: 1,
        limit: 10,
        search: 'dropper',
      });

      expect(result.total).toBe(1);
      expect(result.data[0].fullName).toBe('Dropper');
    });
  });
});
