import { ForbiddenException } from '@nestjs/common';
import { ZoomAnalyticsFacadeService } from './zoom-analytics-facade.service';
import { ZoomAttendanceMarkSource } from 'src/common/enum/zoom-attendance-mark-source.enum';
import { AttendanceStatus } from 'src/common/enum/attendance-status.enum';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { SessionKpiCategory, SessionKpiFilter } from 'src/common/enum/session-kpi.enum';
import { SessionCommunicationPurposeEnum } from 'src/common/enum/session-communication-purpose.enum';
import { SessionCommunicationStatusEnum } from 'src/common/enum/session-communication-status.enum';

describe('ZoomAnalyticsFacadeService', () => {
  const session = { id: 7, onlineType: 'webinar', onlineSession: { id: 70 } } as any;
  const meetingSession = { id: 8, onlineType: 'meeting', onlineSession: { id: 80 } } as any;

  const webinarRepository = { findById: jest.fn(), findByProgramId: jest.fn() };
  const attendeeSummaryRepository = {
    findById: jest.fn(),
    save: jest.fn(),
    findAllByProgramAndRegistration: jest.fn(),
  };
  const onlineAttendanceService = {
    markAttendance: jest.fn(),
    getManualMarksBySession: jest.fn(),
    getAttendedRegistrationIds: jest.fn(),
  };
  const registrationRepository = {
    getEligibleActivationSummary: jest.fn(),
    listAllRmContacts: jest.fn(),
    findProfileByRegistrationId: jest.fn(),
  };
  const provider = {
    getKpis: jest.fn(),
    getGeneralKpis: jest.fn(),
    getLiveStatus: jest.fn(),
    getAttendeeTable: jest.fn(),
    reconcile: jest.fn(),
    getDashboard: jest.fn(),
    getFollowUps: jest.fn(),
  };
  const registry = { resolveForOnlineType: jest.fn().mockReturnValue(provider) };
  const programAnalytics = { getOverallAnalytics: jest.fn() };
  const sessionCommunicationService = { getLatestSessionStatus: jest.fn() };

  let facade: ZoomAnalyticsFacadeService;

  beforeEach(() => {
    jest.clearAllMocks();
    registry.resolveForOnlineType.mockReturnValue(provider);
    webinarRepository.findById.mockResolvedValue(session);
    registrationRepository.getEligibleActivationSummary.mockResolvedValue({
      onlineSessionId: 70,
      totalEligible: 0,
      activeCount: 0,
      inactiveCount: 0,
    });
    registrationRepository.listAllRmContacts.mockResolvedValue([]);
    sessionCommunicationService.getLatestSessionStatus.mockResolvedValue(null);
    facade = new ZoomAnalyticsFacadeService(
      webinarRepository as any,
      attendeeSummaryRepository as any,
      registry as any,
      onlineAttendanceService as any,
      registrationRepository as any,
      programAnalytics as any,
      sessionCommunicationService as any,
    );
  });

  describe('session-scoped reads', () => {
    it("getKpis loads the session then delegates to the session's own onlineType provider", async () => {
      const kpis = { sessionId: 7, totalPanelists: 10 };
      provider.getKpis.mockResolvedValue(kpis);

      const result = await facade.getKpis(7);

      expect(webinarRepository.findById).toHaveBeenCalledWith(7);
      expect(registry.resolveForOnlineType).toHaveBeenCalledWith('webinar');
      expect(provider.getKpis).toHaveBeenCalledWith(session);
      expect(result).toBe(kpis);
    });

    it('getLiveStatus delegates to the provider matching the session', async () => {
      const status = { sessionId: 7, isLive: true };
      provider.getLiveStatus.mockResolvedValue(status);

      const result = await facade.getLiveStatus(7);

      expect(provider.getLiveStatus).toHaveBeenCalledWith(session);
      expect(result).toBe(status);
    });

    it('getAttendeeTable forwards the query to the provider matching the session', async () => {
      const query = { page: 1, limit: 20 };
      const rows = { data: [], total: 0, page: 1, limit: 20 };
      provider.getAttendeeTable.mockResolvedValue(rows);

      const result = await facade.getAttendeeTable(7, query as any);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, query);
      expect(result).toBe(rows);
    });

    it('reconcile delegates to the provider matching the session', async () => {
      await facade.reconcile(7);
      expect(provider.reconcile).toHaveBeenCalledWith(session);
    });

    it('getDashboard delegates to the provider matching the session', async () => {
      const dashboard = { sessionId: 7, reconciledAt: null };
      provider.getDashboard.mockResolvedValue(dashboard);

      const result = await facade.getDashboard(7);

      expect(provider.getDashboard).toHaveBeenCalledWith(session);
      expect(result).toBe(dashboard);
    });

    it('getFollowUps forwards the query to the provider matching the session', async () => {
      const query = { page: 2, limit: 10 };
      const rows = { data: [], total: 0, page: 2, limit: 10 };
      provider.getFollowUps.mockResolvedValue(rows);

      const result = await facade.getFollowUps(7, query as any);

      expect(provider.getFollowUps).toHaveBeenCalledWith(session, query);
      expect(result).toBe(rows);
    });

    it('getSeekerAnalytics rolls up held sessions with Coordinator > RM > system attendance and the all-of-first-N eligibility rule', async () => {
      const past = (iso: string) => new Date(iso);
      webinarRepository.findByProgramId.mockResolvedValue([
        { id: 101, startsAt: past('2026-07-01T10:00:00Z'), endsAt: past('2026-07-01T11:00:00Z') },
        { id: 102, startsAt: past('2026-07-08T10:00:00Z'), endsAt: past('2026-07-08T11:00:00Z') },
        // Far future — not held yet.
        { id: 103, startsAt: new Date('2036-07-15T10:00:00Z'), endsAt: new Date('2036-07-15T11:00:00Z') },
      ]);
      attendeeSummaryRepository.findAllByProgramAndRegistration.mockResolvedValue([
        {
          sessionId: 101,
          isSystemAttended: true,
          joinedAt: past('2026-07-01T10:20:00Z'), // 20 min late (threshold 5 min)
          durationSeconds: 1800,
          noOfDevices: 2,
          dropoffCount: 1,
          rejoinCount: 1,
        },
        {
          sessionId: 102,
          isSystemAttended: true,
          joinedAt: past('2026-07-08T10:01:00Z'),
          durationSeconds: 3000,
          noOfDevices: 1,
          dropoffCount: 0,
          rejoinCount: 0,
        },
      ]);
      // Coordinator overrides system attendance to ABSENT for session 102 — registration 55
      // is finally present only for session 101.
      onlineAttendanceService.getAttendedRegistrationIds.mockImplementation(async (sessionId: number) =>
        sessionId === 101 ? new Set([55]) : new Set(),
      );
      registrationRepository.findProfileByRegistrationId.mockResolvedValue({
        fullName: 'Seeker Fifty Five',
        email: 's55@x.com',
        mobile: '9999999999',
        registrationSeqNumber: 'HDB25-055',
        registeredDate: past('2026-06-01T00:00:00Z'),
        gender: 'FEMALE',
        rmName: 'Asha RM',
      });

      const result = await facade.getSeekerAnalytics(3, 55);

      expect(result.rmName).toBe('Asha RM');
      expect(result.profile?.fullName).toBe('Seeker Fifty Five');
      expect(result.sessionsHeld).toBe(2);
      expect(result.sessionsAttended).toBe(1); // 101 system-attended; 102 overridden absent
      expect(result.attendancePercent).toBe(50);
      expect(result.totalLearningSeconds).toBe(4800);
      expect(result.eligibility.status).toBe('pending'); // 11 sessions haven't been held
      expect(result.sessions).toHaveLength(3);
      expect(result.sessions[0]).toMatchObject({
        sessionId: 101,
        held: true,
        attended: true,
        lateByMinutes: 20,
        deviceCount: 2,
      });
      expect(result.sessions[2]).toMatchObject({ sessionId: 103, held: false, attended: false });
    });

    it('reads attended from the is_attended-backed getAttendedRegistrationIds source, not a fresh manual-marks re-derivation — a Coordinator can mark a seeker present even with no attendee-summary row at all', async () => {
      const past = (iso: string) => new Date(iso);
      webinarRepository.findByProgramId.mockResolvedValue([
        { id: 201, startsAt: past('2026-07-01T10:00:00Z'), endsAt: past('2026-07-01T11:00:00Z') },
      ]);
      // No summary row for this session at all — the seeker never actually joined per Zoom.
      attendeeSummaryRepository.findAllByProgramAndRegistration.mockResolvedValue([]);
      // But a Coordinator override marks them finally present anyway.
      onlineAttendanceService.getAttendedRegistrationIds.mockResolvedValue(new Set([77]));
      registrationRepository.findProfileByRegistrationId.mockResolvedValue(null);

      const result = await facade.getSeekerAnalytics(3, 77);

      expect(result.sessionsAttended).toBe(1);
      expect(result.sessions[0]).toMatchObject({ sessionId: 201, held: true, attended: true });
      expect(onlineAttendanceService.getManualMarksBySession).not.toHaveBeenCalled();
    });

    it('throws not-found when the session does not exist, without touching the provider', async () => {
      webinarRepository.findById.mockResolvedValue(null);

      await expect(facade.getKpis(999)).rejects.toBeInstanceOf(InifniNotFoundException);
      expect(registry.resolveForOnlineType).not.toHaveBeenCalled();
    });

    it("resolves the provider from each session's own onlineType, not a shared global setting", async () => {
      provider.getKpis.mockResolvedValue({});
      await facade.getKpis(7);

      webinarRepository.findById.mockResolvedValue(meetingSession);
      await facade.getKpis(8);

      expect(registry.resolveForOnlineType).toHaveBeenNthCalledWith(1, 'webinar');
      expect(registry.resolveForOnlineType).toHaveBeenNthCalledWith(2, 'meeting');
    });
  });

  describe('getKpisV1', () => {
    it('returns the same 8 KPI figures plus a kpis tile array tagging only the attendance-outcome fields as clickable, plus sideFilterSets', async () => {
      const kpis = {
        sessionId: 7,
        startTime: null,
        durationMinutes: null,
        totalPanelists: 10,
        totalSeekersJoined: 6,
        seekersNotJoined: 4,
        seekersJoinedLate: 1,
        seekersDropped: 2,
        seekersRejoined: 1,
        finalPresent: 6,
        finalAbsent: 4,
        reconciledAt: null,
      };
      provider.getKpis.mockResolvedValue(kpis);
      registrationRepository.getEligibleActivationSummary.mockResolvedValue({
        onlineSessionId: 70,
        totalEligible: 13,
        activeCount: 10,
        inactiveCount: 3,
      });
      registrationRepository.listAllRmContacts.mockResolvedValue([
        { id: 5, name: 'Asha RM' },
        { id: 9, name: 'Ravi RM' },
      ]);

      const result = await facade.getKpisV1(7, ['admin'], 1);

      expect(registrationRepository.getEligibleActivationSummary).toHaveBeenCalledWith(70);
      expect(result).toMatchObject(kpis);
      expect(result.kpis).toEqual([
        { label: 'Total Attendees', value: 10 },
        { label: 'Present', value: 6, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED },
        { label: 'Absent', value: 4, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.NOT_JOINED },
        { label: 'Joined Late', value: 1, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED_LATE },
      ]);
      // Side filters (rmContact/systemAttendance/rmAttendance/coordinatorAttendance/finalAttendance/
      // attendanceOutcome/userType) — never kpiCategory/kpiFilter, which are the
      // clickable-KPI-tile mechanism above, not a side filter.
      expect(Object.keys(result.sideFilterSets.baseSets).sort()).toEqual([
        'attendanceOutcome',
        'coordinatorAttendance',
        'finalAttendance',
        'rmAttendance',
        'rmContact',
        'systemAttendance',
        'userType',
      ]);
      // rmContact: real RM contacts populate `options` (fetched at request time) for an admin/coordinator
      // caller — and `excludeRoles` is never echoed back; enforcement already happened server-side.
      expect(result.sideFilterSets.baseSets.rmContact).toEqual([
        {
          key: 'rmContact',
          label: 'RM',
          type: 'dropdown',
          options: [
            { label: 'Asha RM', value: '5' },
            { label: 'Ravi RM', value: '9' },
          ],
        },
      ]);
      expect(result.sideFilterSets.baseSets.finalAttendance[0].options).toEqual([
        { label: 'Present', value: AttendanceStatus.PRESENT },
        { label: 'Absent', value: AttendanceStatus.ABSENT },
      ]);
      // contextualFilters: "notJoined" drops attendanceOutcome/systemAttendance (redundant/meaningless
      // once a seeker is already known to have never joined); every other kpiFilter keeps the full set.
      expect(result.sideFilterSets.contextualFilters.notJoined.sort()).toEqual([
        'attendanceOutcome',
        'rmContact',
        'userType',
      ]);
      expect(result.sideFilterSets.contextualFilters.dropped.sort()).toEqual([
        'attendanceOutcome',
        'coordinatorAttendance',
        'finalAttendance',
        'rmAttendance',
        'rmContact',
        'systemAttendance',
        'userType',
      ]);
    });

    it('omits rmContact from sideFilterSets for an RM caller (already hard-scoped server-side), skips the RM-contacts lookup, and drops rmContact from every contextualFilters entry too', async () => {
      provider.getKpis.mockResolvedValue({
        sessionId: 7,
        startTime: null,
        durationMinutes: null,
        totalPanelists: 10,
        totalSeekersJoined: 6,
        seekersNotJoined: 4,
        seekersJoinedLate: 1,
        seekersDropped: 2,
        seekersRejoined: 1,
        finalPresent: 6,
        finalAbsent: 4,
        reconciledAt: null,
      });

      const result = await facade.getKpisV1(7, ['relational_manager'], 42);

      expect(registrationRepository.listAllRmContacts).not.toHaveBeenCalled();
      expect(Object.keys(result.sideFilterSets.baseSets).sort()).toEqual([
        'attendanceOutcome',
        'coordinatorAttendance',
        'finalAttendance',
        'rmAttendance',
        'systemAttendance',
        'userType',
      ]);
      expect(result.sideFilterSets.contextualFilters.all).not.toContain('rmContact');
    });

    it('reports zero inactive seekers when the session has no online resource provisioned yet', async () => {
      webinarRepository.findById.mockResolvedValue({ id: 9, onlineType: 'webinar', onlineSession: null });
      provider.getKpis.mockResolvedValue({
        sessionId: 9,
        startTime: null,
        durationMinutes: null,
        totalPanelists: 0,
        totalSeekersJoined: 0,
        seekersNotJoined: 0,
        seekersJoinedLate: 0,
        seekersDropped: 0,
        seekersRejoined: 0,
        finalPresent: 0,
        finalAbsent: 0,
        reconciledAt: null,
      });

      const result = await facade.getKpisV1(9);

      expect(registrationRepository.getEligibleActivationSummary).not.toHaveBeenCalled();
      expect(result.kpis).not.toContainEqual(expect.objectContaining({ label: 'Inactive seekers' }));
    });
  });

  describe('getLiveStatusV1', () => {
    it('delegates to getAttendeeTableV1 for the paginated rows/tableHeaders/kpis, merged with the unscoped getLiveStatus snapshot', async () => {
      provider.getLiveStatus.mockResolvedValue({
        sessionId: 7,
        isLive: true,
        currentlyJoined: 9,
        totalJoinedSoFar: 12,
        rejoinsSoFar: 2,
        lastEventAt: null,
      });
      provider.getKpis.mockResolvedValue({
        sessionId: 7,
        startTime: null,
        durationMinutes: null,
        totalPanelists: 10,
        totalSeekersJoined: 6,
        seekersNotJoined: 4,
        seekersJoinedLate: 1,
        seekersDropped: 2,
        seekersRejoined: 1,
        finalPresent: 6,
        finalAbsent: 4,
        reconciledAt: null,
      });
      provider.getAttendeeTable.mockResolvedValue({ data: [{ attendeeId: 1 }], total: 6, page: 1, limit: 20 });
      const query = { page: 1, limit: 20 };

      const result = await facade.getLiveStatusV1(7, query as any, ['admin'], 1);

      expect(provider.getLiveStatus).toHaveBeenCalledWith(session);
      expect(provider.getKpis).toHaveBeenCalledWith(session);
      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, { page: 1, limit: 20 });
      // No separate top-level currentlyActive/currentlyInactive/totalPanelists — those live only
      // inside kpis, and only for this endpoint (see getLiveStatusV1's own doc comment) — never
      // buildAttendanceTiles, so /kpis and /attendees don't carry them.
      expect(result).toMatchObject({
        data: [{ attendeeId: 1 }],
        total: 6,
        page: 1,
        limit: 20,
        appliedKpi: null,
        sessionId: 7,
        isLive: true,
        currentlyJoined: 9,
        totalJoinedSoFar: 12,
        rejoinsSoFar: 2,
      });
      expect(result).not.toHaveProperty('totalPanelists');
      expect(result).not.toHaveProperty('currentlyActive');
      expect(result).not.toHaveProperty('currentlyInactive');
      expect(result.tableHeaders.length).toBeGreaterThan(0);
      // currentlyActive = totalSeekersJoined(6) - seekersDropped(2); currentlyInactive = totalPanelists(10) - currentlyActive(4).
      expect(result.kpis).toEqual([
        { label: 'Total Attendees', value: 10 },
        { label: 'Present', value: 6, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED },
        { label: 'Absent', value: 4, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.NOT_JOINED },
        { label: 'Joined Late', value: 1, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED_LATE },
        { label: 'Currently active', value: 4, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.ACTIVE },
        { label: 'Currently inactive', value: 6, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.INACTIVE },
      ]);
    });

    it("scopes the delegated getAttendeeTableV1 call to the RM caller's own contact id, same as getKpisV1", async () => {
      provider.getLiveStatus.mockResolvedValue({
        sessionId: 7,
        isLive: false,
        currentlyJoined: 0,
        totalJoinedSoFar: 0,
        rejoinsSoFar: 0,
        lastEventAt: null,
      });
      provider.getKpis.mockResolvedValue({
        sessionId: 7,
        startTime: null,
        durationMinutes: null,
        totalPanelists: 3,
        totalSeekersJoined: 1,
        seekersNotJoined: 2,
        seekersJoinedLate: 0,
        seekersDropped: 0,
        seekersRejoined: 0,
        finalPresent: 1,
        finalAbsent: 2,
        reconciledAt: null,
      });
      provider.getAttendeeTable.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 });
      const query = { page: 1, limit: 20 };

      await facade.getLiveStatusV1(7, query as any, ['relational_manager'], 42);

      expect(provider.getKpis).toHaveBeenCalledWith(session, 42);
      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, { page: 1, limit: 20 }, 42);
    });

    it('drops the rmContact column/value for an RM caller — it always echoes them back at themself', async () => {
      provider.getLiveStatus.mockResolvedValue({
        sessionId: 7,
        isLive: false,
        currentlyJoined: 0,
        totalJoinedSoFar: 0,
        rejoinsSoFar: 0,
        lastEventAt: null,
      });
      provider.getKpis.mockResolvedValue({
        sessionId: 7,
        startTime: null,
        durationMinutes: null,
        totalPanelists: 1,
        totalSeekersJoined: 1,
        seekersNotJoined: 0,
        seekersJoinedLate: 0,
        seekersDropped: 0,
        seekersRejoined: 0,
        finalPresent: 1,
        finalAbsent: 0,
        reconciledAt: null,
      });
      provider.getAttendeeTable.mockResolvedValue({
        data: [{ attendeeId: 1, rmContact: 'Some RM' }],
        total: 1,
        page: 1,
        limit: 20,
      });
      const query = { page: 1, limit: 20 };

      const result = await facade.getLiveStatusV1(7, query as any, ['relational_manager'], 42);

      expect(result.tableHeaders.map((header) => header.key)).not.toContain('rmContact');
      expect(result.data[0]).toMatchObject({ attendeeId: 1, rmContact: null });
    });
  });

  describe('getGeneralKpisV1', () => {
    it('returns the general-attendee figures (incl. known/unknown/loggedIn) plus a kpis tile array, with no "joined"/"not joined"/sideFilterSets tiles', async () => {
      provider.getGeneralKpis.mockResolvedValue({
        sessionId: 7,
        totalGeneralAttendees: 10,
        knownAttendees: 6,
        unknownAttendees: 4,
        generalLoggedIn: 7,
        generalJoinedLate: 2,
        generalDropped: 3,
        generalRejoined: 1,
      });

      const result = await facade.getGeneralKpisV1(7);

      expect(provider.getGeneralKpis).toHaveBeenCalledWith(session);
      expect(result.totalGeneralAttendees).toBe(10);
      expect(result.kpis).toEqual([
        { label: 'Total Attendees', value: 10 },
        { label: 'Logged In', value: 7, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED },
        { label: 'Joined Late', value: 2, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED_LATE },
        { label: 'Dropped', value: 3, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.DROPPED },
        { label: 'Unidentified', value: 4, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.UNKNOWN },
        { label: 'External Members', value: 6, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.KNOWN },
      ]);
      expect((result as any).sideFilterSets).toBeUndefined();
    });
  });

  describe('getAttendeeTableV1', () => {
    const rows = { data: [], total: 0, page: 1, limit: 20 };
    const seekerKpis = {
      sessionId: 7,
      startTime: null,
      durationMinutes: null,
      totalPanelists: 5,
      totalSeekersJoined: 3,
      seekersNotJoined: 2,
      seekersJoinedLate: 1,
      seekersDropped: 1,
      seekersRejoined: 0,
      finalPresent: 3,
      finalAbsent: 2,
      reconciledAt: null,
    };

    beforeEach(() => {
      provider.getKpis.mockResolvedValue(seekerKpis);
    });

    it('rejects a query with only one of filters.kpiCategory/filters.kpiFilter set', async () => {
      await expect(
        facade.getAttendeeTableV1(7, {
          page: 1,
          limit: 20,
          filters: { kpiCategory: SessionKpiCategory.ATTENDANCE },
        } as any),
      ).rejects.toBeInstanceOf(InifniBadRequestException);
      expect(webinarRepository.findById).not.toHaveBeenCalled();
    });

    it('accepts a query with no filters at all (no narrowing)', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20 };

      const result = await facade.getAttendeeTableV1(7, query as any);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, { page: 1, limit: 20 });
      expect(result.appliedKpi).toBeNull();
      expect(result.tableHeaders.length).toBeGreaterThan(0);
    });

    it('embeds the same seeker kpis tile array getKpisV1 would return, fetched from provider.getKpis (not getGeneralKpis)', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20 };

      const result = await facade.getAttendeeTableV1(7, query as any);

      expect(provider.getKpis).toHaveBeenCalledWith(session);
      expect(provider.getGeneralKpis).not.toHaveBeenCalled();
      expect(result.kpis).toEqual([
        { label: 'Total Attendees', value: 5 },
        { label: 'Present', value: 3, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED },
        { label: 'Absent', value: 2, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.NOT_JOINED },
        { label: 'Joined Late', value: 1, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED_LATE },
      ]);
    });

    it('echoes the applied KPI back on the response when filters.kpiCategory/filters.kpiFilter are both set', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = {
        page: 1,
        limit: 20,
        filters: { kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.DROPPED },
      };

      const result = await facade.getAttendeeTableV1(7, query as any);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, {
        page: 1,
        limit: 20,
        kpiCategory: SessionKpiCategory.ATTENDANCE,
        kpiFilter: SessionKpiFilter.DROPPED,
      });
      expect(result.appliedKpi).toEqual({ kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.DROPPED });
    });

    it("an admin/coordinator's explicit filters.rmContact scopes the table to that RM, with no actor scope of their own", async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20, filters: { rmContact: 55 } };

      await facade.getAttendeeTableV1(7, query as any, ['admin'], 1);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, { page: 1, limit: 20 }, 55);
    });

    it("an admin's explicit filters.rmContact scopes the table `data` but NOT the `kpis` tile counts", async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20, filters: { rmContact: 55 } };

      const result = await facade.getAttendeeTableV1(7, query as any, ['admin'], 1);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, { page: 1, limit: 20 }, 55);
      expect(provider.getKpis).toHaveBeenCalledWith(session);
      expect(result.kpis.find((tile) => tile.label === 'Total Attendees')?.value).toBe(seekerKpis.totalPanelists);
    });

    it("an RM caller's own actor-scope is used even when filters.rmContact matches it", async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20, filters: { rmContact: 42 } };

      await facade.getAttendeeTableV1(7, query as any, ['relational_manager'], 42);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, { page: 1, limit: 20 }, 42);
    });

    it('rejects an RM caller passing a filters.rmContact that is not their own — actor scope wins, no silent override', async () => {
      const query = { page: 1, limit: 20, filters: { rmContact: 999 } };

      await expect(
        facade.getAttendeeTableV1(7, query as any, ['relational_manager'], 42),
      ).rejects.toBeInstanceOf(InifniBadRequestException);
      expect(provider.getAttendeeTable).not.toHaveBeenCalled();
    });

    it('drops the rmContact column/value for an RM caller — it always echoes them back at themself', async () => {
      provider.getAttendeeTable.mockResolvedValue({
        data: [{ attendeeId: 1, rmContact: 'Some RM' }],
        total: 1,
        page: 1,
        limit: 20,
      });
      const query = { page: 1, limit: 20 };

      const result = await facade.getAttendeeTableV1(7, query as any, ['relational_manager'], 42);

      expect(result.tableHeaders.map((header) => header.key)).not.toContain('rmContact');
      expect(result.data[0]).toMatchObject({ attendeeId: 1, rmContact: null });
    });

    it('keeps the rmContact column/value for an admin/coordinator — it identifies which RM owns the row', async () => {
      provider.getAttendeeTable.mockResolvedValue({
        data: [{ attendeeId: 1, rmContact: 'Some RM' }],
        total: 1,
        page: 1,
        limit: 20,
      });
      const query = { page: 1, limit: 20 };

      const result = await facade.getAttendeeTableV1(7, query as any, ['admin'], 1);

      expect(result.tableHeaders.map((header) => header.key)).toContain('rmContact');
      expect(result.data[0]).toMatchObject({ attendeeId: 1, rmContact: 'Some RM' });
    });
  });

  describe('getGeneralAttendeesV1', () => {
    const rows = { data: [], total: 0, page: 1, limit: 20 };
    const generalKpis = {
      sessionId: 7,
      totalGeneralAttendees: 2,
      knownAttendees: 1,
      unknownAttendees: 1,
      generalLoggedIn: 2,
      generalJoinedLate: 0,
      generalDropped: 2,
      generalRejoined: 1,
    };

    beforeEach(() => {
      provider.getGeneralKpis.mockResolvedValue(generalKpis);
    });

    it('forces kpiCategory/kpiFilter to ATTENDANCE/GENERAL server-side, never exposing rmContact or the manual-mark filters', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20, search: 'walkin', sortKey: 'joinedAt', sortOrder: 'DESC' as const };

      const result = await facade.getGeneralAttendeesV1(7, query as any);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, {
        page: 1,
        limit: 20,
        search: 'walkin',
        kpiCategory: SessionKpiCategory.ATTENDANCE,
        kpiFilter: SessionKpiFilter.GENERAL,
        generalOnly: true,
        sortKey: 'joinedAt',
        sortOrder: 'DESC',
      });
      expect(result.appliedKpi).toEqual({ kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.GENERAL });
    });

    it('embeds the same general kpis tile array getGeneralKpisV1 would return, fetched from provider.getGeneralKpis (not getKpis)', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20 };

      const result = await facade.getGeneralAttendeesV1(7, query as any);

      expect(provider.getGeneralKpis).toHaveBeenCalledWith(session);
      expect(provider.getKpis).not.toHaveBeenCalled();
      expect(result.kpis).toEqual([
        { label: 'Total Attendees', value: 2 },
        { label: 'Logged In', value: 2, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED },
        { label: 'Joined Late', value: 0, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED_LATE },
        { label: 'Dropped', value: 2, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.DROPPED },
        { label: 'Unidentified', value: 1, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.UNKNOWN },
        { label: 'External Members', value: 1, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.KNOWN },
      ]);
    });

    it("ignores an RM caller's own actor-scope — a general row has no registration/rm_contact to scope by", async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20 };

      await facade.getGeneralAttendeesV1(7, query as any, ['relational_manager'], 42);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, {
        page: 1,
        limit: 20,
        kpiCategory: SessionKpiCategory.ATTENDANCE,
        kpiFilter: SessionKpiFilter.GENERAL,
        generalOnly: true,
      });
    });

    it("surfaces the general-link Value Card's latest bulk-send status for the session", async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const valueCard = {
        status: SessionCommunicationStatusEnum.TRIGGERED,
        requested: 12,
        emailSent: 11,
        whatsappSent: 0,
        skipped: 1,
        lastTriggeredAt: new Date('2026-07-29T10:00:00Z'),
      };
      sessionCommunicationService.getLatestSessionStatus.mockResolvedValue(valueCard);

      const result = await facade.getGeneralAttendeesV1(7, { page: 1, limit: 20 } as any);

      // Read for the general-link purpose, never the seeker VALUE_CARD — the two audiences have
      // separate status rows for the same session.
      expect(sessionCommunicationService.getLatestSessionStatus).toHaveBeenCalledWith(
        7,
        SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD,
      );
      expect(result.communications).toEqual({ valueCard });
      // The rest of the table response is untouched.
      expect(result.total).toBe(0);
      expect(result.appliedKpi).toEqual({
        kpiCategory: SessionKpiCategory.ATTENDANCE,
        kpiFilter: SessionKpiFilter.GENERAL,
      });
    });

    it('reports a null Value Card status when that send has never run for the session', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      sessionCommunicationService.getLatestSessionStatus.mockResolvedValue(null);

      const result = await facade.getGeneralAttendeesV1(7, { page: 1, limit: 20 } as any);

      expect(result.communications).toEqual({ valueCard: null });
    });

    it('forwards attendanceOutcome through, same as the main attendees endpoint', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20, attendanceOutcome: [SessionKpiFilter.DROPPED] };

      await facade.getGeneralAttendeesV1(7, query as any);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, {
        page: 1,
        limit: 20,
        kpiCategory: SessionKpiCategory.ATTENDANCE,
        kpiFilter: SessionKpiFilter.GENERAL,
        generalOnly: true,
        attendanceOutcome: [SessionKpiFilter.DROPPED],
      });
    });

    it("forwards a caller-chosen kpiFilter (e.g. joined) while keeping the general-only scope", async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20, kpiFilter: SessionKpiFilter.JOINED };

      const result = await facade.getGeneralAttendeesV1(7, query as any);

      expect(provider.getAttendeeTable).toHaveBeenCalledWith(session, {
        page: 1,
        limit: 20,
        kpiCategory: SessionKpiCategory.ATTENDANCE,
        kpiFilter: SessionKpiFilter.JOINED,
        generalOnly: true,
      });
      expect(result.appliedKpi).toEqual({ kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter: SessionKpiFilter.JOINED });
    });

    it('returns its own column set — full name/email/phone/in-session-status/type, no rejoinCount (main-screen-only)', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20 };

      const result = await facade.getGeneralAttendeesV1(7, query as any);

      const headerKeys = result.tableHeaders.map((header) => header.key);
      expect(headerKeys).toEqual([
        'fullName',
        'email',
        'mobile',
        'attendance',
        'joinedAt',
        'noOfDevices',
        'dropoffCount',
        'durationSeconds',
        'sourceTag',
      ]);
      expect(headerKeys).not.toContain('rejoinCount');
      expect(headerKeys.length).toBeGreaterThan(0);
    });

    it('marks the "Logged In" (joinedAt) column sortable — the only sortable general-attendee column (TAT ZS9)', async () => {
      provider.getAttendeeTable.mockResolvedValue(rows);
      const query = { page: 1, limit: 20 };

      const result = await facade.getGeneralAttendeesV1(7, query as any);

      const joinedAtHeader = result.tableHeaders.find((header) => header.key === 'joinedAt');
      expect(joinedAtHeader?.sortable).toBe(true);
      expect(result.tableHeaders.filter((header) => header.sortable).map((header) => header.key)).toEqual([
        'joinedAt',
      ]);
    });
  });

  describe('markAttendance — bypasses the resource-type provider, delegates to OnlineAttendanceService', () => {
    const attendee = { id: 42, sessionId: 7, registrationId: 100 } as any;

    it('maps source RM + attended=true to MANUAL_RM role and PRESENT status when the actor holds the RM role', async () => {
      attendeeSummaryRepository.findById.mockResolvedValue({ ...attendee });

      await facade.markAttendance(7, 42, ZoomAttendanceMarkSource.RM, true, 5, ['relational_manager']);

      expect(registry.resolveForOnlineType).not.toHaveBeenCalled();
      expect(onlineAttendanceService.markAttendance).toHaveBeenCalledWith(
        7,
        100,
        AttendanceStatus.PRESENT,
        ['relational_manager'],
        5,
      );
    });

    it('maps source COORDINATOR + attended=false to MANUAL_COORDINATOR role and ABSENT status when the actor holds the Coordinator role', async () => {
      attendeeSummaryRepository.findById.mockResolvedValue({ ...attendee });

      await facade.markAttendance(7, 42, ZoomAttendanceMarkSource.COORDINATOR, false, 9, ['shoba']);

      expect(onlineAttendanceService.markAttendance).toHaveBeenCalledWith(
        7,
        100,
        AttendanceStatus.ABSENT,
        ['shoba'],
        9,
      );
    });

    it('rejects source RM when the actor does not hold the RM active role, even as admin', async () => {
      await expect(facade.markAttendance(7, 42, ZoomAttendanceMarkSource.RM, true, 5, ['admin'])).rejects.toBeInstanceOf(
        ForbiddenException,
      );
      expect(attendeeSummaryRepository.findById).not.toHaveBeenCalled();
      expect(onlineAttendanceService.markAttendance).not.toHaveBeenCalled();
    });

    it('rejects source COORDINATOR when the actor holds the RM role instead', async () => {
      await expect(
        facade.markAttendance(7, 42, ZoomAttendanceMarkSource.COORDINATOR, true, 5, ['relational_manager']),
      ).rejects.toBeInstanceOf(ForbiddenException);
      expect(onlineAttendanceService.markAttendance).not.toHaveBeenCalled();
    });

    it('rejects an attendee id that does not exist', async () => {
      attendeeSummaryRepository.findById.mockResolvedValue(null);

      await expect(
        facade.markAttendance(7, 999, ZoomAttendanceMarkSource.RM, true, 1, ['relational_manager']),
      ).rejects.toBeInstanceOf(InifniNotFoundException);
      expect(onlineAttendanceService.markAttendance).not.toHaveBeenCalled();
    });

    it('rejects an attendee row with no linked registration', async () => {
      attendeeSummaryRepository.findById.mockResolvedValue({ ...attendee, registrationId: null });

      await expect(
        facade.markAttendance(7, 42, ZoomAttendanceMarkSource.RM, true, 1, ['relational_manager']),
      ).rejects.toBeInstanceOf(InifniNotFoundException);
      expect(onlineAttendanceService.markAttendance).not.toHaveBeenCalled();
    });

    it('rejects when the attendee belongs to a different session than the URL', async () => {
      attendeeSummaryRepository.findById.mockResolvedValue({ ...attendee, sessionId: 999 });

      await expect(
        facade.markAttendance(7, 42, ZoomAttendanceMarkSource.RM, true, 1, ['relational_manager']),
      ).rejects.toBeInstanceOf(InifniNotFoundException);
      expect(onlineAttendanceService.markAttendance).not.toHaveBeenCalled();
    });
  });
});
