import { ForbiddenException, Injectable } from '@nestjs/common';
import { ProgramRegistration, ProgramSession, ProgramUserAttendance } from 'src/common/entities';
import { AppLoggerService } from 'src/common/services/logger.service';
import { ExcelService } from 'src/common/services/excel.service';
import { formatDateTimeIST } from 'src/common/utils/common.util';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { AttendanceSourceEnum } from 'src/common/enum/attendance-source.enum';
import { AttendanceStatus } from 'src/common/enum/attendance-status.enum';
import {
  resolveAttendanceStatus,
  activeMarkFrom,
  resolveManualActor,
  isAttendedFromStatus,
} from 'src/common/utils/attendance-resolution.util';
import { assertAttendanceNotLocked } from 'src/common/utils/attendance-lock.util';
import { ModeOfJoiningEnum } from 'src/common/enum/mode-of-joining.enum';
import { AttendanceEvent } from 'src/common/interfaces/attendance-event.interface';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { SessionLaunchMode } from 'src/common/enum/session-launch-mode.enum';
import { computeJoinWindow } from 'src/common/utils/join-window.util';
import { OnlineAttendanceRepository } from './online-attendance.repository';
import { AttendanceReportQueryDto } from './dto/attendance-report-query.dto';
import { JoinSessionDto } from './dto/join-session.dto';
import {
  SessionAttendanceFilter,
  JoinSessionResult,
  SessionAttendanceReport,
  ProgramAttendanceReport,
  SessionReportSummary,
  AttendanceRecord,
  AttendanceBreakdown,
  RegistrationManualMarks,
  RecordZoomJoinParams,
  SessionAttendanceLockRecord,
} from './interfaces/online-attendance.interface';
import {
  ONLINE_ATTENDANCE_LOG,
  JOIN_OPENS_DEFAULT_MINUTES,
  JOIN_CLOSES_BUFFER_DEFAULT_MINUTES,
} from 'src/common/constants/online-attendance.constants';
import { ROLE_GUARD_STRINGS, ROLE_VALUES } from 'src/common/constants/strings-constants';

/** One flat attendee row in the Excel attendance export. */
type ExcelAttendanceRow = Record<string, string | number>;

/**
 * The only sources the zoom module's seeker table "Final" column weighs (see
 * `getManualMarksBySession`) — Zoom's own webhook, RM, and Coordinator (admin resolves at
 * Coordinator's rank already, dec-12). QR_SCAN is excluded from THIS derivation only — it still
 * fully participates in `resolveCreditedStatus` (isAttended / the online-attendance report's own
 * `attendanceStatus`). JOIN_CLICK is excluded from both: a lone click is never enough to credit
 * attendance anywhere in this module, so it's dropped in `resolveCreditedStatus` too.
 */
const SEEKER_TABLE_RESOLUTION_SOURCES: readonly AttendanceSourceEnum[] = [
  AttendanceSourceEnum.ZOOM_WEBHOOK,
  AttendanceSourceEnum.MANUAL_RM,
  AttendanceSourceEnum.MANUAL_COORDINATOR,
  AttendanceSourceEnum.MANUAL_ADMIN,
];

/**
 * Owns online program attendance: surfacing join links, marking attendance from
 * join-clicks / provider webhooks / admin actions, and attendance reports. All
 * attendance lives in program_user_attendance.attendance_events, one entry per
 * source. Provider-specific webhook parsing is delegated to an AttendanceWebhookAdapter.
 */
@Injectable()
export class OnlineAttendanceService {
  constructor(
    private readonly repository: OnlineAttendanceRepository,
    private readonly logger: AppLoggerService,
    private readonly excelService: ExcelService,
  ) {}

  // ---------------------------------------------------------------------------
  // Join (participant)
  // ---------------------------------------------------------------------------

  async joinSession(
    sessionId: number,
    userId: number,
    meta?: JoinSessionDto,
  ): Promise<JoinSessionResult> {
    const session = await this.requireSession(sessionId);
    const joinUrl = this.resolveJoinUrl(session);
    this.ensureJoinWindowOpen(session);

    // Prefer the explicit registration (proxy/child-safe): one user can hold
    // several registrations in a program, so the join is attributed to a specific
    // registration id when supplied, else the authenticated user's own.
    const registration = meta?.registrationId
      ? await this.repository.findConfirmedRegistrationById(session.programId, meta.registrationId)
      : await this.repository.findConfirmedRegistration(session.programId, userId);
    if (!registration) {
      throw new InifniBadRequestException(ERROR_CODES.NOT_REGISTERED_FOR_SESSION, null, null);
    }

    // Attendance is keyed by registration (proxy/child-safe), not the user id.
    let attendance = await this.repository.findBySessionAndRegistration(sessionId, registration.id);
    if (!attendance) {
      attendance = this.repository.create({
        // The row belongs to the registration's user (may differ from the
        // logged-in user when joining on behalf of a proxy/child registration).
        userId: registration.userId ?? userId,
        sessionId,
        programId: session.programId,
        registrationId: registration.id,
        registrationSeqNumber: registration.registrationSeqNumber ?? null,
        fullName: registration.fullName,
        email: registration.emailAddress,
        mobile: registration.mobileNumber,
        isAttended: false,
        attendanceEvents: [],
      });
    }
    // Refresh client metadata on every join — the latest device/mode wins.
    if (meta?.modeOfJoining !== undefined) attendance.modeOfJoining = meta.modeOfJoining;
    if (meta?.deviceType !== undefined) attendance.deviceType = meta.deviceType;
    if (meta?.deviceInfo !== undefined) attendance.deviceInfo = meta.deviceInfo;

    // performedBy/performedByRole record who actually clicked join — the authenticated caller
    // themselves (as opposed to creditCompanions below, where nobody identifiable performed the
    // click; it's an inferred credit off the primary joiner's own click).
    this.appendEvent(
      attendance,
      AttendanceSourceEnum.JOIN_CLICK,
      new Date(),
      userId,
      AttendanceStatus.PRESENT,
      ROLE_VALUES.SEEKER,
    );
    await this.repository.save(attendance);

    if (meta?.companionRegistrationIds?.length) {
      await this.creditCompanions(session, registration.id, meta.companionRegistrationIds);
    }

    return {
      joinUrl,
      onlineType: session.onlineType,
      launchMode: session.onlineSession?.launchMode ?? SessionLaunchMode.SDK,
      meetingId: session.onlineSession?.externalId ?? null,
      meetingPassword: session.onlineSession?.password ?? null,
      attendanceMarked: true,
    };
  }

  /**
   * Credits co-located companions who watched through the primary joiner's
   * device. Each companion must be a confirmed registration of the session's
   * PROGRAM (registrations are program-scoped); unknown/unconfirmed ids are
   * skipped (logged), never fabricated. A companion who already has their own
   * attendance is left untouched (their own join wins). Identified by
   * registration id so proxy/child registrations are handled.
   */
  private async creditCompanions(
    session: ProgramSession,
    primaryRegistrationId: number,
    companionRegistrationIds: number[],
  ): Promise<void> {
    const unique = Array.from(new Set(companionRegistrationIds)).filter(
      (id) => id !== primaryRegistrationId,
    );
    for (const registrationId of unique) {
      const registration = await this.repository.findConfirmedRegistrationById(
        session.programId,
        registrationId,
      );
      if (!registration) {
        this.logger.warn(ONLINE_ATTENDANCE_LOG.COMPANION_NOT_ELIGIBLE, {
          sessionId: session.id,
          registrationId,
        });
        continue;
      }

      let attendance = await this.repository.findBySessionAndRegistration(
        session.id,
        registrationId,
      );
      // A companion who already attended (on their own device) keeps that record.
      if (attendance?.isAttended) continue;

      if (!attendance) {
        attendance = this.repository.create({
          userId: registration.userId ?? null,
          sessionId: session.id,
          programId: session.programId,
          registrationId: registration.id,
          registrationSeqNumber: registration.registrationSeqNumber ?? null,
          fullName: registration.fullName,
          email: registration.emailAddress,
          mobile: registration.mobileNumber,
          isAttended: false,
          attendanceEvents: [],
        });
      }
      attendance.joinWithOthers = true;
      attendance.joinedWithRegistrationId = primaryRegistrationId;
      attendance.modeOfJoining = ModeOfJoiningEnum.JOINING_WITH_OTHERS;
      this.appendEvent(attendance, AttendanceSourceEnum.JOIN_CLICK, new Date(), null);
      await this.repository.save(attendance);
    }
  }

  /**
   * Enforces the admin-configured join window: the link opens
   * `joinOpensMinutesBefore` (default 15) before the session start and closes
   * `JOIN_CLOSES_BUFFER_DEFAULT_MINUTES` (default 10) after the session's
   * scheduled end. Sessions without a configured start time are not gated.
   */
  private ensureJoinWindowOpen(session: ProgramSession): void {
    if (!session.startsAt) return;
    const { opensAt, closesAt } = computeJoinWindow({
      startsAt: session.startsAt,
      joinOpensMinutesBefore: session.onlineSession?.joinOpensMinutesBefore,
      endsAt: session.endsAt,
      defaultOpensBeforeMinutes: JOIN_OPENS_DEFAULT_MINUTES,
      closeBufferMinutes: JOIN_CLOSES_BUFFER_DEFAULT_MINUTES,
    });

    const now = Date.now();
    if (opensAt && now < opensAt.getTime()) {
      throw new InifniBadRequestException(ERROR_CODES.JOIN_NOT_OPEN_YET, null, null);
    }
    if (closesAt && now > closesAt.getTime()) {
      throw new InifniBadRequestException(ERROR_CODES.JOIN_WINDOW_CLOSED, null, null);
    }
  }

  /** Resolves the correct join URL for a session based on its online type. */
  resolveJoinUrl(session: ProgramSession): string {
    let url: string | null | undefined;
    switch (session.onlineType) {
      case OnlineTypeEnum.MEETING:
        url = session.onlineSession?.joinUrl ?? session.meetingLink;
        break;
      case OnlineTypeEnum.WEBINAR:
        url = session.onlineSession?.joinUrl;
        break;
      case OnlineTypeEnum.LIVE_STREAM:
        url = session.onlineSession?.streamUrl;
        break;
      default:
        throw new InifniBadRequestException(ERROR_CODES.SESSION_NOT_ONLINE, null, null);
    }
    if (!url) {
      throw new InifniBadRequestException(ERROR_CODES.JOIN_LINK_NOT_CONFIGURED, null, null);
    }
    return url;
  }

  // ---------------------------------------------------------------------------
  // Cross-module read/write: consumed by the zoom module
  // ---------------------------------------------------------------------------

  /**
   * Every registrant's own RM/Coordinator manual mark for a session, independent of which source
   * won overall — e.g. a Coordinator's later "absent" still leaves the RM's own prior mark visible
   * here, even though it no longer decides `attendanceStatus`. Also resolves the overall effective
   * status via the same precedence engine used everywhere else in this module (`resolveAttendanceStatus`)
   * — the zoom module's seeker table renders this as the "Final" column, alongside the raw
   * RM/Coordinator marks and Zoom's own System mark. Used by the zoom module so it never imports
   * `ProgramUserAttendance` directly (cross-module reads go through this service, never the
   * repository/entity).
   */
  async getManualMarksBySession(sessionId: number): Promise<RegistrationManualMarks[]> {
    const rows = await this.repository.findAllBySession(sessionId);
    return rows
      .filter((row) => row.registrationId !== null)
      .map((row) => {
        // The zoom seeker table's "Final" column only ever weighs Zoom / RM / Coordinator (admin
        // resolves at the same rank as Coordinator, per dec-12 — no separate admin tier) — QR_SCAN and
        // JOIN_CLICK are deliberately excluded here even though the full precedence engine ranks them
        // too. A bare join-click with no Zoom webhook / manual mark is captured in the event log (still
        // visible to the report/report-based `attendanceStatus` elsewhere in this module) but must not
        // decide "Final" on this screen, where "System" already reports Zoom's own webhook-confirmed
        // presence — a click alone isn't that.
        const relevantEvents = row.attendanceEvents.filter((e) =>
          SEEKER_TABLE_RESOLUTION_SOURCES.includes(e.source),
        );
        const resolution = resolveAttendanceStatus(relevantEvents);
        return {
          registrationId: row.registrationId,
          rm: activeMarkFrom(row.attendanceEvents, AttendanceSourceEnum.MANUAL_RM),
          coordinator: activeMarkFrom(row.attendanceEvents, AttendanceSourceEnum.MANUAL_COORDINATOR),
          attendanceStatus: resolution.status,
          decidedBySource: resolution.decidedBySource,
        };
      });
  }

  /**
   * Registration ids credited attended for a session — a direct `is_attended` column read
   * (`OnlineAttendanceRepository.findAttendedRegistrationIdsBySession`), not a fresh precedence
   * resolution: `pushAndResolve` already keeps `is_attended` current on every mark/webhook/undo (see
   * `recomputeSummary`), so re-deriving it from `attendanceEvents` again would just be redundant work.
   * Used by the zoom module for its FINAL-attendance Present/Absent KPI split.
   */
  async getAttendedRegistrationIds(sessionId: number): Promise<Set<number>> {
    const registrationIds = await this.repository.findAttendedRegistrationIdsBySession(sessionId);
    return new Set(registrationIds);
  }

  /**
   * Finds the attendance row for (session, registrationId), falling back to an
   * email-keyed row ONLY when that row is unclaimed (registrationId null) or
   * already belongs to this same registrant. A sibling registration can share
   * one real email (proxy/child regs) — reusing an email match that already
   * belongs to a DIFFERENT registrant would silently misattribute this mark to
   * that sibling instead of creating a fresh row for the intended registrant.
   * Backfills the registrationId onto a newly-claimed unclaimed row (e.g. one
   * created by Zoom reconciliation before the registrationId could be resolved).
   */
  private async findAttendanceRow(
    sessionId: number,
    registrationId: number,
    email: string | null,
  ): Promise<ProgramUserAttendance | null> {
    const direct = await this.repository.findBySessionAndRegistration(sessionId, registrationId);
    if (direct) return direct;
    if (!email) return null;

    const emailMatch = await this.repository.findBySessionAndEmail(sessionId, email);
    if (!emailMatch) return null;
    if (emailMatch.registrationId != null && emailMatch.registrationId !== registrationId) {
      return null;
    }
    if (emailMatch.registrationId == null) emailMatch.registrationId = registrationId;
    return emailMatch;
  }

  /**
   * Appends a ZOOM_WEBHOOK PRESENT mark the moment Zoom's own participant_joined webhook fires — the
   * zoom module's device count/duration/dropoff/rejoin figures stay in zoom_analytics_attendee_summary
   * (unchanged), but the fact that Zoom saw this registrant join now also lands here, so the
   * precedence engine (rank 3, below RM/Coordinator/Admin, above QR/join-click) can weigh it against
   * any manual mark for the same registrant. Idempotent — a reconnect/rejoin doesn't append a second
   * mark, since only the first Zoom sighting is meaningful to the resolution engine (it doesn't rank
   * by recency within the same source).
   */
  async recordZoomJoin(params: RecordZoomJoinParams): Promise<void> {
    const session = await this.requireSession(params.sessionId);
    if (session.isAttendanceLocked) {
      this.logger.log(ONLINE_ATTENDANCE_LOG.ZOOM_JOIN_SKIPPED_LOCKED, {
        sessionId: params.sessionId,
        registrationId: params.registrationId,
      });
      return;
    }

    let attendance = await this.findAttendanceRow(
      params.sessionId,
      params.registrationId,
      params.email ?? null,
    );
    if (!attendance) {
      attendance = this.repository.create({
        userId: params.userId ?? undefined,
        sessionId: params.sessionId,
        programId: params.programId,
        registrationId: params.registrationId,
        fullName: params.fullName ?? undefined,
        email: params.email ?? undefined,
        mobile: params.mobile ?? undefined,
        isAttended: false,
        attendanceEvents: [],
      });
    }

    if (activeMarkFrom(attendance.attendanceEvents, AttendanceSourceEnum.ZOOM_WEBHOOK) === true) return;

    this.appendEvent(attendance, AttendanceSourceEnum.ZOOM_WEBHOOK, params.occurredAt, null);
    await this.repository.save(attendance);
  }

  // ---------------------------------------------------------------------------
  // Admin manual mark / unmark
  // ---------------------------------------------------------------------------

  /**
   * Manual mark by an RM or coordinator/admin. The source is derived from the
   * caller's roles (coordinator/admin → MANUAL_COORDINATOR, RM → MANUAL_RM;
   * TRD dec-12), never client-sent. The event is appended and the effective
   * attendance status recomputed by precedence — a higher-ranked prior mark still wins, so
   * "mark present" does not blindly force PRESENT (PRD BR-SATT-002). Identified
   * by registration (proxy/child-safe); reuses any existing row.
   *
   * Uses {@link OnlineAttendanceRepository.findRegistrationById} (not
   * `findConfirmedRegistrationById`) — unlike a LIVE join, marking attendance is
   * recording a historical fact about a session that already had its roster, so
   * a registration cancelled/archived/rejected AFTER the fact must still be
   * markable/correctable rather than being permanently locked out.
   */
  async markAttendance(
    sessionId: number,
    registrationId: number,
    status: AttendanceStatus,
    roles: string[] | undefined,
    actorUserId?: number,
  ): Promise<AttendanceRecord> {
    const actor = resolveManualActor(roles);
    if (!actor) {
      throw new ForbiddenException(ROLE_GUARD_STRINGS.UNAUTHORIZED);
    }
    const { source, role } = actor;
    const session = await this.requireSession(sessionId);
    assertAttendanceNotLocked(session, roles);
    const registration = await this.repository.findRegistrationById(session.programId, registrationId);
    if (!registration) {
      throw new InifniBadRequestException(ERROR_CODES.NOT_REGISTERED_FOR_SESSION, null, null);
    }
    if (source === AttendanceSourceEnum.MANUAL_RM) {
      this.assertRmScope(session, registration, actorUserId);
    }

    const attendance = await this.findOrCreateForRegistration(session, registration, actorUserId);

    // Idempotent: if this actor's latest active mark for this source is already
    // the same status, skip appending a duplicate event.
    if (!this.hasSameActiveMark(attendance, source, actorUserId ?? null, status)) {
      this.appendEvent(attendance, source, new Date(), actorUserId ?? null, status, role);
    }
    const resolvedStatus = resolveAttendanceStatus(attendance.attendanceEvents).status;
    attendance.isManuallyCheckedIn = resolvedStatus === AttendanceStatus.PRESENT;
    attendance.checkedInByUserId = resolvedStatus === AttendanceStatus.PRESENT ? actorUserId ?? null : null;
    attendance.updatedBy = actorUserId ?? attendance.updatedBy;

    const saved = await this.repository.save(attendance);
    this.logger.log(ONLINE_ATTENDANCE_LOG.ATTENDANCE_MARKED, {
      sessionId,
      registrationId,
      actorUserId,
      source,
      status,
      attendanceStatus: resolvedStatus,
    });
    return this.toRecord(saved);
  }

  /**
   * Owner-scoped undo (PRD dec-03): removes the caller's OWN mark of their
   * role-derived source outright — each source holds at most one mark, so
   * "undo" means the source's entry is gone, not superseded — then recomputes
   * the attendance status over what remains. An actor cannot undo another
   * actor's mark under the same source (e.g. a different RM) — to override,
   * they add their own higher-ranked mark instead.
   */
  async undoAttendance(
    sessionId: number,
    registrationId: number,
    roles: string[] | undefined,
    actorUserId?: number,
  ): Promise<AttendanceRecord | undefined> {
    const actor = resolveManualActor(roles);
    if (!actor) {
      throw new ForbiddenException(ROLE_GUARD_STRINGS.UNAUTHORIZED);
    }
    const { source } = actor;
    const session = await this.requireSession(sessionId);
    assertAttendanceNotLocked(session, roles);
    const attendance = await this.resolveAttendanceRow(session, sessionId, registrationId);
    if (!attendance) {
      this.logger.log(ONLINE_ATTENDANCE_LOG.ATTENDANCE_NOTHING_TO_UNMARK, { sessionId, registrationId });
      return undefined;
    }

    const current = (attendance.attendanceEvents ?? []).find((e) => e.source === source);
    if (!current || (current.performedBy ?? null) !== (actorUserId ?? null)) {
      this.logger.log(ONLINE_ATTENDANCE_LOG.ATTENDANCE_NOTHING_TO_UNMARK, { sessionId, registrationId });
      return this.toRecord(attendance);
    }

    attendance.attendanceEvents = (attendance.attendanceEvents ?? []).filter((e) => e.source !== source);
    const resolvedStatus = this.recomputeSummary(attendance);
    attendance.isManuallyCheckedIn = resolvedStatus === AttendanceStatus.PRESENT;
    attendance.updatedBy = actorUserId ?? attendance.updatedBy;
    const saved = await this.repository.save(attendance);
    this.logger.log(ONLINE_ATTENDANCE_LOG.ATTENDANCE_UNMARKED, {
      sessionId,
      registrationId,
      actorUserId,
      source,
      attendanceStatus: resolvedStatus,
    });
    return this.toRecord(saved);
  }

  /**
   * RM registrant-scope guard (PRD dec-07 / BR-SATT-004). Strict enforcement
   * needs a real registration→RM id, which does not exist yet (only an rmName
   * string) — see TRD risk-r3 / oq-06. Until then this logs-and-allows unless
   * ATTENDANCE_STRICT_RM_SCOPE is on and a linkable RM id is present.
   */
  private assertRmScope(
    session: ProgramSession,
    registration: ProgramRegistration,
    actorUserId?: number,
  ): void {
    const strict = process.env.ATTENDANCE_STRICT_RM_SCOPE === 'true';
    const assignedRmUserId = (registration as { rmUserId?: number | null }).rmUserId ?? null;
    if (!strict || assignedRmUserId === null) {
      this.logger.warn(ONLINE_ATTENDANCE_LOG.RM_SCOPE_NOT_ENFORCED, {
        sessionId: session.id,
        registrationId: registration.id,
        actorUserId,
      });
      return;
    }
    if (assignedRmUserId !== actorUserId) {
      throw new ForbiddenException(ROLE_GUARD_STRINGS.UNAUTHORIZED);
    }
  }

  /** True when this actor already holds `source`'s (sole) mark at the same `status`. */
  private hasSameActiveMark(
    attendance: ProgramUserAttendance,
    source: AttendanceSourceEnum,
    actorUserId: number | null,
    status: AttendanceStatus,
  ): boolean {
    const current = (attendance.attendanceEvents ?? []).find((e) => e.source === source);
    return (
      !!current &&
      (current.performedBy ?? null) === actorUserId &&
      (current.status ?? AttendanceStatus.PRESENT) === status
    );
  }

  /** Finds the attendance row by registration, falling back to the email-keyed row. */
  private async resolveAttendanceRow(
    session: ProgramSession,
    sessionId: number,
    registrationId: number,
  ): Promise<ProgramUserAttendance | null> {
    const registration = await this.repository.findConfirmedRegistrationById(
      session.programId,
      registrationId,
    );
    const email = registration?.emailAddress ?? registration?.user?.email ?? null;
    return this.findAttendanceRow(sessionId, registrationId, email);
  }

  /**
   * Admin: clears a registrant's attendance for an online session (resets the
   * summary flags, mirroring the QR undo-checkin). The event log itself is left
   * untouched. Idempotent — nothing-to-unmark is a no-op.
   */
  async unmarkAttendance(
    sessionId: number,
    registrationId: number,
    roles: string[] | undefined,
    adminUserId?: number,
  ): Promise<AttendanceRecord | undefined> {
    const session = await this.requireSession(sessionId);
    assertAttendanceNotLocked(session, roles);

    const registration = await this.repository.findConfirmedRegistrationById(session.programId, registrationId);
    const email = registration?.emailAddress ?? registration?.user?.email ?? null;
    const attendance = await this.findAttendanceRow(sessionId, registrationId, email);
    if (!attendance || !attendance.isAttended) {
      this.logger.log(ONLINE_ATTENDANCE_LOG.ATTENDANCE_NOTHING_TO_UNMARK, { sessionId, registrationId });
      return attendance ? this.toRecord(attendance) : undefined;
    }

    attendance.isAttended = false;
    attendance.isManuallyCheckedIn = false;
    attendance.checkedInAt = null;
    attendance.checkedInByUserId = null;
    attendance.updatedBy = adminUserId ?? attendance.updatedBy;

    const saved = await this.repository.save(attendance);
    this.logger.log(ONLINE_ATTENDANCE_LOG.ATTENDANCE_UNMARKED, { sessionId, registrationId, adminUserId });
    return this.toRecord(saved);
  }

  // ---------------------------------------------------------------------------
  // Coordinator lock / unlock
  // ---------------------------------------------------------------------------

  /**
   * Locks a session's attendance so only a Coordinator-role actor may edit it going
   * forward (RM, Admin, QR, and the Zoom webhook path are all blocked while locked —
   * see {@link assertAttendanceNotLocked}). Idempotent: locking an already-locked
   * session just refreshes who/when locked it.
   */
  async lockSessionAttendance(sessionId: number, actorUserId: number): Promise<SessionAttendanceLockRecord> {
    await this.requireSession(sessionId);
    const attendanceLockedAt = new Date();
    await this.repository.updateAttendanceLock(sessionId, {
      isAttendanceLocked: true,
      attendanceLockedBy: actorUserId,
      attendanceLockedAt,
      updatedBy: actorUserId,
    });
    this.logger.log(ONLINE_ATTENDANCE_LOG.ATTENDANCE_LOCKED, { sessionId, actorUserId });
    return { sessionId, isAttendanceLocked: true, attendanceLockedBy: actorUserId, attendanceLockedAt };
  }

  /** Unlocks a session's attendance. Idempotent: a no-op when already unlocked. */
  async unlockSessionAttendance(sessionId: number, actorUserId: number): Promise<SessionAttendanceLockRecord> {
    await this.requireSession(sessionId);
    await this.repository.updateAttendanceLock(sessionId, {
      isAttendanceLocked: false,
      attendanceLockedBy: null,
      attendanceLockedAt: null,
      updatedBy: actorUserId,
    });
    this.logger.log(ONLINE_ATTENDANCE_LOG.ATTENDANCE_UNLOCKED, { sessionId, actorUserId });
    return { sessionId, isAttendanceLocked: false, attendanceLockedBy: null, attendanceLockedAt: null };
  }

  /**
   * Finds the attendance row for a registration, reusing a pre-existing
   * email-keyed row (e.g. created by Zoom reconciliation) and back-filling its
   * registration id. Creates a fresh row, seeded from the registration, otherwise.
   */
  private async findOrCreateForRegistration(
    session: ProgramSession,
    registration: ProgramRegistration,
    adminUserId?: number,
  ): Promise<ProgramUserAttendance> {
    const email = registration.emailAddress ?? registration.user?.email ?? null;

    const attendance = await this.findAttendanceRow(session.id, registration.id, email);
    if (attendance) return attendance;

    return this.repository.create({
      sessionId: session.id,
      programId: session.programId,
      registrationId: registration.id,
      registrationSeqNumber: registration.registrationSeqNumber,
      userId: registration.userId ?? registration.user?.id ?? undefined,
      fullName: registration.fullName ?? registration.user?.fullName ?? undefined,
      email: email ?? undefined,
      mobile: registration.mobileNumber ?? undefined,
      isAttended: false,
      attendanceEvents: [],
      createdBy: adminUserId ?? undefined,
      updatedBy: adminUserId ?? undefined,
    });
  }


  // NOTE: manual / QR check-in is owned by the qr-attendance module
  // (QrAttendanceService.manualCheckin / scanQr). Those flows append
  // MANUAL_ADMIN / QR_SCAN events to the same attendance_events log, so the
  // report breakdown below covers all sources without duplicating check-in here.

  // ---------------------------------------------------------------------------
  // Reports
  // ---------------------------------------------------------------------------

  async sessionReport(
    sessionId: number,
    query: AttendanceReportQueryDto,
  ): Promise<SessionAttendanceReport> {
    const session = await this.requireSession(sessionId);
    const filter: SessionAttendanceFilter = {
      page: query.page,
      limit: query.limit,
      isAttended: query.isAttended,
      search: query.search,
    };
    const { data, total } = await this.repository.listBySession(sessionId, filter);
    const all = await this.repository.findAllBySession(sessionId);

    const totalAttended = all.filter((a) => a.isAttended).length;
    return {
      sessionId: session.id,
      sessionName: session.name,
      totalRegistrants: all.length,
      totalAttended,
      attendanceRate: this.rate(totalAttended, all.length),
      breakdown: this.breakdown(all),
      records: data.map((a) => this.toRecord(a)),
      pagination: { page: query.page, limit: query.limit, total },
    };
  }

  async programReport(
    programId: number,
    query: AttendanceReportQueryDto,
  ): Promise<ProgramAttendanceReport> {
    const sessions = await this.repository.listSessionsByProgram(programId, query.sessionId);
    const rowsBySession = this.groupBySession(
      await this.repository.findAllBySessionIds(sessions.map((s) => s.id)),
    );
    let totalRegistrants = 0;
    let totalAttended = 0;

    const sessionReports: SessionReportSummary[] = [];
    for (const session of sessions) {
      const all = rowsBySession.get(session.id) ?? [];
      const attended = all.filter((a) => a.isAttended).length;
      totalRegistrants += all.length;
      totalAttended += attended;
      sessionReports.push({
        sessionId: session.id,
        sessionName: session.name,
        startsAt: session.startsAt,
        totalRegistrants: all.length,
        totalAttended: attended,
        attendanceRate: this.rate(attended, all.length),
        breakdown: this.breakdown(all),
      });
    }

    return {
      programId,
      sessions: sessionReports,
      overallAttendanceRate: this.rate(totalAttended, totalRegistrants),
    };
  }

  // ---------------------------------------------------------------------------
  // Excel export
  // ---------------------------------------------------------------------------

  /** Builds an Excel export of a session's full attendance and returns its URL. */
  async exportSessionReport(sessionId: number): Promise<{ fileUrl: string }> {
    const session = await this.requireSession(sessionId);
    const all = await this.repository.findAllBySession(sessionId);
    const rows = all.map((a, index) => this.toExcelRow(a, index, session.name));
    const fileUrl = await this.uploadExcel(rows, `online-attendance/session-${sessionId}`);
    return { fileUrl };
  }

  /** Builds an Excel export of attendance across a program's sessions. */
  async exportProgramReport(
    programId: number,
    query: AttendanceReportQueryDto,
  ): Promise<{ fileUrl: string }> {
    const sessions = await this.repository.listSessionsByProgram(programId, query.sessionId);
    const rowsBySession = this.groupBySession(
      await this.repository.findAllBySessionIds(sessions.map((s) => s.id)),
    );
    const rows: ExcelAttendanceRow[] = [];
    for (const session of sessions) {
      const all = rowsBySession.get(session.id) ?? [];
      all.forEach((a) => rows.push(this.toExcelRow(a, rows.length, session.name)));
    }
    const fileUrl = await this.uploadExcel(rows, `online-attendance/program-${programId}`);
    return { fileUrl };
  }

  private toExcelRow(
    a: ProgramUserAttendance,
    index: number,
    sessionName: string,
  ): ExcelAttendanceRow {
    const sources = Array.from(
      new Set((a.attendanceEvents ?? []).map((e) => e.source)),
    ).join(', ');
    return {
      'S.No.': index + 1,
      'Session': sessionName ?? '',
      'Full Name': a.fullName ?? '',
      'Email': a.email ?? '',
      'Mobile': a.mobile ?? '',
      'Attended': a.isAttended ? 'Yes' : 'No',
      'Checked In At': formatDateTimeIST(a.checkedInAt),
      'Attendance Sources': sources,
    };
  }

  private async uploadExcel(
    rows: ExcelAttendanceRow[],
    filename: string,
  ): Promise<string> {
    // ExcelService rejects an empty dataset; hand it a single placeholder row so
    // an empty report still produces a valid (header-only) downloadable file.
    const data = rows.length ? rows : [this.emptyExcelRow()];
    return this.excelService.jsonToExcelAndUpload(
      data,
      `${filename}.xlsx`,
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
      { sheetName: 'Attendance', makeHeaderBold: true, autoWidth: true },
    );
  }

  private emptyExcelRow(): ExcelAttendanceRow {
    return {
      'S.No.': '',
      'Session': '',
      'Full Name': '',
      'Email': '',
      'Mobile': '',
      'Attended': '',
      'Checked In At': '',
      'Attendance Sources': '',
    };
  }

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  private appendEvent(
    attendance: ProgramUserAttendance,
    source: AttendanceSourceEnum,
    occurredAt: Date,
    performedBy: number | null,
    status: AttendanceStatus = AttendanceStatus.PRESENT,
    performedByRole: string | null = null,
  ): void {
    this.pushAndResolve(attendance, {
      source,
      status,
      occurredAt: occurredAt.toISOString(),
      performedBy,
      performedByRole,
    });
  }

  /**
   * The single write path for a mark: replaces `source`'s existing entry (if
   * any) with the new one — each source holds at most one event — then
   * recomputes the effective attendance status by precedence (SESSION_ATTENDANCE
   * TRD §3). attendanceStatus/decidedBySource are not stored — they're derived
   * from attendanceEvents on every read instead, so only the derived
   * isAttended/checkedInAt summary fields are refreshed here.
   */
  private pushAndResolve(attendance: ProgramUserAttendance, event: AttendanceEvent): void {
    const withoutSource = (attendance.attendanceEvents ?? []).filter((e) => e.source !== event.source);
    attendance.attendanceEvents = [...withoutSource, event];
    const status = this.recomputeSummary(attendance);
    if (status === AttendanceStatus.PRESENT) {
      attendance.checkedInAt = attendance.checkedInAt ?? new Date(event.occurredAt);
    }
  }

  /**
   * Same precedence engine as `resolveAttendanceStatus`, but with `JOIN_CLICK` events dropped first —
   * a lone click on the join link isn't proof of attendance, only Zoom's own webhook or a manual mark
   * is, so it must never single-handedly decide the credited status.
   */
  private resolveCreditedStatus(events: AttendanceEvent[] | null | undefined) {
    const credited = (events ?? []).filter((event) => event.source !== AttendanceSourceEnum.JOIN_CLICK);
    return resolveAttendanceStatus(credited);
  }

  /** Recomputes isAttended/checkedInAt from the current event log (used after a mark or an undo). */
  private recomputeSummary(attendance: ProgramUserAttendance): AttendanceStatus {
    const { status } = this.resolveCreditedStatus(attendance.attendanceEvents);
    attendance.isAttended = isAttendedFromStatus(status);
    if (status !== AttendanceStatus.PRESENT) attendance.checkedInAt = null;
    return status;
  }

  /** Buckets a flat list of attendance rows by their session id. */
  private groupBySession(
    rows: ProgramUserAttendance[],
  ): Map<number, ProgramUserAttendance[]> {
    const map = new Map<number, ProgramUserAttendance[]>();
    for (const row of rows) {
      const bucket = map.get(row.sessionId);
      if (bucket) bucket.push(row);
      else map.set(row.sessionId, [row]);
    }
    return map;
  }

  private breakdown(records: ProgramUserAttendance[]): AttendanceBreakdown {
    const counts: Record<string, number> = {
      [AttendanceSourceEnum.JOIN_CLICK]: 0,
      [AttendanceSourceEnum.ZOOM_WEBHOOK]: 0,
      [AttendanceSourceEnum.MANUAL_ADMIN]: 0,
      [AttendanceSourceEnum.QR_SCAN]: 0,
    };
    for (const record of records) {
      const seen = new Set<string>();
      for (const event of record.attendanceEvents ?? []) {
        if (!seen.has(event.source)) {
          seen.add(event.source);
          counts[event.source] = (counts[event.source] ?? 0) + 1;
        }
      }
    }
    return counts;
  }

  /** attendanceStatus/decidedBySource are computed from the event log here, never stored. */
  private toRecord(a: ProgramUserAttendance): AttendanceRecord {
    const { status, decidedBySource } = this.resolveCreditedStatus(a.attendanceEvents);
    return {
      attendanceId: a.id,
      userId: a.userId,
      fullName: a.fullName,
      email: a.email,
      mobile: a.mobile,
      isAttended: a.isAttended,
      attendanceStatus: status,
      decidedBySource,
      checkedInAt: a.checkedInAt,
      attendanceEvents: a.attendanceEvents ?? [],
    };
  }

  private rate(attended: number, total: number): string {
    if (!total) return '0%';
    return `${((attended / total) * 100).toFixed(1)}%`;
  }

  private async requireSession(sessionId: number): Promise<ProgramSession> {
    const session = await this.repository.findSessionById(sessionId);
    if (!session) {
      throw new InifniNotFoundException(
        ERROR_CODES.PROGRAM_SESSION_NOTFOUND,
        null,
        null,
        sessionId.toString(),
      );
    }
    return session;
  }
}
