import { BATCH_TIMEZONE } from './booking-batch.constants';

/** EAT intake boundaries — cron runs at these hours; each window spans to the next. */
export const BATCH_WINDOW_START_HOURS = [7, 10, 13, 16, 19, 22] as const;

export interface BatchWindow {
  key: string;
  label: string;
  start: Date;
  end: Date;
}

type EatDateParts = {
  year: number;
  month: number;
  day: number;
  hour: number;
  minute: number;
};

const dateFmt = new Intl.DateTimeFormat('en-GB', {
  day: '2-digit',
  month: 'short',
  year: 'numeric',
  timeZone: BATCH_TIMEZONE,
});

const timeFmt = new Intl.DateTimeFormat('en-GB', {
  hour: '2-digit',
  minute: '2-digit',
  hour12: false,
  timeZone: BATCH_TIMEZONE,
});

function eatParts(date: Date): EatDateParts {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone: BATCH_TIMEZONE,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  }).formatToParts(date);

  const get = (type: Intl.DateTimeFormatPartTypes) =>
    Number(parts.find((p) => p.type === type)?.value ?? 0);

  return {
    year: get('year'),
    month: get('month'),
    day: get('day'),
    hour: get('hour'),
    minute: get('minute'),
  };
}

/**
 * The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
 * key for day-level booking pools — it must match the day the portal calendar
 * renders, so always derive day keys through this (never `toISOString().slice`).
 */
export function eatDay(date: Date): string {
  const { year, month, day } = eatParts(date);
  return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}

/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
function eatToUtc(
  year: number,
  month: number,
  day: number,
  hour: number,
  minute = 0,
): Date {
  // EAT is UTC+3 year-round (no DST). Binary search would be safer across DST zones;
  // for Africa/Addis_Ababa the offset is fixed.
  const utcMs = Date.UTC(year, month - 1, day, hour - 3, minute, 0, 0);
  return new Date(utcMs);
}

function formatWindowLabel(start: Date, end: Date, endHourLabel?: string): string {
  const endTime = endHourLabel ?? timeFmt.format(new Date(end.getTime() - 60_000));
  return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${endTime} EAT`;
}

function windowFromEatStart(
  year: number,
  month: number,
  day: number,
  startHour: number,
): BatchWindow {
  const start = eatToUtc(year, month, day, startHour);
  let endYear = year;
  let endMonth = month;
  let endDay = day;
  let endHour: number;
  let endHourLabel: string;

  const idx = BATCH_WINDOW_START_HOURS.indexOf(startHour as (typeof BATCH_WINDOW_START_HOURS)[number]);
  if (idx === BATCH_WINDOW_START_HOURS.length - 1) {
    endHour = 7;
    endHourLabel = '07:00';
    const next = new Date(eatToUtc(year, month, day, 0));
    next.setUTCDate(next.getUTCDate() + 1);
    const nextParts = eatParts(next);
    endYear = nextParts.year;
    endMonth = nextParts.month;
    endDay = nextParts.day;
  } else {
    endHour = BATCH_WINDOW_START_HOURS[idx + 1];
    endHourLabel = `${String(endHour).padStart(2, '0')}:00`;
  }

  const end = eatToUtc(endYear, endMonth, endDay, endHour);
  return {
    key: start.toISOString(),
    start,
    end,
    label: formatWindowLabel(start, end, endHourLabel),
  };
}

/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */
export function eatDayToUtc(day: string, hour: number, minute = 0): Date {
  const [year, month, dayOfMonth] = day.split('-').map(Number);
  return eatToUtc(year, month, dayOfMonth, hour, minute);
}

/** Shift a `yyyy-MM-dd` EAT day key by whole days. */
export function shiftEatDay(day: string, deltaDays: number): string {
  // Noon UTC keeps the +3h EAT offset from crossing a day boundary.
  const [year, month, dayOfMonth] = day.split('-').map(Number);
  const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12));
  return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String(
    shifted.getUTCDate(),
  ).padStart(2, '0')}`;
}

/**
 * The daily office window `[openHour, closeHour)` in EAT: after `closeHour` the
 * booking desk is shut and reopens `openHour` the next morning. `openHour ===
 * closeHour` means a 24-hour desk that never breaks for the day.
 */
export interface OfficeHours {
  windowOpenHour: number;
  windowCloseHour: number;
}

/** True when the desk runs round the clock (open hour equals close hour). */
export function isRoundTheClock(hours: OfficeHours): boolean {
  return hours.windowOpenHour === hours.windowCloseHour;
}

/**
 * Where the NEXT booking cycle opens after a cycle closes at `closedAt`, given a
 * not-yet-full train and a daily office window. `earliestNextOpen` is the raw
 * ready time (close + doc-review + payment); the desk honours it only while
 * inside office hours:
 *
 *  • round-the-clock desk        → opens at `earliestNextOpen` (no day break)
 *  • ready time before closeHour → opens at `earliestNextOpen`, same day
 *  • ready time at/after closeHour → desk shut; opens next morning at openHour
 *
 * Returns `null` when the next open would fall on/after `departure` — the train
 * leaves before another cycle could run, so the window is done.
 *
 * The desk may run within one EAT day (`closeHour > openHour`), round the clock
 * (`openHour === closeHour`), or overnight across midnight (`openHour >
 * closeHour`, e.g. 08:00 → 07:00). `officeHoursOpen` handles all three.
 */
/**
 * The EAT instant a booking cycle would open if it became ready at `readyAt`,
 * honouring the daily office window but WITHOUT any departure bound:
 *
 *  • round-the-clock desk        → opens at `readyAt` (no day break)
 *  • ready before openHour       → opens at openHour that EAT morning
 *  • ready inside office hours    → opens at `readyAt`
 *  • ready at/after closeHour     → opens at openHour the next morning
 *
 * `nextCycleOpensAt` layers the "before departure" gate on top of this; the first
 * import window uses it directly and lets its own departure cap apply.
 */
export function officeHoursOpen(readyAt: Date, hours: OfficeHours): Date {
  if (isRoundTheClock(hours)) {
    return readyAt;
  }
  const { hour, minute } = eatParts(readyAt);
  const readyMinutes = hour * 60 + minute;
  const openMinutes = hours.windowOpenHour * 60;
  const closeMinutes = hours.windowCloseHour * 60;

  if (hours.windowOpenHour > hours.windowCloseHour) {
    // Overnight desk, e.g. open 08:00 → close 07:00 next morning. The desk is
    // open across midnight: [openHour, 24:00) on this EAT day and [00:00,
    // closeHour) on the next. Only the daytime gap [closeHour, openHour) is shut.
    if (readyMinutes >= openMinutes || readyMinutes < closeMinutes) {
      // Inside the overnight window (either side of midnight) → open when ready.
      return readyAt;
    }
    // In the daytime gap → the desk opens again at openHour this EAT morning.
    return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour);
  }

  if (readyMinutes < openMinutes) {
    // Ready before the desk opens on its own EAT calendar day → open this morning.
    return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour);
  }
  if (readyMinutes < closeMinutes) {
    // Inside office hours → open as soon as ready.
    return readyAt;
  }
  // Desk shut for the day → open tomorrow morning.
  return eatDayToUtc(shiftEatDay(eatDay(readyAt), 1), hours.windowOpenHour);
}

export function nextCycleOpensAt(
  earliestNextOpen: Date,
  hours: OfficeHours,
  departure: Date,
): Date | null {
  const opensAt = officeHoursOpen(earliestNextOpen, hours);
  return opensAt.getTime() < departure.getTime() ? opensAt : null;
}

/**
 * The desk-close instant of the office window containing `opensAt`; null for a
 * round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s
 * EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when
 * `opensAt` sits in the evening half, closeHour the same day when it sits in the
 * after-midnight half.
 */
export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null {
  if (isRoundTheClock(hours)) return null;
  const { hour, minute } = eatParts(opensAt);
  const openMinutes = hour * 60 + minute;
  if (
    hours.windowOpenHour > hours.windowCloseHour &&
    openMinutes >= hours.windowOpenHour * 60
  ) {
    return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour);
  }
  return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour);
}

/**
 * Cap a window close at the desk-close hour that follows its open: the office
 * hours end a running window early rather than letting the duration outlive the
 * desk (open 16:00, 3h duration, desk 8–17 → closes 17:00, not 19:00). A
 * round-the-clock desk never caps; a desk-close at/before the open (degenerate
 * config) is ignored so the window is never clamped to zero length here.
 */
export function clampCloseToOfficeHours(
  opensAt: Date,
  closesAt: Date,
  hours: OfficeHours,
): Date {
  const deskClose = officeCloseAfter(opensAt, hours);
  if (
    deskClose != null &&
    deskClose.getTime() > opensAt.getTime() &&
    closesAt.getTime() > deskClose.getTime()
  ) {
    return deskClose;
  }
  return closesAt;
}

/**
 * The instant a schedule stops accepting bookings. By default that is departure,
 * but a configured close offset (import/export, minutes) pulls it earlier:
 * `departure − offset`. This is the single bound every window close, reopen
 * cycle and export FCFS close is capped at — swap it in wherever the logic used
 * to cap at departure. A non-positive/absent offset yields departure unchanged.
 */
export function bookingCloseCutoff(
  departure: Date,
  direction: string | null | undefined,
  cfg: {
    importCloseOffsetMinutes?: number | null;
    exportCloseOffsetMinutes?: number | null;
  },
): Date {
  const offsetMinutes =
    direction === 'EXPORT'
      ? cfg.exportCloseOffsetMinutes
      : cfg.importCloseOffsetMinutes;
  if (offsetMinutes == null || !(offsetMinutes > 0)) return departure;
  return new Date(departure.getTime() - offsetMinutes * 60_000);
}

export interface InitialWindowTimes {
  windowOpensAt: Date;
  windowClosesAt: Date;
}

/**
 * Import booking-day window. The natural anchor is `windowOpenHour` EAT on
 * departure-day minus `importWindowLeadDays`. When `now` is at/before that anchor
 * (we're still before the lead window) the window opens at the anchor — the normal
 * morning wait.
 *
 * Once `now` is PAST the anchor we're already inside the lead window, so the desk's
 * office hours decide the open the same way a reopen cycle does (via
 * `nextCycleOpensAt`):
 *
 *  • 24-hour desk (open === close)     → opens at `now`, any hour, day or night
 *  • `now` inside [openHour, closeHour) → opens at `now` (desk is open right now)
 *  • `now` before openHour that EAT day → opens at openHour that morning
 *  • `now` at/after closeHour            → desk shut; opens openHour next morning
 *
 * `windowDurationHours` extends from that open, capped at the desk close hour
 * and at departure.
 */
export function computeImportWindowTimes(
  departure: Date,
  cfg: {
    importWindowLeadDays: number;
    windowOpenHour: number;
    windowCloseHour: number;
    windowDurationHours: number;
    importCloseOffsetMinutes?: number | null;
  },
  now: Date,
): InitialWindowTimes {
  // The window opens off the REAL departure (open day = departure − leadDays),
  // but shuts at the configured cutoff (departure − closeOffset, or departure).
  const cutoff = bookingCloseCutoff(departure, 'IMPORT', cfg);
  const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
  const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour);

  let opensAt: Date;
  if (now.getTime() <= anchor.getTime()) {
    // Before the lead window → normal morning wait at the anchor.
    opensAt = anchor;
  } else {
    // Inside the lead window → the office-hours rule decides the open, exactly as a
    // reopen cycle does: open now if the desk is open now (or round-the-clock),
    // else at the next open hour. We use the same primitive as reopen cycles but
    // WITHOUT its `< departure` null-gate — when the next open lands on/after
    // departure the shared cap below clamps the (zero-length) window to departure,
    // which is truthful, rather than masking it as "open now".
    opensAt = officeHoursOpen(now, {
      windowOpenHour: cfg.windowOpenHour,
      windowCloseHour: cfg.windowCloseHour,
    });
  }

  let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
  closesAt = clampCloseToOfficeHours(opensAt, closesAt, {
    windowOpenHour: cfg.windowOpenHour,
    windowCloseHour: cfg.windowCloseHour,
  });
  if (closesAt.getTime() > cutoff.getTime()) {
    closesAt = cutoff;
  }
  return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}

/**
 * Export booking window: a single FCFS window from `exportBookingLeadHours`
 * before departure until departure. The open honours the daily desk hours —
 * when the raw lead instant lands while the desk is shut, the window opens at
 * the next desk opening instead (capped at departure, so a config whose desk
 * never opens before the train leaves yields a zero-length window rather than
 * one that outlives the train).
 */
export function computeExportWindowTimes(
  departure: Date,
  cfg: {
    exportBookingLeadHours: number;
    windowOpenHour: number;
    windowCloseHour: number;
    exportCloseOffsetMinutes?: number | null;
  },
): InitialWindowTimes {
  // Opens off the real departure (lead hours), shuts at the cutoff
  // (departure − closeOffset, or departure when no offset is set).
  const cutoff = bookingCloseCutoff(departure, 'EXPORT', cfg);
  const rawOpen = new Date(
    departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
  );
  let opensAt = officeHoursOpen(rawOpen, {
    windowOpenHour: cfg.windowOpenHour,
    windowCloseHour: cfg.windowCloseHour,
  });
  // Open can't outlive the cutoff (a huge offset would otherwise leave a
  // negative-length window); clamp to a zero-length window at the cutoff.
  if (opensAt.getTime() > cutoff.getTime()) {
    opensAt = cutoff;
  }
  return { windowOpensAt: opensAt, windowClosesAt: cutoff };
}

/**
 * Earliest departure a train may be scheduled for — staff cannot schedule inside
 * the lead window. IMPORT/DOMESTIC lead is in whole EAT days: with lead 3 and
 * today the 11th, the 12th and 13th are blocked and the 14th is the first
 * allowed departure day (00:00 EAT). EXPORT lead is in hours: earliest departure
 * is `now + exportBookingLeadHours` (24h = 1 day). Mirrors the booking-window
 * math so a schedulable date always has a real booking window before it.
 */
export function earliestSchedulableDeparture(
  direction: string | null | undefined,
  cfg: { importWindowLeadDays: number; exportBookingLeadHours: number },
  now: Date,
): Date {
  if (direction === 'EXPORT') {
    return new Date(now.getTime() + cfg.exportBookingLeadHours * 3_600_000);
  }
  const earliestDay = shiftEatDay(eatDay(now), cfg.importWindowLeadDays);
  return eatDayToUtc(earliestDay, 0);
}

/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
  const { year, month, day, hour } = eatParts(date);

  if (hour < 7) {
    const prev = new Date(eatToUtc(year, month, day, 0));
    prev.setUTCDate(prev.getUTCDate() - 1);
    const prevParts = eatParts(prev);
    return windowFromEatStart(prevParts.year, prevParts.month, prevParts.day, 22);
  }

  let startHour: (typeof BATCH_WINDOW_START_HOURS)[number] = 7;
  for (const h of BATCH_WINDOW_START_HOURS) {
    if (hour >= h) startHour = h;
  }

  return windowFromEatStart(year, month, day, startHour);
}

/** All six intake windows for an EAT calendar day (includes overnight 22:00–07:00). */
export function listBatchWindowsForDate(reference: Date): BatchWindow[] {
  const { year, month, day } = eatParts(reference);
  return BATCH_WINDOW_START_HOURS.map((startHour) =>
    windowFromEatStart(year, month, day, startHour),
  );
}

export function compareBatchWindows(a: BatchWindow, b: BatchWindow): number {
  return a.start.getTime() - b.start.getTime();
}

/** Schedule-day windows plus any extra windows that contain booking timestamps (cross-day). */
export function listBatchWindowsForBookings(
  timestamps: Array<Date | null | undefined>,
  referenceDate: Date,
): BatchWindow[] {
  const byKey = new Map<string, BatchWindow>();
  for (const w of listBatchWindowsForDate(referenceDate)) {
    byKey.set(w.key, w);
  }
  for (const ts of timestamps) {
    if (!ts) continue;
    const w = getBatchWindowForTimestamp(ts);
    byKey.set(w.key, w);
  }
  return [...byKey.values()].sort(compareBatchWindows);
}

// ---------------------------------------------------------------------------
// Board-display windows: the REAL booking-window cycles derived from the
// schedule's frozen window rule (open/close hour, lead days, duration, reopen
// gap = doc review + payment) — NOT a fixed clock grid. Import shows each
// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours
// capped at the desk close, reopens after the gap until departure). Export shows
// the single FCFS lead window.
// ---------------------------------------------------------------------------

/** A board window carries an EAT calendar date in addition to the slot times. */
export interface BoardWindow extends BatchWindow {
  /** EAT calendar day as ISO `YYYY-MM-DD`. */
  date: string;
  /** Human label for the day, e.g. `Thu, 05 Jun`. */
  dateLabel: string;
}

/** Config fields the board needs to reconstruct booking-window cycles. */
export interface BoardWindowConfig {
  importWindowLeadDays: number;
  windowOpenHour: number;
  /** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
  windowCloseHour: number;
  windowDurationHours: number;
  /**
   * Gap between a cycle's close and its reopen — always doc review + payment
   * minutes (the schedule's frozen snapshot, or the live sum for legacy rows).
   */
  reopenGapMinutes: number;
  exportBookingLeadHours: number;
  /** Minutes before departure the import window shuts; NULL/0 ⇒ close at departure. */
  importCloseOffsetMinutes?: number | null;
  /** Minutes before departure the export window shuts; NULL/0 ⇒ close at departure. */
  exportCloseOffsetMinutes?: number | null;
}

const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
  weekday: 'short',
  day: '2-digit',
  month: 'short',
  timeZone: BATCH_TIMEZONE,
});

function pad2(n: number): string {
  return String(n).padStart(2, '0');
}

/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */
function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
  const { year, month, day } = eatParts(start);
  return {
    key: start.toISOString(),
    start,
    end,
    label: formatWindowLabel(start, end),
    date: `${year}-${pad2(month)}-${pad2(day)}`,
    dateLabel: dayLabelFmt.format(start),
  };
}

/**
 * The real booking-window cycles for a schedule, straight from config.
 *
 * IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays`
 * for `windowDurationHours` (cut short by the desk close hour); if the train isn't
 * full it reopens `reopenGapMinutes` (doc review + payment) after each close,
 * honouring office hours, until departure. This mirrors `computeImportWindowTimes`
 * + `concludeCycle`'s reopen math so the board shows the exact windows the engine
 * runs.
 * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure,
 * with the open shifted to the next desk opening when it lands outside office hours
 * (same math as `computeExportWindowTimes`).
 *
 * `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
 * `windowOpensAt` instead of recomputing it from config. Pass it so the board
 * shows the real frozen window (and reopen cycles projected from it) even after
 * the global rule changed — the recomputed open time would otherwise drift.
 */
export function listConfigBookingWindows(
  direction: string | null | undefined,
  departure: Date,
  cfg: BoardWindowConfig,
  anchorOpensAt?: Date | null,
): BoardWindow[] {
  // Bookings shut at the cutoff (departure − closeOffset), not departure. The
  // window opens still key off the real departure below; only closes are capped
  // here, so the board draws the exact windows the engine runs.
  const cutoff = bookingCloseCutoff(departure, direction, cfg);

  if (direction === 'EXPORT') {
    const start =
      anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
    return [boardWindowFromInterval(start, cutoff)];
  }

  const windows: BoardWindow[] = [];
  const durationMs = cfg.windowDurationHours * 3_600_000;
  // Post-close gap before the next cycle opens (doc review + payment), subject
  // to office hours below.
  const reopenMs = cfg.reopenGapMinutes * 60_000;
  const officeHours: OfficeHours = {
    windowOpenHour: cfg.windowOpenHour,
    windowCloseHour: cfg.windowCloseHour,
  };
  const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);

  let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
  // The loop terminates naturally: every cycle advances opensAt by at least
  // (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would
  // reach the cutoff. maxCycles is a derived runaway backstop sized to the real
  // span (first open → cutoff) over the smallest possible advance, so a
  // legitimate config is never silently truncated — only a pathological
  // zero-length one would hit it.
  const spanMs = cutoff.getTime() - opensAt.getTime();
  const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000);
  const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2;
  for (let cycle = 0; cycle < maxCycles; cycle += 1) {
    if (opensAt.getTime() >= cutoff.getTime()) break;
    let closesAt = new Date(opensAt.getTime() + durationMs);
    closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
    if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
    windows.push(boardWindowFromInterval(opensAt, closesAt));

    const earliestNextOpen = new Date(closesAt.getTime() + reopenMs);
    opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, cutoff);
    if (opensAt == null) break;
  }

  // Degenerate config (no window before the cutoff) — surface a single window
  // clamped to the cutoff so the board still renders something meaningful.
  if (windows.length === 0) {
    windows.push(boardWindowFromInterval(new Date(cutoff.getTime() - durationMs), cutoff));
  }
  return windows;
}

/** Which config booking-window a timestamp falls in; null if before/after all of them. */
function configWindowForTimestamp(
  windows: BoardWindow[],
  date: Date,
): BoardWindow | null {
  const ms = date.getTime();
  for (const w of windows) {
    if (ms >= w.start.getTime() && ms < w.end.getTime()) return w;
  }
  return null;
}

/**
 * Group items into the real config booking-window cycles for a schedule. Empty
 * windows are kept so the UI shows every cycle. Items whose timestamp falls
 * outside every window (e.g. a booking created before the window opened) are
 * attached to the nearest window by start time so nothing is hidden. Items
 * without a timestamp go to `pendingKey`.
 */
export function groupBookingsIntoBoardWindows<T>(
  items: T[],
  getTimestamp: (item: T) => Date | null | undefined,
  direction: string | null | undefined,
  departure: Date,
  cfg: BoardWindowConfig,
  pendingKey = 'pending-contract',
  anchorOpensAt?: Date | null,
): Map<string, { window: BoardWindow | null; items: T[] }> {
  const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt);
  const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
  for (const w of windows) {
    map.set(w.key, { window: w, items: [] });
  }
  map.set(pendingKey, { window: null, items: [] });

  const firstWindow = windows[0] ?? null;
  const lastWindow = windows[windows.length - 1] ?? null;

  for (const item of items) {
    const ts = getTimestamp(item);
    if (!ts) {
      map.get(pendingKey)!.items.push(item);
      continue;
    }
    let w = configWindowForTimestamp(windows, ts);
    if (!w) {
      // Booked before the window opened → first cycle; after it closed → last cycle.
      w =
        firstWindow && ts.getTime() < firstWindow.start.getTime()
          ? firstWindow
          : lastWindow;
    }
    if (!w) {
      map.get(pendingKey)!.items.push(item);
      continue;
    }
    map.get(w.key)!.items.push(item);
  }

  return map;
}

/** Group items by batch window key; items without a timestamp go to `pendingKey`. */
export function groupByBatchWindow<T>(
  items: T[],
  getTimestamp: (item: T) => Date | null | undefined,
  referenceDate: Date,
  pendingKey = 'pending-contract',
): Map<string, { window: BatchWindow | null; items: T[] }> {
  const timestamps = items.map(getTimestamp);
  const windows = listBatchWindowsForBookings(timestamps, referenceDate);
  const map = new Map<string, { window: BatchWindow | null; items: T[] }>();

  for (const w of windows) {
    map.set(w.key, { window: w, items: [] });
  }
  map.set(pendingKey, { window: null, items: [] });

  for (const item of items) {
    const ts = getTimestamp(item);
    if (!ts) {
      map.get(pendingKey)!.items.push(item);
      continue;
    }
    const w = getBatchWindowForTimestamp(ts);
    if (!map.has(w.key)) {
      map.set(w.key, { window: w, items: [] });
    }
    map.get(w.key)!.items.push(item);
  }

  return map;
}
