import { Injectable } from '@nestjs/common';
import { OnlineAttendanceService } from 'src/online-attendance/online-attendance.service';
import { SessionCommunicationService } from 'src/session-communication/session-communication.service';
import { RegistrationOnlineSessionActivationStatus } from 'src/common/enum/registration-online-session-activation-status.enum';
import { SessionCommunicationPurposeEnum } from 'src/common/enum/session-communication-purpose.enum';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { ZoomWebinarRepository } from '../repositories/zoom-webinar.repository';
import { ZoomRegistrationRepository } from '../repositories/zoom-registration.repository';
import { ZoomAnalyticsAttendeeSummaryRepository } from '../repositories/zoom-analytics-attendee-summary.repository';
import { ZoomAnalyticsRosterRepository } from '../repositories/zoom-analytics-roster.repository';
import { ZoomAnalyticsProviderRegistry } from '../registries/zoom-analytics-provider.registry';
import { ZoomAnalyticsConfigService } from './zoom-analytics-config.service';
import { ZOOM_ANALYTICS_LATE_COMER_BUCKETS } from '../constants/zoom-analytics.constants';
import {
  ZoomProgramOverallAnalytics,
  ZoomProgramSessionTimelineEntry,
  ZoomProgramSessionKpiTiles,
  ZoomProgramCommsChecklistItem,
  ZoomProgramLatecomerColumn,
  ZoomProgramRepeatLatecomerStatus,
  ZoomProgramRepeatLatecomerRow,
  ZoomProgramMultiDeviceColumn,
} from '../interfaces/zoom-analytics.interface';

/** Repeat Latecomers only ever shows the worst offenders, not the whole roster. */
const REPEAT_LATECOMERS_LIMIT = 10;

/**
 * Display labels for the comms-checklist purposes that can be scoped to a single session.
 * WELCOME/PROGRAM_COMPLETION are program-level (sessionId null in hdb_session_communication_status)
 * and never surface here — the checklist is per-session by construction.
 */
const SESSION_COMMS_PURPOSE_LABELS: Partial<Record<SessionCommunicationPurposeEnum, string>> = {
  [SessionCommunicationPurposeEnum.ABSENT]: 'Absent follow-up sent',
  [SessionCommunicationPurposeEnum.INVITE]: 'Invite sent',
  [SessionCommunicationPurposeEnum.VALUE_CARD]: 'Value card sent',
  [SessionCommunicationPurposeEnum.COMMON_INVITE]: 'Common invite sent',
  [SessionCommunicationPurposeEnum.SYSTEM_LINKS]: 'System links sent',
};

/**
 * One registration's computed row for one session — the same shape
 * ZoomAnalyticsFacadeService.getSeekerAnalytics builds per seeker, just
 * accumulated across every registration in a single pass instead of calling
 * that per-seeker method once per registration (which would repeat the
 * manual-marks lookup once per registration per session and be far too slow
 * for a whole-program aggregate).
 */
interface SessionAttendanceRow {
  attended: boolean;
  late: boolean;
}

/** One registration's held-session cell for the Repeat Latecomers table. */
interface RepeatLatecomerCell {
  status: ZoomProgramRepeatLatecomerStatus;
  lateMinutes: number | null;
}

/**
 * Powers the Overall Analytics (program-level) tab: the KPI strip
 * (Total Attendees/Active/Avg attendance/Eligible for next session) and the
 * session-timeline pills' per-session tiles. Served entirely from locally
 * stored data — no live Zoom polling, same as ZoomAnalyticsFacadeService's
 * other read paths.
 */
@Injectable()
export class ZoomProgramAnalyticsService {
  constructor(
    private readonly webinarRepository: ZoomWebinarRepository,
    private readonly registrationRepository: ZoomRegistrationRepository,
    private readonly attendeeSummaryRepository: ZoomAnalyticsAttendeeSummaryRepository,
    private readonly rosterRepository: ZoomAnalyticsRosterRepository,
    private readonly providerRegistry: ZoomAnalyticsProviderRegistry,
    private readonly onlineAttendanceService: OnlineAttendanceService,
    private readonly sessionCommunicationService: SessionCommunicationService,
    private readonly config: ZoomAnalyticsConfigService,
  ) {}

  async getOverallAnalytics(programId: number): Promise<ZoomProgramOverallAnalytics> {
    const [sessions, registrations] = await Promise.all([
      this.webinarRepository.findByProgramId(programId),
      this.registrationRepository.findAllRegistrationsByProgram(programId),
    ]);

    const nowMs = Date.now();
    const lateThresholdMs = this.config.getLateJoinThresholdSeconds() * 1000;
    const isHeld = (session: { startsAt: Date | null; endsAt: Date | null }): boolean => {
      const endMs = session.endsAt?.getTime() ?? session.startsAt?.getTime() ?? null;
      return endMs !== null && endMs < nowMs;
    };
    const heldSessionIds = new Set(sessions.filter(isHeld).map((session) => session.id));

    // One "who's present" lookup per HELD session (not per registration) — the same
    // is_attended-backed source getKpis()'s finalPresent/finalAbsent reads, so "Present"
    // here means the same thing as "Present" on that session's own Attendees screen.
    //
    // "Total Attendees" is likewise that session's own ROSTER (getKpis()'s totalPanelists),
    // NOT every program registration: a registration can be provisioned/active for some
    // sessions and not others (per-session activation, panelist-vs-registrant scoping for
    // webinars, etc.), and the roster is the query that already accounts for all of that —
    // reusing the whole-program registration list here would double-count deactivated or
    // never-provisioned registrants as "attendees" of a session they were never part of.
    //
    // Also re-syncs each held session's attendee-summary rows from the live event log (the
    // same self-heal `getAttendeeTable` runs on its own every read) — without this, `late`/
    // `joinedLate` below would read whatever `reconcile()` or an admin's last Attendees-tab
    // visit happened to leave in that table, which can disagree with that session's own
    // (always-live) Attendees screen for a session nobody has opened recently.
    const attendedIdsBySessionId = new Map<number, Set<number>>();
    const rosterIdsBySessionId = new Map<number, Set<number>>();
    // Union of every registrationId that has EVER appeared on any session's roster, plus a
    // fallback display name for each — the roster query deliberately includes soft-deleted
    // registrations (a session needs to keep showing who was actually there historically,
    // same reasoning as ZoomAnalyticsRosterRepository's own `.withDeleted()`), so a
    // registrant archived/deleted AFTER attending a session would otherwise silently drop out
    // of that session's own Joined Late tile and the Repeat Latecomers table below — both of
    // which currently loop over `registrations` (non-deleted only, see findAllRegistrationsByProgram's
    // own doc comment) rather than each session's roster.
    const rosterNameByRegistrationId = new Map<number, string | null>();
    for (const session of sessions) {
      if (!heldSessionIds.has(session.id)) continue;
      const [attendedIds, roster] = await Promise.all([
        this.onlineAttendanceService.getAttendedRegistrationIds(session.id),
        (async () => {
          const onlineSessionId = session.onlineSession?.id;
          if (!onlineSessionId) return [];
          return session.onlineType === OnlineTypeEnum.WEBINAR
            ? this.rosterRepository.findPanelistRosterByOnlineSession(onlineSessionId)
            : this.rosterRepository.findRegistrantRosterByOnlineSession(onlineSessionId);
        })(),
        this.providerRegistry
          .resolveForOnlineType(session.onlineType)
          .syncAttendeeSummaries(session),
      ]);
      attendedIdsBySessionId.set(session.id, attendedIds);
      rosterIdsBySessionId.set(session.id, new Set(roster.map((seeker) => seeker.registrationId)));
      for (const seeker of roster) {
        if (!rosterNameByRegistrationId.has(seeker.registrationId)) {
          rosterNameByRegistrationId.set(seeker.registrationId, seeker.fullName);
        }
      }
    }

    // Read AFTER the sync loop above, so `joinedAt` reflects the live event log rather than
    // a possibly-stale prior write.
    const summaries = await this.attendeeSummaryRepository.findAllByProgram(programId);

    const summaryByKey = new Map(
      summaries
        .filter((summary) => summary.registrationId !== null)
        .map((summary) => [`${summary.registrationId}:${summary.sessionId}`, summary]),
    );

    // Eligibility for the program's OWN final session: attend every one of the sessions
    // before it, or you're out — "before it" is however many sessions this program
    // actually has minus one, not a fixed count (a 3-session program's gate is S1+S2;
    // a 20-session program's gate is S1..S19).
    const threshold = Math.max(0, sessions.length - 1);
    const eligibilitySessions = sessions.slice(0, threshold);
    const eligibilityDecided =
      eligibilitySessions.length === threshold &&
      eligibilitySessions.every((session) => heldSessionIds.has(session.id));

    const registrationsById = new Map(
      registrations.map((registration) => [registration.registrationId, registration]),
    );
    // Every registration that's either currently active/non-deleted OR has ever shown up on
    // some session's roster — the latter covers a registrant who was archived/deleted AFTER
    // attending, so their historical per-session late/attended facts (and Repeat Latecomers
    // row) don't silently vanish just because they're no longer a current registration.
    const allRegistrationIds = new Set<number>([
      ...registrations.map((registration) => registration.registrationId),
      ...rosterNameByRegistrationId.keys(),
    ]);

    const rowsBySessionId = new Map<number, SessionAttendanceRow[]>();
    const repeatLatecomerCellsByRegistration = new Map<number, RepeatLatecomerCell[]>();
    const seekerNameByRegistrationId = new Map<number, string | null>();
    let totalAttendeeCount = 0;
    let activeCount = 0;
    let attendancePercentSum = 0;
    let attendancePercentCount = 0;
    let eligibleCount = 0;

    for (const registrationId of allRegistrationIds) {
      // Only a still-current registration counts toward these three program-wide KPIs —
      // matching findAllRegistrationsByProgram's own documented scope (Total Attendees/
      // Active/Avg attendance are about the program's CURRENT roster, not its full history).
      const registration = registrationsById.get(registrationId);
      seekerNameByRegistrationId.set(
        registrationId,
        registration?.fullName ?? rosterNameByRegistrationId.get(registrationId) ?? null,
      );
      if (registration) {
        if (registration.seatAllocated) totalAttendeeCount++;
        if (registration.activationStatus === RegistrationOnlineSessionActivationStatus.ACTIVE)
          activeCount++;
      }

      let sessionsAttended = 0;
      let sessionsHeldForRegistration = 0;
      let eligibilityAllAttended = true;
      let eligibilityRowIndex = 0;

      for (const session of sessions) {
        const held = heldSessionIds.has(session.id);
        const summary = summaryByKey.get(`${registrationId}:${session.id}`);

        const attended = held
          ? (attendedIdsBySessionId.get(session.id)?.has(registrationId) ?? false)
          : false;

        const joinedAt = summary?.joinedAt ?? null;
        const startMs = session.startsAt?.getTime() ?? null;
        const late = !!(
          joinedAt &&
          startMs !== null &&
          joinedAt.getTime() - startMs > lateThresholdMs
        );

        if (held) {
          sessionsHeldForRegistration++;
          if (attended) sessionsAttended++;
        }
        if (eligibilityRowIndex < threshold) {
          if (!attended) eligibilityAllAttended = false;
          eligibilityRowIndex++;
        }

        // The timeline tile only ever counts this session's own roster (see the
        // rosterIdsBySessionId comment above) — sessionsAttended/eligibility above stay
        // unscoped, matching getSeekerAnalytics's own (also roster-agnostic) definition.
        if (held && rosterIdsBySessionId.get(session.id)?.has(registrationId)) {
          const bucket = rowsBySessionId.get(session.id) ?? [];
          bucket.push({ attended, late });
          rowsBySessionId.set(session.id, bucket);
        }

        // Repeat Latecomers' pattern cells — one per held session, in schedule order.
        // `late` takes precedence over `attended`: joining late is a fact about the raw
        // join event, independent of whether a manual RM/Coordinator override later marked
        // the seeker finally present or finally absent (same principle as the per-session
        // Late Comers widget and the Joined Late timeline tile above — a late joiner must
        // never be relabeled "absent" just because their final attendance ended up absent).
        if (held) {
          const status: ZoomProgramRepeatLatecomerStatus = late
            ? 'late'
            : !attended
              ? 'absent'
              : 'onTime';
          const lateMinutes =
            late && joinedAt && startMs !== null
              ? Math.round((joinedAt.getTime() - startMs) / 60_000)
              : null;
          const cells = repeatLatecomerCellsByRegistration.get(registrationId) ?? [];
          cells.push({ status, lateMinutes });
          repeatLatecomerCellsByRegistration.set(registrationId, cells);
        }
      }

      if (registration) {
        if (sessionsHeldForRegistration > 0) {
          attendancePercentSum += Math.round(
            (sessionsAttended / sessionsHeldForRegistration) * 100,
          );
          attendancePercentCount++;
        }
        if (eligibilityDecided && eligibilityAllAttended) eligibleCount++;
      }
    }

    // Held sessions only, in schedule order — the columns/labels shared by the
    // Repeat Latecomers table and the Multiple Logins per session chart.
    const heldSessionsInOrder = sessions
      .map((session, index) => ({ session, label: `S${index + 1}` }))
      .filter(({ session }) => heldSessionIds.has(session.id));

    const repeatLatecomerRows: ZoomProgramRepeatLatecomerRow[] = [...allRegistrationIds]
      .map((registrationId) => {
        const cells = repeatLatecomerCellsByRegistration.get(registrationId) ?? [];
        const lateCells = cells.filter((cell) => cell.status === 'late');
        const lateCount = lateCells.length;
        const avgLateMinutes =
          lateCount > 0
            ? Math.round(
                lateCells.reduce((sum, cell) => sum + (cell.lateMinutes ?? 0), 0) / lateCount,
              )
            : null;
        return {
          registrationId,
          seekerName: seekerNameByRegistrationId.get(registrationId) ?? '—',
          statuses: cells.map((cell) => cell.status),
          lateCount,
          avgLateMinutes,
        };
      })
      .filter((row) => row.lateCount > 0)
      .sort((a, b) => b.lateCount - a.lateCount)
      .slice(0, REPEAT_LATECOMERS_LIMIT);

    // Multi-device counts come straight from the bulk attendee summaries already fetched
    // above — no extra query, and no need for getDashboard()'s heavier per-session call.
    const multiDeviceCountBySessionId = new Map<number, number>();
    for (const summary of summaries) {
      if ((summary.noOfDevices ?? 0) > 1 && heldSessionIds.has(summary.sessionId)) {
        multiDeviceCountBySessionId.set(
          summary.sessionId,
          (multiDeviceCountBySessionId.get(summary.sessionId) ?? 0) + 1,
        );
      }
    }
    const multiDeviceColumns: ZoomProgramMultiDeviceColumn[] = heldSessionsInOrder.map(
      ({ session, label }) => ({
        sessionId: session.id,
        label,
        count: multiDeviceCountBySessionId.get(session.id) ?? 0,
      }),
    );

    const timeline: ZoomProgramSessionTimelineEntry[] = sessions.map((session, index) => {
      const held = heldSessionIds.has(session.id);
      const base = {
        sessionId: session.id,
        label: `S${index + 1}`,
        startsAt: session.startsAt ?? null,
      };
      if (!held) {
        return { ...base, status: 'upcoming' as const, commsChecklist: [] };
      }
      const rows = rowsBySessionId.get(session.id) ?? [];
      const totalAttendees = rosterIdsBySessionId.get(session.id)?.size ?? 0;
      const presentCount = rows.filter((row) => row.attended).length;
      const kpis: ZoomProgramSessionKpiTiles = {
        totalAttendees,
        present: presentCount,
        absent: totalAttendees - presentCount,
        joinedLate: rows.filter((row) => row.late).length,
      };
      return { ...base, status: 'completed' as const, kpis };
    });

    // The Latecomers heatmap reuses getDashboard()'s own lateComers computation (the
    // exact same one behind the per-session Late Comers widget) rather than
    // re-deriving it here — one call per HELD session, never per registration.
    const latecomerColumns: ZoomProgramLatecomerColumn[] = [];
    for (const [index, session] of sessions.entries()) {
      if (!heldSessionIds.has(session.id)) continue;
      const dashboard = await this.providerRegistry
        .resolveForOnlineType(session.onlineType)
        .getDashboard(session);
      const countByLabel = new Map(
        dashboard.lateComers.buckets.map((bucket) => [bucket.label, bucket.count]),
      );
      latecomerColumns.push({
        sessionId: session.id,
        label: `S${index + 1}`,
        countsByBand: ZOOM_ANALYTICS_LATE_COMER_BUCKETS.map(
          (bucket) => countByLabel.get(bucket.label) ?? 0,
        ),
      });
    }

    const upcomingSessionIds = timeline
      .filter((entry) => entry.status === 'upcoming')
      .map((entry) => entry.sessionId);
    const triggeredBySessionId =
      await this.sessionCommunicationService.getTriggeredPurposesForSessions(
        programId,
        upcomingSessionIds,
      );
    for (const entry of timeline) {
      if (entry.status !== 'upcoming') continue;
      const purposes = triggeredBySessionId.get(entry.sessionId) ?? [];
      entry.commsChecklist = purposes.reduce<ZoomProgramCommsChecklistItem[]>((items, purpose) => {
        const label = SESSION_COMMS_PURPOSE_LABELS[purpose];
        if (label) items.push({ purpose, label });
        return items;
      }, []);
    }

    return {
      programId,
      kpis: {
        totalAttendees: totalAttendeeCount,
        active: activeCount,
        avgAttendancePercent:
          attendancePercentCount > 0
            ? Math.round(attendancePercentSum / attendancePercentCount)
            : null,
        eligibleForNextSession: eligibleCount,
        thresholdSession: threshold,
      },
      timeline,
      latecomers: {
        bandLabels: ZOOM_ANALYTICS_LATE_COMER_BUCKETS.map((bucket) => bucket.label),
        columns: latecomerColumns,
      },
      repeatLatecomers: {
        sessionLabels: heldSessionsInOrder.map(({ label }) => label),
        rows: repeatLatecomerRows,
      },
      multipleLogins: {
        columns: multiDeviceColumns,
      },
    };
  }
}
