import { Injectable } from '@nestjs/common';
import {
  ProgramSession,
  ProgramRegistrationOnlineSession,
  BackgroundJob,
  SessionCommunicationStatus,
} from 'src/common/entities';
import { PendingSessionJob } from '../interfaces/online-session.interface';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';
import { AppLoggerService } from 'src/common/services/logger.service';
import { handleKnownErrors } from 'src/common/utils/handle-error.util';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { SessionProviderType } from 'src/common/enum/session-provider.enum';
import { ZoomWebinarStatus } from 'src/common/enum/zoom-webinar-status.enum';
import { JoinLinkGenerationStatus } from 'src/common/enum/join-link-generation-status.enum';
import { SessionLinkModeEnum } from 'src/common/enum/session-link-mode.enum';
import { SessionCommunicationPurposeEnum } from 'src/common/enum/session-communication-purpose.enum';
import {
  CreateSessionInput,
  UpdateSessionInput,
  CreateSharedSessionInput,
} from 'src/common/interfaces/online-session.interface';
import { CreateOnlineSessionDto } from '../dto/create-online-session.dto';
import { CreateSharedOnlineSessionDto } from '../dto/create-shared-online-session.dto';
import { UpdateOnlineSessionDto } from '../dto/update-online-session.dto';
import { RegisterParticipantDto } from '../dto/register-participant.dto';
import { BulkRegisterParticipantsDto } from '../dto/bulk-register-participants.dto';
import { OnlineSessionRepository } from '../repositories/online-session.repository';
import { OnlineSessionProviderRegistry } from './online-session-provider.registry';
import {
  OnlineSessionList,
  BulkOnlineSessionResult,
  BulkOnlineSessionFailure,
  BulkRegistrationStart,
  BulkRegistrationStatus,
  BulkRegistrationFailureList,
  BulkRegistrationJobMetadata,
  SessionRegistrationList,
  ProvisionStatusOverview,
  ProvisionRegistrationList,
  ProvisionRegistrationStatus,
  EligibleCountSummary,
  ProgramEligibleRegistrationList,
  ProgramEligibleRegistrationsQuery,
  RmContactOption,
  ProgramBulkCommunications,
  SessionBulkCommunications,
  SessionBulkCommunicationStatus,
  RegistrationActivationResult,
  SessionAttendanceSummary,
} from '../interfaces/online-session.interface';
import { ONLINE_SESSION_LOG } from 'src/common/constants/online-session.constants';
import { JoinLinkSchedulerService } from 'src/join-link-scheduler/join-link-scheduler.service';
import { RegistrationOnlineSessionActivationStatus } from 'src/common/enum/registration-online-session-activation-status.enum';
import { UserTypeFilterValue } from 'src/common/utils/user-type-filter.util';

/**
 * FACADE over online-session providers. Controllers talk only to this; it
 * resolves the right provider (by DTO on create, by persisted value afterwards)
 * and delegates. It never contains provider-specific logic.
 */
@Injectable()
export class OnlineSessionService {
  constructor(
    private readonly registry: OnlineSessionProviderRegistry,
    private readonly repository: OnlineSessionRepository,
    private readonly logger: AppLoggerService,
    private readonly linkScheduler: JoinLinkSchedulerService,
  ) {}

  async create(dto: CreateOnlineSessionDto): Promise<ProgramSession> {
    try {
      const provider = dto.provider ?? SessionProviderType.ZOOM;
      const session = await this.registry.resolve(provider).create(this.toCreateInput(dto, provider));
      await this.syncLinkSchedule(session, dto.createdBy);
      return session;
    } catch (error) {
      this.logger.error(ONLINE_SESSION_LOG.CREATE_FAILED, error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ONLINE_SESSION_OPERATION_FAILED, error);
    }
  }

  // Sync the schedule to the program session's linkGenerationAt (best-effort).
  private async syncLinkSchedule(session: ProgramSession, actorUserId?: number): Promise<void> {
    try {
      const fireAt = session.linkGenerationAt ? new Date(session.linkGenerationAt) : null;
      // Only (re)register when the fire time is still in the future. A past time
      // means it already fired/completed; re-scheduling it would be rejected by
      // EventBridge and would clobber a COMPLETED/FAILED run back to SCHEDULED.
      if (fireAt && fireAt.getTime() > Date.now()) {
        await this.linkScheduler.createLinkSchedule({
          programSessionId: session.id,
          fireAt: fireAt.toISOString(),
          actorUserId,
        });
        // Keep the DB status in step with the registered schedule so the fired
        // event's atomic claim (which requires SCHEDULED) can succeed.
        await this.repository.setLinkGenerationStatus(
          session.id,
          JoinLinkGenerationStatus.SCHEDULED,
        );
      } else {
        // No future schedule → ensure no stale AWS schedule survives. Leave the
        // status untouched (a terminal COMPLETED/FAILED or default NOT_SCHEDULED).
        await this.linkScheduler.deleteLinkSchedule(session.id);
      }
    } catch (error) {
      this.logger.error(ONLINE_SESSION_LOG.CREATE_FAILED, (error as Error)?.stack, {
        error,
        programSessionId: session.id,
        context: 'sync-link-schedule',
      });
    }
  }

  /**
   * Provisions several online sessions in one request. Each session is created
   * independently against its provider — provisioning is an external side effect
   * (e.g. a Zoom API call) that cannot participate in a DB transaction, so a
   * failure on one entry does not roll back or abort the others. Successes and
   * failures are returned side by side so the caller can retry only what failed.
   */
  async createBulk(dtos: CreateOnlineSessionDto[]): Promise<BulkOnlineSessionResult> {
    const created: ProgramSession[] = [];
    const failed: BulkOnlineSessionFailure[] = [];
    for (const dto of dtos) {
      try {
        const session = await this.create(dto);
        created.push(session);
      } catch (error) {
        this.logger.error(ONLINE_SESSION_LOG.CREATE_FAILED, error?.stack, {
          error,
          programSessionId: dto.programSessionId,
        });
        failed.push({
          programSessionId: dto.programSessionId,
          error: error?.message ?? ONLINE_SESSION_LOG.CREATE_FAILED,
        });
      }
    }
    if (failed.length) {
      this.logger.warn(ONLINE_SESSION_LOG.BULK_CREATE_PARTIAL(failed.length, dtos.length));
    }
    return { created, failed };
  }

  /**
   * Fire-and-forget: issue the role/placeholder ("system") general links for one session —
   * called by `ZoomBulkRegistrationService.startBulkRegistration` on every bulk-register run
   * against an existing session (not at session-creation time). Safe to call repeatedly:
   * `ZoomGeneratedLinkService.generate` skips recipients already registered for the session
   * rather than duplicating rows.
   *
   * Only meaningful in PER_SESSION mode — a SHARED session's general links are already handled
   * by `createShared`'s own background task (see zoom.provider.ts), and skipped here.
   */
  generateGeneralLinksForSession(
    session: ProgramSession,
    provider: SessionProviderType = SessionProviderType.ZOOM,
    actorUserId?: number,
  ): void {
    const linkMode = session.onlineSession?.linkMode ?? SessionLinkModeEnum.PER_SESSION;
    console.log(
      `generateGeneralLinksForSession: sessionId=${session.id}, provider=${provider}, linkMode=${linkMode}`,
    );
    if (linkMode !== SessionLinkModeEnum.PER_SESSION) return;
    if (!session.onlineSession?.externalId) {
      this.logger.warn(ONLINE_SESSION_LOG.GENERAL_LINKS_SKIPPED_NOT_PROVISIONED(session.id));
      return;
    }
    this.registry.resolve(provider).generateGeneralLinks(session, actorUserId);
  }

  /**
   * Provisions ONE recurring webinar shared across several program sessions (the
   * "same link" model): one external resource, one join link per registrant valid
   * for every session. Returns all persisted sessions.
   */
  async createShared(dto: CreateSharedOnlineSessionDto): Promise<ProgramSession[]> {
    try {
      const provider = dto.provider ?? SessionProviderType.ZOOM;
      const sessions = await this.registry
        .resolve(provider)
        .createShared(this.toCreateSharedInput(dto, provider));
      for (const session of sessions) {
        await this.syncLinkSchedule(session, dto.createdBy);
      }
      return sessions;
    } catch (error) {
      this.logger.error(ONLINE_SESSION_LOG.CREATE_FAILED, (error as Error)?.stack, { error });
      handleKnownErrors(ERROR_CODES.ONLINE_SESSION_OPERATION_FAILED, error);
    }
  }

  async update(id: number, dto: UpdateOnlineSessionDto): Promise<ProgramSession> {
    try {
      const session = await this.findOne(id);
      // No provisioned online session → nothing to update on the provider; 404
      // rather than silently no-op'ing.
      if (!session.onlineSession?.externalId) {
        throw new InifniNotFoundException(
          ERROR_CODES.ONLINE_SESSION_NOTFOUND,
          null,
          null,
          id.toString(),
        );
      }
      const provider = this.providerOf(session);
      const updated = await this.registry.resolve(provider).update(session, this.toUpdateInput(dto));
      await this.syncLinkSchedule(updated, dto.updatedBy);
      return updated;
    } catch (error) {
      this.logger.error(ONLINE_SESSION_LOG.UPDATE_FAILED, error?.stack, { error, id });
      handleKnownErrors(ERROR_CODES.ONLINE_SESSION_OPERATION_FAILED, error);
    }
  }

  /**
   * Manually overrides the lifecycle status. Local-only — it writes the status
   * marker into whichever details blob backs the session and never calls the
   * provider (Zoom has no matching concept, and the resource may no longer
   * exist). Webhook-driven transitions still run and may overwrite this later.
   */
  async updateStatus(
    id: number,
    status: ZoomWebinarStatus,
    actorUserId?: number,
  ): Promise<ProgramSession> {
    try {
      const session = await this.findOne(id);
      if (session.onlineSession) {
        session.onlineSession.status = status;
      }
      if (actorUserId) session.updatedBy = actorUserId;
      return await this.repository.save(session);
    } catch (error) {
      this.logger.error(ONLINE_SESSION_LOG.UPDATE_FAILED, error?.stack, { error, id });
      handleKnownErrors(ERROR_CODES.ONLINE_SESSION_OPERATION_FAILED, error);
    }
  }

  async remove(id: number, actorUserId?: number): Promise<void> {
    try {
      const session = await this.findOne(id);
      // The program session may exist with no provisioned online session (never
      // created, or already deleted) — there is nothing to delete, so 404 rather
      // than silently succeeding.
      if (!session.onlineSession?.externalId) {
        throw new InifniNotFoundException(
          ERROR_CODES.ONLINE_SESSION_NOTFOUND,
          null,
          null,
          id.toString(),
        );
      }
      const provider = this.providerOf(session);
      await this.registry.resolve(provider).remove(session, actorUserId);
      try {
        await this.linkScheduler.deleteLinkSchedule(id);
      } catch (error) {
        this.logger.error(ONLINE_SESSION_LOG.DELETE_FAILED, (error as Error)?.stack, {
          error,
          id,
          context: 'delete-link-schedule',
        });
      }
    } catch (error) {
      this.logger.error(ONLINE_SESSION_LOG.DELETE_FAILED, error?.stack, { error, id });
      handleKnownErrors(ERROR_CODES.ONLINE_SESSION_OPERATION_FAILED, error);
    }
  }

  async findOne(id: number): Promise<ProgramSession> {
    const session = await this.repository.findById(id);
    if (!session) {
      throw new InifniNotFoundException(
        ERROR_CODES.ONLINE_SESSION_NOTFOUND,
        null,
        null,
        id.toString(),
      );
    }
    return session;
  }

  async findAll(
    limit: number,
    offset: number,
    status?: ZoomWebinarStatus,
    programId?: number,
  ): Promise<OnlineSessionList> {
    const { data, total } = await this.repository.findAll(limit, offset, status, programId);
    return {
      data,
      pagination: {
        totalPages: limit ? Math.ceil(total / limit) : 1,
        pageNumber: limit ? Math.floor(offset / limit) + 1 : 1,
        pageSize: limit,
        totalRecords: total,
        numberOfRecords: data.length,
      },
    };
  }

  /**
   * Latest bulk-communication status per session, keyed by session id. Rows come newest-first,
   * so the first row seen for each (session, purpose) is the latest; sessions with no send for a
   * purpose get `null`. Invite/Absent/Value Card are session-scoped. Welcome is program-level
   * (its status row has no session id), so a program's latest Welcome is echoed onto every one
   * of its sessions. Used to enrich the online-session GET responses (read-only).
   */
  async getSessionBulkCommunications(
    sessions: Array<{ id: number; programId: number | null }>,
  ): Promise<Map<number, SessionBulkCommunications>> {
    const map = new Map<number, SessionBulkCommunications>();
    if (!sessions.length) {
      return map;
    }

    // Seed every requested session so the response always carries the shape.
    for (const session of sessions) {
      map.set(Number(session.id), {
        welcome: null,
        invite: null,
        absent: null,
        valueCard: null,
        programCompletion: null,
      });
    }

    const sessionIds = sessions.map((session) => Number(session.id));
    const programIds = [
      ...new Set(
        sessions
          .map((session) => (session.programId != null ? Number(session.programId) : null))
          .filter((id): id is number => id != null),
      ),
    ];

    // Session-scoped statuses (Invite / Absent / Value Card).
    const sessionRows = await this.repository.findSessionBulkStatuses(sessionIds);
    for (const row of sessionRows) {
      const entry = map.get(Number(row.sessionId));
      if (!entry) {
        continue;
      }
      const summary = this.toStatusSummary(row);
      if (row.purpose === SessionCommunicationPurposeEnum.INVITE && !entry.invite) {
        entry.invite = summary;
      } else if (row.purpose === SessionCommunicationPurposeEnum.ABSENT && !entry.absent) {
        entry.absent = summary;
      } else if (row.purpose === SessionCommunicationPurposeEnum.VALUE_CARD && !entry.valueCard) {
        entry.valueCard = summary;
      }
    }

    // Program-level statuses (Welcome / Program Completion) — echo each program's latest onto
    // every session of it.
    const programRows = await this.repository.findProgramLevelStatuses(programIds);
    const welcomeByProgram = new Map<number, SessionBulkCommunications['welcome']>();
    const completionByProgram = new Map<number, SessionBulkCommunications['programCompletion']>();
    for (const row of programRows) {
      const programId = Number(row.programId);
      if (
        row.purpose === SessionCommunicationPurposeEnum.WELCOME &&
        !welcomeByProgram.has(programId)
      ) {
        welcomeByProgram.set(programId, this.toStatusSummary(row));
      } else if (
        row.purpose === SessionCommunicationPurposeEnum.PROGRAM_COMPLETION &&
        !completionByProgram.has(programId)
      ) {
        completionByProgram.set(programId, this.toStatusSummary(row));
      }
    }
    for (const session of sessions) {
      const entry = map.get(Number(session.id));
      if (entry && session.programId != null) {
        const programId = Number(session.programId);
        entry.welcome = welcomeByProgram.get(programId) ?? null;
        entry.programCompletion = completionByProgram.get(programId) ?? null;
      }
    }

    return map;
  }

  /**
   * Latest program-level bulk-communication status (Welcome / Program Completion) for one program,
   * used to enrich the eligible-registrations GET response (read-only). Rows come newest-first, so
   * the first row seen per purpose is the latest; a purpose never triggered stays `null`. Mirrors
   * getSessionBulkCommunications but program-scoped — Invite/Absent/Value Card are session-scoped
   * and have no single-program value, so they're omitted.
   */
  async getProgramBulkCommunications(programId: number): Promise<ProgramBulkCommunications> {
    const communications: ProgramBulkCommunications = {
      welcome: null,
      programCompletion: null,
    };
    if (!programId) {
      return communications;
    }

    const rows = await this.repository.findProgramLevelStatuses([Number(programId)]);
    for (const row of rows) {
      if (row.purpose === SessionCommunicationPurposeEnum.WELCOME && !communications.welcome) {
        communications.welcome = this.toStatusSummary(row);
      } else if (
        row.purpose === SessionCommunicationPurposeEnum.PROGRAM_COMPLETION &&
        !communications.programCompletion
      ) {
        communications.programCompletion = this.toStatusSummary(row);
      }
    }
    return communications;
  }

  /**
   * Still-running background job (status PENDING/PROCESSING; any type) created by each session's
   * own creator that targets that session, keyed by session id. Jobs come newest-first, so the
   * first job seen per session id is the latest still-running one; sessions with no such job are
   * absent from the map. A job can target several sessions at once (metadata.sessionIds), so it's
   * matched to every requested session it targets whose creator started that job — not just one.
   * Used to enrich the online-session GET responses (read-only).
   */
  async getPendingJobsForSessions(
    sessions: Array<{ id: number; createdBy: number | null }>,
  ): Promise<Map<number, PendingSessionJob>> {
    const map = new Map<number, PendingSessionJob>();
    if (!sessions.length) {
      return map;
    }

    const createdByBySession = new Map(
      sessions.map((session) => [Number(session.id), session.createdBy]),
    );
    const jobs = await this.repository.findPendingJobsBySessionIds(
      sessions.map((session) => Number(session.id)),
    );
    for (const job of jobs) {
      const jobSessionIds: number[] = (job.metadata as { sessionIds?: number[] })?.sessionIds ?? [];
      for (const sessionId of jobSessionIds) {
        if (map.has(sessionId) || !createdByBySession.has(sessionId)) {
          continue;
        }
        if (createdByBySession.get(sessionId) !== job.createdBy) {
          continue;
        }
        map.set(sessionId, {
          id: job.id,
          type: job.type,
          status: job.status,
          createdAt: job.createdAt,
          completedAt: job.completedAt,
        });
      }
    }
    return map;
  }

  /** Map a status row to the display summary used on the online-session responses. */
  private toStatusSummary(row: SessionCommunicationStatus): SessionBulkCommunicationStatus {
    return {
      status: row.status,
      requested: row.requestedCount,
      emailSent: row.emailEnqueuedCount,
      whatsappSent: row.whatsappEnqueuedCount,
      skipped: row.skippedCount,
      lastTriggeredAt: row.createdAt,
    };
  }

  // ---------------------------------------------------------------------------
  // Registration & tracking — delegated to the resolved provider.
  //
  // Registration inputs (registrationId / programId / jobId) carry no provider,
  // so these default to the sole provider, mirroring `create()`'s
  // `dto.provider ?? ZOOM`. Once a second provider exists, resolve it from the
  // registration's session instead. Session-scoped ops (sync/analytics) load the
  // session first and resolve the provider from it, like the lifecycle methods.
  // ---------------------------------------------------------------------------

  register(dto: RegisterParticipantDto): Promise<ProgramRegistrationOnlineSession | void> {
    return this.registry.resolve(SessionProviderType.ZOOM).register(dto);
  }

  bulkRegister(
    dto: BulkRegisterParticipantsDto,
    actorUserId?: number,
  ): Promise<BulkRegistrationStart> {
    return this.registry.resolve(SessionProviderType.ZOOM).bulkRegister(dto, actorUserId);
  }

  /**
   * Detailed status of a bulk registration job: running counts plus the
   * eligibility breakdown and a paginated per-item failure list. The provider
   * owns the raw job row; shaping the metadata (a module-owned jsonb schema on
   * the shared background_jobs table) is provider-neutral and lives here.
   */
  async getBulkRegistrationStatus(jobId: number): Promise<BulkRegistrationStatus> {
    const job = await this.registry.resolve(SessionProviderType.ZOOM).bulkRegisterStatus(jobId);
    return this.toBulkRegistrationStatus(job);
  }

  /**
   * Paginated, enriched per-item failure list for a bulk registration job. Split
   * out of the status view: the failure list can be large and is only needed once
   * a job reports failures, so it is fetched on demand rather than on every poll.
   */
  getBulkRegistrationFailures(
    jobId: number,
    paging: { page: number; limit: number },
    rmContactId?: number,
  ): Promise<BulkRegistrationFailureList> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .bulkRegisterFailures(jobId, paging, rmContactId);
  }

  retryBulkRegistration(jobId: number, actorUserId?: number): Promise<BulkRegistrationStart> {
    return this.registry.resolve(SessionProviderType.ZOOM).retryBulkRegistration(jobId, actorUserId);
  }

  /**
   * Current failures for a whole program (optionally one session), merged across
   * the program's job chain and de-staled. Lets an admin see "what's still
   * failing" by program/session instead of resolving individual job ids.
   */
  getProgramRegistrationFailures(
    programId: number,
    query: { sessionId?: number; page: number; limit: number },
    rmContactId?: number,
  ): Promise<BulkRegistrationFailureList> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .getProgramRegistrationFailures(programId, query, rmContactId);
  }

  /** Re-run the current failures of a whole program (optionally one session) as a fresh job. */
  retryProgramRegistration(
    programId: number,
    sessionId?: number,
    actorUserId?: number,
  ): Promise<BulkRegistrationStart> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .retryProgramRegistration(programId, sessionId, actorUserId);
  }

  /** Maps a persisted bulk-registration job row into the detailed status view. */
  private toBulkRegistrationStatus(job: BackgroundJob): BulkRegistrationStatus {
    const metadata = (job.metadata ?? {}) as Partial<BulkRegistrationJobMetadata>;
    return {
      jobId: job.id,
      type: job.type,
      status: job.status,
      total: job.total,
      generated: job.generated,
      skipped: job.skipped,
      failed: job.failed,
      eligible: metadata.eligibleCount ?? 0,
      ineligible: metadata.ineligible ?? [],
      errorMessage: job.errorMessage,
      retryOfJobId: metadata.retryOfJobId ?? null,
      completedAt: job.completedAt,
    };
  }

  listRegistrations(
    sessionId: number,
    query: { page: number; limit: number; search?: string },
    rmContactId?: number,
  ): Promise<SessionRegistrationList> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .listRegistrations(sessionId, query, rmContactId);
  }

  exportRegistrations(
    sessionId: number,
    search?: string,
    rmContactId?: number,
  ): Promise<{ fileUrl: string }> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .exportRegistrations(sessionId, search, rmContactId);
  }

  setRegistrationActivation(
    registrationId: number,
    activationStatus: RegistrationOnlineSessionActivationStatus,
    reason: string | null | undefined,
    actingUserId: number | null | undefined,
  ): Promise<RegistrationActivationResult> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .setRegistrationActivation(registrationId, activationStatus, reason, actingUserId);
  }

  getEligibleCount(sessionId: number): Promise<EligibleCountSummary> {
    return this.registry.resolve(SessionProviderType.ZOOM).getEligibleCount(sessionId);
  }

  listProgramEligibleRegistrations(
    programId: number,
    query: ProgramEligibleRegistrationsQuery,
  ): Promise<ProgramEligibleRegistrationList> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .listProgramEligibleRegistrations(programId, query);
  }

  getProgramEligibleRmContacts(): Promise<RmContactOption[]> {
    return this.registry.resolve(SessionProviderType.ZOOM).getProgramEligibleRmContacts();
  }

  /**
   * Per-session Registered/Attended/Absent + duration-bucket rollup, keyed by
   * program-session id. Used to enrich the online-session list/detail GET
   * responses (read-only). `rmContactId`, when given (an RM caller), scopes
   * every count to their own contacts.
   */
  getSessionAttendanceSummary(
    sessions: ProgramSession[],
    rmContactId?: number,
  ): Promise<Map<number, SessionAttendanceSummary>> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .getSessionAttendanceSummary(sessions, rmContactId);
  }

  /**
   * Program-level provisioning overview: every online session of the program (or
   * one session), each with live counts — total eligible, generated, failed, and
   * yet-to-generate. Counts are computed on read, so they always reflect the
   * current extension rows and outstanding failures.
   */
  getProgramProvisionStatus(
    programId: number,
    sessionId?: number,
    rmContactId?: number,
    userType?: UserTypeFilterValue[],
  ): Promise<ProvisionStatusOverview> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .getProgramProvisionStatus(programId, sessionId, rmContactId, userType);
  }

  /**
   * Per-session provisioning drilldown: the eligible registrations bucketed into
   * generated / failed / yet-to-generate, optionally filtered to one bucket.
   */
  getSessionProvisionRegistrations(
    sessionId: number,
    query: {
      status?: ProvisionRegistrationStatus;
      page: number;
      limit: number;
      userType?: UserTypeFilterValue[];
    },
    rmContactId?: number,
  ): Promise<ProvisionRegistrationList> {
    return this.registry
      .resolve(SessionProviderType.ZOOM)
      .getSessionProvisionRegistrations(sessionId, query, rmContactId);
  }

  /**
   * Which provider backs an existing session. Persisted on the online-session
   * row; defaults to Zoom for sessions created before the field existed.
   */
  private providerOf(session: ProgramSession): SessionProviderType {
    return (session.onlineSession?.provider as SessionProviderType) ?? SessionProviderType.ZOOM;
  }

  private toCreateInput(
    dto: CreateOnlineSessionDto,
    provider: SessionProviderType,
  ): CreateSessionInput {
    return {
      provider,
      onlineType: dto.onlineType ?? OnlineTypeEnum.WEBINAR,
      programSessionId: dto.programSessionId,
      title: dto.title,
      startAt: dto.startAt,
      duration: dto.duration,
      password: dto.password,
      hostEmail: dto.hostEmail,
      requireRegistration: dto.requireRegistration,
      launchMode: dto.launchMode,
      joinOpensMinutesBefore: dto.joinOpensMinutesBefore,
      hostStartOpensMinutesBefore: dto.hostStartOpensMinutesBefore,
      status: dto.status,
      registrationStartsAt: dto.registrationStartsAt,
      registrationEndsAt: dto.registrationEndsAt,
      actorUserId: dto.createdBy ?? dto.updatedBy,
    };
  }

  private toCreateSharedInput(
    dto: CreateSharedOnlineSessionDto,
    provider: SessionProviderType,
  ): CreateSharedSessionInput {
    return {
      provider,
      onlineType: dto.onlineType,
      programSessionIds: dto.programSessionIds,
      title: dto.title,
      password: dto.password,
      requireRegistration: dto.requireRegistration,
      linkType: dto.linkType,
      hostEmail: dto.hostEmail,
      launchMode: dto.launchMode,
      joinOpensMinutesBefore: dto.joinOpensMinutesBefore,
      hostStartOpensMinutesBefore: dto.hostStartOpensMinutesBefore,
      status: dto.status,
      recurrence: dto.recurrence,
      actorUserId: dto.createdBy,
    };
  }

  private toUpdateInput(dto: UpdateOnlineSessionDto): UpdateSessionInput {
    return {
      title: dto.title,
      startAt: dto.startAt,
      duration: dto.duration,
      password: dto.password,
      launchMode: dto.launchMode,
      joinOpensMinutesBefore: dto.joinOpensMinutesBefore,
      hostStartOpensMinutesBefore: dto.hostStartOpensMinutesBefore,
      registrationStartsAt: dto.registrationStartsAt,
      registrationEndsAt: dto.registrationEndsAt,
      actorUserId: dto.updatedBy,
      status: dto.status,
    };
  }
}
