import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, QueryFailedError, Repository } from 'typeorm';
import { ZoomGeneratedRegistrantLink } from 'src/common/entities';
import { GeneratedLinkSourceType } from 'src/common/enum/generated-link-source-type.enum';
import { OnlineSessionRegistrationStatus } from 'src/common/enum/online-session-registration-status.enum';
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';

export interface PaginatedGeneratedLinks {
  data: ZoomGeneratedRegistrantLink[];
  total: number;
}

/** The entity's own partial unique indexes — a violation on either means someone else (a concurrent/overlapping request for the same scope) already registered this exact recipient first. */
const RECIPIENT_UNIQUENESS_CONSTRAINTS = [
  'uq_zoom_generated_link_session_user',
  'uq_zoom_generated_link_session_batch_seq',
];

/** Postgres error code for a unique-constraint violation. */
const POSTGRES_UNIQUE_VIOLATION = '23505';

/** Owns `zoom_generated_registrant_link` — pre-generated Zoom registrants with no program registration of their own. */
@Injectable()
export class ZoomGeneratedRegistrantLinkRepository {
  constructor(
    @InjectRepository(ZoomGeneratedRegistrantLink)
    private readonly repo: Repository<ZoomGeneratedRegistrantLink>,
    private readonly logger: AppLoggerService,
  ) {}

  create(data: Partial<ZoomGeneratedRegistrantLink>): ZoomGeneratedRegistrantLink {
    return this.repo.create(new ZoomGeneratedRegistrantLink(data));
  }

  /**
   * Which of these user ids already hold a REGISTERED row for this session — these are skipped
   * outright, no re-registration. A prior FAILED row is a different story: it must not go on
   * blocking every future retry forever, but it also can't just sit there while we insert a
   * fresh row for the same (session, user) — the partial unique index only guards non-deleted
   * rows, so a fresh insert alongside a non-deleted FAILED row still collides. So we soft-delete
   * that stale FAILED row right here, clearing the way for the caller's upcoming insert, instead
   * of inserting first and catching the constraint violation after the fact.
   */
  async excludeRegisteredAndClearFailedByUser(programSessionId: number, userIds: number[]): Promise<Set<number>> {
    if (!userIds.length) return new Set();
    try {
      const rows = await this.repo.find({
        where: {
          programSessionId,
          sourceType: GeneratedLinkSourceType.ROLE,
          userId: In(userIds),
          deletedAt: IsNull(),
        },
        select: ['id', 'userId', 'status'],
      });
      await this.softDeleteStaleFailedRows(rows);
      return new Set(
        rows
          .filter((row) => row.status === OnlineSessionRegistrationStatus.REGISTERED)
          .map((row) => row.userId as number),
      );
    } catch (error) {
      this.logger.error('Error finding existing Zoom generated links by user ids', error?.stack, {
        error,
        programSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_GENERATED_LINK_GET_FAILED, error);
    }
  }

  /**
   * Which sequence numbers already hold a REGISTERED row for this session+batch — these are
   * skipped outright. A stale FAILED row for a sequence number is soft-deleted here, same
   * reasoning as `excludeRegisteredAndClearFailedByUser`, so a fresh insert for that slot on
   * retry doesn't collide with it.
   */
  async excludeRegisteredAndClearFailedByBatch(programSessionId: number, batchName: string): Promise<Set<number>> {
    try {
      const rows = await this.repo.find({
        where: {
          programSessionId,
          sourceType: GeneratedLinkSourceType.PLACEHOLDER,
          batchName,
          deletedAt: IsNull(),
        },
        select: ['id', 'sequenceNumber', 'status'],
      });
      await this.softDeleteStaleFailedRows(rows);
      return new Set(
        rows
          .filter((row) => row.status === OnlineSessionRegistrationStatus.REGISTERED)
          .map((row) => row.sequenceNumber as number),
      );
    } catch (error) {
      this.logger.error('Error finding existing Zoom generated links by batch', error?.stack, {
        error,
        programSessionId,
        batchName,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_GENERATED_LINK_GET_FAILED, error);
    }
  }

  private async softDeleteStaleFailedRows(
    rows: Pick<ZoomGeneratedRegistrantLink, 'id' | 'status'>[],
  ): Promise<void> {
    const staleFailedIds = rows
      .filter((row) => row.status === OnlineSessionRegistrationStatus.FAILED)
      .map((row) => row.id);
    if (staleFailedIds.length) {
      await this.repo.softDelete(staleFailedIds);
    }
  }

  /**
   * Every active (non-soft-deleted, still-registered) generated link for a session, keyed by
   * normalized (trim+lowercase) `registrantEmail` — the same normalization
   * `ZoomLiveEventAnalyticsProviderBase.normalizeEmail` applies to live-event emails, so this map
   * can be matched directly against that provider's per-email aggregates without re-normalizing.
   */
  async findActiveMapBySession(programSessionId: number): Promise<Map<string, ZoomGeneratedRegistrantLink>> {
    try {
      const rows = await this.repo.find({
        where: {
          programSessionId,
          status: OnlineSessionRegistrationStatus.REGISTERED,
          deletedAt: IsNull(),
        },
      });
      return new Map(rows.map((row) => [row.registrantEmail.trim().toLowerCase(), row]));
    } catch (error) {
      this.logger.error('Error mapping Zoom generated registrant links by session', error?.stack, {
        error,
        programSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_GENERATED_LINK_GET_FAILED, error);
    }
  }

  /**
   * Saves each row independently (not one wrapped transaction) — the same reasoning as
   * `ZoomAnalyticsAttendeeSummaryRepository.upsertMany`: a bad row is logged and skipped
   * without rolling back everyone else in the batch.
   */
  async saveMany(rows: ZoomGeneratedRegistrantLink[]): Promise<ZoomGeneratedRegistrantLink[]> {
    if (!rows.length) return [];
    const results = await Promise.allSettled(rows.map((row) => this.repo.save(row)));
    const saved: ZoomGeneratedRegistrantLink[] = [];
    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        saved.push(result.value);
        return;
      }
      if (this.isRecipientAlreadyRegisteredElsewhere(result.reason)) {
        // Someone else — an overlapping/retried request for the same session — won the race and
        // registered this exact recipient first. Not an application error: the recipient already
        // has a row, which is the very state we were trying to reach, so log it quietly.
        this.logger.warn('Skipped a Zoom generated registrant link — already registered by a concurrent request', {
          row: rows[index],
        });
        return;
      }
      this.logger.error('Error saving Zoom generated registrant link', result.reason?.stack, {
        error: result.reason,
        row: rows[index],
      });
    });
    return saved;
  }

  private isRecipientAlreadyRegisteredElsewhere(error: unknown): boolean {
    return (
      error instanceof QueryFailedError &&
      (error as unknown as { code?: string; constraint?: string }).code === POSTGRES_UNIQUE_VIOLATION &&
      RECIPIENT_UNIQUENESS_CONSTRAINTS.includes(
        (error as unknown as { constraint?: string }).constraint ?? '',
      )
    );
  }

  /** `status` narrows to one status (e.g. REGISTERED-only for the provisioning drilldown's 'generalLink' bucket); omitted = every non-deleted row, same as before. */
  async listBySession(
    programSessionId: number,
    page: number,
    limit: number,
    status?: OnlineSessionRegistrationStatus,
  ): Promise<PaginatedGeneratedLinks> {
    try {
      const [data, total] = await this.repo.findAndCount({
        where: { programSessionId, deletedAt: IsNull(), ...(status ? { status } : {}) },
        order: { id: 'ASC' },
        take: limit,
        skip: (page - 1) * limit,
      });
      return { data, total };
    } catch (error) {
      this.logger.error('Error listing Zoom generated registrant links', error?.stack, {
        error,
        programSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_GENERATED_LINK_GET_FAILED, error);
    }
  }

  /**
   * Every active generated link for one user (join URL included) — e.g. "fetch my Zoom links".
   * Defaults to every program/session for this user; `programId`/`sessionId` are both optional and
   * independent, narrowing the result further when passed (unlike `listBySession`/`listByProgram`'s
   * own single-scope callers, this one caller may combine or omit either). No pagination — a user
   * only ever holds one active ROLE row per session (see the table's own partial unique index), so
   * even the unscoped result set stays small.
   */
  async findByUserId(
    userId: number,
    programId?: number,
    sessionId?: number,
  ): Promise<ZoomGeneratedRegistrantLink[]> {
    try {
      return await this.repo.find({
        where: {
          userId,
          deletedAt: IsNull(),
          ...(programId ? { programId } : {}),
          ...(sessionId ? { programSessionId: sessionId } : {}),
        },
        order: { id: 'ASC' },
      });
    } catch (error) {
      this.logger.error('Error listing Zoom generated registrant links by user id', error?.stack, {
        error,
        userId,
        programId,
        sessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_GENERATED_LINK_GET_FAILED, error);
    }
  }

  /**
   * Live (non-deleted, REGISTERED) generated-link count per session — the provisioning KPI's
   * "generated in general links" tally, distinct from the ProgramRegistration-based provisioning
   * counts in `ProvisionCounts`. Grouped in one query rather than one COUNT per session.
   */
  async countRegisteredByProgramSessionIds(
    programSessionIds: number[],
  ): Promise<Map<number, number>> {
    if (!programSessionIds.length) return new Map();
    try {
      const rows = await this.repo
        .createQueryBuilder('link')
        .select('link.program_session_id', 'programSessionId')
        .addSelect('COUNT(*)', 'count')
        .where('link.program_session_id IN (:...programSessionIds)', { programSessionIds })
        .andWhere('link.status = :status', { status: OnlineSessionRegistrationStatus.REGISTERED })
        .andWhere('link.deleted_at IS NULL')
        .groupBy('link.program_session_id')
        .getRawMany<{ programSessionId: string; count: string }>();
      return new Map(rows.map((row) => [Number(row.programSessionId), Number(row.count)]));
    } catch (error) {
      this.logger.error('Error counting registered Zoom generated registrant links', error?.stack, {
        error,
        programSessionIds,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_GENERATED_LINK_GET_FAILED, error);
    }
  }

  /** Same as `listBySession`, scoped to a whole program (every session) instead of one session. */
  async listByProgram(programId: number, page: number, limit: number): Promise<PaginatedGeneratedLinks> {
    try {
      const [data, total] = await this.repo.findAndCount({
        where: { programId, deletedAt: IsNull() },
        order: { id: 'ASC' },
        take: limit,
        skip: (page - 1) * limit,
      });
      return { data, total };
    } catch (error) {
      this.logger.error('Error listing Zoom generated registrant links', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_GENERATED_LINK_GET_FAILED, error);
    }
  }
}
