/**
 * Outbox send-timing scheduler (Handoff 03, the "core new logic").
 *
 * Given when a lead last messaged (`recvAt`), the current time (`now`), and the
 * recipient's timezone, guess a humane `notBefore` send time + a queue priority.
 * Goals: be timely for live/hot leads, human-paced otherwise, bias into the
 * lead's own rhythm (the day-part they reached out in), and never cold-text in
 * the dead-of-night.
 *
 * Pure and deterministic: `now` and the jitter source are injected, so every
 * branch is unit-testable without wall-clock or RNG.
 */

import { DEFAULT_TIMEZONE } from './timezone';

export type Priority = 'HOT' | 'HIGH' | 'NORMAL' | 'LOW' | 'STALE';
export type DayPart = 'DEAD' | 'MORNING' | 'AFTERNOON' | 'EVENING' | 'LATE';

export interface SchedulerConfig {
  /** Recency under this is LIVE/HOT — send promptly regardless of day-part. */
  readonly liveWindowMs: number;
  /** HOT send delay window (also the undo window), seconds. */
  readonly hotDelaySec: readonly [number, number];
  /** Paced jitter for in-window cold sends, seconds. */
  readonly pacedDelaySec: readonly [number, number];
  /** Send-friendly window, recipient-local hours: [open, close) with close < open meaning wrap past midnight. */
  readonly windowOpenHour: number; // 9  → 09:00
  readonly windowCloseHour: number; // 1 → 01:00 next day
  /** On Fri/Sat the window extends later (LATE is prime for this work). */
  readonly weekendCloseHour: number; // 3 → 03:00 next day
  /** No cold sends inside [quietStart, quietEnd) local. */
  readonly quietStartHour: number; // 3
  readonly quietEndHour: number; // 9
  /** Floor for a deferred send hour (never schedule the "open" before this). */
  readonly minDeferHour: number; // 10
  readonly defaultTimezone: string;
}

export const DEFAULT_SCHEDULER_CONFIG: SchedulerConfig = Object.freeze({
  liveWindowMs: 60 * 60 * 1000,
  hotDelaySec: [60, 180],
  pacedDelaySec: [20, 90],
  windowOpenHour: 9,
  windowCloseHour: 1,
  weekendCloseHour: 3,
  quietStartHour: 3,
  quietEndHour: 9,
  minDeferHour: 10,
  defaultTimezone: DEFAULT_TIMEZONE,
});

export interface ScheduleInput {
  /** Epoch ms of the lead's last inbound message. */
  readonly recvAt: number;
  /** Epoch ms "now". */
  readonly now: number;
  /** Recipient IANA timezone; defaults to config.defaultTimezone. */
  readonly tz?: string;
  /** 0..1 source for jitter; defaults to Math.random. Inject for tests. */
  readonly rand?: () => number;
}

export interface ScheduleResult {
  readonly priority: Priority;
  /** Epoch ms the send becomes eligible. */
  readonly notBefore: number;
  /** Recipient day-part at the time they messaged (drives rhythm bias). */
  readonly recvDayPart: DayPart;
  /** Human-readable explanation for tuning/observability. */
  readonly reason: string;
}

const HOUR = 60 * 60 * 1000;

/** Recency → queue priority / SLA bucket. */
export function derivePriority(ageMs: number): Priority {
  if (ageMs < 60 * 60 * 1000) return 'HOT';
  if (ageMs < 6 * HOUR) return 'HIGH';
  if (ageMs < 24 * HOUR) return 'NORMAL';
  if (ageMs < 72 * HOUR) return 'LOW';
  return 'STALE';
}

/** Recipient-local clock + calendar fields at an instant. */
interface LocalFields {
  readonly year: number;
  readonly month: number; // 1-12
  readonly day: number;
  readonly hour: number; // 0-23
  readonly minute: number;
  readonly second: number;
  readonly weekday: number; // 0=Sun … 6=Sat
}

const WEEKDAY_INDEX: Readonly<Record<string, number>> = {
  Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
};

function localFields(epochMs: number, tz: string): LocalFields {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone: tz,
    hour12: false,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    weekday: 'short',
  }).formatToParts(new Date(epochMs));

  const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '0';
  let hour = parseInt(get('hour'), 10);
  if (hour === 24) hour = 0; // some engines emit 24 for midnight
  return {
    year: parseInt(get('year'), 10),
    month: parseInt(get('month'), 10),
    day: parseInt(get('day'), 10),
    hour,
    minute: parseInt(get('minute'), 10),
    second: parseInt(get('second'), 10),
    weekday: WEEKDAY_INDEX[get('weekday')] ?? 0,
  };
}

/** Offset (ms) such that localWallClockAsUTC = epoch + offset, measured at `epochMs`. */
function tzOffsetMs(epochMs: number, tz: string): number {
  const f = localFields(epochMs, tz);
  const asUtc = Date.UTC(f.year, f.month - 1, f.day, f.hour, f.minute, f.second);
  return asUtc - epochMs;
}

/**
 * Epoch ms for a given recipient-local wall-clock hour on the local calendar
 * day of `referenceEpoch`, optionally rolled forward `dayOffset` days. DST is
 * approximated using the offset at `referenceEpoch` (±1h at most, twice a year).
 */
function zonedEpochForHour(referenceEpoch: number, tz: string, hour: number, dayOffset = 0): number {
  const f = localFields(referenceEpoch, tz);
  const offset = tzOffsetMs(referenceEpoch, tz);
  const wallUtc = Date.UTC(f.year, f.month - 1, f.day + dayOffset, hour, 0, 0);
  return wallUtc - offset;
}

export function dayPartForHour(hour: number): DayPart {
  if (hour >= 3 && hour < 9) return 'DEAD';
  if (hour >= 9 && hour < 12) return 'MORNING';
  if (hour >= 12 && hour < 17) return 'AFTERNOON';
  if (hour >= 17 && hour < 22) return 'EVENING';
  return 'LATE'; // 22:00–03:00
}

/** Is the local hour inside the send-friendly window (weekend-extended)? */
function inSendFriendlyWindow(hour: number, isWeekendNight: boolean, cfg: SchedulerConfig): boolean {
  const close = isWeekendNight ? cfg.weekendCloseHour : cfg.windowCloseHour;
  // Window wraps past midnight: [open, 24) ∪ [0, close).
  return hour >= cfg.windowOpenHour || hour < close;
}

/** Preferred deferred send hour: bias to the lead's day-part, floored to humane hours. */
function preferredDeferHour(recvDayPart: DayPart, cfg: SchedulerConfig): number {
  const byPart: Record<DayPart, number> = {
    DEAD: cfg.minDeferHour,
    MORNING: 9,
    AFTERNOON: 12,
    EVENING: 17,
    LATE: 22,
  };
  return Math.max(cfg.minDeferHour, byPart[recvDayPart]);
}

function jitterMs(rangeSec: readonly [number, number], rand: () => number): number {
  const [lo, hi] = rangeSec;
  return Math.round((lo + rand() * (hi - lo)) * 1000);
}

/**
 * Friday or Saturday *evening/night* in the recipient's zone — when LATE is
 * prime. We treat the whole Fri/Sat local day plus the small hours rolling into
 * Sat/Sun as weekend-night for window-extension purposes.
 */
function isWeekendNight(weekday: number, hour: number): boolean {
  // Fri (5) or Sat (6) at any send-friendly hour, or the post-midnight tail
  // (Sat 0-3 belongs to Fri night, Sun 0-3 to Sat night).
  if (weekday === 5 || weekday === 6) return true;
  if ((weekday === 6 || weekday === 0) && hour < 3) return true;
  return false;
}

export function schedule(input: ScheduleInput, cfg: SchedulerConfig = DEFAULT_SCHEDULER_CONFIG): ScheduleResult {
  const tz = input.tz ?? cfg.defaultTimezone;
  const rand = input.rand ?? Math.random;
  const age = input.now - input.recvAt;
  const priority = derivePriority(age);
  const recvDayPart = dayPartForHour(localFields(input.recvAt, tz).hour);

  // LIVE/HOT: they're looking right now — send promptly, day-part be damned.
  // The short delay doubles as the undo window.
  if (priority === 'HOT') {
    return {
      priority,
      notBefore: input.now + jitterMs(cfg.hotDelaySec, rand),
      recvDayPart,
      reason: `LIVE (<60m) — prompt send +${cfg.hotDelaySec[0]}-${cfg.hotDelaySec[1]}s (covers undo window)`,
    };
  }

  const nowLocal = localFields(input.now, tz);
  const weekendNight = isWeekendNight(nowLocal.weekday, nowLocal.hour);

  if (inSendFriendlyWindow(nowLocal.hour, weekendNight, cfg)) {
    return {
      priority,
      notBefore: input.now + jitterMs(cfg.pacedDelaySec, rand),
      recvDayPart,
      reason: `in send-friendly window${weekendNight ? ' (weekend-night extended)' : ''} — paced send`,
    };
  }

  // Outside the window (incl. quiet hours): defer to the next humane opening,
  // biased toward the lead's own day-part.
  const targetHour = preferredDeferHour(recvDayPart, cfg);
  // If that hour has already passed locally today, roll to tomorrow.
  const dayOffset = nowLocal.hour >= targetHour ? 1 : 0;
  const notBefore = zonedEpochForHour(input.now, tz, targetHour, dayOffset);

  return {
    priority,
    notBefore,
    recvDayPart,
    reason:
      `outside window (local ${String(nowLocal.hour).padStart(2, '0')}:00) — ` +
      `deferred to ${String(targetHour).padStart(2, '0')}:00 ${dayOffset ? 'tomorrow' : 'today'} ` +
      `(matched ${recvDayPart} rhythm)`,
  };
}
