// csvAutoDetect — holdings-CSV detection tests (Issue #245) + transaction-CSV
// corpus contract (Issue #326).
//
// Covers `autoDetectHoldingColumns` (column detection) and `analyzeHoldingsCsv`
// (delimiter/header + full analysis). These helpers are pure and DOM-free, in
// line with the project's "test the extracted pure pieces" convention. The
// numeric parsing + duplicate-merge path lives in `holdingsFromCsvRows`
// (tested in useSnapshotEditor.test.ts).
//
// The second half of the file is the #326 corpus contract on the TRANSACTION
// flow (`autoDetectConfig` + `preprocessQuotedCSV`), which had no test at all.

import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import Papa from "papaparse";
import {
  autoDetectHoldingColumns,
  analyzeHoldingsCsv,
  autoDetectConfig,
  preprocessQuotedCSV,
} from "./csvAutoDetect";
import type { DecimalSeparator } from "./amountParser";
import { detectAmountSeparators, mapRow } from "./importFormat";
import {
  CSV_FIXTURE_NAMES,
  readCsvFixture,
  type CsvFixtureName,
} from "../__fixtures__/csv";

describe("autoDetectHoldingColumns (#245)", () => {
  it("detects symbol/quantity/price/book_cost from EN headers", () => {
    const data = [
      ["Symbol", "Quantity", "Price", "Book Cost"],
      ["AAPL", "10", "150.25", "1200.00"],
      ["MSFT", "5", "300.50", "1400.00"],
    ];
    expect(autoDetectHoldingColumns(data, true)).toEqual({
      symbol: 0,
      quantity: 1,
      unit_price: 2,
      book_cost: 3,
    });
  });

  it("leaves unit_price + book_cost null when the CSV has no such columns", () => {
    const data = [
      ["Symbol", "Shares"],
      ["AAPL", "10"],
      ["MSFT", "5"],
    ];
    const m = autoDetectHoldingColumns(data, true)!;
    expect(m.symbol).toBe(0);
    expect(m.quantity).toBe(1);
    expect(m.unit_price).toBeNull();
    expect(m.book_cost).toBeNull();
  });

  it("never maps a Value (qty×price) column to price or book_cost", () => {
    const data = [
      ["Symbol", "Quantity", "Price", "Value"],
      ["AAPL", "10", "150.25", "1502.50"],
      ["MSFT", "4", "300.50", "1202.00"],
    ];
    const m = autoDetectHoldingColumns(data, true)!;
    expect(m.symbol).toBe(0);
    expect(m.quantity).toBe(1);
    expect(m.unit_price).toBe(2);
    // The Value column (col 3) must NOT be picked up as the cost basis.
    expect(m.book_cost).toBeNull();
  });

  it("detects headerless CSVs positionally (symbol / int qty / decimal price)", () => {
    const data = [
      ["AAPL", "10", "150.25"],
      ["MSFT", "5", "300.50"],
      ["GOOG", "2", "140.10"],
    ];
    const m = autoDetectHoldingColumns(data, false)!;
    expect(m.symbol).toBe(0);
    expect(m.quantity).toBe(1);
    expect(m.unit_price).toBe(2);
  });

  it("returns null when there are no data rows", () => {
    expect(autoDetectHoldingColumns([], true)).toBeNull();
    expect(autoDetectHoldingColumns([["Symbol", "Qty"]], true)).toBeNull();
  });
});

describe("analyzeHoldingsCsv (#245)", () => {
  it("parses an EN comma CSV with a header row", () => {
    const csv = "Symbol,Quantity,Price\nAAPL,10,150.25\nMSFT,5,300.50\n";
    const a = analyzeHoldingsCsv(csv)!;
    expect(a.delimiter).toBe(",");
    expect(a.hasHeader).toBe(true);
    expect(a.headers).toEqual(["Symbol", "Quantity", "Price"]);
    expect(a.rows).toEqual([
      ["AAPL", "10", "150.25"],
      ["MSFT", "5", "300.50"],
    ]);
    expect(a.mapping).toEqual({
      symbol: 0,
      quantity: 1,
      unit_price: 2,
      book_cost: null,
    });
  });

  it("handles FR headers (accents), a semicolon delimiter and FR numbers", () => {
    const csv =
      "Symbole;Quantité;Cours;Coût\nAAPL;10;150,25;1 200,00\nMSFT;5;300,50;1 400,00\n";
    const a = analyzeHoldingsCsv(csv)!;
    expect(a.delimiter).toBe(";");
    expect(a.hasHeader).toBe(true);
    expect(a.mapping).toEqual({
      symbol: 0,
      quantity: 1,
      unit_price: 2,
      book_cost: 3,
    });
    expect(a.rows[0]).toEqual(["AAPL", "10", "150,25", "1 200,00"]);
  });

  it("returns generated headers + all rows for a headerless CSV", () => {
    const csv = "AAPL,10,150.25\nMSFT,5,300.50\nGOOG,2,140.10\n";
    const a = analyzeHoldingsCsv(csv)!;
    expect(a.hasHeader).toBe(false);
    expect(a.headers).toEqual(["Col 0", "Col 1", "Col 2"]);
    expect(a.rows).toHaveLength(3);
    expect(a.mapping.symbol).toBe(0);
    expect(a.mapping.quantity).toBe(1);
  });

  it("returns null on empty / whitespace-only content", () => {
    expect(analyzeHoldingsCsv("")).toBeNull();
    expect(analyzeHoldingsCsv("   \n  \n")).toBeNull();
  });
});

// =============================================================================
// Issue #326 — transaction-CSV corpus contract
// =============================================================================
//
// `autoDetectConfig`, `detectAmountMode`, `detectSingleAmount` and
// `preprocessQuotedCSV` had NO test, on a codebase of 871. This block freezes
// what they do TODAY, so the detection rewrite (#323-#332) measures itself
// against an established contract rather than against an intention.
//
// ┌── HOW TO READ THIS BLOCK ────────────────────────────────────────────────┐
// │ Every expectation is what the code DOES, verified by running it — never  │
// │ what it should do. Tests whose name carries `KNOWN DEFECT` encode        │
// │ behaviour that is wrong and is expected to change; each states the right │
// │ answer in a comment and names the issue that owes the fix. When that     │
// │ issue lands, the test is meant to fail: update the expectation, do not   │
// │ delete the test.                                                         │
// └──────────────────────────────────────────────────────────────────────────┘

/**
 * The row-mapping rule is `mapRow` (`src/utils/importFormat.ts`) since #325.
 *
 * It used to be duplicated here as a hand copy called `mapCorpusRow`, because
 * the rule lived inside a `useCallback` of a React hook and the repository has
 * no jsdom; a static guard test pinned the five production expressions so the
 * copy could not silently start lying. #325 lifted the rule out as a pure
 * function, so the mirror and its guard are gone and these tests run the real
 * thing — which is the whole point of the extraction.
 *
 * The shape below keeps the corpus assertions readable: a row is either its
 * three parsed fields or its error.
 */
function mapCorpusRow(
  raw: string[],
  cfg: NonNullable<ReturnType<typeof autoDetectConfig>>,
  decimalSeparators?: ReadonlyMap<number, DecimalSeparator>
): { date: string; description: string; amount: number } | { error: string } {
  const row = mapRow(raw, { ...cfg, encoding: "utf-8" }, { decimalSeparators });
  return row.parsed ?? { error: row.error! };
}

/** Full pipeline: raw file text -> signed amounts, as the wizard runs it. */
function parseFixtureEndToEnd(name: CsvFixtureName) {
  const rawContent = readCsvFixture(name);
  const cfg = autoDetectConfig(rawContent);
  if (!cfg) throw new Error(`autoDetectConfig returned null for ${name}`);
  const format = { ...cfg, encoding: "utf-8" };

  const data = Papa.parse(preprocessQuotedCSV(rawContent), {
    delimiter: cfg.delimiter,
    skipEmptyLines: true,
  }).data as string[][];

  const startIdx = cfg.skipLines + (cfg.hasHeader ? 1 : 0);
  const dataRows: string[][] = [];
  for (let i = startIdx; i < data.length; i++) {
    const raw = data[i];
    if (raw.length <= 1 && raw[0]?.trim() === "") continue;
    dataRows.push(raw);
  }
  const decimalSeparators = detectAmountSeparators(dataRows, format);
  const rows = dataRows.map((raw) => mapCorpusRow(raw, cfg, decimalSeparators));
  return { config: cfg, rows };
}

/** Signed amounts only — the number that actually reaches the database. */
function amountsOf(name: CsvFixtureName): (number | string)[] {
  return parseFixtureEndToEnd(name).rows.map((r) =>
    "error" in r ? r.error : r.amount
  );
}

/** The amounts every well-detected fixture in the corpus must produce. */
const REFERENCE_AMOUNTS = [-84.32, 1250, -142.18, -56.75, 300, -6.95];

describe("corpus integrity (#326)", () => {
  it("exposes every declared fixture as readable, non-empty content", () => {
    expect(CSV_FIXTURE_NAMES).toHaveLength(11);
    for (const name of CSV_FIXTURE_NAMES) {
      expect(readCsvFixture(name).trim().length).toBeGreaterThan(0);
    }
  });

  it("detects a configuration for every fixture (none returns null)", () => {
    for (const name of CSV_FIXTURE_NAMES) {
      expect(autoDetectConfig(readCsvFixture(name)), name).not.toBeNull();
    }
  });

  it("runs the production rule, with no mirror left to drift (#325)", () => {
    // The guard that used to live here pinned five expressions of
    // `parseFilesInternal` so the hand-written mirror could not drift. Both are
    // gone: the wizard delegates to `mapRow` and these tests call it directly.
    const SRC = readFileSync(
      resolve(import.meta.dirname, "..", "hooks", "useImportWizard.ts"),
      "utf-8"
    );
    expect(SRC).toContain("mapRow(raw, config, {");
    // The `?? 0` fallbacks silently read column 0 — usually the date — when a
    // mapping was incomplete. They must never come back.
    expect(SRC).not.toContain("columnMapping.debitAmount ?? 0");
    expect(SRC).not.toContain("columnMapping.creditAmount ?? 0");
    expect(SRC).not.toContain("columnMapping.amount ?? 0");
    expect(SRC).not.toContain("isNaN(credit)");
  });
});

describe("autoDetectConfig — signed single amount (#326)", () => {
  it("returns the full contract for a FR semicolon file", () => {
    expect(autoDetectConfig(readCsvFixture("signed-amount"))).toEqual({
      delimiter: ";",
      hasHeader: true,
      skipLines: 0,
      dateFormat: "DD/MM/YYYY",
      columnMapping: { date: 0, description: 1, amount: 2 },
      amountMode: "single",
      signConvention: "negative_expense",
    });
  });

  it("parses end to end to the reference amounts", () => {
    expect(amountsOf("signed-amount")).toEqual(REFERENCE_AMOUNTS);
  });

  it("carries the description through untouched", () => {
    const { rows } = parseFixtureEndToEnd("signed-amount");
    expect(rows[0]).toEqual({
      date: "2025-01-05",
      description: "EPICERIE METRO SAINTE-FOY",
      amount: -84.32,
    });
    expect(rows[1]).toEqual({
      date: "2025-01-15",
      description: "DEPOT PAIE EMPLOYEUR",
      amount: 1250,
    });
  });
});

describe("autoDetectConfig — debit/credit pair (#326)", () => {
  it("detects the sparse-complementary pair as debit_credit", () => {
    expect(autoDetectConfig(readCsvFixture("debit-credit"))).toEqual({
      delimiter: ";",
      hasHeader: true,
      skipLines: 0,
      dateFormat: "DD/MM/YYYY",
      columnMapping: {
        date: 0,
        description: 1,
        debitAmount: 2,
        creditAmount: 3,
      },
      amountMode: "debit_credit",
      signConvention: "negative_expense",
    });
  });

  it("parses end to end to the reference amounts", () => {
    // Debit -> negative, credit -> positive. Correct today because the file
    // happens to list Debit before Credit — see the reversed fixture.
    expect(amountsOf("debit-credit")).toEqual(REFERENCE_AMOUNTS);
  });
});

describe("autoDetectConfig — KNOWN DEFECT: debit/credit order guessed by position (#326, fixed by #328)", () => {
  // `detectAmountMode` assigns the LEFTMOST column of a complementary pair to
  // `debitAmount` and the rightmost to `creditAmount` (csvAutoDetect.ts:461-470).
  // It never looks at the header labels. A file laid out `Credit;Debit` is
  // therefore mapped backwards, and every sign in the import is inverted.
  //
  // #328 resolves the order from the header dictionary instead of the position.

  it("maps Credit to debitAmount and Debit to creditAmount", () => {
    const cfg = autoDetectConfig(readCsvFixture("debit-credit-reversed"))!;
    // Column 2 is labelled "Credit" and column 3 "Debit" in the fixture.
    expect(cfg.columnMapping.debitAmount).toBe(2); // DEFECT — should be 3
    expect(cfg.columnMapping.creditAmount).toBe(3); // DEFECT — should be 2
    expect(cfg.amountMode).toBe("debit_credit");
  });

  it("inverts every sign end to end — groceries become income", () => {
    expect(amountsOf("debit-credit-reversed")).toEqual(
      // DEFECT — should be REFERENCE_AMOUNTS
      [84.32, -1250, 142.18, 56.75, -300, 6.95]
    );
  });

  it("still nets to the same total, which is why the bug hides", () => {
    // The signs are wrong one by one, but the sum is merely negated — no
    // aggregate check catches it. Only a per-row review does.
    const sum = (xs: (number | string)[]) =>
      xs.reduce<number>((a, b) => a + (typeof b === "number" ? b : 0), 0);
    expect(sum(amountsOf("debit-credit-reversed"))).toBeCloseTo(
      -sum(REFERENCE_AMOUNTS),
      6
    );
  });
});

describe("autoDetectConfig — unused column filled with 0,00 (#326, fixed by #325)", () => {
  // FIXED. When the unused half of a debit/credit pair carries "0,00" instead
  // of an empty cell, detection always got the mode right —
  // `isSparseComplementary` treats a 0 as absent. The PARSING was what broke:
  // the rule branched on `isNaN(credit)`, and "0,00" parses to 0, not NaN, so
  // every debit row imported as 0,00 and the expense vanished — silently, since
  // 0 passes validation.
  //
  // The rule is `credit - debit` on magnitudes now, for which a 0,00 cell needs
  // no special case: zero is the identity of the subtraction.

  it("still detects the debit_credit mode correctly", () => {
    const cfg = autoDetectConfig(readCsvFixture("unused-column-zero"))!;
    expect(cfg.amountMode).toBe("debit_credit");
    expect(cfg.columnMapping.debitAmount).toBe(2);
    expect(cfg.columnMapping.creditAmount).toBe(3);
  });

  it("parses end to end to the reference amounts", () => {
    expect(amountsOf("unused-column-zero")).toEqual(REFERENCE_AMOUNTS);
  });

  it("keeps every expense, and none of them is zero", () => {
    const amounts = amountsOf("unused-column-zero");
    expect(amounts.filter((a) => a === 0)).toHaveLength(0);
    expect(amounts.filter((a) => typeof a === "number" && a < 0)).toHaveLength(
      4
    );
  });

  it("reads the same file identically to its empty-cell twin", () => {
    // Same six transactions, written with empty cells instead of 0,00.
    expect(amountsOf("unused-column-zero")).toEqual(amountsOf("debit-credit"));
  });
});

describe("autoDetectConfig — preamble before the header (#326)", () => {
  it("counts the preamble lines into skipLines", () => {
    expect(autoDetectConfig(readCsvFixture("preamble"))).toEqual({
      delimiter: ";",
      hasHeader: true,
      skipLines: 3,
      dateFormat: "DD/MM/YYYY",
      columnMapping: { date: 0, description: 1, amount: 2 },
      amountMode: "single",
      signConvention: "negative_expense",
    });
  });

  it("parses end to end to the reference amounts", () => {
    expect(amountsOf("preamble")).toEqual(REFERENCE_AMOUNTS);
  });
});

describe("autoDetectConfig — header carrying a number (#326)", () => {
  // Two fixtures, opposite outcomes, same root cause — `detectHeader` decides
  // "this row is data" from `parseFrenchAmount` alone (csvAutoDetect.ts:224).

  it("is unharmed when the numeric header cell starts with a letter", () => {
    // "Solde 2024" normalizes to "Solde2024"; parseFloat stops at the 'S' and
    // returns NaN, so the header is correctly recognised. The running-balance
    // column is also correctly excluded from the amount candidates.
    expect(autoDetectConfig(readCsvFixture("header-with-number"))).toEqual({
      delimiter: ";",
      hasHeader: true,
      skipLines: 0,
      dateFormat: "DD/MM/YYYY",
      columnMapping: { date: 0, description: 1, amount: 2 },
      amountMode: "single",
      signConvention: "negative_expense",
    });
  });

  it("parses the balance-column file end to end to the reference amounts", () => {
    expect(amountsOf("header-with-number")).toEqual(REFERENCE_AMOUNTS);
  });
});

describe("autoDetectConfig — header cell starting with digits (#326, fixed by #325)", () => {
  // FIXED, by the other of the two routes link 1 named. "2025 Montant"
  // normalizes to "2025Montant"; `parseFloat` returned the 2025 prefix,
  // `hasNumber` flipped true, and the header row was taken for data.
  //
  // Link 1's note: "#328 adds a lexical signal to `detectHeader`, and #325
  // anchors the parser so a numeric prefix no longer reads as a number. Either
  // fix closes this." #325 landed first — the anchored parser returns NaN for
  // that cell, so `detectHeader` sees a row with no number and no date.
  // #328 keeps its lexical signal for header cells that carry a BARE number.

  it("recognises the header row", () => {
    const cfg = autoDetectConfig(readCsvFixture("header-numeric-label"))!;
    expect(cfg.hasHeader).toBe(true);
    expect(cfg.columnMapping).toEqual({ date: 0, description: 1, amount: 2 });
  });

  it("skips the header instead of emitting it as an error row", () => {
    const { rows } = parseFixtureEndToEnd("header-numeric-label");
    expect(rows).toHaveLength(6);
    expect(amountsOf("header-numeric-label")).toEqual(REFERENCE_AMOUNTS);
  });
});

describe("autoDetectConfig — no header row (#326)", () => {
  it("recognises a headerless file and maps it positionally", () => {
    expect(autoDetectConfig(readCsvFixture("no-header"))).toEqual({
      delimiter: ";",
      hasHeader: false,
      skipLines: 0,
      dateFormat: "DD/MM/YYYY",
      columnMapping: { date: 0, description: 1, amount: 2 },
      amountMode: "single",
      signConvention: "negative_expense",
    });
  });

  it("parses end to end to the reference amounts, losing no first row", () => {
    expect(amountsOf("no-header")).toEqual(REFERENCE_AMOUNTS);
  });
});

describe("autoDetectConfig — KNOWN DEFECT: all-positive amounts (#326, fixed by #329)", () => {
  // `detectSingleAmount` (csvAutoDetect.ts:507-530) infers the convention from
  // the share of negative values alone: no negatives -> `positive_expense` ->
  // every amount is negated at parse time. On a file that mixes expenses and
  // income and signs neither, the income is imported as an expense.
  //
  // Nothing in the file distinguishes the two cases, so detection cannot be
  // "fixed" here. #329 surfaces it instead: a confidence score, a mandatory
  // preview showing signed totals, and a "flip the signs" button.

  it("infers positive_expense from the absence of negatives", () => {
    const cfg = autoDetectConfig(readCsvFixture("all-positive"))!;
    expect(cfg.signConvention).toBe("positive_expense");
    expect(cfg.amountMode).toBe("single");
  });

  it("negates the income rows along with the expenses", () => {
    expect(amountsOf("all-positive")).toEqual([
      -84.32, // expense — correct
      -1250, // DEFECT — payroll deposit, should be +1250
      -142.18, // expense — correct
      -56.75, // expense — correct
      -300, // DEFECT — incoming transfer, should be +300
      -6.95, // expense — correct
    ]);
  });

  it("produces a total that is plainly wrong, which the preview will expose", () => {
    const total = amountsOf("all-positive").reduce<number>(
      (a, b) => a + (typeof b === "number" ? b : 0),
      0
    );
    expect(total).toBeCloseTo(-1840.2, 2); // truth is +1259.80
  });
});

describe("preprocessQuotedCSV + autoDetectConfig — whole-line-quoted file (#326)", () => {
  it("unwraps the outer quotes and un-escapes the inner ones", () => {
    const unwrapped = preprocessQuotedCSV(readCsvFixture("desjardins-quoted"));
    expect(unwrapped.split("\n")[0]).toBe('Date,"Description",Montant');
    expect(unwrapped.split("\n")[1]).toBe(
      '05/01/2025,"EPICERIE METRO SAINTE-FOY",-84.32'
    );
  });

  it("leaves an ordinary file untouched", () => {
    const raw = readCsvFixture("signed-amount");
    expect(preprocessQuotedCSV(raw)).toBe(raw);
  });

  it("only fires when every non-empty line is quoted AND contains a `,\"\"`", () => {
    // The guard is narrow on purpose (csvAutoDetect.ts:37-40). A semicolon
    // file can never match it, because the probe looks for a comma.
    const semicolonQuoted = '"Date;""Desc"";Montant"\n"05/01/2025;""A"";-1,00"';
    expect(preprocessQuotedCSV(semicolonQuoted)).toBe(semicolonQuoted);
  });

  it("detects the comma delimiter through the unwrapping", () => {
    expect(autoDetectConfig(readCsvFixture("desjardins-quoted"))).toEqual({
      delimiter: ",",
      hasHeader: true,
      skipLines: 0,
      dateFormat: "DD/MM/YYYY",
      columnMapping: { date: 0, description: 1, amount: 2 },
      amountMode: "single",
      signConvention: "negative_expense",
    });
  });

  it("parses end to end to the reference amounts", () => {
    expect(amountsOf("desjardins-quoted")).toEqual(REFERENCE_AMOUNTS);
  });
});

describe("autoDetectConfig — KNOWN DEFECT: absolute amount + D/C indicator (#326, fixed by #328)", () => {
  // The third amount mode: one column of unsigned magnitudes plus a
  // single-letter D/C column carrying the direction. Nothing reads that
  // column. Detection sees only positive numbers, picks `positive_expense`,
  // and every credit is imported as an expense.
  //
  // #328 must DETECT this shape and REFUSE the file with a dedicated message,
  // rather than import it backwards. The spec decision is explicit: refused,
  // not silently mis-imported.

  it("ignores the indicator column entirely", () => {
    const cfg = autoDetectConfig(readCsvFixture("absolute-indicator"))!;
    expect(cfg.amountMode).toBe("single");
    expect(cfg.columnMapping.amount).toBe(2);
    // Column 3 ("Sens", values D/C) appears nowhere in the mapping.
    expect(Object.values(cfg.columnMapping)).not.toContain(3);
    expect(cfg.signConvention).toBe("positive_expense");
  });

  it("imports both credit rows as expenses", () => {
    const amounts = amountsOf("absolute-indicator");
    // Rows 2 and 5 are flagged "C" in the fixture.
    expect(amounts[1]).toBe(-1250); // DEFECT — should be +1250
    expect(amounts[4]).toBe(-300); // DEFECT — should be +300
    expect(amounts.every((a) => typeof a === "number" && a < 0)).toBe(true);
  });

  it("is currently indistinguishable from the all-positive fixture", () => {
    // Same detected config, same wrong outcome — which is exactly why #328
    // needs the indicator column as its discriminator.
    const withIndicator = autoDetectConfig(readCsvFixture("absolute-indicator"))!;
    const without = autoDetectConfig(readCsvFixture("all-positive"))!;
    expect(withIndicator.amountMode).toBe(without.amountMode);
    expect(withIndicator.signConvention).toBe(without.signConvention);
    expect(withIndicator.columnMapping).toEqual(without.columnMapping);
  });
});

describe("autoDetectConfig — degenerate input (#326)", () => {
  it("returns null on content with fewer than two non-empty lines", () => {
    expect(autoDetectConfig("")).toBeNull();
    expect(autoDetectConfig("Date;Description;Montant")).toBeNull();
    expect(autoDetectConfig("   \n   \n")).toBeNull();
  });

  it("returns null when no column parses as a date", () => {
    expect(
      autoDetectConfig("Nom;Ville;Montant\nAlice;Quebec;10,00\nBob;Levis;20,00")
    ).toBeNull();
  });
});
