/**
 * Scoffable Partners API - reference integration client + test suite (single file).
 *
 * One dependency-free TypeScript file (Node 18+ global `fetch`), runnable with no install or build step.
 * It contains two things you can copy independently:
 *   1. OrderSyncClient - polls /v1/orderUpdates, persists its cursor, and handles every documented
 *      failure (401 / 429 / 5xx / expired pageId) the way the API expects. (Track 1.)
 *   2. Till - a *sample* projection of the order stream into point-of-sale style state, demonstrating the
 *      order-handling rules (lifecycle, versioning, substitutions, payments, …). Your real projection
 *      lands in your till/ledger; this just models the resulting state. (Track 2.)
 * …plus a scenario suite that drives the client through every integration-test scenario and asserts each
 * behaves as documented.
 *
 * Run the whole suite:
 *     BASE_URL=https://test-partners-api.scoffable.com TOKEN=… npx tsx integration-test-suite.ts
 *
 * Pass scenario ids to run a subset (e.g. skip the ~60s rate-limit wait):
 *     … npx tsx integration-test-suite.ts test-authentication test-pagination
 *
 * Scenarios are selected purely by URL path segment - `${BASE_URL}/${scenario}/v1/...` - exactly as a real
 * caller does. Exits 0 if every scenario passes, non-zero otherwise. Optional env
 * flags: VERBOSE=1 (access log), BODY=1 (also dump bodies), STATE=1 (per-scenario till table), STEP=1
 * (pause after each applied order).
 */

import * as readline from "node:readline";

// ---------------------------------------------------------------------------------------------------
//  Wire types - the shape of the API responses (see the API reference for the full schema).
// ---------------------------------------------------------------------------------------------------

export type Money = { amount: number; currency: string };

export type OrderItem = {
  id: string;
  name: string;
  type: "product" | "offer" | "voucher" | "adjustment";
  barcode?: string | null;
  quantityOrdered: number;
  quantityFulfilled: number;
  price?: Money;
  total?: Money;
  optionCategories?: { name: string; selectedOptions: { name: string; optionPrice?: Money }[] }[];
  substitutionDetails?: { substitutedBy?: string[]; substitutedFor?: string[] };
  priceAdjustmentDetails?: { relatedPriceAdjustment?: string; itemsAdjusted?: string[] };
};

export type CustomerPayment = {
  type: "online" | "voucher" | "cash";
  collectedBy: "scoffable" | "vendor";
  payment: Money;
};

export type Order = {
  id: number;
  version: number;
  latestVersion: boolean;
  vendorId: string;
  status: "placed" | "accepted" | "rejected" | "cancelled";
  type: "delivery" | "collection";
  placedAt: number;
  acceptedAt?: number;
  cancelledAt?: number;
  deliveredAt?: number;
  deliveryProvider?: string;
  deliveryFee?: Money;
  serviceFee?: Money;
  total: Money;
  items: OrderItem[];
  customerPayments?: CustomerPayment[];
};

export type OrderUpdatesPage = { hasMore: boolean; data: Order[]; nextPageId?: string | null };

// ---------------------------------------------------------------------------------------------------
//  Errors the client surfaces to its caller.
// ---------------------------------------------------------------------------------------------------

/** The token was rejected (401). There is nothing to retry - surface it and stop. */
export class AuthError extends Error {}
/** Your saved cursor is no longer valid and a clean restart didn't help. Alert; don't loop. */
export class CursorExpiredError extends Error {}

// ---------------------------------------------------------------------------------------------------
//  Cursor persistence. Persist `nextPageId` so you resume exactly where you left off across restarts.
//  The default keeps it in memory; a real integration would write it to a file/row/keystore.
// ---------------------------------------------------------------------------------------------------

export interface CursorStore {
  load(): Promise<string | null>;
  save(cursor: string | null): Promise<void>;
}

export class MemoryCursorStore implements CursorStore {
  private cursor: string | null = null;
  async load() {
    return this.cursor;
  }
  async save(cursor: string | null) {
    this.cursor = cursor;
  }
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export type ClientConfig = {
  /** Base URL up to (but not including) `/v1` - e.g. https://partners-api.scoffable.com */
  baseUrl: string;
  token: string;
  pageSize?: number; // 1-100; if you omit it the API defaults to 10, but this client requests 50 (see constructor)
  cursorStore?: CursorStore;
  /** Extra request headers. Integrators don't need this; the test suite uses it for nothing sensitive. */
  extraHeaders?: Record<string, string>;
  log?: (message: string) => void;
  /** Trace HTTP traffic via `log`: `access` logs one line per request, `body` also dumps the response. */
  trace?: { access?: boolean; body?: boolean };
  /** Called after each (new) order is applied to the projection - e.g. to log, checkpoint, or step through. */
  onApply?: (order: Order, till: Till) => void | Promise<void>;
};

/** Counters describing how a sync went - handy for monitoring (and for the test suite). */
export type SyncStats = {
  pages: number;
  ordersSeen: number; // unique (id, version)
  superseded: number; // versions skipped because latestVersion was false (a newer one already exists)
  rateLimited: number; // number of 429s honoured
  serverErrors: number; // number of 5xx retried
  restarts: number; // expired-cursor clean restarts
};

const MAX_RATE_LIMIT_RETRIES = 6; // consecutive 429s on one request before giving up
const MAX_SERVER_RETRIES = 6; // consecutive 5xx on one request before giving up
const MAX_RESTARTS = 3; // consecutive expired-cursor restarts before alerting

export class OrderSyncClient {
  readonly till = new Till();
  private readonly baseUrl: string;
  private readonly token: string;
  private readonly pageSize: number;
  private readonly store: CursorStore;
  private readonly extraHeaders: Record<string, string>;
  private readonly log: (m: string) => void;
  private readonly trace: { access?: boolean; body?: boolean };
  private readonly onApply?: (order: Order, till: Till) => void | Promise<void>;
  private readonly seen = new Set<string>();
  private readonly stats: SyncStats = { pages: 0, ordersSeen: 0, superseded: 0, rateLimited: 0, serverErrors: 0, restarts: 0 };

  constructor(cfg: ClientConfig) {
    this.baseUrl = cfg.baseUrl.replace(/\/$/, "");
    this.token = cfg.token;
    this.pageSize = cfg.pageSize ?? 50;
    this.store = cfg.cursorStore ?? new MemoryCursorStore();
    this.extraHeaders = cfg.extraHeaders ?? {};
    this.log = cfg.log ?? (() => {});
    this.trace = cfg.trace ?? {};
    this.onApply = cfg.onApply;
  }

  /**
   * Drains the order stream from the saved cursor to the end, applying each order to the projection.
   * Resilient to the documented failures; resumes from the persisted cursor, never double-processes.
   */
  async sync(): Promise<SyncStats> {
    let cursor = await this.store.load();
    let restarts = 0;

    while (true) {
      const resumingFromCursor = cursor !== null; // a cold start (no cursor) isn't "progress past a restart"
      const res = await this.requestPageWithRetries(cursor);

      if (res.status === 404) {
        // Expired/unknown pageId. Restart cleanly from the beginning - but bound it, so a cursor that
        // keeps expiring raises an alert instead of looping forever.
        if (++restarts > MAX_RESTARTS) {
          throw new CursorExpiredError("pageId kept expiring after restarting from the beginning");
        }
        this.stats.restarts++;
        this.log(`pageId not found - restarting from the beginning (attempt ${restarts})`);
        cursor = null;
        await this.store.save(null);
        continue;
      }

      if (!res.ok) {
        throw new Error(`Unexpected ${res.status} from orderUpdates`);
      }

      const page = (await res.json()) as OrderUpdatesPage;
      this.stats.pages++;
      for (const order of page.data) {
        const key = `${order.id}:${order.version}`;
        if (this.seen.has(key)) continue; // a page may be re-delivered - never process an order twice
        this.seen.add(key);
        this.stats.ordersSeen++;
        if (!order.latestVersion) {
          // Superseded before we fetched it - a newer version already exists (and the stream will deliver
          // it), so for current-state tracking there's nothing to do: applying it would just be a write
          // we'd immediately overwrite. Skip it. (Every real transition arrives as the current latest, so
          // nothing meaningful is lost - see the lifecycle scenario.)
          this.stats.superseded++;
          this.log(`skip order ${order.id} v${order.version} - superseded (latestVersion:false)`);
          continue;
        }
        this.till.apply(order);
        if (this.onApply) await this.onApply(order, this.till);
      }

      // Persist the cursor after EVERY page - including the last one. When hasMore is false you've caught
      // up for now, but nextPageId still marks where the next new update (a fresh order, or a *new version*
      // of one you've already seen) will appear. Saving it means the next sync() resumes exactly there and
      // picks up what's arrived since, rather than replaying from an older point.
      if (page.nextPageId != null) {
        cursor = page.nextPageId;
        await this.store.save(cursor);
      }
      // Only a successful *cursor-follow* clears the restart count. A cold-start page doesn't - otherwise
      // a cursor that always expires (cold start → page → its cursor 404s → restart → repeat) would loop
      // forever, since each restart's fresh page would keep resetting the bound.
      if (resumingFromCursor) restarts = 0;

      if (!page.hasMore) break; // caught up - next sync() resumes from the cursor saved just above
    }

    return this.stats;
  }

  /** A snapshot of the sync counters - readable even after sync() throws. */
  get statistics(): SyncStats {
    return { ...this.stats };
  }

  /** Fetches one page, honouring 429 `Retry-After` and retrying transient 5xx - both retry the SAME request. */
  private async requestPageWithRetries(cursor: string | null): Promise<Response> {
    let rateLimitRetries = 0;
    let serverRetries = 0;

    while (true) {
      const res = await this.fetchPage(cursor);

      if (res.status === 401) {
        throw new AuthError("Partners API rejected the token (401)");
      }

      if (res.status === 429) {
        if (++rateLimitRetries > MAX_RATE_LIMIT_RETRIES) {
          throw new Error("Still rate limited after backing off repeatedly");
        }
        this.stats.rateLimited++;
        const waitMs = retryAfterMs(res) ?? backoffMs(rateLimitRetries);
        this.log(`429 - waiting ${Math.round(waitMs / 1000)}s before retrying the same request`);
        await sleep(waitMs);
        continue; // same cursor, same request
      }

      if (res.status >= 500) {
        if (++serverRetries > MAX_SERVER_RETRIES) {
          throw new Error(`Server error ${res.status} persisted after retrying`);
        }
        this.stats.serverErrors++;
        const waitMs = backoffMs(serverRetries);
        this.log(`${res.status} - retrying the same request in ${Math.round(waitMs / 1000)}s`);
        await sleep(waitMs);
        continue; // same cursor, same request
      }

      return res; // 2xx, or 404 for the caller to handle
    }
  }

  private fetchPage(cursor: string | null): Promise<Response> {
    const url = new URL(`${this.baseUrl}/v1/orderUpdates`);
    if (cursor) url.searchParams.set("pageId", cursor);
    url.searchParams.set("pageSize", String(this.pageSize));
    return this.tracedFetch(url, {
      headers: { Authorization: `Bearer ${this.token}`, ...this.extraHeaders },
    });
  }

  /** A single `fetch`, with optional access/body logging via `log` (controlled by `trace`). */
  private async tracedFetch(url: URL | string, init: RequestInit): Promise<Response> {
    const started = Date.now();
    const res = await fetch(url, init);
    if (this.trace.access) {
      this.log(`${init.method ?? "GET"} ${url} -> ${res.status} (${Date.now() - started}ms)`);
    }
    if (this.trace.body) {
      const text = await res.clone().text(); // clone so the caller can still read the body
      // Dump the full body, pretty-printed when it's JSON, so you can inspect entire order objects.
      let pretty = text;
      try {
        pretty = JSON.stringify(JSON.parse(text), null, 2);
      } catch {
        /* not JSON (e.g. an error string) - log as-is */
      }
      this.log(`  body:\n${pretty}`);
    }
    return res;
  }

  /** Fetch a single order by id - the other half of the API. */
  async getOrder(id: number): Promise<Order | null> {
    const res = await this.tracedFetch(`${this.baseUrl}/v1/orders/${id}`, {
      headers: { Authorization: `Bearer ${this.token}`, ...this.extraHeaders },
    });
    if (res.status === 404) return null;
    if (res.status === 401) throw new AuthError("Partners API rejected the token (401)");
    if (!res.ok) throw new Error(`Unexpected ${res.status} from orders/${id}`);
    return (await res.json()) as Order;
  }
}

/**
 * Reads `Retry-After` from a 429, in ms, or null if absent/unparseable. Per RFC 7231 the header may be
 * either delta-seconds (e.g. `120`) or an HTTP-date (e.g. `Wed, 21 Oct 2025 07:28:00 GMT`); we handle both
 * so a date-valued header is honoured rather than silently falling through to backoff.
 */
function retryAfterMs(res: Response): number | null {
  const raw = res.headers.get("retry-after");
  if (!raw) return null;
  const seconds = Number(raw);
  if (Number.isFinite(seconds)) return seconds * 1000; // delta-seconds form
  const dateMs = Date.parse(raw); // HTTP-date form
  if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
  return null;
}

/** Exponential backoff with a small floor, capped - for transient 5xx (no Retry-After to honour). */
function backoffMs(attempt: number): number {
  return Math.min(1000 * 2 ** (attempt - 1), 30_000);
}

// ---------------------------------------------------------------------------------------------------
//  Till - a sample projection of the order stream into point-of-sale state.
//
//  It keeps the latest version of each order (ignoring any older version that arrives late) and exposes
//  helpers for the documented order-handling rules. A real till would post a sale on `accepted` and a
//  void on `cancelled`; here we model the resulting *state* so it's easy to read and assert against.
// ---------------------------------------------------------------------------------------------------

export class Till {
  private readonly latest = new Map<number, Order>();
  private readonly everAccepted = new Set<number>();

  apply(order: Order): void {
    const current = this.latest.get(order.id);
    if (current && order.version <= current.version) return; // ignore an older/equal (e.g. out-of-order) version
    if (order.status === "accepted") this.everAccepted.add(order.id);
    this.latest.set(order.id, order);
  }

  /** Every order we're holding, at its latest version. */
  orders(): Order[] {
    return [...this.latest.values()];
  }

  get(id: number): Order | undefined {
    return this.latest.get(id);
  }

  /** Orders currently counting as a sale (accepted and not since cancelled/rejected). */
  activeSales(): Order[] {
    return this.orders().filter((o) => o.status === "accepted");
  }

  /** Orders that were a sale but have since been cancelled - the sale must be reversed/voided. */
  voided(): Order[] {
    return this.orders().filter((o) => o.status === "cancelled" && this.everAccepted.has(o.id));
  }

  /** The product lines actually delivered (fulfilled > 0) - i.e. substitutes, not the items they replaced. */
  deliveredProducts(order: Order): OrderItem[] {
    return order.items.filter((i) => i.type === "product" && i.quantityFulfilled > 0);
  }

  /** Distinct vendor locations across the orders held - each order is routed by its vendorId. */
  vendorIds(): Set<string> {
    return new Set(this.orders().map((o) => o.vendorId));
  }

  /**
   * True when an order's payments add up to its total. Note this returns false for an order with no
   * `customerPayments` (e.g. an unpaid `placed` order) - here that's fine because we only call it where
   * payments exist, but if you reuse it, treat "no payments" as not-applicable rather than not-reconciling.
   */
  paymentsReconcile(order: Order): boolean {
    const paid = (order.customerPayments ?? []).reduce((sum, p) => sum + p.payment.amount, 0);
    return (order.customerPayments?.length ?? 0) > 0 && paid === order.total.amount;
  }
}

const BASE_URL = process.env.BASE_URL;
const TOKEN = process.env.TOKEN;
const BACKLOG = 247; // test-pagination / rate-limit / transient-error stream length (not a round multiple)
const BODY = !!process.env.BODY; // dump response bodies
const VERBOSE = BODY || !!process.env.VERBOSE; // one access-log line per request (implied by BODY)
const STATE = !!process.env.STATE; // print a per-scenario table of the client's resulting state (Track 2)
const STEP = !!process.env.STEP; // pause after each order is applied and re-render the state (Track 2)

if (!BASE_URL || !TOKEN) {
  console.error("Usage: BASE_URL=<test host> TOKEN=<bearer token> npx tsx integration-test-suite.ts [scenario ...]");
  process.exit(2);
}

function assert(cond: unknown, message: string): asserts cond {
  if (!cond) throw new Error(message);
}

/** The client created by the most recent clientFor() call, so STATE rendering can read its Till. */
let lastClient: OrderSyncClient | undefined;
/** Set false (within a scenario) once the user chooses to run it to the end. Reset per scenario. */
let stepping = STEP;

function prompt(question: string): Promise<string> {
  return new Promise((resolve) => {
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    rl.question(question, (answer) => { rl.close(); resolve(answer); });
  });
}

/** STEP=1 hook: after each order is applied, re-render the scenario's state and pause for the user. */
async function stepThrough(scenario: string, order: Order, till: Till): Promise<void> {
  if (!stepping) return;
  const render = STATE_RENDERERS[scenario];
  console.log(`\n  ── applied order ${order.id} v${order.version} [${order.status}] ──`);
  if (render) console.log(render(till).replace(/^/gm, "    "));
  if (!process.stdin.isTTY) return; // non-interactive (e.g. piped): show each step but don't block
  const answer = await prompt("    [Enter] next · [q] run to end · [Ctrl-C] quit: ");
  if (answer.trim().toLowerCase() === "q") stepping = false;
}

/** Builds a fresh client pointed at one scenario (the id is just a path segment on the base URL). */
function clientFor(scenario: string, opts: Partial<ClientConfig> = {}): OrderSyncClient {
  lastClient = new OrderSyncClient({
    baseUrl: `${BASE_URL}/${scenario}`,
    token: TOKEN!,
    log: VERBOSE ? (m) => console.log(`      ${m}`) : undefined,
    trace: { access: VERBOSE, body: BODY },
    onApply: STEP ? (order, till) => stepThrough(scenario, order, till) : undefined,
    ...opts,
  });
  return lastClient;
}

// --------------------------------------------------------------------------------------------------
//  Optional `STATE=1` rendering - a per-scenario ASCII view of the client's resulting Till, tailored
//  to what each Track-2 scenario is about. Lets you step through and see exactly what your system holds.
// --------------------------------------------------------------------------------------------------

const gbp = (minor: number) => "£" + (minor / 100).toFixed(2);

/** A minimal fixed-width ASCII table. */
function tbl(headers: string[], rows: string[][]): string {
  const widths = headers.map((h, c) => Math.max(h.length, ...rows.map((r) => (r[c] ?? "").length)));
  const fmt = (r: string[]) => "  " + r.map((cell, c) => (cell ?? "").padEnd(widths[c])).join("  ");
  return [fmt(headers), "  " + widths.map((w) => "-".repeat(w)).join("  "), ...rows.map(fmt)].join("\n");
}

const legsOf = (o: Order) => (o.customerPayments ?? []).map((p) => `${p.type} ${gbp(p.payment.amount)}`).join(", ");

/** Per-scenario renderers, keyed by scenario id. Only Track-2 scenarios have one. */
const STATE_RENDERERS: Record<string, (t: Till) => string> = {
  "test-order-lifecycle": (t) => {
    const sale = new Set(t.activeSales().map((o) => o.id));
    const reversed = new Set(t.voided().map((o) => o.id));
    return tbl(
      ["Order", "Ver", "Status", "Books as", "Total", "Payments"],
      t.orders().map((o) => [
        String(o.id), "v" + o.version, o.status,
        sale.has(o.id) ? "SALE" : reversed.has(o.id) ? "VOID (reversed)" : "- not a sale",
        gbp(o.total.amount), legsOf(o),
      ]),
    );
  },

  "test-versioning": (t) => {
    const o = t.get(200)!;
    return "Four versions (delivered across three polls) collapsed to one record at the latest amount:\n" +
      tbl(["Order", "Kept ver", "Status", "Total"], [[String(o.id), "v" + o.version, o.status, gbp(o.total.amount)]]);
  },

  "test-out-of-order-versions": (t) => {
    const o = t.get(250)!;
    return "Kept the newer version; ignored the older one that arrived afterwards:\n" +
      tbl(["Order", "Kept ver", "Status", "Total"], [[String(o.id), "v" + o.version, o.status, gbp(o.total.amount)]]);
  },

  "test-items-and-options": (t) => {
    const o = t.get(300)!;
    const rows = o.items.map((i) => {
      const notes: string[] = [];
      const premium = (i.optionCategories ?? []).flatMap((c) => c.selectedOptions).filter((s) => s.optionPrice);
      if (premium.length) notes.push(premium.map((s) => `${s.name} +${gbp(s.optionPrice!.amount)}`).join("; "));
      if (i.type !== "product") notes.push(`(${i.type})`);
      if (i.barcode) notes.push("barcode " + i.barcode);
      return [i.name, `${i.quantityOrdered}/${i.quantityFulfilled}`, gbp(i.price?.amount ?? 0), gbp(i.total?.amount ?? 0), notes.join(" · ")];
    });
    return tbl(["Item", "ord/ful", "Unit", "Line", "Notes"], rows) +
      `\n  fees ${gbp((o.deliveryFee?.amount ?? 0) + (o.serviceFee?.amount ?? 0))}   TOTAL ${gbp(o.total.amount)}`;
  },

  "test-substitutions": (t) => {
    const o = t.get(400)!;
    const name = new Map(o.items.map((i) => [i.id, i.name]));
    const rows = o.items.map((i) => {
      const sd = i.substitutionDetails;
      const note = sd?.substitutedBy?.length ? "→ replaced by " + sd.substitutedBy.map((x) => name.get(x) ?? x).join(", ")
        : sd?.substitutedFor?.length ? "✓ delivered, substitute for " + sd.substitutedFor.map((x) => name.get(x) ?? x).join(", ") : "";
      return [i.name, `${i.quantityOrdered}/${i.quantityFulfilled}`, gbp(i.total?.amount ?? 0), note];
    });
    return tbl(["Item", "ord/ful", "Line", "Substitution"], rows) + `\n  TOTAL ${gbp(o.total.amount)} (delivered items carry the value; replaced originals net to £0)`;
  },

  "test-price-adjustments": (t) => {
    const o = t.get(500)!;
    const name = new Map(o.items.map((i) => [i.id, i.name]));
    const rows = o.items.map((i) => {
      const sd = i.substitutionDetails, pad = i.priceAdjustmentDetails;
      let note = "";
      if (sd?.substitutedBy?.length) note = "replaced by " + sd.substitutedBy.map((x) => name.get(x) ?? x).join(",");
      else if (sd?.substitutedFor?.length) note = "delivered (substitute)";
      if (pad?.itemsAdjusted?.length) note = "adjusts " + pad.itemsAdjusted.map((x) => name.get(x) ?? x).join(",");
      return [i.name, i.type, gbp(i.total?.amount ?? 0), note];
    });
    return tbl(["Item", "Type", "Line", "Link"], rows) + `\n  net TOTAL ${gbp(o.total.amount)} (dearer substitute price-matched back to the original)`;
  },

  "test-vouchers": (t) => {
    const o = t.get(600)!;
    return "Items - the vendor-funded voucher is a negative line that reduces takings:\n" +
      tbl(["Item", "Type", "Line"], o.items.map((i) => [i.name, i.type, gbp(i.total?.amount ?? 0)])) +
      `\n  takings (order total) ${gbp(o.total.amount)}\n` +
      "Payments - the Scoffable voucher is money received, so the vendor still gets full value:\n" +
      tbl(["Payment", "Collected by", "Amount"], (o.customerPayments ?? []).map((p) => [p.type, p.collectedBy, gbp(p.payment.amount)]));
  },

  "test-payments": (t) =>
    t.orders().map((o) => {
      const legs = (o.customerPayments ?? []).reduce((s, p) => s + p.payment.amount, 0);
      return `Order ${o.id} - total ${gbp(o.total.amount)}\n` +
        tbl(["Payment", "Collected by", "Amount"], (o.customerPayments ?? []).map((p) => [p.type, p.collectedBy, gbp(p.payment.amount)])) +
        `\n  legs sum ${gbp(legs)} ${legs === o.total.amount ? "✓ reconciles" : "✗ does not reconcile"}`;
    }).join("\n\n"),

  "test-delivery": (t) =>
    tbl(["Order", "Type", "Provider", "deliveredAt"],
      t.orders().map((o) => [String(o.id), o.type, o.deliveryProvider ?? "-", o.deliveredAt ? new Date(o.deliveredAt).toISOString() : "- (none)"])),

  "test-multi-location": (t) =>
    tbl(["Order", "Vendor", "Total"],
      t.orders().map((o) => [String(o.id), o.vendorId.slice(0, 8) + "…", gbp(o.total.amount)])),
};

type Check = { id: string; track: 1 | 2; run: () => Promise<void> };

const checks: Check[] = [
  // ---- Track 1 - connection & sync -------------------------------------------------------------
  {
    id: "test-authentication",
    track: 1,
    run: async () => {
      const c = clientFor("test-authentication");
      await c.sync();
      assert(c.till.activeSales().length >= 1, "expected at least one accepted order");
    },
  },
  {
    id: "test-invalid-token",
    track: 1,
    run: async () => {
      const c = clientFor("test-invalid-token");
      let threw = false;
      try {
        await c.sync();
      } catch (e) {
        if (e instanceof AuthError) threw = true;
        else throw e;
      }
      assert(threw, "expected an AuthError (401), surfaced rather than retried");
    },
  },
  {
    id: "test-pagination",
    track: 1,
    run: async () => {
      const c = clientFor("test-pagination", { pageSize: 25 });
      const stats = await c.sync();
      assert(c.till.orders().length === BACKLOG, `expected ${BACKLOG} orders, got ${c.till.orders().length}`);
      assert(stats.ordersSeen === BACKLOG, "expected no duplicate processing across pages");
    },
  },
  {
    id: "test-rate-limit",
    track: 1,
    run: async () => {
      const c = clientFor("test-rate-limit", { pageSize: 25 });
      const stats = await c.sync(); // honours Retry-After (~60s) then completes
      assert(stats.rateLimited > 0, "expected a 429 mid-stream");
      assert(c.till.orders().length === BACKLOG, "expected the stream to complete after backing off");
    },
  },
  {
    id: "test-transient-error",
    track: 1,
    run: async () => {
      const c = clientFor("test-transient-error", { pageSize: 25 });
      const stats = await c.sync();
      assert(stats.serverErrors > 0, "expected a transient 5xx mid-stream");
      assert(c.till.orders().length === BACKLOG, "expected the stream to complete after retrying");
    },
  },
  {
    id: "test-expired-page-id",
    track: 1,
    run: async () => {
      const c = clientFor("test-expired-page-id");
      let threw = false;
      try {
        await c.sync();
      } catch (e) {
        if (e instanceof CursorExpiredError) threw = true;
        else throw e;
      }
      assert(c.statistics.restarts > 0, "expected at least one clean restart on the expired cursor");
      assert(threw, "expected a bounded CursorExpiredError rather than an infinite loop");
    },
  },
  {
    id: "test-empty-stream",
    track: 1,
    run: async () => {
      // No orders yet: the client should sync cleanly to completion with zero orders, not error or hang.
      const c = clientFor("test-empty-stream");
      await c.sync();
      assert(c.till.orders().length === 0, "an empty stream should yield zero orders");
      assert(c.statistics.ordersSeen === 0, "no orders should have been processed");
    },
  },

  // ---- Track 2 - order handling ----------------------------------------------------------------
  {
    id: "test-order-lifecycle",
    track: 2,
    run: async () => {
      // Order 100 progresses across three polls (placed -> accepted -> cancelled), each version arriving as
      // the current latest. So we ring the sale when we see it accepted and void it when it's cancelled -
      // none of these are skipped, because each is latestVersion:true at its poll.
      const c = clientFor("test-order-lifecycle");
      await c.sync(); // poll 1 -> 100 placed
      await c.sync(); // poll 2 -> 100 accepted (sale recorded)
      await c.sync(); // poll 3 -> 100 cancelled (+ 101 rejected, 102 accepted, 103 placed)
      const sales = c.till.activeSales().map((o) => o.id);
      const voided = c.till.voided().map((o) => o.id);
      assert(sales.length === 1, `exactly one active sale expected, got [${sales}]`);
      assert(c.till.get(102)?.status === "accepted", "the accepted order (102) should be a sale");
      assert(voided.length === 1 && voided[0] === 100, "the cancelled order (100) should be voided");
    },
  },
  {
    id: "test-versioning",
    track: 2,
    run: async () => {
      // One order (200) lives as four versions, delivered across three polls - a new version lands on a
      // LATER poll, exactly as in production. Each sync() drains one poll, persists the cursor, and the
      // next sync() resumes from it to pick up what's since arrived.
      const c = clientFor("test-versioning");

      await c.sync(); // poll 1 -> v1 (placed)
      assert(c.till.get(200)?.version === 1, "after poll 1 the order is at v1");
      assert(c.till.get(200)?.status === "placed", "v1 is the placed version");

      await c.sync(); // poll 2 -> v2, v3 (two new versions); converge to v3
      assert(c.till.get(200)?.version === 3, "poll 2 delivers v2+v3 and converges to v3");
      assert(c.till.get(200)?.total.amount === 1700, "v3 holds the latest amount (1700), not v1's 3600");

      await c.sync(); // poll 3 -> v4 (delivered)
      assert(c.till.get(200)?.version === 4, "poll 3 delivers the final v4");
      assert(c.till.get(200)?.status === "accepted", "order is at its latest (accepted/delivered) version");
      assert(c.till.get(200)?.total.amount === 1700, "the latest version's amount (1700) still holds");

      // Four versions across three polls still converge to a single current record.
      assert(c.till.orders().length === 1, "the four versions converge to a single order");
    },
  },
  {
    id: "test-out-of-order-versions",
    track: 2,
    run: async () => {
      const c = clientFor("test-out-of-order-versions");
      await c.sync();
      const o = c.till.get(250)!;
      assert(o?.version === 2, "should keep the newer version (v2)");
      assert(o?.status === "accepted", "must not regress to the older 'placed' version");
      // The newer version's money must win too - not revert to the older 1290.
      assert(o?.total.amount === 1490, "should keep the newer version's amount (1490), not the older 1290");
    },
  },
  {
    id: "test-items-and-options",
    track: 2,
    run: async () => {
      const c = clientFor("test-items-and-options");
      await c.sync();
      const o = c.till.get(300)!;
      const short = o.items.find((i) => i.name === "Bottled Water")!;
      assert(short.quantityFulfilled === 2 && short.quantityOrdered === 3, "fulfilled quantity should differ from ordered");
      assert(!!short.barcode, "the packaged product should carry a barcode");
      assert(!o.items.find((i) => i.name === "Set meal for 2")!.barcode, "the composite set meal should have no barcode");
      assert(o.items.some((i) => i.type === "offer"), "the offer line should be present (a discount, not a product)");
    },
  },
  {
    id: "test-substitutions",
    track: 2,
    run: async () => {
      const c = clientFor("test-substitutions");
      await c.sync();
      const delivered = c.till.deliveredProducts(c.till.get(400)!).map((i) => i.name);
      assert(delivered.includes("Dairy Milk 200g") && delivered.includes("Large Chocolate Bar 200g"), "should record the delivered substitutes");
      assert(!delivered.includes("Galaxy 200g") && !delivered.includes("Small Chocolate Bar 50g"), "should not record the replaced originals");
    },
  },
  {
    id: "test-price-adjustments",
    track: 2,
    run: async () => {
      const c = clientFor("test-price-adjustments");
      await c.sync();
      const o = c.till.get(500)!;
      assert(c.till.deliveredProducts(o).some((i) => i.name === "Premium Chocolate 100g"), "should record the delivered substitute");
      const adjustment = o.items.find((i) => i.type === "adjustment");
      assert(!!adjustment && (adjustment.total?.amount ?? 0) < 0, "a negative price adjustment should be present");
    },
  },
  {
    id: "test-vouchers",
    track: 2,
    run: async () => {
      const c = clientFor("test-vouchers");
      await c.sync();
      const o = c.till.get(600)!;
      assert(o.items.some((i) => i.type === "voucher" && (i.total?.amount ?? 0) < 0), "vendor voucher item should reduce takings");
      assert((o.customerPayments ?? []).some((p) => p.type === "voucher" && p.collectedBy === "scoffable"), "Scoffable voucher should be a payment received");
    },
  },
  {
    id: "test-payments",
    track: 2,
    run: async () => {
      const c = clientFor("test-payments");
      await c.sync();
      const orders = c.till.orders();
      assert(orders.length === 2, "expected two orders covering the common payment shapes");
      for (const o of orders) assert(c.till.paymentsReconcile(o), `order ${o.id} legs should reconcile to total`);
      const types = new Set(orders.flatMap((o) => (o.customerPayments ?? []).map((p) => p.type)));
      assert(types.has("online") && types.has("cash") && types.has("voucher"), "should cover online, cash and voucher");
    },
  },
  {
    id: "test-delivery",
    track: 2,
    run: async () => {
      const c = clientFor("test-delivery");
      await c.sync();
      assert(c.till.get(800)?.type === "collection", "the collection order should not be treated as a delivery");
      const vendorDelivery = c.till.get(802)!;
      assert(vendorDelivery.deliveryProvider === "vendor" && !vendorDelivery.deliveredAt, "a vendor delivery may never report deliveredAt");
    },
  },
  {
    id: "test-multi-location",
    track: 2,
    run: async () => {
      const c = clientFor("test-multi-location");
      await c.sync();
      assert(c.till.vendorIds().size === 2, "orders should be routed to two distinct vendor locations");
    },
  },
];

async function main() {
  const filter = process.argv.slice(2);
  const selected = filter.length ? checks.filter((c) => filter.includes(c.id)) : checks;
  if (!selected.length) {
    console.error(`No matching scenarios. Known: ${checks.map((c) => c.id).join(", ")}`);
    process.exit(2);
  }

  console.log(`Running ${selected.length} scenario(s) against ${BASE_URL}\n`);
  const results: { id: string; track: number; ok: boolean; ms: number; error?: string }[] = [];

  for (const check of selected) {
    const started = Date.now();
    lastClient = undefined;
    stepping = STEP; // re-arm stepping for each scenario (a previous 'q' only skips its own scenario)
    try {
      await check.run();
      results.push({ id: check.id, track: check.track, ok: true, ms: Date.now() - started });
      console.log(`  PASS  [T${check.track}] ${check.id}  (${Date.now() - started}ms)`);
    } catch (err: any) {
      results.push({ id: check.id, track: check.track, ok: false, ms: Date.now() - started, error: err?.message ?? String(err) });
      console.log(`  FAIL  [T${check.track}] ${check.id}  - ${err?.message ?? err}`);
    }
    const render = STATE_RENDERERS[check.id];
    if (STATE && render && lastClient) {
      console.log(render(lastClient.till).replace(/^/gm, "    ") + "\n");
    }
  }

  const passed = results.filter((r) => r.ok).length;
  console.log(`\n${passed}/${results.length} passed.`);
  if (passed !== results.length) process.exit(1);
}

main().catch((err) => {
  console.error("Suite crashed:", err?.message ?? err);
  process.exit(1);
});
