import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import {
  ProgramRegistrationOnlineSession,
  ProgramSession,
  ProgramUserAttendance,
} from 'src/common/entities';
import { AppLoggerService } from 'src/common/services/logger.service';
import { ModeOfOperationEnum } from 'src/common/enum/mode-of-operation.enum';
import { SessionLaunchMode } from 'src/common/enum/session-launch-mode.enum';
import { AttendanceSourceEnum } from 'src/common/enum/attendance-source.enum';
import { computeJoinWindow } from 'src/common/utils/join-window.util';
import {
  JOIN_OPENS_DEFAULT_MINUTES,
  JOIN_CLOSES_BUFFER_DEFAULT_MINUTES,
} from 'src/common/constants/online-attendance.constants';
import { RegistrationOnlineSessionActivationStatus } from 'src/common/enum/registration-online-session-activation-status.enum';
import {
  RegistrationLike,
  RegistrationOnlineSessionInfo,
  RegistrationViewerContact,
} from './interfaces/registration-session-info.interface';

/**
 * Builds, for a registration, the per-session online details (join URL, live
 * join window, attendance) across all ONLINE sessions of its program. Used by
 * the registration-by-id and user-registrations read APIs. Lookups are batched
 * so the (paginated) list endpoint stays free of N+1 queries.
 */
@Injectable()
export class RegistrationSessionInfoService {
  constructor(
    @InjectRepository(ProgramSession)
    private readonly sessionRepo: Repository<ProgramSession>,
    @InjectRepository(ProgramRegistrationOnlineSession)
    private readonly registrationOnlineSessionRepo: Repository<ProgramRegistrationOnlineSession>,
    @InjectRepository(ProgramUserAttendance)
    private readonly attendanceRepo: Repository<ProgramUserAttendance>,
    private readonly logger: AppLoggerService,
  ) {}

  /** Convenience for a single registration. */
  async buildForRegistration(
    registration: RegistrationLike,
    viewerContact?: RegistrationViewerContact,
  ): Promise<RegistrationOnlineSessionInfo[]> {
    const map = await this.buildForRegistrations([registration], viewerContact);
    return map.get(registration.id) ?? [];
  }

  /**
   * Registration-level (session-independent) eligibility to see any join action at
   * all: the registration must hold an allocated seat, its activation rollup must
   * be ACTIVE (not deactivated), and — when a `viewerContact` is given — the
   * viewer's own contact (email/mobile) must match this registration (else it's a
   * proxy/other-person registration the viewer made on someone else's behalf).
   * Same rule `toInfo` uses per-session, exposed directly so callers can set it
   * once on the registration itself instead of reading it off every entry of its
   * (identical, per-session-duplicated) `onlineSessions` array.
   */
  isEligibleToJoin(registration: RegistrationLike, viewerContact?: RegistrationViewerContact): boolean {
    if (registration.seatAllocated !== true) return false;
    if (registration.activationStatus !== RegistrationOnlineSessionActivationStatus.ACTIVE) return false;
    return !viewerContact || matchesViewerContact(registration, viewerContact);
  }

  /**
   * Batched build: registrationId -> its program's online sessions with join + attendance state.
   * When `viewerContact` is given, a registration's joinUrl is only surfaced if the
   * registration's email/mobile matches it — otherwise the registration belongs to
   * (or was made as a proxy for) someone else and the join link is withheld.
   */
  async buildForRegistrations(
    registrations: RegistrationLike[],
    viewerContact?: RegistrationViewerContact,
  ): Promise<Map<number, RegistrationOnlineSessionInfo[]>> {
    const result = new Map<number, RegistrationOnlineSessionInfo[]>();
    const valid = (registrations ?? []).filter((r) => r && r.id);
    if (!valid.length) return result;
    valid.forEach((r) => result.set(r.id, []));

    try {
      const programIds = unique(valid.map((r) => r.programId).filter(isNumber));
      if (!programIds.length) return result;

      // Online sessions of the relevant programs (with their provider resource).
      const sessions = await this.sessionRepo.find({
        where: { programId: In(programIds), deletedAt: IsNull() },
        relations: { onlineSession: true },
        order: { displayOrder: 'ASC', id: 'ASC' },
      });
      const onlineSessions = sessions.filter(isOnlineSession);
      if (!onlineSessions.length) return result;

      const sessionsByProgram = groupBy(onlineSessions, (s) => s.programId);
      const sessionIds = onlineSessions.map((s) => s.id);
      const regIds = valid.map((r) => r.id);

      const joinUrlByRegSession = await this.loadJoinUrls(regIds);
      const byRegSession = await this.loadAttendance(sessionIds, regIds);
      const now = new Date();

      for (const reg of valid) {
        const programSessions = sessionsByProgram.get(reg.programId as number) ?? [];
        result.set(
          reg.id,
          programSessions.map((session) =>
            this.toInfo(session, reg, joinUrlByRegSession, byRegSession, now, viewerContact),
          ),
        );
      }
    } catch (error) {
      // Non-fatal enrichment: log and return whatever (empty) defaults were seeded.
      this.logger.warn('Failed to build registration session info', {
        error: (error as Error)?.message,
      });
    }
    return result;
  }

  /** registrationId:onlineSessionId -> the registrant's extension (join URL, panelist flag). */
  private async loadJoinUrls(
    registrationIds: number[],
  ): Promise<Map<string, ProgramRegistrationOnlineSession>> {
    const rows = await this.registrationOnlineSessionRepo.find({
      where: { registrationId: In(registrationIds), deletedAt: IsNull() },
    });
    const map = new Map<string, ProgramRegistrationOnlineSession>();
    for (const row of rows) {
      if (row.onlineSessionId != null) {
        map.set(`${row.registrationId}:${row.onlineSessionId}`, row);
      }
    }
    return map;
  }

  /**
   * Attendance rows for the given sessions, matched strictly by registration id.
   * Every write path (join-click, Zoom webhook, manual mark, QR check-in) sets
   * registrationId on creation, so this is the only safe key — a sibling
   * registration can share the same user id or email (proxy/duplicate regs),
   * and matching on those would misattribute one registrant's attendance to
   * another's.
   */
  private async loadAttendance(
    sessionIds: number[],
    registrationIds: number[],
  ): Promise<Map<string, ProgramUserAttendance>> {
    const rows = registrationIds.length
      ? await this.attendanceRepo.find({
          where: { sessionId: In(sessionIds), registrationId: In(registrationIds) },
        })
      : [];

    const byRegSession = new Map<string, ProgramUserAttendance>();
    for (const row of rows) {
      byRegSession.set(`${row.registrationId}:${row.sessionId}`, row);
    }
    return byRegSession;
  }

  private toInfo(
    session: ProgramSession,
    registration: RegistrationLike,
    joinUrlByRegSession: Map<string, ProgramRegistrationOnlineSession>,
    byRegSession: Map<string, ProgramUserAttendance>,
    now: Date,
    viewerContact?: RegistrationViewerContact,
  ): RegistrationOnlineSessionInfo {
    const online = session.onlineSession;
    const extension = online ? joinUrlByRegSession.get(`${registration.id}:${online.id}`) : undefined;
    // Only expose this registration's own generated join link. Do not fall back
    // to the shared session-level link — a registrant must get their personal URL.
    // When a viewer contact is given, withhold it further unless the registration's
    // own email/mobile matches that contact (proxy registrations belong to someone else).
    const eligibleToJoin = this.isEligibleToJoin(registration, viewerContact);
    const joinUrl = eligibleToJoin ? extension?.joinUrl ?? null : null;

    const preSessionJoiningTime = online?.joinOpensMinutesBefore ?? JOIN_OPENS_DEFAULT_MINUTES;
    const window = computeJoinWindow({
      startsAt: session.startsAt,
      joinOpensMinutesBefore: preSessionJoiningTime,
      endsAt: session.endsAt,
      defaultOpensBeforeMinutes: JOIN_OPENS_DEFAULT_MINUTES,
      closeBufferMinutes: JOIN_CLOSES_BUFFER_DEFAULT_MINUTES,
      now,
    });

    const attendance = byRegSession.get(`${registration.id}:${session.id}`);
    const events = attendance?.attendanceEvents ?? [];

    return {
      sessionId: session.id,
      sessionName: session.name ?? null,
      onlineType: session.onlineType ?? null,
      startsAt: session.startsAt ?? null,
      endsAt: session.endsAt ?? null,
      launchMode: online?.launchMode ?? SessionLaunchMode.SDK,
      providerType: online?.provider ?? null,
      provisioned: !!online?.externalId,
      webinarId: online?.externalId ?? null,
      joinUrl,
      isPanelist: extension?.isPanelist ?? false,
      eligibleToJoin,
      joinEnabled: window.isOpen && !!joinUrl,
      joinOpensAt: window.opensAt,
      joinClosesAt: window.closesAt,
      preSessionJoiningTime,
      attendance: {
        isAttended: attendance?.isAttended ?? false,
        checkedInAt: attendance?.checkedInAt ?? null,
        viaJoinClick: events.some((e) => e.source === AttendanceSourceEnum.JOIN_CLICK),
        viaZoom: events.some((e) => e.source === AttendanceSourceEnum.ZOOM_WEBHOOK),
        viaManual: events.some((e) => e.source === AttendanceSourceEnum.MANUAL_ADMIN),
      },
    };
  }
}

/**
 * A session is "online" when its mode of operation is ONLINE or HYBRID. This is
 * the authoritative signal — `onlineType` can be NA or null even on sessions
 * meant to run online (the Zoom resource may not be provisioned yet).
 */
function isOnlineSession(session: ProgramSession): boolean {
  return (
    session.modeOfOperation === ModeOfOperationEnum.ONLINE ||
    session.modeOfOperation === ModeOfOperationEnum.HYBRID
  );
}

/**
 * Whether a registration's own contact (email, case-insensitive, or mobile) matches
 * the given viewer contact. Mirrors the normalization used by the seeker-facing
 * "ownerOrContact" rule (see registration.repository.ts / registration-action.service.ts).
 */
function matchesViewerContact(
  registration: RegistrationLike,
  viewerContact: RegistrationViewerContact,
): boolean {
  const regEmail = registration.emailAddress?.trim().toLowerCase() || null;
  const contactEmail = viewerContact.emailAddress?.trim().toLowerCase() || null;
  if (regEmail && contactEmail && regEmail === contactEmail) return true;

  const regMobile = registration.mobileNumber?.trim() || null;
  const contactMobile = viewerContact.mobileNumber?.trim() || null;
  if (regMobile && contactMobile && regMobile === contactMobile) return true;

  return false;
}

function isNumber(value: unknown): value is number {
  return typeof value === 'number' && !Number.isNaN(value);
}

function unique<T>(values: T[]): T[] {
  return Array.from(new Set(values));
}

function groupBy<T>(items: T[], keyOf: (item: T) => number): Map<number, T[]> {
  const map = new Map<number, T[]>();
  for (const item of items) {
    const key = keyOf(item);
    const bucket = map.get(key);
    if (bucket) bucket.push(item);
    else map.set(key, [item]);
  }
  return map;
}
