// web/src/setup/universalSample.ts is the only thing standing between a user
// and a fully inverted import.
//
// The server cannot catch a sign inversion: /journal/preview returns
// byte-identical counts whether "positive amounts are debits" or "credits" is
// chosen, the staging validates, the post succeeds, and ledger_out_of_balance()
// reads 0.0000 — debits equal credits either way, only the SIDES are wrong.
// The direction summary tested here is the single check that can see it, and
// it does so by re-implementing the engine's amount parsing in the browser
// bundle. That mirror is maintained by hand (see the module's own header for
// why it isn't an import), so the last describe below pins both parsers to one
// shared table of cases: if either side changes, this file fails rather than
// the two drifting apart in silence.

import { describe, it, expect } from "vitest";
import {
  parseAmountCents,
  isBlankAmountCell,
  formatCents,
  sniffDateFormatSample,
  summarizeSampleDirections,
  type KnownAccount,
} from "../../web/src/setup/universalSample.js";
import {
  parseUniversalAmount,
  isBlankAmountCell as engineIsBlankAmountCell,
} from "../../tools/universal-map-logic.js";

// ---------------------------------------------------------------------------
// a small chart, and the lookup the wizard hands in
// ---------------------------------------------------------------------------

const CHART: Record<string, KnownAccount> = {
  "Operating Checking": { name: "Operating Checking", type: "asset" },
  "1000": { name: "Operating Checking", type: "asset" },
  "Product Sales": { name: "Product Sales", type: "income" },
  "4000": { name: "Product Sales", type: "income" },
  "Rent Expense": { name: "Rent Expense", type: "expense" },
  "Credit Card": { name: "Credit Card", type: "liability" },
};

/** Name-or-code resolution, as UniversalWizard's accountLookup does it. */
function lookup(raw: string): KnownAccount | undefined {
  return CHART[raw.trim()];
}

// ---------------------------------------------------------------------------
// the direction check — signed-amount shape
// ---------------------------------------------------------------------------

describe("summarizeSampleDirections — signed amount column", () => {
  // account, amount. One deposit and the sale that produced it.
  const rows = [
    ["Operating Checking", "1,200.00"],
    ["Product Sales", "(1,200.00)"],
  ];

  it("reads the SAME rows as money in one way round and money out the other", () => {
    const asDebit = summarizeSampleDirections(
      rows, 0, { kind: "signed", amountCol: 1, positiveIs: "debit" }, lookup);
    const asCredit = summarizeSampleDirections(
      rows, 0, { kind: "signed", amountCol: 1, positiveIs: "credit" }, lookup);

    // Right way round: cash came in, revenue was earned. Nothing to flag.
    expect(asDebit).toEqual([
      { account: "Operating Checking", effect: "$1,200.00 in", unusual: false },
      { account: "Product Sales", effect: "$1,200.00 earned", unusual: false },
    ]);

    // Inverted: the identical file now says money LEFT the bank and revenue
    // was REVERSED — and both rows are flagged. This is the whole point of
    // the check: the server sees no difference between these two runs.
    expect(asCredit).toEqual([
      { account: "Operating Checking", effect: "$1,200.00 out", unusual: true },
      { account: "Product Sales", effect: "$1,200.00 reversed", unusual: true },
    ]);
  });

  it("uses each account kind's own words for growing and shrinking", () => {
    const spend = [
      ["Rent Expense", "2500.00"],
      ["Credit Card", "-2500.00"],
    ];
    expect(
      summarizeSampleDirections(
        spend, 0, { kind: "signed", amountCol: 1, positiveIs: "debit" }, lookup),
    ).toEqual([
      { account: "Rent Expense", effect: "$2,500.00 spent", unusual: false },
      { account: "Credit Card", effect: "$2,500.00 owed", unusual: false },
    ]);

    expect(
      summarizeSampleDirections(
        spend, 0, { kind: "signed", amountCol: 1, positiveIs: "credit" }, lookup),
    ).toEqual([
      { account: "Rent Expense", effect: "$2,500.00 refunded", unusual: true },
      { account: "Credit Card", effect: "$2,500.00 paid down", unusual: true },
    ]);
  });

  it("sums an account's sample rows and drops the ones that net to nothing", () => {
    const netted = [
      ["Operating Checking", "100.00"],
      ["Operating Checking", "-100.00"], // nets to zero: says nothing, so shows nothing
      ["Product Sales", "-40.00"],
      ["Product Sales", "-60.00"],
    ];
    expect(
      summarizeSampleDirections(
        netted, 0, { kind: "signed", amountCol: 1, positiveIs: "debit" }, lookup),
    ).toEqual([
      { account: "Product Sales", effect: "$100.00 earned", unusual: false },
    ]);
  });

  it("shows the biggest few, largest first, honouring the limit", () => {
    const many = [
      ["Rent Expense", "50.00"],
      ["Operating Checking", "900.00"],
      ["Product Sales", "-400.00"],
      ["Credit Card", "-70.00"],
    ];
    const top = summarizeSampleDirections(
      many, 0, { kind: "signed", amountCol: 1, positiveIs: "debit" }, lookup, 2);
    expect(top.map((d) => d.account)).toEqual(["Operating Checking", "Product Sales"]);
  });
});

// ---------------------------------------------------------------------------
// the direction check — debit/credit shape
// ---------------------------------------------------------------------------

describe("summarizeSampleDirections — debit and credit columns", () => {
  // account, debit, credit — the shape most GL exports use.
  const rows = [
    ["Operating Checking", "1,200.00", ""],
    ["Product Sales", "", "1,200.00"],
    ["Rent Expense", "300.00", "-"], // "-" is an empty cell, not a value
    ["Operating Checking", "", "300.00"],
  ];
  const shape = { kind: "debit-credit" as const, debitCol: 1, creditCol: 2 };

  it("puts debits on the debit side and credits on the credit side", () => {
    expect(summarizeSampleDirections(rows, 0, shape, lookup, 5)).toEqual([
      { account: "Product Sales", effect: "$1,200.00 earned", unusual: false },
      { account: "Operating Checking", effect: "$900.00 in", unusual: false },
      { account: "Rent Expense", effect: "$300.00 spent", unusual: false },
    ]);
  });

  it("skips a line with BOTH sides filled, exactly as the engine rejects it", () => {
    const both = [
      ["Operating Checking", "100.00", "40.00"],
      ["Product Sales", "", "60.00"],
    ];
    expect(summarizeSampleDirections(both, 0, shape, lookup, 5)).toEqual([
      { account: "Product Sales", effect: "$60.00 earned", unusual: false },
    ]);
  });

  it("skips a cell it cannot parse rather than reading it as zero", () => {
    const dirty = [
      ["Operating Checking", "1.234,56", ""], // European format — engine refuses
      ["Product Sales", "", "60.00"],
    ];
    expect(summarizeSampleDirections(dirty, 0, shape, lookup, 5)).toEqual([
      { account: "Product Sales", effect: "$60.00 earned", unusual: false },
    ]);
  });
});

// ---------------------------------------------------------------------------
// accounts we can't resolve
// ---------------------------------------------------------------------------

describe("summarizeSampleDirections — unresolvable accounts", () => {
  const shape = { kind: "signed" as const, amountCol: 1, positiveIs: "debit" as const };

  it("skips an account it can't find instead of guessing a kind for it", () => {
    const rows = [
      ["Suspense 9999", "5,000.00"], // biggest row in the file, and unknown
      ["Operating Checking", "120.00"],
    ];
    const out = summarizeSampleDirections(rows, 0, shape, lookup, 5);
    // The unknown account is absent — not shown as an asset, not shown as
    // anything. A guessed kind here would be a guessed DIRECTION.
    expect(out).toEqual([
      { account: "Operating Checking", effect: "$120.00 in", unusual: false },
    ]);
  });

  it("resolves by account code as well as by name", () => {
    const byCode = [["1000", "120.00"], ["4000", "-120.00"]];
    expect(summarizeSampleDirections(byCode, 0, shape, lookup, 5)).toEqual([
      { account: "Operating Checking", effect: "$120.00 in", unusual: false },
      { account: "Product Sales", effect: "$120.00 earned", unusual: false },
    ]);
  });

  it("skips blank account cells and blank/zero amounts", () => {
    const rows = [
      ["", "500.00"],
      ["Operating Checking", ""],
      ["Operating Checking", "-"],
      ["Operating Checking", "0.00"],
      ["Operating Checking", "75.00"],
    ];
    expect(summarizeSampleDirections(rows, 0, shape, lookup, 5)).toEqual([
      { account: "Operating Checking", effect: "$75.00 in", unusual: false },
    ]);
  });

  it("says nothing at all when nothing in the sample resolves", () => {
    expect(
      summarizeSampleDirections([["Who Knows", "10.00"]], 0, shape, lookup, 5),
    ).toEqual([]);
  });
});

// ---------------------------------------------------------------------------
// the date sniff — the OTHER answer that parses cleanly while being wrong
// ---------------------------------------------------------------------------

describe("sniffDateFormatSample", () => {
  it("settles an unambiguous format", () => {
    expect(sniffDateFormatSample(["2024-03-01", "2024-11-30"]))
      .toEqual({ format: "YYYY-MM-DD", confident: true });
    expect(sniffDateFormatSample(["31-Dec-24", "01-Jan-25"]))
      .toEqual({ format: "D-MMM-YY", confident: true });
  });

  it("is confident about month-first only once a day above 12 proves it", () => {
    const proven = sniffDateFormatSample(["03/31/2024", "01/02/2024"]);
    expect(proven).toEqual({ format: "MM/DD/YYYY", confident: true });
  });

  it("refuses to settle month-first vs day-first when every day is 12 or less", () => {
    const ambiguous = sniffDateFormatSample(["01/02/2024", "03/04/2024"]);
    expect(ambiguous.format).toBe("MM/DD/YYYY");
    expect(ambiguous.confident).toBe(false);
    expect(ambiguous.ambiguousBecause).toMatch(/only you can say which/);
  });

  it("says so plainly when the column holds no dates, or none it can read", () => {
    expect(sniffDateFormatSample(["", "  "])).toMatchObject({ format: null, confident: false });
    const unreadable = sniffDateFormatSample(["last Tuesday"]);
    expect(unreadable.format).toBeNull();
    expect(unreadable.ambiguousBecause).toContain("last Tuesday");
  });

  it("rejects a date that isn't on the calendar", () => {
    expect(sniffDateFormatSample(["2024-02-30"]).format).toBeNull();
    // …but 2024 IS a leap year, so this one is real
    expect(sniffDateFormatSample(["2024-02-29"]).format).toBe("YYYY-MM-DD");
  });
});

// ---------------------------------------------------------------------------
// the anti-drift table: this mirror vs the engine it mirrors
// ---------------------------------------------------------------------------

/**
 * Every case runs through BOTH parsers. The engine returns signed
 * ten-thousandths and throws on anything it won't accept; the mirror returns
 * signed cents and returns null instead. Agreement means: same acceptance
 * decision, and — when accepted — engineUnits === mirrorCents * 100.
 */
const AMOUNT_CASES: readonly string[] = [
  // plain
  "0.00", "1", "1.5", "1.50", "125.00", "-125.00", "+125.00", ".50", "0",
  // parentheses negatives, with the symbol inside and out
  "(500.00)", "$(1,000.00)", "($1,000.00)", "(0.01)",
  // currency symbols and whitespace
  "$1,234.56", "  $42.00  ", "$0.99",
  // thousands separators
  "1,234.56", "1,234,567.89", "12,345", "1,23,456.00", "1,2345.00",
  // trailing minus (mainframe/AS-400 style exports)
  "500.00-", "$1,234.56-", "(500.00-)",
  // European format — must be refused, never silently misread
  "1.234,56", "1.234.567", "1.234.567,89",
  // too many decimals
  "123.456", "1,234.567", "0.001",
  // too large for the engine's 11-whole-digit cap
  "99999999999.99", "999999999999.00", "1234567890123.45",
  // outright junk
  "", "   ", "-", "--", "$", "abc", "1.2.3", "1-2", "(500", "500)", "1 234.56",
  "12,34", "0x10", "1e5", "--5.00", "1,234.5.6",
];

/** null when the engine refuses the cell. */
function engineUnits(raw: string): number | null {
  try {
    return parseUniversalAmount(raw).units;
  } catch {
    return null;
  }
}

describe("parseAmountCents mirrors the engine's parseUniversalAmount", () => {
  it("agrees with the engine on every case in the shared table", () => {
    const disagreements: string[] = [];
    for (const raw of AMOUNT_CASES) {
      const engine = engineUnits(raw);
      const mirror = parseAmountCents(raw);
      const mirrorUnits = mirror === null ? null : mirror * 100;
      if (engine !== mirrorUnits) {
        disagreements.push(
          `${JSON.stringify(raw)}: engine ${String(engine)} vs mirror ${String(mirrorUnits)}`,
        );
      }
    }
    expect(disagreements).toEqual([]);
  });

  it("actually accepts the dirty shapes we promise to accept", () => {
    // Guards the test above against passing because BOTH parsers reject
    // everything — the table has to be doing real work.
    expect(parseAmountCents("$1,234.56")).toBe(123456);
    expect(parseAmountCents("(500.00)")).toBe(-50000);
    expect(parseAmountCents("$(1,000.00)")).toBe(-100000);
    expect(parseAmountCents("500.00-")).toBe(-50000);
    expect(parseAmountCents("  $42.00  ")).toBe(4200);
    expect(parseAmountCents(".50")).toBe(50);
    expect(parseAmountCents("1,234,567.89")).toBe(123456789);
  });

  it("refuses the shapes a silent misread would corrupt the books with", () => {
    expect(parseAmountCents("1.234,56")).toBeNull(); // European: 1234.56, not 1.23
    expect(parseAmountCents("123.456")).toBeNull(); // third decimal would be lost
    expect(parseAmountCents("999999999999.00")).toBeNull(); // past the engine's cap
    expect(parseAmountCents("abc")).toBeNull();
  });

  it("agrees with the engine about which cells count as blank", () => {
    for (const raw of ["", "   ", "-", "--", " - ", "0", "0.00", "x"]) {
      expect(isBlankAmountCell(raw)).toBe(engineIsBlankAmountCell(raw));
    }
    // "0" is NOT blank — it is a real zero, and the two shapes treat it
    // differently downstream.
    expect(isBlankAmountCell("0")).toBe(false);
  });
});

describe("formatCents", () => {
  it("renders whole dollars, cents and thousands the way the summary reads them", () => {
    expect(formatCents(0)).toBe("$0.00");
    expect(formatCents(5)).toBe("$0.05");
    expect(formatCents(123456)).toBe("$1,234.56");
    expect(formatCents(-840000)).toBe("$8,400.00"); // sign lives in the words, not the number
    expect(formatCents(100000000)).toBe("$1,000,000.00");
  });
});
