import { Injectable } from '@nestjs/common';
import { ProgramRegistration, ProgramSession, BackgroundJob } from 'src/common/entities';
import { AppLoggerService } from 'src/common/services/logger.service';
import { handleKnownErrors } from 'src/common/utils/handle-error.util';
import { UserTypeFilterValue } from 'src/common/utils/user-type-filter.util';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { MainInifniException } from 'src/common/exceptions/infini-abstract-exception';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { JobTypeEnum } from 'src/common/enum/job-type.enum';
import { ExportJobStatus } from 'src/common/enum/export-job-status.enum';
import { OnlineSessionRegistrationStatus } from 'src/common/enum/online-session-registration-status.enum';
import { SessionProviderType } from 'src/common/enum/session-provider.enum';
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 { OnlineSessionService } from 'src/online-session/services/online-session.service';
import {
  BulkRegistrationFailure,
  BulkRegistrationFailureList,
  BulkRegistrationFailureRow,
  BulkRegistrationIneligible,
  BulkRegistrationJobMetadata,
  ProvisionCounts,
  ProvisionKpiTile,
  ProvisionRegistrationList,
  ProvisionRegistrationRow,
  ProvisionRegistrationStatus,
  ProvisionStatusOverview,
} from 'src/online-session/interfaces/online-session.interface';
import { ZoomRegistrationRepository } from '../repositories/zoom-registration.repository';
import { ZoomGeneratedRegistrantLinkRepository } from '../repositories/zoom-generated-registrant-link.repository';
import { ZoomRegistrationService } from './zoom-registration.service';
import { ZoomFinalSessionAttendanceService } from './zoom-final-session-attendance.service';
import { PanelistBulkOutcome, BulkTally } from '../interfaces/zoom-registration.interface';
import { ZoomRole } from '../enums/zoom-role.enum';
import { BulkRegisterZoomDto } from '../dto/bulk-register-zoom.dto';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { SessionLinkModeEnum } from 'src/common/enum/session-link-mode.enum';
import {
  DEFAULT_BATCH_SIZE,
  PROVISIONED_SESSION_SCAN_LIMIT,
  BULK_BATCH_DELAY_MS,
} from 'src/common/constants/zoom.constants';

/** Reason recorded for a registrant excluded before any Zoom call. */
const INELIGIBLE_NO_SEAT_REASON = 'NO_SEAT';

/** One unit of bulk work: register this registrant onto this session's Zoom resource. */
interface RegistrationWorkItem {
  registration: ProgramRegistration;
  session: ProgramSession;
}

/**
 * Registers a program's eligible registrants to a Zoom webinar/meeting as a
 * background job, mirroring the QR bulk-generation pattern: the HTTP call returns
 * a jobId immediately, work runs via setImmediate, and progress is tracked on the
 * shared background_jobs table for the admin to poll.
 *
 * Beyond the running counts, the job records the transparency data the admin
 * needs to trust it: the eligibility breakdown (eligible count + ineligible
 * registrants with a reason) and a per-item failure list (which registration
 * failed against which session, and why). Both live in `background_jobs.metadata`
 * and drive the poll endpoint. Failed items can be re-run in isolation via
 * {@link retryFailedRegistration}.
 *
 * Registrants are fetched by PROGRAM (registrations carry no session). The job
 * resolves the target webinar SESSION(s) — the `sessionId`, or every provisioned
 * session of the program — and registers each eligible registrant against each.
 * Already-registered pairs are skipped, so a re-run is idempotent.
 */
@Injectable()
export class ZoomBulkRegistrationService {
  constructor(
    private readonly repository: ZoomRegistrationRepository,
    private readonly generatedLinkRepository: ZoomGeneratedRegistrantLinkRepository,
    private readonly registrationService: ZoomRegistrationService,
    private readonly onlineSessionService: OnlineSessionService,
    private readonly finalSessionAttendanceService: ZoomFinalSessionAttendanceService,
    private readonly logger: AppLoggerService,
  ) {}

  /** Creates the job, kicks off processing in the background, and returns the job id. */
  async startBulkRegistration(
    dto: BulkRegisterZoomDto,
    actorUserId?: number,
  ): Promise<{ jobId: number; status: ExportJobStatus; total: number }> {
    try {
      const targetSessions = await this.resolveTargetSessions(dto);
      const programId = targetSessions[0].programId as number;
      const role = dto.role ?? ZoomRole.ATTENDEE;
      const batchSize = dto.batchSize ?? DEFAULT_BATCH_SIZE;
      // Registrants are program-scoped; program_session_id is ignored. The
      // ineligible set is the complement, captured for the breakdown.
      // `excludeDeactivated` keeps a registrant whose registration-level
      // activation rollup is INACTIVE (see
      // ZoomRegistrationService.setRegistrationActivation) out of the work
      // list entirely, so a bulk run can never freshly (re)provision them onto
      // a session that has no extension row yet — which would silently undo
      // the deactivation for that session. Other callers of this repository
      // method deliberately omit the option (they still need inactive
      // registrants in the result to bucket/display them).
      const registrations = await this.repository.findEligibleRegistrationsByProgram(
        programId,
        undefined,
        { excludeDeactivated: true },
      );
      const ineligible = await this.loadIneligible(programId);

      // A multi-session program's FINAL session is gated on prior attendance: anyone who missed
      // an already-elapsed earlier session gets no NEW join link for the final session (the same
      // rule ZoomFinalSessionConfirmService.confirmFinalSession unregisters already-generated
      // links on). Excluded from THAT session's work items only — every other target session
      // still registers the full eligible set. Returns null for single-session programs.
      const finalSessionAbsentees = await this.finalSessionAttendanceService.resolveFinalSessionAbsentees(
        programId,
        registrations.map((r) => r.id),
      );
      const excludeIdsBySessionId = new Map<number, Set<number>>();
      if (
        finalSessionAbsentees?.absenteeRegistrationIds.size &&
        targetSessions.some((session) => session.id === finalSessionAbsentees.finalSessionId)
      ) {
        excludeIdsBySessionId.set(
          finalSessionAbsentees.finalSessionId,
          finalSessionAbsentees.absenteeRegistrationIds,
        );
        this.logger.log('Excluding final-session absentees from bulk Zoom registration', {
          programId,
          finalSessionId: finalSessionAbsentees.finalSessionId,
          excludedCount: finalSessionAbsentees.absenteeRegistrationIds.size,
        });
      }

      const workItems = this.buildWorkItems(targetSessions, registrations, excludeIdsBySessionId);
      // Unit of work is registrants × sessions, minus any final-session absentee exclusions.
      const total = workItems.length;

      const metadata: BulkRegistrationJobMetadata = {
        sessionIds: targetSessions.map((s) => s.id),
        role,
        batchSize,
        eligibleCount: registrations.length,
        ineligible,
        failures: [],
      };
      const job = await this.repository.createBulkJob({
        type: JobTypeEnum.BULK_ZOOM_REGISTRATION,
        status: ExportJobStatus.PROCESSING,
        total,
        generated: 0,
        skipped: 0,
        failed: 0,
        programId,
        createdBy: actorUserId ?? null,
        metadata,
      });

      setImmediate(() =>
        this.processWorkItems(job.id, workItems, role, batchSize, metadata, actorUserId),
      );
      // Fire-and-forget, best-effort: keep each target session's RM/Admin/Shoba + placeholder
      // ("system") general links topped up on every bulk-register run, not only when the
      // session was first created — idempotent, see generateGeneralLinksForSession's own doc.
      for (const session of targetSessions) {
        this.onlineSessionService.generateGeneralLinksForSession(
          session,
          SessionProviderType.ZOOM,
          actorUserId,
        );
      }
      this.logger.log('Bulk Zoom registration job started', {
        jobId: job.id,
        total,
        eligible: registrations.length,
        ineligible: ineligible.length,
        programId,
        sessionIds: metadata.sessionIds,
      });
      return { jobId: job.id, status: job.status, total: job.total };
    } catch (error) {
      this.logger.error('Error starting bulk Zoom registration', error?.stack, { error, dto });
      handleKnownErrors(ERROR_CODES.ZOOM_BULK_START_FAILED, error);
    }
  }

  /**
   * Re-runs only the failed items of an earlier job as a fresh job, so the admin
   * can regenerate failures without re-pushing everyone. The exact
   * (registration, session) pairs recorded in the source job's failure list are
   * re-attempted; already-registered pairs are skipped (idempotent). A new job id
   * is returned and the source job is left untouched as a historical record.
   */
  async retryFailedRegistration(
    jobId: number,
    actorUserId?: number,
  ): Promise<{ jobId: number; status: ExportJobStatus; total: number }> {
    try {
      const sourceJob = await this.getBulkJobStatus(jobId);
      const metadata = (sourceJob.metadata ?? {}) as Partial<BulkRegistrationJobMetadata>;
      const failures = metadata.failures ?? [];
      if (!failures.length) {
        throw new InifniBadRequestException(
          ERROR_CODES.ZOOM_BULK_NO_FAILURES_TO_RETRY,
          null,
          null,
          jobId.toString(),
        );
      }
      const role = (metadata.role as ZoomRole) ?? ZoomRole.ATTENDEE;
      const batchSize = metadata.batchSize ?? DEFAULT_BATCH_SIZE;

      const workItems = await this.resolveFailedWorkItems(failures);
      const total = workItems.length;
      const retryMetadata: BulkRegistrationJobMetadata = {
        sessionIds: [...new Set(workItems.map((w) => w.session.id))],
        role,
        batchSize,
        eligibleCount: total,
        ineligible: [],
        failures: [],
        retryOfJobId: jobId,
      };
      const retryJob = await this.repository.createBulkJob({
        type: JobTypeEnum.BULK_ZOOM_REGISTRATION,
        status: ExportJobStatus.PROCESSING,
        total,
        generated: 0,
        skipped: 0,
        failed: 0,
        programId: sourceJob.programId,
        createdBy: actorUserId ?? null,
        metadata: retryMetadata,
      });

      setImmediate(() =>
        this.processWorkItems(retryJob.id, workItems, role, batchSize, retryMetadata, actorUserId),
      );
      this.logger.log('Bulk Zoom registration retry started', {
        retryJobId: retryJob.id,
        sourceJobId: jobId,
        total,
      });
      return { jobId: retryJob.id, status: retryJob.status, total: retryJob.total };
    } catch (error) {
      this.logger.error('Error starting bulk Zoom registration retry', error?.stack, {
        error,
        jobId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_BULK_RETRY_FAILED, error);
    }
  }

  /**
   * The current, de-staled failures for a whole PROGRAM (optionally one
   * session), merged across the program's entire job chain rather than keyed to
   * a single jobId. Later jobs win on the same (registration, session) pair, so
   * the newest reason survives; pairs since resolved are dropped. Paginated +
   * enriched like the per-job view.
   */
  async getProgramFailures(
    programId: number,
    query: { sessionId?: number; page: number; limit: number },
    rmContactId?: number,
  ): Promise<BulkRegistrationFailureList> {
    try {
      const jobs = await this.repository.findBulkJobsByProgram(programId);
      const merged = this.mergeJobFailures(jobs, query.sessionId);
      let failures = await this.dropResolvedFailures(merged);
      if (rmContactId != null) {
        failures = await this.restrictFailuresToRm(failures, programId, rmContactId);
      }
      const page = query.page > 0 ? query.page : 1;
      const limit = query.limit > 0 ? query.limit : failures.length;
      const start = (page - 1) * limit;
      const pageFailures = failures.slice(start, start + limit);

      const data = await this.enrichFailures(pageFailures);
      return { data, pagination: { page, limit, total: failures.length } };
    } catch (error) {
      this.logger.error('Error fetching program Zoom registration failures', error?.stack, {
        error,
        programId,
        sessionId: query.sessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_BULK_FAILURES_FETCH_FAILED, error);
    }
  }

  /**
   * Re-runs the current, de-staled failures for a whole PROGRAM (optionally one
   * session) as a fresh job — the by-program/session counterpart to
   * {@link retryFailedRegistration}. Failures are merged across the program's
   * job chain and pairs since resolved are dropped, so an admin can clear
   * "what's still failing" for a program without tracking individual job ids.
   * Role/batchSize are inherited from the program's newest job.
   */
  async retryProgramFailures(
    programId: number,
    sessionId?: number,
    actorUserId?: number,
  ): Promise<{ jobId: number; status: ExportJobStatus; total: number }> {
    try {
      const jobs = await this.repository.findBulkJobsByProgram(programId);
      const merged = this.mergeJobFailures(jobs, sessionId);
      const failures = await this.dropResolvedFailures(merged);
      if (!failures.length) {
        throw new InifniBadRequestException(
          ERROR_CODES.ZOOM_BULK_NO_FAILURES_TO_RETRY,
          null,
          null,
          String(programId),
        );
      }
      const latest = (jobs[0]?.metadata ?? {}) as Partial<BulkRegistrationJobMetadata>;
      const role = (latest.role as ZoomRole) ?? ZoomRole.ATTENDEE;
      const batchSize = latest.batchSize ?? DEFAULT_BATCH_SIZE;

      const workItems = await this.resolveFailedWorkItems(failures);
      const total = workItems.length;
      const retryMetadata: BulkRegistrationJobMetadata = {
        sessionIds: [...new Set(workItems.map((w) => w.session.id))],
        role,
        batchSize,
        eligibleCount: total,
        ineligible: [],
        failures: [],
        retryOfJobId: jobs[0]?.id,
      };
      const retryJob = await this.repository.createBulkJob({
        type: JobTypeEnum.BULK_ZOOM_REGISTRATION,
        status: ExportJobStatus.PROCESSING,
        total,
        generated: 0,
        skipped: 0,
        failed: 0,
        programId,
        createdBy: actorUserId ?? null,
        metadata: retryMetadata,
      });

      setImmediate(() =>
        this.processWorkItems(retryJob.id, workItems, role, batchSize, retryMetadata, actorUserId),
      );
      this.logger.log('Bulk Zoom registration program retry started', {
        retryJobId: retryJob.id,
        programId,
        sessionId,
        total,
      });
      return { jobId: retryJob.id, status: retryJob.status, total: retryJob.total };
    } catch (error) {
      this.logger.error('Error starting program Zoom registration retry', error?.stack, {
        error,
        programId,
        sessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_BULK_RETRY_FAILED, error);
    }
  }

  /**
   * Program-level provisioning overview: every online session of the program (or
   * a single session when `sessionId` is given) with its live provisioning counts
   * — total eligible, generated (has a Zoom link), failed (still outstanding), and
   * yet-to-generate. `totalEligible` at the top level is program-scoped (every
   * seat-allocated registrant of the program); each session's own `counts`,
   * however, are session-scoped — a registrant deactivated for a given session
   * is excluded from THAT session's counts entirely (same convention as
   * getSessionProvisionRegistrations), so different sessions can show
   * different totals. The program's FINAL session additionally excludes anyone
   * who missed an already-elapsed earlier session (see
   * ZoomFinalSessionAttendanceService.resolveFinalSessionAbsentees) — the same
   * gate startBulkRegistration applies before generating a NEW join link.
   * Nothing is stored: counts are derived from the current extension rows and
   * the program's de-staled failure set on each call.
   */
  /** A session already underway or finished — its provisioning window is closed. */
  private hasSessionStarted(session: ProgramSession): boolean {
    return session.startsAt != null && session.startsAt <= new Date();
  }

  async getProgramProvisionStatus(
    programId: number,
    sessionId?: number,
    rmContactId?: number,
    userType?: UserTypeFilterValue[],
  ): Promise<ProvisionStatusOverview> {
    try {
      const sessions = sessionId
        ? [await this.onlineSessionService.findOne(sessionId)]
        : (
            await this.onlineSessionService.findAll(
              PROVISIONED_SESSION_SCAN_LIMIT,
              0,
              undefined,
              programId,
            )
          ).data;

      // The `userType` filter narrows the eligible set itself, so every count derived below
      // (totalEligible, generated, failed, yetToGenerate) reflects it — the tiles can never
      // disagree with the drilldown rows about which registrants are in scope.
      const eligible = await this.repository.findEligibleRegistrationsByProgram(
        programId,
        rmContactId,
        { userType },
      );
      const eligibleIds = eligible.map((r) => r.id);
      const totalEligible = eligibleIds.length;

      // The final session (if the program has >1 sessions) drops anyone who missed an
      // already-elapsed earlier session from its OWN still-open counts — same gate
      // startBulkRegistration applies, so this view never advertises a "yet to generate" count
      // a bulk run would just skip over anyway.
      const finalSessionAbsentees = await this.finalSessionAttendanceService.resolveFinalSessionAbsentees(
        programId,
        eligibleIds,
      );

      // Active extension keys ("regId:onlineSessionId") across every target session.
      const onlineSessionIds = sessions
        .map((s) => s.onlineSession?.id)
        .filter((id): id is number => id != null);
      const activeKeys = await this.repository.findActiveExtensionKeys(eligibleIds, onlineSessionIds);
      // Pairs explicitly deactivated for that specific session — excluded from
      // that session's counts entirely (see findInactiveExtensionKeys).
      const inactiveKeys = await this.repository.findInactiveExtensionKeys(eligibleIds, onlineSessionIds);

      // Outstanding (de-staled) failures for the program, grouped by session id.
      const jobs = await this.repository.findBulkJobsByProgram(programId);
      const outstanding = await this.dropResolvedFailures(this.mergeJobFailures(jobs, sessionId));
      const eligibleIdSet = new Set(eligibleIds);
      const failedIdsBySession = new Map<number, Set<number>>();
      for (const failure of outstanding) {
        if (!eligibleIdSet.has(failure.registrationId)) continue;
        const set = failedIdsBySession.get(failure.sessionId) ?? new Set<number>();
        set.add(failure.registrationId);
        failedIdsBySession.set(failure.sessionId, set);
      }

      // General/staff-placeholder links generated per session (zoom_generated_registrant_link),
      // unrelated to the ProgramRegistration-based counts above — surfaced as its own KPI tile.
      const generalLinksBySession = await this.generatedLinkRepository.countRegisteredByProgramSessionIds(
        sessions.map((s) => s.id),
      );

      const sessionsOut = sessions.map((session) => {
        const onlineSessionId = session.onlineSession?.id ?? null;
        const sessionStarted = this.hasSessionStarted(session);
        // Final-session absentees are only dropped from the still-open (not-yet-started)
        // window — same as startBulkRegistration, an already-generated link from before the
        // registrant's absence was known is left alone rather than retroactively hidden.
        const finalSessionAbsenteeIds =
          !sessionStarted && finalSessionAbsentees?.finalSessionId === session.id
            ? finalSessionAbsentees.absenteeRegistrationIds
            : null;
        // This session's own eligible set: program-eligible MINUS anyone
        // deactivated for THIS session specifically (and, for the final session,
        // MINUS anyone who missed an earlier elapsed session). Once the session has
        // started/completed, provisioning is closed — the set collapses to
        // exactly those with an ACTIVE extension row, so generated always
        // equals totalEligible and failed/yetToGenerate are always 0 (there's
        // no more window left to generate or fail in).
        const sessionEligibleIds = (
          onlineSessionId
            ? eligibleIds.filter((rid) =>
                sessionStarted
                  ? activeKeys.has(`${rid}:${onlineSessionId}`)
                  : !inactiveKeys.has(`${rid}:${onlineSessionId}`),
              )
            : eligibleIds
        ).filter((rid) => !finalSessionAbsenteeIds?.has(rid));
        const sessionEligibleIdSet = new Set(sessionEligibleIds);
        const sessionTotalEligible = sessionEligibleIds.length;
        const generated = onlineSessionId
          ? sessionEligibleIds.filter((rid) => activeKeys.has(`${rid}:${onlineSessionId}`)).length
          : 0;
        // A registration that ended up generated is no longer a failure, even if an
        // older job recorded it — count only outstanding-and-not-generated failures.
        // Also drop anyone since deactivated for this session — they're excluded
        // from the session's counts entirely, not "still failing".
        const failedIds = [...(failedIdsBySession.get(session.id) ?? new Set<number>())].filter((rid) =>
          sessionEligibleIdSet.has(rid),
        );
        const failed = onlineSessionId
          ? failedIds.filter((rid) => !activeKeys.has(`${rid}:${onlineSessionId}`)).length
          : failedIds.length;
        const yetToGenerate = Math.max(0, sessionTotalEligible - generated - failed);
        const counts: ProvisionCounts = {
          totalEligible: sessionTotalEligible,
          generated,
          failed,
          yetToGenerate,
          generalLinks: generalLinksBySession.get(session.id) ?? 0,
        };
        return {
          session,
          counts,
          kpis: this.buildProvisionKpis(counts, sessionStarted),
        };
      });

      return { programId, totalEligible, sessions: sessionsOut };
    } catch (error) {
      this.logger.error('Error building program provision status', error?.stack, {
        error,
        programId,
        sessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_BULK_FAILURES_FETCH_FAILED, error);
    }
  }

  /**
   * Per-session provisioning drilldown: bucketed into generated / failed /
   * yet-to-generate / generalLink, optionally filtered to a single bucket, with a
   * summary and pagination. `status` filters which bucket is returned; the summary
   * always reflects all four. `generalLink` is served from a different table
   * (`zoom_generated_registrant_link`, no `ProgramRegistration` of its own) — see
   * {@link ProvisionRegistrationRow.registrationId}.
   *
   * A registration with an already-generated (ACTIVE) extension for THIS
   * session always stays listed as "generated" — even if the registration has
   * since been deleted or made otherwise ineligible at the program level — so
   * a previously-generated join link never silently disappears from this
   * view. Everyone else is subject to standard program-level eligibility
   * (not deleted, not an ineligible status, seat allocated) plus the
   * activation rollup: a registrant with a session-specific extension whose
   * own activationStatus is INACTIVE is dropped regardless of source; a
   * registrant with no session-specific row falls back to the registration's
   * program-level activation rollup instead. When this session is the
   * program's FINAL session, anyone who missed an already-elapsed earlier
   * session is dropped the same way (see
   * ZoomFinalSessionAttendanceService.resolveFinalSessionAbsentees) — again only
   * from the not-yet-generated bucket, never hiding an already-generated link.
   */
  /** Display formatting only — filtering/counting always happens on the raw lowercase enum value beforehand. */
  private static capitalizeFirst<T extends string>(value: T): string {
    return value.charAt(0).toUpperCase() + value.slice(1);
  }

  async getSessionProvisionRegistrations(
    sessionId: number,
    query: {
      status?: ProvisionRegistrationStatus;
      page: number;
      limit: number;
      userType?: UserTypeFilterValue[];
    },
    rmContactId?: number,
  ): Promise<ProvisionRegistrationList> {
    try {
      const session = await this.onlineSessionService.findOne(sessionId);
      const programId = session.programId as number;
      const onlineSessionId = session.onlineSession?.id ?? null;
      const sessionStarted = this.hasSessionStarted(session);

      const eligible = await this.repository.findEligibleRegistrationsByProgram(
        programId,
        rmContactId,
        { userType: query.userType },
      );
      const eligibleIdSet = new Set(eligible.map((r) => r.id));
      // If THIS session is the program's final session, anyone who missed an already-elapsed
      // earlier session is excluded below (only from the still-open, not-yet-generated bucket —
      // same gate startBulkRegistration applies before generating a NEW join link).
      const finalSessionAbsentees = !sessionStarted
        ? await this.finalSessionAttendanceService.resolveFinalSessionAbsentees(
            programId,
            eligible.map((r) => r.id),
          )
        : null;
      const finalSessionAbsenteeIds =
        finalSessionAbsentees?.finalSessionId === sessionId
          ? finalSessionAbsentees.absenteeRegistrationIds
          : null;
      // Broader than `eligible`: also covers registrations that were eligible
      // when their link was generated but have since been deleted/made
      // ineligible, so an already-active extension can still be found below.
      // Scoped by `userType` too, not just `eligible` — the row list below is built from THIS
      // set (an already-generated link keeps a registrant listed even once they're no longer
      // program-eligible), so leaving it unscoped would leak rows the filter excludes.
      const registrations = await this.repository.findAllRegistrationsByProgramIncludingDeleted(
        programId,
        rmContactId,
        { userType: query.userType },
      );
      const registrationIds = registrations.map((r) => r.id);
      // Fetch each provisioned registrant's join URL + activation status in one
      // query. The extension row's `status` (REGISTERED) never flips back on
      // deactivation — only `activationStatus` and `joinUrl` (nulled) do — so
      // "generated" must check activationStatus, not mere presence in this map,
      // or a deactivated registrant would misleadingly show as generated with a
      // null joinUrl.
      const joinUrlByReg = onlineSessionId
        ? await this.repository.findActiveExtensionJoinUrls(registrationIds, onlineSessionId)
        : new Map<
            string,
            {
              joinUrl: string | null;
              activationStatus: RegistrationOnlineSessionActivationStatus | null;
              activationSource: RegistrationOnlineSessionActivationSource | null;
            }
          >();

      const jobs = await this.repository.findBulkJobsByProgram(programId);
      const outstanding = await this.dropResolvedFailures(this.mergeJobFailures(jobs, sessionId));
      const failureByReg = new Map(outstanding.map((f) => [f.registrationId, f]));

      const activeEligible = registrations.filter((registration) => {
        const extension = joinUrlByReg.get(String(registration.id));
        if (extension) {
          // Extension is authoritative here: an ACTIVE generated link keeps
          // the registrant listed regardless of program-level eligibility; an
          // INACTIVE one drops them regardless of activationSource.
          return extension.activationStatus === RegistrationOnlineSessionActivationStatus.ACTIVE;
        }
        // No extension row for this session. Once the session has started/completed,
        // its provisioning window is closed — such a registrant is excluded entirely
        // rather than shown as pending/failed. For an upcoming session, fall back to
        // program-level eligibility, bucketed as failed/pending below.
        if (sessionStarted) return false;
        if (!eligibleIdSet.has(registration.id)) return false;
        // Missed an earlier elapsed session of a multi-session program: excluded from THIS
        // (final) session's list entirely, same as a deactivated registrant — a bulk run would
        // skip them too, so they'd never actually reach "pending".
        if (finalSessionAbsenteeIds?.has(registration.id)) return false;
        return registration.activationStatus !== RegistrationOnlineSessionActivationStatus.INACTIVE;
      });

      const rows: ProvisionRegistrationRow[] = activeEligible.map((registration) => {
        const regKey = String(registration.id);
        const extension = joinUrlByReg.get(regKey);
        const generated = extension?.activationStatus === RegistrationOnlineSessionActivationStatus.ACTIVE;
        const failure = failureByReg.get(registration.id);
        const status: ProvisionRegistrationStatus = generated
          ? 'generated'
          : failure
            ? 'failed'
            : 'pending';
        return {
          registrationId: registration.id,
          registrationSeqNumber: registration.registrationSeqNumber ?? null,
          fullName: registration.fullName ?? null,
          email: registration.emailAddress ?? null,
          mobile: registration.mobileNumber ?? null,
          status,
          joinUrl: extension?.joinUrl ?? null,
          // No extension row means this registrant only reached `rows` via the
          // upcoming-session fallback (a started session excludes them entirely
          // instead) — reflect their own program-level activation rollup rather
          // than null.
          activationStatus: extension?.activationStatus ?? registration.activationStatus ?? null,
          reason: status === 'failed' ? failure?.reason : undefined,
          message: status === 'failed' ? failure?.message : undefined,
          generatedLinkId: null,
        };
      });

      const generalLinksBySession = await this.generatedLinkRepository.countRegisteredByProgramSessionIds([
        sessionId,
      ]);
      const summary: ProvisionCounts = {
        totalEligible: rows.length,
        generated: rows.filter((r) => r.status === 'generated').length,
        failed: rows.filter((r) => r.status === 'failed').length,
        yetToGenerate: rows.filter((r) => r.status === 'pending').length,
        generalLinks: generalLinksBySession.get(sessionId) ?? 0,
      };
      const kpis = this.buildProvisionKpis(summary, sessionStarted);
      const page = query.page > 0 ? query.page : 1;

      // 'generalLink' rows live in a different table (zoom_generated_registrant_link, no
      // ProgramRegistration of their own) — fetched and paginated independently of the
      // registration-based rows above, but folded into the same response shape.
      if (query.status === 'generalLink') {
        const limit = query.limit > 0 ? query.limit : summary.generalLinks || 1;
        const { data: links, total } = await this.generatedLinkRepository.listBySession(
          sessionId,
          page,
          limit,
          OnlineSessionRegistrationStatus.REGISTERED,
        );
        const data: ProvisionRegistrationRow[] = links.map((link) => ({
          registrationId: null,
          registrationSeqNumber: null,
          fullName: link.displayName,
          email: link.sourceEmail ?? link.registrantEmail,
          mobile: link.sourceMobile,
          status: ZoomBulkRegistrationService.capitalizeFirst('generalLink'),
          joinUrl: link.joinUrl,
          activationStatus: null,
          generatedLinkId: Number(link.id),
        }));
        return {
          sessionId,
          summary,
          kpis,
          data,
          pagination: { page, limit, total },
        };
      }

      const filtered = query.status ? rows.filter((r) => r.status === query.status) : rows;
      const limit = query.limit > 0 ? query.limit : filtered.length;
      const start = (page - 1) * limit;
      const displayRows = filtered.slice(start, start + limit).map((row) => ({
        ...row,
        status: ZoomBulkRegistrationService.capitalizeFirst(row.status),
        activationStatus: row.activationStatus ? ZoomBulkRegistrationService.capitalizeFirst(row.activationStatus) : null,
      }));
      return {
        sessionId,
        summary,
        kpis,
        data: displayRows,
        pagination: { page, limit, total: filtered.length },
      };
    } catch (error) {
      this.logger.error('Error building session provision registrations', error?.stack, {
        error,
        sessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_BULK_FAILURES_FETCH_FAILED, error);
    }
  }

  /**
   * Renders a session's provisioning counts as the v1-style KPI tile array (label/value), the same
   * shape `ZoomAnalyticsFacadeService` uses for its `/kpis` responses — including
   * `counts.generalLinks`, which counts a different table (zoom_generated_registrant_link) than the
   * other three tiles but is surfaced here alongside them. Each clickable tile carries the `filter`
   * value the frontend re-sends as `?status=` to `getSessionProvisionRegistrations` to drill into
   * its rows; `'Total Eligible'` has none since it isn't a single bucket. Once the session has
   * started/completed there's no more provisioning window left, so `counts.yetToGenerate` is
   * always 0 — the 'Yet To Generate' tile is dropped entirely rather than shown at 0.
   */
  private buildProvisionKpis(counts: ProvisionCounts, sessionStarted: boolean): ProvisionKpiTile[] {
    const tiles: ProvisionKpiTile[] = [
      { label: 'Total Eligible', value: counts.totalEligible },
      { label: 'Generated', value: counts.generated, filter: 'generated' },
      { label: 'Failed', value: counts.failed, filter: 'failed' },
    ];
    if (!sessionStarted) {
      tiles.push({ label: 'Yet To Generate', value: counts.yetToGenerate, filter: 'pending' });
    }
    tiles.push({ label: 'Others (General Links)', value: counts.generalLinks, filter: 'generalLink' });
    return tiles;
  }

  /**
   * Resolves the webinar/meeting sessions to register everyone against. A
   * `sessionId` targets that one session (must be Zoom-provisioned). Without a
   * `sessionId`, every Zoom-provisioned session of the program is targeted —
   * registrants are bulk-registered to all of them. At least one provisioned
   * session is required.
   */
  private async resolveTargetSessions(dto: BulkRegisterZoomDto): Promise<ProgramSession[]> {
    if (dto.sessionId) {
      const session = await this.onlineSessionService.findOne(dto.sessionId);
      if (!session.onlineSession?.externalId) {
        throw new InifniBadRequestException(
          ERROR_CODES.ZOOM_SESSION_NOT_PROVISIONED,
          null,
          null,
          dto.sessionId.toString(),
        );
      }
      return [session];
    }

    const { data } = await this.onlineSessionService.findAll(
      PROVISIONED_SESSION_SCAN_LIMIT,
      0,
      undefined,
      dto.programId as number,
    );
    const provisioned = data.filter((s) => s.onlineSession?.externalId);
    if (provisioned.length === 0) {
      throw new InifniBadRequestException(
        ERROR_CODES.ZOOM_BULK_WEBINAR_UNRESOLVED,
        null,
        null,
        String(dto.programId),
      );
    }
    return provisioned;
  }

  async getBulkJobStatus(jobId: number) {
    const job = await this.repository.findBulkJobById(jobId);
    if (!job) {
      throw new InifniNotFoundException(
        ERROR_CODES.ZOOM_BULK_JOB_NOTFOUND,
        null,
        null,
        jobId.toString(),
      );
    }
    return job;
  }

  /**
   * Paginated per-item failure list for a bulk job, enriched with the
   * registrant's identity and the target session's name so the admin can act on
   * each failure without resolving raw ids. Only the requested page is enriched —
   * the DB lookups are scoped to that slice, not the whole failure set.
   */
  async getBulkJobFailures(
    jobId: number,
    paging: { page: number; limit: number },
    rmContactId?: number,
  ): Promise<BulkRegistrationFailureList> {
    try {
      const job = await this.getBulkJobStatus(jobId);
      const metadata = (job.metadata ?? {}) as Partial<BulkRegistrationJobMetadata>;
      // Drop pairs since resolved (retried or manually pushed) so the list
      // reflects what is ACTUALLY still failing, not this run's frozen snapshot.
      let failures = await this.dropResolvedFailures(metadata.failures ?? []);
      if (rmContactId != null && job.programId != null) {
        failures = await this.restrictFailuresToRm(failures, job.programId, rmContactId);
      }
      const page = paging.page > 0 ? paging.page : 1;
      const limit = paging.limit > 0 ? paging.limit : failures.length;
      const start = (page - 1) * limit;
      const pageFailures = failures.slice(start, start + limit);

      const data = await this.enrichFailures(pageFailures);
      return { data, pagination: { page, limit, total: failures.length } };
    } catch (error) {
      this.logger.error('Error fetching bulk Zoom registration failures', error?.stack, {
        error,
        jobId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_BULK_FAILURES_FETCH_FAILED, error);
    }
  }

  /**
   * Enriches a page of stored failures with the current registration + session
   * rows. Lookups are batched over the page's distinct ids; a registration or
   * session that no longer exists leaves its enrichment fields null rather than
   * dropping the failure — the failure still happened and must stay visible.
   */
  private async enrichFailures(
    failures: BulkRegistrationFailure[],
  ): Promise<BulkRegistrationFailureRow[]> {
    if (!failures.length) return [];
    const registrationIds = [...new Set(failures.map((f) => f.registrationId))];
    const sessionIds = [...new Set(failures.map((f) => f.sessionId))];

    const registrations = await this.repository.findRegistrationsByIds(registrationIds);
    const registrationById = new Map(registrations.map((r) => [r.id, r]));

    const sessionResults = await Promise.allSettled(
      sessionIds.map((id) => this.onlineSessionService.findOne(id)),
    );
    const sessionNameById = new Map<number, string>();
    const onlineSessionIdBySession = new Map<number, number>();
    for (const result of sessionResults) {
      if (result.status === 'fulfilled') {
        sessionNameById.set(result.value.id, result.value.name);
        if (result.value.onlineSession?.id != null) {
          onlineSessionIdBySession.set(result.value.id, result.value.onlineSession.id);
        }
      }
    }

    const pairs = failures
      .map((failure) => {
        const onlineSessionId = onlineSessionIdBySession.get(failure.sessionId);
        return onlineSessionId != null
          ? { registrationId: failure.registrationId, onlineSessionId }
          : null;
      })
      .filter((pair): pair is { registrationId: number; onlineSessionId: number } => pair != null);
    const snapshotByPair = await this.repository.findExtensionSnapshots(pairs);

    return failures.map((failure) => {
      const registration = registrationById.get(failure.registrationId);
      const onlineSessionId = onlineSessionIdBySession.get(failure.sessionId);
      const snapshot =
        onlineSessionId != null
          ? snapshotByPair.get(`${failure.registrationId}:${onlineSessionId}`)
          : undefined;
      return {
        registrationId: failure.registrationId,
        registrationSeqNumber: registration?.registrationSeqNumber ?? null,
        fullName: registration?.fullName ?? null,
        email: registration?.emailAddress ?? null,
        mobile: registration?.mobileNumber ?? null,
        sessionId: failure.sessionId,
        sessionName: sessionNameById.get(failure.sessionId) ?? null,
        reason: failure.reason,
        message: failure.message,
        joinUrl: snapshot?.joinUrl ?? null,
        activationStatus: snapshot?.activationStatus ?? null,
      };
    });
  }

  /**
   * Restricts a failure list to registrations belonging to the given RM —
   * reuses the same eligible-by-program-and-rm set the provisioning views are
   * scoped by, so an RM caller sees the same "their own contacts" boundary
   * everywhere.
   */
  private async restrictFailuresToRm(
    failures: BulkRegistrationFailure[],
    programId: number,
    rmContactId: number,
  ): Promise<BulkRegistrationFailure[]> {
    if (!failures.length) return failures;
    const eligible = await this.repository.findEligibleRegistrationsByProgram(programId, rmContactId);
    const allowedIds = new Set(eligible.map((r) => r.id));
    return failures.filter((failure) => allowedIds.has(failure.registrationId));
  }

  /**
   * Merges the per-item failures of a program's jobs into one set keyed by
   * (registration, session). Jobs arrive newest-first; walking oldest-first lets
   * a newer job's entry overwrite an older one, so the surviving reason is the
   * most recent. An optional `sessionId` narrows to a single session.
   */
  private mergeJobFailures(
    jobs: BackgroundJob[],
    sessionId?: number,
  ): BulkRegistrationFailure[] {
    const byPair = new Map<string, BulkRegistrationFailure>();
    for (const job of [...jobs].reverse()) {
      const metadata = (job.metadata ?? {}) as Partial<BulkRegistrationJobMetadata>;
      for (const failure of metadata.failures ?? []) {
        if (sessionId != null && failure.sessionId !== sessionId) continue;
        byPair.set(`${failure.registrationId}:${failure.sessionId}`, failure);
      }
    }
    return [...byPair.values()];
  }

  /**
   * Drops failures that are no longer real: a (registration, session) pair that
   * now holds an active Zoom extension has since been registered (by a retry or
   * a manual push) and must not be reported as failed. The failure's `sessionId`
   * is a program-session id, so it is first mapped to its online-session id (the
   * extension key); a session that can no longer be resolved is treated as
   * unresolved and kept, since the failure cannot be disproved.
   */
  private async dropResolvedFailures(
    failures: BulkRegistrationFailure[],
  ): Promise<BulkRegistrationFailure[]> {
    if (!failures.length) return [];
    const sessionIds = [...new Set(failures.map((f) => f.sessionId))];
    const sessionResults = await Promise.allSettled(
      sessionIds.map((id) => this.onlineSessionService.findOne(id)),
    );
    const onlineSessionIdBySession = new Map<number, number>();
    for (const result of sessionResults) {
      if (result.status === 'fulfilled') {
        const onlineSessionId = result.value.onlineSession?.id;
        if (onlineSessionId != null) onlineSessionIdBySession.set(result.value.id, onlineSessionId);
      }
    }

    const registrationIds = [...new Set(failures.map((f) => f.registrationId))];
    const onlineSessionIds = [...new Set(onlineSessionIdBySession.values())];
    const activeKeys = await this.repository.findActiveExtensionKeys(
      registrationIds,
      onlineSessionIds,
    );

    return failures.filter((failure) => {
      const onlineSessionId = onlineSessionIdBySession.get(failure.sessionId);
      if (onlineSessionId == null) return true;
      return !activeKeys.has(`${failure.registrationId}:${onlineSessionId}`);
    });
  }

  /**
   * Builds the flat (registration × session) work list for the initial run. `excludeIdsBySessionId`
   * drops specific registrants from one session's pairs only (the final-session absentee gate) —
   * every other session still gets the full registrant list.
   */
  private buildWorkItems(
    targetSessions: ProgramSession[],
    registrations: ProgramRegistration[],
    excludeIdsBySessionId?: Map<number, Set<number>>,
  ): RegistrationWorkItem[] {
    const items: RegistrationWorkItem[] = [];
    for (const session of targetSessions) {
      const excluded = excludeIdsBySessionId?.get(session.id);
      for (const registration of registrations) {
        if (excluded?.has(registration.id)) continue;
        items.push({ registration, session });
      }
    }
    return items;
  }

  /** Loads the program's ineligible registrants and maps each to a skip reason. */
  private async loadIneligible(programId: number): Promise<BulkRegistrationIneligible[]> {
    const rows = await this.repository.findIneligibleRegistrationsByProgram(programId);
    return rows.map((row) => ({
      registrationId: row.registrationId,
      registrationSeqNumber: row.registrationSeqNumber,
      // Seat takes precedence: a registrant with no seat is ineligible regardless
      // of status. Otherwise the disqualifying status is the reason.
      reason: !row.seatAllocated ? INELIGIBLE_NO_SEAT_REASON : row.registrationStatus,
    }));
  }

  /**
   * Rebuilds the (registration, session) work items for a set of recorded
   * failures, loading the current registration and session rows. Items whose
   * registration or session no longer exists are dropped rather than aborting the
   * whole retry.
   */
  private async resolveFailedWorkItems(
    failures: BulkRegistrationFailure[],
  ): Promise<RegistrationWorkItem[]> {
    const registrationIds = [...new Set(failures.map((f) => f.registrationId))];
    const sessionIds = [...new Set(failures.map((f) => f.sessionId))];

    const registrations = await this.repository.findRegistrationsByIds(registrationIds);
    const registrationById = new Map(registrations.map((r) => [r.id, r]));

    const sessionResults = await Promise.allSettled(
      sessionIds.map((id) => this.onlineSessionService.findOne(id)),
    );
    const sessionById = new Map<number, ProgramSession>();
    for (const result of sessionResults) {
      if (result.status === 'fulfilled') sessionById.set(result.value.id, result.value);
    }

    const items: RegistrationWorkItem[] = [];
    for (const failure of failures) {
      const registration = registrationById.get(failure.registrationId);
      const session = sessionById.get(failure.sessionId);
      if (registration && session) items.push({ registration, session });
    }
    return items;
  }

  /**
   * Runs a bulk job to completion. Attendees, meetings, and shared-webinar
   * registrations use the per-item path, grouped by registrant so a registrant's
   * sibling sessions (same recurring/SHARED webinar) are always registered one at
   * a time rather than concurrently — see {@link registerRegistrantGroup} for why.
   * Different registrants still register in parallel, up to `batchSize` at once.
   * Panelists on a PER_SESSION webinar are batched per session — Zoom accepts the
   * whole `panelists` array in one call, collapsing ~2N API calls to a handful.
   * Progress (counts + failures) is flushed after every batch/group so the poll
   * endpoint stays live; each item/group is independent and idempotent.
   */
  private async processWorkItems(
    jobId: number,
    workItems: RegistrationWorkItem[],
    role: ZoomRole,
    batchSize: number,
    baseMetadata: BulkRegistrationJobMetadata,
    actorUserId?: number,
  ): Promise<void> {
    const tally: BulkTally = { registered: 0, skipped: 0, failed: 0 };
    const failures: BulkRegistrationFailure[] = [];
    const flush = async (): Promise<void> => {
      await this.repository.updateBulkJob(jobId, {
        generated: tally.registered,
        skipped: tally.skipped,
        failed: tally.failed,
        metadata: { ...baseMetadata, failures },
      });
    };

    try {
      // Split panelist-on-PER_SESSION-webinar items (batchable, grouped by
      // session) from everything else (per-item).
      const perItem: RegistrationWorkItem[] = [];
      const panelistBySession = new Map<number, RegistrationWorkItem[]>();
      for (const item of workItems) {
        if (role === ZoomRole.PANELIST && this.isPanelistBatchable(item.session)) {
          const group = panelistBySession.get(item.session.id) ?? [];
          group.push(item);
          panelistBySession.set(item.session.id, group);
        } else {
          perItem.push(item);
        }
      }

      // --- per-item path (attendees, meetings, shared webinars) ---
      // Grouped by registrant: a registrant registering against several sibling
      // sessions of one SHARED webinar must do so one session at a time, so the
      // first call's persisted join link is there for the second to reuse instead
      // of both racing the real Zoom API for the same person. Batching is over
      // registrant groups, not raw items, so throughput across DIFFERENT
      // registrants is unaffected.
      const registrantGroups = this.groupByRegistration(perItem);
      for (let offset = 0; offset < registrantGroups.length; offset += batchSize) {
        const batch = registrantGroups.slice(offset, offset + batchSize);
        await Promise.all(
          batch.map((group) => this.registerRegistrantGroup(group, role, tally, failures, actorUserId)),
        );
        await flush();
        // Pace successive batches to stay under Zoom's API rate limits.
        if (offset + batchSize < registrantGroups.length) await this.delay(BULK_BATCH_DELAY_MS);
      }

      // --- batched panelist path (one array call per webinar session) ---
      const panelistGroups = [...panelistBySession.values()];
      for (let index = 0; index < panelistGroups.length; index++) {
        await this.registerPanelistGroup(panelistGroups[index], tally, failures, actorUserId);
        await flush();
        if (index < panelistGroups.length - 1) await this.delay(BULK_BATCH_DELAY_MS);
      }

      await this.repository.updateBulkJobStatus(jobId, ExportJobStatus.COMPLETED, {
        generated: tally.registered,
        skipped: tally.skipped,
        failed: tally.failed,
        metadata: { ...baseMetadata, failures },
      });
      this.logger.log('Bulk Zoom registration job completed', {
        jobId,
        registered: tally.registered,
        skipped: tally.skipped,
        failed: tally.failed,
      });
    } catch (error) {
      await this.repository.updateBulkJobStatus(jobId, ExportJobStatus.FAILED, {
        generated: tally.registered,
        skipped: tally.skipped,
        failed: tally.failed,
        metadata: { ...baseMetadata, failures },
        errorMessage: (error as Error)?.message ?? 'Bulk Zoom registration failed',
      });
      this.logger.error('Bulk Zoom registration job failed', error?.stack, { error, jobId });
    }
  }

  /**
   * Panelists can be array-added only on a PER_SESSION webinar. SHARED webinars
   * reuse one link across sibling sessions and meetings have no panelists — both
   * keep the per-item path, which handles their link semantics.
   */
  private isPanelistBatchable(session: ProgramSession): boolean {
    return (
      session.onlineType !== OnlineTypeEnum.MEETING &&
      session.onlineSession?.linkMode !== SessionLinkModeEnum.SHARED &&
      !!session.onlineSession?.externalId
    );
  }

  /**
   * Groups flat work items by registrant, preserving each registrant's first
   * appearance order. A registrant's items span one entry per target session, so
   * the resulting groups are exactly what {@link registerRegistrantGroup} needs.
   */
  private groupByRegistration(items: RegistrationWorkItem[]): RegistrationWorkItem[][] {
    const byRegistration = new Map<number, RegistrationWorkItem[]>();
    for (const item of items) {
      const group = byRegistration.get(item.registration.id) ?? [];
      group.push(item);
      byRegistration.set(item.registration.id, group);
    }
    return [...byRegistration.values()];
  }

  /**
   * Registers one registrant against every item in their group, ONE AT A TIME.
   * For a SHARED (recurring) webinar, sibling sessions share one real Zoom
   * webinar and {@link ZoomRegistrationService} reuses the first session's join
   * link for the rest instead of calling Zoom again — but that reuse check only
   * sees a sibling's link once it's persisted. Running a registrant's sessions
   * concurrently (Promise.all) races that check: every sibling sees "no link
   * yet" at the same instant and each independently calls Zoom's real
   * `addRegistrant` for the same person, tripping Zoom's per-registrant daily
   * rate limit. Sequential-per-registrant avoids that; different registrants
   * still run concurrently via the batch in {@link processWorkItems}.
   */
  private async registerRegistrantGroup(
    items: RegistrationWorkItem[],
    role: ZoomRole,
    tally: BulkTally,
    failures: BulkRegistrationFailure[],
    actorUserId?: number,
  ): Promise<void> {
    for (const item of items) {
      await this.registerOneItem(item, role, tally, failures, actorUserId);
    }
  }

  /** Registers one work item via the per-item path, updating the shared tally. */
  private async registerOneItem(
    item: RegistrationWorkItem,
    role: ZoomRole,
    tally: BulkTally,
    failures: BulkRegistrationFailure[],
    actorUserId?: number,
  ): Promise<void> {
    const { registration, session } = item;
    try {
      const outcome = await this.registrationService.registerForBulk(
        registration,
        session,
        role,
        actorUserId,
      );
      if (outcome === 'registered') tally.registered++;
      else tally.skipped++;
    } catch (err) {
      tally.failed++;
      const failure = this.toFailure(registration.id, session.id, err);
      failures.push(failure);
      // Persist the failure as a durable row on the extension table (keyed by the
      // pair) alongside the job-metadata tally, so failures survive beyond the job
      // record. Best-effort: a write miss must not disrupt the batch tally.
      await this.persistFailedExtension(registration, session, failure, actorUserId);
      this.logger.error('Bulk Zoom registration failed for registration', (err as Error)?.stack, {
        err,
        registrationId: registration.id,
        sessionId: session.id,
      });
    }
  }

  /**
   * Registers a whole webinar session's panelists in one batched call, then folds
   * the per-registration outcomes into the shared tally + failure list. A
   * session-wide failure (e.g. session not provisioned) fails every item in the
   * group so nothing is silently dropped.
   */
  private async registerPanelistGroup(
    items: RegistrationWorkItem[],
    tally: BulkTally,
    failures: BulkRegistrationFailure[],
    actorUserId?: number,
  ): Promise<void> {
    const session = items[0].session;
    const registrations = items.map((item) => item.registration);

    let outcomes: Map<number, PanelistBulkOutcome>;
    try {
      outcomes = await this.registrationService.registerPanelistsForBulk(
        registrations,
        session,
        actorUserId,
      );
    } catch (err) {
      for (const registration of registrations) {
        tally.failed++;
        const failure = this.toFailure(registration.id, session.id, err);
        failures.push(failure);
        await this.persistFailedExtension(registration, session, failure, actorUserId);
      }
      this.logger.error('Bulk panelist registration failed for session', (err as Error)?.stack, {
        err,
        sessionId: session.id,
      });
      return;
    }

    for (const registration of registrations) {
      const outcome = outcomes.get(registration.id);
      if (!outcome || outcome.status === 'failed') {
        tally.failed++;
        const failure = this.toFailure(
          registration.id,
          session.id,
          outcome && outcome.status === 'failed' ? outcome.error : undefined,
        );
        failures.push(failure);
        await this.persistFailedExtension(registration, session, failure, actorUserId);
      } else if (outcome.status === 'registered') {
        tally.registered++;
      } else {
        tally.skipped++;
      }
    }
  }

  /**
   * Maps a per-registrant error to a stored failure. Known domain errors carry a
   * stable code + message; anything else is bucketed under the generic Zoom API
   * error so the reason is never empty.
   */
  /**
   * Best-effort durable write of a failed (registration, online session) pair to
   * the extension table, mirroring the job-metadata failure tally. Keyed by the
   * session's online-session id; skipped (metadata still records it) when the
   * session is not provisioned. Never throws — a write miss must not disrupt the
   * batch tally or abort the run.
   */
  private async persistFailedExtension(
    registration: ProgramRegistration,
    session: ProgramSession,
    failure: BulkRegistrationFailure,
    actorUserId?: number,
  ): Promise<void> {
    const onlineSessionId = session.onlineSession?.id;
    if (onlineSessionId == null) return;
    try {
      await this.repository.recordExtensionFailure({
        registrationId: registration.id,
        onlineSessionId,
        reason: failure.reason,
        message: failure.message,
        actorUserId,
      });
    } catch (error) {
      this.logger.error('Error persisting failed zoom extension row', error?.stack, {
        error,
        registrationId: registration.id,
        onlineSessionId,
      });
    }
  }

  private toFailure(
    registrationId: number,
    sessionId: number,
    err: unknown,
  ): BulkRegistrationFailure {
    if (err instanceof MainInifniException) {
      return { registrationId, sessionId, reason: err.code, message: err.message };
    }
    const message = err instanceof Error ? err.message : String(err);
    return { registrationId, sessionId, reason: ERROR_CODES.ZOOM_API_ERROR, message };
  }

  private delay(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}
