import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import {
  parseOrderHTML,
  validateOrderTotals,
  resolveCategory,
  NotAReceiptError,
  orderDescription,
  type MessageMeta,
  type ParsedOrder,
} from "../../lib/order-ingestion";

/**
 * Rewritten 2026-07-26. The previous unit suite exercised synthetic fixtures
 * built to satisfy the parser, so it passed while the parser could not read a
 * real email. These run against unmodified captured receipts.
 */
const dir = resolve(__dirname, "../fixtures/orders/real");
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
  messageId: "unit-1",
  subject: "Order Confirmation for Siddharth from Mad Mex",
  receivedAt: "2026-07-16T03:34:00Z",
  sender: "DoorDash Order <no-reply@doordash.com>",
  ...over,
});

describe("payment detection", () => {
  it("credits-only", () => {
    const p = parseOrderHTML(html("dd-01"), meta());
    expect(p.payment.credits_amount).toBe(14.64);
    expect(p.payment.card_last4).toBeNull();
    expect(p.payment.ambiguous).toBe(false);
  });

  it("card-only produces no credits figure", () => {
    const p = parseOrderHTML(
      html("dd-27"),
      meta({ subject: "Order Confirmation for Siddharth from Subway" })
    );
    expect(p.payment.card_last4).toBe("8032");
    expect(p.payment.credits_amount).toBeNull();
  });

  it("'and/or credits' is ambiguous, not silently credits", () => {
    // Regression: an earlier regex delimited the payment line on a double
    // space, which whitespace collapsing removes. Every card and mixed receipt
    // fell through to the credits branch — this one booked the whole $60.93 as
    // credits spend that never happened.
    const p = parseOrderHTML(
      html("dd-10"),
      meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
    );
    expect(p.payment.ambiguous).toBe(true);
    expect(p.payment.card_last4).toBe("8032");
    expect(p.payment.credits_amount).toBeNull();
  });
});

describe("validateOrderTotals", () => {
  const base = (over: Partial<ParsedOrder> = {}): ParsedOrder => ({
    order_reference: "x",
    platform: "doordash",
    merchant_name: "M",
    order_datetime: "2026-03-01T00:00:00Z",
    currency: "AUD",
    payment: { credits_amount: 10, card_amount: null, card_last4: null, ambiguous: false },
    totals: {
      subtotal: null, taxes: null, delivery_fee: null,
      service_fee: null, tip: null, discounts: null, total_charged: 10,
    },
    line_items: [],
    route: [],
    is_family: false,
    flags: [],
    ...over,
  });

  it("rejects a non-positive total", () => {
    const o = base();
    o.totals.total_charged = 0;
    expect(validateOrderTotals(o).ok).toBe(false);
  });

  it("rejects payments that do not account for the total", () => {
    const r = validateOrderTotals(
      base({ payment: { credits_amount: 5, card_amount: null, card_last4: null, ambiguous: false } })
    );
    expect(r.ok).toBe(false);
    expect(r.reason).toMatch(/payments sum/);
  });

  it("accepts a matching total", () => {
    expect(validateOrderTotals(base()).ok).toBe(true);
  });

  it("does not gate on DoorDash's non-reconciling fee breakdown", () => {
    const p = parseOrderHTML(html("dd-01"), meta());
    expect(p.totals.subtotal).toBe(22.10);
    expect(p.totals.discounts).toBe(24.09); // 22.10 + 1.99 — genuinely printed
    expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
  });
});

describe("resolveCategory", () => {
  const o = (merchant: string, platform: ParsedOrder["platform"] = "doordash") =>
    ({ merchant_name: merchant, platform }) as ParsedOrder;

  it("maps grocers to groceries", () => {
    expect(resolveCategory(o("Woolworths"))).toBe("groceries");
    expect(resolveCategory(o("ALDI"))).toBe("groceries");
    expect(resolveCategory(o("GLOMARK Kandana", "ubereats"))).toBe("groceries");
  });

  it("maps restaurants to dining rather than 'other'", () => {
    // The earlier six-merchant allowlist sent every one of these to `other`.
    for (const m of ["Carl's Jr.", "Taco Bell", "Chilli India", "Oporto", "Schnitz", "Souvlaki GR"]) {
      expect(resolveCategory(o(m))).toBe("dining");
    }
  });

  it("maps rides to transport", () => {
    expect(resolveCategory(o("Uber Trip", "uber"))).toBe("transport");
  });
});

describe("parse guards", () => {
  it("throws on a body too short to be a receipt", () => {
    expect(() => parseOrderHTML("<html></html>", meta())).toThrow(NotAReceiptError);
  });

  it("throws rather than inventing a platform", () => {
    expect(() =>
      parseOrderHTML(html("dd-01"), meta({ subject: "Newsletter", sender: "someone@example.com" }))
    ).toThrow(/platform/i);
  });
});

describe("order_reference anchoring", () => {
  it("takes Uber's tripReference, not the first UUID in the document", () => {
    const p = parseOrderHTML(
      html("ue-00"),
      meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" })
    );
    // The UUID the PDF redirect actually resolves to for this receipt.
    expect(p.order_reference).toBe("34d6b4ee-da8f-5029-8d14-bd359617c8e9");
    expect(p.flags).not.toContain("order_uuid_ambiguous");
  });

  it("is stable across repeated parses of the same message", () => {
    const m = meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" });
    const a = parseOrderHTML(html("ue-00"), m).order_reference;
    const b = parseOrderHTML(html("ue-00"), m).order_reference;
    expect(a).toBe(b);
  });

  it("flags ambiguity only when several UUIDs and no anchor", () => {
    // Strip the anchor from a receipt that carries multiple UUIDs (ue-09 has 6).
    const stripped = html("ue-09").replace(/tripReference/gi, "notTheAnchor");
    const p = parseOrderHTML(
      stripped,
      meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" })
    );
    expect(p.flags).toContain("order_uuid_ambiguous");
  });
});

describe("mixed Uber payment (issuer-named card leg)", () => {
  it("captures both legs when the card is labelled by issuer, not brand", () => {
    // Real receipt: Uber Cash $1.17 + Westpac ••••8032 $15.33 = $16.50.
    // A brand allowlist (Visa|MasterCard|Amex) misses "Westpac" and drops the
    // card half, leaving payments that do not account for the total.
    const p = parseOrderHTML(
      readFileSync(resolve(dir, "ue-mixed.html"), "utf-8"),
      meta({ subject: "Your Friday morning order with Uber Eats", sender: "uber.com", receivedAt: "2026-01-09T09:26:44Z" })
    );
    expect(p.totals.total_charged).toBeCloseTo(16.50, 2);
    expect(p.payment.credits_amount).toBeCloseTo(1.17, 2);
    expect(p.payment.card_amount).toBeCloseTo(15.33, 2);
    expect(p.payment.card_last4).toBe("8032");
    expect(validateOrderTotals(p).ok).toBe(true);
  });
});

describe("Uber route (pick-up / delivery)", () => {
  const uber = (f: string, subject = "Your Wednesday order with Uber Eats") =>
    parseOrderHTML(html(f), meta({ subject, sender: "uber.com" }));

  it("reads both stops with their times, as printed", () => {
    const p = uber("ue-00");
    expect(p.route).toEqual([
      { label: "Pick-up", time: "1:20 pm", address: "197 Watton St, Werribee VIC 3030, Australia" },
      { label: "Delivery", time: "1:40 pm", address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia" },
    ]);
  });

  it("de-duplicates the block Uber renders twice", () => {
    // The receipt emits the whole address section a second time for narrow
    // screens. Without de-duplication every trip has four stops.
    expect(uber("ue-00").route).toHaveLength(2);
    expect(uber("ue-26").route).toHaveLength(2);
  });

  it("keeps the receipt's own wording rather than normalising it", () => {
    // Uber is not internally consistent: "Pick-up" on some receipts,
    // "Pickup" on others. Inventing a canonical spelling would hide that a
    // template changed.
    expect(uber("ue-mixed", "Your Friday morning order with Uber Eats").route[0].label).toBe("Pickup");
  });

  it("works on an international receipt", () => {
    const p = uber("ue-26");
    expect(p.route[1].address).toContain("Luzern, Switzerland");
  });

  it("DoorDash has no route — its receipts carry no addresses", () => {
    expect(parseOrderHTML(html("dd-01"), meta()).route).toEqual([]);
  });
});

describe("Uber line items", () => {
  it("itemises a grocery order, binding qty/title/amount by item id", () => {
    const p = parseOrderHTML(
      html("ue-09"),
      meta({ subject: "Your Sunday evening order with Uber Eats", sender: "uber.com" })
    );
    expect(p.line_items).toHaveLength(5);
    expect(p.line_items[0]).toMatchObject({
      qty: 1,
      description: "Highland Brewing MILK FULL CREAM U H T 900ML",
      amount: 440,
    });
    // A sold-out item prints 0.00 and is kept: it is why the total is lower
    // than what was ordered, and dropping it makes the receipt unexplainable.
    expect(p.line_items.map((i) => i.amount)).toContain(0);
  });

  it("a restaurant order legitimately has none", () => {
    // Uber itemises groceries only; a restaurant receipt states a total and
    // nothing else. Empty here is the receipt, not a parse failure — so it
    // must not raise no_line_items_parsed either.
    const p = parseOrderHTML(
      html("ue-00"),
      meta({ subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
    );
    expect(p.line_items).toEqual([]);
    expect(p.flags).not.toContain("no_line_items_parsed");
  });
});

/**
 * Uber trips. Captured 2026-06 via a dry-run against the real mailbox after the
 * user pointed out that only *overseas* rides go on a card — local rides are
 * paid with credits, which puts them in the same class as delivery orders.
 */
describe("Uber trips", () => {
  const utMeta: Record<string, MessageMeta> = JSON.parse(
    readFileSync(resolve(dir, "ut-meta.json"), "utf-8")
  );
  const trip = (f: string) => parseOrderHTML(html(f), utMeta[f]);

  it("a local trip is credits-funded", () => {
    const p = trip("ut-00");
    expect(p.platform).toBe("uber");
    expect(p.currency).toBe("AUD");
    expect(p.totals.total_charged).toBeCloseTo(84.78, 2);
    expect(p.payment.credits_amount).toBeCloseTo(84.78, 2);
    expect(p.payment.card_last4).toBeNull();
    expect(validateOrderTotals(p).ok).toBe(true);
  });

  it("an overseas trip is card-settled", () => {
    const p = trip("ut-01");
    expect(p.currency).toBe("NZD");
    expect(p.totals.total_charged).toBeCloseTo(55.51, 2);
    expect(p.payment.card_amount).toBeCloseTo(55.51, 2);
    expect(p.payment.card_last4).toBe("3893");
  });

  it("labels the two ends of a trip, which the receipt does not", () => {
    // Delivery receipts write "1:20 pm - Pick-up"; trip receipts print the time
    // alone. The naive split put the time in `label` and left `time` null.
    const p = trip("ut-00");
    expect(p.route).toEqual([
      {
        label: "Pick-up",
        time: "7:32 pm",
        address: "Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia",
      },
      {
        label: "Drop-off",
        time: "8:10 pm",
        address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia",
      },
    ]);
  });

  it("rejects the charge summary Uber sends before the receipt", () => {
    // Uber sends two mails per trip with the same subject and the same total.
    // The first says "This is not a payment receipt" and carries no
    // tripReference, so order_reference would fall back to msg:<id> and I7
    // could not dedupe it — every trip would be recorded twice.
    expect(() => trip("ut-summary")).toThrow(NotAReceiptError);
  });
});

describe("orderDescription", () => {
  it("names the restaurant, not the courier", () => {
    // The platform is provenance and lives in the Order details panel, which
    // already renders expense_metadata.platform. Putting it here fragmented the
    // merchant — the same restaurant read differently depending on who carried
    // the bag, which nobody rating the food cares about.
    expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex");
    expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe(
      "Order - Coles (Wyndham Vale)"
    );
  });

  it("leaves a merchant that already names the platform alone", () => {
    // A trip's merchant is literally "Uber Trip". What identifies a trip is its
    // addresses, and those live in the Order details panel.
    expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip");
  });

  it("gives the same description whichever platform delivered it", () => {
    // The regression this whole change exists to prevent.
    expect(orderDescription("doordash", "TEG Kebabs & Biryani")).toBe(
      orderDescription("ubereats", "TEG Kebabs & Biryani")
    );
  });
});

/**
 * Two parse failures that between them accounted for 218 of the 287 unreadable
 * messages in the 776-message capture set. Neither was an "old template": the
 * Uber one fails on current 2024-2025 mail, and the DoorDash one fails on every
 * order paid from credits, in every year.
 */
describe("currency notations Uber actually sends", () => {
  const uber = (f: string, subject: string) =>
    parseOrderHTML(html(f), meta({ subject, sender: "Uber Receipts <noreply@uber.com>" }));

  it("reads a symbol-prefixed Australian total", () => {
    // "Total A$54.87". The old pattern allowed a 3-letter ISO code or a bare
    // "$", so A$ — which is what Uber sends for ordinary domestic orders —
    // matched neither and 98 of 275 Uber Eats mails were unreadable.
    const o = uber("ue-aud-prefix", "Your Friday evening order with Uber Eats");
    expect(o.totals.total_charged).toBe(54.87);
    expect(o.currency).toBe("AUD");
  });

  it("reads NZ$ as New Zealand dollars, not Australian", () => {
    // The prefix is the only thing distinguishing them, and getting it wrong
    // books a Queenstown dinner at the wrong rate rather than failing loudly.
    const o = uber("ue-nzd-prefix", "Your Saturday evening order with Uber Eats");
    expect(o.totals.total_charged).toBe(22.83);
    expect(o.currency).toBe("NZD");
  });

  it("reads a bare rupee symbol on a trip", () => {
    const o = uber("ut-inr-symbol", "Your Friday evening trip with Uber");
    expect(o.totals.total_charged).toBe(622.74);
    expect(o.currency).toBe("INR");
  });

  it("still reads the space-separated ISO form", () => {
    // The [Family] LKR receipts depend on this and must not regress.
    const o = uber("ut-nzd-prefix", "Your Sunday afternoon trip with Uber");
    expect(o.totals.total_charged).toBe(10.83);
    expect(o.currency).toBe("NZD");
  });
});

describe("credits-funded orders are orders", () => {
  const credits = () =>
    parseOrderHTML(
      html("dd-credits-zero"),
      meta({ subject: "Order Confirmation for Siddharth from Chilli India" })
    );

  it("records the subtotal when the card was charged nothing", () => {
    // The receipt says "Subtotal $71.86 ... Total Charged $0.00" — truthfully,
    // because credits covered it. Reading that as a $0 order threw away the
    // credit-funded spend this pipeline exists to surface.
    const o = credits();
    expect(o.totals.total_charged).toBe(71.86);
    expect(o.totals.subtotal).toBe(71.86);
    expect(o.flags).toContain("credits_funded_zero_charge");
  });

  it("books the amount as credits, not as a card charge", () => {
    const o = credits();
    expect(o.payment.credits_amount).toBe(71.86);
    expect(o.payment.card_amount).toBeNull();
    expect(o.payment.card_last4).toBeNull();
  });

  it("passes validation instead of being rejected as non-positive", () => {
    // Both stated totals are $0.00 and agree, so the header cross-check has to
    // stand down here or it rejects the very figure the parser overrode.
    const o = credits();
    expect(validateOrderTotals(o, html("dd-credits-zero"))).toEqual({ ok: true });
  });

  it("does not invent a total when the receipt never says credits", () => {
    // The guard that keeps this from becoming "any zero total borrows the
    // subtotal" — a genuinely empty receipt must still fail.
    const notCredits = html("dd-credits-zero").replace(/Paid with/gi, "Charged to");
    const o = parseOrderHTML(notCredits, meta({ subject: "Order Confirmation for Siddharth from Chilli India" }));
    expect(o.totals.total_charged).toBe(0);
    expect(validateOrderTotals(o, notCredits).ok).toBe(false);
  });
});

describe("Uber Cash is credits, not a card", () => {
  it("reads a payment line carrying a timestamp and a prefixed currency", () => {
    // "Payments Uber Cash 10/17/25 8:50 PM A$54.87". The old pattern allowed
    // neither the timestamp nor the A$ prefix, so credits_amount stayed null
    // and the order was filed as card-settled — sent looking for a card leg
    // that does not exist, and left as an orphan with nothing to match on.
    const o = parseOrderHTML(
      html("ue-aud-prefix"),
      meta({
        subject: "Your Friday evening order with Uber Eats",
        sender: "Uber Receipts <noreply@uber.com>",
      })
    );
    expect(o.payment.credits_amount).toBe(54.87);
    expect(o.payment.card_last4).toBeNull();
    expect(o.payment.ambiguous).toBe(false);
  });

  it("still reads the plain form, where the timestamp follows the amount", () => {
    // "Uber Cash $25.33 22/7/26 1:41 pm" — the older layout the widened
    // pattern must not break.
    const o = parseOrderHTML(
      html("ue-00"),
      meta({
        subject: "Your order with Uber Eats",
        sender: "Uber Receipts <noreply@uber.com>",
      })
    );
    expect(o.payment.credits_amount).toBe(25.33);
  });

  it("still reads the card leg of a mixed payment", () => {
    // "Uber Cash $1.17 ... Westpac ••••8032 $15.33" — the credits half must
    // not swallow the card half.
    const o = parseOrderHTML(
      html("ue-mixed"),
      meta({ subject: "Your order with Uber Eats", sender: "Uber Receipts <noreply@uber.com>" })
    );
    expect(o.payment.credits_amount).toBe(1.17);
    expect(o.payment.card_last4).toBe("8032");
  });
});

describe("payment legs", () => {
  const trip = (f: string) =>
    parseOrderHTML(
      html(f),
      meta({
        subject: "Your Wednesday afternoon trip with Uber",
        sender: "Uber Receipts <noreply@uber.com>",
      })
    );

  it("does not add a superseded authorisation to the settled charge", () => {
    // "Citi Prestige ••••0253 AED 17.67" then the same card "AED 577.83",
    // against a stated total of 577.83. The first is a hold, not a part
    // payment; adding it overstates the trip by the held amount.
    const o = trip("ut-reauth");
    expect(o.totals.total_charged).toBe(577.83);
    expect(o.payment.card_amount).toBe(577.83);
    expect(o.payment.card_last4).toBe("0253");
    expect(validateOrderTotals(o, html("ut-reauth")).ok).toBe(true);
  });

  it("adds the legs of a genuinely split payment", () => {
    // "PayPal - <email> A$78.41" + "Uber Cash A$6.85" = 85.26. Neither leg
    // equals the total, so both are real and both must be counted — and the
    // PayPal leg carries no card mask to anchor on.
    const o = trip("ut-paypal");
    expect(o.totals.total_charged).toBe(85.26);
    expect(o.payment.credits_amount).toBe(6.85);
    expect(o.payment.card_amount).toBe(78.41);
    expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true);
  });
});

describe("HTML entities in line items", () => {
  it("splits options on an encoded bullet instead of swallowing it", () => {
    // "<b>Sweet &amp; Sour Crunch</b> (…)<br><font>&bull; Sweet &amp; Sour
    // Crunch 12 Pieces</font>". &bull; was not in the decode table, and
    // parseDoorDashLineItems splits on the literal "•" — so the option never
    // separated and the entity was rendered raw in the Order details panel.
    const o = parseOrderHTML(
      html("dd-bull-entity"),
      meta({ subject: "Order Confirmation for Siddharth from Red Rooster" })
    );
    const all = JSON.stringify(o.line_items);
    expect(all).not.toContain("&bull;");
    expect(all).not.toContain("&amp;");

    const item = o.line_items.find((i) => i.description.startsWith("Sweet"));
    expect(item).toBeDefined();
    // The ampersand decodes, and the bullet becomes a boundary, not text.
    expect(item!.description).toContain("Sweet & Sour Crunch");
    expect(item!.description).not.toContain("•");
    expect(item!.options.length).toBeGreaterThan(0);
  });

  it("leaves a literal &amp;bull; alone rather than turning it into a bullet", () => {
    // Why &amp; resolves last: decoding it first would rewrite text that was
    // deliberately escaped.
    const o = parseOrderHTML(
      html("dd-01").replace("Mad Mex", "A&amp;amp;bull;B"),
      meta()
    );
    expect(JSON.stringify(o)).not.toContain("A&bull;B".replace("&bull;", "•"));
  });
});
