import { describe, it, expect } from "vitest";
import {
  sniffHeaders,
  parseUniversalAmount,
  unitsToAmountString,
  isBlankAmountCell,
  sniffDateFormat,
  parseUniversalDate,
  parseDates,
  collectDistinctTypes,
  fuzzyPrefillTypeMap,
  subtypeFromSourceType,
  applyChartMapping,
  applyJournalMapping,
  type ChartMapping,
  type TypeValueMap,
  type UniversalJournalMapping,
  type UniversalDateFormat,
} from "../../tools/universal-map-logic.js";
import { matchPreset, UNIVERSAL_PRESETS } from "../../tools/universal-presets.js";
import type { JournalEntry } from "../../tools/journal-logic.js";

// ---------------------------------------------------------------------------
// Header sniff
// ---------------------------------------------------------------------------

describe("sniffHeaders", () => {
  it("uses row 1 as headers when hasHeader is true", () => {
    const rows = [
      [" Date ", "Account", "Debit", "Credit"],
      ["01/15/2026", "Checking", "100.00", ""],
      ["01/15/2026", "Sales", "", "100.00"],
    ];
    const s = sniffHeaders(rows, true);
    expect(s.headers).toEqual(["Date", "Account", "Debit", "Credit"]);
    expect(s.sampleRows).toEqual([rows[1], rows[2]]);
    expect(s.dataStartIndex).toBe(1);
    expect(s.columnCount).toBe(4);
  });

  it("generates Column N headers when hasHeader is false", () => {
    const rows = [
      ["01/15/2026", "Checking", "100.00"],
      ["01/15/2026", "Sales", "-100.00"],
    ];
    const s = sniffHeaders(rows, false);
    expect(s.headers).toEqual(["Column 1", "Column 2", "Column 3"]);
    expect(s.sampleRows).toEqual(rows);
    expect(s.dataStartIndex).toBe(0);
  });

  it("caps the sample at 6 data rows", () => {
    const rows = [["h"]].concat(Array.from({ length: 10 }, (_, i) => [`r${i}`]));
    expect(sniffHeaders(rows, true).sampleRows).toHaveLength(6);
  });

  it("uses the widest row for columnCount (ragged files)", () => {
    const rows = [
      ["a", "b"],
      ["1", "2", "3"],
    ];
    const s = sniffHeaders(rows, true);
    expect(s.columnCount).toBe(3);
    expect(sniffHeaders(rows, false).headers).toHaveLength(3);
  });

  it("handles empty input", () => {
    const s = sniffHeaders([], true);
    expect(s.headers).toEqual([]);
    expect(s.sampleRows).toEqual([]);
    expect(s.columnCount).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// Amounts (string ops only)
// ---------------------------------------------------------------------------

describe("parseUniversalAmount", () => {
  it("parses a plain amount", () => {
    expect(parseUniversalAmount("500.00")).toEqual({ units: 5000000, text: "500.00" });
  });

  it("parses thousands separators", () => {
    expect(parseUniversalAmount("1,234.56").text).toBe("1234.56");
    expect(parseUniversalAmount("1,234,567.89").text).toBe("1234567.89");
    expect(parseUniversalAmount("1,234").text).toBe("1234.00");
  });

  it("parses a currency symbol", () => {
    expect(parseUniversalAmount("$1,234.56").text).toBe("1234.56");
    expect(parseUniversalAmount("$ 500.00").text).toBe("500.00");
  });

  it("parses parentheses as negative", () => {
    expect(parseUniversalAmount("(500.00)")).toEqual({ units: -5000000, text: "-500.00" });
    expect(parseUniversalAmount("($1,234.56)").text).toBe("-1234.56");
  });

  it("parses a currency symbol BEFORE the parentheses too", () => {
    expect(parseUniversalAmount("$(1,000.00)")).toEqual({ units: -10000000, text: "-1000.00" });
  });

  it("parses a leading minus", () => {
    expect(parseUniversalAmount("-500.00").units).toBe(-5000000);
    expect(parseUniversalAmount("-$500.00").text).toBe("-500.00");
  });

  it("parses a trailing minus", () => {
    expect(parseUniversalAmount("500.00-")).toEqual({ units: -5000000, text: "-500.00" });
  });

  it("parses a leading plus and surrounding whitespace", () => {
    expect(parseUniversalAmount("  +25.00  ").text).toBe("25.00");
  });

  it("pads short decimals to 2dp", () => {
    expect(parseUniversalAmount("5").text).toBe("5.00");
    expect(parseUniversalAmount("5.5").text).toBe("5.50");
  });

  it("rejects more than 2 decimal places with an actionable error (dollars and cents only)", () => {
    // The downstream pipeline is cents-based (centsFromAmountString truncates
    // a 3rd/4th decimal) — sub-cent inputs must fail loudly here, never
    // silently diverge later.
    expect(() => parseUniversalAmount("1.234")).toThrow(/more than 2 decimal places/);
    expect(() => parseUniversalAmount("0.1234")).toThrow(/dollars and cents/);
    expect(() => parseUniversalAmount("1.2340")).toThrow(/more than 2 decimal places/);
    expect(() => parseUniversalAmount("1,234.567")).toThrow(/unparseable amount/);
  });

  it("parses a bare-fraction amount", () => {
    expect(parseUniversalAmount(".50").text).toBe("0.50");
  });

  it("parses zero without a negative sign", () => {
    expect(parseUniversalAmount("(0.00)").text).toBe("0.00");
    expect(parseUniversalAmount("0").units).toBe(0);
  });

  it("rejects European decimal-comma format with a clear error", () => {
    expect(() => parseUniversalAmount("1.234,56")).toThrow(/European/);
    expect(() => parseUniversalAmount("1234,56")).toThrow(/European/);
    expect(() => parseUniversalAmount("1.234.567,89")).toThrow(/European/);
  });

  it("rejects multi-dot grouping with no comma as European-style", () => {
    expect(() => parseUniversalAmount("1.234.567")).toThrow(/European/);
  });

  it("rejects malformed comma grouping", () => {
    expect(() => parseUniversalAmount("12,34.56")).toThrow(/unparseable amount/);
  });

  it("rejects long decimal tails the same way", () => {
    expect(() => parseUniversalAmount("1.23456")).toThrow(/more than 2 decimal places/);
  });

  it("rejects text and blank", () => {
    expect(() => parseUniversalAmount("abc")).toThrow(/unparseable amount/);
    expect(() => parseUniversalAmount("")).toThrow(/blank/);
  });

  it("rejects absurdly large amounts rather than losing precision", () => {
    expect(() => parseUniversalAmount("123456789012345.00")).toThrow(/too large/);
  });
});

describe("unitsToAmountString", () => {
  it("always renders exactly 2 decimal places", () => {
    expect(unitsToAmountString(5000000)).toBe("500.00");
    expect(unitsToAmountString(-5000000)).toBe("-500.00");
    expect(unitsToAmountString(12300)).toBe("1.23");
    expect(unitsToAmountString(0)).toBe("0.00");
  });

  it("accepts bigint units (group totals can exceed 2^53)", () => {
    expect(unitsToAmountString(123456789012345678900n)).toBe("12345678901234567.89");
    expect(unitsToAmountString(-100n)).toBe("-0.01");
  });

  it("throws on sub-cent residue rather than silently truncating money", () => {
    expect(() => unitsToAmountString(12340)).toThrow(/sub-cent/);
    expect(() => unitsToAmountString(1n)).toThrow(/sub-cent/);
  });
});

describe("isBlankAmountCell", () => {
  it("treats empty, '-' and '--' as blank", () => {
    expect(isBlankAmountCell("")).toBe(true);
    expect(isBlankAmountCell("  ")).toBe(true);
    expect(isBlankAmountCell("-")).toBe(true);
    expect(isBlankAmountCell("--")).toBe(true);
  });

  it("does not treat '0' or a real amount as blank", () => {
    expect(isBlankAmountCell("0")).toBe(false);
    expect(isBlankAmountCell("5.00")).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// Dates
// ---------------------------------------------------------------------------

describe("sniffDateFormat", () => {
  it("recognizes ISO YYYY-MM-DD confidently", () => {
    const s = sniffDateFormat(["2026-01-31", "2026-02-01"]);
    expect(s.format).toBe("YYYY-MM-DD");
    expect(s.confident).toBe(true);
  });

  it("recognizes MM/DD/YYYY confidently when a day over 12 proves the order", () => {
    const s = sniffDateFormat(["01/31/2026", "02/15/2026"]);
    expect(s.format).toBe("MM/DD/YYYY");
    expect(s.confident).toBe(true);
  });

  it("defaults to MM/DD/YYYY but is not confident when every day is 12 or less", () => {
    const s = sniffDateFormat(["01/05/2026", "02/03/2026"]);
    expect(s.format).toBe("MM/DD/YYYY");
    expect(s.confident).toBe(false);
    expect(s.ambiguousBecause).toMatch(/12 or less/);
  });

  it("recognizes DD/MM/YYYY when the first component exceeds 12", () => {
    const s = sniffDateFormat(["31/01/2026", "15/02/2026"]);
    expect(s.format).toBe("DD/MM/YYYY");
    expect(s.confident).toBe(true);
  });

  it("recognizes M/D/YY, with the same day-ambiguity rule", () => {
    const proven = sniffDateFormat(["1/15/26", "2/28/26"]);
    expect(proven.format).toBe("M/D/YY");
    expect(proven.confident).toBe(true);

    const ambiguous = sniffDateFormat(["1/5/26", "2/3/26"]);
    expect(ambiguous.format).toBe("M/D/YY");
    expect(ambiguous.confident).toBe(false);
  });

  it("recognizes MMM D YYYY (with or without a comma)", () => {
    const s = sniffDateFormat(["Jan 5 2026", "Feb 12, 2026"]);
    expect(s.format).toBe("MMM D YYYY");
    expect(s.confident).toBe(true);
  });

  it("recognizes D-MMM-YY", () => {
    const s = sniffDateFormat(["5-Jan-26", "12-Feb-26"]);
    expect(s.format).toBe("D-MMM-YY");
    expect(s.confident).toBe(true);
  });

  it("recognizes MM-DD-YYYY, ambiguous when days stay at 12 or less", () => {
    expect(sniffDateFormat(["01-31-2026"]).format).toBe("MM-DD-YYYY");
    expect(sniffDateFormat(["01-31-2026"]).confident).toBe(true);
    expect(sniffDateFormat(["01-05-2026"]).confident).toBe(false);
  });

  it("ignores blank values when sniffing", () => {
    expect(sniffDateFormat(["", "2026-01-31", " "]).format).toBe("YYYY-MM-DD");
  });

  it("returns null with a reason when no single format fits every value", () => {
    const s = sniffDateFormat(["2026-01-31", "01/31/2026"]);
    expect(s.format).toBeNull();
    expect(s.confident).toBe(false);
    expect(s.ambiguousBecause).toBeTruthy();
  });

  it("returns null for an empty sample", () => {
    expect(sniffDateFormat([]).format).toBeNull();
    expect(sniffDateFormat(["", " "]).format).toBeNull();
  });
});

describe("parseUniversalDate", () => {
  it("parses every supported format to ISO", () => {
    expect(parseUniversalDate("01/31/2026", "MM/DD/YYYY")).toBe("2026-01-31");
    expect(parseUniversalDate("1/5/26", "M/D/YY")).toBe("2026-01-05");
    expect(parseUniversalDate("2026-01-31", "YYYY-MM-DD")).toBe("2026-01-31");
    expect(parseUniversalDate("31/01/2026", "DD/MM/YYYY")).toBe("2026-01-31");
    expect(parseUniversalDate("Jan 5 2026", "MMM D YYYY")).toBe("2026-01-05");
    expect(parseUniversalDate("Feb 12, 2026", "MMM D YYYY")).toBe("2026-02-12");
    expect(parseUniversalDate("5-Jan-26", "D-MMM-YY")).toBe("2026-01-05");
    expect(parseUniversalDate("01-31-2026", "MM-DD-YYYY")).toBe("2026-01-31");
  });

  it("windows two-digit years: 70+ is 1900s, below 70 is 2000s", () => {
    expect(parseUniversalDate("1/5/98", "M/D/YY")).toBe("1998-01-05");
    expect(parseUniversalDate("1/5/69", "M/D/YY")).toBe("2069-01-05");
    expect(parseUniversalDate("5-Jan-98", "D-MMM-YY")).toBe("1998-01-05");
  });

  it("accepts a 4-digit year in D-MMM-YY (a common export variant)", () => {
    expect(parseUniversalDate("5-Jan-2026", "D-MMM-YY")).toBe("2026-01-05");
  });

  it("validates the calendar", () => {
    expect(() => parseUniversalDate("13/01/2026", "MM/DD/YYYY")).toThrow(/month/);
    expect(() => parseUniversalDate("02/30/2026", "MM/DD/YYYY")).toThrow(/day/);
    expect(() => parseUniversalDate("Feb 30 2026", "MMM D YYYY")).toThrow(/day/);
    expect(() => parseUniversalDate("Foo 5 2026", "MMM D YYYY")).toThrow(/month/);
  });

  it("rejects a value that does not fit the given format", () => {
    expect(() => parseUniversalDate("2026-01-31", "MM/DD/YYYY")).toThrow(/unparseable date/);
    expect(() => parseUniversalDate("", "MM/DD/YYYY")).toThrow(/blank/);
  });

  it("never falls back to ambiguous Date() parsing", () => {
    // A value the JS Date constructor would happily parse must still be
    // rejected when it doesn't match the declared format.
    expect(() => parseUniversalDate("January 5, 2026 08:00", "MMM D YYYY")).toThrow(
      /unparseable date/,
    );
  });
});

describe("parseDates", () => {
  it("returns aligned ISO dates and row-numbered errors", () => {
    const { dates, errors } = parseDates(["01/15/2026", "bad", "01/20/2026"], "MM/DD/YYYY");
    expect(dates).toEqual(["2026-01-15", null, "2026-01-20"]);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(2);
    expect(errors[0]!.message).toMatch(/unparseable date "bad"/);
  });
});

// ---------------------------------------------------------------------------
// Chart mapping — type value-mapping
// ---------------------------------------------------------------------------

const chartRows = (cells: string[][]): string[][] => [["Name", "Type", "Code"], ...cells];

const basicChartMapping: ChartMapping = { headerRows: 1, nameCol: 0, typeCol: 1, codeCol: 2 };

describe("collectDistinctTypes", () => {
  it("collects distinct trimmed raw types in first-appearance order", () => {
    const rows = chartRows([
      ["Checking", " Bank ", "1000"],
      ["Savings", "Bank", "1010"],
      ["Visa", "Credit Card", "2000"],
      ["Sales", "Income", "4000"],
    ]);
    expect(collectDistinctTypes(rows, basicChartMapping)).toEqual([
      "Bank",
      "Credit Card",
      "Income",
    ]);
  });

  it("skips blank type cells (they error later, in applyChartMapping)", () => {
    const rows = chartRows([["Checking", "", "1000"]]);
    expect(collectDistinctTypes(rows, basicChartMapping)).toEqual([]);
  });
});

describe("fuzzyPrefillTypeMap", () => {
  it("prefills asset synonyms", () => {
    const m = fuzzyPrefillTypeMap([
      "Bank",
      "Cash",
      "Checking",
      "Fixed Asset",
      "Other Current Asset",
      "Accounts Receivable",
      "Asset",
    ]);
    for (const k of Object.keys(m)) expect(m[k]).toBe("asset");
  });

  it("prefills liability synonyms", () => {
    const m = fuzzyPrefillTypeMap([
      "Accounts Payable",
      "AP",
      "Credit Card",
      "Loan",
      "LOC",
      "Line of Credit",
      "Other Current Liability",
      "Long Term Liability",
      "Liability",
    ]);
    for (const k of Object.keys(m)) expect(m[k]).toBe("liability");
  });

  it("prefills equity, income, and expense synonyms", () => {
    const m = fuzzyPrefillTypeMap([
      "Equity",
      "Retained Earnings",
      "Capital",
      "Income",
      "Revenue",
      "Sales",
      "Expense",
      "COGS",
      "Cost of Goods Sold",
      "Cost of Sales",
      "Overhead",
    ]);
    expect(m["Equity"]).toBe("equity");
    expect(m["Retained Earnings"]).toBe("equity");
    expect(m["Capital"]).toBe("equity");
    expect(m["Income"]).toBe("income");
    expect(m["Revenue"]).toBe("income");
    expect(m["Sales"]).toBe("income");
    expect(m["Expense"]).toBe("expense");
    expect(m["COGS"]).toBe("expense");
    expect(m["Cost of Goods Sold"]).toBe("expense");
    expect(m["Cost of Sales"]).toBe("expense");
    expect(m["Overhead"]).toBe("expense");
  });

  it("is case- and punctuation-insensitive", () => {
    const m = fuzzyPrefillTypeMap(["ACCOUNTS RECEIVABLE", "credit-card", "Owner's Equity"]);
    expect(m["ACCOUNTS RECEIVABLE"]).toBe("asset");
    expect(m["credit-card"]).toBe("liability");
    expect(m["Owner's Equity"]).toBe("equity");
  });

  it("prefers the longer, more specific phrase on overlap", () => {
    // "Cost of Sales" contains "Sales" (income) but the longer phrase wins.
    expect(fuzzyPrefillTypeMap(["Cost of Sales"])["Cost of Sales"]).toBe("expense");
  });

  it("respects word boundaries — no substring-inside-a-word matches", () => {
    // "Overheads Analysis Code" contains the WORD-adjacent "overheads"; but a
    // truly embedded fragment like "scapital" must not match "capital".
    expect(fuzzyPrefillTypeMap(["Xscapitals"])["Xscapitals"]).toBeNull();
  });

  it("returns null for unknown types so the UI forces a choice", () => {
    expect(fuzzyPrefillTypeMap(["Widget Class"])["Widget Class"]).toBeNull();
  });

  it("returns null on an equal-strength tie between kinds", () => {
    // "cash" (asset, 4 chars) vs "loan" (liability, 4 chars): a tie must not
    // silently pick a side.
    expect(fuzzyPrefillTypeMap(["Cash Loan"])["Cash Loan"]).toBeNull();
  });

  it("treats Object.prototype member names as ordinary unknown types", () => {
    const names = ["constructor", "toString", "hasOwnProperty", "valueOf", "__proto__"];
    const m = fuzzyPrefillTypeMap(names);
    for (const name of names) {
      expect(Object.prototype.hasOwnProperty.call(m, name)).toBe(true);
      expect(m[name]).toBeNull();
    }
    // "__proto__" round-trip: the result must carry it as an OWN key (a
    // plain {} would have mutated its prototype instead of storing it).
    expect(Object.keys(fuzzyPrefillTypeMap(["__proto__"]))).toEqual(["__proto__"]);
  });
});

describe("subtypeFromSourceType", () => {
  it("maps clear source types to our subtype keys", () => {
    expect(subtypeFromSourceType("Bank")).toBe("bank");
    expect(subtypeFromSourceType("Credit Card")).toBe("credit-card");
    expect(subtypeFromSourceType("Line of Credit")).toBe("line-of-credit");
    expect(subtypeFromSourceType("Accounts Receivable")).toBe("accounts-receivable");
    expect(subtypeFromSourceType("Accounts Payable")).toBe("accounts-payable");
    expect(subtypeFromSourceType("Other Current Asset")).toBe("other-current-asset");
    expect(subtypeFromSourceType("Fixed Asset")).toBe("fixed-asset");
    expect(subtypeFromSourceType("Other Asset")).toBe("other-asset");
    expect(subtypeFromSourceType("Other Current Liability")).toBe("other-current-liability");
    expect(subtypeFromSourceType("Long Term Liability")).toBe("long-term-liability");
    expect(subtypeFromSourceType("Equity")).toBe("equity");
    expect(subtypeFromSourceType("Income")).toBe("income");
    expect(subtypeFromSourceType("Other Income")).toBe("other-income");
    expect(subtypeFromSourceType("Expense")).toBe("expense");
    expect(subtypeFromSourceType("Cost of Goods Sold")).toBe("cost-of-goods-sold");
    expect(subtypeFromSourceType("Other Expense")).toBe("other-expense");
  });

  it("maps Xero's Direct Costs to cost-of-goods-sold", () => {
    expect(subtypeFromSourceType("Direct Costs")).toBe("cost-of-goods-sold");
  });

  it("is case/punctuation-insensitive", () => {
    expect(subtypeFromSourceType("CREDIT CARD")).toBe("credit-card");
    expect(subtypeFromSourceType("long-term liability")).toBe("long-term-liability");
  });

  it("returns undefined when the source type is not a clear match", () => {
    expect(subtypeFromSourceType("Widget Class")).toBeUndefined();
    expect(subtypeFromSourceType("")).toBeUndefined();
  });

  it("never resolves Object.prototype member names to a subtype", () => {
    for (const name of ["constructor", "toString", "hasOwnProperty", "valueOf", "__proto__"]) {
      expect(subtypeFromSourceType(name)).toBeUndefined();
    }
  });
});

// ---------------------------------------------------------------------------
// Chart mapping — applyChartMapping
// ---------------------------------------------------------------------------

describe("applyChartMapping", () => {
  const typeMap: TypeValueMap = {
    Bank: "asset",
    Income: "income",
    Expense: "expense",
    "Credit Card": "liability",
    "Fixed Asset": "asset",
  };

  it("maps name/type/code rows", () => {
    const rows = chartRows([
      ["Checking", "Bank", "1000"],
      ["Sales", "Income", "4000"],
    ]);
    const { accounts, errors } = applyChartMapping(rows, basicChartMapping, typeMap);
    expect(errors).toEqual([]);
    expect(accounts).toEqual([
      { name: "Checking", type: "asset", code: "1000", aliases: [], subtype: "bank" },
      { name: "Sales", type: "income", code: "4000", aliases: [], subtype: "income" },
    ]);
  });

  it("omits code when the cell is blank or unmapped", () => {
    const rows = chartRows([["Checking", "Bank", ""]]);
    const withBlank = applyChartMapping(rows, basicChartMapping, typeMap);
    expect(withBlank.accounts[0]!.code).toBeUndefined();

    const noCodeCol = applyChartMapping(rows, { headerRows: 1, nameCol: 0, typeCol: 1 }, typeMap);
    expect(noCodeCol.accounts[0]!.code).toBeUndefined();
  });

  it("errors, with row numbers, on blank names, blank types, and unmapped types", () => {
    const rows = chartRows([
      ["", "Bank", "1000"],
      ["Truck", "", "1500"],
      ["Gadgets", "Widget Class", "9000"],
    ]);
    const { accounts, errors } = applyChartMapping(rows, basicChartMapping, typeMap);
    expect(accounts).toEqual([]);
    expect(errors).toHaveLength(3);
    expect(errors[0]).toMatchObject({ row: 2 });
    expect(errors[0]!.message).toMatch(/name is blank/);
    expect(errors[1]).toMatchObject({ row: 3 });
    expect(errors[1]!.message).toMatch(/type is blank/);
    expect(errors[2]).toMatchObject({ row: 4 });
    expect(errors[2]!.message).toMatch(/"Widget Class".*isn't mapped/);
  });

  it("errors on raw types named after Object.prototype members instead of inheriting them", () => {
    const rows = chartRows([
      ["Acct A", "constructor", ""],
      ["Acct B", "toString", ""],
      ["Acct C", "hasOwnProperty", ""],
      ["Acct D", "valueOf", ""],
      ["Acct E", "__proto__", ""],
    ]);
    const { accounts, errors } = applyChartMapping(rows, basicChartMapping, typeMap);
    expect(accounts).toEqual([]);
    expect(errors).toHaveLength(5);
    for (const e of errors) expect(e.message).toMatch(/isn't mapped/);
  });

  it("errors on a raw type explicitly mapped to null (UI must force a choice)", () => {
    const rows = chartRows([["Mystery", "Odd Type", ""]]);
    const { errors } = applyChartMapping(rows, basicChartMapping, {
      "Odd Type": null,
    });
    expect(errors).toHaveLength(1);
    expect(errors[0]!.message).toMatch(/isn't mapped/);
  });

  it("errors on duplicate account names, naming the first row", () => {
    const rows = chartRows([
      ["Checking", "Bank", "1000"],
      ["Checking", "Bank", "1001"],
    ]);
    const { accounts, errors } = applyChartMapping(rows, basicChartMapping, typeMap);
    expect(accounts).toHaveLength(1);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(3);
    expect(errors[0]!.message).toMatch(/duplicate.*row 2/);
  });

  it("builds full-path names from a parent column and aliases the leaf", () => {
    const rows = [
      ["Name", "Type", "Code", "Parent"],
      ["Checking", "Bank", "1000", "Assets"],
      ["Sales", "Income", "4000", ""],
    ];
    const mapping: ChartMapping = { headerRows: 1, nameCol: 0, typeCol: 1, codeCol: 2, parentCol: 3 };
    const { accounts, errors } = applyChartMapping(rows, mapping, typeMap);
    expect(errors).toEqual([]);
    expect(accounts[0]!.name).toBe("Assets:Checking");
    expect(accounts[0]!.aliases).toEqual(["Checking"]);
    expect(accounts[1]!.name).toBe("Sales");
    expect(accounts[1]!.aliases).toEqual([]);
  });

  it("supports fullPathCol (which may be the same column as nameCol, QB-style)", () => {
    const rows = [
      ["Account", "Type"],
      ["Assets:Bank Accounts:Checking", "Bank"],
      ["Sales", "Income"],
    ];
    const mapping: ChartMapping = { headerRows: 1, nameCol: 0, typeCol: 1, fullPathCol: 0 };
    const { accounts, errors } = applyChartMapping(rows, mapping, typeMap);
    expect(errors).toEqual([]);
    expect(accounts[0]!.name).toBe("Assets:Bank Accounts:Checking");
    expect(accounts[0]!.aliases).toEqual(["Checking"]);
  });

  it("gives no alias to an ambiguous leaf (mirrors the QB chart loader)", () => {
    const rows = [
      ["Account", "Type"],
      ["Truck:Depreciation", "Fixed Asset"],
      ["Trailer:Depreciation", "Fixed Asset"],
    ];
    const mapping: ChartMapping = { headerRows: 1, nameCol: 0, typeCol: 1, fullPathCol: 0 };
    const { accounts } = applyChartMapping(rows, mapping, typeMap);
    expect(accounts[0]!.aliases).toEqual([]);
    expect(accounts[1]!.aliases).toEqual([]);
  });

  it("gives no alias to a leaf that collides with another account's full name", () => {
    const rows = [
      ["Account", "Type"],
      ["Checking", "Bank"],
      ["Assets:Checking", "Bank"],
    ];
    const mapping: ChartMapping = { headerRows: 1, nameCol: 0, typeCol: 1, fullPathCol: 0 };
    const { accounts } = applyChartMapping(rows, mapping, typeMap);
    expect(accounts[1]!.aliases).toEqual([]);
  });

  it("supports a configurable path separator", () => {
    const rows = [
      ["Account", "Type"],
      ["Assets > Checking", "Bank"],
    ];
    const mapping: ChartMapping = {
      headerRows: 1, nameCol: 0, typeCol: 1, fullPathCol: 0, pathSeparator: " > ",
    };
    const { accounts } = applyChartMapping(rows, mapping, typeMap);
    expect(accounts[0]!.name).toBe("Assets > Checking");
    expect(accounts[0]!.aliases).toEqual(["Checking"]);
  });

  it("emits subtype only when it agrees with the user's chosen kind", () => {
    const rows = chartRows([["Company Visa", "Credit Card", ""]]);
    const asLiability = applyChartMapping(rows, basicChartMapping, { "Credit Card": "liability" });
    expect(asLiability.accounts[0]!.subtype).toBe("credit-card");

    // The user overrode "Credit Card" to asset — a liability subtype would
    // contradict them, so it is dropped.
    const asAsset = applyChartMapping(rows, basicChartMapping, { "Credit Card": "asset" });
    expect(asAsset.accounts[0]!.subtype).toBeUndefined();
  });

  it("skips blank rows silently", () => {
    const rows = chartRows([
      ["", "", ""],
      ["Checking", "Bank", "1000"],
    ]);
    const { accounts, errors } = applyChartMapping(rows, basicChartMapping, typeMap);
    expect(errors).toEqual([]);
    expect(accounts).toHaveLength(1);
  });

  it("throws on a structurally invalid mapping (parentCol AND fullPathCol)", () => {
    const mapping: ChartMapping = {
      headerRows: 1, nameCol: 0, typeCol: 1, parentCol: 2, fullPathCol: 3,
    };
    expect(() => applyChartMapping([], mapping, {})).toThrow(/parentCol and fullPathCol/);
  });
});

// ---------------------------------------------------------------------------
// Journal mapping
// ---------------------------------------------------------------------------

const dcMapping: UniversalJournalMapping = {
  headerRows: 1,
  dateCol: 0,
  dateFormat: "MM/DD/YYYY",
  accountCol: 1,
  amountShape: { kind: "debit-credit", debitCol: 2, creditCol: 3 },
  grouping: { kind: "key-column", groupKeyCol: 4 },
  memoCol: 5,
};

const dcHeader = ["Date", "Account", "Debit", "Credit", "Trans", "Memo"];

describe("applyJournalMapping — debit/credit shape", () => {
  it("maps rows into QB-loader-compatible entry groups", () => {
    const rows = [
      dcHeader,
      ["01/15/2026", "Checking", "1,000.00", "", "T1", "deposit"],
      ["01/15/2026", "Sales", "", "$1,000.00", "T1", "deposit"],
      ["01/16/2026", "Rent Expense", "500.00", "", "T2", "rent"],
      ["01/16/2026", "Checking", "", "500.00", "T2", "rent"],
    ];
    const { entries, errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toEqual([]);
    expect(entries).toHaveLength(2);

    // Shape compatibility with the QB loader's JournalEntry groups.
    const first: JournalEntry = entries[0]!;
    expect(first.entryKey).toBe("T1");
    expect(first.dateIso).toBe("2026-01-15");
    expect(first.lines).toHaveLength(2);
    expect(first.lines[0]).toEqual({
      entryKey: "T1",
      type: "",
      dateIso: "2026-01-15",
      num: "",
      name: "",
      memo: "deposit",
      account: "Checking",
      debit: "1000.00",
      credit: "",
    });
    // Continuation lines leave dateIso blank, mirroring the QB parser.
    expect(first.lines[1]!.dateIso).toBe("");
    expect(first.lines[1]!.credit).toBe("1000.00");
    expect(entries[1]!.entryKey).toBe("T2");
  });

  it("flips a negative debit to a credit (and vice versa)", () => {
    const rows = [
      dcHeader,
      ["01/15/2026", "Checking", "(250.00)", "", "T1", ""],
      ["01/15/2026", "Refunds", "", "-250.00", "T1", ""],
    ];
    const { entries, errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toEqual([]);
    expect(entries[0]!.lines[0]!.debit).toBe("");
    expect(entries[0]!.lines[0]!.credit).toBe("250.00");
    expect(entries[0]!.lines[1]!.debit).toBe("250.00");
    expect(entries[0]!.lines[1]!.credit).toBe("");
  });

  it("errors when both debit and credit have a value", () => {
    const rows = [dcHeader, ["01/15/2026", "Checking", "5.00", "5.00", "T1", ""]];
    const { entries, errors } = applyJournalMapping(rows, dcMapping);
    expect(entries).toEqual([]);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(2);
    expect(errors[0]!.message).toMatch(/both debit .* and credit/);
  });

  it("errors when neither debit nor credit has a value (including '--' and '0')", () => {
    const rows = [
      dcHeader,
      ["01/15/2026", "Checking", "--", "", "T1", ""],
      ["01/15/2026", "Checking", "0.00", "0", "T2", ""],
    ];
    const { errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toHaveLength(2);
    expect(errors[0]!.message).toMatch(/neither debit nor credit/);
    expect(errors[1]!.message).toMatch(/neither debit nor credit/);
  });

  it("collects an amount parse error with its row number", () => {
    const rows = [dcHeader, ["01/15/2026", "Checking", "1.234,56", "", "T1", ""]];
    const { errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(2);
    expect(errors[0]!.message).toMatch(/European/);
  });

  it("errors on a blank account and a bad date", () => {
    const rows = [
      dcHeader,
      ["01/15/2026", "", "5.00", "", "T1", ""],
      ["not-a-date", "Checking", "5.00", "", "T2", ""],
    ];
    const { errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toHaveLength(2);
    expect(errors[0]!.message).toMatch(/account is blank/);
    expect(errors[1]!.message).toMatch(/unparseable date/);
  });

  it("rejects sub-cent amounts as row errors (dollars and cents only)", () => {
    const rows = [
      dcHeader,
      ["01/15/2026", "Checking", "0.1234", "", "T1", ""],
      ["01/15/2026", "Sales", "", "0.123", "T1", ""],
    ];
    const { entries, errors } = applyJournalMapping(rows, dcMapping);
    expect(entries).toEqual([]);
    expect(errors).toHaveLength(2);
    expect(errors[0]!.message).toMatch(/more than 2 decimal places/);
    expect(errors[1]!.message).toMatch(/dollars and cents/);
  });

  it("maps optional name and ref columns", () => {
    const mapping: UniversalJournalMapping = {
      ...dcMapping,
      nameCol: 5,
      refCol: 5,
      memoCol: undefined,
    };
    const rows = [dcHeader, ["01/15/2026", "Checking", "5.00", "", "T1", "Gnome Depot"]];
    const { entries } = applyJournalMapping(rows, mapping);
    expect(entries[0]!.lines[0]!.name).toBe("Gnome Depot");
    expect(entries[0]!.lines[0]!.num).toBe("Gnome Depot");
    expect(entries[0]!.lines[0]!.memo).toBe("");
  });

  it("skips blank rows silently", () => {
    const rows = [
      dcHeader,
      ["", "", "", "", "", ""],
      ["01/15/2026", "Checking", "5.00", "", "T1", ""],
      ["01/15/2026", "Sales", "", "5.00", "T1", ""],
    ];
    const { entries, errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toEqual([]);
    expect(entries).toHaveLength(1);
  });
});

describe("applyJournalMapping — signed shape", () => {
  const signedMapping: UniversalJournalMapping = {
    headerRows: 1,
    dateCol: 0,
    dateFormat: "YYYY-MM-DD",
    accountCol: 1,
    amountShape: { kind: "signed", amountCol: 2, positiveIs: "debit" },
    grouping: { kind: "key-column", groupKeyCol: 3 },
  };
  const sHeader = ["Date", "Account", "Amount", "Journal"];

  it("routes positive to debit and negative to credit when positiveIs is 'debit'", () => {
    const rows = [
      sHeader,
      ["2026-01-15", "Checking", "1,000.00", "J1"],
      ["2026-01-15", "Sales", "(1,000.00)", "J1"],
    ];
    const { entries, errors } = applyJournalMapping(rows, signedMapping);
    expect(errors).toEqual([]);
    expect(entries[0]!.lines[0]!.debit).toBe("1000.00");
    expect(entries[0]!.lines[0]!.credit).toBe("");
    expect(entries[0]!.lines[1]!.debit).toBe("");
    expect(entries[0]!.lines[1]!.credit).toBe("1000.00");
  });

  it("routes positive to credit when positiveIs is 'credit'", () => {
    const mapping: UniversalJournalMapping = {
      ...signedMapping,
      amountShape: { kind: "signed", amountCol: 2, positiveIs: "credit" },
    };
    const rows = [
      sHeader,
      ["2026-01-15", "Sales", "1,000.00", "J1"],
      ["2026-01-15", "Checking", "-1,000.00", "J1"],
    ];
    const { entries, errors } = applyJournalMapping(rows, mapping);
    expect(errors).toEqual([]);
    expect(entries[0]!.lines[0]!.credit).toBe("1000.00");
    expect(entries[0]!.lines[1]!.debit).toBe("1000.00");
  });

  it("handles trailing-minus negatives", () => {
    const rows = [
      sHeader,
      ["2026-01-15", "Checking", "500.00", "J1"],
      ["2026-01-15", "Sales", "500.00-", "J1"],
    ];
    const { entries, errors } = applyJournalMapping(rows, signedMapping);
    expect(errors).toEqual([]);
    expect(entries[0]!.lines[1]!.credit).toBe("500.00");
  });

  it("errors on blank and zero amounts", () => {
    const rows = [
      sHeader,
      ["2026-01-15", "Checking", "", "J1"],
      ["2026-01-15", "Checking", "0.00", "J2"],
    ];
    const { errors } = applyJournalMapping(rows, signedMapping);
    expect(errors).toHaveLength(2);
    expect(errors[0]!.message).toMatch(/blank/);
    expect(errors[1]!.message).toMatch(/zero/);
  });
});

describe("applyJournalMapping — key-column grouping", () => {
  it("merges non-adjacent rows sharing a key into one entry", () => {
    const rows = [
      dcHeader,
      ["01/15/2026", "Checking", "100.00", "", "T1", ""],
      ["01/16/2026", "Rent Expense", "50.00", "", "T2", ""],
      ["01/15/2026", "Sales", "", "100.00", "T1", ""],
      ["01/16/2026", "Checking", "", "50.00", "T2", ""],
    ];
    const { entries, errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toEqual([]);
    expect(entries).toHaveLength(2);
    expect(entries[0]!.entryKey).toBe("T1");
    expect(entries[0]!.lines).toHaveLength(2);
    expect(entries[1]!.entryKey).toBe("T2");
  });

  it("errors on a blank grouping key", () => {
    const rows = [dcHeader, ["01/15/2026", "Checking", "5.00", "", "", ""]];
    const { errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.message).toMatch(/grouping key is blank/);
  });

  it("errors when rows under one key disagree on the date", () => {
    const rows = [
      dcHeader,
      ["01/15/2026", "Checking", "100.00", "", "T1", ""],
      ["01/17/2026", "Sales", "", "100.00", "T1", ""],
    ];
    const { errors } = applyJournalMapping(rows, dcMapping);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(3);
    expect(errors[0]!.message).toMatch(/2026-01-17.*2026-01-15.*"T1".*row 2/s);
  });
});

describe("applyJournalMapping — consecutive-balanced grouping", () => {
  const cbMapping: UniversalJournalMapping = {
    headerRows: 1,
    dateCol: 0,
    dateFormat: "MM/DD/YYYY",
    accountCol: 1,
    amountShape: { kind: "debit-credit", debitCol: 2, creditCol: 3 },
    grouping: { kind: "consecutive-balanced" },
  };
  const cbHeader = ["Date", "Account", "Debit", "Credit"];

  it("closes a group exactly when debits equal credits, with synthesized row-N keys", () => {
    const rows = [
      cbHeader,
      ["01/15/2026", "Checking", "100.00", ""],
      ["01/15/2026", "Sales", "", "100.00"],
      ["01/16/2026", "Rent Expense", "40.00", ""],
      ["01/16/2026", "Utilities", "10.00", ""],
      ["01/16/2026", "Checking", "", "50.00"],
    ];
    const { entries, errors } = applyJournalMapping(rows, cbMapping);
    expect(errors).toEqual([]);
    expect(entries).toHaveLength(2);
    expect(entries[0]!.entryKey).toBe("row-2");
    expect(entries[0]!.lines).toHaveLength(2);
    expect(entries[1]!.entryKey).toBe("row-4");
    expect(entries[1]!.lines).toHaveLength(3);
    expect(entries[1]!.dateIso).toBe("2026-01-16");
  });

  it("errors on an unbalanced tail, naming the start row — never a silent drop", () => {
    const rows = [
      cbHeader,
      ["01/15/2026", "Checking", "100.00", ""],
      ["01/15/2026", "Sales", "", "100.00"],
      ["01/16/2026", "Rent Expense", "40.00", ""],
      ["01/16/2026", "Checking", "", "25.00"],
    ];
    const { entries, errors } = applyJournalMapping(rows, cbMapping);
    expect(entries).toHaveLength(1);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(4);
    expect(errors[0]!.message).toMatch(/row 4.*never balance/s);
    expect(errors[0]!.message).toMatch(/40\.00/);
    expect(errors[0]!.message).toMatch(/25\.00/);
  });

  it("errors when the date changes mid-unbalanced-group (misgrouping signal)", () => {
    const rows = [
      cbHeader,
      ["01/15/2026", "Checking", "100.00", ""],
      ["01/16/2026", "Rent Expense", "40.00", ""],
      ["01/16/2026", "Checking", "", "40.00"],
    ];
    const { entries, errors } = applyJournalMapping(rows, cbMapping);
    // Row 2 never balances before the date moves on; rows 3-4 start fresh
    // and balance on their own.
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(2);
    expect(errors[0]!.message).toMatch(/date changes on row 3/);
    expect(entries).toHaveLength(1);
    expect(entries[0]!.entryKey).toBe("row-3");
  });

  it("refuses a huge-magnitude group off by one cent that FLOAT accumulation would close as balanced", () => {
    // Deterministically reproduce (fixed-seed LCG, no Math.random) a pair of
    // 2dp row multisets whose TRUE sums differ by exactly one cent but whose
    // sequential Number() accumulations compare EQUAL — the partial sums run
    // far past 2^53, where float addition rounds. An implementation that
    // accumulates group totals as floats closes this group as "balanced";
    // the bigint implementation must refuse it.
    let seed = 99 >>> 0;
    const rng = (): number => {
      seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
      return seed / 0x100000000;
    };
    const MAX_UNITS = 9_999_999_999_999n * 100n; // $99,999,999,999.99
    const randUnits = (): bigint =>
      BigInt(8_000_000_000_000 + Math.floor(rng() * 1_999_999_999_999)) * 100n;
    const floatSum = (a: bigint[]): number => a.reduce((s, u) => s + Number(u), 0);
    const bigSum = (a: bigint[]): bigint => a.reduce((s, u) => s + u, 0n);

    const N = 300;
    let debits: bigint[] | null = null;
    let credits: bigint[] | null = null;
    for (let attempt = 0; attempt < 1000 && debits === null; attempt++) {
      const d = Array.from({ length: N }, randUnits);
      const c = Array.from({ length: N - 1 }, randUnits);
      const rem = bigSum(d) - 100n - bigSum(c);
      if (rem <= 0n || rem > MAX_UNITS || rem % 100n !== 0n) continue;
      c.push(rem);
      if (floatSum(d) === floatSum(c)) {
        debits = d;
        credits = c;
      }
    }
    // The seed above finds a collision within a few dozen attempts; if this
    // ever fails, the float trap itself has changed shape — investigate.
    expect(debits).not.toBeNull();
    expect(bigSum(debits!) - bigSum(credits!)).toBe(100n); // truly one cent off
    expect(floatSum(debits!)).toBe(floatSum(credits!)); // ...yet floats agree

    const toCell = (u: bigint): string => {
      const cents = u / 100n;
      return `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`;
    };
    const rows = [
      ...debits!.map((u) => ["01/15/2026", "Big Debits", toCell(u), ""]),
      ...credits!.map((u) => ["01/15/2026", "Big Credits", "", toCell(u)]),
    ];
    const mapping: UniversalJournalMapping = {
      headerRows: 0, dateCol: 0, dateFormat: "MM/DD/YYYY", accountCol: 1,
      amountShape: { kind: "debit-credit", debitCol: 2, creditCol: 3 },
      grouping: { kind: "consecutive-balanced" },
    };
    const { entries, errors } = applyJournalMapping(rows, mapping);
    expect(entries).toEqual([]);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(1);
    expect(errors[0]!.message).toMatch(/never balance/);
    expect(errors[0]!.message).toMatch(/0\.01/); // off by exactly one cent
  });

  it("abandons a group when a row inside it cannot be read, and says so", () => {
    const rows = [
      cbHeader,
      ["01/15/2026", "Checking", "100.00", ""],
      ["01/15/2026", "Sales", "", "garbage"],
      ["01/16/2026", "Rent Expense", "40.00", ""],
      ["01/16/2026", "Checking", "", "40.00"],
    ];
    const { entries, errors } = applyJournalMapping(rows, cbMapping);
    // Row 3's own error + the abandoned-group error covering row 2.
    expect(errors).toHaveLength(2);
    expect(errors.some((e) => e.row === 3 && /unparseable amount/.test(e.message))).toBe(true);
    expect(errors.some((e) => e.row === 2 && /row 3/.test(e.message))).toBe(true);
    expect(entries).toHaveLength(1);
    expect(entries[0]!.entryKey).toBe("row-4");
  });
});

describe("applyJournalMapping — structural validation", () => {
  it("throws on a negative column index (config error, not a data error)", () => {
    const mapping: UniversalJournalMapping = { ...dcMapping, dateCol: -1 };
    expect(() => applyJournalMapping([], mapping)).toThrow(/dateCol/);
  });

  it("throws on an unknown date format", () => {
    const mapping = { ...dcMapping, dateFormat: "QQQ" as UniversalDateFormat };
    expect(() => applyJournalMapping([], mapping)).toThrow(/date format/i);
  });
});

// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------

describe("presets", () => {
  it("ships all seven presets", () => {
    expect(UNIVERSAL_PRESETS.map((p) => p.key)).toEqual([
      "sage-businessworks",
      "sage-50",
      "xero",
      "wave",
      "freshbooks",
      "generic-debit-credit",
      "generic-signed",
    ]);
    for (const p of UNIVERSAL_PRESETS) expect(p.label).toBeTruthy();
  });

  it("matches synthetic Sage-BusinessWorks-shaped journal headers", () => {
    const headers = ["Trx Date", "Account Number", "Description", "Trx Number", "Debit Amount", "Credit Amount"];
    const m = matchPreset(headers)!;
    expect(m.preset.key).toBe("sage-businessworks");
    expect(m.likelyKind).toBe("journal");
    const byField = Object.fromEntries(m.journal.guesses.map((g) => [g.field, g]));
    expect(byField["date"]!.col).toBe(0);
    expect(byField["account"]!.col).toBe(1);
    expect(byField["debit"]!.col).toBe(4);
    expect(byField["credit"]!.col).toBe(5);
    expect(byField["groupKey"]!.col).toBe(3);
    expect(byField["date"]!.confidence).toBe("exact");
  });

  it("matches synthetic Xero-journal-shaped headers (signed NetAmount)", () => {
    const headers = [
      "JournalDate", "JournalNumber", "Reference", "SourceType",
      "AccountCode", "AccountName", "Description", "NetAmount", "GrossAmount", "TaxAmount",
    ];
    const m = matchPreset(headers)!;
    expect(m.preset.key).toBe("xero");
    expect(m.likelyKind).toBe("journal");
    const byField = Object.fromEntries(m.journal.guesses.map((g) => [g.field, g]));
    expect(byField["date"]!.col).toBe(0);
    expect(byField["groupKey"]!.col).toBe(1);
    expect(byField["amount"]!.col).toBe(7);
    expect(byField["account"]!.col).toBe(5);
  });

  it("matches a generic debit/credit file and identifies a chart file as a chart", () => {
    const journal = matchPreset(["Date", "Account", "Debit", "Credit", "Memo"])!;
    expect(journal.likelyKind).toBe("journal");
    expect(journal.journal.guesses.length).toBeGreaterThanOrEqual(4);

    const chart = matchPreset(["Account Name", "Account Type", "Account Code"])!;
    expect(chart.likelyKind).toBe("chart");
    const byField = Object.fromEntries(chart.chart.guesses.map((g) => [g.field, g]));
    expect(byField["name"]!.col).toBe(0);
    expect(byField["type"]!.col).toBe(1);
  });

  it("returns null when nothing matches at all", () => {
    expect(matchPreset(["zzz", "qqq"])).toBeNull();
  });

  it("never assigns one column to two fields", () => {
    const m = matchPreset(["Date", "Account", "Debit", "Credit", "Amount"])!;
    const cols = m.journal.guesses.map((g) => g.col);
    expect(new Set(cols).size).toBe(cols.length);
  });
});

// ---------------------------------------------------------------------------
// Property test: seeded-LCG random balanced files
// ---------------------------------------------------------------------------

/** Deterministic LCG (numerical-recipes constants) — no Math.random anywhere. */
function makeLcg(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 0x100000000;
  };
}

interface GenLine { side: "debit" | "credit"; cents: number }
interface GenEntry { date: string; lines: GenLine[] }

/** Random balanced entries: 1-3 debit lines, then credit lines partitioning
 *  the identical total — every entry balances by construction, and the
 *  running total only reaches balance on the entry's final line. */
function generateEntries(rng: () => number, count: number): GenEntry[] {
  const entries: GenEntry[] = [];
  for (let i = 0; i < count; i++) {
    const day = String(1 + (i % 28)).padStart(2, "0");
    const debitCount = 1 + Math.floor(rng() * 3);
    const lines: GenLine[] = [];
    let total = 0;
    for (let d = 0; d < debitCount; d++) {
      const cents = 1 + Math.floor(rng() * 999999);
      total += cents;
      lines.push({ side: "debit", cents });
    }
    let remaining = total;
    while (remaining > 0) {
      const take = remaining <= 100 || rng() < 0.4
        ? remaining
        : 1 + Math.floor(rng() * (remaining - 1));
      lines.push({ side: "credit", cents: take });
      remaining -= take;
    }
    entries.push({ date: `01/${day}/2026`, lines });
  }
  return entries;
}

function dirtyRender(cents: number, rng: () => number, negative: boolean): string {
  const whole = Math.floor(cents / 100);
  const frac = String(cents % 100).padStart(2, "0");
  let digits = String(whole);
  if (rng() < 0.5 && digits.length > 3) {
    // insert US thousands separators
    let out = "";
    while (digits.length > 3) {
      out = "," + digits.slice(-3) + out;
      digits = digits.slice(0, -3);
    }
    digits = digits + out;
  }
  let s = `${digits}.${frac}`;
  if (rng() < 0.3) s = `$${s}`;
  if (negative) {
    const style = rng();
    if (style < 0.34) s = `(${s})`;
    else if (style < 0.67) s = `-${s}`;
    else s = `${s}-`;
  }
  if (rng() < 0.2) s = `  ${s} `;
  return s;
}

function sumUnits(entry: JournalEntry): { debit: number; credit: number } {
  // String-ops only, even in tests — parseUniversalAmount never floats.
  let debit = 0;
  let credit = 0;
  for (const line of entry.lines) {
    if (line.debit !== "") debit += parseUniversalAmount(line.debit).units;
    if (line.credit !== "") credit += parseUniversalAmount(line.credit).units;
  }
  return { debit, credit };
}

describe("property: random balanced files always balance or error by row — never silent drops", () => {
  const ENTRY_COUNT = 40;

  function renderDebitCredit(gen: GenEntry[], rng: () => number, withKey: boolean): string[][] {
    const rows: string[][] = [];
    gen.forEach((entry, i) => {
      for (const line of entry.lines) {
        // Sometimes render a credit as a NEGATIVE amount in the debit column
        // (and vice versa) — the flip rule must preserve balance.
        const flip = rng() < 0.15;
        let debitCell = "";
        let creditCell = "";
        if (line.side === "debit") {
          if (flip) creditCell = dirtyRender(line.cents, rng, true);
          else debitCell = dirtyRender(line.cents, rng, false);
        } else {
          if (flip) debitCell = dirtyRender(line.cents, rng, true);
          else creditCell = dirtyRender(line.cents, rng, false);
        }
        const row = [entry.date, `Account ${1 + Math.floor(rng() * 9)}`, debitCell, creditCell];
        if (withKey) row.push(`T${i + 1}`);
        rows.push(row);
      }
    });
    return rows;
  }

  function renderSigned(gen: GenEntry[], rng: () => number, withKey: boolean): string[][] {
    const rows: string[][] = [];
    gen.forEach((entry, i) => {
      for (const line of entry.lines) {
        const cell = dirtyRender(line.cents, rng, line.side === "credit");
        const row = [entry.date, `Account ${1 + Math.floor(rng() * 9)}`, cell];
        if (withKey) row.push(`T${i + 1}`);
        rows.push(row);
      }
    });
    return rows;
  }

  it("debit/credit shape, key grouping", () => {
    const rng = makeLcg(0xa11ce);
    const gen = generateEntries(rng, ENTRY_COUNT);
    const rows = renderDebitCredit(gen, rng, true);
    const mapping: UniversalJournalMapping = {
      headerRows: 0, dateCol: 0, dateFormat: "MM/DD/YYYY", accountCol: 1,
      amountShape: { kind: "debit-credit", debitCol: 2, creditCol: 3 },
      grouping: { kind: "key-column", groupKeyCol: 4 },
    };
    const { entries, errors } = applyJournalMapping(rows, mapping);
    expect(errors).toEqual([]);
    expect(entries).toHaveLength(ENTRY_COUNT);
    const totalLines = entries.reduce((n, e) => n + e.lines.length, 0);
    expect(totalLines).toBe(rows.length); // no silent drops
    for (const entry of entries) {
      const { debit, credit } = sumUnits(entry);
      expect(debit).toBe(credit);
      expect(debit).toBeGreaterThan(0);
    }
  });

  it("debit/credit shape, consecutive-balanced grouping", () => {
    const rng = makeLcg(0xbee5);
    const gen = generateEntries(rng, ENTRY_COUNT);
    const rows = renderDebitCredit(gen, rng, false);
    const mapping: UniversalJournalMapping = {
      headerRows: 0, dateCol: 0, dateFormat: "MM/DD/YYYY", accountCol: 1,
      amountShape: { kind: "debit-credit", debitCol: 2, creditCol: 3 },
      grouping: { kind: "consecutive-balanced" },
    };
    const { entries, errors } = applyJournalMapping(rows, mapping);
    expect(errors).toEqual([]);
    const totalLines = entries.reduce((n, e) => n + e.lines.length, 0);
    expect(totalLines).toBe(rows.length); // no silent drops
    for (const entry of entries) {
      const { debit, credit } = sumUnits(entry);
      expect(debit).toBe(credit);
      expect(debit).toBeGreaterThan(0);
    }
  });

  it("signed shape, key grouping", () => {
    const rng = makeLcg(0xcafe5);
    const gen = generateEntries(rng, ENTRY_COUNT);
    const rows = renderSigned(gen, rng, true);
    const mapping: UniversalJournalMapping = {
      headerRows: 0, dateCol: 0, dateFormat: "MM/DD/YYYY", accountCol: 1,
      amountShape: { kind: "signed", amountCol: 2, positiveIs: "debit" },
      grouping: { kind: "key-column", groupKeyCol: 3 },
    };
    const { entries, errors } = applyJournalMapping(rows, mapping);
    expect(errors).toEqual([]);
    expect(entries).toHaveLength(ENTRY_COUNT);
    for (const entry of entries) {
      const { debit, credit } = sumUnits(entry);
      expect(debit).toBe(credit);
    }
  });

  it("signed shape, consecutive-balanced grouping", () => {
    const rng = makeLcg(0xd00d);
    const gen = generateEntries(rng, ENTRY_COUNT);
    const rows = renderSigned(gen, rng, false);
    const mapping: UniversalJournalMapping = {
      headerRows: 0, dateCol: 0, dateFormat: "MM/DD/YYYY", accountCol: 1,
      amountShape: { kind: "signed", amountCol: 2, positiveIs: "debit" },
      grouping: { kind: "consecutive-balanced" },
    };
    const { entries, errors } = applyJournalMapping(rows, mapping);
    expect(errors).toEqual([]);
    const totalLines = entries.reduce((n, e) => n + e.lines.length, 0);
    expect(totalLines).toBe(rows.length);
    for (const entry of entries) {
      const { debit, credit } = sumUnits(entry);
      expect(debit).toBe(credit);
    }
  });

  it("an unbalanced tail is a row-numbered error, and every dropped row is covered by it", () => {
    const rng = makeLcg(0xfeed);
    const gen = generateEntries(rng, 10);
    const rows = renderDebitCredit(gen, rng, false);
    rows.pop(); // amputate the final balancing line
    const mapping: UniversalJournalMapping = {
      headerRows: 0, dateCol: 0, dateFormat: "MM/DD/YYYY", accountCol: 1,
      amountShape: { kind: "debit-credit", debitCol: 2, creditCol: 3 },
      grouping: { kind: "consecutive-balanced" },
    };
    const { entries, errors } = applyJournalMapping(rows, mapping);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.message).toMatch(/never balance/);
    const totalLines = entries.reduce((n, e) => n + e.lines.length, 0);
    // Rows kept + rows covered by the tail error account for every input row.
    const tailStartRow = errors[0]!.row;
    const tailRowCount = rows.length - (tailStartRow - 1);
    expect(totalLines + tailRowCount).toBe(rows.length);
    for (const entry of entries) {
      const { debit, credit } = sumUnits(entry);
      expect(debit).toBe(credit);
    }
  });
});
