import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, In, Not } from 'typeorm';
import { ProgramUserAttendance, ProgramSession, ProgramRegistration } 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 { REGISTRATION_INELIGIBLE_STATUSES } from 'src/common/constants/zoom.constants';
import {
  SessionAttendanceFilter,
  PaginatedAttendance,
} from './interfaces/online-attendance.interface';
import { ONLINE_ATTENDANCE_LOG } from 'src/common/constants/online-attendance.constants';

@Injectable()
export class OnlineAttendanceRepository {
  constructor(
    @InjectRepository(ProgramUserAttendance)
    private readonly attendanceRepo: Repository<ProgramUserAttendance>,
    @InjectRepository(ProgramSession)
    private readonly sessionRepo: Repository<ProgramSession>,
    @InjectRepository(ProgramRegistration)
    private readonly registrationRepo: Repository<ProgramRegistration>,
    private readonly logger: AppLoggerService,
  ) {}

  // ---- Sessions -------------------------------------------------------------

  async findSessionById(id: number): Promise<ProgramSession | null> {
    try {
      return await this.sessionRepo.findOne({
        where: { id, deletedAt: IsNull() },
        relations: ['onlineSession'],
      });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.SESSION_FIND_FAILED, error?.stack, { error, id });
      handleKnownErrors(ERROR_CODES.PROGRAM_SESSION_FIND_BY_ID_FAILED, error);
    }
  }

  /**
   * Column-only UPDATE — deliberately NOT `sessionRepo.save(session)`. `ProgramSession.onlineSession`
   * is `cascade: true`, and `findSessionById` eagerly loads it; saving the full entity graph would
   * re-persist (and cascade-save) the joined online-session row too. This must touch only the lock
   * columns, never anything else on the session.
   */
  async updateAttendanceLock(
    sessionId: number,
    patch: {
      isAttendanceLocked: boolean;
      attendanceLockedBy: number | null;
      attendanceLockedAt: Date | null;
      updatedBy: number;
    },
  ): Promise<void> {
    try {
      await this.sessionRepo.update({ id: sessionId }, patch);
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.SESSION_SAVE_FAILED, error?.stack, { error, sessionId });
      handleKnownErrors(ERROR_CODES.PROGRAM_SESSION_SAVE_FAILED, error);
    }
  }

  /** Resolves the session for a Zoom meeting OR webinar external id. */
  async findSessionByMeetingOrWebinarId(extId: string): Promise<ProgramSession | null> {
    try {
      return await this.sessionRepo
        .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(ONLINE_ATTENDANCE_LOG.SESSION_FIND_BY_EXT_FAILED, error?.stack, {
        error,
        extId,
      });
      handleKnownErrors(ERROR_CODES.PROGRAM_SESSION_FIND_BY_ID_FAILED, error);
    }
  }

  async listSessionsByProgram(programId: number, sessionId?: number): Promise<ProgramSession[]> {
    try {
      const sessionQuery = this.sessionRepo
        .createQueryBuilder('session')
        .where('session.deleted_at IS NULL')
        .andWhere('session.program_id = :programId', { programId });
      if (sessionId) sessionQuery.andWhere('session.id = :sessionId', { sessionId });
      return await sessionQuery.orderBy('session.starts_at', 'ASC').getMany();
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.SESSIONS_BY_PROGRAM_FAILED, error?.stack, {
        error,
        programId,
      });
      handleKnownErrors(ERROR_CODES.PROGRAM_SESSION_GET_FAILED, error);
    }
  }

  // ---- Registrations --------------------------------------------------------

  /**
   * A registration is per-PROGRAM (not per-session): one registration attends
   * every online session of its program via a per-session join link. So a
   * session's participant is confirmed against the session's program, never the
   * registration's own `program_session_id` (which would only match the single
   * session it was booked under).
   */
  async findConfirmedRegistration(
    programId: number,
    userId: number,
  ): Promise<ProgramRegistration | null> {
    try {
      return await this.registrationRepo.findOne({
        where: {
          programId,
          userId,
          seatAllocated: true,
          registrationStatus: Not(In(REGISTRATION_INELIGIBLE_STATUSES)),
          deletedAt: IsNull(),
        },
      });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.CONFIRMED_REG_FIND_FAILED, error?.stack, {
        error,
        programId,
        userId,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  /** A confirmed registration looked up by its own id, scoped to the program. */
  async findConfirmedRegistrationById(
    programId: number,
    registrationId: number,
  ): Promise<ProgramRegistration | null> {
    try {
      return await this.registrationRepo.findOne({
        where: {
          id: registrationId,
          programId,
          seatAllocated: true,
          registrationStatus: Not(In(REGISTRATION_INELIGIBLE_STATUSES)),
          deletedAt: IsNull(),
        },
      });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.CONFIRMED_REG_FIND_FAILED, error?.stack, {
        error,
        programId,
        registrationId,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  /**
   * Same lookup as {@link findConfirmedRegistrationById} but with NO eligibility
   * gate at all — not `seatAllocated`/`registrationStatus`, and not `deletedAt`
   * either: soft-deleting a registration keeps the row (see
   * `RegistrationRepository.softDeleteRegistration`), it doesn't erase it. Used
   * for admin attendance MARKING (not live join): a session's attendance is a
   * historical fact about what happened during it, so a registration
   * cancelled/archived/rejected/soft-deleted AFTER the session occurred must
   * still be markable/correctable — the same "past sessions aren't
   * retroactively hidden by a later status change" rule the KPI/roster queries
   * already apply (see TERMINAL_REGISTRATION_ACTIVATION_SOURCES).
   */
  async findRegistrationById(
    programId: number,
    registrationId: number,
  ): Promise<ProgramRegistration | null> {
    try {
      return await this.registrationRepo.findOne({
        where: {
          id: registrationId,
          programId,
        },
      });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.CONFIRMED_REG_FIND_FAILED, error?.stack, {
        error,
        programId,
        registrationId,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  // ---- Attendance -----------------------------------------------------------

  async findById(id: number): Promise<ProgramUserAttendance | null> {
    try {
      return await this.attendanceRepo.findOne({ where: { id } });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_FIND_BY_ID_FAILED, error?.stack, {
        error,
        id,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  async findBySessionAndUser(
    sessionId: number,
    userId: number,
  ): Promise<ProgramUserAttendance | null> {
    try {
      return await this.attendanceRepo.findOne({ where: { sessionId, userId } });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_FIND_BY_USER_FAILED, error?.stack, {
        error,
        sessionId,
        userId,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  async findBySessionAndRegistration(
    sessionId: number,
    registrationId: number,
  ): Promise<ProgramUserAttendance | null> {
    try {
      return await this.attendanceRepo.findOne({ where: { sessionId, registrationId } });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_FIND_BY_USER_FAILED, error?.stack, {
        error,
        sessionId,
        registrationId,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  async findBySessionAndEmail(
    sessionId: number,
    email: string,
  ): Promise<ProgramUserAttendance | null> {
    try {
      return await this.attendanceRepo.findOne({ where: { sessionId, email } });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_FIND_BY_EMAIL_FAILED, error?.stack, {
        error,
        sessionId,
        email,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  async save(attendance: ProgramUserAttendance): Promise<ProgramUserAttendance> {
    try {
      return await this.attendanceRepo.save(attendance);
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_SAVE_FAILED, error?.stack, { error });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_SAVE_FAILED, error);
    }
  }

  create(data: Partial<ProgramUserAttendance>): ProgramUserAttendance {
    return this.attendanceRepo.create(new ProgramUserAttendance(data));
  }

  async listBySession(
    sessionId: number,
    filter: SessionAttendanceFilter,
  ): Promise<PaginatedAttendance> {
    try {
      const attendanceQuery = this.attendanceRepo
        .createQueryBuilder('attendance')
        .where('attendance.session_id = :sessionId', { sessionId });
      if (filter.isAttended !== undefined) {
        attendanceQuery.andWhere('attendance.is_attended = :isAttended', {
          isAttended: filter.isAttended,
        });
      }
      if (filter.search) {
        attendanceQuery.andWhere(
          '(attendance.full_name ILIKE :search OR attendance.email ILIKE :search OR attendance.mobile ILIKE :search)',
          { search: `%${filter.search}%` },
        );
      }
      const [data, total] = await attendanceQuery
        .orderBy('attendance.full_name', 'ASC')
        .take(filter.limit)
        .skip((filter.page - 1) * filter.limit)
        .getManyAndCount();
      return { data, total };
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_LIST_FAILED, error?.stack, {
        error,
        sessionId,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  /** All attendance rows for a session — used for report aggregates. */
  async findAllBySession(sessionId: number): Promise<ProgramUserAttendance[]> {
    try {
      return await this.attendanceRepo.find({ where: { sessionId } });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_FETCH_FAILED, error?.stack, {
        error,
        sessionId,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  /**
   * Registration ids marked `is_attended = true` for a session — a direct column read, not a
   * precedence re-derivation: `pushAndResolve` (see `OnlineAttendanceService`) already keeps
   * `is_attended` current on every mark/webhook/undo, so this is the cheapest correct source for
   * "who's credited present," no JSON event-log parsing needed.
   */
  async findAttendedRegistrationIdsBySession(sessionId: number): Promise<number[]> {
    try {
      const rows = await this.attendanceRepo
        .createQueryBuilder('attendance')
        .select('attendance.registration_id', 'registrationId')
        .where('attendance.session_id = :sessionId', { sessionId })
        .andWhere('attendance.is_attended = true')
        .andWhere('attendance.registration_id IS NOT NULL')
        .getRawMany<{ registrationId: number }>();
      return rows.map((row) => Number(row.registrationId));
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_FETCH_FAILED, error?.stack, {
        error,
        sessionId,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }

  /**
   * All attendance rows across a set of sessions in ONE query — batches the
   * program-level report aggregates so they don't fan out to a per-session query
   * (N+1). Callers group the rows by `sessionId` in memory.
   */
  async findAllBySessionIds(sessionIds: number[]): Promise<ProgramUserAttendance[]> {
    if (!sessionIds.length) return [];
    try {
      return await this.attendanceRepo.find({ where: { sessionId: In(sessionIds) } });
    } catch (error) {
      this.logger.error(ONLINE_ATTENDANCE_LOG.ATTENDANCE_FETCH_FAILED, error?.stack, {
        error,
        sessionIds,
      });
      handleKnownErrors(ERROR_CODES.ONLINE_ATTENDANCE_GET_FAILED, error);
    }
  }
}
