import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import { Pool } from "pg";
import { createPool, resetDB, seedParticipants } from "./helpers";

/**
 * The receipt lane, exercised against the real schema.
 *
 * Every fixture here is a receipt that actually exists: a Coles split-tender e-receipt
 * (7777 Werribee, 11/07/2026, $114.57 settled $40.75 gift card + $73.82 Mastercard), a
 * Woolworths e-receipt (3345 Wyndham Vale, 29/06/2026, $40.75, single gift card) and a
 * Coles photo (556 Manor Lakes, 29/07/2026, $23.18). The split one is the reason the lane
 * writes one transaction per tender leg rather than one per receipt, so it is the case
 * these tests are built around.
 */

const TOKEN = "test-receipt-token";
let pool: Pool;
let POST: typeof import("../../app/api/receipts/ingest/route").POST;

const req = (body: unknown, token: string | null = TOKEN) =>
  ({
    headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
    json: async () => body,
  }) as never;

const colesSplit = () => ({
  receipt_uid: "coles:7777:114:2153:2026-07-11",
  capture_event_id: 412,
  image_sha256: "sha-coles-split",
  merchant_name: "Coles",
  store_detail: "7777",
  transaction_date: "2026-07-11",
  total: 114.57,
  tender_raw: "EFT $40.75\nEFT $73.82\n***** 0443 MASTERCARD\nCREDIT ACCOUNT STORE CARD",
  tender_legs: [
    { leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const },
    { leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" as const },
  ],
  line_items: [
    { description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
    { description: "Tomatoes", qty: 1, unit: "ea", amount: 98.71, category: "produce" },
  ],
});

const woolworthsGiftCard = () => ({
  receipt_uid: "woolworths:3345:62:148:2026-06-29",
  capture_event_id: 500,
  merchant_name: "Woolworths",
  store_detail: "3345",
  transaction_date: "2026-06-29",
  total: 40.75,
  tender_legs: [{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const }],
  line_items: [{ description: "Oat Milk", qty: 2, unit: "ea", amount: 40.75, category: "dairy" }],
});

beforeAll(async () => {
  process.env.RECEIPT_INGEST_TOKEN = TOKEN;
  pool = createPool();
  ({ POST } = await import("../../app/api/receipts/ingest/route"));
});

beforeEach(async () => {
  await resetDB(pool);
  await pool.query("DELETE FROM expense_metadata");
  // transactions.owner_id references participants, and resetDB truncates it with RESTART
  // IDENTITY — so DEFAULT_OWNER_ID (1) has to be re-seeded or every insert here fails the
  // foreign key. Same dependency the order lane has; it just never had to say so.
  await seedParticipants(pool);
});

const legsOf = async (group: string) =>
  (await pool.query(
    `SELECT t.id, t.amount::text, t.payment_method, t.statement_id, em.order_reference, em.line_items, em.flags
       FROM expense_metadata em JOIN transactions t ON t.id = em.transaction_id
      WHERE em.receipt_group = $1 ORDER BY em.order_reference`,
    [group]
  )).rows;

describe("auth", () => {
  it("rejects a missing or wrong token", async () => {
    expect((await POST(req(colesSplit(), null))).status).toBe(401);
    expect((await POST(req(colesSplit(), "nope"))).status).toBe(401);
  });

  it("rejects a body missing what it needs to book money", async () => {
    expect((await POST(req({ merchant_name: "Coles" }))).status).toBe(400);
  });
});

describe("a split-tender receipt", () => {
  it("books one transaction per leg, not one for the total", async () => {
    const res = await POST(req(colesSplit()));
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.legs).toHaveLength(2);

    const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
    expect(rows.map((r) => r.amount)).toEqual(["40.75", "73.82"]);
    // Not one $114.57 row: marked credits it would let the $73.82 statement line
    // double-count; marked card it would be searched for at ±1% of $114.57 and never match.
    expect(rows.some((r) => r.amount === "114.57")).toBe(false);
  });

  it("sums the legs back to the printed total", async () => {
    await POST(req(colesSplit()));
    const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
    const total = rows.reduce((sum, r) => sum + Number(r.amount), 0);
    expect(Number(total.toFixed(2))).toBe(114.57);
  });

  it("marks the gift-card leg credits and the card leg card", async () => {
    await POST(req(colesSplit()));
    const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
    expect(rows.find((r) => r.amount === "40.75")!.payment_method).toBe("credits");
    expect(rows.find((r) => r.amount === "73.82")!.payment_method).toBe("card");
  });

  it("leaves every leg as a manual row for the reconciliation queue", async () => {
    await POST(req(colesSplit()));
    const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
    expect(rows.every((r) => r.statement_id === null)).toBe(true);
  });

  it("puts the line items on the card leg only", async () => {
    // They describe the whole shop but transaction_id is UNIQUE on expense_metadata, so
    // they can attach to one row — and the card leg is the one that reconciles onto the
    // statement line, which is where an unreadable descriptor gets its contents.
    await POST(req(colesSplit()));
    const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
    expect(rows.find((r) => r.amount === "73.82")!.line_items).toHaveLength(2);
    expect(rows.find((r) => r.amount === "40.75")!.line_items).toHaveLength(0);
  });

  it("flags the split so it can be shown as one purchase", async () => {
    await POST(req(colesSplit()));
    const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
    expect(rows[0].flags).toContain("split_tender");
  });
});

describe("a gift-card receipt", () => {
  it("becomes a visible transaction on the day it is scanned", async () => {
    const res = await POST(req(woolworthsGiftCard()));
    expect(res.status).toBe(200);
    const rows = await legsOf("pantry:woolworths:3345:62:148:2026-06-29");
    expect(rows).toHaveLength(1);
    expect(rows[0].amount).toBe("40.75");
    expect(rows[0].payment_method).toBe("credits");
  });

  it("is owned, so it is not invisible in every view", async () => {
    // Owner scoping is COALESCE(t.owner_id, s.owner_id) and these rows carry no statement;
    // a NULL owner is how 85 backfilled order rows ended up in the table and on no screen.
    await POST(req(woolworthsGiftCard()));
    const { rows } = await pool.query("SELECT owner_id FROM transactions WHERE merchant_name = 'Woolworths'");
    expect(rows[0].owner_id).not.toBeNull();
  });
});

describe("idempotency", () => {
  it("keys on the receipt, so the same shop from two files lands once", async () => {
    // A photo and the store's e-receipt PDF are different files with different hashes.
    // Keying on the capture would let one purchase arrive twice.
    await POST(req(colesSplit()));
    await POST(req({ ...colesSplit(), capture_event_id: 999, image_sha256: "sha-different-file" }));
    const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
    expect(rows).toHaveLength(2); // still just the two legs
  });

  it("reports the replay rather than silently doing nothing", async () => {
    await POST(req(colesSplit()));
    const body = await (await POST(req(colesSplit()))).json();
    expect(body.legs.every((l: { skipped?: string }) => l.skipped === "already_ingested")).toBe(true);
  });

  it("falls back to the capture when the receipt did not identify itself", async () => {
    // A crumpled photo can lose the header entirely. That receipt still becomes spend; it
    // just cannot be recognised if the same shop is scanned again from another file.
    await POST(req({ ...colesSplit(), receipt_uid: null }));
    expect(await legsOf("pantry:capture:412")).toHaveLength(2);
  });
});

describe("validation — what becomes money", () => {
  it("refuses a receipt whose tender does not add up to its total", async () => {
    // A missed leg is spend that never appears; booking the rest would put a number in the
    // ledger nobody can stand behind.
    const broken = { ...colesSplit(), tender_legs: [colesSplit().tender_legs[0]] };
    const res = await POST(req(broken));
    expect(res.status).toBe(422);
    expect((await res.json()).reason).toContain("tender legs sum to 40.75");
    expect(await legsOf("pantry:coles:7777:114:2153:2026-07-11")).toHaveLength(0);
  });

  it("refuses a receipt with no tender at all", async () => {
    expect((await POST(req({ ...colesSplit(), tender_legs: [] }))).status).toBe(422);
  });

  it("flags but accepts lines that do not sum, because promo rows are skipped by design", async () => {
    const res = await POST(req({ ...colesSplit(), line_items: [{ description: "One line", amount: 10 }] }));
    expect(res.status).toBe(200);
    expect((await res.json()).flags.some((f: string) => f.startsWith("line_items_sum_"))).toBe(true);
  });

  it("writes nothing at all when validation fails", async () => {
    await POST(req({ ...colesSplit(), tender_legs: [] }));
    const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions");
    expect(rows[0].n).toBe(0);
  });

  it("marks a leg nobody could classify as reconcilable rather than deciding for them", async () => {
    const unknown = { ...colesSplit(), tender_legs: [{ leg_index: 1, amount: 114.57, card_last4: "9999", card_product: null, class: null }] };
    const res = await POST(req(unknown));
    expect(res.status).toBe(200);
    const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
    // NULL is what needsCardMatch() treats as still needing a card match, so it surfaces in
    // the queue instead of being silently excluded from it.
    expect(rows[0].payment_method).toBeNull();
    expect(rows[0].flags).toContain("unclassified_tender");
  });
});

describe("dry run", () => {
  it("validates without writing", async () => {
    const res = await POST(req({ ...colesSplit(), dryRun: true }));
    expect(res.status).toBe(200);
    expect((await res.json()).dryRun).toBe(true);
    const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions");
    expect(rows[0].n).toBe(0);
  });
});
