import { ForbiddenException, Injectable } from '@nestjs/common';
import { ProgramSession } from 'src/common/entities';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { AttendanceStatus } from 'src/common/enum/attendance-status.enum';
import { ROLE_VALUES, ROLE_GUARD_STRINGS } from 'src/common/constants/strings-constants';
import { SessionKpiCategory, SessionKpiFilter } from 'src/common/enum/session-kpi.enum';
import { OnlineAttendanceService } from 'src/online-attendance/online-attendance.service';
import { SessionCommunicationService } from 'src/session-communication/session-communication.service';
import { SessionCommunicationPurposeEnum } from 'src/common/enum/session-communication-purpose.enum';
import { ZoomWebinarRepository } from '../repositories/zoom-webinar.repository';
import { ZoomRegistrationRepository } from '../repositories/zoom-registration.repository';
import { ZoomAnalyticsAttendeeSummaryRepository } from '../repositories/zoom-analytics-attendee-summary.repository';
import { ZoomProgramAnalyticsService } from './zoom-program-analytics.service';
import { ZoomAnalyticsProviderRegistry } from '../registries/zoom-analytics-provider.registry';
import { ZoomAnalyticsProvider } from '../interfaces/zoom-analytics-provider.interface';
import { ZoomAttendanceMarkSource } from 'src/common/enum/zoom-attendance-mark-source.enum';
import {
  ATTENDEE_SIDE_FILTER_SETS,
  ATTENDEE_SIDE_FILTER_CONTEXTUAL_FILTERS,
  ZOOM_ANALYTICS_DEFAULTS,
} from '../constants/zoom-analytics.constants';
import {
  ZoomSessionKpis,
  ZoomGeneralAttendeeKpis,
  ZoomLiveStatus,
  ZoomAttendeeQuery,
  PaginatedZoomAttendeeRows,
  ZoomSessionKpisV1,
  ZoomGeneralAttendeeKpisV1,
  ZoomLiveStatusV1,
  PaginatedZoomAttendeeRowsV1,
  PaginatedZoomGeneralAttendeeRowsV1,
  SessionKpiTile,
  SessionAttendeeTableHeader,
  ZoomAttendeeQueryV1Input,
  AnalyticsGeneralAttendeeQueryV1Input,
  SessionAttendeeSideFilter,
  SessionAttendeeFilterSets,
  ZoomSessionDashboard,
  PaginatedZoomFollowUpRows,
  ZoomSeekerAnalytics,
  ZoomSeekerSessionRow,
  ZoomProgramOverallAnalytics,
} from '../interfaces/zoom-analytics.interface';

/** The single active role a claimed ZoomAttendanceMarkSource maps to, for OnlineAttendanceService.markAttendance's role-derived source resolution. */
const MARK_SOURCE_ROLE: Record<ZoomAttendanceMarkSource, string> = {
  [ZoomAttendanceMarkSource.RM]: ROLE_VALUES.RELATIONAL_MANAGER,
  [ZoomAttendanceMarkSource.COORDINATOR]: ROLE_VALUES.COORDINATOR,
};

/**
 * The one service exported from ZoomModule for the Analytics module to
 * consume. Most methods resolve the provider matching the fetched session's
 * OWN `onlineType` via the registry, then delegate — never a global config
 * value, so webinar and meeting sessions are both handled correctly in the
 * same deployment. `markAttendance` deliberately bypasses the provider port
 * entirely — marking RM/Coordinator attendance is a human action, not a
 * Zoom-resource-specific concern — and delegates to OnlineAttendanceService,
 * the one canonical attendance table (program_user_attendance), rather than
 * writing a Zoom-only flag.
 */
@Injectable()
export class ZoomAnalyticsFacadeService {
  constructor(
    private readonly webinarRepository: ZoomWebinarRepository,
    private readonly attendeeSummaryRepository: ZoomAnalyticsAttendeeSummaryRepository,
    private readonly registry: ZoomAnalyticsProviderRegistry,
    private readonly onlineAttendanceService: OnlineAttendanceService,
    private readonly registrationRepository: ZoomRegistrationRepository,
    private readonly programAnalytics: ZoomProgramAnalyticsService,
    private readonly sessionCommunicationService: SessionCommunicationService,
  ) {}

  /**
   * `actorRoles`/`actorUserId` are the caller's real active role(s)/id (from the `active-role`
   * header, via req.user). An RM caller only ever sees their own seekers — the KPI/attendee
   * counts are scoped to registrations where `rm_contact` is that RM, never client-supplied.
   */
  async getKpis(sessionId: number, actorRoles: string[] = [], actorUserId: number | null = null): Promise<ZoomSessionKpis> {
    const session = await this.requireSession(sessionId);
    const provider = this.resolveProvider(session);
    const rmContactId = this.resolveRmContactId(actorRoles, actorUserId);
    return rmContactId ? provider.getKpis(session, rmContactId) : provider.getKpis(session);
  }

  /** The general-attendee counterpart to `getKpis` — no `rmContactId`: a general row has no registration, so no RM scoping applies. */
  async getGeneralKpis(sessionId: number): Promise<ZoomGeneralAttendeeKpis> {
    const session = await this.requireSession(sessionId);
    return this.resolveProvider(session).getGeneralKpis(session);
  }

  async getLiveStatus(sessionId: number): Promise<ZoomLiveStatus> {
    const session = await this.requireSession(sessionId);
    return this.resolveProvider(session).getLiveStatus(session);
  }

  async getAttendeeTable(
    sessionId: number,
    query: ZoomAttendeeQuery,
    actorRoles: string[] = [],
    actorUserId: number | null = null,
  ): Promise<PaginatedZoomAttendeeRows> {
    const session = await this.requireSession(sessionId);
    const provider = this.resolveProvider(session);
    const rmContactId = this.resolveRmContactId(actorRoles, actorUserId);
    return rmContactId ? provider.getAttendeeTable(session, query, rmContactId) : provider.getAttendeeTable(session, query);
  }

  /**
   * v1 — the same KPI figures as `getKpis`, plus a `kpis` tile array: each attendance-outcome figure
   * (present/absent/joinedLate) carries its own `kpiCategory`/`kpiFilter` so the frontend can pass a
   * clicked tile straight back into `getAttendeeTableV1`'s query params — clicking a KPI narrows the
   * attendee table to exactly the rows behind that count. Present/Absent read `finalPresent`/
   * `finalAbsent` (FINAL attendance — Coordinator > RM > Zoom webhook — see `buildAttendanceTiles`),
   * NOT `totalSeekersJoined`/`seekersNotJoined` (raw Zoom join activity, still on the payload for
   * anyone who wants the Zoom-only figure). Dropped/rejoined have no tile of their own —
   * the attendanceOutcome side filter already covers them, and `seekersDropped`/`seekersRejoined` stay
   * on the raw KPI payload for anyone who wants the count without a tile. `totalPanelists` and the
   * session-timing fields describe the session, not a seeker subset, so they're plain stats with no
   * `kpiCategory`/`kpiFilter` (not clickable). Also includes "Inactive seekers" — a DIFFERENT,
   * pre-existing metric (`ZoomRegistrationRepository.getEligibleActivationSummary`'s `inactiveCount`:
   * registrants provisioned onto this session but deactivated from it) rather than an attendance
   * outcome; it's not clickable because the roster query behind `getAttendeeTable` deliberately
   * excludes inactive registrants entirely (see `listBySession`'s activation-status EXISTS clause), so
   * there is no matching attendee-row filter to narrow to. Also returns `sideFilterSets` — the
   * attendees screen's independent side filters (rmContact/rm/coordinator/final, see
   * `SessionAttendeeSideFilter`) — folded into this one response rather than a separate
   * `filter-config` endpoint, since this KPIs call is already the natural "load this session's
   * analytics screen" entry point.
   */
  async getKpisV1(
    sessionId: number,
    actorRoles: string[] = [],
    actorUserId: number | null = null,
  ): Promise<ZoomSessionKpisV1> {
    const [kpis, session, sideFilterSets] = await Promise.all([
      this.getKpis(sessionId, actorRoles, actorUserId),
      this.requireSession(sessionId),
      this.buildSideFilterSets(actorRoles),
    ]);
    const inactiveSeekers = session.onlineSession
      ? (await this.registrationRepository.getEligibleActivationSummary(session.onlineSession.id)).inactiveCount
      : 0;

    return {
      ...kpis,
      kpis: ZoomAnalyticsFacadeService.buildAttendanceTiles(kpis),
      // tile('Inactive seekers', inactiveSeekers) — see buildAttendanceTiles; kept out for the same
      // reason the tile array construction originally commented it out.
      sideFilterSets,
    };
  }

  /**
   * v1 — exactly `getAttendeeTableV1`'s own paginated attendee rows + table headers + KPI tiles
   * (delegated to directly, so filters/sorting/pagination/RM-scoping/appliedKpi all behave identically
   * — this is the SAME table, not a second implementation of it), plus the unscoped `getLiveStatus`
   * snapshot, plus a "Currently active"/"Currently inactive" tile pair appended onto `kpis` — live-
   * monitor-only, not part of `buildAttendanceTiles`, so `/kpis` and `/attendees` never show them.
   * Derived from `getKpis` alone — no extra live-event scan: `getKpis` already computes the roster
   * live from the event log on every call, so `totalSeekersJoined - seekersDropped` IS "seekers who
   * joined and are still connected right now," no separate polling needed.
   */
  async getLiveStatusV1(
    sessionId: number,
    query: ZoomAttendeeQueryV1Input,
    actorRoles: string[] = [],
    actorUserId: number | null = null,
  ): Promise<ZoomLiveStatusV1> {
    const session = await this.requireSession(sessionId);
    const provider = this.resolveProvider(session);
    const rmContactId = this.resolveRmContactId(actorRoles, actorUserId);
    const [liveStatus, kpis, attendeeTable] = await Promise.all([
      provider.getLiveStatus(session),
      rmContactId ? provider.getKpis(session, rmContactId) : provider.getKpis(session),
      this.getAttendeeTableV1(sessionId, query, actorRoles, actorUserId),
    ]);
    const currentlyActive = Math.max(kpis.totalSeekersJoined - kpis.seekersDropped, 0);
    const currentlyInactive = Math.max(kpis.totalPanelists - currentlyActive, 0);

    return {
      ...attendeeTable,
      ...liveStatus,
      kpis: [
        ...attendeeTable.kpis,
        ZoomAnalyticsFacadeService.tile('Currently active', currentlyActive, SessionKpiFilter.ACTIVE),
        ZoomAnalyticsFacadeService.tile('Currently inactive', currentlyInactive, SessionKpiFilter.INACTIVE),
      ],
    };
  }

  /**
   * v1 — the general-attendee counterpart to `getKpisV1`: the general figures (including the
   * Known/Unknown split — see `ZoomGeneralAttendeeKpis`) plus a `kpis` tile array. No "Present"/
   * "Absent" tiles — those are seeker-roster concepts; a known-but-not-yet-joined
   * general attendee already shows up via `totalGeneralAttendees`/`knownAttendees`, and the join
   * outcome tiles (joinedLate/dropped/rejoined) only ever describe people who actually joined — so
   * only those are clickable; `totalGeneralAttendees`/`knownAttendees`/`unknownAttendees` are plain
   * stats, same reasoning as `totalPanelists` above. No `sideFilterSets` either — none of the main
   * screen's side filters apply to a row with no registration.
   */
  async getGeneralKpisV1(sessionId: number): Promise<ZoomGeneralAttendeeKpisV1> {
    const kpis = await this.getGeneralKpis(sessionId);
    return { ...kpis, kpis: ZoomAnalyticsFacadeService.buildGeneralAttendanceTiles(kpis) };
  }

  /**
   * v1 — adds clickable-KPI narrowing (`kpiCategory`/`kpiFilter`), an admin/coordinator-only explicit
   * `rmContact` scope-down filter, sorting, and response-shape additions (`tableHeaders`, `appliedKpi`,
   * `kpis`) on top of `getAttendeeTable`. kpiCategory/kpiFilter/rmContact all travel bundled inside
   * `query.filters` — the same wire convention as `registration-list-view`'s `filters` param —
   * rather than as separate top-level query params. An RM caller's own actor-scope always wins:
   * passing an `rmContact` that isn't their own is rejected (400) rather than silently ignored or
   * silently overridden — either would be a privilege-escalation-shaped bug hiding behind a query
   * param. `kpis` embeds the same tile array `getKpisV1`/`getGeneralKpisV1` return (fetched
   * alongside the table in one round trip, via the same `buildAttendanceTiles`/
   * `buildGeneralAttendanceTiles` helpers those endpoints use) so the frontend can render the KPI
   * tiles and the table from this single call, without a second request racing this one and
   * risking a tile/table mismatch — `generalOnly` picks which tile set and table headers apply,
   * same as it already picks the row scope.
   */
  async getAttendeeTableV1(
    sessionId: number,
    query: ZoomAttendeeQueryV1Input,
    actorRoles: string[] = [],
    actorUserId: number | null = null,
  ): Promise<PaginatedZoomAttendeeRowsV1> {
    const {
      kpiCategory,
      kpiFilter,
      rmContact,
      systemAttendance,
      rmAttendance,
      coordinatorAttendance,
      finalAttendance,
      attendanceOutcome,
      userType,
      generalOnly,
    } = query.filters ?? {};
    if (!!kpiCategory !== !!kpiFilter) {
      throw new InifniBadRequestException(ERROR_CODES.ZOOM_ANALYTICS_INVALID_KPI_FILTER);
    }
    // A general row has no registration and therefore no rm_contact to scope by — resolving an RM
    // caller's own id here would apply listBySession's rmContactId EXISTS clause (registration.id =
    // attendee.registration_id), which can never match a NULL registration_id, silently zeroing out
    // every general-attendee row for an RM caller.
    const rmContactId = generalOnly
      ? undefined
      : this.resolveEffectiveRmContactId(actorRoles, actorUserId, rmContact);
    // The `kpis` tile array always reflects the actor's own mandatory scope only — never an admin/
    // coordinator's explicit `rmContact` scope-down. That filter (like kpiCategory/kpiFilter/
    // systemAttendance/etc.) narrows the table `data`, not the tile counts; otherwise clicking
    // "scope to this RM" would also shrink the KPI totals out from under the caller.
    const kpiRmContactId = generalOnly ? undefined : this.resolveRmContactId(actorRoles, actorUserId);
    // An RM caller's own contact is themself on every row — showing an "RM Contact" column/value that
    // always echoes the viewer back at them is redundant, so it's dropped for that caller here (both
    // the header and the row value; getLiveStatusV1 inherits this since it spreads this response
    // verbatim). Admins/coordinators — including one explicitly scoped to a single RM via `rmContact` —
    // still see it, since for them it identifies WHICH RM owns the row.
    const isRmActor = actorRoles.includes(ROLE_VALUES.RELATIONAL_MANAGER);
    const session = await this.requireSession(sessionId);
    const provider = this.resolveProvider(session);
    const providerQuery: ZoomAttendeeQuery = {
      page: query.page,
      limit: query.limit,
      search: query.search,
      kpiCategory,
      kpiFilter,
      systemAttendance,
      rmAttendance,
      coordinatorAttendance,
      finalAttendance,
      attendanceOutcome,
      // A general attendee has no registration, so no user account to read a type from — passing this
      // through for `generalOnly` would apply an EXISTS clause that can never match a NULL
      // registration_id and silently zero out every general row (same trap as `rmContactId` above).
      userType: generalOnly ? undefined : userType,
      generalOnly,
      sortKey: query.sortKey,
      sortOrder: query.sortOrder,
    };
    const [result, kpiTiles] = await Promise.all([
      rmContactId
        ? provider.getAttendeeTable(session, providerQuery, rmContactId)
        : provider.getAttendeeTable(session, providerQuery),
      generalOnly
        ? provider.getGeneralKpis(session).then(ZoomAnalyticsFacadeService.buildGeneralAttendanceTiles)
        : (kpiRmContactId ? provider.getKpis(session, kpiRmContactId) : provider.getKpis(session)).then(
            ZoomAnalyticsFacadeService.buildAttendanceTiles,
          ),
    ]);

    return {
      ...result,
      data: isRmActor ? result.data.map((row) => ({ ...row, rmContact: null })) : result.data,
      tableHeaders: (generalOnly
        ? ZoomAnalyticsFacadeService.GENERAL_ATTENDEE_TABLE_HEADERS
        : ZoomAnalyticsFacadeService.ATTENDEE_TABLE_HEADERS
      ).filter((header) => !(isRmActor && header.key === 'rmContact')),
      appliedKpi:
        kpiCategory && kpiFilter && kpiFilter !== SessionKpiFilter.ALL ? { kpiCategory, kpiFilter } : null,
      kpis: kpiTiles,
      isAttendanceLocked: session.isAttendanceLocked,
    };
  }

  /**
   * v1 — dedicated "General attendees" list: attendee rows with no registrationId (shared/
   * common-link joins, not tied to any registration), without the rest of `getAttendeeTableV1`'s
   * surface. Forces `kpiCategory` to ATTENDANCE and `generalOnly: true` server-side rather than
   * accepting either from the caller — this endpoint's whole purpose is "only those rows," never
   * client-chosen. `kpiFilter` IS caller-chosen (defaulting to GENERAL, i.e. no extra condition,
   * when omitted) — `generalOnly` keeps `applyKpiFilter`'s `registration_id IS NULL` scope in force
   * no matter which value is picked, so the same clickable-tile values this screen's own tiles use
   * (joined/notJoined/dropped/rejoined/joinedLate/known/unknown) narrow the general-only rows too,
   * instead of being mutually exclusive with them. rmContact/systemAttendance/rmAttendance/coordinatorAttendance/
   * finalAttendance still aren't exposed: a general row has no registration, so it has no RM owner
   * and no manual marks to filter by. Still delegates to `getAttendeeTableV1` so RM/admin/coordinator
   * scoping and row shape stay identical to the main attendees screen — `generalOnly: true` alone is
   * enough for it to pick `GENERAL_ATTENDEE_TABLE_HEADERS` and the general `kpis` tile set on its own.
   *
   * `communications` adds the general-link ("pre-test") Value Card's latest bulk-send outcome for the
   * session — the same status projection the online-session GET responses carry for the seeker
   * purposes, read from hdb_session_communication_status via SessionCommunicationService (that domain
   * owns the table; no cross-domain DB access here). It rides along on this call, like `kpis` does,
   * so the screen that triggers the send renders its "already sent" state without a second request.
   * Fetched alongside the table rather than after it, since neither depends on the other.
   */
  async getGeneralAttendeesV1(
    sessionId: number,
    query: AnalyticsGeneralAttendeeQueryV1Input,
    actorRoles: string[] = [],
    actorUserId: number | null = null,
  ): Promise<PaginatedZoomGeneralAttendeeRowsV1> {
    const [table, valueCard] = await Promise.all([
      this.getAttendeeTableV1(
        sessionId,
        {
          page: query.page,
          limit: query.limit,
          search: query.search,
          filters: {
            kpiCategory: SessionKpiCategory.ATTENDANCE,
            kpiFilter: query.kpiFilter ?? SessionKpiFilter.GENERAL,
            attendanceOutcome: query.attendanceOutcome,
            generalOnly: true,
          },
          sortKey: query.sortKey,
          sortOrder: query.sortOrder,
        },
        actorRoles,
        actorUserId,
      ),
      this.sessionCommunicationService.getLatestSessionStatus(
        sessionId,
        SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD,
      ),
    ]);

    return { ...table, communications: { valueCard } };
  }

  async reconcile(sessionId: number): Promise<void> {
    const session = await this.requireSession(sessionId);
    return this.resolveProvider(session).reconcile(session);
  }

  /**
   * Cross-session rollup for one seeker (the seeker-detail screen): per-session
   * attendance/duration/devices from the attendee summaries, with each held
   * session's final attendance read from the same is_attended-backed source
   * `getKpis()`'s finalPresent/getAttendedRegistrationIds and every other
   * final-attendance consumer use (Coordinator > RM > Zoom system) — NOT a
   * fresh re-derivation from raw manual marks, which can disagree with that
   * precedence's own undo/timing handling. A session counts as held once its
   * scheduled end (or start, when no end is stored) has passed. Served
   * entirely from locally stored data.
   */
  async getSeekerAnalytics(programId: number, registrationId: number): Promise<ZoomSeekerAnalytics> {
    const [sessions, summaries, profile] = await Promise.all([
      this.webinarRepository.findByProgramId(programId),
      this.attendeeSummaryRepository.findAllByProgramAndRegistration(programId, registrationId),
      this.registrationRepository.findProfileByRegistrationId(registrationId),
    ]);
    const rmName = profile?.rmName ?? null;
    const summaryBySessionId = new Map(summaries.map((row) => [row.sessionId, row]));
    const nowMs = Date.now();
    const lateThresholdMs = ZOOM_ANALYTICS_DEFAULTS.LATE_JOIN_THRESHOLD_SECONDS * 1000;

    const rows: ZoomSeekerSessionRow[] = [];
    for (const session of sessions) {
      const endMs = session.endsAt?.getTime() ?? session.startsAt?.getTime() ?? null;
      const held = endMs !== null && endMs < nowMs;
      const summary = summaryBySessionId.get(session.id);

      let attended = false;
      if (held) {
        const attendedIds = await this.onlineAttendanceService.getAttendedRegistrationIds(session.id);
        attended = attendedIds.has(registrationId);
      }

      const joinedAt = summary?.joinedAt ?? null;
      const startMs = session.startsAt?.getTime() ?? null;
      const lateByMinutes =
        joinedAt && startMs !== null && joinedAt.getTime() - startMs > lateThresholdMs
          ? Math.round((joinedAt.getTime() - startMs) / 60_000)
          : null;

      rows.push({
        sessionId: session.id,
        startsAt: session.startsAt ?? null,
        held,
        attended,
        joinedAt,
        lateByMinutes,
        durationSeconds: summary?.durationSeconds ?? 0,
        deviceCount: summary?.noOfDevices ?? null,
        dropoffCount: summary?.dropoffCount ?? 0,
        rejoinCount: summary?.rejoinCount ?? 0,
      });
    }

    const heldRows = rows.filter((row) => row.held);
    const sessionsAttended = heldRows.filter((row) => row.attended).length;
    const threshold = ZOOM_ANALYTICS_DEFAULTS.ELIGIBILITY_THRESHOLD_SESSIONS;
    const eligibilityRows = rows.slice(0, threshold);
    const eligibilityDecided =
      eligibilityRows.length === threshold && eligibilityRows.every((row) => row.held);

    return {
      programId,
      registrationId,
      rmName,
      profile,
      sessionsHeld: heldRows.length,
      sessionsAttended,
      attendancePercent:
        heldRows.length > 0 ? Math.round((sessionsAttended / heldRows.length) * 100) : null,
      totalLearningSeconds: heldRows.reduce((sum, row) => sum + row.durationSeconds, 0),
      eligibility: {
        thresholdSession: threshold,
        status: !eligibilityDecided
          ? 'pending'
          : eligibilityRows.every((row) => row.attended)
            ? 'eligible'
            : 'not-eligible',
      },
      sessions: rows,
    };
  }

  /** Post-session analytics dashboard — served from locally stored data; Zoom is only hit by reconcile(). */
  async getDashboard(sessionId: number): Promise<ZoomSessionDashboard> {
    const session = await this.requireSession(sessionId);
    return this.resolveProvider(session).getDashboard(session);
  }

  /** Paginated "Seekers Who Need Attention" follow-up table — served from locally stored data. */
  async getFollowUps(sessionId: number, query: ZoomAttendeeQuery): Promise<PaginatedZoomFollowUpRows> {
    const session = await this.requireSession(sessionId);
    return this.resolveProvider(session).getFollowUps(session, query);
  }

  /** Overall Analytics (program-level) tab — KPI strip + session timeline, served from locally stored data. */
  async getProgramOverallAnalytics(programId: number): Promise<ZoomProgramOverallAnalytics> {
    return this.programAnalytics.getOverallAnalytics(programId);
  }

  /**
   * Marks a seeker's RM/Coordinator attendance via OnlineAttendanceService —
   * program_user_attendance (keyed by sessionId+registrationId, both already on the
   * zoom_analytics_attendee_summary row) is the one canonical attendance table; this
   * bypasses the resource-type provider since the write applies identically regardless
   * of Webinar vs Meeting. `attendeeId` still identifies the zoom summary row (device
   * count/duration/etc. stay there) — `sessionId` (the route's own path segment) is
   * validated against the attendee row's own sessionId so an attendeeId can't be marked
   * against a session it doesn't actually belong to. `actorRoles` is the caller's real
   * active role(s) (from the `active-role` header); the claimed `source` is only accepted
   * if it maps to a role the caller actually holds — an admin cannot claim source RM or
   * COORDINATOR without also holding that active role.
   */
  async markAttendance(
    sessionId: number,
    attendeeId: number,
    source: ZoomAttendanceMarkSource,
    attended: boolean,
    markedBy: number | null,
    actorRoles: string[],
  ): Promise<void> {
    if (!actorRoles.includes(MARK_SOURCE_ROLE[source])) {
      throw new ForbiddenException(ROLE_GUARD_STRINGS.UNAUTHORIZED);
    }

    const attendee = await this.attendeeSummaryRepository.findById(attendeeId);
    if (!attendee?.registrationId || attendee.sessionId !== sessionId) {
      throw new InifniNotFoundException(
        ERROR_CODES.ZOOM_ANALYTICS_ATTENDEE_NOTFOUND,
        null,
        null,
        attendeeId.toString(),
      );
    }

    await this.onlineAttendanceService.markAttendance(
      sessionId,
      attendee.registrationId,
      attended ? AttendanceStatus.PRESENT : AttendanceStatus.ABSENT,
      [MARK_SOURCE_ROLE[source]],
      markedBy ?? undefined,
    );
  }

  /** Only relational_manager actors get scoped to their own seekers — every other allowed role (admin, shoba) sees the full roster. */
  private resolveRmContactId(actorRoles: string[], actorUserId: number | null): number | undefined {
    return actorRoles.includes(ROLE_VALUES.RELATIONAL_MANAGER) && actorUserId ? actorUserId : undefined;
  }

  /**
   * Resolves `ATTENDEE_SIDE_FILTER_SETS`/`ATTENDEE_SIDE_FILTER_CONTEXTUAL_FILTERS` down to exactly
   * what THIS caller may use, mirroring `registration-list-view`'s own `{ baseSets, contextualFilters }`
   * filter-config shape one-for-one:
   * - `baseSets`: drops any filter whose `excludeRoles` matches one of `actorRoles`, and strips
   *   `excludeRoles` from what's left (it's now moot — everything remaining already applies to this
   *   caller, so echoing back roles it was excluded FOR would just leak irrelevant config;
   *   enforcement is server-side, not something the client is trusted to honor itself). `rmContact`'s
   *   `options` are populated here from real RM contacts (id/name), same source and "every RM with
   *   the role, not just ones with registrants yet" reasoning as
   *   `ZoomRegistrationRepository.listAllRmContacts` and the `online-session/eligible-registrations`
   *   screen's own `rmContact` dropdown (same key, same shape) — skipped entirely when `rmContact`
   *   didn't survive the role check (an RM caller never sees this filter, so there's nothing to
   *   populate for them).
   * - `contextualFilters`: each kpiFilter's key list is filtered down to keys that actually survived
   *   into `baseSets` above — so an RM caller's `contextualFilters` never dangling-references `rmContact`.
   */
  private async buildSideFilterSets(actorRoles: string[]): Promise<SessionAttendeeFilterSets> {
    const baseSets: Record<string, SessionAttendeeSideFilter[]> = {};
    for (const [key, filters] of Object.entries(ATTENDEE_SIDE_FILTER_SETS)) {
      const applicable = filters
        .filter((filter) => !filter.excludeRoles?.some((role) => actorRoles.includes(role)))
        .map((filter) => ({ key: filter.key, label: filter.label, type: filter.type, options: filter.options }));
      if (applicable.length) baseSets[key] = applicable;
    }

    if (baseSets.rmContact) {
      const rmContacts = await this.registrationRepository.listAllRmContacts();
      const options = rmContacts.map((rm) => ({ label: rm.name, value: String(rm.id) }));
      baseSets.rmContact = baseSets.rmContact.map((filter) => ({ ...filter, options }));
    }

    const contextualFilters: Record<string, string[]> = {};
    for (const [kpiFilter, keys] of Object.entries(ATTENDEE_SIDE_FILTER_CONTEXTUAL_FILTERS)) {
      contextualFilters[kpiFilter] = keys.filter((key) => key in baseSets);
    }

    return { baseSets, contextualFilters };
  }

  /**
   * v1 — combines the RM caller's own actor-scope with an admin/coordinator's explicit `rmContact`
   * scope-down filter. An RM caller has no way to widen their view via `explicitRmContact` (their own
   * `resolveRmContactId` always wins); if they pass a *different* RM's id, that's rejected outright
   * rather than silently ignored (which would look like a no-op bug) or silently overridden (which
   * would be a privilege-escalation-shaped bug).
   */
  private resolveEffectiveRmContactId(
    actorRoles: string[],
    actorUserId: number | null,
    explicitRmContact?: number,
  ): number | undefined {
    const actorRmContactId = this.resolveRmContactId(actorRoles, actorUserId);
    if (actorRmContactId && explicitRmContact && explicitRmContact !== actorRmContactId) {
      throw new InifniBadRequestException(
        ERROR_CODES.ZOOM_ANALYTICS_RM_SCOPE_CONFLICT,
        null,
        null,
        String(explicitRmContact),
      );
    }
    return actorRmContactId ?? explicitRmContact;
  }

  /** Column definitions for the v1 attendee table — mirrors `ZoomAttendeeRow`'s own fields. */
  private static readonly ATTENDEE_TABLE_HEADERS: SessionAttendeeTableHeader[] = [
    { key: 'fullName', label: 'Name', sortable: true, filterable: true, type: 'string' },
    // Zoom's own reported display name — can differ from the registration's fullName (e.g. joined
    // under a nickname); shown as its own column so a mismatch is visible, not silently overwritten.
    // { key: 'zoomDisplayName', label: 'Logged Name', sortable: false, filterable: true, type: 'string' },
    { key: 'rmContact', label: 'RM', sortable: false, filterable: false, type: 'string' },
    { key: 'mobile', label: 'Contact no.', sortable: false, filterable: true, type: 'string' },
    // { key: 'email', label: 'Email', sortable: false, filterable: true, type: 'string' },
    { key: 'joinedAt', label: 'Logged In', sortable: true, filterable: false, type: 'date' },
    { key: 'noOfDevices', label: 'No. of Login(s)', sortable: false, filterable: false, type: 'number' },
    { key: 'dropoffCount', label: 'Drop-off(s)', sortable: true, filterable: false, type: 'number' },
    { key: 'rejoinCount', label: 'Rejoin(s)', sortable: true, filterable: false, type: 'number' },
    { key: 'durationSeconds', label: 'Duration', sortable: true, filterable: false, type: 'number' },
    // One grouped "Attendance" column carrying all 4 sub-signals (system/rm/coordinator/final) — the
    // frontend renders System/RM/Coord/Final as sub-columns from this one object per row.
    { key: 'attendance', label: 'Attendance', sortable: false, filterable: false, type: 'string' },
  ];

  /**
   * General-attendees table headers — its own column set, not a filtered `ATTENDEE_TABLE_HEADERS`:
   * `fullName` here is the best-known display name (registered seekers don't reach this list, so in
   * practice this is either the generated link's own `displayName` or Zoom's reported name — see
   * `getAttendeeTable`'s fallback), and `email` (commented out on the seeker table, which shows
   * `mobile` but not `email`) is shown directly since a general row has no registration to look it
   * up from elsewhere. No `rmContact` here either — a general row has no registration to resolve an
   * RM from.
   * `attendance` only carries the `system` (ever-joined) sub-signal for a general row — rm/
   * coordinator/final all stay null (no registration to mark) — rendered here as "In-session
   * status". `sourceTag` ("Type") is the known/unknown badge: "Role" or "System" for a matched
   * generated link (by its `sourceType`, not its role key/batch name), or `null` for an unmatched
   * ("Unidentified") row.
   */
  private static readonly GENERAL_ATTENDEE_TABLE_HEADERS: SessionAttendeeTableHeader[] = [
    { key: 'fullName', label: 'Name', sortable: false, filterable: true, type: 'string' },
    { key: 'email', label: 'Email', sortable: false, filterable: true, type: 'string' },
    { key: 'mobile', label: 'Phone Number', sortable: false, filterable: true, type: 'string' },
    { key: 'attendance', label: 'In-session status', sortable: false, filterable: false, type: 'string' },
    { key: 'joinedAt', label: 'Logged In', sortable: true, filterable: false, type: 'date' },
    { key: 'noOfDevices', label: 'No. of Login(s)', sortable: false, filterable: false, type: 'number' },
    { key: 'dropoffCount', label: 'Drop-off(s)', sortable: false, filterable: false, type: 'number' },
    { key: 'durationSeconds', label: 'Duration', sortable: false, filterable: false, type: 'number' },
    { key: 'sourceTag', label: 'Type', sortable: false, filterable: true, type: 'string' },
  ];

  /** Builds one KPI tile — clickable (carries kpiCategory/kpiFilter) only when `kpiFilter` is passed. Shared by every v1 response that surfaces attendance-outcome tiles (`getKpisV1`, `getGeneralKpisV1`, and `getAttendeeTableV1`'s own `kpis` field), so they can never drift out of sync with each other. */
  private static tile(label: string, value: number, kpiFilter?: SessionKpiFilter): SessionKpiTile {
    return kpiFilter ? { label, value, kpiCategory: SessionKpiCategory.ATTENDANCE, kpiFilter } : { label, value };
  }

  /**
   * The registered-seeker tiles — shared by `getKpisV1` and `getAttendeeTableV1` so the list's
   * embedded `kpis` always matches the dedicated KPIs endpoint. No "Currently active"/"Currently
   * inactive" here — those are live-monitor-only tiles, added by `getLiveStatusV1` alone (see its own
   * doc comment), never `/kpis` or `/attendees`. Present/Absent read `finalPresent`/`finalAbsent` —
   * FINAL attendance (Coordinator > RM > Zoom webhook), not raw Zoom join activity — so they agree
   * with the "Final" column shown per row; `SessionKpiFilter.JOINED`/`NOT_JOINED` narrow the table by
   * that same final-attendance split (see `getAttendeeTable`'s `needsFinalPresenceFilter`).
   */
  private static buildAttendanceTiles(kpis: ZoomSessionKpis): SessionKpiTile[] {
    return [
      ZoomAnalyticsFacadeService.tile('Total Attendees', kpis.totalPanelists),
      ZoomAnalyticsFacadeService.tile('Present', kpis.finalPresent, SessionKpiFilter.JOINED),
      ZoomAnalyticsFacadeService.tile('Absent', kpis.finalAbsent, SessionKpiFilter.NOT_JOINED),
      ZoomAnalyticsFacadeService.tile('Joined Late', kpis.seekersJoinedLate, SessionKpiFilter.JOINED_LATE),
      // tile('Dropped', kpis.seekersDropped, SessionKpiFilter.DROPPED) and
      // tile('Rejoined', kpis.seekersRejoined, SessionKpiFilter.REJOINED) — dropped from the tile
      // list; the attendanceOutcome side filter already covers dropped/rejoined, a dedicated KPI
      // tile is redundant. DROPPED/REJOINED stay fully functional as kpiFilter/attendanceOutcome
      // values and seekersDropped/seekersRejoined stay on the raw ZoomSessionKpis payload — only the
      // tile is removed.
    ];
  }

  /**
   * The 6 general-attendee tiles — shared by `getGeneralKpisV1` and `getAttendeeTableV1` (when
   * `generalOnly`) so the general list's embedded `kpis` always matches the dedicated general KPIs
   * endpoint. Labels/order match the admin "General Link" screen: Total Attendees, Logged In,
   * Joined Late, Dropped, Unidentified, External Members. "Logged In" reuses `SessionKpiFilter.JOINED`
   * (already `is_system_attended = true`, which `applyKpiFilter` applies regardless of
   * `generalOnly`) rather than a new filter value. "Unidentified"/"External Members" (the
   * unknown/known split) are clickable via the dedicated `SessionKpiFilter.UNKNOWN`/`KNOWN` values —
   * there's no repository-level column for that split, so `applyKpiFilter` resolves it live with a
   * per-email EXISTS/NOT EXISTS match against `zoom_generated_registrant_link` (see its own comment).
   * `generalRejoined` is still computed and returned on the raw KPIs object, just not tiled here —
   * this screen doesn't show a Rejoined tile.
   */
  private static buildGeneralAttendanceTiles(kpis: ZoomGeneralAttendeeKpis): SessionKpiTile[] {
    return [
      ZoomAnalyticsFacadeService.tile('Total Attendees', kpis.totalGeneralAttendees),
      ZoomAnalyticsFacadeService.tile('Logged In', kpis.generalLoggedIn, SessionKpiFilter.JOINED),
      ZoomAnalyticsFacadeService.tile('Joined Late', kpis.generalJoinedLate, SessionKpiFilter.JOINED_LATE),
      ZoomAnalyticsFacadeService.tile('Dropped', kpis.generalDropped, SessionKpiFilter.DROPPED),
      ZoomAnalyticsFacadeService.tile('Unidentified', kpis.unknownAttendees, SessionKpiFilter.UNKNOWN),
      ZoomAnalyticsFacadeService.tile('Internal Members', kpis.knownAttendees, SessionKpiFilter.KNOWN),
    ];
  }

  private resolveProvider(session: ProgramSession): ZoomAnalyticsProvider {
    return this.registry.resolveForOnlineType(session.onlineType);
  }

  private async requireSession(sessionId: number) {
    const session = await this.webinarRepository.findById(sessionId);
    if (!session) {
      throw new InifniNotFoundException(ERROR_CODES.PROGRAM_SESSION_NOTFOUND, null, null, sessionId.toString());
    }
    return session;
  }
}
