import {
  getBatchWindowForTimestamp,
  listBatchWindowsForDate,
  listBatchWindowsForBookings,
  BATCH_WINDOW_START_HOURS,
  listConfigBookingWindows,
  groupBookingsIntoBoardWindows,
  computeImportWindowTimes,
  computeExportWindowTimes,
  bookingCloseCutoff,
  type BoardWindowConfig,
} from './batch-window.util';

describe('batch-window.util', () => {
  it('maps 20:15 EAT to the 19:00–22:00 window', () => {
    // 20:15 EAT = 17:15 UTC on 11 Jun 2026
    const ts = new Date('2026-06-11T17:15:00.000Z');
    const window = getBatchWindowForTimestamp(ts);

    expect(window.label).toContain('19:00');
    expect(window.label).toContain('22:00');
    expect(window.label).toContain('11 Jun 2026');
  });

  it('maps 08:30 EAT to the 07:00–10:00 window', () => {
    const ts = new Date('2026-06-11T05:30:00.000Z'); // 08:30 EAT
    const window = getBatchWindowForTimestamp(ts);
    expect(window.label).toContain('07:00');
    expect(window.label).toContain('10:00');
  });

  it('maps 02:00 EAT to the previous day 22:00–07:00 window', () => {
    const ts = new Date('2026-06-11T23:00:00.000Z'); // 02:00 EAT on 12 Jun
    const window = getBatchWindowForTimestamp(ts);
    expect(window.label).toContain('22:00');
    expect(window.label).toContain('07:00');
    expect(window.label).toContain('11 Jun 2026');
  });

  it('lists six windows for a calendar day', () => {
    const ref = new Date('2026-06-11T12:00:00.000Z');
    const windows = listBatchWindowsForDate(ref);
    expect(windows).toHaveLength(BATCH_WINDOW_START_HOURS.length);
    expect(windows[0].label).toContain('07:00');
    expect(windows[windows.length - 1].label).toContain('22:00');
  });

  it('includes cross-day overnight window when booking signed at 00:02 EAT', () => {
    // 21:02 UTC = 00:02 EAT on 12 Jun → belongs to 11 Jun 22:00–07:00 window
    const fullyExecutedAt = new Date('2026-06-11T21:02:05.153Z');
    const scheduleDate = new Date('2026-06-12T06:00:00.000Z');
    const windows = listBatchWindowsForBookings([fullyExecutedAt], scheduleDate);
    const overnight = windows.find((w) => w.label.includes('22:00') && w.label.includes('07:00'));
    expect(overnight).toBeDefined();
    expect(overnight!.label).toContain('11 Jun 2026');
    expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
  });
});

describe('computeImportWindowTimes — first-window open respects office hours', () => {
  // Departs Mon 06 Jul 08:00 EAT (05:00 UTC). Lead 3 days → anchor 03 Jul 08:00
  // EAT (05:00 UTC). Bounded desk 08:00–17:00, 15h window.
  const departure = new Date('2026-07-06T05:00:00.000Z');
  const bounded = {
    importWindowLeadDays: 3,
    windowOpenHour: 8,
    windowCloseHour: 17,
    windowDurationHours: 15,
  };

  it('opens at the morning anchor when now is before the lead window', () => {
    // Now = 02 Jul 06:00 EAT (before the 03 Jul anchor).
    const now = new Date('2026-07-02T03:00:00.000Z');
    const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
    // Anchor: 03 Jul 08:00 EAT = 05:00 UTC.
    expect(windowOpensAt.toISOString()).toBe('2026-07-03T05:00:00.000Z');
  });

  it('opens NOW when inside the lead window and inside office hours (past the anchor)', () => {
    // Now = 05 Jul 12:00 EAT (09:00 UTC): inside lead days, inside 08:00–17:00,
    // anchor already passed → open immediately.
    const now = new Date('2026-07-05T09:00:00.000Z');
    const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
    expect(windowOpensAt.toISOString()).toBe('2026-07-05T09:00:00.000Z');
  });

  it('waits for next morning when now is after the desk closes', () => {
    // Departs 06 Jul 14:00 EAT (11:00 UTC) so next-morning open sits before departure.
    // Now = 05 Jul 18:00 EAT (15:00 UTC): after 17:00 close → open 06 Jul 08:00 EAT.
    const lateDeparture = new Date('2026-07-06T11:00:00.000Z');
    const now = new Date('2026-07-05T15:00:00.000Z');
    const { windowOpensAt } = computeImportWindowTimes(lateDeparture, bounded, now);
    // 06 Jul 08:00 EAT = 05:00 UTC.
    expect(windowOpensAt.toISOString()).toBe('2026-07-06T05:00:00.000Z');
  });

  it('opens this morning when now is before the desk opens on a lead day', () => {
    // Now = 05 Jul 06:00 EAT (03:00 UTC): inside lead days but before 08:00 → 08:00 today.
    const now = new Date('2026-07-05T03:00:00.000Z');
    const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
    expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
  });

  it('24-hour desk opens NOW at any hour, day or night, once inside the lead window', () => {
    // Round-the-clock desk (open === close). Now = 05 Jul 03:00 EAT (00:00 UTC),
    // deep night, past the anchor → open immediately.
    const roundClock = { ...bounded, windowOpenHour: 8, windowCloseHour: 8 };
    const now = new Date('2026-07-05T00:00:00.000Z');
    const { windowOpensAt } = computeImportWindowTimes(departure, roundClock, now);
    expect(windowOpensAt.toISOString()).toBe('2026-07-05T00:00:00.000Z');
  });

  it('caps the close at departure', () => {
    // Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00
    // EAT); a 24h duration would close 06 Jul 12:00 EAT, past the 06 Jul 08:00
    // departure → clamped to departure.
    const now = new Date('2026-07-05T09:00:00.000Z');
    const { windowClosesAt } = computeImportWindowTimes(
      departure,
      { ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 },
      now,
    );
    expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
  });

  it('desk close hour cuts the window short (duration never outlives the desk)', () => {
    // Opens now (05 Jul 12:00 EAT); the 15h duration would run to 03:00 next
    // day, but the desk shuts 17:00 EAT (14:00 UTC) → the window closes with it.
    const now = new Date('2026-07-05T09:00:00.000Z');
    const { windowClosesAt } = computeImportWindowTimes(departure, bounded, now);
    expect(windowClosesAt.toISOString()).toBe('2026-07-05T14:00:00.000Z');
  });

  describe('overnight desk (open > close, wraps past midnight)', () => {
    // Desk open 08:00, closes 05:00 next morning — open across midnight.
    const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };

    it('opens NOW at 00:00 (deep night is INSIDE the overnight window)', () => {
      // Now = 05 Jul 00:00 EAT (04 Jul 21:00 UTC): after midnight, before 05:00 →
      // inside the overnight desk → open immediately. This is the reported bug.
      const now = new Date('2026-07-04T21:00:00.000Z');
      const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
      expect(windowOpensAt.toISOString()).toBe('2026-07-04T21:00:00.000Z');
    });

    it('opens NOW at 22:00 (evening is INSIDE the overnight window)', () => {
      // Now = 05 Jul 22:00 EAT (19:00 UTC): after 08:00 open → inside → open now.
      const now = new Date('2026-07-05T19:00:00.000Z');
      const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
      expect(windowOpensAt.toISOString()).toBe('2026-07-05T19:00:00.000Z');
    });

    it('waits to 08:00 when now is in the daytime gap [05:00, 08:00)', () => {
      // Now = 05 Jul 06:00 EAT (03:00 UTC): desk shut (gap) → open 08:00 today.
      const now = new Date('2026-07-05T03:00:00.000Z');
      const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
      // 05 Jul 08:00 EAT = 05:00 UTC.
      expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
    });
  });
});

describe('computeImportWindowTimes — overnight desk (open > close, wraps midnight)', () => {
  // Overnight desk 08:00 → 07:00 next morning: open across [08:00, 24:00) and
  // [00:00, 07:00). Only the daytime gap [07:00, 08:00) is shut.
  const overnight = {
    importWindowLeadDays: 3,
    windowOpenHour: 8,
    windowCloseHour: 7,
    windowDurationHours: 6,
  };

  it('opens NOW in the evening side of the window (after open hour)', () => {
    // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 20:00 EAT (17:00 UTC):
    // ≥ 08:00 → desk open → open immediately.
    const departure = new Date('2026-07-06T07:00:00.000Z');
    const now = new Date('2026-07-05T17:00:00.000Z');
    const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
    expect(windowOpensAt.toISOString()).toBe('2026-07-05T17:00:00.000Z');
  });

  it('opens NOW after midnight (before close hour)', () => {
    // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 06 Jul 02:00 EAT (05 Jul 23:00
    // UTC): < 07:00 → still inside the overnight window → open immediately.
    const departure = new Date('2026-07-06T07:00:00.000Z');
    const now = new Date('2026-07-05T23:00:00.000Z');
    const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
    expect(windowOpensAt.toISOString()).toBe('2026-07-05T23:00:00.000Z');
  });

  it('waits until open hour in the daytime gap [close, open)', () => {
    // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 07:30 EAT (04:30 UTC):
    // in the shut daytime gap → opens 05 Jul 08:00 EAT (05:00 UTC).
    const departure = new Date('2026-07-06T07:00:00.000Z');
    const now = new Date('2026-07-05T04:30:00.000Z');
    const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
    expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
  });
});

describe('batch-window board windows (config-driven booking cycles)', () => {
  // Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
  // 3h long, reopen gap (doc review + payment) 90m.
  const cfg: BoardWindowConfig = {
    importWindowLeadDays: 3,
    windowOpenHour: 8,
    windowCloseHour: 17,
    windowDurationHours: 3,
    reopenGapMinutes: 90,
    exportBookingLeadHours: 24,
  };

  it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => {
    // departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC)
    const departure = new Date('2026-06-08T11:00:00.000Z');
    const windows = listConfigBookingWindows('IMPORT', departure, cfg);

    expect(windows[0].date).toBe('2026-06-05');
    expect(windows[0].label).toContain('08:00');
    expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
    // end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC)
    expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
  });

  it('import: reopens after the doc-review + payment gap while inside office hours', () => {
    const departure = new Date('2026-06-08T11:00:00.000Z');
    const windows = listConfigBookingWindows('IMPORT', departure, cfg);
    // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
    expect(windows.length).toBeGreaterThanOrEqual(2);
    expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
    expect(windows[1].date).toBe('2026-06-05');
  });

  it('import: pauses at close hour and resumes next morning at open hour', () => {
    const departure = new Date('2026-06-08T11:00:00.000Z');
    const windows = listConfigBookingWindows('IMPORT', departure, cfg);
    // Day 05 Jun: 08:00, 12:30, 17:00-clamped… the cycle whose reopen lands
    // at/after 17:00 EAT rolls to 06 Jun 08:00 EAT (05:00 UTC).
    const day6First = windows.find((w) => w.date === '2026-06-06');
    expect(day6First).toBeDefined();
    expect(day6First!.start.toISOString()).toBe('2026-06-06T05:00:00.000Z'); // 08:00 EAT
    // Cycles span the office days between the window day and departure.
    const days = new Set(windows.map((w) => w.date));
    expect(days.has('2026-06-05')).toBe(true);
    expect(days.has('2026-06-06')).toBe(true);
  });

  it('import: 24-hour desk (open hour === close hour) never breaks for the day', () => {
    const roundClock: BoardWindowConfig = { ...cfg, windowOpenHour: 8, windowCloseHour: 8 };
    const departure = new Date('2026-06-08T11:00:00.000Z');
    const windows = listConfigBookingWindows('IMPORT', departure, roundClock);
    // Reopen chains straight through midnight: an overnight cycle exists.
    const crossesNight = windows.some(
      (w, i) => i > 0 && windows[i - 1].date !== w.date,
    );
    expect(crossesNight).toBe(true);
    // Cycles run continuously from the window day up to departure — the last one
    // reaches departure, proving the runaway cap did not truncate the projection.
    expect(windows[windows.length - 1].end.getTime()).toBe(departure.getTime());
    // Spans the full lead (window day 05 Jun → departure 08 Jun).
    expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
  });

  it('import: desk close hour cuts a cycle short (duration past 17:00 clamps)', () => {
    const longCfg: BoardWindowConfig = { ...cfg, windowDurationHours: 10 };
    const departure = new Date('2026-06-08T11:00:00.000Z');
    const windows = listConfigBookingWindows('IMPORT', departure, longCfg);
    // Cycle 1 opens 08:00 EAT; 10h would close 18:00 — desk shuts 17:00 (14:00 UTC).
    expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
    expect(windows[0].end.toISOString()).toBe('2026-06-05T14:00:00.000Z');
    // Reopen 90m after the clamped close lands past 17:00 → next morning 08:00 EAT.
    expect(windows[1].start.toISOString()).toBe('2026-06-06T05:00:00.000Z');
  });

  it('export: single FCFS window exportBookingLeadHours before departure', () => {
    const departure = new Date('2026-06-08T11:00:00.000Z');
    const windows = listConfigBookingWindows('EXPORT', departure, cfg);
    expect(windows).toHaveLength(1);
    // 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun
    expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z');
    expect(windows[0].end.toISOString()).toBe(departure.toISOString());
  });

  it('buckets bookings into config cycles and keeps empty + pending windows', () => {
    const departure = new Date('2026-06-08T11:00:00.000Z');
    const items = [
      { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1
      { id: 'b', ts: null }, // pending
    ];
    const map = groupBookingsIntoBoardWindows(
      items,
      (i) => i.ts,
      'IMPORT',
      departure,
      cfg,
      'pending-contract',
    );
    const pending = map.get('pending-contract');
    expect(pending?.items.map((i) => i.id)).toEqual(['b']);
    const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
    expect(withA?.window?.date).toBe('2026-06-05');
    // empty cycles are retained for the UI
    const emptyCount = [...map.values()].filter(
      (b) => b.window && b.items.length === 0,
    ).length;
    expect(emptyCount).toBeGreaterThan(0);
  });

  it('attaches a booking made before the window opened to the first cycle', () => {
    const departure = new Date('2026-06-08T11:00:00.000Z');
    const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }];
    const map = groupBookingsIntoBoardWindows(
      items,
      (i) => i.ts,
      'IMPORT',
      departure,
      cfg,
      'pending-contract',
    );
    const withEarly = [...map.values()].find((b) =>
      b.items.some((i) => i.id === 'early'),
    );
    expect(withEarly?.window?.date).toBe('2026-06-05');
    expect(withEarly?.window?.label).toContain('08:00');
  });
});

// Regression: a schedule created INSIDE its own window day must open right away
// when the desk is open, and re-deriving after a settings change (close hour
// extended past "now", or lead pulled so the window day becomes today) must
// yield an immediate open — not tomorrow morning.
describe('computeImportWindowTimes — immediate open inside the window day', () => {
  // 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC
  const now = new Date('2026-07-06T16:15:17.000Z');
  // Departs Thu 9 Jul ~08:53 EAT
  const departure = new Date('2026-07-09T05:53:00.000Z');
  const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 };

  it('desk 8–23, created 19:15 on the window day → opens NOW', () => {
    const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
    expect(t.windowOpensAt.getTime()).toBe(now.getTime());
  });

  it('desk 8–17, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => {
    const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now);
    expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z');
  });

  it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => {
    // Same call restampPendingWindows makes after the global-rules edit.
    const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
    expect(t.windowOpensAt.getTime()).toBe(now.getTime());
  });

  it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => {
    const departsJul10 = new Date('2026-07-10T05:53:00.000Z');
    const t = computeImportWindowTimes(
      departsJul10,
      { ...base, importWindowLeadDays: 4, windowCloseHour: 23 },
      now,
    );
    expect(t.windowOpensAt.getTime()).toBe(now.getTime());
  });
});

// Booking-close offset: a configured offset pulls the window close earlier than
// departure by that many minutes, separately for import and export.
describe('bookingCloseCutoff — departure − offset', () => {
  const departure = new Date('2026-07-10T13:00:00.000Z'); // 16:00 EAT Jul 10

  it('returns departure unchanged when no offset is set', () => {
    expect(bookingCloseCutoff(departure, 'IMPORT', {}).toISOString()).toBe(
      departure.toISOString(),
    );
    expect(
      bookingCloseCutoff(departure, 'EXPORT', {
        importCloseOffsetMinutes: 180,
      }).toISOString(),
    ).toBe(departure.toISOString());
  });

  it('a non-positive offset is treated as no offset', () => {
    expect(
      bookingCloseCutoff(departure, 'IMPORT', {
        importCloseOffsetMinutes: 0,
      }).toISOString(),
    ).toBe(departure.toISOString());
    expect(
      bookingCloseCutoff(departure, 'IMPORT', {
        importCloseOffsetMinutes: -5,
      }).toISOString(),
    ).toBe(departure.toISOString());
  });

  it('import 3-hour offset: 16:00 EAT departure → cutoff 13:00 EAT (14:00 → 3h before)', () => {
    // Departure 16:00 EAT (13:00 UTC), 3h offset → 13:00 EAT = 10:00 UTC.
    const cutoff = bookingCloseCutoff(departure, 'IMPORT', {
      importCloseOffsetMinutes: 180,
    });
    expect(cutoff.toISOString()).toBe('2026-07-10T10:00:00.000Z');
  });

  it('export 1-day offset: Jul-10 16:00 EAT departure → cutoff Jul-9 16:00 EAT', () => {
    const cutoff = bookingCloseCutoff(departure, 'EXPORT', {
      exportCloseOffsetMinutes: 1440,
    });
    // Jul 9 16:00 EAT = Jul 9 13:00 UTC.
    expect(cutoff.toISOString()).toBe('2026-07-09T13:00:00.000Z');
  });

  it('import and export offsets are independent', () => {
    const cfg = {
      importCloseOffsetMinutes: 180,
      exportCloseOffsetMinutes: 1440,
    };
    expect(bookingCloseCutoff(departure, 'IMPORT', cfg).toISOString()).toBe(
      '2026-07-10T10:00:00.000Z',
    );
    expect(bookingCloseCutoff(departure, 'EXPORT', cfg).toISOString()).toBe(
      '2026-07-09T13:00:00.000Z',
    );
    // DOMESTIC uses the import offset.
    expect(bookingCloseCutoff(departure, 'DOMESTIC', cfg).toISOString()).toBe(
      '2026-07-10T10:00:00.000Z',
    );
  });
});

describe('window-time computation honours the close offset', () => {
  it('export closes at departure − offset, not departure', () => {
    // Departs Jul 10 16:00 EAT (13:00 UTC), lead 24h, 24-hour desk, 1-day offset.
    const departure = new Date('2026-07-10T13:00:00.000Z');
    const { windowClosesAt } = computeExportWindowTimes(departure, {
      exportBookingLeadHours: 48,
      windowOpenHour: 8,
      windowCloseHour: 8, // 24-hour desk
      exportCloseOffsetMinutes: 1440,
    });
    // Jul 9 16:00 EAT = Jul 9 13:00 UTC.
    expect(windowClosesAt.toISOString()).toBe('2026-07-09T13:00:00.000Z');
  });

  it('import close is capped at the cutoff (departure − offset)', () => {
    // Round-the-clock desk, opens 05 Jul 12:00 EAT, 24h duration would run to
    // 06 Jul 12:00; departure 06 Jul 08:00 EAT (05:00 UTC) with a 2-hour offset →
    // cutoff 06 Jul 06:00 EAT = 03:00 UTC.
    const departure = new Date('2026-07-06T05:00:00.000Z');
    const now = new Date('2026-07-05T09:00:00.000Z');
    const { windowClosesAt } = computeImportWindowTimes(
      departure,
      {
        importWindowLeadDays: 3,
        windowOpenHour: 8,
        windowCloseHour: 8, // 24-hour desk (no office-hour cap)
        windowDurationHours: 24,
        importCloseOffsetMinutes: 120,
      },
      now,
    );
    expect(windowClosesAt.toISOString()).toBe('2026-07-06T03:00:00.000Z');
  });
});
