import { ValidationError } from '../errors/index.js';

/**
 * Schedule conflict detection (SPEC.md §18).
 *
 * All checks run server-side. Faculty and room double-booking are hard blocks
 * (SPEC.md §33.12); section over-capacity and student conflicts are blocks by
 * default but can be relaxed by configuration (ASSUMPTIONS.md D6).
 */

export const DAYS_OF_WEEK = [
  'MONDAY',
  'TUESDAY',
  'WEDNESDAY',
  'THURSDAY',
  'FRIDAY',
  'SATURDAY',
  'SUNDAY',
] as const;

export type DayOfWeek = (typeof DAYS_OF_WEEK)[number];

export type Modality = 'ONSITE' | 'ONLINE_SYNCHRONOUS' | 'ONLINE_ASYNCHRONOUS' | 'HYBRID';

/**
 * One recurring class meeting. Times are minutes from midnight in the
 * institution's timezone, which makes overlap arithmetic exact and free of
 * timezone drift — the conversion happens once, at the edge.
 */
export interface MeetingSlot {
  readonly id: string;
  readonly sectionOfferingId: string;
  readonly dayOfWeek: DayOfWeek;
  /** Inclusive start, minutes from midnight. `07:30` is 450. */
  readonly startMinutes: number;
  /** Exclusive end, minutes from midnight. A class ending at 09:00 is 540. */
  readonly endMinutes: number;
  readonly roomId: string | null;
  readonly facultyProfileId: string | null;
  readonly modality: Modality;
  /** Optional effectivity window for short courses; null means the whole term. */
  readonly effectiveFrom?: Date | null;
  readonly effectiveTo?: Date | null;
}

export type ConflictKind =
  | 'FACULTY_DOUBLE_BOOKED'
  | 'ROOM_DOUBLE_BOOKED'
  | 'STUDENT_SCHEDULE_CONFLICT'
  | 'SECTION_OVER_CAPACITY';

export interface ScheduleConflict {
  readonly kind: ConflictKind;
  readonly severity: 'BLOCK' | 'WARN';
  readonly message: string;
  /** The two meetings that clash, or one meeting for capacity conflicts. */
  readonly slotIds: readonly string[];
  readonly sectionOfferingIds: readonly string[];
}

export function minutesToTimeString(minutes: number): string {
  const hours = Math.floor(minutes / 60);
  const mins = minutes % 60;
  return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}

export function timeStringToMinutes(value: string): number {
  const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
  if (!match) {
    throw new ValidationError(`"${value}" is not a valid 24-hour time (HH:MM).`);
  }
  return Number(match[1]) * 60 + Number(match[2]);
}

export function validateSlot(slot: MeetingSlot): void {
  if (slot.startMinutes < 0 || slot.endMinutes > 24 * 60) {
    throw new ValidationError('A class meeting must fall within a single day.');
  }
  if (slot.endMinutes <= slot.startMinutes) {
    throw new ValidationError('A class meeting must end after it starts.');
  }
}

/** Whether two date windows overlap. A null bound means "unbounded". */
function datesOverlap(a: MeetingSlot, b: MeetingSlot): boolean {
  const aFrom = a.effectiveFrom?.getTime() ?? Number.NEGATIVE_INFINITY;
  const aTo = a.effectiveTo?.getTime() ?? Number.POSITIVE_INFINITY;
  const bFrom = b.effectiveFrom?.getTime() ?? Number.NEGATIVE_INFINITY;
  const bTo = b.effectiveTo?.getTime() ?? Number.POSITIVE_INFINITY;
  return aFrom <= bTo && bFrom <= aTo;
}

/**
 * Whether two meetings occupy the same time. End times are exclusive, so a
 * class ending at 09:00 does not clash with one starting at 09:00.
 */
export function slotsOverlap(a: MeetingSlot, b: MeetingSlot): boolean {
  if (a.dayOfWeek !== b.dayOfWeek) return false;
  if (a.startMinutes >= b.endMinutes || b.startMinutes >= a.endMinutes) return false;
  return datesOverlap(a, b);
}

/**
 * Asynchronous online meetings do not occupy a person or a room at a fixed
 * time, so they are excluded from double-booking checks.
 */
function occupiesRealTime(slot: MeetingSlot): boolean {
  return slot.modality !== 'ONLINE_ASYNCHRONOUS';
}

/** A room is only contended for when the meeting is physically on site. */
function occupiesRoom(slot: MeetingSlot): boolean {
  return slot.roomId !== null && (slot.modality === 'ONSITE' || slot.modality === 'HYBRID');
}

/**
 * Finds every conflict within a set of meetings — used when publishing a term's
 * schedule, and again when a single new meeting is added.
 */
export function detectMeetingConflicts(slots: readonly MeetingSlot[]): ScheduleConflict[] {
  const conflicts: ScheduleConflict[] = [];

  for (let i = 0; i < slots.length; i += 1) {
    const a = slots[i];
    if (!a) continue;
    for (let j = i + 1; j < slots.length; j += 1) {
      const b = slots[j];
      if (!b) continue;
      if (!slotsOverlap(a, b)) continue;

      if (
        a.facultyProfileId !== null &&
        a.facultyProfileId === b.facultyProfileId &&
        occupiesRealTime(a) &&
        occupiesRealTime(b)
      ) {
        conflicts.push({
          kind: 'FACULTY_DOUBLE_BOOKED',
          severity: 'BLOCK',
          message: `The assigned faculty member is already teaching on ${formatDay(a.dayOfWeek)} from ${minutesToTimeString(b.startMinutes)} to ${minutesToTimeString(b.endMinutes)}.`,
          slotIds: [a.id, b.id],
          sectionOfferingIds: [a.sectionOfferingId, b.sectionOfferingId],
        });
      }

      if (a.roomId !== null && a.roomId === b.roomId && occupiesRoom(a) && occupiesRoom(b)) {
        conflicts.push({
          kind: 'ROOM_DOUBLE_BOOKED',
          severity: 'BLOCK',
          message: `The room is already in use on ${formatDay(a.dayOfWeek)} from ${minutesToTimeString(b.startMinutes)} to ${minutesToTimeString(b.endMinutes)}.`,
          slotIds: [a.id, b.id],
          sectionOfferingIds: [a.sectionOfferingId, b.sectionOfferingId],
        });
      }
    }
  }

  return conflicts;
}

/**
 * Conflicts within one student's proposed load. Two meetings of *different*
 * sections that overlap are a clash; two meetings of the same section never are.
 */
export function detectStudentScheduleConflicts(slots: readonly MeetingSlot[]): ScheduleConflict[] {
  const conflicts: ScheduleConflict[] = [];

  for (let i = 0; i < slots.length; i += 1) {
    const a = slots[i];
    if (!a || !occupiesRealTime(a)) continue;
    for (let j = i + 1; j < slots.length; j += 1) {
      const b = slots[j];
      if (!b || !occupiesRealTime(b)) continue;
      if (a.sectionOfferingId === b.sectionOfferingId) continue;
      if (!slotsOverlap(a, b)) continue;

      conflicts.push({
        kind: 'STUDENT_SCHEDULE_CONFLICT',
        severity: 'BLOCK',
        message: `Two selected subjects meet at the same time on ${formatDay(a.dayOfWeek)} (${minutesToTimeString(a.startMinutes)}–${minutesToTimeString(a.endMinutes)}).`,
        slotIds: [a.id, b.id],
        sectionOfferingIds: [a.sectionOfferingId, b.sectionOfferingId],
      });
    }
  }

  return conflicts;
}

export interface CapacityCheckInput {
  readonly sectionOfferingId: string;
  readonly capacity: number;
  readonly enrolledCount: number;
  readonly sectionName: string;
}

/**
 * Over-capacity check. Severity follows institutional configuration: a block by
 * default, downgraded to a warning when overloading is permitted.
 */
export function detectCapacityConflict(
  input: CapacityCheckInput,
  allowOverload: boolean,
): ScheduleConflict | null {
  if (input.enrolledCount < input.capacity) return null;

  return {
    kind: 'SECTION_OVER_CAPACITY',
    severity: allowOverload ? 'WARN' : 'BLOCK',
    message: `Section ${input.sectionName} is at capacity (${input.enrolledCount}/${input.capacity}).`,
    slotIds: [],
    sectionOfferingIds: [input.sectionOfferingId],
  };
}

export function hasBlockingConflict(conflicts: readonly ScheduleConflict[]): boolean {
  return conflicts.some((conflict) => conflict.severity === 'BLOCK');
}

function formatDay(day: DayOfWeek): string {
  return day.charAt(0) + day.slice(1).toLowerCase();
}

/** Teaching load totals for a faculty member, used by the Dean's load view. */
export interface FacultyLoadSummary {
  readonly sectionCount: number;
  readonly totalUnits: number;
  readonly lectureHoursPerWeek: number;
  readonly laboratoryHoursPerWeek: number;
  readonly totalHoursPerWeek: number;
}

export interface LoadItem {
  readonly sectionOfferingId: string;
  readonly lectureUnits: number;
  readonly laboratoryUnits: number;
  readonly slots: readonly MeetingSlot[];
  readonly isLaboratory: boolean;
}

/**
 * Computes a faculty member's weekly load. Hours come from the actual scheduled
 * meetings rather than from a units-to-hours assumption, so a laboratory block
 * that runs three hours for one unit is counted correctly.
 */
export function computeFacultyLoad(items: readonly LoadItem[]): FacultyLoadSummary {
  let lectureMinutes = 0;
  let laboratoryMinutes = 0;
  let totalUnits = 0;

  for (const item of items) {
    totalUnits += item.lectureUnits + item.laboratoryUnits;
    for (const slot of item.slots) {
      const minutes = slot.endMinutes - slot.startMinutes;
      if (item.isLaboratory) {
        laboratoryMinutes += minutes;
      } else {
        lectureMinutes += minutes;
      }
    }
  }

  const round = (minutes: number): number => Math.round((minutes / 60) * 100) / 100;

  return {
    sectionCount: new Set(items.map((item) => item.sectionOfferingId)).size,
    totalUnits,
    lectureHoursPerWeek: round(lectureMinutes),
    laboratoryHoursPerWeek: round(laboratoryMinutes),
    totalHoursPerWeek: round(lectureMinutes + laboratoryMinutes),
  };
}
