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

/**
 * What happens to a scanned receipt when its statement finally arrives.
 *
 * This is the case the first design of this lane got wrong, so it is tested first-class.
 * Reconciliation moves a manual row's overrides, tags and splits onto the statement row and
 * then hides the manual row from every figure. `expense_metadata` was the one child it left
 * behind — which did not matter while metadata only ever came from an email that had made
 * its own transaction, and matters completely now that it carries a shop's line items. Left
 * unmoved, the contents of the shop vanish at exactly the moment the statement line shows
 * up, and `COLES 0556 MANOR LAKES` stays as unreadable as it was before anything was
 * scanned.
 */

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

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

// The reconcile route authenticates a browser session rather than a shared secret.
const userReq = (body: unknown) => ({ json: async () => body }) as never;

const colesSplit = {
  receipt_uid: "coles:7777:114:2153:2026-07-11",
  capture_event_id: 412,
  merchant_name: "Coles",
  transaction_date: "2026-07-11",
  total: 114.57,
  tender_legs: [
    { leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" },
    { leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" },
  ],
  line_items: [
    { description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
    { description: "Jasmine Rice 1kg", qty: 1, unit: "ea", amount: 98.71, category: "pantry_dry" },
  ],
};

async function statementLine(amount: number, date: string, description: string) {
  const statement = await pool.query(
    `INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
     VALUES ('sept.pdf', '1234-5678-9012-3302', 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`,
    [date, ownerId]
  );
  const txn = await pool.query(
    `INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
     VALUES ($1, $2, $3, $4, 'debit', $5) RETURNING id`,
    [statement.rows[0].id, date, description, amount, ownerId]
  );
  return txn.rows[0].id as number;
}

beforeAll(async () => {
  process.env.RECEIPT_INGEST_TOKEN = TOKEN;
  pool = createPool();
  // The reconcile route scopes every query to the signed-in user. Mocked before the import
  // (doMock is not hoisted, so it can close over `ownerId`, which resetDB reassigns on each
  // run) because an ES module binding cannot be reassigned afterwards.
  vi.doMock("@/lib/auth", () => ({
    getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
  }));
  ({ POST: ingest } = await import("../../app/api/receipts/ingest/route"));
  ({ POST: reconcile } = await import("../../app/api/transactions/reconcile/route"));
});

beforeEach(async () => {
  await resetDB(pool);
  await pool.query("DELETE FROM expense_metadata");
  ({ ownerId } = await seedParticipants(pool));
});

const reconcileAs = (manualId: number, statementId: number) =>
  reconcile(userReq({ matches: [{ manual_id: manualId, statement_tx_id: statementId }] }));

describe("a card leg meeting its statement line", () => {
  it("carries the shop's line items onto the statement row", async () => {
    await ingest(ingestReq(colesSplit));
    const manual = await pool.query(
      `SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
        WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
    );
    const manualId = manual.rows[0].id as number;
    const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");

    const res = await reconcileAs(manualId, statementId);
    expect(res.status ?? 200).toBe(200);

    const moved = await pool.query(`SELECT transaction_id, line_items FROM expense_metadata WHERE order_reference = $1`, [
      "pantry:coles:7777:114:2153:2026-07-11#2",
    ]);
    // The whole point: the items are now on the row that survives, not the one that got hidden.
    expect(moved.rows[0].transaction_id).toBe(statementId);
    expect(moved.rows[0].line_items).toHaveLength(2);
  });

  it("counts the shop once, not twice", async () => {
    await ingest(ingestReq(colesSplit));
    const manual = await pool.query(
      `SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
        WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
    );
    const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
    await reconcileAs(manual.rows[0].id, statementId);

    // Reconciled manual rows are excluded from figures by reconciled_with_id; what remains
    // live is the gift-card leg plus the statement line = the $114.57 that was actually spent.
    const { rows } = await pool.query(
      `SELECT coalesce(sum(amount), 0)::text AS total FROM transactions
        WHERE reconciled_with_id IS NULL AND superseded_by_id IS NULL`
    );
    expect(Number(rows[0].total)).toBeCloseTo(114.57, 2);
  });

  it("leaves the gift-card leg alone", async () => {
    await ingest(ingestReq(colesSplit));
    const manual = await pool.query(
      `SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
        WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
    );
    const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
    await reconcileAs(manual.rows[0].id, statementId);

    const gift = await pool.query(
      `SELECT t.reconciled_with_id, t.payment_method FROM transactions t
         JOIN expense_metadata em ON em.transaction_id = t.id
        WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#1'`
    );
    expect(gift.rows[0].reconciled_with_id).toBeNull();
    expect(gift.rows[0].payment_method).toBe("credits");
  });

  it("keeps the other source's metadata when the statement row already has some", async () => {
    // An emailed or Paperless copy of the same purchase may have got there first.
    // transaction_id is UNIQUE, so one of them has to lose — and it must lose visibly
    // rather than by constraint violation at 11pm.
    await ingest(ingestReq(colesSplit));
    const manual = await pool.query(
      `SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
        WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
    );
    const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
    await pool.query(
      `INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items)
       VALUES ($1, 'email', 'email:already-here', '[]'::jsonb)`,
      [statementId]
    );

    const res = await reconcileAs(manual.rows[0].id, statementId);
    expect(res.status ?? 200).toBe(200);

    const incumbent = await pool.query(`SELECT source FROM expense_metadata WHERE transaction_id = $1`, [statementId]);
    expect(incumbent.rows.map((r) => r.source)).toEqual(["email"]);

    const pantryRow = await pool.query(`SELECT flags FROM expense_metadata WHERE order_reference = $1`, [
      "pantry:coles:7777:114:2153:2026-07-11#2",
    ]);
    expect(JSON.stringify(pantryRow.rows[0].flags)).toContain("metadata_collision_on_reconcile");
  });
});
