import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosRequestConfig, Method } from 'axios';
import { AppLoggerService } from 'src/common/services/logger.service';
import { handleKnownErrors } from 'src/common/utils/handle-error.util';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { ZoomOAuthService } from './zoom-oauth.service';
import {
  ZOOM_ENV_KEYS,
  ZOOM_DEFAULTS,
  ZOOM_API_PATHS,
  ZOOM_RESOURCE_TYPE,
  ZOOM_APPROVAL_TYPE,
  ZOOM_MANUAL_APPROVAL_REGISTRANTS,
  ZOOM_NO_COMMS_WEBINAR_SETTINGS,
  ZOOM_NO_COMMS_MEETING_SETTINGS,
  ZOOM_PAGINATION,
  ZOOM_COLLECTION_KEY,
  ZOOM_HTTP_STATUS,
  ZOOM_API_CODE,
  ZOOM_LOG,
  ZOOM_RETRY,
  ZOOM_IDEMPOTENT_METHODS,
  ZOOM_REGISTRATION_TYPE,
} from 'src/common/constants/zoom.constants';
import { ZoomUserEndpoint, ZoomRegistrantStatusAction } from '../enums/zoom-role.enum';
import {
  ZoomWebinarCreatePayload,
  ZoomWebinarUpdatePayload,
  ZoomMeetingCreatePayload,
  ZoomMeetingUpdatePayload,
  ZoomApiResponse,
  ZoomRecurrence,
} from '../interfaces/zoom-api.interface';

/**
 * Thin, typed wrapper over the Zoom REST API. Owns request construction,
 * bearer-token injection, a single 401 retry (token refresh), and the
 * `next_page_token` pagination loop used by the list endpoints.
 */
@Injectable()
export class ZoomApiService {
  private readonly baseUrl: string;

  constructor(
    private readonly configService: ConfigService,
    private readonly oauthService: ZoomOAuthService,
    private readonly logger: AppLoggerService,
  ) {
    this.baseUrl =
      this.configService.get<string>(ZOOM_ENV_KEYS.BASE_URL) ?? ZOOM_DEFAULTS.BASE_URL;
  }

  private isEnabled(): boolean {
    return this.configService.get<string>(ZOOM_ENV_KEYS.ENABLE) === 'true';
  }

  /**
   * Issues an authenticated request to the Zoom API. Recovers from two failure
   * classes before giving up:
   *   - 401: invalidate the cached token and retry once (refresh).
   *   - 429 / transient 5xx: retry with exponential backoff (honouring any
   *     `Retry-After` header), up to ZOOM_RETRY.MAX_ATTEMPTS. 5xx is only retried
   *     for idempotent methods so a create is never silently duplicated.
   * Throws a mapped Zoom exception once recovery is exhausted.
   */
  private async request<T = ZoomApiResponse>(
    method: Method,
    path: string,
    options: { body?: unknown; params?: Record<string, unknown> } = {},
  ): Promise<T> {
    if (!this.isEnabled()) {
      handleKnownErrors(ERROR_CODES.ZOOM_DISABLED, new Error('Zoom integration disabled'));
    }
    let refreshedOnce = false;
    let transientAttempts = 0;
    for (;;) {
      try {
        return await this.send<T>(method, path, options, refreshedOnce);
      } catch (error: any) {
        const status: number | undefined = error?.response?.status;

        // 401 → refresh the token once and retry immediately.
        if (status === ZOOM_HTTP_STATUS.UNAUTHORIZED && !refreshedOnce) {
          this.oauthService.invalidate();
          refreshedOnce = true;
          continue;
        }

        // 429 / transient 5xx → back off and retry while attempts remain.
        if (
          this.isRetryableTransient(method, status) &&
          transientAttempts < ZOOM_RETRY.MAX_ATTEMPTS - 1
        ) {
          const waitMs = this.retryDelayMs(error, transientAttempts);
          transientAttempts++;
          this.logger.warn(ZOOM_LOG.API_RETRY_SCHEDULED, {
            method,
            path,
            status,
            attempt: transientAttempts,
            waitMs,
          });
          await this.delay(waitMs);
          continue;
        }

        const detail = error?.response?.data?.message ?? error?.message ?? 'unknown';
        this.logger.error(ZOOM_LOG.API_REQUEST_FAILED, error?.stack, {
          method,
          path,
          status,
          detail,
        });
        // Zoom rejected the request (missing scopes, bad payload, etc.) — this is a
        // bad-request condition, not an internal failure, so surface it as 400.
        // Thrown directly so Zoom's own message reaches the {0} slot; handleKnownErrors
        // does not forward template args.
        throw new InifniBadRequestException(ERROR_CODES.ZOOM_API_ERROR, error, null, detail);
      }
    }
  }

  /**
   * A 429 is always retryable (the request was rejected before processing). A 5xx
   * is retryable only for idempotent methods, so a transient failure on a create
   * (POST) is never replayed into a duplicate Zoom resource.
   */
  private isRetryableTransient(method: Method, status: number | undefined): boolean {
    if (status === ZOOM_HTTP_STATUS.TOO_MANY_REQUESTS) return true;
    if (typeof status === 'number' && status >= ZOOM_HTTP_STATUS.SERVER_ERROR_MIN) {
      return ZOOM_IDEMPOTENT_METHODS.includes(String(method).toUpperCase());
    }
    return false;
  }

  /**
   * Backoff before the next transient retry: honours Zoom's `Retry-After`
   * (seconds) when present, else exponential backoff, both capped at MAX_DELAY_MS.
   */
  private retryDelayMs(error: any, attempt: number): number {
    const retryAfter = error?.response?.headers?.['retry-after'];
    const headerMs = retryAfter !== undefined ? Number(retryAfter) * 1000 : NaN;
    if (Number.isFinite(headerMs) && headerMs > 0) {
      return Math.min(headerMs, ZOOM_RETRY.MAX_DELAY_MS);
    }
    const backoff = ZOOM_RETRY.BASE_DELAY_MS * 2 ** attempt;
    return Math.min(backoff, ZOOM_RETRY.MAX_DELAY_MS);
  }

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

  /**
   * Formats a value for Zoom's `start_time` field as a whole-second timestamp.
   * JS `Date.toISOString()` emits a millisecond fraction (`...:00.000Z`), which
   * Zoom mis-parses — for recurring meetings/webinars it shifts every occurrence
   * off the intended time (the shared "same link" flow passes the session Date
   * straight through, so it hit this). A Date is rendered as its UTC instant
   * (paired with the `timezone` field for display); a string is passed through
   * verbatim apart from stripping any millisecond fraction, so a caller may still
   * send local time (no `Z`) to be interpreted in `timezone`.
   */
  private toZoomStartTime(value: string | Date): string {
    const iso = value instanceof Date ? value.toISOString() : value;
    return iso.replace(/\.\d+(Z|[+-]\d{2}:?\d{2})?$/, '$1');
  }

  private async send<T>(
    method: Method,
    path: string,
    options: { body?: unknown; params?: Record<string, unknown> },
    isRetry: boolean,
  ): Promise<T> {
    const accessToken = await this.oauthService.getAccessToken();
    const config: AxiosRequestConfig = {
      method,
      url: `${this.baseUrl}${path}`,
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      params: options.params,
      data: options.body,
    };
    const response = await axios.request<T>(config);
    if (isRetry) {
      this.logger.log(ZOOM_LOG.API_RETRY_SUCCEEDED, { path });
    }
    return response.data;
  }

  // ---------------------------------------------------------------------------
  // Webinar lifecycle
  // ---------------------------------------------------------------------------

  async createWebinar(payload: ZoomWebinarCreatePayload, hostEmail: string): Promise<ZoomApiResponse> {
    // A recurrence rule promotes this to a recurring webinar (type 9): one webinar,
    // many occurrences, one join link per registrant valid for all of them.
    const recurring = !!payload.recurrence;
    const body: Record<string, unknown> = {
      topic: payload.title,
      type: recurring
        ? ZOOM_RESOURCE_TYPE.RECURRING_FIXED_WEBINAR
        : ZOOM_RESOURCE_TYPE.SCHEDULED_WEBINAR,
      start_time: this.toZoomStartTime(payload.startAt),
      timezone: ZOOM_DEFAULTS.TIMEZONE,
      duration: payload.duration,
      password: payload.password ?? undefined,
      settings: {
        panelists_video: true,
        allow_multiple_devices: true,
        // Gated by ZOOM_MANUAL_APPROVAL_REGISTRANTS — see its doc comment. When
        // on, every registrant OUR backend adds auto-approves itself right
        // after creation (see WebinarService.addParticipant); only a
        // registrant added through some OTHER path (e.g. Zoom's own
        // registration_url) sits `pending`.
        approval_type: ZOOM_MANUAL_APPROVAL_REGISTRANTS
          ? ZOOM_APPROVAL_TYPE.MANUAL
          : ZOOM_APPROVAL_TYPE.AUTOMATIC,
        // Every attendee/panelist joins muted; they unmute themselves as needed.
        mute_upon_entry: true,
        // Recurring webinars default this to whatever the Zoom account has
        // configured; pin it explicitly so registering once always covers every
        // occurrence instead of depending on that account default.
        ...(recurring
          ? { registration_type: ZOOM_REGISTRATION_TYPE.REGISTER_ONCE_ATTEND_ANY }
          : {}),
        // The platform is the sole sender of join links, so silence every Zoom-sent
        // email on every webinar. Applied directly here (not via a template), so no
        // template is required to get "no communication" behaviour.
        ...ZOOM_NO_COMMS_WEBINAR_SETTINGS,
      },
    };
    if (recurring) {
      body.recurrence = this.toZoomRecurrence(payload.recurrence as ZoomRecurrence);
    }
    return this.request('POST', ZOOM_API_PATHS.USER_WEBINARS(hostEmail), { body });
  }

  /** Maps our camelCase recurrence to Zoom's snake_case `recurrence` body. */
  private toZoomRecurrence(recurrence: ZoomRecurrence): Record<string, unknown> {
    const body: Record<string, unknown> = {
      type: recurrence.type,
      repeat_interval: recurrence.repeatInterval ?? 1,
    };
    if (recurrence.weeklyDays !== undefined) body.weekly_days = recurrence.weeklyDays;
    // Zoom rejects a recurrence carrying both end_times and end_date_time; prefer
    // an explicit occurrence count, else an end date.
    if (recurrence.endTimes !== undefined) body.end_times = recurrence.endTimes;
    else if (recurrence.endDateTime !== undefined) body.end_date_time = recurrence.endDateTime;
    return body;
  }

  /** Existing webinar templates under the host account (each `{ id, name }`). */
  async listWebinarTemplates(hostEmail: string): Promise<ZoomApiResponse[]> {
    const response = await this.request<ZoomApiResponse>(
      'GET',
      ZOOM_API_PATHS.USER_WEBINAR_TEMPLATES(hostEmail),
    );
    return (response?.templates as ZoomApiResponse[]) ?? [];
  }

  /** Existing meeting templates under the host account (each `{ id, name }`). */
  async listMeetingTemplates(hostEmail: string): Promise<ZoomApiResponse[]> {
    const response = await this.request<ZoomApiResponse>(
      'GET',
      ZOOM_API_PATHS.USER_MEETING_TEMPLATES(hostEmail),
    );
    return (response?.templates as ZoomApiResponse[]) ?? [];
  }

  async updateWebinar(webinarIdExt: string, payload: ZoomWebinarUpdatePayload): Promise<ZoomApiResponse> {
    const body: Record<string, unknown> = {};
    if (payload.title !== undefined) body.topic = payload.title;
    if (payload.startAt !== undefined) body.start_time = this.toZoomStartTime(payload.startAt);
    if (payload.duration !== undefined) body.duration = payload.duration;
    if (payload.password !== undefined) body.password = payload.password;
    return this.request('PATCH', ZOOM_API_PATHS.WEBINAR(webinarIdExt), { body });
  }

  /**
   * Moves a single occurrence of a recurring webinar to an exact date/time — a
   * recurrence rule only generates occurrences on its own cadence, so shared
   * ("same link") webinars pin each occurrence to its real program-session date.
   * `startAt` should be an unambiguous instant (UTC ISO with a trailing Z).
   */
  async updateWebinarOccurrence(
    webinarIdExt: string,
    occurrenceId: string,
    payload: { startAt?: string; duration?: number },
  ): Promise<ZoomApiResponse> {
    const body: Record<string, unknown> = {};
    if (payload.startAt !== undefined) body.start_time = this.toZoomStartTime(payload.startAt);
    if (payload.duration !== undefined) body.duration = payload.duration;
    return this.request('PATCH', ZOOM_API_PATHS.WEBINAR(webinarIdExt), {
      body,
      params: { occurrence_id: occurrenceId },
    });
  }

  async deleteWebinar(webinarIdExt: string): Promise<ZoomApiResponse> {
    // cancel_webinar_reminder: false — do NOT email panelists/registrants the
    // webinar cancellation. The platform is the sole sender of all comms.
    return this.request('DELETE', ZOOM_API_PATHS.WEBINAR(webinarIdExt), {
      params: { cancel_webinar_reminder: false },
    });
  }

  // ---------------------------------------------------------------------------
  // Meeting lifecycle
  // ---------------------------------------------------------------------------

  async createMeeting(payload: ZoomMeetingCreatePayload, hostEmail: string): Promise<ZoomApiResponse> {
    // Gated by ZOOM_MANUAL_APPROVAL_REGISTRANTS — same reasoning as createWebinar above.
    const approvalType = !payload.requireRegistration
      ? ZOOM_APPROVAL_TYPE.NONE
      : ZOOM_MANUAL_APPROVAL_REGISTRANTS
        ? ZOOM_APPROVAL_TYPE.MANUAL
        : ZOOM_APPROVAL_TYPE.AUTOMATIC;
    // A recurrence rule promotes this to a recurring meeting (type 8): one meeting,
    // many occurrences, one join link (per-registrant or shared) across them.
    const recurring = !!payload.recurrence;
    const body: Record<string, unknown> = {
      topic: payload.title,
      type: recurring
        ? ZOOM_RESOURCE_TYPE.RECURRING_FIXED_MEETING
        : ZOOM_RESOURCE_TYPE.SCHEDULED_MEETING,
      start_time: this.toZoomStartTime(payload.startAt),
      timezone: ZOOM_DEFAULTS.TIMEZONE,
      duration: payload.duration,
      password: payload.password ?? undefined,
      settings: {
        approval_type: approvalType,
        join_before_host: false,
        waiting_room: true,
        // Every participant joins muted; they unmute themselves as needed.
        mute_upon_entry: true,
        // Recurring, registration-based meetings default this to whatever the
        // Zoom account has configured; pin it explicitly so registering once
        // always covers every occurrence instead of depending on that default.
        ...(recurring && payload.requireRegistration
          ? { registration_type: ZOOM_REGISTRATION_TYPE.REGISTER_ONCE_ATTEND_ANY }
          : {}),
        // The platform is the sole sender of join links, so silence every Zoom-sent
        // email on every meeting. Applied directly here (not via a template), so no
        // template is required to get "no communication" behaviour.
        ...ZOOM_NO_COMMS_MEETING_SETTINGS,
      },
    };
    if (recurring) {
      body.recurrence = this.toZoomRecurrence(payload.recurrence as ZoomRecurrence);
    }
    return this.request('POST', ZOOM_API_PATHS.USER_MEETINGS(hostEmail), { body });
  }

  /**
   * Moves a single occurrence of a recurring meeting to an exact date/time —
   * mirrors {@link updateWebinarOccurrence} for the meeting endpoint. `startAt`
   * should be an unambiguous instant (UTC ISO with a trailing Z).
   */
  async updateMeetingOccurrence(
    meetingIdExt: string,
    occurrenceId: string,
    payload: { startAt?: string; duration?: number },
  ): Promise<ZoomApiResponse> {
    const body: Record<string, unknown> = {};
    if (payload.startAt !== undefined) body.start_time = this.toZoomStartTime(payload.startAt);
    if (payload.duration !== undefined) body.duration = payload.duration;
    return this.request('PATCH', ZOOM_API_PATHS.MEETING(meetingIdExt), {
      body,
      // schedule_for_reminder: false — never email the host on the occurrence
      // pinning that runs while provisioning a "same link" recurring meeting.
      params: { occurrence_id: occurrenceId, schedule_for_reminder: false },
    });
  }

  async updateMeeting(meetingIdExt: string, payload: ZoomMeetingUpdatePayload): Promise<ZoomApiResponse> {
    const body: Record<string, unknown> = {};
    if (payload.title !== undefined) body.topic = payload.title;
    if (payload.startAt !== undefined) body.start_time = this.toZoomStartTime(payload.startAt);
    if (payload.duration !== undefined) body.duration = payload.duration;
    if (payload.password !== undefined) body.password = payload.password;
    // Zoom defaults to emailing the host/alt-host on every meeting edit; the
    // platform is the sole sender of join links, so suppress it (mirrors the
    // delete path). Applies to plain edits and to the occurrence-pinning that
    // runs during "same link" recurring creation.
    return this.request('PATCH', ZOOM_API_PATHS.MEETING(meetingIdExt), {
      body,
      params: { schedule_for_reminder: false },
    });
  }

  async deleteMeeting(meetingIdExt: string): Promise<ZoomApiResponse> {
    // schedule_for_reminder: false — no host cancellation email.
    // cancel_meeting_reminder: false — no registrant "meeting cancelled" email.
    // The platform is the sole sender, so Zoom stays silent on delete too.
    return this.request('DELETE', ZOOM_API_PATHS.MEETING(meetingIdExt), {
      params: { schedule_for_reminder: false, cancel_meeting_reminder: false },
    });
  }

  async addMeetingRegistrant(
    meetingIdExt: string,
    registrant: { firstName: string; lastName?: string; email: string },
  ): Promise<ZoomApiResponse> {
    return this.request('POST', ZOOM_API_PATHS.MEETING_REGISTRANTS(meetingIdExt), {
      body: {
        first_name: registrant.firstName,
        last_name: registrant.lastName,
        email: registrant.email,
      },
    });
  }

  async removeMeetingRegistrant(meetingIdExt: string, registrantIdExt: string): Promise<ZoomApiResponse> {
    return this.request(
      'DELETE',
      `${ZOOM_API_PATHS.MEETING_REGISTRANTS(meetingIdExt)}/${registrantIdExt}`,
    );
  }

  /**
   * Cancels or re-approves a meeting registrant without deleting their record —
   * unlike {@link removeMeetingRegistrant}, Zoom keeps the registrant on file so
   * `APPROVE` can restore the same join link later.
   */
  async updateMeetingRegistrantStatus(
    meetingIdExt: string,
    action: ZoomRegistrantStatusAction,
    registrant: { id: string; email?: string | null },
  ): Promise<void> {
    await this.request('PUT', ZOOM_API_PATHS.MEETING_REGISTRANTS_STATUS(meetingIdExt), {
      body: {
        action,
        registrants: [{ id: registrant.id, email: registrant.email ?? undefined }],
      },
    });
  }

  async fetchMeetingRegistrants(meetingIdExt: string): Promise<ZoomApiResponse[]> {
    return this.fetchAllPages(ZOOM_API_PATHS.MEETING_REGISTRANTS(meetingIdExt), ZOOM_COLLECTION_KEY.REGISTRANTS, {
      page_size: ZOOM_PAGINATION.DEFAULT_PAGE_SIZE,
    });
  }

  async fetchMeetingParticipants(
    meetingIdExt: string,
    occurrenceId?: string,
  ): Promise<ZoomApiResponse[]> {
    return this.fetchAllPages(
      ZOOM_API_PATHS.PAST_MEETING_PARTICIPANTS(meetingIdExt),
      ZOOM_COLLECTION_KEY.PARTICIPANTS,
      this.withOccurrence({ page_size: ZOOM_PAGINATION.DEFAULT_PAGE_SIZE }, occurrenceId),
    );
  }

  // ---------------------------------------------------------------------------
  // Registrants / panelists
  // ---------------------------------------------------------------------------

  async addRegistrant(
    webinarIdExt: string,
    registrant: { firstName: string; lastName?: string; email: string },
  ): Promise<ZoomApiResponse> {
    return this.request('POST', ZOOM_API_PATHS.WEBINAR_REGISTRANTS(webinarIdExt), {
      body: {
        first_name: registrant.firstName,
        last_name: registrant.lastName,
        email: registrant.email,
      },
    });
  }

  async addPanelist(
    webinarIdExt: string,
    panelist: { name: string; email: string },
  ): Promise<ZoomApiResponse> {
    return this.request('POST', ZOOM_API_PATHS.WEBINAR_PANELISTS(webinarIdExt), {
      body: { panelists: [{ name: panelist.name, email: panelist.email }] },
    });
  }

  /**
   * Add many panelists in a single request. Zoom's panelist endpoint accepts the
   * whole `panelists` array at once, so this is one API call for N panelists
   * instead of N calls. The response does NOT include per-panelist join URLs —
   * fetch them afterwards via {@link fetchPanelists}.
   */
  async addPanelistsBatch(
    webinarIdExt: string,
    panelists: { name: string; email: string }[],
  ): Promise<ZoomApiResponse> {
    return this.request('POST', ZOOM_API_PATHS.WEBINAR_PANELISTS(webinarIdExt), {
      body: { panelists },
    });
  }

  async removeUser(
    webinarIdExt: string,
    endpoint: ZoomUserEndpoint,
    registrationIdExt: string,
  ): Promise<ZoomApiResponse> {
    return this.request(
      'DELETE',
      `${ZOOM_API_PATHS.WEBINAR(webinarIdExt)}/${endpoint}/${registrationIdExt}`,
    );
  }

  /**
   * Cancels or re-approves a webinar registrant without deleting their record —
   * unlike {@link removeUser}, Zoom keeps the registrant on file so `APPROVE`
   * can restore the same join link later. Registrants only — panelists have no
   * status concept in Zoom's API.
   */
  async updateWebinarRegistrantStatus(
    webinarIdExt: string,
    action: ZoomRegistrantStatusAction,
    registrant: { id: string; email?: string | null },
  ): Promise<void> {
    await this.request('PUT', ZOOM_API_PATHS.WEBINAR_REGISTRANTS_STATUS(webinarIdExt), {
      body: {
        action,
        registrants: [{ id: registrant.id, email: registrant.email ?? undefined }],
      },
    });
  }

  // ---------------------------------------------------------------------------
  // Paginated reads
  // ---------------------------------------------------------------------------

  async fetchPanelists(webinarIdExt: string): Promise<ZoomApiResponse[]> {
    return this.fetchAllPages(ZOOM_API_PATHS.WEBINAR_PANELISTS(webinarIdExt), ZOOM_COLLECTION_KEY.PANELISTS);
  }

  async fetchRegistrants(webinarIdExt: string): Promise<ZoomApiResponse[]> {
    return this.fetchAllPages(ZOOM_API_PATHS.WEBINAR_REGISTRANTS(webinarIdExt), ZOOM_COLLECTION_KEY.REGISTRANTS, {
      page_size: ZOOM_PAGINATION.DEFAULT_PAGE_SIZE,
    });
  }

  /**
   * Participants of a past webinar. For a recurring ("same link") webinar, pass
   * the `occurrenceId` to scope the report to the single occurrence backing one
   * program session; omit it for a one-off webinar.
   */
  async fetchParticipants(webinarIdExt: string, occurrenceId?: string): Promise<ZoomApiResponse[]> {
    return this.fetchAllPages(
      ZOOM_API_PATHS.PAST_WEBINAR_PARTICIPANTS(webinarIdExt),
      ZOOM_COLLECTION_KEY.PARTICIPANTS,
      this.withOccurrence({ page_size: ZOOM_PAGINATION.DEFAULT_PAGE_SIZE }, occurrenceId),
    );
  }

  async fetchAbsentees(webinarIdExt: string, occurrenceId?: string): Promise<ZoomApiResponse[]> {
    return this.fetchAllPages(
      ZOOM_API_PATHS.PAST_WEBINAR_ABSENTEES(webinarIdExt),
      ZOOM_COLLECTION_KEY.REGISTRANTS,
      this.withOccurrence({ page_size: ZOOM_PAGINATION.DEFAULT_PAGE_SIZE }, occurrenceId),
    );
  }

  /** Adds Zoom's `occurrence_id` query param when scoping a recurring webinar report. */
  private withOccurrence(
    params: Record<string, unknown>,
    occurrenceId?: string,
  ): Record<string, unknown> {
    return occurrenceId ? { ...params, occurrence_id: occurrenceId } : params;
  }

  /**
   * Walks all pages of a Zoom list endpoint, concatenating the array under
   * `collectionKey`. Past-webinar endpoints 404 (code 3001) before data exists,
   * which is treated as an empty result rather than an error.
   */
  private async fetchAllPages(
    path: string,
    collectionKey: string,
    baseParams: Record<string, unknown> = {},
  ): Promise<ZoomApiResponse[]> {
    const all: ZoomApiResponse[] = [];
    let nextPageToken = '';
    try {
      do {
        const params: Record<string, unknown> = { ...baseParams };
        if (nextPageToken) params.next_page_token = nextPageToken;
        const data = await this.request<ZoomApiResponse>('GET', path, { params });
        const collection = data?.[collectionKey];
        if (Array.isArray(collection)) {
          all.push(...collection);
        }
        nextPageToken = data?.next_page_token ?? '';
      } while (nextPageToken);
      return all;
    } catch (error: any) {
      // `request()` wraps the axios error as `exception.error`; unwrap to read the
      // real HTTP status / Zoom error code (a past-* endpoint 404s with code 3001
      // until the session has occurred — treated as an empty result, not an error).
      const axiosError = error?.error ?? error;
      const httpStatus = axiosError?.response?.status;
      const zoomCode = axiosError?.response?.data?.code;
      if (
        httpStatus === ZOOM_HTTP_STATUS.NOT_FOUND ||
        zoomCode === ZOOM_API_CODE.PAST_DATA_NOT_READY
      ) {
        this.logger.log(ZOOM_LOG.PAST_DATA_UNAVAILABLE, { path });
        return [];
      }
      throw error;
    }
  }
}
