/**
 * EXPORT "LEDGER DAY" (D+19) — one departure day, THREE trains of three
 * different consist styles, ~22 bookings of every flavour (ONE_TIME +
 * GENERAL, container + bulk, USD + ETB), every one of them driven to a known
 * final fate — and a generated day-ledger REPORT at the end.
 *
 *   GRAIN  built train TRN-LEDGER-PW2 — 37 × PW2 box wagons (the physical
 *          consist IS the cap; the only built-train schedule in the suite)
 *          → bulk only, 37/37 = 2 590 T
 *   BOX    loco pair, 54 × NW5 → containers only, 54/54
 *   MIX    loco pair, 54 slots → 24w containers (NW5) + 30w bulk (CW4)
 *
 *   145 slots, filled FCFS in wave order (export reserves at ACCEPT):
 *     – bulk wave fills GRAIN exactly; containers then cascade PAST the full
 *       PW2 train onto BOX; the mixed wave lands on MIX
 *     – ONE booking per train never pays → all three EXPIRE in one sweep
 *     – 2 late bookings are REFUSED at create (export never queues)
 *     – 1 refused customer rebooks GRAIN's freed 5 wagons, pays, rides
 *
 *   The final test classifies every booking of the day and writes
 *   `cypress/reports/export-ledger-<day>.json`.
 *
 * Sequential steps of one journey — retries off.
 */

import {
  acceptExport,
  bookBulk,
  bookContainers,
  clearGeneralBooking,
  createImportSchedule,
  db,
  dayLedger,
  departureAt,
  eatDayStr,
  ensureExportRoute,
  EXP_DEST,
  EXP_ORIGIN,
  expectWagonType,
  forceReservationExpiry,
  forceWindowOpen,
  markPaid,
  pollAllocations,
  pollBookingStatus,
  pollDb,
  resetCorridorDay,
  seedImportContract,
  withBooking,
  writeLedgerReport,
  type LedgerRow,
  type ScheduleRow,
} from "./import-utils";

const GRAIN_AT = departureAt(19);
const BOX_AT = new Date(GRAIN_AT.getTime() + 2 * 3_600_000);
const MIX_AT = new Date(GRAIN_AT.getTime() + 4 * 3_600_000);
const DAY = eatDayStr(GRAIN_AT);

const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;

type Kind = "ONE_TIME" | "GENERAL";
interface Line {
  suffix: string;
  kind: Kind;
  currency: "ETB" | "USD";
  freight: "CONTAINER" | "BULK";
  twenty?: number;
  tons?: number;
  wagons: number;
  wagonType: "PW2" | "NW5" | "CW4";
  pays: boolean;
}

/** Wave 1 — fills the built 37 × PW2 GRAIN train exactly. */
const GRAIN_LINES: Line[] = [
  { suffix: "LG1", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 700, wagons: 10, wagonType: "PW2", pays: true },
  { suffix: "LG2", kind: "GENERAL", currency: "ETB", freight: "BULK", tons: 560, wagons: 8, wagonType: "PW2", pays: true },
  { suffix: "LG3", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 560, wagons: 8, wagonType: "PW2", pays: true },
  { suffix: "LG4", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 420, wagons: 6, wagonType: "PW2", pays: true },
  { suffix: "LG5", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 350, wagons: 5, wagonType: "PW2", pays: false },
];

/** Wave 2 — 54 × NW5 on the pure container train. */
const BOX_LINES: Line[] = [
  { suffix: "LB1", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 40, wagons: 20, wagonType: "NW5", pays: true },
  { suffix: "LB2", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 24, wagons: 12, wagonType: "NW5", pays: true },
  { suffix: "LB3", kind: "GENERAL", currency: "USD", freight: "CONTAINER", twenty: 16, wagons: 8, wagonType: "NW5", pays: true },
  { suffix: "LB4", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 12, wagons: 6, wagonType: "NW5", pays: true },
  { suffix: "LB5", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 8, wagons: 4, wagonType: "NW5", pays: true },
  { suffix: "LB6", kind: "GENERAL", currency: "ETB", freight: "CONTAINER", twenty: 4, wagons: 2, wagonType: "NW5", pays: true },
  { suffix: "LB7", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 2, wagons: 1, wagonType: "NW5", pays: true },
  { suffix: "LB8", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 2, wagons: 1, wagonType: "NW5", pays: false },
];

/** Wave 3 — 24w containers + 30w bulk share the MIX train. */
const MIX_LINES: Line[] = [
  { suffix: "LM1", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 24, wagons: 12, wagonType: "NW5", pays: true },
  { suffix: "LM2", kind: "GENERAL", currency: "ETB", freight: "CONTAINER", twenty: 16, wagons: 8, wagonType: "NW5", pays: true },
  { suffix: "LM3", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 8, wagons: 4, wagonType: "NW5", pays: true },
  { suffix: "LM4", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: true },
  { suffix: "LM5", kind: "GENERAL", currency: "ETB", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: true },
  { suffix: "LM6", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: false },
];

const ALL_LINES = [...GRAIN_LINES, ...BOX_LINES, ...MIX_LINES];
const PAID = ALL_LINES.filter((l) => l.pays);
const UNPAID = ALL_LINES.filter((l) => !l.pays);
/** Refused at create once all 145 slots are held; LR2 later redeems itself. */
const REJECTED = ["LR1", "LR2"];

let isoSeed = 20_000;

/** Book one line and take it to its FCFS reservation. */
function bookAndAccept(line: Line) {
  if (line.freight === "CONTAINER") {
    bookContainers({
      suffix: line.suffix,
      runStamp: stamp,
      isoSeed,
      twenty: line.twenty,
      scheduledDate: DAY,
    });
    isoSeed += (line.twenty ?? 0) + 5;
  } else {
    // PW2 bulk rides the built GRAIN consist → GRAINS cargo; CW4 bulk rides the
    // loco-pair MIX train → WHEAT. One wagon length per cargo keeps the
    // loco-pair length budget deterministic (see seed 5b3).
    bookBulk({
      suffix: line.suffix,
      tons: line.tons!,
      scheduledDate: DAY,
      cargoCode: line.wagonType === "PW2" ? "E2E_IMP_GRAINS" : "E2E_IMP_WHEAT",
    });
  }
  if (line.kind === "GENERAL") clearGeneralBooking(line.suffix, DAY, "export");
  else acceptExport(line.suffix);
}

function seedLine(line: Line) {
  seedImportContract({
    suffix: line.suffix,
    reference: stampedRef(line.suffix),
    kind: line.kind,
    currency: line.currency,
    freight: line.freight,
    direction: "EXPORT",
    originCode: EXP_ORIGIN,
    destCode: EXP_DEST,
  });
}

interface DaySchedule {
  id: string;
  reference: string;
  max_wagons: number;
  booking_window_status: string;
}

function daySchedule(at: Date) {
  return db<DaySchedule>(
    `SELECT ts.id, ts.reference, ts.max_wagons, ts.booking_window_status
     FROM freight.train_schedules ts
     JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
     JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
     WHERE ts.deleted_at IS NULL
       AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
     ORDER BY ts.created_at DESC LIMIT 1`,
    [EXP_ORIGIN, EXP_DEST, at.toISOString()],
  ).then(({ rows }) => {
    expect(rows, `schedule departing ${at.toISOString()}`).to.have.length(1);
    return cy.wrap(rows[0], { log: false });
  });
}

describe("export ledger day: three trains, every fate, one report", { retries: 0 }, () => {
  before(() => {
    cy.task("db:seedFile", "seed-import-corridor.sql");
    ALL_LINES.forEach(seedLine);
    seedLine({
      suffix: "LR1",
      kind: "ONE_TIME",
      currency: "USD",
      freight: "CONTAINER",
      twenty: 2,
      wagons: 1,
      wagonType: "NW5",
      pays: false,
    });
    seedLine({
      suffix: "LR2",
      kind: "ONE_TIME",
      currency: "ETB",
      freight: "BULK",
      tons: 350,
      wagons: 5,
      wagonType: "PW2",
      pays: false,
    });
  });

  it("operations schedules THREE trains for one day: 37×PW2 built GRAIN, 54×NW5 BOX, 54 MIX", () => {
    ensureExportRoute();
    resetCorridorDay(GRAIN_AT, EXP_DEST, EXP_ORIGIN);

    // The built train's coupled consist IS its capacity — 37, not the
    // loco-length-derived 54 every other schedule in the suite gets.
    createImportSchedule({
      departure: GRAIN_AT,
      trainCode: "TRN-LEDGER-PW2",
      maxWagons: 37,
      kind: "bulk",
      originCode: EXP_ORIGIN,
      destCode: EXP_DEST,
    });
    createImportSchedule({
      departure: BOX_AT,
      locoPair: ["LOCO-LED-3", "LOCO-LED-4"],
      originCode: EXP_ORIGIN,
      destCode: EXP_DEST,
    });
    createImportSchedule({
      departure: MIX_AT,
      locoPair: ["LOCO-LED-5", "LOCO-LED-6"],
      originCode: EXP_ORIGIN,
      destCode: EXP_DEST,
    });

    [GRAIN_AT, BOX_AT, MIX_AT].forEach((at) =>
      daySchedule(at).then((s) => forceWindowOpen(s.id, 120)),
    );
    daySchedule(GRAIN_AT).then((s) =>
      expect(s.max_wagons, "built consist = 37 PW2").to.eq(37),
    );
    daySchedule(BOX_AT).then((s) => expect(s.max_wagons, "BOX = 54").to.eq(54));
    daySchedule(MIX_AT).then((s) => expect(s.max_wagons, "MIX = 54").to.eq(54));
  });

  it("wave 1 — five bulk bookings fill the built PW2 train exactly (37/37 reserved)", () => {
    GRAIN_LINES.forEach(bookAndAccept);
    daySchedule(GRAIN_AT).then((s) => {
      GRAIN_LINES.forEach((l) =>
        withBooking(l.suffix, (b) =>
          expect(b.train_schedule_id, `${l.suffix} on GRAIN`).to.eq(s.id),
        ),
      );
    });
  });

  it("ONE_TIME is single-slot; GENERAL draws down again but is stopped at the FCFS accept", () => {
    // A second booking on the live ONE_TIME contract LG1 is refused outright…
    bookBulk({
      suffix: "LG1",
      tons: 70,
      scheduledDate: DAY,
      cargoCode: "E2E_IMP_GRAINS",
      expectFailure: "already has an active booking",
    });

    // …while the GENERAL neighbour LG2 draws again freely. Engine truth: a
    // GENERAL booking clears PER BOOKING, so it SKIPS the create-time
    // window/space gate that refused the ONE_TIME latecomers above — a full
    // day stops it only later, when staff try to accept it onto a train.
    bookBulk({ suffix: "LG2", tons: 70, scheduledDate: DAY, cargoCode: "E2E_IMP_GRAINS" });
    withBooking("LG2", (probe) => {
      expect(probe.status, "GENERAL drawdown accepted at create").to.eq(
        "AWAITING_DOCUMENTS",
      );
      // Dispose of the probe so the day's ledger keeps exactly one live
      // booking per contract (every lookup skips soft-deleted rows).
      db(`UPDATE freight.bookings SET deleted_at = now() WHERE id = $1`, [probe.id]);
    });
    withBooking("LG2", (b) =>
      expect(b.status, "LG2's real reservation is untouched").to.be.oneOf([
        "SELECTED_FOR_BATCH",
        "AWAITING_PAYMENT",
      ]),
    );
  });

  it("wave 2 — containers cascade PAST the full PW2 train onto BOX (54/54 reserved)", () => {
    BOX_LINES.forEach(bookAndAccept);
    daySchedule(BOX_AT).then((s) => {
      BOX_LINES.forEach((l) =>
        withBooking(l.suffix, (b) =>
          expect(b.train_schedule_id, `${l.suffix} on BOX`).to.eq(s.id),
        ),
      );
    });
  });

  it("wave 3 — containers and bulk share MIX (24w + 30w = 54/54 reserved)", () => {
    MIX_LINES.forEach(bookAndAccept);
    daySchedule(MIX_AT).then((s) => {
      MIX_LINES.forEach((l) =>
        withBooking(l.suffix, (b) =>
          expect(b.train_schedule_id, `${l.suffix} on MIX`).to.eq(s.id),
        ),
      );
    });
  });

  it("every reservation's pay deadline is clamped to its OWN train's window close", () => {
    const check = (at: Date, lines: Line[]) =>
      daySchedule(at).then((s) => {
        db<{ window_closes_at: string }>(
          `SELECT window_closes_at FROM freight.train_schedules WHERE id = $1`,
          [s.id],
        ).then(({ rows }) => {
          lines.forEach((l) =>
            withBooking(l.suffix, (b) => {
              expect(
                new Date(b.payment_deadline!).getTime(),
                `${l.suffix} clamped`,
              ).to.be.at.most(new Date(rows[0].window_closes_at).getTime());
            }),
          );
        });
      });
    check(GRAIN_AT, GRAIN_LINES);
    check(BOX_AT, BOX_LINES);
    check(MIX_AT, MIX_LINES);
  });

  it("all 145 slots are held — two late bookings are REFUSED (export never queues)", () => {
    bookContainers({
      suffix: "LR1",
      runStamp: stamp,
      isoSeed: 29_000,
      twenty: 2,
      scheduledDate: DAY,
      expectFailure: /space|window/i,
    });
    bookBulk({
      suffix: "LR2",
      tons: 350,
      scheduledDate: DAY,
      cargoCode: "E2E_IMP_GRAINS",
      expectFailure: /space|window/i,
    });
  });

  it("16 of the 19 pay — typed allocation on all three trains", () => {
    PAID.forEach((l) => {
      markPaid(l.suffix);
      pollAllocations(l.suffix, l.wagons);
      expectWagonType(l.suffix, l.wagonType, l.wagons);
    });
  });

  it("the three unpaid — one per train — expire in one sweep", () => {
    UNPAID.forEach((l) => forceReservationExpiry(l.suffix));
    UNPAID.forEach((l) => pollBookingStatus(l.suffix, "EXPIRED"));
  });

  it("redemption: the refused bulk customer takes GRAIN's freed 5 PW2 wagons and pays", () => {
    bookBulk({ suffix: "LR2", tons: 350, scheduledDate: DAY, cargoCode: "E2E_IMP_GRAINS" });
    acceptExport("LR2");
    markPaid("LR2");
    pollAllocations("LR2", 5);
    expectWagonType("LR2", "PW2", 5);
    daySchedule(GRAIN_AT).then((s) =>
      withBooking("LR2", (b) =>
        expect(b.train_schedule_id, "LR2 rides GRAIN").to.eq(s.id),
      ),
    );
  });

  it("per-train totals: 37 PW2 + 54 NW5 + 54 mixed, links match bookings", () => {
    const totals: Array<[Date, string, number, number]> = [
      // [departure, label, wagons, bookings]
      [GRAIN_AT, "GRAIN", 37, 5], // 4 paid + the redeemed LR2
      [BOX_AT, "BOX", 53, 7], // one booking expired: 54 − 1
      [MIX_AT, "MIX", 44, 5], // one 10w booking expired: 54 − 10
    ];
    totals.forEach(([at, label, wagons, bookings]) => {
      daySchedule(at).then((s) => {
        pollDb<{ n: string }>(
          `${label} carries ${bookings} bookings`,
          `SELECT count(*) AS n FROM freight.train_schedule_bookings
           WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
          [s.id],
          (row) => Number(row?.n) === bookings,
          15,
        );
        db<{ n: string }>(
          `SELECT count(DISTINCT wba.train_set_wagon_id) AS n
           FROM freight.wagon_booking_allocations wba
           JOIN freight.train_schedule_bookings tsb
             ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
           WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
          [s.id],
        ).then(({ rows }) =>
          expect(Number(rows[0].n), `${label} wagons`).to.eq(wagons),
        );
      });
    });
  });

  it("THE LEDGER — classify every booking of the day and write the report", () => {
    dayLedger(stamp, { rejected: REJECTED, redeemed: ["LR2"] }).then((rows) => {
      const ledger = rows as LedgerRow[];
      const byFate = (fate: string) => ledger.filter((r) => r.fate === fate);

      // Class counts: 16 riding + 1 redeemed + 3 expired + 2 refusals.
      expect(byFate("PAID_RIDING").length, "paid & riding").to.eq(PAID.length);
      expect(byFate("REBOOKED_PAID").length, "refused then rebooked").to.eq(1);
      expect(byFate("EXPIRED_UNPAID").length, "reserved but never paid").to.eq(
        UNPAID.length,
      );
      expect(byFate("REJECTED_NO_SPACE").length, "refused at create").to.eq(
        REJECTED.length,
      );
      expect(byFate("RESERVED_UNPAID").length, "nothing left hanging").to.eq(0);

      // Every engineered line landed in its expected class.
      PAID.forEach((l) => {
        const row = ledger.find((r) => r.suffix === l.suffix && r.fate === "PAID_RIDING");
        expect(row, `${l.suffix} rides`).to.exist;
        expect(row!.kind, `${l.suffix} kind`).to.eq(l.kind);
        expect(row!.currency, `${l.suffix} currency`).to.eq(l.currency);
        expect(row!.wagon_type, `${l.suffix} wagon type`).to.eq(l.wagonType);
        expect(row!.wagons, `${l.suffix} wagons`).to.eq(l.wagons);
      });
      UNPAID.forEach((l) =>
        expect(
          ledger.find((r) => r.suffix === l.suffix && r.fate === "EXPIRED_UNPAID"),
          `${l.suffix} expired`,
        ).to.exist,
      );
      // LR2 appears TWICE: the refusal and the redemption.
      expect(
        ledger.filter((r) => r.suffix === "LR2").length,
        "LR2 refused then rebooked",
      ).to.eq(2);

      // Currency split of the riding cargo (USD vs ETB invoices on one day).
      const riding = [...byFate("PAID_RIDING"), ...byFate("REBOOKED_PAID")];
      expect(riding.filter((r) => r.currency === "USD").length, "USD riders").to.be.greaterThan(0);
      expect(riding.filter((r) => r.currency === "ETB").length, "ETB riders").to.be.greaterThan(0);
      // Every riding booking sits on a train, every expired one does not ride.
      riding.forEach((r) => expect(r.train, `${r.suffix} has a train`).to.be.a("string"));

      writeLedgerReport(`export-ledger-${DAY}`, ledger);
    });
  });
});

export {};
