/**
 * Shapes of the JSON columns `Venue.settings` and `Event.config`, plus the venue-local time helpers the
 * always-on scheduler and metrics need. Time zone maths uses Intl (full ICU) on wall-clock values, so
 * DST transitions never shift a business date or a schedule slot.
 */
import { z } from 'zod';
import { RoundKind } from './generated/prisma/enums';

// ───────────────────────────── Shared primitives ─────────────────────────────

const HH_MM = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const MINUTES_PER_DAY = 24 * 60;
const DAY_MS = 86_400_000;

/** Venue-local hour before which the night still belongs to the previous business date. */
export const BUSINESS_DAY_CUTOFF_HOUR = 5;

export const packKeySchema = z
  .string()
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Pack keys are lower-case words joined by hyphens');

/** Opening time: 00:00–23:59. */
export const openTimeSchema = z.string().regex(HH_MM, 'Expected HH:MM between 00:00 and 23:59');
/** Closing time: 00:00–23:59 or 24:00 (end of the day). */
export const closeTimeSchema = z
  .string()
  .refine((v) => v === '24:00' || HH_MM.test(v), 'Expected HH:MM between 00:00 and 24:00');

export function isValidTimeZone(timeZone: string): boolean {
  if (!timeZone) return false;
  try {
    new Intl.DateTimeFormat('en-US', { timeZone });
    return true;
  } catch {
    return false;
  }
}

export const timeZoneSchema = z.string().refine(isValidTimeZone, 'Unknown IANA time zone');

/** 'HH:MM' → minutes since midnight ('24:00' → 1440). Assumes a value that passed the schemas above. */
export function timeToMinutes(hhmm: string): number {
  const [h, m] = hhmm.split(':');
  return Number(h) * 60 + Number(m);
}

// ───────────────────────────── Venue settings ─────────────────────────────

/**
 * One opening window. `day` is the weekday the window OPENS on (0 = Sunday … 6 = Saturday).
 * `close <= open` means the window runs past midnight into the next day (Fri 11:00–02:00 covers
 * Saturday 01:30); `open == close` therefore means 24 hours from `open`.
 */
export const scheduleSlotSchema = z.object({
  day: z.number().int().min(0).max(6),
  open: openTimeSchema,
  close: closeTimeSchema,
});
export type ScheduleSlot = z.output<typeof scheduleSlotSchema>;

export const alwaysOnSettingsSchema = z.object({
  enabled: z.boolean().default(false),
  packKey: packKeySchema.default('originals-v1'),
  schedule: z.array(scheduleSlotSchema).max(50).default([]),
});

export const venueSettingsSchema = z.object({
  alwaysOn: alwaysOnSettingsSchema.prefault({}),
  /** Teams of 1 ranked with everyone (true) or on a separate Solo board (false, default). */
  mergeSolos: z.boolean().default(false),
});

export type VenueSettings = z.output<typeof venueSettingsSchema>;
export type VenueSettingsInput = z.input<typeof venueSettingsSchema>;

/** Parse `Venue.settings` (null/undefined → defaults). Throws a ZodError on invalid data. */
export function parseVenueSettings(json: unknown): VenueSettings {
  return venueSettingsSchema.parse(json ?? {});
}

export const DEFAULT_VENUE_SETTINGS: VenueSettings = parseVenueSettings({});

// ───────────────────────────── Event config ─────────────────────────────

export const roundPlanItemSchema = z.object({
  kind: z.enum(RoundKind),
  packKey: packKeySchema,
  /** Human label shown on TV, e.g. "The Rush". */
  label: z.string().trim().min(1).max(40),
  /** Segment index; rounds of one segment share a label and a shorter break. */
  segment: z.number().int().min(0).max(50),
});
export type RoundPlanItem = z.output<typeof roundPlanItemSchema>;

/**
 * Default 50-minute championship. Must stay identical to DEFAULT_CHAMPIONSHIP_PLAN in
 * packages/engine/src/types.ts (db does not depend on the engine; a test guards the copy).
 */
export const DEFAULT_CHAMPIONSHIP_PLAN: readonly Readonly<RoundPlanItem>[] = Object.freeze(
  [
    { kind: 'RUSH', packKey: 'originals-v1', label: 'The Rush', segment: 0 },
    { kind: 'RUSH', packKey: 'originals-v1', label: 'The Rush', segment: 0 },
    { kind: 'MUSIC_MAYHEM', packKey: 'music-mayhem-v1', label: 'Music Mayhem', segment: 1 },
    { kind: 'MUSIC_MAYHEM', packKey: 'music-mayhem-v1', label: 'Music Mayhem', segment: 1 },
    { kind: 'TABLE_WARS', packKey: 'originals-v1', label: 'Table Wars', segment: 2 },
    { kind: 'TABLE_WARS', packKey: 'originals-v1', label: 'Table Wars', segment: 2 },
    { kind: 'POWER_ROUND', packKey: 'originals-v1', label: 'Power Round', segment: 3 },
    { kind: 'POWER_ROUND', packKey: 'originals-v1', label: 'Power Round', segment: 3 },
    { kind: 'FINAL_RUSH', packKey: 'originals-v1', label: 'Final Rush', segment: 4 },
  ].map((item) => Object.freeze(item as RoundPlanItem)),
);

/** Fresh, mutable copy of the default plan (safe to hand to the engine). */
export function defaultChampionshipPlan(): RoundPlanItem[] {
  return DEFAULT_CHAMPIONSHIP_PLAN.map((item) => ({ ...item }));
}

const ms = (min: number, max: number) => z.number().int().min(min).max(max);

/** Partial override of the engine's DEFAULT_TIMINGS (all values in milliseconds). */
export const timingsOverrideSchema = z
  .object({
    introMs: ms(0, 60_000),
    pendingMs: ms(0, 60_000),
    windowMs: ms(3_000, 120_000),
    difficultWindowMs: ms(3_000, 120_000),
    lockedMs: ms(0, 30_000),
    revealMs: ms(0, 60_000),
    graceMs: ms(0, 5_000),
    roundOverMs: ms(0, 120_000),
    breakMs: ms(0, 600_000),
  })
  .partial()
  .refine(
    (t) => t.graceMs === undefined || t.windowMs === undefined || t.graceMs < t.windowMs,
    'graceMs must be shorter than windowMs',
  );
export type TimingsOverride = z.output<typeof timingsOverrideSchema>;

export const eventConfigSchema = z.object({
  plan: z
    .array(roundPlanItemSchema)
    .min(1)
    .max(30)
    .default(defaultChampionshipPlan)
    .refine(
      (plan) => plan.every((item, i) => i === 0 || item.segment >= plan[i - 1]!.segment),
      'Plan segments must not decrease',
    ),
  mergeSolos: z.boolean().default(false),
  timings: timingsOverrideSchema.optional(),
});
export type EventConfig = z.output<typeof eventConfigSchema>;
export type EventConfigInput = z.input<typeof eventConfigSchema>;

/** Parse `Event.config` (null/undefined → defaults). Throws a ZodError on invalid data. */
export function parseEventConfig(json: unknown): EventConfig {
  return eventConfigSchema.parse(json ?? {});
}

// ───────────────────────────── Venue-local time ─────────────────────────────

export interface ZonedParts {
  year: number;
  month: number; // 1–12
  day: number;
  hour: number; // 0–23
  minute: number;
  second: number;
  weekday: number; // 0 = Sunday
}

const WEEKDAY_INDEX: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
const formatters = new Map<string, Intl.DateTimeFormat>();

function formatterFor(timeZone: string): Intl.DateTimeFormat {
  let f = formatters.get(timeZone);
  if (!f) {
    f = new Intl.DateTimeFormat('en-US', {
      timeZone,
      hourCycle: 'h23',
      year: 'numeric',
      month: 'numeric',
      day: 'numeric',
      hour: 'numeric',
      minute: 'numeric',
      second: 'numeric',
      weekday: 'short',
    });
    formatters.set(timeZone, f);
  }
  return f;
}

/** Wall-clock fields of an instant in a time zone. Throws RangeError for an unknown zone. */
export function zonedParts(timeZone: string, epochMs: number): ZonedParts {
  const out: ZonedParts = { year: 0, month: 0, day: 0, hour: 0, minute: 0, second: 0, weekday: 0 };
  for (const part of formatterFor(timeZone).formatToParts(epochMs)) {
    switch (part.type) {
      case 'year':
        out.year = Number(part.value);
        break;
      case 'month':
        out.month = Number(part.value);
        break;
      case 'day':
        out.day = Number(part.value);
        break;
      case 'hour':
        out.hour = Number(part.value) % 24; // some ICU builds render midnight as "24"
        break;
      case 'minute':
        out.minute = Number(part.value);
        break;
      case 'second':
        out.second = Number(part.value);
        break;
      case 'weekday':
        out.weekday = WEEKDAY_INDEX[part.value] ?? 0;
        break;
    }
  }
  return out;
}

/** The wall clock of `epochMs` in `timeZone`, encoded as if it were a UTC instant (second precision). */
function wallClockMs(timeZone: string, epochMs: number): number {
  const p = zonedParts(timeZone, epochMs);
  return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
}

const pad = (n: number, width = 2) => String(n).padStart(width, '0');

function isoDate(year: number, month: number, day: number): string {
  return `${pad(year, 4)}-${pad(month)}-${pad(day)}`;
}

function parseIsoDate(date: string): { year: number; month: number; day: number } {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
  if (!m) throw new RangeError(`Expected YYYY-MM-DD, got "${date}"`);
  const year = Number(m[1]);
  const month = Number(m[2]);
  const day = Number(m[3]);
  const check = new Date(Date.UTC(year, month - 1, day));
  if (check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) throw new RangeError(`Invalid date "${date}"`);
  return { year, month, day };
}

/** Calendar arithmetic on dates (no time zone involved, so DST cannot interfere). */
export function addDaysToDate(date: string, days: number): string {
  const { year, month, day } = parseIsoDate(date);
  const d = new Date(Date.UTC(year, month - 1, day + days));
  return isoDate(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}

function weekdayOfDate(date: string): number {
  const { year, month, day } = parseIsoDate(date);
  return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
}

/** Venue-local calendar date of an instant ('YYYY-MM-DD'). */
export function localDate(timeZone: string, epochMs: number): string {
  const p = zonedParts(timeZone, epochMs);
  return isoDate(p.year, p.month, p.day);
}

/**
 * Venue-local business date: before `cutoffHour` (default 05:00) the night still belongs to the
 * previous date, so Friday 01:30 is part of Thursday's always-on night.
 */
export function businessDate(timeZone: string, nowMs: number, cutoffHour: number = BUSINESS_DAY_CUTOFF_HOUR): string {
  const p = zonedParts(timeZone, nowMs);
  const date = isoDate(p.year, p.month, p.day);
  return p.hour < cutoffHour ? addDaysToDate(date, -1) : date;
}

/**
 * The instant at which the wall clock in `timeZone` reads `date time`. Ambiguous times (DST fall-back)
 * resolve to the first occurrence; non-existent times (spring-forward gap) resolve to the moment the
 * clock jumps past them. `time` may be '24:00' (midnight at the end of `date`).
 */
export function zonedDateTimeToEpoch(timeZone: string, date: string, time: string): number {
  const { year, month, day } = parseIsoDate(date);
  if (!(time === '24:00' || HH_MM.test(time))) throw new RangeError(`Expected HH:MM, got "${time}"`);
  const wall = Date.UTC(year, month - 1, day, 0, timeToMinutes(time));

  // Real offsets are within ±14 h, so the instant lies in [wall − 14h, wall + 14h]; sampling the
  // offset at both ends and the middle finds every offset in force there (at most one transition).
  const candidates = [...new Set([-14, 0, 14].map((h) => wall - (wallClockMs(timeZone, wall + h * 3_600_000) - (wall + h * 3_600_000))))].sort(
    (a, b) => a - b,
  );
  const exact = candidates.find((c) => wallClockMs(timeZone, c) === wall);
  if (exact !== undefined) return exact;

  // Spring-forward gap: binary-search the first second whose wall clock is at or past `wall`.
  let lo = Math.floor(candidates[0]! / 1000);
  let hi = Math.ceil(candidates[candidates.length - 1]! / 1000);
  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    if (wallClockMs(timeZone, mid * 1000) >= wall) hi = mid;
    else lo = mid + 1;
  }
  return lo * 1000;
}

// ───────────────────────────── Always-on schedule ─────────────────────────────

function slotCovers(slot: ScheduleSlot, weekday: number, minuteOfDay: number): boolean {
  const open = timeToMinutes(slot.open);
  const close = timeToMinutes(slot.close);
  if (close > open) return slot.day === weekday && minuteOfDay >= open && minuteOfDay < close;
  // Past midnight: [open, 24:00) on slot.day, then [00:00, close) on the following day.
  const yesterday = (weekday + 6) % 7;
  return (slot.day === weekday && minuteOfDay >= open) || (slot.day === yesterday && minuteOfDay < close);
}

/** True when `nowMs` falls inside any slot of `schedule`, evaluated on the venue's wall clock. */
export function scheduleCovers(schedule: readonly ScheduleSlot[], timeZone: string, nowMs: number): boolean {
  if (schedule.length === 0) return false;
  const p = zonedParts(timeZone, nowMs);
  const minuteOfDay = p.hour * 60 + p.minute;
  return schedule.some((slot) => slotCovers(slot, p.weekday, minuteOfDay));
}

/** True when always-on is enabled AND `nowMs` is inside the venue's opening schedule. */
export function isWithinSchedule(settings: VenueSettings, timeZone: string, nowMs: number): boolean {
  return settings.alwaysOn.enabled && scheduleCovers(settings.alwaysOn.schedule, timeZone, nowMs);
}

/**
 * When always-on next becomes active: `nowMs` if it already is, otherwise the earliest upcoming slot
 * opening (within the next week). Null when always-on is disabled or the schedule is empty.
 */
export function nextOpening(settings: VenueSettings, timeZone: string, nowMs: number): number | null {
  const { enabled, schedule } = settings.alwaysOn;
  if (!enabled || schedule.length === 0) return null;
  if (scheduleCovers(schedule, timeZone, nowMs)) return nowMs;

  const today = localDate(timeZone, nowMs);
  let best: number | null = null;
  for (let offset = 0; offset <= 7; offset++) {
    const date = addDaysToDate(today, offset);
    const weekday = weekdayOfDate(date);
    for (const slot of schedule) {
      if (slot.day !== weekday) continue;
      const at = zonedDateTimeToEpoch(timeZone, date, slot.open);
      if (at > nowMs && (best === null || at < best) && scheduleCovers(schedule, timeZone, at)) best = at;
    }
    if (best !== null) return best; // later dates can only open later
  }
  return best;
}

/** Minutes in a day, exported for UIs rendering schedules. */
export const MINUTES_IN_DAY = MINUTES_PER_DAY;
export const ONE_DAY_MS = DAY_MS;
