import { ConfigService } from '@nestjs/config';
import { ProgramSession, OnlineSession } from 'src/common/entities';
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 { PROGRAM_TYPE_KEYS } from 'src/common/constants/string-constants';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';
import InifniConflictException from 'src/common/exceptions/infini-conflict-exception';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import {
  CreateSessionInput,
  CreateSharedSessionInput,
  SessionRecurrenceInput,
} from 'src/common/interfaces/online-session.interface';
import { SessionLaunchMode } from 'src/common/enum/session-launch-mode.enum';
import { SessionLinkModeEnum } from 'src/common/enum/session-link-mode.enum';
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 { ZoomWebinarRepository } from '../repositories/zoom-webinar.repository';
import { ZoomApiService } from '../services/zoom-api.service';
import {
  ZOOM_ENV_KEYS,
  ZOOM_LOG,
  ZOOM_RECURRENCE_TYPE,
  ZOOM_CANCEL_INSTEAD_OF_DELETE_REGISTRANT,
  ZOOM_MANUAL_APPROVAL_REGISTRANTS,
} from 'src/common/constants/zoom.constants';
import { ZoomApiResponse, ZoomRecurrence, ZoomOccurrence } from '../interfaces/zoom-api.interface';

/**
 * Shared plumbing for the webinar/meeting handlers: session lookup, host-email
 * resolution, common field copying, and the create -> persist -> rollback
 * skeleton that guarantees we never leave an orphaned Zoom resource behind.
 */
export abstract class ZoomSessionBase {
  constructor(
    protected readonly repo: ZoomWebinarRepository,
    protected readonly zoomApi: ZoomApiService,
    protected readonly config: ConfigService,
    protected readonly logger: AppLoggerService,
  ) {}

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

  /**
   * Guards against double-provisioning: a program session may back only one
   * online session at a time. If a Zoom resource already exists, the caller must
   * delete it before creating a new one — otherwise the old Zoom resource (and
   * any links/registrants issued for it) would be silently orphaned.
   */
  protected ensureNotProvisioned(session: ProgramSession): void {
    const existingExtId = session.onlineSession?.externalId;
    if (existingExtId) {
      throw new InifniConflictException(
        ERROR_CODES.ONLINE_SESSION_ALREADY_EXISTS,
        null,
        null,
        session.id.toString(),
      );
    }
  }

  protected resolveHostEmail(provided?: string): string {
    return provided ?? this.config.get<string>(ZOOM_ENV_KEYS.ADMIN_EMAIL) ?? '';
  }

  /**
   * Prefixes the Zoom meeting/webinar title with the session's position within
   * its program (e.g. "Session - 1 Leadership Program"), so the title alone
   * tells hosts/attendees which session a given Zoom resource belongs to. Uses
   * the caller-supplied title when given, otherwise the program name (not the
   * session name) as the base.
   *
   * PT_TAT programs use a fixed "{program name} - Session {N}" format instead
   * (e.g. "TAT Online - Session 1"), always derived from the program name —
   * any caller-supplied title is ignored for this program type.
   */
  protected buildSessionTitle(session: ProgramSession, explicitTitle?: string): string {
    this.logger.log("Zoom title", {
      sessionId: session.id,
      displayOrder: session.displayOrder,
      explicitTitle,
      programName: session.program?.name,
      programType: session.program?.type?.key,
    });
    if (session.program?.type?.key === PROGRAM_TYPE_KEYS.TAT) {
      return `TAT Online - Session ${session.displayOrder}`;
    }
    const baseTitle = explicitTitle ?? session.program?.name ?? session.name;
    return `Session - ${session.displayOrder} ${baseTitle}`;
  }

  /**
   * Whether registrant removal cancels (default) or hard-deletes on Zoom. A
   * cancelled registrant stays on file so a later reactivation can re-approve
   * the same registrant/join link; a deleted one cannot be re-approved, so
   * `approveParticipant` must register fresh whenever this is `false`.
   */
  protected cancelInsteadOfDeleteRegistrant(): boolean {
    return ZOOM_CANCEL_INSTEAD_OF_DELETE_REGISTRANT;
  }

  /**
   * Whether the Zoom resource requires manual registrant approval. Gates BOTH
   * the `approval_type` sent on create (see `ZoomApiService.createWebinar`/
   * `createMeeting`) and the immediate approve call `addParticipant` issues for
   * `autoApprove` callers — kept as one flag so the two never drift apart
   * (approving an already-auto-approved registrant would error on Zoom's side).
   */
  protected manualApprovalEnabled(): boolean {
    return ZOOM_MANUAL_APPROVAL_REGISTRANTS;
  }

  /** Provisions a single, non-recurring resource for one program session. */
  abstract create(input: CreateSessionInput): Promise<ProgramSession>;

  /**
   * Derives a session's Zoom duration in minutes from its own `startsAt`/`endsAt`
   * instead of requiring the caller to supply one.
   */
  protected deriveDurationMinutes(session: ProgramSession): number {
    const minutes =
      session.startsAt && session.endsAt
        ? Math.round((session.endsAt.getTime() - session.startsAt.getTime()) / 60000)
        : 0;
    if (minutes < 1) {
      throw new InifniBadRequestException(
        ERROR_CODES.ONLINE_SESSION_SHARED_MISSING_DURATION_INPUT,
        null,
        null,
        session.id.toString(),
      );
    }
    return minutes;
  }

  // ---------------------------------------------------------------------------
  // Shared ("same link") recurring resource — common orchestration.
  //
  // Both webinars and meetings support the shared model: ONE recurring Zoom
  // resource backs several program sessions, so a registrant registered once
  // holds a single join link valid for every session. The orchestration
  // (validate → create recurring resource → pin occurrences to the real session
  // dates → persist one row per session sharing the external id) is identical for
  // both types; only the per-type Zoom calls differ, provided by the hooks below.
  // ---------------------------------------------------------------------------

  /** Session type this handler provisions (webinar or meeting). */
  protected abstract sharedOnlineType(): OnlineTypeEnum.WEBINAR | OnlineTypeEnum.MEETING;

  /**
   * Creates the ONE shared Zoom resource (webinar/meeting) for the group.
   * `recurrence` is set for a recurring group and `undefined` for a non-recurring
   * one (a plain scheduled resource).
   */
  protected abstract createSharedResource(
    input: CreateSharedSessionInput,
    anchor: ProgramSession,
    recurrence: ZoomRecurrence | undefined,
    hostEmail: string,
    duration: number,
  ): Promise<ZoomApiResponse>;

  /** Moves one occurrence to an exact instant (UTC ISO start_time). */
  protected abstract updateOccurrence(
    externalId: string,
    occurrenceId: string,
    payload: { startAt: string; duration: number },
  ): Promise<void>;

  /** Deletes the recurring resource on the provider (rollback / last-session removal). */
  protected abstract deleteSharedResource(externalId: string): Promise<void>;

  /**
   * Provisions ONE recurring resource shared across several program sessions and
   * writes one online-session row per session (all sharing its external id, each
   * pinned to its Zoom occurrence). All rows persist in one transaction; the
   * resource is rolled back if that fails.
   */
  async createSharedGroup(input: CreateSharedSessionInput): Promise<ProgramSession[]> {
    const ids = Array.from(new Set(input.programSessionIds ?? []));
    if (ids.length < 1) {
      throw new InifniBadRequestException(
        ERROR_CODES.ONLINE_SESSION_SHARED_MIN_SESSIONS,
        null,
        null,
        String(ids.length),
      );
    }

    const sessions = await this.repo.findByIds(ids);
    if (sessions.length !== ids.length) {
      const found = new Set(sessions.map((s) => s.id));
      throw new InifniNotFoundException(
        ERROR_CODES.ZOOM_WEBINAR_NOTFOUND,
        null,
        null,
        ids.filter((id) => !found.has(id)).join(', '),
      );
    }

    const programIds = Array.from(new Set(sessions.map((s) => s.programId)));
    if (programIds.length !== 1 || programIds[0] == null) {
      throw new InifniBadRequestException(
        ERROR_CODES.ONLINE_SESSION_SHARED_CROSS_PROGRAM,
        null,
        null,
        programIds.join(', '),
      );
    }
    sessions.forEach((s) => this.ensureNotProvisioned(s));

    const ordered = [...sessions].sort(
      (a, b) => (a.startsAt?.getTime() ?? 0) - (b.startsAt?.getTime() ?? 0),
    );
    const anchor = ordered[0];
    if (!anchor.startsAt) {
      throw new InifniBadRequestException(
        ERROR_CODES.ONLINE_SESSION_SHARED_MISSING_START,
        null,
        null,
        anchor.id.toString(),
      );
    }

    const onlineType = this.sharedOnlineType();
    return input.linkType === SessionLinkModeEnum.PER_SESSION
      ? this.createIndividualLinks(input, ordered, onlineType)
      : this.createSharedLink(input, ordered, anchor, onlineType);
  }

  /**
   * `PER_SESSION` ("individual"): each session gets its own distinct,
   * non-recurring resource — reuses the plain single-session {@link create} hook
   * per session instead of one resource shared across the group.
   */
  private async createIndividualLinks(
    input: CreateSharedSessionInput,
    ordered: ProgramSession[],
    onlineType: OnlineTypeEnum.WEBINAR | OnlineTypeEnum.MEETING,
  ): Promise<ProgramSession[]> {
    const created: ProgramSession[] = [];
    for (const session of ordered) {
      const duration = this.deriveDurationMinutes(session);
      created.push(
        await this.create({
          provider: input.provider,
          onlineType,
          programSessionId: session.id,
          title: input.title,
          startAt: (session.startsAt as Date).toISOString(),
          duration,
          password: input.password,
          hostEmail: input.hostEmail,
          requireRegistration: input.requireRegistration,
          launchMode: input.launchMode,
          joinOpensMinutesBefore: input.joinOpensMinutesBefore,
          hostStartOpensMinutesBefore: input.hostStartOpensMinutesBefore,
          status: input.status,
          actorUserId: input.actorUserId,
        }),
      );
    }
    return created;
  }

  /**
   * `SHARED` ("group"): ONE recurring resource backs every session, all sharing
   * its external id and join link — the original "same link" orchestration.
   */
  private async createSharedLink(
    input: CreateSharedSessionInput,
    ordered: ProgramSession[],
    anchor: ProgramSession,
    onlineType: OnlineTypeEnum.WEBINAR | OnlineTypeEnum.MEETING,
  ): Promise<ProgramSession[]> {
    const hostEmail = this.resolveHostEmail(input.hostEmail);
    // Recurring whenever the group spans more than one session; a single-session
    // "shared" call creates one plain resource (no per-day occurrences).
    const recurring = ordered.length > 1;
    const recurrence = recurring ? this.buildRecurrence(input.recurrence, ordered) : undefined;
    const duration = this.deriveDurationMinutes(anchor);
    const zoomResponse = await this.createSharedResource(input, anchor, recurrence, hostEmail, duration);
    const extId = zoomResponse?.id != null ? String(zoomResponse.id) : null;
    if (!extId) {
      throw new InifniBadRequestException(ERROR_CODES.ZOOM_API_ERROR, null, null, 'missing resource id');
    }
    const occurrenceBySession = recurring
      ? await this.alignOccurrencesToSessions(extId, ordered, zoomResponse.occurrences ?? [], duration)
      : new Map<number, string>();
    // Meetings decide per-user vs one shared link; webinars always use registrants.
    const requireRegistration =
      onlineType === OnlineTypeEnum.MEETING ? input.requireRegistration !== false : null;

    for (const session of ordered) {
      session.onlineType = onlineType;
      session.onlineSession = new OnlineSession({
        programId: session.programId,
        programSessionId: session.id,
        type: onlineType,
        provider: SessionProviderType.ZOOM,
        externalId: extId,
        joinUrl: zoomResponse?.join_url ?? '',
        password: zoomResponse?.password ?? input.password ?? '',
        panelistUrl: '',
        registrationUrl: zoomResponse?.registration_url ?? '',
        requireRegistration,
        hostEmail,
        startUrl: zoomResponse?.start_url ?? null,
        status: input.status ?? ZoomWebinarStatus.DRAFT,
        linkMode: SessionLinkModeEnum.SHARED,
        occurrenceId: occurrenceBySession.get(session.id) ?? null,
        launchMode: input.launchMode ?? SessionLaunchMode.SDK,
        joinOpensMinutesBefore: input.joinOpensMinutesBefore ?? null,
        hostStartOpensMinutesBefore: input.hostStartOpensMinutesBefore ?? null,
        createdBy: input.actorUserId ?? null,
        updatedBy: input.actorUserId ?? null,
      });
      if (input.actorUserId) session.updatedBy = input.actorUserId;
    }

    try {
      const saved = await this.repo.saveSessionsInTransaction(ordered);
      this.logger.log(ZOOM_LOG.WEBINAR_CREATED, {
        ext: extId,
        shared: true,
        onlineType,
        sessions: saved.map((s) => s.id),
      });
      return saved;
    } catch (error) {
      try {
        await this.deleteSharedResource(extId);
      } catch (cleanupError) {
        this.logger.error(ZOOM_LOG.ROLLBACK_FAILED, (cleanupError as Error)?.stack, { extId });
      }
      this.logger.error(ZOOM_LOG.SESSION_PERSIST_FAILED, (error as Error)?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_SAVE_FAILED, error);
    }
  }

  /**
   * Builds the Zoom recurrence rule that seeds the occurrences.
   *
   * The occurrence COUNT is always forced to the number of sessions
   * (`endTimes = ordered.length`), because every occurrence is afterwards pinned to
   * a real session date (see {@link alignOccurrencesToSessions}) and we need exactly
   * one per session. The pattern (frequency / weekly days) is only a seed and does
   * NOT change the final dates, so `recurrence` is optional — omit it and a weekly
   * rule is derived from the sessions' weekdays. Any caller-supplied end
   * (`endTimes`/`endDateTime`) is intentionally ignored so it can never disagree
   * with the session count.
   */
  protected buildRecurrence(
    input: SessionRecurrenceInput | undefined,
    ordered: ProgramSession[],
  ): ZoomRecurrence {
    const endTimes = ordered.length;
    if (input) {
      const type =
        input.frequency === 'daily'
          ? ZOOM_RECURRENCE_TYPE.DAILY
          : input.frequency === 'monthly'
            ? ZOOM_RECURRENCE_TYPE.MONTHLY
            : ZOOM_RECURRENCE_TYPE.WEEKLY;
      return {
        type,
        repeatInterval: input.repeatInterval ?? 1,
        weeklyDays: input.weeklyDays ?? this.deriveWeeklyDays(ordered),
        endTimes,
      };
    }
    return {
      type: ZOOM_RECURRENCE_TYPE.WEEKLY,
      repeatInterval: 1,
      weeklyDays: this.deriveWeeklyDays(ordered),
      endTimes,
    };
  }

  /** Distinct Zoom weekday numbers (1=Sun … 7=Sat) across the sessions' start dates. */
  private deriveWeeklyDays(ordered: ProgramSession[]): string {
    return Array.from(
      new Set(ordered.filter((s) => s.startsAt).map((s) => (s.startsAt as Date).getDay() + 1)),
    )
      .sort((a, b) => a - b)
      .join(',');
  }

  /**
   * Pins each generated occurrence to its real program-session date. A recurrence
   * rule only produces occurrences on its own cadence, so we pair the time-sorted
   * occurrences with the time-sorted sessions 1:1 and PATCH each occurrence's
   * start_time to the exact session instant (UTC). Returns session id → occurrence
   * id for attendance scoping. A failed realign is logged but keeps the (misdated)
   * occurrence mapped rather than aborting provisioning.
   */
  protected async alignOccurrencesToSessions(
    externalId: string,
    sessions: ProgramSession[],
    occurrences: ZoomOccurrence[],
    duration: number,
  ): Promise<Map<number, string>> {
    const map = new Map<number, string>();
    const sortedOccurrences = occurrences
      .filter((o) => o.occurrence_id && o.start_time)
      .map((o) => ({ id: o.occurrence_id as string, time: new Date(o.start_time as string).getTime() }))
      .filter((o) => !Number.isNaN(o.time))
      .sort((a, b) => a.time - b.time);

    if (sortedOccurrences.length < sessions.length) {
      this.logger.warn('Fewer Zoom occurrences than shared sessions; some sessions unmapped', {
        externalId,
        occurrences: sortedOccurrences.length,
        sessions: sessions.length,
      });
    }

    // `sessions` is already start-time ascending; pair by index.
    for (let i = 0; i < sessions.length; i++) {
      const session = sessions[i];
      const occurrence = sortedOccurrences[i];
      if (!occurrence) continue;
      map.set(session.id, occurrence.id);
      if (!session.startsAt) continue;
      if (occurrence.time === session.startsAt.getTime()) continue;
      const startAt = session.startsAt.toISOString();
      try {
        await this.updateOccurrence(externalId, occurrence.id, { startAt, duration });
      } catch (error) {
        this.logger.error('Failed to align occurrence to session date', (error as Error)?.stack, {
          externalId,
          occurrenceId: occurrence.id,
          sessionId: session.id,
          startAt,
        });
      }
    }
    return map;
  }

  /** Registration-window, launch, and audit fields applied to both resource types. */
  protected applyCommonFields(session: ProgramSession, input: CreateSessionInput): void {
    if (input.registrationStartsAt)
      session.registrationStartsAt = new Date(input.registrationStartsAt);
    if (input.registrationEndsAt) session.registrationEndsAt = new Date(input.registrationEndsAt);
    if (session.onlineSession) {
      session.onlineSession.launchMode = input.launchMode ?? SessionLaunchMode.SDK;
      if (input.joinOpensMinutesBefore !== undefined)
        session.onlineSession.joinOpensMinutesBefore = input.joinOpensMinutesBefore;
      if (input.hostStartOpensMinutesBefore !== undefined)
        session.onlineSession.hostStartOpensMinutesBefore = input.hostStartOpensMinutesBefore;
    }
    if (input.actorUserId) session.updatedBy = input.actorUserId;
  }

  /**
   * Saves the session; if the DB write fails after a Zoom resource was created,
   * deletes that resource so nothing is left orphaned. Mirrors the original
   * ZoomWebinarService rollback behaviour.
   */
  protected async persistWithRollback(
    session: ProgramSession,
    extId: string | null,
    rollback: () => Promise<unknown>,
    logEvent: string,
  ): Promise<ProgramSession> {
    try {
      const saved = await this.repo.save(session);
      this.logger.log(logEvent, { id: saved.id, ext: extId });
      return saved;
    } catch (error) {
      if (extId) {
        try {
          await rollback();
        } catch (cleanupError) {
          this.logger.error(ZOOM_LOG.ROLLBACK_FAILED, cleanupError?.stack, {
            extId,
          });
        }
      }
      this.logger.error(ZOOM_LOG.SESSION_PERSIST_FAILED, error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_SAVE_FAILED, error);
    }
  }
}
