import { Injectable } from '@nestjs/common';
import { ProgramSession, User, ZoomGeneratedRegistrantLink } from 'src/common/entities';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { AppLoggerService } from 'src/common/services/logger.service';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { OnlineSessionRegistrationStatus } from 'src/common/enum/online-session-registration-status.enum';
import { GeneratedLinkSourceType } from 'src/common/enum/generated-link-source-type.enum';
import {
  DEFAULT_BATCH_SIZE,
  BULK_BATCH_DELAY_MS,
  PROVISIONED_SESSION_SCAN_LIMIT,
} from 'src/common/constants/zoom.constants';
import { UserRepository } from 'src/user/user.repository';
import { OnlineSessionService } from 'src/online-session/services/online-session.service';
import {
  ZoomGeneratedRegistrantLinkRepository,
  PaginatedGeneratedLinks,
} from '../repositories/zoom-generated-registrant-link.repository';
import { WebinarService } from '../sessions/webinar.service';
import { MeetingService } from '../sessions/meeting.service';
import { ZoomRole } from '../enums/zoom-role.enum';
import { ZoomSessionHandler, ZoomContactInfo, ZoomParticipantResult } from '../interfaces/zoom-session.interface';
import {
  buildZoomUserRegistrantEmail,
  buildZoomPlaceholderRegistrantEmail,
} from '../utils/zoom-registrant-email.util';
import { splitZoomContactName } from '../utils/zoom-registrant-name.util';
import { GenerateZoomGeneralLinksV1Dto } from '../dto/generate-zoom-general-links-v1.dto';
import { GenerateZoomGeneralLinksResultV1, GeneratedZoomLinkV1 } from '../dto/generated-zoom-link-v1.dto';

/** `roleKey` value stamped on PLACEHOLDER rows, which have no real role of their own. */
const SYSTEM_ROLE_KEY = 'SYSTEM';

/** Same fallback the auth guards use for a role with no priority set — lowest priority, never wins a tie. */
const DEFAULT_ROLE_PRIORITY = 999;

/** One recipient queued for Zoom registration, before we know the outcome. Session-independent — the same set is registered against every target session. */
interface PendingRecipient {
  sourceType: GeneratedLinkSourceType;
  displayName: string;
  registrantEmail: string;
  /** Zoom requires both — split off `displayName`, see `splitZoomContactName`. */
  zoomFirstName: string;
  zoomLastName: string;
  userId?: number;
  roleKey?: string;
  sourceEmail?: string;
  sourceMobile?: string;
  batchName?: string;
  sequenceNumber?: number;
}

/**
 * Result of one Zoom call for a shared-resource group, keyed by recipient — a plain `.message`
 * capture rather than an `Error`, since Zoom API failures throw `InifniBadRequestException`,
 * which does not extend the built-in `Error` class.
 */
type ZoomCallOutcome = { ok: true; result: ZoomParticipantResult } | { ok: false; message: string };

/**
 * Orchestrates the two ways to pre-generate a Zoom registrant join link for people with no
 * `ProgramRegistration` row of their own:
 * - ROLE: every real user holding one of the caller's role keys.
 * - PLACEHOLDER: `count` generic slots named "<name> 1".."<name> <count>".
 *
 * Scope is `programId` (every Zoom-provisioned session of that program) XOR `sessionId` (one
 * session only) — same convention as `BulkRegisterZoomDto`/`BulkRegisterParticipantsDto`. Sessions
 * that share one Zoom resource (a recurring/"same link" group, same `onlineSession.externalId`)
 * are registered with Zoom exactly once per recipient — Zoom registers a participant per
 * meeting/webinar, not per occurrence — but still get one persisted link row per session so
 * per-session listing works, all reusing the single call's `joinUrl`/registrant id.
 *
 * Reuses the same low-level per-registrant Zoom call the real seeker-registration flow uses
 * (`WebinarService`/`MeetingService.addParticipant`) and the same paced-batch convention
 * `ZoomBulkRegistrationService` uses for real registrants, so this doesn't trip Zoom's
 * per-resource rate limit. Results are persisted in `zoom_generated_registrant_link`;
 * already-registered (recipient, session) pairs from a prior identical call are skipped rather
 * than re-hitting Zoom or duplicating rows.
 */
@Injectable()
export class ZoomGeneratedLinkService {
  constructor(
    private readonly onlineSessionService: OnlineSessionService,
    private readonly linkRepository: ZoomGeneratedRegistrantLinkRepository,
    private readonly userRepository: UserRepository,
    private readonly webinar: WebinarService,
    private readonly meeting: MeetingService,
    private readonly logger: AppLoggerService,
  ) {}

  async generate(
    dto: GenerateZoomGeneralLinksV1Dto,
    actorUserId?: number,
  ): Promise<GenerateZoomGeneralLinksResultV1> {
    const sessions = await this.resolveTargetSessions(dto);
    // Every target session belongs to the same program — `dto.programId` when scoped that way,
    // or the one resolved session's own programId when scoped by `sessionId`.
    const programId = dto.programId ?? sessions[0].programId;
    const recipients = await this.resolvePendingRecipients(dto, programId);

    // Sessions of a shared ("same link") group hit the SAME Zoom resource — group by
    // `externalId` so each recipient is registered with Zoom once per shared resource, not
    // once per sibling session (Zoom registers/removes a registrant per meeting/webinar, not
    // per occurrence — see ZoomRegistrationService's identical dedupeKey convention).
    const groups = this.groupBySharedResource(sessions);

    let skippedExisting = 0;
    const saved: ZoomGeneratedRegistrantLink[] = [];
    for (let index = 0; index < groups.length; index++) {
      const { rows, skippedExisting: groupSkipped } = await this.registerGroup(
        groups[index],
        recipients,
        actorUserId,
      );
      skippedExisting += groupSkipped;
      saved.push(...(await this.linkRepository.saveMany(rows)));
      // Pace between groups too, same reasoning as within a group's batches.
      if (rows.length > 0 && index < groups.length - 1) {
        await this.delay(BULK_BATCH_DELAY_MS);
      }
    }

    return {
      created: saved.map((row) => this.toResponseRow(row)),
      skippedExisting,
      failed: saved
        .filter((row) => row.status === OnlineSessionRegistrationStatus.FAILED)
        .map((row) => ({
          name: row.displayName,
          email: row.sourceEmail ?? row.registrantEmail,
          reason: row.failureReason ?? 'Unknown error',
        })),
    };
  }

  async list(scope: { programId?: number; sessionId?: number }, page: number, limit: number): Promise<PaginatedGeneratedLinks> {
    return scope.sessionId
      ? this.linkRepository.listBySession(scope.sessionId, page, limit)
      : this.linkRepository.listByProgram(scope.programId as number, page, limit);
  }

  async listByUser(
    userId: number,
    programId?: number,
    sessionId?: number,
  ): Promise<ZoomGeneratedRegistrantLink[]> {
    return this.linkRepository.findByUserId(userId, programId, sessionId);
  }

  /**
   * `sessionId` -> that one session (must be Zoom-provisioned). `programId` -> every
   * Zoom-provisioned session of the program. Mirrors
   * `ZoomBulkRegistrationService.resolveTargetSessions` one-for-one.
   */
  private async resolveTargetSessions(dto: GenerateZoomGeneralLinksV1Dto): Promise<ProgramSession[]> {
    if (dto.sessionId) {
      const session = await this.onlineSessionService.findOne(dto.sessionId);
      if (!session.onlineSession?.externalId) {
        throw new InifniBadRequestException(
          ERROR_CODES.ZOOM_SESSION_NOT_PROVISIONED,
          null,
          null,
          String(dto.sessionId),
        );
      }
      return [session];
    }

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

  private async resolvePendingRecipients(
    dto: GenerateZoomGeneralLinksV1Dto,
    programId: number,
  ): Promise<PendingRecipient[]> {
    const recipients: PendingRecipient[] = [];

    if (dto.roleKeys?.length) {
      const users = await this.userRepository.getUsersByRoleKeys(dto.roleKeys);
      // A user holding several of the requested roles can come back from the join more than
      // once — the unique index is one row per (session, user), so dedupe here rather than
      // let the second copy die on that constraint mid-batch.
      const seenUserIds = new Set<number>();
      for (const user of users) {
        if (seenUserIds.has(user.id)) continue;
        if (!user.email) {
          this.logger.warn(`Skipping user ${user.id} for Zoom general link — no email on file`);
          continue;
        }
        seenUserIds.add(user.id);
        const displayName = this.resolveUserDisplayName(user);
        recipients.push({
          sourceType: GeneratedLinkSourceType.ROLE,
          displayName,
          registrantEmail: buildZoomUserRegistrantEmail(user.email, user.id, programId),
          ...splitZoomContactName(displayName),
          userId: user.id,
          // A user can hold several of the requested roles — record only their highest-priority one.
          roleKey: this.resolvePriorityRoleKey(user, dto.roleKeys),
          sourceEmail: user.email,
          // Stored only when BOTH country code and phone number exist.
          sourceMobile:
            user.phoneNumber && user.countryCode
              ? `${user.countryCode}${user.phoneNumber}`
              : undefined,
        });
      }
    }

    if (dto.placeholderName && dto.placeholderCount && dto.placeholderDomain) {
      const slug = dto.placeholderName.toLowerCase().replace(/\s+/g, '');
      for (let sequenceNumber = 1; sequenceNumber <= dto.placeholderCount; sequenceNumber++) {
        const displayName = `${dto.placeholderName} ${sequenceNumber}`;
        recipients.push({
          sourceType: GeneratedLinkSourceType.PLACEHOLDER,
          displayName,
          registrantEmail: buildZoomPlaceholderRegistrantEmail(slug, dto.placeholderDomain, sequenceNumber, programId),
          ...splitZoomContactName(displayName),
          roleKey: SYSTEM_ROLE_KEY,
          batchName: dto.placeholderName,
          sequenceNumber,
        });
      }
    }

    return recipients;
  }

  /**
   * Also clears the way for the retry it's about to allow: any of these recipients' stale
   * FAILED rows for this session get soft-deleted as a side effect (see
   * `excludeRegisteredAndClearFailedByUser`/`...ByBatch`), so `registerInBatches`'s insert right
   * after this never collides with a leftover non-deleted row from an earlier failed attempt.
   */
  private async excludeAlreadyRegistered(
    session: ProgramSession,
    recipients: PendingRecipient[],
  ): Promise<{ toRegister: PendingRecipient[]; skippedExisting: number }> {
    const roleUserIds = recipients
      .filter((recipient) => recipient.sourceType === GeneratedLinkSourceType.ROLE)
      .map((recipient) => recipient.userId as number);
    const existingUserIds = await this.linkRepository.excludeRegisteredAndClearFailedByUser(
      session.id,
      roleUserIds,
    );

    const placeholderBatchNames = [
      ...new Set(
        recipients
          .filter((recipient) => recipient.sourceType === GeneratedLinkSourceType.PLACEHOLDER)
          .map((recipient) => recipient.batchName as string),
      ),
    ];
    const existingSequencesByBatch = new Map<string, Set<number>>();
    for (const batchName of placeholderBatchNames) {
      existingSequencesByBatch.set(
        batchName,
        await this.linkRepository.excludeRegisteredAndClearFailedByBatch(session.id, batchName),
      );
    }

    let skippedExisting = 0;
    const toRegister = recipients.filter((recipient) => {
      const alreadyExists =
        recipient.sourceType === GeneratedLinkSourceType.ROLE
          ? existingUserIds.has(recipient.userId as number)
          : existingSequencesByBatch.get(recipient.batchName as string)?.has(recipient.sequenceNumber as number);
      if (alreadyExists) skippedExisting++;
      return !alreadyExists;
    });

    return { toRegister, skippedExisting };
  }

  /** Sessions sharing the same Zoom resource (`onlineSession.externalId`), in first-seen order. */
  private groupBySharedResource(sessions: ProgramSession[]): ProgramSession[][] {
    const groups = new Map<string, ProgramSession[]>();
    for (const session of sessions) {
      const externalId = session.onlineSession?.externalId as string;
      const group = groups.get(externalId);
      if (group) group.push(session);
      else groups.set(externalId, [session]);
    }
    return [...groups.values()];
  }

  /**
   * Registers every recipient against one shared Zoom resource exactly once — even though the
   * group may back several sibling `ProgramSession` rows — then persists one link row per
   * (session, recipient) pair so per-session listing still works, all rows reusing the single
   * call's `joinUrl`/registrant id.
   */
  private async registerGroup(
    group: ProgramSession[],
    recipients: PendingRecipient[],
    actorUserId?: number,
  ): Promise<{ rows: ZoomGeneratedRegistrantLink[]; skippedExisting: number }> {
    let skippedExisting = 0;
    const toRegisterBySession = new Map<number, PendingRecipient[]>();
    for (const session of group) {
      const excluded = await this.excludeAlreadyRegistered(session, recipients);
      skippedExisting += excluded.skippedExisting;
      toRegisterBySession.set(session.id, excluded.toRegister);
    }

    // A recipient may already be registered on some sibling sessions but not others — only
    // call Zoom for recipients still needing at least one session in this group.
    const pendingByEmail = new Map<string, PendingRecipient>();
    for (const toRegister of toRegisterBySession.values()) {
      for (const recipient of toRegister) {
        pendingByEmail.set(recipient.registrantEmail, recipient);
      }
    }

    const anchor = group[0];
    const handler = this.handlerFor(anchor);
    const outcomeByEmail = new Map<string, ZoomCallOutcome>();
    const pending = [...pendingByEmail.values()];
    for (let offset = 0; offset < pending.length; offset += DEFAULT_BATCH_SIZE) {
      const batch = pending.slice(offset, offset + DEFAULT_BATCH_SIZE);
      // Sequential within the batch (not Promise.all) — same reasoning as
      // ZoomBulkRegistrationService: several registrants sharing one Zoom resource must not
      // race Zoom's per-resource rate limit.
      for (const recipient of batch) {
        outcomeByEmail.set(recipient.registrantEmail, await this.callZoom(anchor, handler, recipient));
      }
      if (offset + DEFAULT_BATCH_SIZE < pending.length) {
        await this.delay(BULK_BATCH_DELAY_MS);
      }
    }

    const rows: ZoomGeneratedRegistrantLink[] = [];
    for (const session of group) {
      for (const recipient of toRegisterBySession.get(session.id) ?? []) {
        rows.push(
          this.buildRow(session, recipient, outcomeByEmail.get(recipient.registrantEmail), actorUserId),
        );
      }
    }

    return { rows, skippedExisting };
  }

  private async callZoom(
    session: ProgramSession,
    handler: ZoomSessionHandler,
    recipient: PendingRecipient,
  ): Promise<ZoomCallOutcome> {
    try {
      const contact: ZoomContactInfo = {
        firstName: recipient.zoomFirstName,
        lastName: recipient.zoomLastName,
        email: recipient.registrantEmail,
        name: recipient.displayName,
      };
      // Staff/placeholder links are admin-requested, not an unreviewed public
      // signup — auto-approve them too when manual approval is on, same as a
      // real ProgramRegistration (see ZoomRegistrationService.pushParticipant).
      const result = await handler.addParticipant(session, contact, ZoomRole.ATTENDEE, true);
      return { ok: true, result };
    } catch (error) {
      this.logger.error('Zoom registration failed for a generated link recipient', error?.stack, {
        error,
        recipient,
      });
      // Zoom API errors surface as InifniBadRequestException, which carries the real message
      // but does NOT extend the built-in Error class — read `.message` directly rather than
      // gating on `instanceof Error`, or the actual failure reason gets lost.
      return { ok: false, message: error?.message ?? 'Unknown error' };
    }
  }

  private buildRow(
    session: ProgramSession,
    recipient: PendingRecipient,
    outcome: ZoomCallOutcome | undefined,
    actorUserId?: number,
  ): ZoomGeneratedRegistrantLink {
    const base: Partial<ZoomGeneratedRegistrantLink> = {
      programId: session.programId,
      programSessionId: session.id,
      sourceType: recipient.sourceType,
      userId: recipient.userId ?? null,
      roleKey: recipient.roleKey ?? null,
      batchName: recipient.batchName ?? null,
      sequenceNumber: recipient.sequenceNumber ?? null,
      displayName: recipient.displayName,
      registrantEmail: recipient.registrantEmail,
      sourceEmail: recipient.sourceEmail ?? null,
      sourceMobile: recipient.sourceMobile ?? null,
      createdBy: actorUserId ?? null,
      updatedBy: actorUserId ?? null,
    };

    if (!outcome || !outcome.ok) {
      return this.linkRepository.create({
        ...base,
        status: OnlineSessionRegistrationStatus.FAILED,
        failureReason: outcome?.message ?? 'Unknown error',
      });
    }

    return this.linkRepository.create({
      ...base,
      joinUrl: outcome.result.joinUrl,
      externalRegistrantId: outcome.result.zoomRegistrantId,
      status: OnlineSessionRegistrationStatus.REGISTERED,
    });
  }

  /**
   * A user can hold several of the caller's requested roles at once — record only their
   * highest-priority one (lowest `UserRole.priority` number wins), same convention the auth
   * guards use to pick a user's active role (`CombinedAuthGuard.getHighestPriorityRole` et al.),
   * not just whichever requested role key happened to match first.
   */
  private resolvePriorityRoleKey(user: User, requestedRoleKeys: string[]): string | undefined {
    const matches = (user.userRoleMaps ?? []).filter(
      (map) => map.role?.roleKey && requestedRoleKeys.includes(map.role.roleKey),
    );
    if (!matches.length) return undefined;

    return matches.reduce((best, current) =>
      (current.role.priority ?? DEFAULT_ROLE_PRIORITY) < (best.role.priority ?? DEFAULT_ROLE_PRIORITY)
        ? current
        : best,
    ).role.roleKey;
  }

  /** Best available human name for a role-matched staff user, most-authoritative first. */
  private resolveUserDisplayName(user: User): string {
    return (
      user.orgUsrName ||
      user.legalFullName ||
      user.fullName ||
      `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim() ||
      user.email
    );
  }

  private handlerFor(session: ProgramSession): ZoomSessionHandler {
    return session.onlineType === OnlineTypeEnum.MEETING ? this.meeting : this.webinar;
  }

  private toResponseRow(row: ZoomGeneratedRegistrantLink): GeneratedZoomLinkV1 {
    return {
      id: row.id,
      programSessionId: row.programSessionId,
      sourceType: row.sourceType,
      userId: row.userId,
      roleKey: row.roleKey,
      batchName: row.batchName,
      sequenceNumber: row.sequenceNumber,
      displayName: row.displayName,
      // Never the uniquely-tagged address actually sent to Zoom — that's `row.registrantEmail`,
      // kept internal (used to match Zoom analytics reports back to this row), not surfaced here.
      email: row.sourceEmail ?? row.registrantEmail,
      sourceMobile: row.sourceMobile,
      joinUrl: row.joinUrl,
      status: row.status,
      failureReason: row.failureReason,
    };
  }

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