import { Injectable } from '@nestjs/common';
import {
  OnlineSession,
  ProgramRegistration,
  ProgramRegistrationOnlineSession,
  User,
} from 'src/common/entities';
import { AppLoggerService } from 'src/common/services/logger.service';
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 { MainInifniException } from 'src/common/exceptions/infini-abstract-exception';
import { ExcelService } from 'src/common/services/excel.service';
import { ZoomRegistrationRepository } from '../repositories/zoom-registration.repository';
import { ZoomAnalyticsAttendeeSummaryRepository } from '../repositories/zoom-analytics-attendee-summary.repository';
import { ZoomFinalSessionAttendanceService } from './zoom-final-session-attendance.service';
import {
  RegistrationWithJoinUrl,
  EligibleActivationSummary,
  ProgramEligibleRegistrationRow,
  ProgramEligibleRegistrationsQuery,
  ProgramEligibleKpis,
  ProgramEligibleKpiTile,
  RmContactOption,
  RegistrationActivationResult,
} from '../interfaces/zoom-registration.interface';
import {
  ProgramEligibleKpiCategory,
  ProgramEligibleKpiFilter,
} from 'src/common/enum/program-eligible-kpi.enum';
import { SessionAttendanceSummary } from 'src/online-session/interfaces/online-session.interface';
import { RegistrationOnlineSessionActivationStatus } from 'src/common/enum/registration-online-session-activation-status.enum';
import { RegistrationOnlineSessionActivationSource } from 'src/common/enum/registration-online-session-activation-source.enum';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { ProgramSession } from 'src/common/entities';
import { WebinarService } from '../sessions/webinar.service';
import { MeetingService } from '../sessions/meeting.service';
import {
  ZoomContactInfo,
  ZoomSessionHandler,
  ZoomParticipantResult,
} from '../interfaces/zoom-session.interface';
import { SessionLinkModeEnum } from 'src/common/enum/session-link-mode.enum';
import { RegisterZoomUserDto } from '../dto/register-zoom-user.dto';
import { ZoomRole } from '../enums/zoom-role.enum';
import { SessionProviderType } from 'src/common/enum/session-provider.enum';
import { ZoomRegistrationUpdate } from '../enums/zoom-registration-update.enum';
import { OnlineSessionService } from 'src/online-session/services/online-session.service';
import { buildZoomRegistrantEmail } from '../utils/zoom-registrant-email.util';
import { PanelistBulkOutcome } from '../interfaces/zoom-registration.interface';
import { splitZoomContactName } from '../utils/zoom-registrant-name.util';
import { computeJoinWindow } from 'src/common/utils/join-window.util';
import { JOIN_OPENS_DEFAULT_MINUTES } from 'src/common/constants/online-attendance.constants';

/**
 * Common orchestration for adding/removing a program registrant to/from the Zoom
 * resource backing their session. The type-specific Zoom calls (registrant vs
 * panelist vs shared-link) live in the per-type handlers; this service owns the
 * shared parts: the `hdb_program_registration_online_session` extension, contact info, and
 * dispatch. The canonical registration is `hdb_program_registration`.
 */
@Injectable()
export class ZoomRegistrationService {
  constructor(
    private readonly registrationRepository: ZoomRegistrationRepository,
    private readonly webinar: WebinarService,
    private readonly meeting: MeetingService,
    private readonly logger: AppLoggerService,
    private readonly excelService: ExcelService,
    private readonly onlineSessionService: OnlineSessionService,
    private readonly attendeeSummaryRepository: ZoomAnalyticsAttendeeSummaryRepository,
    private readonly finalSessionAttendanceService: ZoomFinalSessionAttendanceService,
  ) {}

  /**
   * Per-session Registered/Attended/Absent + duration-bucket rollup for the
   * online-session list, keyed by program-session id. `registered` comes from
   * {@link ZoomRegistrationRepository.getEligibleActivationSummaries} — the
   * per-session extension count when the session has extension rows, or the
   * program's eligible-registration count otherwise; `attended`/`absent`/
   * duration buckets come from the reconciled attendee summaries (already
   * activation- and rm-scoped — see
   * {@link ZoomAnalyticsAttendeeSummaryRepository.getSessionAttendanceCounts}).
   * `rmContactId`, when given (an RM caller), scopes every count to their own contacts.
   *
   * `getSessionAttendanceCounts` counts any existing attendee-summary row that
   * isn't (yet) flagged attended as "absent" — it has no idea whether the
   * session has actually started, so a session whose rows happen to already
   * exist (e.g. seeded at provisioning time) can show a nonzero "absent" hours
   * before it begins. Gated here instead: before the session's pre-join window
   * opens (`computeJoinWindow` — the same "join opens N minutes before start"
   * rule the attendance/registration join guard uses), attended/absent/duration
   * are forced to 0 regardless of what the repository returned. Once the window
   * has opened (including after the session ends), the real reconciled counts
   * pass through unchanged.
   *
   * `registered` for a program's FINAL session additionally excludes anyone who missed an
   * already-elapsed earlier session (see
   * {@link ZoomFinalSessionAttendanceService.resolveFinalSessionAbsentees}) — but ONLY while it is
   * still the "program eligible count" fallback (no real extension row exists for it yet). Once a
   * real registration exists, `getEligibleActivationSummaries` already switches to counting actual
   * rows — which the bulk-registration gate keeps absentee-free going forward — so this never
   * retroactively hides a real number.
   */
  async getSessionAttendanceSummary(
    sessions: ProgramSession[],
    rmContactId?: number,
  ): Promise<Map<number, SessionAttendanceSummary>> {
    const map = new Map<number, SessionAttendanceSummary>();
    if (!sessions.length) return map;

    // `OnlineSession.id` / `online_session_id` are `bigint` columns — TypeORM/pg
    // return bigint values as strings at runtime despite the `number` TS type, so
    // every id must be coerced with `Number(...)` before use as a Map key, or
    // lookups below silently miss.
    const onlineSessionIdBySessionId = new Map<number, number>();
    const onlineSessionSummaryTargets: { onlineSessionId: number; programId: number }[] = [];
    for (const session of sessions) {
      const onlineSessionId = session.onlineSession?.id;
      if (onlineSessionId != null) {
        const numericOnlineSessionId = Number(onlineSessionId);
        onlineSessionIdBySessionId.set(session.id, numericOnlineSessionId);
        onlineSessionSummaryTargets.push({ onlineSessionId: numericOnlineSessionId, programId: session.programId });
      }
    }

    const [attendanceBySessionId, registeredByOnlineSessionId, finalSessionAdjustmentBySessionId] =
      await Promise.all([
        this.attendeeSummaryRepository.getSessionAttendanceCounts(
          sessions.map((s) => s.id),
          rmContactId,
        ),
        this.registrationRepository.getEligibleActivationSummaries(onlineSessionSummaryTargets, rmContactId),
        this.resolveFinalSessionRegisteredAdjustments(sessions, onlineSessionIdBySessionId, rmContactId),
      ]);

    const now = Date.now();
    for (const session of sessions) {
      const onlineSessionId = onlineSessionIdBySessionId.get(session.id);
      const attendance = attendanceBySessionId.get(session.id);
      const rawRegistered = onlineSessionId != null ? registeredByOnlineSessionId.get(onlineSessionId) ?? 0 : 0;
      const registeredAdjustment = finalSessionAdjustmentBySessionId.get(session.id) ?? 0;

      const { opensAt } = computeJoinWindow({
        startsAt: session.startsAt,
        joinOpensMinutesBefore: session.onlineSession?.joinOpensMinutesBefore,
        endsAt: session.endsAt,
        defaultOpensBeforeMinutes: JOIN_OPENS_DEFAULT_MINUTES,
      });
      const preJoin = opensAt != null && now < opensAt.getTime();

      map.set(session.id, {
        onlineSessionId: onlineSessionId ?? 0,
        registered: Math.max(0, rawRegistered - registeredAdjustment),
        attended: preJoin ? 0 : attendance?.attended ?? 0,
        absent: preJoin ? 0 : attendance?.absent ?? 0,
        durationBuckets: preJoin
          ? { under60: 0, from60to90: 0, from90to120: 0 }
          : attendance?.durationBuckets ?? { under60: 0, from60to90: 0, from90to120: 0 },
      });
    }
    return map;
  }

  /**
   * For each of `sessions` that is its own program's FINAL session (a program with >1 session)
   * AND doesn't yet have any real ACTIVE registration for it, the count of registrants who missed
   * an earlier elapsed session — to subtract from that session's "registered" fallback. Keyed by
   * program-session id.
   *
   * `programId` is optional on the online-session list (a page can span many different
   * programs), so this is written to stay cheap for the common case: per distinct programId
   * present in `sessions`, it first asks only for the final session id (one lightweight query)
   * and skips everything else unless THIS PAGE actually contains that program's final session —
   * a single-session program, or a multi-session one whose final session isn't on this page,
   * never pays for the eligible-registrations fetch or the per-elapsed-session attendance
   * queries that `resolveFinalSessionAbsentees` would otherwise run.
   */
  private async resolveFinalSessionRegisteredAdjustments(
    sessions: ProgramSession[],
    onlineSessionIdBySessionId: Map<number, number>,
    rmContactId?: number,
  ): Promise<Map<number, number>> {
    const adjustmentBySessionId = new Map<number, number>();
    const programIds = [...new Set(sessions.map((s) => s.programId))];

    await Promise.all(
      programIds.map(async (programId) => {
        const finalSessionId = await this.finalSessionAttendanceService.getFinalSessionId(programId);
        if (finalSessionId == null) return;

        const finalSession = sessions.find((s) => s.programId === programId && s.id === finalSessionId);
        const onlineSessionId = finalSession ? onlineSessionIdBySessionId.get(finalSession.id) : undefined;
        if (!finalSession || onlineSessionId == null) return;

        const eligible = await this.registrationRepository.findEligibleRegistrationsByProgram(
          programId,
          rmContactId,
        );
        const eligibleIds = eligible.map((r) => r.id);
        const absentees = await this.finalSessionAttendanceService.resolveFinalSessionAbsentees(
          programId,
          eligibleIds,
        );
        if (!absentees?.absenteeRegistrationIds.size) return;

        // A real (active) extension already exists for this session — `registered` already
        // reflects actual rows, not the eligible-count fallback, so never retroactively hide them.
        const activeKeys = await this.registrationRepository.findActiveExtensionKeys(eligibleIds, [
          onlineSessionId,
        ]);
        if (activeKeys.size > 0) return;

        adjustmentBySessionId.set(finalSession.id, absentees.absenteeRegistrationIds.size);
      }),
    );

    return adjustmentBySessionId;
  }

  /**
   * Admin "registration ↔ join URL" view for a session. Registrations are
   * program-scoped, so we resolve the session's program and list that program's
   * eligible registrants, each with its Zoom join URL (null until pushed to Zoom).
   */
  async listSessionRegistrations(
    sessionId: number,
    query: { page: number; limit: number; search?: string },
    rmContactId?: number,
  ): Promise<{
    data: RegistrationWithJoinUrl[];
    pagination: { page: number; limit: number; total: number };
  }> {
    const session = await this.resolveSession(sessionId);
    const { data, total } = await this.registrationRepository.listEligibleRegistrationsByProgram(
      session.programId,
      query,
      session.onlineSession?.id,
      rmContactId,
    );
    return { data, pagination: { page: query.page, limit: query.limit, total } };
  }

  /** Excel export of the registration ↔ join URL view; returns the file URL. */
  async exportSessionRegistrations(
    sessionId: number,
    search?: string,
    rmContactId?: number,
  ): Promise<{ fileUrl: string }> {
    const session = await this.resolveSession(sessionId);
    const rows = await this.registrationRepository.findEligibleRegistrationRowsByProgram(
      session.programId,
      search,
      session.onlineSession?.id,
      rmContactId,
    );
    const exportRows: (RegistrationWithJoinUrl | null)[] = rows.length ? rows : [null];
    const excelRows = exportRows.map((r: RegistrationWithJoinUrl | null, index: number) => ({
      'S.No.': r ? index + 1 : '',
      'Seq Number': r?.registrationSeqNumber ?? '',
      'Full Name': r?.fullName ?? '',
      'Email': r?.email ?? '',
      'Mobile': r?.mobile ?? '',
      'Registration Status': r?.registrationStatus ?? '',
      'Seat Allocated': r ? (r.seatAllocated ? 'Yes' : 'No') : '',
      'Registered to Zoom': r ? (r.joinUrl ? 'Yes' : 'No') : '',
      'Role': r ? (r.isPanelist ? 'Panelist' : 'Attendee') : '',
      'Join URL': r?.joinUrl ?? '',
    }));
    const fileUrl = await this.excelService.jsonToExcelAndUpload(
      excelRows,
      `zoom-registrations/session-${sessionId}.xlsx`,
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
      { sheetName: 'Registrations', makeHeaderBold: true, autoWidth: true },
    );
    return { fileUrl };
  }

  /**
   * Paginated, searchable list of a program's seat-allocated registrants — no
   * per-session joinUrl (that only means something scoped to one session; use
   * listSessionRegistrations for that view of the same eligible set).
   */
  async listProgramEligibleRegistrations(
    programId: number,
    query: ProgramEligibleRegistrationsQuery,
  ): Promise<{
    data: ProgramEligibleRegistrationRow[];
    pagination: { offset: number; limit: number; total: number };
    kpis: ProgramEligibleKpiTile[];
  }> {
    const [{ data, total }, kpis] = await Promise.all([
      this.registrationRepository.listSeatAllocatedRegistrationsByProgram(programId, query),
      // Unfiltered program totals — independent of this page's search/filters,
      // so the KPI tiles stay stable while the table below them is searched/filtered.
      this.registrationRepository.getProgramEligibleKpis(programId, query.rmContactId),
    ]);
    return {
      data,
      pagination: { offset: query.offset, limit: query.limit, total },
      kpis: ZoomRegistrationService.buildProgramEligibleKpiTiles(kpis),
    };
  }

  /**
   * Converts the flat {@link ProgramEligibleKpis} object into the same clickable tile-array shape
   * the zoom module's v1 attendee table uses (`SessionKpiTile` — see
   * `ZoomAnalyticsFacadeService.buildAttendanceTiles`): `label`/`value`, plus `kpiCategory`/
   * `kpiFilter` wherever clicking the tile narrows the table (via
   * `ZoomRegistrationRepository.resolveProgramEligibleKpiFilterIds`). `totalEligible`/
   * `totalSessions` are plain stats — neither is keyed by a `ProgramEligibleKpiFilter` — so those
   * two tiles never carry `kpiCategory`/`kpiFilter`. No "Absent At Least Once"/"Present For All" tile
   * here — those use a different session-set/criteria than the final-session eligibility check (can
   * overlap with it rather than complement it) and would be confusing shown alongside it; they're
   * still exposed on {@link ProgramEligibleKpis} itself for callers that want that separate stat.
   * "Not Eligible For S{n}" is `notEligibleForFinalSession`, the STRICT complement of
   * `eligibleForFinalSession` (they always sum to `totalEligible`) — not `absentAtLeastOnce`. Both
   * final-session tiles always render, reported as 0 when null (program has ≤ 1 session — no "final
   * session" concept applies) rather than omitted.
   */
  private static buildProgramEligibleKpiTiles(kpis: ProgramEligibleKpis): ProgramEligibleKpiTile[] {
    return [
      { label: 'Total Attendees', value: kpis.totalEligible },
      {
        label: 'Active',
        value: kpis.active,
        kpiCategory: ProgramEligibleKpiCategory.ELIGIBILITY,
        kpiFilter: ProgramEligibleKpiFilter.ACTIVE,
      },
      {
        label: 'Inactive',
        value: kpis.inactive,
        kpiCategory: ProgramEligibleKpiCategory.ELIGIBILITY,
        kpiFilter: ProgramEligibleKpiFilter.INACTIVE,
      },
      {
        label: `Not Eligible For S${kpis.totalSessions}`,
        value: kpis.notEligibleForFinalSession ?? 0,
        kpiCategory: ProgramEligibleKpiCategory.ELIGIBILITY,
        kpiFilter: ProgramEligibleKpiFilter.NOT_ELIGIBLE_FOR_FINAL_SESSION,
      },
      // { label: 'Total Sessions', value: kpis.totalSessions },
      {
        label: `Eligible For S${kpis.totalSessions}`,
        value: kpis.eligibleForFinalSession ?? 0,
        kpiCategory: ProgramEligibleKpiCategory.ELIGIBILITY,
        kpiFilter: ProgramEligibleKpiFilter.ELIGIBLE_FOR_FINAL_SESSION,
      },
    ];
  }

  /** The dynamic half of the eligible-registrations filter set: every user holding the RM role. */
  async getProgramEligibleRmContacts(): Promise<RmContactOption[]> {
    return this.registrationRepository.listAllRmContacts();
  }

  /** Resolves the program session, validating it owns a program (registrations are program-scoped). */
  private async resolveSession(sessionId: number): Promise<ProgramSession> {
    const session = await this.onlineSessionService.findOne(sessionId);
    if (!session.programId) {
      throw new InifniBadRequestException(
        ERROR_CODES.ZOOM_SESSION_NOT_PROVISIONED,
        null,
        null,
        sessionId.toString(),
      );
    }
    return session;
  }

  /**
   * Registration-level activation toggle: flips the registrant's active/inactive
   * state for EVERY upcoming session of their program (sessions starting from
   * now on). No session parameter — past sessions are never in scope, so their
   * rows, counts, and attendance history stay intact. Blocked entirely (whole
   * program, every registrant) while ANY session of the program is currently
   * in progress — flipping join links mid-session risks disrupting an
   * in-progress meeting for whoever is already in it.
   * Deactivating removes each upcoming session's Zoom join link (provider-level)
   * without deleting the extension rows, so reactivating later re-provisions
   * them in place. Sessions already in the requested state are skipped
   * (idempotent re-runs). The registration's rollup status is always persisted,
   * even when no session rows needed changing, so the overall state is
   * authoritative for list views.
   */
  async setRegistrationActivation(
    registrationId: number,
    activationStatus: RegistrationOnlineSessionActivationStatus,
    reason: string | null | undefined,
    actingUserId: number | null | undefined,
  ): Promise<RegistrationActivationResult> {
    const registration = await this.requireRegistration(registrationId);

    if (!registration.seatAllocated) {
      throw new InifniBadRequestException(
        ERROR_CODES.ZOOM_REGISTRATION_NOT_ELIGIBLE,
        null,
        null,
        registrationId.toString(),
      );
    }

    const now = new Date();

    if (registration.programId) {
      const hasLiveSession = await this.registrationRepository.hasLiveSessionForProgram(
        registration.programId,
        now,
      );
      if (hasLiveSession) {
        throw new InifniBadRequestException(
          ERROR_CODES.ZOOM_SESSION_IN_PROGRESS,
          null,
          null,
          registrationId.toString(),
        );
      }
    }

    // Every upcoming session of the registrant's program they are provisioned
    // on. A registration without a program has nothing to cascade over — the
    // rollup below is still persisted.
    const cascade = registration.programId
      ? await this.registrationRepository.findProvisionedExtensionsFromSession(
          registration.id,
          registration.programId,
          now,
        )
      : [];

    // Reactivating a row whose activationSource shows the registration lifecycle
    // cascade already deactivated it (ARCHIVED/CANCELLED/DELETED) is never allowed
    // here — the registration itself must be un-cancelled/un-archived/restored
    // first. Blocks the whole request rather than silently skipping the terminal
    // rows, since a registration is cancelled/archived/deleted as a whole, not
    // per session.
    if (activationStatus === RegistrationOnlineSessionActivationStatus.ACTIVE) {
      const hasTerminalRow = cascade.some(
        (item) =>
          item.extension.activationSource === RegistrationOnlineSessionActivationSource.ARCHIVED ||
          item.extension.activationSource === RegistrationOnlineSessionActivationSource.CANCELLED ||
          item.extension.activationSource === RegistrationOnlineSessionActivationSource.DELETED,
      );
      if (hasTerminalRow) {
        throw new InifniBadRequestException(
          ERROR_CODES.ZOOM_ACTIVATION_STATUS_LOCKED,
          null,
          null,
          registrationId.toString(),
        );
      }
    }

    // Already-in-state sessions are skipped (idempotent re-runs). A live
    // session is never in this cascade to begin with (findProvisionedExtensionsFromSession
    // scopes to starts_at >= now), and the upfront check above already blocked
    // the whole call if one exists.
    const toChange = cascade.filter((item) => item.extension.activationStatus !== activationStatus);

    const reasonValue = reason ?? null;
    const actingUserIdValue = actingUserId ?? null;

    try {
      // Provider calls first, DB writes after — if Zoom rejects any call the
      // whole toggle fails with no rows written.
      const updates: {
        item: (typeof toChange)[number];
        joinUrl: string | null;
        externalRegistrantId?: string | null;
      }[] = [];
      // A recurring/shared-link meeting or webinar backs several upcoming
      // ProgramSession occurrences with the SAME underlying Zoom resource
      // (same `onlineSession.externalId`) — Zoom registers/removes a
      // registrant per MEETING, not per occurrence, so every cascade item on
      // that resource is really the same Zoom-side add/remove action. Calling
      // it once per occurrence anyway means the second removal 404s
      // ("registrant not found") since the first call already removed them —
      // which aborted the whole toggle before any DB row was written, even
      // though Zoom-side the registrant really was gone. The registration
      // (contact) is fixed for the whole cascade, so dedupe purely on the
      // target Zoom resource; every cascade item sharing it still gets its
      // own DB row updated below from the single shared result.
      const contact = this.contactInfo(registration);
      const callZoomOnce = new Map<string, Promise<ZoomParticipantResult | void>>();
      for (const item of toChange) {
        const dedupeKey = item.session.onlineSession?.externalId ?? null;

        if (activationStatus === RegistrationOnlineSessionActivationStatus.INACTIVE) {
          let call = dedupeKey ? callZoomOnce.get(dedupeKey) : undefined;
          if (!call) {
            call = this.handlerFor(item.session).removeParticipant(item.session, item.extension, contact);
            if (dedupeKey) callZoomOnce.set(dedupeKey, call);
          }
          await call;
          updates.push({ item, joinUrl: null });
        } else {
          let call = dedupeKey ? callZoomOnce.get(dedupeKey) : undefined;
          if (!call) {
            // A cancelled registrant (see the INACTIVE branch above) is still on
            // file with Zoom — re-approve that same registrant id to restore
            // their original join link instead of registering them fresh, which
            // would either duplicate or collide with the cancelled record.
            call = item.extension.externalRegistrantId
              ? this.handlerFor(item.session).approveParticipant(item.session, item.extension, contact)
              : this.handlerFor(item.session).addParticipant(item.session, contact, ZoomRole.ATTENDEE, true);
            if (dedupeKey) callZoomOnce.set(dedupeKey, call);
          }
          const result = (await call) as ZoomParticipantResult;
          updates.push({ item, joinUrl: result.joinUrl, externalRegistrantId: result.zoomRegistrantId });
        }
      }

      const activationSource =
        activationStatus === RegistrationOnlineSessionActivationStatus.INACTIVE
          ? RegistrationOnlineSessionActivationSource.INACTIVE
          : RegistrationOnlineSessionActivationSource.ACTIVE;

      await this.registrationRepository.withTransaction(async (manager) => {
        for (const update of updates) {
          await this.registrationRepository.updateActivationStatus(
            update.item.extension.id,
            {
              activationStatus,
              activationSource,
              joinUrl: update.joinUrl,
              ...(update.externalRegistrantId !== undefined
                ? { externalRegistrantId: update.externalRegistrantId }
                : {}),
              activationChangedBy: actingUserIdValue,
              activationReason: reasonValue,
            },
            manager,
          );
          if (update.item.extension.onlineSessionId != null) {
            await this.registrationRepository.adjustEligibleActiveCount(
              update.item.extension.onlineSessionId,
              activationStatus === RegistrationOnlineSessionActivationStatus.INACTIVE ? -1 : 1,
              manager,
            );
          }
        }
        // Always persist the rollup — the overall status is the point of this toggle.
        await this.registrationRepository.updateRegistrationActivationStatus(
          registration.id,
          activationStatus,
          manager,
        );
      });
    } catch (error) {
      this.logger.error('Error updating registration activation status', error?.stack, {
        error: error instanceof MainInifniException ? error.toStringDetail() : error,
        registrationId,
        activationStatus,
      });
      // A MainInifniException here is already a specific, well-formed error from the
      // provider (e.g. ZOOM_API_ERROR carrying Zoom's own rejection reason) or the
      // repository (via handleKnownErrors) — rethrow it as-is instead of stomping it
      // with the generic Z_BR_010 message, which hid the actionable reason from callers.
      if (error instanceof MainInifniException) {
        throw error;
      }
      throw new InifniBadRequestException(
        ERROR_CODES.ZOOM_ACTIVATION_UPDATE_FAILED,
        error instanceof Error ? error : null,
        null,
        registrationId.toString(),
      );
    }

    const updatedOnlineSessionIds = toChange
      .map((item) => item.extension.onlineSessionId)
      .filter((id): id is number => id != null);
    this.logger.log('Registration activation status updated (all upcoming sessions)', {
      registrationId,
      updatedOnlineSessionIds,
      activationStatus,
    });
    return { registrationId, activationStatus, updatedOnlineSessionIds };
  }

  /**
   * Called for a registration that has been cancelled/archived/soft-deleted to
   * release Zoom access on every upcoming session — past/live sessions are
   * never touched, since `findProvisionedExtensionsFromSession` scopes to
   * `starts_at >= now`. Each affected row's `activationStatus` is set to
   * `INACTIVE` and its `activationSource` records why (`ARCHIVED`/`CANCELLED`/
   * `DELETED`), so a manual reactivation attempt later can tell "the
   * registration was terminated" apart from "an admin just toggled this off".
   *
   * For cancel/archive, callers invoke this AFTER that DB change already
   * committed — cancelling/archiving doesn't touch `deletedAt`, so the
   * registration is still visible to `requireRegistration`'s lookup. For soft
   * delete, callers MUST invoke this BEFORE `deletedAt` is set — once the
   * registration is soft-deleted it disappears from that same lookup
   * (`deletedAt: IsNull()`), and this call would silently no-op.
   *
   * Best-effort by design: cancelling/archiving/deleting a registration must
   * never fail because Zoom is unreachable. Each session is removed from Zoom
   * and updated independently — one failure is logged and skipped, leaving
   * that row `ACTIVE` for later reconciliation, while the rest still get
   * cleaned up. The registration's rollup is always updated to `INACTIVE`,
   * since the registration itself is terminal regardless of individual Zoom
   * outcomes. Never throws.
   */
  async cascadeTerminalActivation(
    registrationId: number,
    source:
      | RegistrationOnlineSessionActivationSource.ARCHIVED
      | RegistrationOnlineSessionActivationSource.CANCELLED
      | RegistrationOnlineSessionActivationSource.DELETED,
    actingUserId: number | null | undefined,
  ): Promise<void> {
    const actingUserIdValue = actingUserId ?? null;
    const reason =
      source === RegistrationOnlineSessionActivationSource.CANCELLED
        ? 'Registration cancelled'
        : source === RegistrationOnlineSessionActivationSource.ARCHIVED
          ? 'Registration archived'
          : 'Registration deleted';

    try {
      const registration = await this.requireRegistration(registrationId);
      if (!registration.programId) {
        return;
      }

      const cascade = await this.registrationRepository.findProvisionedExtensionsFromSession(
        registration.id,
        registration.programId,
        new Date(),
      );
      const toCascade = cascade.filter(
        (item) => item.extension.activationStatus === RegistrationOnlineSessionActivationStatus.ACTIVE,
      );

      const contact = this.contactInfo(registration);
      const callZoomOnce = new Map<string, Promise<ZoomParticipantResult | void>>();
      for (const item of toCascade) {
        try {
          const dedupeKey = item.session.onlineSession?.externalId ?? null;
          let call = dedupeKey ? callZoomOnce.get(dedupeKey) : undefined;
          if (!call) {
            call = this.handlerFor(item.session).removeParticipant(item.session, item.extension, contact);
            if (dedupeKey) callZoomOnce.set(dedupeKey, call);
          }
          await call;

          await this.registrationRepository.withTransaction(async (manager) => {
            await this.registrationRepository.updateActivationStatus(
              item.extension.id,
              {
                activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
                activationSource: source,
                joinUrl: null,
                activationChangedBy: actingUserIdValue,
                activationReason: reason,
              },
              manager,
            );
            if (item.extension.onlineSessionId != null) {
              await this.registrationRepository.adjustEligibleActiveCount(
                item.extension.onlineSessionId,
                -1,
                manager,
              );
            }
          });
        } catch (error) {
          this.logger.error('Error cascading terminal activation status to session, skipping', error?.stack, {
            error: error instanceof MainInifniException ? error.toStringDetail() : error,
            registrationId,
            onlineSessionId: item.extension.onlineSessionId,
            source,
          });
        }
      }

      await this.registrationRepository.withTransaction(async (manager) => {
        await this.registrationRepository.updateRegistrationActivationStatus(
          registration.id,
          RegistrationOnlineSessionActivationStatus.INACTIVE,
          manager,
        );
      });
    } catch (error) {
      this.logger.error('Error cascading terminal activation status for registration', error?.stack, {
        error: error instanceof MainInifniException ? error.toStringDetail() : error,
        registrationId,
        source,
      });
    }
  }

  /** Live active/inactive breakdown of one session's eligible (provisioned) registrants. */
  async getEligibleCount(sessionId: number): Promise<EligibleActivationSummary> {
    const session = await this.resolveSession(sessionId);
    const onlineSession = session.onlineSession;
    if (!onlineSession) {
      throw new InifniBadRequestException(
        ERROR_CODES.ZOOM_SESSION_NOT_PROVISIONED,
        null,
        null,
        sessionId.toString(),
      );
    }
    return this.registrationRepository.getEligibleActivationSummary(onlineSession.id);
  }

  /** Top-level dispatch for register / unregister / downgrade actions. */
  async handle(
    dto: RegisterZoomUserDto,
    options?: { restrictUnregisterToTargetSession?: boolean },
  ): Promise<ProgramRegistrationOnlineSession | void> {
    const registration = await this.requireRegistration(dto.registrationId);
    // A registration can span several online sessions, so an explicit sessionId
    // selects which one to act on; otherwise act on the registration's own session.
    const session = dto.sessionId
      ? await this.onlineSessionService.findOne(dto.sessionId)
      : registration.programSession;

    switch (dto.action) {
      case ZoomRegistrationUpdate.REGISTER:
        return this.register(registration, session, dto.role ?? ZoomRole.ATTENDEE, dto.actingUserId);
      case ZoomRegistrationUpdate.UNREGISTER:
        return this.unregister(
          registration,
          session,
          options?.restrictUnregisterToTargetSession ?? false,
        );
      case ZoomRegistrationUpdate.DOWNGRADE_TO_AUDIO:
        return this.downgrade(registration, session, dto.actingUserId);
      default:
        throw new InifniBadRequestException(
          ERROR_CODES.ZOOM_INVALID_WEBINAR_STATE,
          null,
          null,
          String(dto.action),
        );
    }
  }

  /** Picks the per-type handler (meeting vs webinar). */
  private handlerFor(session?: ProgramSession | null): ZoomSessionHandler {
    return session?.onlineType === OnlineTypeEnum.MEETING ? this.meeting : this.webinar;
  }

  private async register(
    registration: ProgramRegistration,
    session: ProgramSession | null | undefined,
    role: ZoomRole,
    actingUserId?: number,
  ): Promise<ProgramRegistrationOnlineSession> {
    const onlineSession = this.requireOnlineSession(registration, session);
    const existing = await this.registrationRepository.findZoomExtension(
      registration.id,
      onlineSession.id,
    );
    if (existing) {
      throw new InifniBadRequestException(
        ERROR_CODES.ZOOM_USER_ALREADY_REGISTERED,
        null,
        null,
        registration.id.toString(),
      );
    }

    return this.pushParticipant(registration, session, role, actingUserId);
  }

  /**
   * Registers one confirmed registrant for a bulk run: skips (rather than throws)
   * when the registrant is already on the Zoom resource, so a re-run is idempotent.
   * Real Zoom/DB errors propagate so the bulk job can count them as failures.
   */
  async registerForBulk(
    registration: ProgramRegistration,
    session: ProgramSession | null | undefined,
    role: ZoomRole,
    actingUserId?: number,
  ): Promise<'registered' | 'skipped'> {
    const onlineSession = this.requireOnlineSession(registration, session);
    const existing = await this.registrationRepository.findZoomExtension(
      registration.id,
      onlineSession.id,
    );
    if (existing) return 'skipped';
    await this.pushParticipant(registration, session, role, actingUserId);
    return 'registered';
  }

  /**
   * Bulk-registers many PANELISTS onto ONE webinar in a handful of API calls
   * rather than per person: Zoom accepts the whole `panelists` array at once, and
   * the join links are fetched once for the session (see
   * {@link WebinarService.addPanelistsBulk}). Already-registered pairs are skipped
   * (idempotent). Returns a per-registration outcome so the bulk job can tally
   * successes/skips and record failures with a reason.
   *
   * Only valid for a PER_SESSION webinar; callers route MEETING/SHARED/attendee
   * work through the per-item path, which handles their link semantics.
   */
  async registerPanelistsForBulk(
    registrations: ProgramRegistration[],
    session: ProgramSession,
    actingUserId?: number,
  ): Promise<Map<number, PanelistBulkOutcome>> {
    const outcomes = new Map<number, PanelistBulkOutcome>();
    if (registrations.length === 0) return outcomes;

    const onlineSession = this.requireOnlineSession(registrations[0], session);

    // Batched idempotency: which (registration, session) pairs are already active.
    const activeKeys = await this.registrationRepository.findActiveExtensionKeys(
      registrations.map((r) => r.id),
      [onlineSession.id],
    );

    const toRegister: { registration: ProgramRegistration; contact: ZoomContactInfo }[] = [];
    for (const registration of registrations) {
      if (activeKeys.has(`${registration.id}:${onlineSession.id}`)) {
        outcomes.set(registration.id, { status: 'skipped' });
      } else {
        toRegister.push({ registration, contact: this.contactInfo(registration) });
      }
    }
    if (toRegister.length === 0) return outcomes;

    // One array-add (chunked) + one panelist-list fetch for the whole session.
    // Isolate a failure here (Zoom rejects a chunk, or the list fetch errors):
    // mark only the attempted registrants failed and keep the already-computed
    // skips, instead of throwing and letting the caller blanket-fail the whole
    // group (which would reclassify already-registered panelists as failed).
    let detailsByEmail: Map<string, ZoomParticipantResult>;
    try {
      detailsByEmail = await this.webinar.addPanelistsBulk(
        session,
        toRegister.map((item) => item.contact),
      );
    } catch (error) {
      for (const { registration } of toRegister) {
        outcomes.set(registration.id, { status: 'failed', error });
      }
      return outcomes;
    }

    for (const { registration, contact } of toRegister) {
      const details = detailsByEmail.get(contact.email.toLowerCase());
      // A registrant Zoom did not return — or returned without a usable join URL
      // — is a failure, not a success. Recording it as "registered" with a null
      // link would leave the panelist linkless and never retried.
      if (!details || !details.joinUrl) {
        outcomes.set(registration.id, {
          status: 'failed',
          error: new InifniBadRequestException(
            ERROR_CODES.ZOOM_PANELIST_ADD_FAILED,
            null,
            null,
            registration.id.toString(),
          ),
        });
        continue;
      }
      try {
        await this.registrationRepository.createZoomExtension({
          registrationId: registration.id,
          onlineSessionId: onlineSession.id,
          provider: SessionProviderType.ZOOM,
          isPanelist: true,
          externalRegistrantId: details.zoomRegistrantId,
          joinUrl: details.joinUrl,
          registrationType: 'video',
          createdBy: actingUserId ? ({ id: actingUserId } as User) : undefined,
          updatedBy: actingUserId ? ({ id: actingUserId } as User) : undefined,
        });
        outcomes.set(registration.id, { status: 'registered' });
      } catch (error) {
        outcomes.set(registration.id, { status: 'failed', error });
      }
    }
    return outcomes;
  }

  /**
   * Shared push: provisions the registrant on the Zoom resource and persists the
   * 1:1 extension row. Callers guarantee no extension exists yet.
   */
  private async pushParticipant(
    registration: ProgramRegistration,
    session: ProgramSession | null | undefined,
    role: ZoomRole,
    actingUserId?: number,
  ): Promise<ProgramRegistrationOnlineSession> {
    const onlineSession = this.requireOnlineSession(registration, session);
    const contact = this.contactInfo(registration);
    // "Same link" reuse: for a shared recurring webinar, a registrant holds ONE
    // Zoom registration (one join link) valid for every session. If they already
    // hold a link on a sibling session sharing this webinar, copy it instead of
    // registering them on Zoom again — one registrant, one link, one row/session.
    const result = await this.resolveParticipant(registration, session, onlineSession, contact, role);

    const extension = await this.registrationRepository.createZoomExtension({
      registrationId: registration.id,
      onlineSessionId: onlineSession.id,
      provider: SessionProviderType.ZOOM,
      isPanelist: result.isPanelist,
      externalRegistrantId: result.zoomRegistrantId,
      joinUrl: result.joinUrl,
      registrationType: result.isPanelist ? 'video' : 'audio',
      createdBy: actingUserId ? ({ id: actingUserId } as User) : undefined,
      updatedBy: actingUserId ? ({ id: actingUserId } as User) : undefined,
    });
    this.logger.log('Zoom user registered', {
      registrationId: registration.id,
      onlineSessionId: onlineSession.id,
      onlineType: session?.onlineType,
      isPanelist: result.isPanelist,
    });
    return extension;
  }

  /**
   * Produces the join details for a registrant. For a SHARED (recurring) webinar,
   * reuses the registrant's existing link from a sibling session if present so
   * they are registered on Zoom only once; otherwise pushes them to the provider.
   */
  private async resolveParticipant(
    registration: ProgramRegistration,
    session: ProgramSession | null | undefined,
    onlineSession: OnlineSession,
    contact: ZoomContactInfo,
    role: ZoomRole,
  ): Promise<ZoomParticipantResult> {
    if (onlineSession.linkMode === SessionLinkModeEnum.SHARED && onlineSession.externalId) {
      const sibling = await this.registrationRepository.findExtensionByRegistrationAndExternalId(
        registration.id,
        onlineSession.externalId,
      );
      if (sibling && sibling.joinUrl) {
        this.logger.log('Reusing shared webinar join link', {
          registrationId: registration.id,
          externalId: onlineSession.externalId,
        });
        return {
          joinUrl: sibling.joinUrl,
          zoomRegistrantId: sibling.externalRegistrantId ?? null,
          isPanelist: sibling.isPanelist,
        };
      }
    }
    return this.handlerFor(session).addParticipant(session as ProgramSession, contact, role, true);
  }

  /**
   * Resolves and validates the online session to act on. Without a provisioned
   * Zoom resource there is nothing to register the user to — fail loudly instead
   * of silently writing an empty extension row (which would falsely count as
   * "registered" and be skipped on a re-run). The returned session's id keys the
   * per-registrant extension, so a registration can hold one row per session.
   */
  private requireOnlineSession(
    registration: ProgramRegistration,
    session: ProgramSession | null | undefined,
  ): OnlineSession {
    if (!session?.onlineSession?.externalId) {
      throw new InifniBadRequestException(
        ERROR_CODES.ZOOM_SESSION_NOT_PROVISIONED,
        null,
        null,
        registration.id.toString(),
      );
    }
    return session.onlineSession;
  }

  private async unregister(
    registration: ProgramRegistration,
    session: ProgramSession | null | undefined,
    restrictToTargetSession = false,
  ): Promise<void> {
    const onlineSession = this.requireOnlineSession(registration, session);
    const extension = await this.requireExtension(registration.id, onlineSession.id);
    // Provider removal is webinar-level: for a shared recurring webinar this drops
    // the registrant from every occurrence at once. Do it once, then reflect it by
    // soft-deleting all their sibling extension rows in the group.
    await this.handlerFor(session).removeParticipant(
      session as ProgramSession,
      extension,
      this.contactInfo(registration),
    );

    // The final-session-confirm block only revokes the FINAL session (the earlier sessions have
    // already elapsed and their extension rows are kept as history), so it sets
    // restrictToTargetSession — skip the shared-group cascade and soft-delete just this session's row.
    if (
      !restrictToTargetSession &&
      onlineSession.linkMode === SessionLinkModeEnum.SHARED &&
      onlineSession.externalId
    ) {
      const siblings = await this.registrationRepository.findExtensionsByRegistrationAndExternalId(
        registration.id,
        onlineSession.externalId,
      );
      for (const ext of siblings) {
        await this.registrationRepository.softDeleteZoomExtension(ext);
      }
      this.logger.log('Zoom user unregistered from shared webinar', {
        registrationId: registration.id,
        externalId: onlineSession.externalId,
        removed: siblings.length,
      });
      return;
    }

    await this.registrationRepository.softDeleteZoomExtension(extension);
    this.logger.log('Zoom user unregistered', {
      registrationId: registration.id,
      onlineSessionId: onlineSession.id,
    });
  }

  private async downgrade(
    registration: ProgramRegistration,
    session: ProgramSession | null | undefined,
    actingUserId?: number,
  ): Promise<ProgramRegistrationOnlineSession> {
    const onlineSession = this.requireOnlineSession(registration, session);
    const extension = await this.requireExtension(registration.id, onlineSession.id);
    if (!extension.isPanelist) {
      throw new InifniBadRequestException(ERROR_CODES.ZOOM_NOT_A_PANELIST, null, null);
    }

    // Panelists exist only on webinars, so downgrade is always a webinar operation.
    const contact = this.contactInfo(registration);
    const result = await this.webinar.downgradePanelist(session as ProgramSession, extension, contact);

    extension.isPanelist = false;
    extension.registrationType = 'audio';
    extension.externalRegistrantId = result.zoomRegistrantId;
    extension.joinUrl = result.joinUrl;
    if (actingUserId) extension.updatedBy = { id: actingUserId } as User;
    const saved = await this.registrationRepository.saveZoomExtension(extension);
    this.logger.log('Zoom panelist downgraded to attendee', { registrationId: registration.id });
    return saved;
  }

  private contactInfo(registration: ProgramRegistration): ZoomContactInfo {
    const rawEmail = registration.emailAddress ?? '';
    const fullName = registration.fullName ?? registration.user?.legalFullName ?? rawEmail;
    const { zoomFirstName: firstName, zoomLastName: lastName } = splitZoomContactName(fullName);

    // The platform is the sole sender of join links and every Zoom resource is
    // created with all Zoom email suppressed, so the address handed to Zoom is a
    // per-registration uniqueness key (lets several registrations sharing one real
    // email each get their own registrant + join link) rather than a delivery
    // target. See buildZoomRegistrantEmail.
    const email = rawEmail ? buildZoomRegistrantEmail(rawEmail, registration.id) : rawEmail;
    return { firstName, lastName, email, name: fullName };
  }

  private async requireRegistration(registrationId: number): Promise<ProgramRegistration> {
    const registration = await this.registrationRepository.findRegistrationById(registrationId);
    if (!registration) {
      throw new InifniNotFoundException(
        ERROR_CODES.ZOOM_REGISTRATION_NOTFOUND,
        null,
        null,
        registrationId.toString(),
      );
    }
    return registration;
  }

  private async requireExtension(
    registrationId: number,
    onlineSessionId: number,
  ): Promise<ProgramRegistrationOnlineSession> {
    const extension = await this.registrationRepository.findZoomExtension(
      registrationId,
      onlineSessionId,
    );
    if (!extension) {
      throw new InifniNotFoundException(
        ERROR_CODES.ZOOM_REGISTRATION_NOTFOUND,
        null,
        null,
        registrationId.toString(),
      );
    }
    return extension;
  }
}
