import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, EntityManager, IsNull, Not, In, SelectQueryBuilder } from 'typeorm';
import {
  ProgramRegistration,
  ProgramRegistrationOnlineSession,
  ProgramSession,
  OnlineSession,
  BackgroundJob,
  User,
  RegistrationPaymentDetail,
} from 'src/common/entities';
import { AppLoggerService } from 'src/common/services/logger.service';
import { handleKnownErrors } from 'src/common/utils/handle-error.util';
import { formatPaymentStatus } from 'src/common/utils/status-formatter.util';
import { calculateAge } from 'src/common/utils/common.util';
import {
  UserTypeFilterValue,
  resolveUserTypeFilter,
  userTypeFilterIncludesAccountless,
} from 'src/common/utils/user-type-filter.util';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { ROLE_KEYS } from 'src/common/constants/strings-constants';
import { ExportJobStatus } from 'src/common/enum/export-job-status.enum';
import { JobTypeEnum } from 'src/common/enum/job-type.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,
  TERMINAL_REGISTRATION_ACTIVATION_SOURCES,
} from 'src/common/enum/registration-online-session-activation-source.enum';
import { PaymentStatusEnum } from 'src/common/enum/payment-status.enum';
import { ProgramEligibleKpiFilter } from 'src/common/enum/program-eligible-kpi.enum';
import { REGISTRATION_INELIGIBLE_STATUSES } from 'src/common/constants/zoom.constants';
import {
  RegistrationWithJoinUrl,
  EligibleActivationSummary,
  ProgramEligibleRegistrationRow,
  ProgramSessionAttendance,
  SessionAttendanceState,
  ConductedSession,
  ProgramEligibleRegistrationsQuery,
  ProgramEligibleKpis,
  RmContactOption,
  ProgramRegistrationStatusRow,
} from '../interfaces/zoom-registration.interface';
import { ZoomRegistrationProfile } from '../interfaces/zoom-analytics.interface';

/**
 * A user's display name, falling back through org_usr_name → legal_full_name →
 * full_name → "first_name last_name" — same identity-resolution chain used for
 * an RM's name wherever it's selected, sorted, or listed. `alias` is the query
 * builder alias for the `users` row (e.g. "rmContactUser" joined off
 * `registration.rm_contact`, or "user" when querying `users` directly).
 */
function rmDisplayNameSql(alias: string): string {
  return `COALESCE(
    NULLIF(TRIM(${alias}.org_usr_name), ''),
    NULLIF(TRIM(${alias}.legal_full_name), ''),
    NULLIF(TRIM(${alias}.full_name), ''),
    NULLIF(TRIM(CONCAT(COALESCE(${alias}.first_name, ''), ' ', COALESCE(${alias}.last_name, ''))), '')
  )`;
}

/**
 * The canonical registration stays in `hdb_program_registration`; the
 * provider-specific fields (provider, external registrant id, join url, panelist
 * flag) live in the 1:1 `hdb_program_registration_online_session` extension.
 * This repository spans both.
 */
@Injectable()
export class ZoomRegistrationRepository {
  constructor(
    @InjectRepository(ProgramRegistration)
    private readonly registrationRepo: Repository<ProgramRegistration>,
    @InjectRepository(ProgramRegistrationOnlineSession)
    private readonly zoomRepo: Repository<ProgramRegistrationOnlineSession>,
    @InjectRepository(BackgroundJob)
    private readonly jobRepo: Repository<BackgroundJob>,
    private readonly logger: AppLoggerService,
  ) {}

  private zoom(manager?: EntityManager): Repository<ProgramRegistrationOnlineSession> {
    return manager ? manager.getRepository(ProgramRegistrationOnlineSession) : this.zoomRepo;
  }

  /** Runs the given DB work in one transaction — used to keep the activation-row update and the eligible-count adjustment atomic. */
  async withTransaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
    return this.zoomRepo.manager.transaction(work);
  }

  /** Loads a program registration with the relations the Zoom flow needs. */
  async findRegistrationById(
    registrationId: number,
    manager?: EntityManager,
  ): Promise<ProgramRegistration | null> {
    try {
      const repo = manager
        ? manager.getRepository(ProgramRegistration)
        : this.registrationRepo;
      return await repo.findOne({
        where: { id: registrationId, deletedAt: IsNull() },
        relations: { user: true, programSession: { onlineSession: true } },
      });
    } catch (error) {
      this.logger.error('Error finding program registration', error?.stack, {
        error,
        registrationId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /** Confirmed registrations for a session, used to map participants → users. */
  async findRegistrationsBySession(
    programSessionId: number,
    manager?: EntityManager,
  ): Promise<ProgramRegistration[]> {
    try {
      const repo = manager
        ? manager.getRepository(ProgramRegistration)
        : this.registrationRepo;
      return await repo.find({
        where: { programSession: { id: programSessionId }, deletedAt: IsNull() },
        relations: { user: true },
      });
    } catch (error) {
      this.logger.error('Error fetching registrations by session', error?.stack, {
        error,
        programSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Eligible registrations of a PROGRAM — the bulk Zoom register target set.
   * Registrations are fetched by `program_id` (program_session_id is typically
   * empty); the caller resolves the target webinar session separately.
   * `rmContactId`, when given (an RM caller), scopes the set to their own
   * contacts — same convention as the other RM-scoped listing queries.
   *
   * `options.excludeDeactivated`, when true, also requires the registration-level
   * activation rollup to be ACTIVE — used only by the bulk-registration start path
   * so a deactivated registrant is never freshly (re)provisioned onto a session
   * that has no extension row yet. Left off (the default) for every other caller
   * (KPI/provision-status views, RM failure-scoping), which deliberately still
   * need BOTH active and inactive registrants in the result to bucket/display them.
   *
   * `options.userType`, when given, narrows to registrants whose own user account
   * is one of the listed types (Org/Seeker) — plus, when `Seeker` is selected, the
   * "registered for someone else" rows, which have no user account of their own
   * (see userTypeFilterIncludesAccountless). Filtered in the query rather than by
   * the caller so the provision-status counts and the drilldown rows are derived
   * from one identical eligible set.
   */
  /**
   * Layers a resolved `userType` selection onto a `find()` where-clause.
   *
   * Returns the clause untouched when no filter is active. With a filter, the
   * registrant's own user row must match one of the selected types — and when
   * `Seeker` is selected, a registration with NO user row of its own also matches
   * ("registered for someone else"; see userTypeFilterIncludesAccountless). That
   * "or" can't be expressed inside a single relation condition, so it becomes
   * TypeORM's array-of-where form, which ORs its entries — hence the base clause
   * being duplicated across both arms rather than shared.
   */
  private applyUserTypeWhere<T extends Record<string, unknown>>(
    baseWhere: T,
    userTypes: UserTypeFilterValue[] | null,
  ): T | T[] {
    if (!userTypes) return baseWhere;
    const typeMatch = { ...baseWhere, user: { userType: In(userTypes) } } as unknown as T;
    if (!userTypeFilterIncludesAccountless(userTypes)) return typeMatch;
    return [typeMatch, { ...baseWhere, userId: IsNull() } as unknown as T];
  }

  async findEligibleRegistrationsByProgram(
    programId: number,
    rmContactId?: number,
    options?: { excludeDeactivated?: boolean; userType?: UserTypeFilterValue[] },
  ): Promise<ProgramRegistration[]> {
    try {
      const userTypes = resolveUserTypeFilter(options?.userType);
      const baseWhere = {
        programId,
        seatAllocated: true,
        registrationStatus: Not(In(REGISTRATION_INELIGIBLE_STATUSES)),
        deletedAt: IsNull(),
        ...(rmContactId != null ? { rmContact: rmContactId } : {}),
        ...(options?.excludeDeactivated
          ? { activationStatus: RegistrationOnlineSessionActivationStatus.ACTIVE }
          : {}),
      };
      return await this.registrationRepo.find({
        where: this.applyUserTypeWhere(baseWhere, userTypes),
        relations: { user: true },
      });
    } catch (error) {
      this.logger.error('Error fetching eligible registrations by program', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Every registration of a program, regardless of eligibility — no
   * `seatAllocated`/`registrationStatus`/`deletedAt` filter, and soft-deleted
   * rows included via `withDeleted`. Used only to cross-reference an
   * already-generated session extension for a registrant who is no longer
   * program-eligible (deleted, cancelled, seat released after the fact), so a
   * previously-generated join link doesn't silently vanish from the
   * per-session provisioning drilldown just because the registration changed
   * state afterwards. `rmContactId` scopes to an RM's own contacts, same as
   * {@link findEligibleRegistrationsByProgram}. Distinct from
   * {@link findAllRegistrationsByProgram} (the KPI roster query), which
   * excludes deleted rows and returns a raw partial projection, not entities.
   */
  async findAllRegistrationsByProgramIncludingDeleted(
    programId: number,
    rmContactId?: number,
    options?: { userType?: UserTypeFilterValue[] },
  ): Promise<ProgramRegistration[]> {
    try {
      // `userType` narrows by the registrant's own account type, same semantics as
      // {@link findEligibleRegistrationsByProgram} — kept in step with it so a caller that scopes
      // both sets (the provisioning drilldown) can't end up with an already-generated row from a
      // user type the caller filtered out.
      const userTypes = resolveUserTypeFilter(options?.userType);
      const baseWhere = {
        programId,
        ...(rmContactId != null ? { rmContact: rmContactId } : {}),
      };
      return await this.registrationRepo.find({
        where: this.applyUserTypeWhere(baseWhere, userTypes),
        relations: { user: true },
        withDeleted: true,
      });
    } catch (error) {
      this.logger.error('Error fetching all registrations by program', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * The complement of {@link findEligibleRegistrationsByProgram}: a program's
   * registrations that are NOT eligible for Zoom registration, with just enough
   * to explain why (no seat, or a disqualifying status). Powers the job's
   * eligibility breakdown; kept lightweight (no relations) as it is display-only.
   */
  async findIneligibleRegistrationsByProgram(
    programId: number,
  ): Promise<
    Array<{
      registrationId: number;
      registrationSeqNumber: string | null;
      registrationStatus: string;
      seatAllocated: boolean;
    }>
  > {
    try {
      return await this.registrationRepo
        .createQueryBuilder('registration')
        .select([
          'registration.id AS "registrationId"',
          'registration.registration_seq_number AS "registrationSeqNumber"',
          'registration.registration_status AS "registrationStatus"',
          'registration.seat_allocated AS "seatAllocated"',
        ])
        .where('registration.program_id = :programId', { programId })
        .andWhere('registration.deleted_at IS NULL')
        .andWhere(
          '(registration.seat_allocated = false OR registration.registration_status IN (:...excluded))',
          { excluded: REGISTRATION_INELIGIBLE_STATUSES },
        )
        .orderBy('registration.id', 'DESC')
        .getRawMany();
    } catch (error) {
      this.logger.error('Error fetching ineligible registrations by program', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Every non-deleted registration in a program, with just enough fields to
   * drive the Overall Analytics "Total Attendees"/"Active" KPI tiles — no
   * relations, no status filtering (unlike {@link findEligibleRegistrationsByProgram}),
   * since those KPIs count the whole roster.
   */
  async findAllRegistrationsByProgram(programId: number): Promise<ProgramRegistrationStatusRow[]> {
    try {
      const rows = await this.registrationRepo
        .createQueryBuilder('registration')
        .select([
          'registration.id AS "registrationId"',
          'registration.registration_status AS "registrationStatus"',
          'registration.activation_status AS "activationStatus"',
          'registration.seat_allocated AS "seatAllocated"',
          'registration.full_name AS "fullName"',
        ])
        .where('registration.program_id = :programId', { programId })
        .andWhere('registration.deleted_at IS NULL')
        .getRawMany();
      // `id` is bigint — node-postgres returns it as a string with no further
      // coercion from getRawMany, same as every other raw bigint id read in
      // this module (see ZoomAnalyticsRosterRepository.coerceRow).
      return rows.map((row) => ({ ...row, registrationId: Number(row.registrationId) }));
    } catch (error) {
      this.logger.error('Error fetching all registrations by program', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Loads specific registrations by id (with the relations the push flow needs)
   * — used to re-run only the failed items of an earlier bulk job. Soft-deleted
   * registrations are excluded, so a since-deleted registrant is simply dropped.
   */
  async findRegistrationsByIds(ids: number[]): Promise<ProgramRegistration[]> {
    if (!ids.length) return [];
    try {
      return await this.registrationRepo.find({
        where: { id: In(ids), deletedAt: IsNull() },
        relations: { user: true },
      });
    } catch (error) {
      this.logger.error('Error fetching registrations by ids', error?.stack, { error, ids });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /** Raw correlated-subquery SQL for a registration's most recent payment record's status — reused in both the SELECT (via callback) and the WHERE clause below, since Postgres won't let WHERE reference a SELECT-list alias. */
  private static readonly LATEST_PAYMENT_STATUS_SQL = `(
    SELECT payment.payment_status FROM hdb_registration_payment_detail payment
    WHERE payment.registration_id = registration.id
    ORDER BY payment.payment_date DESC, payment.updated_at DESC
    LIMIT 1
  )`;

  /**
   * Raw correlated-subquery SQL for a registration's most recent payment
   * record's mode — same reasoning/reuse as LATEST_PAYMENT_STATUS_SQL above.
   */
  private static readonly LATEST_PAYMENT_MODE_SQL = `(
    SELECT payment.payment_mode FROM hdb_registration_payment_detail payment
    WHERE payment.registration_id = registration.id
    ORDER BY payment.payment_date DESC, payment.updated_at DESC
    LIMIT 1
  )`;

  /**
   * Whitelisted sort keys → real columns/expressions. Falls back to
   * `registration.registration_seq_number` for anything unrecognized.
   * `paymentStatus`/`paymentMode` are deliberately absent — both are derived
   * (a formatted label / a correlated-subquery value), not plain sortable
   * columns; kept in sync with `sortable` in PROGRAM_ELIGIBLE_REGISTRATIONS_TABLE_HEADERS.
   */
  private static readonly SORT_COLUMNS: Record<string, string> = {
    registrationSeqNumber: 'registration.registration_seq_number',
    fullName: 'registration.full_name',
    gender: 'registration.gender',
    mobile: 'registration.mobile_number',
    email: 'registration.email_address',
    activationStatus: 'registration.activation_status',
    rmContact: rmDisplayNameSql('rmContactUser'),
    city: 'registration.city',
  };

  /**
   * Paginated, searchable, filterable, sortable list of a program's
   * seat-allocated registrants. `joinUrl` here is the registrant's latest
   * extension row across ANY session (no single session is in scope on this
   * program-wide view) — see listEligibleRegistrationsByProgram for the
   * per-session-scoped view.
   */
  async listSeatAllocatedRegistrationsByProgram(
    programId: number,
    options: ProgramEligibleRegistrationsQuery,
  ): Promise<{ data: ProgramEligibleRegistrationRow[]; total: number }> {
    try {
      // Narrows to the registrant ids behind one clicked ProgramEligibleKpiTile — resolved via the
      // exact same breakdown getProgramEligibleKpis counts from, so a tile's value and the rows
      // behind it can never disagree. `null` (ALL/omitted, or unrecognized) means no narrowing.
      const kpiFilterIds =
        options.kpiFilter && options.kpiFilter !== ProgramEligibleKpiFilter.ALL
          ? await this.resolveProgramEligibleKpiFilterIds(programId, options.kpiFilter, options.rmContactId)
          : null;
      const baseQuery = () => {
        const qb = this.registrationRepo
          .createQueryBuilder('registration')
          .leftJoin(User, 'rmContactUser', 'rmContactUser.id = registration.rm_contact')
          .select([
            'registration.id AS "registrationId"',
            'registration.registration_seq_number AS "registrationSeqNumber"',
            'registration.full_name AS "fullName"',
            'registration.email_address AS "email"',
            'registration.mobile_number AS "mobile"',
            'registration.seat_allocated AS "seatAllocated"',
            'registration.gender AS "gender"',
            'registration.user_profile_url AS "profileUrl"',
            'registration.activation_status AS "activationStatus"',
            'registration.allocated_program_id AS "allocatedProgramId"',
            'registration.is_free_seat AS "isFreeSeat"',
            `${rmDisplayNameSql('rmContactUser')} AS "rmContactOrgName"`,
            'registration.other_infinitheism_contact AS "otherInfinitheismContact"',
            'registration.registration_mode AS "registrationMode"',
            'registration.dob AS "dob"',
            'registration.city AS "city"',
            'registration.other_city_name AS "otherCityName"',
          ])
          .addSelect(
            (sub) =>
              sub
                .select('payment.payment_status')
                .from(RegistrationPaymentDetail, 'payment')
                .where('payment.registration_id = registration.id')
                .orderBy('payment.payment_date', 'DESC')
                .addOrderBy('payment.updated_at', 'DESC')
                .limit(1),
            'paymentStatusRaw',
          )
          .addSelect(
            (sub) =>
              sub
                .select('extension.join_url')
                .from(ProgramRegistrationOnlineSession, 'extension')
                .where('extension.registration_id = registration.id')
                .andWhere('extension.deleted_at IS NULL')
                .andWhere("extension.status = 'registered'")
                .orderBy('extension.id', 'DESC')
                .limit(1),
            'joinUrl',
          )
          // Count of the program's sessions the registrant actually attended.
          .addSelect(
            (sub) =>
              sub
                .select('COUNT(DISTINCT att.session_id)')
                .from('program_user_attendance', 'att')
                .where('att.registration_id = registration.id')
                .andWhere('att.is_attended = true'),
            'attendedSessions',
          )
          .where('registration.program_id = :programId', { programId })
          .andWhere('registration.deleted_at IS NULL')
          .andWhere('registration.seat_allocated = true')
          .andWhere('registration.registration_status NOT IN (:...excluded)', {
            excluded: REGISTRATION_INELIGIBLE_STATUSES,
          });
        if (options.search) {
          qb.andWhere(
            '(registration.full_name ILIKE :search OR registration.email_address ILIKE :search OR registration.mobile_number ILIKE :search)',
            { search: `%${options.search}%` },
          );
        }
        // Server-derived RM scope: an RM caller only ever sees their own
        // contacts (same convention as the main registration list view).
        if (options.rmContactId != null) {
          qb.andWhere('registration.rm_contact = :scopedRmContactId', {
            scopedRmContactId: options.rmContactId,
          });
        }
        this.applyProgramEligibleFilters(qb, options.filters, options.rmContactId != null);
        if (kpiFilterIds != null) {
          if (kpiFilterIds.length === 0) {
            qb.andWhere('1 = 0');
          } else {
            qb.andWhere('registration.id IN (:...kpiFilterIds)', { kpiFilterIds });
          }
        }
        return qb;
      };

      const sortColumn =
        (options.sortKey && ZoomRegistrationRepository.SORT_COLUMNS[options.sortKey]) ||
        'registration.registration_seq_number';
      const sortOrder = options.sortOrder ?? 'DESC';

      const [rawData, total, programSessions] = await Promise.all([
        baseQuery()
          .orderBy(sortColumn, sortOrder)
          .addOrderBy('registration.id', 'DESC') // deterministic tiebreaker for stable pagination
          .offset(options.offset)
          .limit(options.limit)
          .getRawMany(),
        baseQuery().getCount(),
        this.listProgramSessions(programId),
      ]);
      // Per-registration attended-session ids for just this page (one extra query, no N+1).
      const attendedByRegistration = await this.findAttendedSessionIdsByRegistration(
        rawData.map((row) => Number(row.registrationId)),
      );
      return {
        data: rawData.map((row) =>
          this.toProgramEligibleRow(
            row,
            programSessions,
            attendedByRegistration.get(Number(row.registrationId)) ?? new Set<number>(),
          ),
        ),
        total,
      };
    } catch (error) {
      this.logger.error('Error listing seat-allocated registrations by program', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /** Age bracket codes (from filter-hierarchy.config.ts's `age` filter) → inclusive [min, max] years. */
  private static readonly AGE_BUCKETS: Record<string, { min: number; max: number }> = {
    '0-20': { min: 0, max: 20 },
    '21-30': { min: 21, max: 30 },
    '31-50': { min: 31, max: 50 },
    '51-65': { min: 51, max: 65 },
    '>65': { min: 66, max: 200 }, // 200 is an arbitrary upper bound, mirrors RegistrationRepository.findRegistrations
  };

  /**
   * Applies the equality filters described by the `filters` array in the
   * eligible-registrations response. `registrationStatus`/`activationStatus`/
   * `registrationMode` match raw columns directly; `gender` is case-insensitive
   * since its filter values are lowercase ("male"/"female") while the column
   * stores GenderEnum's capitalized casing ("Male"/"Female"); `rmContact` matches
   * the *resolved* display name (mirroring the "Other" → free-text fallback in
   * {@link toProgramEligibleRow}); `age` goes through {@link applyAgeFilter};
   * `paymentMode`/`paymentStatus` use the SAME coarse codes as the main
   * registration module's filter config (filter-hierarchy.config.ts's `payment`
   * set: online/offline, payment_completed/payment_pending/failed/no_payment) —
   * not raw enum values — so they go through dedicated helpers below;
   * `userType` is the registrant's own account type (Org/Seeker), resolved by
   * {@link resolveUserTypeFilter}.
   */
  private applyProgramEligibleFilters(
    qb: SelectQueryBuilder<ProgramRegistration>,
    filters: Record<string, string | string[]> | undefined,
    rmScoped = false,
  ): void {
    if (!filters) return;
    if (filters.gender) {
      // `gender` is a native Postgres enum column — `lower()` has no overload for it,
      // so it must be cast to text first (Postgres error 42883, function lower(gender_enum)
      // does not exist) before comparing against the filter's lowercase value.
      qb.andWhere('LOWER(registration.gender::text) = LOWER(:fGender)', { fGender: filters.gender });
    }
    if (filters.activationStatus) {
      qb.andWhere('registration.activation_status = :fActivationStatus', {
        fActivationStatus: filters.activationStatus,
      });
    }
    if (filters.registrationMode) {
      qb.andWhere('registration.registration_mode = :fRegistrationMode', {
        fRegistrationMode: filters.registrationMode,
      });
    }
    // Ignored when the caller is RM-scoped — the server-side scope already
    // pins rm_contact to the caller's own id.
    if (filters.rmContact && !rmScoped) {
      // rm_contact is a plain FK to the user's id — filter by id, not the
      // display name (see listAllRmContacts for why: name collisions, and no
      // stable name for the "Other" placeholder user).
      qb.andWhere('registration.rm_contact = :fRmContactId', { fRmContactId: Number(filters.rmContact) });
    }
    if (filters.age) {
      this.applyAgeFilter(qb, String(filters.age));
    }
    if (filters.paymentMode) {
      qb.andWhere(`${ZoomRegistrationRepository.LATEST_PAYMENT_MODE_SQL} = :fPaymentMode`, {
        fPaymentMode: filters.paymentMode,
      });
    }
    if (filters.paymentStatus) {
      this.applyPaymentStatusFilter(qb, String(filters.paymentStatus));
    }
    // `userType` — the registrant's OWN user account type, so it needs the users row, which this
    // projection deliberately doesn't join (it selects only registration columns + the rm-contact
    // user). An EXISTS subquery rather than a join keeps the existing select list and getCount()
    // untouched, and matches how the attendee-summary repository scopes by registration too.
    const userTypes = resolveUserTypeFilter(filters.userType);
    if (userTypes) {
      // The `user_id IS NULL` arm keeps "registered for someone else" rows in scope for a Seeker
      // selection — those carry only owner_user_id (the person who filled the form), never a user
      // row for the registrant, so an EXISTS on user_id alone would silently drop all of them.
      // See userTypeFilterIncludesAccountless.
      const accountlessSql = userTypeFilterIncludesAccountless(userTypes)
        ? ' OR registration.user_id IS NULL'
        : '';
      qb.andWhere(
        `(EXISTS (SELECT 1 FROM users filter_user WHERE filter_user.id = registration.user_id AND filter_user.user_type IN (:...fUserTypes))${accountlessSql})`,
        { fUserTypes: userTypes },
      );
    }
  }

  /**
   * `age` filter values are bracket codes (e.g. "21-30", ">65"), converted to a
   * `dob` range the same way RegistrationRepository.findRegistrations does: the
   * bracket's min age → the latest birth date that age implies, and its max
   * age+1 → the earliest. Unknown codes match nothing rather than being ignored.
   */
  private applyAgeFilter(qb: SelectQueryBuilder<ProgramRegistration>, code: string): void {
    const bucket = ZoomRegistrationRepository.AGE_BUCKETS[code];
    if (!bucket) {
      qb.andWhere('1 = 0');
      return;
    }
    const today = new Date();
    const maxBirthDate = new Date(today.getFullYear() - bucket.min, today.getMonth(), today.getDate());
    const minBirthDate = new Date(today.getFullYear() - bucket.max - 1, today.getMonth(), today.getDate());
    qb.andWhere('registration.dob BETWEEN :fMinBirthDate AND :fMaxBirthDate', {
      fMinBirthDate: minBirthDate.toISOString().split('T')[0],
      fMaxBirthDate: maxBirthDate.toISOString().split('T')[0],
    });
  }

  /**
   * `paymentStatus` filter values are the coarse codes from the `filters`
   * response (`payment_completed` | `payment_pending` | `failed` | `no_payment`),
   * mirroring how RegistrationRepository.findRegistrations buckets the same
   * codes for the main registration list — not raw PaymentStatusEnum values.
   * `no_payment` means "free seat" (no payment ever expected); `payment_pending`
   * also covers "no payment record yet" for a non-free allocated seat.
   */
  private applyPaymentStatusFilter(qb: SelectQueryBuilder<ProgramRegistration>, code: string): void {
    const noPaymentRecordSql = `NOT EXISTS (SELECT 1 FROM hdb_registration_payment_detail payment WHERE payment.registration_id = registration.id)`;
    switch (code) {
      case 'payment_completed':
        qb.andWhere(`${ZoomRegistrationRepository.LATEST_PAYMENT_STATUS_SQL} IN (:...fCompleted)`, {
          fCompleted: [PaymentStatusEnum.ONLINE_COMPLETED, PaymentStatusEnum.OFFLINE_COMPLETED],
        });
        return;
      case 'payment_pending':
        qb.andWhere(
          `(${ZoomRegistrationRepository.LATEST_PAYMENT_STATUS_SQL} IN (:...fPending) OR (${noPaymentRecordSql} AND registration.allocated_program_id IS NOT NULL AND registration.is_free_seat = false))`,
          { fPending: [PaymentStatusEnum.ONLINE_PENDING, PaymentStatusEnum.OFFLINE_PENDING] },
        );
        return;
      case 'failed':
        qb.andWhere(`${ZoomRegistrationRepository.LATEST_PAYMENT_STATUS_SQL} = :fFailed`, {
          fFailed: PaymentStatusEnum.FAILED,
        });
        return;
      case 'no_payment':
        qb.andWhere('registration.is_free_seat = true');
        return;
      default:
        // Unknown code — match nothing rather than silently ignoring the filter.
        qb.andWhere('1 = 0');
    }
  }

  /**
   * Shapes one raw row from {@link listSeatAllocatedRegistrationsByProgram} into the
   * public row: resolves the RM's display name (falling back to the free-text
   * "other" contact, same convention as the main registration list view) and
   * formats the latest payment record's raw enum into a display label.
   */
  /** Uppercases just the first character, leaving the rest as-is — same behavior as RegistrationService.capitalizeFirstLetter, kept local rather than importing a private method across modules. */
  private capitalizeFirst(value: string | null): string | null {
    if (!value) return value;
    return value.charAt(0).toUpperCase() + value.slice(1);
  }

  /**
   * All of the program's (non-deleted, scheduled) sessions ordered chronologically
   * by start time — the ordered slots for each registrant's per-session attendance
   * breakdown. Includes sessions that have not started yet so their state can be
   * reported as 'not_started'; the "attended/conducted" denominator is derived from
   * the subset that has already started (see the caller).
   */
  private async listProgramSessions(programId: number): Promise<ConductedSession[]> {
    const rows = await this.registrationRepo.manager
      .createQueryBuilder()
      .select([
        'ps.id AS "id"',
        'ps.name AS "name"',
        'ps.starts_at AS "startsAt"',
        'ps.ends_at AS "endsAt"',
        'os.id AS "onlineSessionId"',
      ])
      .from('program_session', 'ps')
      .leftJoin('hdb_online_session', 'os', 'os.program_session_id = ps.id AND os.deleted_at IS NULL')
      .where('ps.program_id = :programId', { programId })
      .andWhere('ps.deleted_at IS NULL')
      .andWhere('ps.starts_at IS NOT NULL')
      .orderBy('ps.starts_at', 'ASC')
      // display_order tiebreaker matches ZoomFinalSessionConfirmService.resolveFinalSession's
      // ordering, so "the final session" means the same session in both places.
      .addOrderBy('ps.display_order', 'ASC')
      .addOrderBy('ps.id', 'ASC') // stable ordering for sessions sharing a start time + display order
      .getRawMany<{
        id: number;
        name: string | null;
        startsAt: Date | null;
        endsAt: Date | null;
        onlineSessionId: number | null;
      }>();
    return rows.map((row) => ({
      id: Number(row.id),
      name: row.name ?? null,
      startsAt: row.startsAt ?? null,
      endsAt: row.endsAt ?? null,
      onlineSessionId: row.onlineSessionId != null ? Number(row.onlineSessionId) : null,
    }));
  }

  /**
   * For the given registrations, the set of conducted-session ids each one
   * actually attended (program_user_attendance.is_attended = true), keyed by
   * registration id. One query over the page's registrations — no N+1.
   */
  private async findAttendedSessionIdsByRegistration(
    registrationIds: number[],
    sessionIds?: number[],
  ): Promise<Map<number, Set<number>>> {
    const result = new Map<number, Set<number>>();
    if (registrationIds.length === 0) return result;
    if (sessionIds != null && sessionIds.length === 0) return result;
    const qb = this.registrationRepo.manager
      .createQueryBuilder()
      .select(['att.registration_id AS "registrationId"', 'att.session_id AS "sessionId"'])
      .from('program_user_attendance', 'att')
      .where('att.registration_id IN (:...registrationIds)', { registrationIds })
      .andWhere('att.is_attended = true');
    if (sessionIds != null) {
      qb.andWhere('att.session_id IN (:...sessionIds)', { sessionIds });
    }
    const rows = await qb.getRawMany<{ registrationId: number; sessionId: number }>();
    for (const row of rows) {
      const registrationId = Number(row.registrationId);
      const sessions = result.get(registrationId) ?? new Set<number>();
      sessions.add(Number(row.sessionId));
      result.set(registrationId, sessions);
    }
    return result;
  }

  /**
   * Full id-set breakdown backing BOTH {@link getProgramEligibleKpis}'s counts and
   * {@link resolveProgramEligibleKpiFilterIds}'s row-narrowing — computed once so a KPI tile's
   * value and the rows filtered behind it (when that tile is clicked) can never drift apart.
   * Always computed over the FULL eligible set for the program — the same predicate as
   * {@link findEligibleRegistrationsByProgram} — ignoring the list's transient search/filters; only
   * the RM visibility scope narrows it.
   */
  private async computeProgramEligibleBreakdown(
    programId: number,
    rmContactId?: number,
  ): Promise<{
    totalEligible: number;
    totalSessions: number;
    activeIds: Set<number>;
    inactiveIds: Set<number>;
    absentAtLeastOnceIds: Set<number>;
    presentForAllIds: Set<number>;
    /** null when the program has 1 or 0 sessions — no "final session" concept applies. */
    eligibleForFinalSessionIds: Set<number> | null;
    /** Strict complement of eligibleForFinalSessionIds over the full eligible set; null iff that is null. */
    notEligibleForFinalSessionIds: Set<number> | null;
  }> {
    const eligibleRegistrations = await this.findEligibleRegistrationsByProgram(programId, rmContactId);
    const totalEligible = eligibleRegistrations.length;
    // `ProgramRegistration.id` is a `bigint` column — the pg driver returns it as a STRING at
    // runtime despite the `number` TS type (same footgun as `OnlineSession.id` elsewhere in this
    // file), so every id must be coerced with `Number(...)` here or lookups against the
    // Number-keyed maps below (attendedBySession, activationByPair) silently miss for everyone.
    const activeIds = new Set(
      eligibleRegistrations
        .filter(
          (registration) =>
            registration.activationStatus === RegistrationOnlineSessionActivationStatus.ACTIVE,
        )
        .map((registration) => Number(registration.id)),
    );
    const inactiveIds = new Set(
      eligibleRegistrations
        .filter((registration) => !activeIds.has(Number(registration.id)))
        .map((registration) => Number(registration.id)),
    );

    const programSessions = await this.listProgramSessions(programId);
    const totalSessions = programSessions.length;

    if (!totalEligible) {
      return {
        totalEligible,
        totalSessions,
        activeIds,
        inactiveIds,
        absentAtLeastOnceIds: new Set(),
        presentForAllIds: new Set(),
        eligibleForFinalSessionIds: totalSessions > 1 ? new Set() : null,
        notEligibleForFinalSessionIds: totalSessions > 1 ? new Set() : null,
      };
    }

    const registrationIds = eligibleRegistrations.map((registration) => Number(registration.id));
    const now = new Date();
    // A session only counts toward attendance/eligibility once it has actually ENDED — a
    // session still in progress (or with no recorded end time) can't yet be marked "missed".
    const conductedSessions = programSessions.filter(
      (session) => session.endsAt != null && session.endsAt <= now,
    );

    const attendedBySession = await this.findAttendedSessionIdsByRegistration(
      registrationIds,
      conductedSessions.map((session) => session.id),
    );

    const absentAtLeastOnceIds = new Set<number>();
    const presentForAllIds = new Set<number>();
    if (conductedSessions.length > 0) {
      for (const registrationId of registrationIds) {
        const attendedCount = attendedBySession.get(registrationId)?.size ?? 0;
        if (attendedCount >= conductedSessions.length) presentForAllIds.add(registrationId);
        else absentAtLeastOnceIds.add(registrationId);
      }
    }

    // No "final session" concept for a single-session (or sessionless) program —
    // mirrors ZoomFinalSessionConfirmService.resolveFinalSession's own guard.
    // Eligible = currently ACTIVE (the registration's own rollup, same field the
    // Active/Inactive tiles use — not a per-session frozen status) AND present for
    // every non-final session that has already happened (a not-yet-started
    // non-final session can't have been missed, so it isn't checked yet).
    let eligibleForFinalSessionIds: Set<number> | null = null;
    if (totalSessions > 1) {
      const nonFinalSessions = programSessions.slice(0, -1);
      const elapsedNonFinal = nonFinalSessions.filter(
        (session) => session.endsAt != null && session.endsAt <= now,
      );

      eligibleForFinalSessionIds = new Set(
        registrationIds.filter((registrationId) => {
          if (!activeIds.has(registrationId)) return false;
          const attendedSet = attendedBySession.get(registrationId) ?? new Set<number>();
          return elapsedNonFinal.every((session) => attendedSet.has(session.id));
        }),
      );
    }

    // Strict complement of eligibleForFinalSessionIds over the FULL eligible set — deliberately NOT
    // absentAtLeastOnceIds, which uses a different session-set/criteria (see the KPI's own JSDoc) and
    // can overlap with eligibleForFinalSessionIds rather than complement it.
    const notEligibleForFinalSessionIds =
      eligibleForFinalSessionIds != null
        ? new Set(registrationIds.filter((registrationId) => !eligibleForFinalSessionIds!.has(registrationId)))
        : null;

    return {
      totalEligible,
      totalSessions,
      activeIds,
      inactiveIds,
      absentAtLeastOnceIds,
      presentForAllIds,
      eligibleForFinalSessionIds,
      notEligibleForFinalSessionIds,
    };
  }

  /**
   * Program-wide KPI tiles for the eligible-registrations screen (see
   * {@link ProgramEligibleKpis} for the field-by-field contract). Always
   * computed over the FULL eligible set for the program — the same predicate as
   * {@link findEligibleRegistrationsByProgram} — ignoring the list's transient
   * search/filters; only the RM visibility scope narrows it.
   */
  async getProgramEligibleKpis(programId: number, rmContactId?: number): Promise<ProgramEligibleKpis> {
    try {
      const breakdown = await this.computeProgramEligibleBreakdown(programId, rmContactId);
      return {
        totalEligible: breakdown.totalEligible,
        active: breakdown.activeIds.size,
        inactive: breakdown.inactiveIds.size,
        absentAtLeastOnce: breakdown.absentAtLeastOnceIds.size,
        presentForAll: breakdown.presentForAllIds.size,
        totalSessions: breakdown.totalSessions,
        eligibleForFinalSession: breakdown.eligibleForFinalSessionIds?.size ?? null,
        notEligibleForFinalSession: breakdown.notEligibleForFinalSessionIds?.size ?? null,
      };
    } catch (error) {
      this.logger.error('Error computing program eligible KPIs', error?.stack, { error, programId });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * The registrant ids matching one {@link ProgramEligibleKpiFilter} value — the row-narrowing
   * counterpart to {@link getProgramEligibleKpis}'s own counts, resolved via the exact same
   * {@link computeProgramEligibleBreakdown} so a KPI tile's value and the rows behind it can never
   * disagree. Returns `null` for `ALL` (no narrowing) and for `ELIGIBLE_FOR_FINAL_SESSION` /
   * `NOT_ELIGIBLE_FOR_FINAL_SESSION` when the program has ≤ 1 session (no such concept — mirrors the
   * KPI's own `null`).
   */
  private async resolveProgramEligibleKpiFilterIds(
    programId: number,
    kpiFilter: ProgramEligibleKpiFilter,
    rmContactId?: number,
  ): Promise<number[] | null> {
    if (kpiFilter === ProgramEligibleKpiFilter.ALL) return null;
    try {
      const breakdown = await this.computeProgramEligibleBreakdown(programId, rmContactId);
      switch (kpiFilter) {
        case ProgramEligibleKpiFilter.ACTIVE:
          return [...breakdown.activeIds];
        case ProgramEligibleKpiFilter.INACTIVE:
          return [...breakdown.inactiveIds];
        case ProgramEligibleKpiFilter.ABSENT_AT_LEAST_ONCE:
          return [...breakdown.absentAtLeastOnceIds];
        case ProgramEligibleKpiFilter.PRESENT_FOR_ALL:
          return [...breakdown.presentForAllIds];
        case ProgramEligibleKpiFilter.ELIGIBLE_FOR_FINAL_SESSION:
          return breakdown.eligibleForFinalSessionIds ? [...breakdown.eligibleForFinalSessionIds] : null;
        case ProgramEligibleKpiFilter.NOT_ELIGIBLE_FOR_FINAL_SESSION:
          return breakdown.notEligibleForFinalSessionIds
            ? [...breakdown.notEligibleForFinalSessionIds]
            : null;
        default:
          return null;
      }
    } catch (error) {
      this.logger.error('Error resolving program eligible KPI filter ids', error?.stack, {
        error,
        programId,
        kpiFilter,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  private toProgramEligibleRow(
    row: Record<string, any>,
    programSessions: ConductedSession[],
    attendedSessionIds: Set<number>,
  ): ProgramEligibleRegistrationRow {
    const rmContactOrgName: string | undefined = row.rmContactOrgName;
    const rmContact =
      rmContactOrgName && rmContactOrgName.trim().toLowerCase() === 'other'
        ? row.otherInfinitheismContact || null
        : rmContactOrgName || null;
    const city =
      row.city && row.city.trim().toLowerCase() === 'other' ? row.otherCityName ?? null : row.city ?? null;
    const now = new Date();
    const conductedCount = programSessions.filter(
      (session) => session.endsAt != null && session.endsAt <= now,
    ).length;

    return {
      registrationId: Number(row.registrationId),
      registrationSeqNumber: row.registrationSeqNumber ?? null,
      fullName: row.fullName ?? null,
      email: row.email ?? null,
      mobile: row.mobile ?? null,
      seatAllocated: row.seatAllocated ? 'Yes' : 'No',
      gender: this.capitalizeFirst(row.gender ?? null),
      profileUrl: row.profileUrl ?? null,
      activationStatus: this.capitalizeFirst(row.activationStatus) ?? '',
      rmContact: this.capitalizeFirst(rmContact),
      paymentStatus: formatPaymentStatus(
        row.paymentStatusRaw ?? '',
        row.allocatedProgramId != null ? Number(row.allocatedProgramId) : null,
        !!row.isFreeSeat,
      ),
      registrationMode: this.capitalizeFirst(row.registrationMode ?? null),
      age: row.dob ? calculateAge(row.dob) : null,
      city: this.capitalizeFirst(city),
      joinUrl: row.joinUrl ?? null,
      // Denominator is the number of sessions that have already ENDED ("conducted");
      // not-yet-ended sessions are listed in sessionAttendance but excluded here.
      attendedSessions: `${row.attendedSessions != null ? Number(row.attendedSessions) : 0}/${conductedCount}`,
      sessionAttendance: this.buildSessionAttendance(programSessions, attendedSessionIds),
    };
  }

  /**
   * Maps the program's sessions to a per-session attendance breakdown for one
   * registrant, preserving the chronological order of {@link listProgramSessions}.
   * `status` is true when the session's id is in the registrant's attended set,
   * else false (absent). `sessionState` is the session's lifecycle relative to
   * now: 'not_started' (start time in the future), 'in_progress' (started, not
   * yet ended — or ended time unknown), or 'completed' (end time in the past).
   */
  private buildSessionAttendance(
    programSessions: ConductedSession[],
    attendedSessionIds: Set<number>,
  ): ProgramSessionAttendance[] {
    const now = new Date();
    return programSessions.map((session) => ({
      sessionId: session.id,
      name: session.name,
      sessionState: this.resolveSessionState(session, now),
      status: attendedSessionIds.has(session.id),
    }));
  }

  /** Lifecycle state of a session relative to `now` (see {@link buildSessionAttendance}). */
  private resolveSessionState(session: ConductedSession, now: Date): SessionAttendanceState {
    if (session.startsAt != null && session.startsAt > now) return 'not_started';
    if (session.endsAt != null && session.endsAt <= now) return 'completed';
    return 'in_progress';
  }

  /**
   * All RM contacts (id + display name) — the dynamic half of the
   * `eligible-registrations` filter set (gender/registrationMode/age/
   * activationStatus/paymentMode/paymentStatus are static). Sourced from the
   * user-role-map, the same way `UserController.getRMList` /
   * `UserRepository.getUsersWithRoleFiltering` builds the admin "RM list"
   * dropdown elsewhere — every user holding the RM role, not just RMs who
   * happen to already have registrants in this program (a DISTINCT-over-
   * registrations query would miss RMs with zero registrants so far).
   *
   * Filtering by id (not name) because `registration.rm_contact` is a plain FK
   * to the user's id — matching by name risks collisions (two RMs sharing a
   * display name) and doesn't have a stable value for the "Other" placeholder
   * user, whereas the id is exact either way. Sorted case-insensitively by
   * name for display, matching the admin RM-list convention.
   */
  /**
   * A registration's basic identity (name/email/mobile/registration number/registered date) +
   * RM name, deliberately WITHOUT the usual `deleted_at IS NULL` guard — the seeker-detail
   * screen shows this alongside a registrant's Zoom attendance HISTORY, which is unaffected by
   * the registration itself later being soft-deleted (same reasoning as
   * ZoomAnalyticsRosterRepository's own `.withDeleted()`: a session needs to keep showing who
   * was actually there, even if that registrant no longer exists as an active record). Returns
   * null only when the id has never existed at all.
   */
  async findProfileByRegistrationId(registrationId: number): Promise<ZoomRegistrationProfile | null> {
    try {
      const row = await this.registrationRepo
        .createQueryBuilder('registration')
        .withDeleted()
        .leftJoin(User, 'rmUser', 'rmUser.id = registration.rm_contact')
        .select([
          'registration.full_name AS "fullName"',
          'registration.email_address AS "email"',
          'registration.mobile_number AS "mobile"',
          'registration.registration_seq_number AS "registrationSeqNumber"',
          'registration.registration_date AS "registeredDate"',
          'registration.gender AS "gender"',
          rmDisplayNameSql('rmUser') + ' AS "rmName"',
        ])
        .where('registration.id = :registrationId', { registrationId })
        .getRawOne<ZoomRegistrationProfile>();
      return row ?? null;
    } catch (error) {
      this.logger.error('Error fetching registration profile', error?.stack, {
        error,
        registrationId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_ANALYTICS_GET_FAILED, error);
    }
  }

  async listAllRmContacts(): Promise<RmContactOption[]> {
    try {
      const rows = await this.registrationRepo.manager
        .createQueryBuilder(User, 'user')
        .innerJoin('user.userRoleMaps', 'userRoleMaps')
        .innerJoin('userRoleMaps.role', 'role')
        .select('user.id', 'id')
        .addSelect(rmDisplayNameSql('user'), 'displayName')
        .where('role.role_key = :roleKey', { roleKey: ROLE_KEYS.RELATIONAL_MANAGER })
        .andWhere('user.is_system_user = false')
        .andWhere('user.is_ai_user = false')
        .getRawMany<{ id: number; displayName: string | null }>();

      return rows
        .filter((row) => !!row.displayName)
        .map((row) => ({ id: Number(row.id), name: row.displayName as string }))
        .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
    } catch (error) {
      this.logger.error('Error listing all RM contacts', error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Paginated, searchable list of a program's eligible registrations for the
   * admin "registration ↔ join URL" view. When `onlineSessionId` is given, the
   * join URL/panelist/activation columns come from that exact session's
   * extension row; otherwise they fall back to the registrant's latest
   * extension across any session.
   */
  async listEligibleRegistrationsByProgram(
    programId: number,
    options: { page: number; limit: number; search?: string },
    onlineSessionId?: number,
    rmContactId?: number,
  ): Promise<{ data: RegistrationWithJoinUrl[]; total: number }> {
    try {
      const dataQb = this.eligibleRowsBaseQuery(programId, options.search, onlineSessionId, rmContactId)
        .orderBy('registration.id', 'DESC')
        .offset((options.page - 1) * options.limit)
        .limit(options.limit);
      const countQb = this.eligibleRowsBaseQuery(programId, options.search, onlineSessionId, rmContactId);

      const [data, total] = await Promise.all([
        dataQb.getRawMany<RegistrationWithJoinUrl>(),
        countQb.getCount(),
      ]);
      return { data, total };
    } catch (error) {
      this.logger.error('Error listing eligible registrations by program', error?.stack, {
        error,
        programId,
        onlineSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /** All eligible registration rows for a program (no pagination) — for Excel export. */
  async findEligibleRegistrationRowsByProgram(
    programId: number,
    search?: string,
    onlineSessionId?: number,
    rmContactId?: number,
  ): Promise<RegistrationWithJoinUrl[]> {
    try {
      return await this.eligibleRowsBaseQuery(programId, search, onlineSessionId, rmContactId)
        .orderBy('registration.id', 'DESC')
        .getRawMany<RegistrationWithJoinUrl>();
    } catch (error) {
      this.logger.error('Error fetching eligible registration rows by program', error?.stack, {
        error,
        programId,
        onlineSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Shared query: eligible registrations of a program. A registration can have
   * several online-session extensions, so join URL / panelist / activation
   * columns are pulled via scalar subqueries. When `onlineSessionId` is given,
   * each subquery is scoped to that one session's extension row (the accurate
   * per-session view), AND a registrant deactivated for that specific session
   * is excluded from the results entirely; otherwise (no session in scope) it
   * falls back to the latest extension across any session (legacy program-wide
   * view) with no activation filtering — keeping one row per registration.
   */
  private eligibleRowsBaseQuery(
    programId: number,
    search?: string,
    onlineSessionId?: number,
    rmContactId?: number,
  ) {
    const scopeToExtension = (
      sub: SelectQueryBuilder<ProgramRegistrationOnlineSession>,
    ): SelectQueryBuilder<ProgramRegistrationOnlineSession> => {
      sub
        .where('extension.registration_id = registration.id')
        .andWhere('extension.deleted_at IS NULL')
        .andWhere("extension.status = 'registered'");
      if (onlineSessionId != null) {
        sub
          .andWhere('extension.online_session_id = :onlineSessionId')
          .setParameter('onlineSessionId', onlineSessionId);
      }
      return sub.orderBy('extension.id', 'DESC').limit(1);
    };

    const registrationQuery = this.registrationRepo
      .createQueryBuilder('registration')
      .select([
        'registration.id AS "registrationId"',
        'registration.registration_seq_number AS "registrationSeqNumber"',
        'registration.full_name AS "fullName"',
        'registration.email_address AS "email"',
        'registration.mobile_number AS "mobile"',
        'registration.registration_status AS "registrationStatus"',
        'registration.seat_allocated AS "seatAllocated"',
      ])
      .addSelect(
        (sub) =>
          scopeToExtension(sub.select('extension.join_url').from(ProgramRegistrationOnlineSession, 'extension')),
        'joinUrl',
      )
      .addSelect(
        (sub) =>
          scopeToExtension(
            sub.select('extension.is_panelist').from(ProgramRegistrationOnlineSession, 'extension'),
          ),
        'isPanelist',
      )
      .addSelect(
        (sub) =>
          scopeToExtension(
            sub.select('extension.activation_status').from(ProgramRegistrationOnlineSession, 'extension'),
          ),
        'activationStatus',
      )
      .where('registration.program_id = :programId', { programId })
      .andWhere('registration.deleted_at IS NULL')
      .andWhere('registration.seat_allocated = true')
      .andWhere('registration.registration_status NOT IN (:...excluded)', {
        excluded: REGISTRATION_INELIGIBLE_STATUSES,
      });
    if (search) {
      registrationQuery.andWhere(
        '(registration.full_name ILIKE :search OR registration.email_address ILIKE :search OR registration.mobile_number ILIKE :search)',
        { search: `%${search}%` },
      );
    }
    // Server-derived RM scope: an RM caller only ever sees their own contacts
    // (same convention as listSeatAllocatedRegistrationsByProgram above).
    if (rmContactId != null) {
      registrationQuery.andWhere('registration.rm_contact = :rmContactId', { rmContactId });
    }
    // Session-scoped view only: drop a registrant who has been deactivated for
    // THIS specific session for a registration-lifecycle reason (their
    // extension row's activation_status is INACTIVE with activation_source
    // ARCHIVED/CANCELLED/DELETED). A registrant merely toggled off for just
    // this session still holds a live registration and stays listed — same
    // convention as getEligibleActivationSummary/-ies. A registrant with no
    // extension row yet (never provisioned) is NOT excluded here — that's the
    // "pending" case, not "inactive". Only applied when onlineSessionId is
    // given; the program-wide legacy view (no session in scope) shows every
    // eligible registrant regardless.
    if (onlineSessionId != null) {
      registrationQuery.andWhere(
        `NOT EXISTS ${registrationQuery
          .subQuery()
          .select('1')
          .from(ProgramRegistrationOnlineSession, 'activationCheck')
          .where('activationCheck.registration_id = registration.id')
          .andWhere('activationCheck.online_session_id = :activationCheckOnlineSessionId')
          .andWhere('activationCheck.deleted_at IS NULL')
          .andWhere('activationCheck.activation_status = :inactiveActivationStatus')
          .andWhere('activationCheck.activation_source IN (:...activationCheckTerminalSources)')
          .getQuery()}`,
        {
          activationCheckOnlineSessionId: onlineSessionId,
          inactiveActivationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
          activationCheckTerminalSources: TERMINAL_REGISTRATION_ACTIVATION_SOURCES,
        },
      );
    }
    return registrationQuery;
  }

  /**
   * Live active/inactive breakdown of one session's provisioned (REGISTERED)
   * extension rows — powers the eligible-count screen. `totalEligible` is the
   * provisioned count for THIS session, not the program-wide seat-allocated
   * count (a registrant can be seat-allocated but not yet provisioned here).
   * `activeCount` (and its complement `inactiveCount`) excludes a row the
   * moment it's INACTIVE, regardless of why — a manual per-session toggle
   * counts the same as a registration-lifecycle cascade.
   */
  async getEligibleActivationSummary(onlineSessionId: number): Promise<EligibleActivationSummary> {
    try {
      const row = await this.zoomRepo
        .createQueryBuilder('extension')
        .select('COUNT(*)', 'total')
        .addSelect(
          `COUNT(*) FILTER (WHERE extension.activation_status = :active)`,
          'activeCount',
        )
        .where('extension.online_session_id = :onlineSessionId', { onlineSessionId })
        .andWhere('extension.deleted_at IS NULL')
        .andWhere('extension.status = :registered', {
          registered: OnlineSessionRegistrationStatus.REGISTERED,
        })
        .setParameter('active', RegistrationOnlineSessionActivationStatus.ACTIVE)
        .getRawOne<{ total: string; activeCount: string }>();

      const totalEligible = Number(row?.total ?? 0);
      const activeCount = Number(row?.activeCount ?? 0);
      return {
        onlineSessionId,
        totalEligible,
        activeCount,
        inactiveCount: Math.max(totalEligible - activeCount, 0),
      };
    } catch (error) {
      this.logger.error('Error computing eligible activation summary', error?.stack, {
        error,
        onlineSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Batched sibling of {@link getEligibleActivationSummary}: the "Registered"
   * count for several online sessions at once, powering the online-session
   * list's per-session attendance summary. A session is scored one of two
   * ways, decided independently per `onlineSessionId` by whether it has ANY
   * extension row at all (i.e. it was provisioned per-registrant, vs. a
   * general-link session that never gets individual extension rows):
   *
   * - Has extension row(s): count that session's own extension rows where
   *   `activation_status = ACTIVE` and `deleted_at IS NULL`. The underlying
   *   registration's own status/deletion is irrelevant here — a deleted
   *   registration whose extension row is still active still counts.
   * - No extension rows: count the session's *program*'s eligible
   *   registrations directly — `seat_allocated = true`, `registration_status`
   *   not in {@link REGISTRATION_INELIGIBLE_STATUSES}, `activation_status =
   *   ACTIVE`, `deleted_at IS NULL` on the registration itself.
   *
   * `rmContactId`, when given, scopes every count to that RM's own contacts.
   */
  async getEligibleActivationSummaries(
    sessions: { onlineSessionId: number; programId: number }[],
    rmContactId?: number,
  ): Promise<Map<number, number>> {
    const map = new Map<number, number>();
    if (!sessions.length) return map;
    const onlineSessionIds = sessions.map((session) => session.onlineSessionId);
    try {
      const extensionRows = await this.zoomRepo
        .createQueryBuilder('extension')
        .select('DISTINCT extension.online_session_id', 'onlineSessionId')
        .where('extension.online_session_id IN (:...onlineSessionIds)', { onlineSessionIds })
        .getRawMany<{ onlineSessionId: string }>();
      const sessionIdsWithExtension = new Set(extensionRows.map((row) => Number(row.onlineSessionId)));

      const withExtensionIds = onlineSessionIds.filter((id) => sessionIdsWithExtension.has(id));
      const withoutExtension = sessions.filter((session) => !sessionIdsWithExtension.has(session.onlineSessionId));

      await Promise.all([
        withExtensionIds.length
          ? (async () => {
              const query = this.zoomRepo
                .createQueryBuilder('extension')
                .select('extension.online_session_id', 'onlineSessionId')
                .addSelect('COUNT(*)', 'total')
                .where('extension.online_session_id IN (:...withExtensionIds)', { withExtensionIds })
                .andWhere('extension.deleted_at IS NULL')
                .andWhere('extension.status = :registered', {
                  registered: OnlineSessionRegistrationStatus.REGISTERED,
                })
                .andWhere('extension.activation_status = :activeStatus', {
                  activeStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
                })
                .groupBy('extension.online_session_id');
              if (rmContactId != null) {
                query.andWhere(
                  `EXISTS ${query
                    .subQuery()
                    .select('1')
                    .from(ProgramRegistration, 'registration')
                    .withDeleted()
                    .where('registration.id = extension.registration_id')
                    .andWhere('registration.rm_contact = :rmContactId')
                    .getQuery()}`,
                  { rmContactId },
                );
              }
              const rows = await query.getRawMany<{ onlineSessionId: string; total: string }>();
              for (const row of rows) {
                map.set(Number(row.onlineSessionId), Number(row.total));
              }
            })()
          : Promise.resolve(),
        ...withoutExtension.map((session) =>
          (async () => {
            const query = this.registrationRepo
              .createQueryBuilder('registration')
              .where('registration.program_id = :programId', { programId: session.programId })
              .andWhere('registration.deleted_at IS NULL')
              .andWhere('registration.seat_allocated = true')
              .andWhere('registration.activation_status = :activeStatus', {
                activeStatus: RegistrationOnlineSessionActivationStatus.ACTIVE,
              })
              .andWhere('registration.registration_status NOT IN (:...excluded)', {
                excluded: REGISTRATION_INELIGIBLE_STATUSES,
              });
            if (rmContactId != null) {
              query.andWhere('registration.rm_contact = :rmContactId', { rmContactId });
            }
            map.set(session.onlineSessionId, await query.getCount());
          })(),
        ),
      ]);
      return map;
    } catch (error) {
      this.logger.error('Error computing eligible activation summaries', error?.stack, {
        error,
        onlineSessionIds,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Records the outcome of an activation toggle on an extension row: the new
   * status, who changed it, when, and why. Always called inside the same
   * transaction as the provider add/remove call and the eligible-count
   * adjustment below.
   */
  async updateActivationStatus(
    extensionId: number,
    updates: {
      activationStatus: RegistrationOnlineSessionActivationStatus;
      activationSource: RegistrationOnlineSessionActivationSource;
      joinUrl: string | null;
      externalRegistrantId?: string | null;
      activationChangedBy: number | null;
      activationReason: string | null;
    },
    manager: EntityManager,
  ): Promise<void> {
    try {
      await this.zoom(manager).update(
        { id: extensionId },
        {
          activationStatus: updates.activationStatus,
          activationSource: updates.activationSource,
          joinUrl: updates.joinUrl,
          ...(updates.externalRegistrantId !== undefined
            ? { externalRegistrantId: updates.externalRegistrantId }
            : {}),
          activationChangedAt: new Date(),
          activationChangedBy: updates.activationChangedBy,
          activationReason: updates.activationReason,
        },
      );
    } catch (error) {
      this.logger.error('Error updating zoom extension activation status', error?.stack, {
        error,
        extensionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_SAVE_FAILED, error);
    }
  }

  /**
   * Atomically adjusts `hdb_online_session.eligible_active_count` by `delta`
   * (+1 on activation, -1 on deactivation), floored at 0 so duplicate/racing
   * calls never drive it negative. Must run inside the caller's transaction.
   */
  async adjustEligibleActiveCount(
    onlineSessionId: number,
    delta: 1 | -1,
    manager: EntityManager,
  ): Promise<void> {
    try {
      const setExpression =
        delta > 0 ? 'eligible_active_count + 1' : 'GREATEST(eligible_active_count - 1, 0)';
      await manager
        .createQueryBuilder()
        .update(OnlineSession)
        .set({ eligibleActiveCount: () => setExpression })
        .where('id = :id', { id: onlineSessionId })
        .execute();
    } catch (error) {
      this.logger.error('Error adjusting online session eligible active count', error?.stack, {
        error,
        onlineSessionId,
        delta,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_SAVE_FAILED, error);
    }
  }

  /**
   * Mirrors a per-session activation toggle onto the owning registration's
   * rollup (`hdb_program_registration.activation_status`). Runs inside the
   * caller's transaction, alongside the extension-row update and the
   * eligible-count adjustment, so all three commit together.
   */
  async updateRegistrationActivationStatus(
    registrationId: number,
    activationStatus: RegistrationOnlineSessionActivationStatus,
    manager: EntityManager,
  ): Promise<void> {
    try {
      await manager.getRepository(ProgramRegistration).update(
        { id: registrationId },
        { activationStatus, activationUpdatedAt: new Date() },
      );
    } catch (error) {
      this.logger.error('Error updating registration activation status rollup', error?.stack, {
        error,
        registrationId,
        activationStatus,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_SAVE_FAILED, error);
    }
  }

  // ─── Bulk Zoom registration jobs (reuses the shared background_jobs table) ────

  async createBulkJob(data: Partial<BackgroundJob>): Promise<BackgroundJob> {
    try {
      const job = this.jobRepo.create(data);
      return await this.jobRepo.save(job);
    } catch (error) {
      this.logger.error('Error creating bulk Zoom registration job', error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_BULK_START_FAILED, error);
    }
  }

  async findBulkJobById(id: number): Promise<BackgroundJob | null> {
    try {
      return await this.jobRepo.findOne({ where: { id } });
    } catch (error) {
      this.logger.error('Error finding bulk Zoom registration job', error?.stack, { error, id });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Every bulk Zoom registration job of a program, newest first. Lets the
   * failure/retry views aggregate across a program's whole job chain (initial
   * run + retries) so failures can be addressed by program/session rather than
   * by hunting down individual job ids.
   */
  async findBulkJobsByProgram(programId: number): Promise<BackgroundJob[]> {
    try {
      return await this.jobRepo.find({
        where: { type: JobTypeEnum.BULK_ZOOM_REGISTRATION, programId },
        order: { id: 'DESC' },
      });
    } catch (error) {
      this.logger.error('Error finding bulk Zoom registration jobs by program', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Of the given registrations × online sessions, the pairs that CURRENTLY hold
   * an active extension — i.e. are already registered. Returned as a set of
   * `${registrationId}:${onlineSessionId}` keys so callers can drop failures
   * that have since been resolved (by a retry or a manual push) instead of
   * reporting stale failures. One batched query, no N+1.
   */
  async findActiveExtensionKeys(
    registrationIds: number[],
    onlineSessionIds: number[],
  ): Promise<Set<string>> {
    if (!registrationIds.length || !onlineSessionIds.length) return new Set();
    try {
      const rows = await this.zoomRepo.find({
        where: {
          registrationId: In(registrationIds),
          onlineSessionId: In(onlineSessionIds),
          status: OnlineSessionRegistrationStatus.REGISTERED,
          deletedAt: IsNull(),
        },
        select: ['registrationId', 'onlineSessionId'],
      });
      return new Set(rows.map((row) => `${row.registrationId}:${row.onlineSessionId}`));
    } catch (error) {
      this.logger.error('Error finding active zoom extension keys', error?.stack, {
        error,
        registrationIds,
        onlineSessionIds,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Of the given registrations × online sessions, the pairs deactivated for
   * that specific session for a registration-lifecycle reason (activation_status
   * INACTIVE with activation_source ARCHIVED/CANCELLED/DELETED) — NOT a mere
   * manual per-session toggle, which still leaves the registrant validly
   * eligible/provisioned. Lets a caller exclude a registrant from a session's
   * eligible/provisioning counts without disturbing {@link findActiveExtensionKeys},
   * which other callers (bulk registration idempotency checks, panelist dedup)
   * rely on regardless of activation. Returned as `${registrationId}:${onlineSessionId}`
   * keys. One batched query, no N+1.
   */
  async findInactiveExtensionKeys(
    registrationIds: number[],
    onlineSessionIds: number[],
  ): Promise<Set<string>> {
    if (!registrationIds.length || !onlineSessionIds.length) return new Set();
    try {
      const rows = await this.zoomRepo.find({
        where: {
          registrationId: In(registrationIds),
          onlineSessionId: In(onlineSessionIds),
          status: OnlineSessionRegistrationStatus.REGISTERED,
          activationStatus: RegistrationOnlineSessionActivationStatus.INACTIVE,
          activationSource: In(TERMINAL_REGISTRATION_ACTIVATION_SOURCES),
          deletedAt: IsNull(),
        },
        select: ['registrationId', 'onlineSessionId'],
      });
      return new Set(rows.map((row) => `${row.registrationId}:${row.onlineSessionId}`));
    } catch (error) {
      this.logger.error('Error finding inactive zoom extension keys', error?.stack, {
        error,
        registrationIds,
        onlineSessionIds,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Active extensions for a set of registrations on ONE online session, mapping
   * each registration id to its individual join URL + activation status. Powers
   * the per-session provisioning drilldown, which shows a generated registrant's
   * join link/activation state next to its status. Keyed by stringified
   * registration id so bigint ids match regardless of how the driver hydrates
   * them. One batched query, no N+1.
   */
  async findActiveExtensionJoinUrls(
    registrationIds: number[],
    onlineSessionId: number,
  ): Promise<
    Map<
      string,
      {
        joinUrl: string | null;
        activationStatus: RegistrationOnlineSessionActivationStatus | null;
        activationSource: RegistrationOnlineSessionActivationSource | null;
      }
    >
  > {
    if (!registrationIds.length) return new Map();
    try {
      const rows = await this.zoomRepo.find({
        where: {
          registrationId: In(registrationIds),
          onlineSessionId,
          status: OnlineSessionRegistrationStatus.REGISTERED,
          deletedAt: IsNull(),
        },
        select: ['registrationId', 'joinUrl', 'activationStatus', 'activationSource'],
      });
      return new Map(
        rows.map((row) => [
          String(row.registrationId),
          {
            joinUrl: row.joinUrl ?? null,
            activationStatus: row.activationStatus ?? null,
            activationSource: row.activationSource ?? null,
          },
        ]),
      );
    } catch (error) {
      this.logger.error('Error finding active zoom extension join urls', error?.stack, {
        error,
        registrationIds,
        onlineSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Profile image (registration.user_profile_url) for a batch of registration ids — used to surface
   * a seeker's photo on the live/attendees analytics tables. Keyed by `String(id)`, same
   * bigint-safety reasoning as {@link findActiveExtensionJoinUrls}.
   */
  async findProfileImagesByIds(registrationIds: number[]): Promise<Map<string, string | null>> {
    if (!registrationIds.length) return new Map();
    try {
      const rows = await this.registrationRepo.find({
        where: { id: In(registrationIds) },
        select: ['id', 'profileUrl'],
        withDeleted: true,
      });
      return new Map(rows.map((row) => [String(row.id), row.profileUrl ?? null]));
    } catch (error) {
      this.logger.error('Error finding registration profile images', error?.stack, { error, registrationIds });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Registration-level activation status (hdb_program_registration.activation_status — the rollup
   * that mirrors the most recently toggled online-session extension) for a batch of registration
   * ids. Used by the attendees analytics table, which surfaces the registration's activation state
   * rather than any one session's extension row. Keyed by `String(id)`, same bigint-safety
   * reasoning as {@link findActiveExtensionJoinUrls}.
   */
  async findActivationStatusesByIds(
    registrationIds: number[],
  ): Promise<Map<string, RegistrationOnlineSessionActivationStatus | null>> {
    if (!registrationIds.length) return new Map();
    try {
      const rows = await this.registrationRepo.find({
        where: { id: In(registrationIds) },
        select: ['id', 'activationStatus'],
        withDeleted: true,
      });
      return new Map(rows.map((row) => [String(row.id), row.activationStatus ?? null]));
    } catch (error) {
      this.logger.error('Error finding registration activation statuses', error?.stack, {
        error,
        registrationIds,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * "RM Contact" for a batch of registration ids, surfaced on the live/attendees analytics tables:
   * the assigned RM's `org_usr_name` — collapsing to `legal_full_name` when that's blank — or, when
   * `org_usr_name` is the literal "Other" placeholder, the registration's own free-text
   * `other_infinitheism_contact` number instead. Same fallback RegistrationService's own "RM Contact"
   * column uses. Keyed by `String(id)`, same bigint-safety reasoning as {@link findActiveExtensionJoinUrls}.
   */
  async findRmContactsByIds(registrationIds: number[]): Promise<Map<string, string | null>> {
    if (!registrationIds.length) return new Map();
    try {
      const rows = await this.registrationRepo
        .createQueryBuilder('registration')
        .leftJoin(User, 'rmContactUser', 'rmContactUser.id = registration.rm_contact')
        .select('registration.id', 'id')
        .addSelect('rmContactUser.org_usr_name', 'orgUsrName')
        .addSelect(
          `COALESCE(NULLIF(TRIM(rmContactUser.org_usr_name), ''), NULLIF(TRIM(rmContactUser.legal_full_name), ''))`,
          'rmDisplayName',
        )
        .addSelect('registration.other_infinitheism_contact', 'otherInfinitheismContact')
        .where('registration.id IN (:...registrationIds)', { registrationIds })
        .withDeleted()
        .getRawMany<{
          id: string;
          orgUsrName: string | null;
          rmDisplayName: string | null;
          otherInfinitheismContact: string | null;
        }>();
      return new Map(
        rows.map((row) => [
          String(row.id),
          row.orgUsrName?.trim().toLowerCase() === 'other'
            ? row.otherInfinitheismContact ?? null
            : row.rmDisplayName ?? null,
        ]),
      );
    } catch (error) {
      this.logger.error('Error finding RM contacts for registrations', error?.stack, { error, registrationIds });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Extension row (any status — REGISTERED or FAILED) for a set of
   * (registrationId, onlineSessionId) pairs, mapping each pair to its join URL +
   * activation status. Used to enrich the bulk-registration failure view: a
   * failed registrant's row is exactly what we want here, unlike
   * {@link findActiveExtensionJoinUrls} which only sees REGISTERED rows. Keyed by
   * `${registrationId}:${onlineSessionId}`. One batched query, no N+1.
   */
  async findExtensionSnapshots(
    pairs: { registrationId: number; onlineSessionId: number }[],
  ): Promise<Map<string, { joinUrl: string | null; activationStatus: RegistrationOnlineSessionActivationStatus | null }>> {
    if (!pairs.length) return new Map();
    try {
      const registrationIds = [...new Set(pairs.map((p) => p.registrationId))];
      const onlineSessionIds = [...new Set(pairs.map((p) => p.onlineSessionId))];
      const rows = await this.zoomRepo.find({
        where: {
          registrationId: In(registrationIds),
          onlineSessionId: In(onlineSessionIds),
          deletedAt: IsNull(),
        },
        select: ['registrationId', 'onlineSessionId', 'joinUrl', 'activationStatus'],
      });
      const map = new Map<
        string,
        { joinUrl: string | null; activationStatus: RegistrationOnlineSessionActivationStatus | null }
      >();
      for (const row of rows) {
        map.set(`${row.registrationId}:${row.onlineSessionId}`, {
          joinUrl: row.joinUrl ?? null,
          activationStatus: row.activationStatus ?? null,
        });
      }
      return map;
    } catch (error) {
      this.logger.error('Error finding zoom extension snapshots', error?.stack, { error, pairs });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  async updateBulkJob(id: number, updates: Partial<BackgroundJob>): Promise<void> {
    try {
      await this.jobRepo.update(id, updates);
    } catch (error) {
      this.logger.error('Error updating bulk Zoom registration job', error?.stack, { error, id });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_SAVE_FAILED, error);
    }
  }

  async updateBulkJobStatus(
    id: number,
    status: ExportJobStatus,
    extra?: Partial<BackgroundJob>,
  ): Promise<void> {
    try {
      await this.jobRepo.update(id, {
        status,
        ...(extra ?? {}),
        ...(status === ExportJobStatus.COMPLETED || status === ExportJobStatus.FAILED
          ? { completedAt: new Date() }
          : {}),
      });
    } catch (error) {
      this.logger.error('Error updating bulk Zoom registration job status', error?.stack, {
        error,
        id,
        status,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_SAVE_FAILED, error);
    }
  }

  /**
   * The active extension for a registrant on a specific online session. A
   * registration can be on several online sessions, so the online session id is
   * part of the key.
   */
  async findZoomExtension(
    registrationId: number,
    onlineSessionId: number,
    manager?: EntityManager,
  ): Promise<ProgramRegistrationOnlineSession | null> {
    try {
      return await this.zoom(manager).findOne({
        where: {
          registrationId,
          onlineSessionId,
          status: OnlineSessionRegistrationStatus.REGISTERED,
          deletedAt: IsNull(),
        },
      });
    } catch (error) {
      this.logger.error('Error finding zoom registration extension', error?.stack, {
        error,
        registrationId,
        onlineSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * The registrant's provisioned (REGISTERED) extension rows on every session of
   * the program starting at or after `fromStartsAt`, each paired with its owning
   * ProgramSession (onlineSession attached, so the per-type Zoom handlers can be
   * invoked directly). Powers the forward cascade of the activation toggle:
   * deactivating/reactivating at session N applies to N and every later session.
   * Ordered by session start time ascending.
   */
  async findProvisionedExtensionsFromSession(
    registrationId: number,
    programId: number,
    fromStartsAt: Date,
  ): Promise<{ extension: ProgramRegistrationOnlineSession; session: ProgramSession }[]> {
    try {
      const extensions = await this.zoomRepo
        .createQueryBuilder('extension')
        .innerJoinAndSelect('extension.onlineSession', 'onlineSession')
        .innerJoinAndSelect('onlineSession.programSession', 'programSession')
        .where('extension.registration_id = :registrationId', { registrationId })
        .andWhere('extension.deleted_at IS NULL')
        .andWhere('extension.status = :registered', {
          registered: OnlineSessionRegistrationStatus.REGISTERED,
        })
        .andWhere('onlineSession.deleted_at IS NULL')
        .andWhere('onlineSession.program_id = :programId', { programId })
        .andWhere('programSession.deleted_at IS NULL')
        .andWhere('programSession.starts_at >= :fromStartsAt', { fromStartsAt })
        .orderBy('programSession.starts_at', 'ASC')
        .getMany();

      return extensions.map((extension) => {
        const session = extension.onlineSession!.programSession as ProgramSession;
        // The Zoom handlers read session.onlineSession (externalId, linkMode, ...) — reattach it,
        // since the join loaded it on the extension side of the relation.
        session.onlineSession = extension.onlineSession;
        return { extension, session };
      });
    } catch (error) {
      this.logger.error('Error finding provisioned extensions from session', error?.stack, {
        error,
        registrationId,
        programId,
        fromStartsAt,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Whether ANY session of the given program is currently in progress
   * (starts_at <= now <= ends_at) — used to block the registration-level
   * activation toggle entirely while a session is live, program-wide, rather
   * than silently skipping just that one session. Flipping join links mid-session
   * risks disrupting an in-progress meeting for whoever is already in it.
   */
  async hasLiveSessionForProgram(programId: number, now: Date): Promise<boolean> {
    try {
      const count = await this.registrationRepo.manager
        .createQueryBuilder(ProgramSession, 'session')
        .where('session.program_id = :programId', { programId })
        .andWhere('session.deleted_at IS NULL')
        .andWhere('session.starts_at <= :now', { now })
        .andWhere('session.ends_at >= :now', { now })
        .getCount();
      return count > 0;
    } catch (error) {
      this.logger.error('Error checking for a live session in program', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Any active extension this registrant holds on a session sharing the given
   * external webinar id — i.e. a sibling occurrence of a "same link" recurring
   * webinar. Lets the register flow reuse that registrant's single Zoom join link
   * instead of pushing them to Zoom again for each session. Newest first.
   */
  async findExtensionByRegistrationAndExternalId(
    registrationId: number,
    externalId: string,
    manager?: EntityManager,
  ): Promise<ProgramRegistrationOnlineSession | null> {
    try {
      return await this.zoom(manager)
        .createQueryBuilder('extension')
        .innerJoin(
          OnlineSession,
          'onlineSession',
          'onlineSession.id = extension.online_session_id AND onlineSession.deleted_at IS NULL',
        )
        .where('extension.registration_id = :registrationId', { registrationId })
        .andWhere('extension.deleted_at IS NULL')
        .andWhere('extension.status = :registered', {
          registered: OnlineSessionRegistrationStatus.REGISTERED,
        })
        .andWhere('onlineSession.external_id = :externalId', { externalId })
        .orderBy('extension.id', 'DESC')
        .getOne();
    } catch (error) {
      this.logger.error('Error finding sibling zoom extension by external id', error?.stack, {
        error,
        registrationId,
        externalId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * All active extensions this registrant holds across sessions sharing the given
   * external webinar id — the sibling group to soft-delete when they cancel out of
   * a "same link" recurring webinar (Zoom registration is webinar-level).
   */
  async findExtensionsByRegistrationAndExternalId(
    registrationId: number,
    externalId: string,
    manager?: EntityManager,
  ): Promise<ProgramRegistrationOnlineSession[]> {
    try {
      return await this.zoom(manager)
        .createQueryBuilder('extension')
        .innerJoin(
          OnlineSession,
          'onlineSession',
          'onlineSession.id = extension.online_session_id AND onlineSession.deleted_at IS NULL',
        )
        .where('extension.registration_id = :registrationId', { registrationId })
        .andWhere('extension.deleted_at IS NULL')
        .andWhere('extension.status = :registered', {
          registered: OnlineSessionRegistrationStatus.REGISTERED,
        })
        .andWhere('onlineSession.external_id = :externalId', { externalId })
        .getMany();
    } catch (error) {
      this.logger.error('Error finding sibling zoom extensions by external id', error?.stack, {
        error,
        registrationId,
        externalId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }

  /**
   * Persists a successful registration for a (registration, online session) pair.
   * A prior FAILED attempt leaves a durable row on the same pair (the partial
   * unique index allows only one active row per pair), so this is an upsert: if
   * such a row exists it is flipped to REGISTERED in place — clearing the failure
   * reason/message and bumping the attempt count — rather than inserting a second
   * row that would violate the index. Callers guarantee no REGISTERED row exists.
   */
  async createZoomExtension(
    data: Partial<ProgramRegistrationOnlineSession>,
    manager?: EntityManager,
  ): Promise<ProgramRegistrationOnlineSession> {
    try {
      const repo = this.zoom(manager);
      const existing =
        data.registrationId != null && data.onlineSessionId != null
          ? await repo.findOne({
              where: {
                registrationId: data.registrationId,
                onlineSessionId: data.onlineSessionId,
                deletedAt: IsNull(),
              },
            })
          : null;

      if (existing) {
        repo.merge(existing, data, {
          status: OnlineSessionRegistrationStatus.REGISTERED,
          failureReason: null,
          failureMessage: null,
          attemptCount: (existing.attemptCount ?? 0) + 1,
          lastAttemptAt: new Date(),
        });
        return await repo.save(existing);
      }

      const entity = repo.create(
        new ProgramRegistrationOnlineSession({
          ...data,
          status: OnlineSessionRegistrationStatus.REGISTERED,
          lastAttemptAt: new Date(),
        }),
      );
      return await repo.save(entity);
    } catch (error) {
      this.logger.error('Error creating zoom registration extension', error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_SAVE_FAILED, error);
    }
  }

  /**
   * Records a failed provisioning attempt for a (registration, online session)
   * pair as a durable row (status FAILED, no join link), keyed the same way a
   * success is. Idempotent per pair via the partial unique index: an existing
   * FAILED row is updated (reason/message refreshed, attempt count bumped); a
   * fresh failure inserts a new row. A pair that is already REGISTERED is left
   * untouched so a stray late failure never clobbers a live registration.
   */
  async recordExtensionFailure(
    data: {
      registrationId: number;
      onlineSessionId: number;
      provider?: SessionProviderType | null;
      reason: string;
      message?: string | null;
      actorUserId?: number;
    },
    manager?: EntityManager,
  ): Promise<void> {
    try {
      const repo = this.zoom(manager);
      const existing = await repo.findOne({
        where: {
          registrationId: data.registrationId,
          onlineSessionId: data.onlineSessionId,
          deletedAt: IsNull(),
        },
      });

      if (existing?.status === OnlineSessionRegistrationStatus.REGISTERED) return;

      if (existing) {
        existing.status = OnlineSessionRegistrationStatus.FAILED;
        existing.failureReason = data.reason;
        existing.failureMessage = data.message ?? null;
        existing.attemptCount = (existing.attemptCount ?? 0) + 1;
        existing.lastAttemptAt = new Date();
        if (data.actorUserId) existing.updatedBy = { id: data.actorUserId } as User;
        await repo.save(existing);
        return;
      }

      const entity = repo.create(
        new ProgramRegistrationOnlineSession({
          registrationId: data.registrationId,
          onlineSessionId: data.onlineSessionId,
          provider: data.provider ?? SessionProviderType.ZOOM,
          status: OnlineSessionRegistrationStatus.FAILED,
          failureReason: data.reason,
          failureMessage: data.message ?? null,
          attemptCount: 1,
          lastAttemptAt: new Date(),
          createdBy: data.actorUserId ? ({ id: data.actorUserId } as User) : undefined,
          updatedBy: data.actorUserId ? ({ id: data.actorUserId } as User) : undefined,
        }),
      );
      await repo.save(entity);
    } catch (error) {
      this.logger.error('Error recording zoom registration failure', error?.stack, {
        error,
        registrationId: data.registrationId,
        onlineSessionId: data.onlineSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_SAVE_FAILED, error);
    }
  }

  async saveZoomExtension(
    entity: ProgramRegistrationOnlineSession,
    manager?: EntityManager,
  ): Promise<ProgramRegistrationOnlineSession> {
    try {
      return await this.zoom(manager).save(entity);
    } catch (error) {
      this.logger.error('Error saving zoom registration extension', error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_SAVE_FAILED, error);
    }
  }

  async softDeleteZoomExtension(
    entity: ProgramRegistrationOnlineSession,
    manager?: EntityManager,
  ): Promise<void> {
    try {
      entity.deletedAt = new Date();
      await this.zoom(manager).save(entity);
    } catch (error) {
      this.logger.error('Error soft deleting zoom registration extension', error?.stack, {
        error,
        id: entity?.id,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_DELETE_FAILED, error);
    }
  }

  /**
   * Seat-allocated registrations currently provisioned (REGISTERED, not soft-deleted)
   * on one specific online session — used by the final-session-confirm feature to find
   * who to evaluate for the program's final session. `rmContactId`, when given (an RM
   * caller), scopes the set to their own contacts, same convention as
   * {@link listEligibleRegistrationsByProgram}.
   */
  async listRegisteredExtensionsForSession(
    onlineSessionId: number,
    rmContactId?: number,
  ): Promise<{ registration: ProgramRegistration; extension: ProgramRegistrationOnlineSession }[]> {
    try {
      const qb = this.zoomRepo
        .createQueryBuilder('extension')
        .innerJoinAndSelect('extension.registration', 'registration')
        .where('extension.online_session_id = :onlineSessionId', { onlineSessionId })
        .andWhere('extension.deleted_at IS NULL')
        .andWhere('extension.status = :registered', {
          registered: OnlineSessionRegistrationStatus.REGISTERED,
        })
        .andWhere('registration.deleted_at IS NULL')
        .andWhere('registration.seat_allocated = true');
      if (rmContactId != null) {
        qb.andWhere('registration.rm_contact = :rmContactId', { rmContactId });
      }
      const extensions = await qb.getMany();
      return extensions.map((extension) => ({ registration: extension.registration, extension }));
    } catch (error) {
      this.logger.error('Error listing registered extensions for session', error?.stack, {
        error,
        onlineSessionId,
        rmContactId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_GET_FAILED, error);
    }
  }
}
