import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, EntityManager, IsNull, In } from 'typeorm';
import {
  ProgramSession,
  OnlineSession,
  ProgramRegistrationOnlineSession,
} 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 { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { ZoomWebinarStatus } from 'src/common/enum/zoom-webinar-status.enum';
import { ZOOM_ANALYTICS_LOG } from 'src/zoom/constants/zoom-analytics.constants';

/**
 * Webinars are not a standalone table — a Zoom webinar IS a `program_session`
 * whose `online_type = webinar` and whose Zoom identifiers live in the related
 * `hdb_online_session` row. This repository wraps that access so the services
 * read/write through one place.
 */
@Injectable()
export class ZoomWebinarRepository {
  constructor(
    @InjectRepository(ProgramSession)
    private readonly sessionRepo: Repository<ProgramSession>,
    private readonly logger: AppLoggerService,
  ) {}

  private repo(manager?: EntityManager): Repository<ProgramSession> {
    return manager ? manager.getRepository(ProgramSession) : this.sessionRepo;
  }

  async save(session: ProgramSession, manager?: EntityManager): Promise<ProgramSession> {
    try {
      return await this.repo(manager).save(session);
    } catch (error) {
      this.logger.error('Error saving webinar session', error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_SAVE_FAILED, error);
    }
  }

  /**
   * Soft-deletes the online_session row directly (sets deleted_at). Used by
   * remove() instead of orphan-nullify: TypeORM's orphan removal first UPDATEs
   * program_session_id to NULL, which would transiently turn a session row
   * (program_id set, program_session_id NULL) into a template-shaped row that
   * collides with uq_online_session_program_id_template. Both online_session
   * unique indexes are scoped to `deleted_at IS NULL`, so a soft-deleted row
   * frees its uniqueness slot and the program/session can be re-provisioned.
   */
  async deleteOnlineSession(onlineSessionId: number, manager?: EntityManager): Promise<void> {
    try {
      const repo = manager
        ? manager.getRepository(OnlineSession)
        : this.sessionRepo.manager.getRepository(OnlineSession);
      await repo.softDelete(onlineSessionId);
    } catch (error) {
      this.logger.error('Error deleting online session row', error?.stack, {
        error,
        onlineSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_SAVE_FAILED, error);
    }
  }

  /**
   * Soft-deletes every registrant join-link extension bound to an online session.
   * Called from remove() so a removed session leaves no active
   * ProgramRegistrationOnlineSession rows pointing at a soft-deleted online_session.
   */
  async softDeleteExtensionsByOnlineSessionId(
    onlineSessionId: number,
    manager?: EntityManager,
  ): Promise<void> {
    try {
      const repo = manager
        ? manager.getRepository(ProgramRegistrationOnlineSession)
        : this.sessionRepo.manager.getRepository(ProgramRegistrationOnlineSession);
      await repo.softDelete({ onlineSessionId, deletedAt: IsNull() });
    } catch (error) {
      this.logger.error('Error soft deleting online session extensions', error?.stack, {
        error,
        onlineSessionId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_REGISTRATION_DELETE_FAILED, error);
    }
  }

  async findById(id: number, manager?: EntityManager): Promise<ProgramSession | null> {
    try {
      return await this.repo(manager).findOne({
        where: { id, deletedAt: IsNull() },
        relations: ['onlineSession', 'program', 'program.type'],
      });
    } catch (error) {
      this.logger.error('Error finding webinar session by id', error?.stack, { error, id });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_FIND_BY_ID_FAILED, error);
    }
  }

  /** Every webinar/meeting session of a program (with its online-session row), earliest first — the seeker-detail rollup's session list. */
  async findByProgramId(programId: number): Promise<ProgramSession[]> {
    try {
      return await this.sessionRepo
        .createQueryBuilder('session')
        .leftJoinAndSelect('session.onlineSession', 'onlineSession', 'onlineSession.deleted_at IS NULL')
        .where('session.deleted_at IS NULL')
        .andWhere('session.program_id = :programId', { programId })
        .andWhere('session.online_type IN (:...types)', {
          types: [OnlineTypeEnum.WEBINAR, OnlineTypeEnum.MEETING],
        })
        .orderBy('session.starts_at', 'ASC')
        .addOrderBy('session.id', 'ASC')
        .getMany();
    } catch (error) {
      this.logger.error('Error fetching program sessions for seeker analytics', error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_GET_FAILED, error);
    }
  }

  /** Finds the program session backing a given external Zoom webinar id. */
  async findByWebinarExtId(
    webinarIdExt: string,
    manager?: EntityManager,
  ): Promise<ProgramSession | null> {
    return this.findByExtId(webinarIdExt, manager);
  }

  /**
   * Finds the program session backing a given external Zoom id, matched on the
   * related online-session row's `external_id` (meeting or webinar).
   */
  async findByExtId(extId: string, manager?: EntityManager): Promise<ProgramSession | null> {
    try {
      return await this.repo(manager)
        .createQueryBuilder('session')
        .leftJoinAndSelect(
          'session.onlineSession',
          'onlineSession',
          'onlineSession.deleted_at IS NULL',
        )
        .where('session.deleted_at IS NULL')
        .andWhere('onlineSession.external_id = :externalId', { externalId: extId })
        .getOne();
    } catch (error) {
      this.logger.error('Error finding session by zoom ext id', error?.stack, {
        error,
        extId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_FIND_BY_ID_FAILED, error);
    }
  }

  /**
   * Same lookup as `findByExtId`, but disambiguates when a program schedules
   * multiple sessions against the same recurring Zoom webinar/meeting (they
   * all share one `external_id`). In order: (1) Zoom's `occurrence_id` picks
   * the exact sibling when the webhook carries one — reliable for webinars,
   * but Zoom's `meeting.*` events often omit it entirely; (2) failing that,
   * `meetingStartTime` — the instance's own reported actual start time,
   * stable across every event in that instance's lifecycle — picks whichever
   * sibling's own `startsAt` is closest to it; (3) failing that (no
   * meetingStartTime given), falls back to whichever sibling's own
   * `[startsAt, endsAt]` window contains `occurredAt`, the individual
   * participant event's own timestamp — weaker, since a participant can join
   * early or leave late relative to the session's actual boundary. If nothing
   * disambiguates, returns the first match rather than guessing further, and
   * logs a warning so a misattributed webhook is auditable instead of silent.
   */
  async findByExtIdForOccurrence(
    extId: string,
    occurrenceId: string | null,
    occurredAt: Date,
    meetingStartTime?: Date | null,
    manager?: EntityManager,
  ): Promise<ProgramSession | null> {
    try {
      const candidates = await this.repo(manager)
        .createQueryBuilder('session')
        .leftJoinAndSelect(
          'session.onlineSession',
          'onlineSession',
          'onlineSession.deleted_at IS NULL',
        )
        .where('session.deleted_at IS NULL')
        .andWhere('onlineSession.external_id = :externalId', { externalId: extId })
        .getMany();

      if (candidates.length <= 1) return candidates[0] ?? null;

      if (occurrenceId) {
        const exactMatch = candidates.find(
          (candidate) => candidate.onlineSession?.occurrenceId === occurrenceId,
        );
        if (exactMatch) return exactMatch;
        this.logger.warn(ZOOM_ANALYTICS_LOG.WEBHOOK_OCCURRENCE_ID_UNMATCHED, {
          extId,
          occurrenceId,
          candidateOccurrenceIds: candidates.map((candidate) => candidate.onlineSession?.occurrenceId),
        });
      }

      if (meetingStartTime) {
        const withStartsAt = candidates.filter((candidate) => candidate.startsAt);
        if (withStartsAt.length) {
          const closest = withStartsAt.reduce((best, candidate) =>
            Math.abs(candidate.startsAt!.getTime() - meetingStartTime.getTime()) <
            Math.abs(best.startsAt!.getTime() - meetingStartTime.getTime())
              ? candidate
              : best,
          );
          // Only trust "closest" within a day of the reported start — beyond that it's not really
          // disambiguating, just picking whichever sibling happens to be least far away.
          if (Math.abs(closest.startsAt!.getTime() - meetingStartTime.getTime()) <= 24 * 60 * 60 * 1000) {
            return closest;
          }
        }
      }

      const activeNow = candidates.find(
        (candidate) =>
          candidate.startsAt &&
          candidate.endsAt &&
          candidate.startsAt.getTime() <= occurredAt.getTime() &&
          occurredAt.getTime() <= candidate.endsAt.getTime(),
      );
      if (activeNow) return activeNow;

      this.logger.warn(ZOOM_ANALYTICS_LOG.WEBHOOK_OCCURRENCE_AMBIGUOUS, {
        extId,
        occurrenceId,
        occurredAt,
        meetingStartTime,
        candidateIds: candidates.map((candidate) => candidate.id),
      });
      return candidates[0];
    } catch (error) {
      this.logger.error('Error finding session by zoom ext id for occurrence', error?.stack, {
        error,
        extId,
        occurrenceId,
      });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_FIND_BY_ID_FAILED, error);
    }
  }

  /**
   * Saves several program sessions (and their cascaded online-session rows) in
   * one transaction — used when provisioning a shared webinar so the sibling
   * rows are all-or-nothing.
   */
  async saveSessionsInTransaction(sessions: ProgramSession[]): Promise<ProgramSession[]> {
    try {
      return await this.sessionRepo.manager.transaction(async (transactionManager) => {
        return await transactionManager.getRepository(ProgramSession).save(sessions);
      });
    } catch (error) {
      this.logger.error('Error saving shared webinar sessions', (error as Error)?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_SAVE_FAILED, error);
    }
  }

  /** Loads several program sessions (with their online-session rows) by id. */
  async findByIds(ids: number[], manager?: EntityManager): Promise<ProgramSession[]> {
    if (!ids.length) return [];
    try {
      return await this.repo(manager).find({
        where: { id: In(ids), deletedAt: IsNull() },
        relations: ['onlineSession', 'program', 'program.type'],
      });
    } catch (error) {
      this.logger.error('Error finding webinar sessions by ids', error?.stack, { error, ids });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_FIND_BY_ID_FAILED, error);
    }
  }

  /**
   * All sessions backed by the same external Zoom id — the sibling group of a
   * shared ("same link") recurring webinar. Ordered by start time so callers can
   * map occurrences deterministically.
   */
  async findSiblingSessionsByExtId(
    extId: string,
    manager?: EntityManager,
  ): Promise<ProgramSession[]> {
    try {
      return await this.repo(manager)
        .createQueryBuilder('session')
        .leftJoinAndSelect(
          'session.onlineSession',
          'onlineSession',
          'onlineSession.deleted_at IS NULL',
        )
        .where('session.deleted_at IS NULL')
        .andWhere('onlineSession.external_id = :externalId', { externalId: extId })
        .orderBy('session.starts_at', 'ASC')
        .addOrderBy('session.id', 'ASC')
        .getMany();
    } catch (error) {
      this.logger.error('Error finding sibling sessions by ext id', error?.stack, { error, extId });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_FIND_BY_ID_FAILED, error);
    }
  }

  async findAllWebinars(
    limit: number,
    offset: number,
    status?: ZoomWebinarStatus,
  ): Promise<{ data: ProgramSession[]; total: number }> {
    try {
      const sessionQuery = this.sessionRepo
        .createQueryBuilder('session')
        .leftJoinAndSelect(
          'session.onlineSession',
          'onlineSession',
          'onlineSession.deleted_at IS NULL',
        )
        .where('session.deleted_at IS NULL')
        .andWhere('session.online_type IN (:...types)', {
          types: [OnlineTypeEnum.WEBINAR, OnlineTypeEnum.MEETING],
        });
      if (status) {
        sessionQuery.andWhere('onlineSession.status = :status', { status });
      }
      const [data, total] = await sessionQuery
        .orderBy('session.starts_at', 'DESC')
        .addOrderBy('session.id', 'DESC')
        .take(limit)
        .skip(offset)
        .getManyAndCount();
      return { data, total };
    } catch (error) {
      this.logger.error('Error fetching zoom sessions', error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ZOOM_WEBINAR_GET_FAILED, error);
    }
  }

}
