import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
  parseCsvRows,
  detectCsv,
  applyCsvMapping,
  validateMapping,
  type CsvMapping,
} from "../../tools/csv-map-logic.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

describe("parseCsvRows", () => {
  it("splits a simple comma-separated file into rows", () => {
    expect(parseCsvRows("a,b,c\n1,2,3\n")).toEqual([
      ["a", "b", "c"],
      ["1", "2", "3"],
    ]);
  });

  it("handles quoted fields with embedded commas", () => {
    expect(parseCsvRows('date,desc\n2026-01-01,"Coffee, and bagel"\n')).toEqual([
      ["date", "desc"],
      ["2026-01-01", "Coffee, and bagel"],
    ]);
  });

  it("handles escaped quotes inside a quoted field", () => {
    expect(parseCsvRows('a\n"She said ""hi"" today"\n')).toEqual([["a"], ['She said "hi" today']]);
  });

  it("handles embedded newlines inside a quoted field", () => {
    expect(parseCsvRows('a,b\n"line1\nline2",x\n')).toEqual([
      ["a", "b"],
      ["line1\nline2", "x"],
    ]);
  });

  it("treats a bare mid-field quote as literal text and preserves every subsequent row (RFC 4180: a quote is only special as a field's FIRST character)", () => {
    const text = 'Description\n12" TV\n15" Monitor\nThird Row\n';
    expect(parseCsvRows(text)).toEqual([
      ["Description"],
      ['12" TV'],
      ['15" Monitor'],
      ["Third Row"],
    ]);
  });

  it("preserves a possessive/inch-mark quote in the middle of a field with other columns", () => {
    const text = 'Date,Description,Amount\n2026-01-01,Bob\'s "Big" Sale,10.00\n';
    expect(parseCsvRows(text)).toEqual([
      ["Date", "Description", "Amount"],
      ["2026-01-01", "Bob's \"Big\" Sale", "10.00"],
    ]);
  });

  it("throws a loud error naming the row where an unterminated quote opened", () => {
    const text = 'Description\n"this quote never closes\nMore text here\n';
    expect(() => parseCsvRows(text)).toThrow(
      /A quote mark opened on row 2 was never closed/,
    );
  });

  it("unwraps Excel's =\"...\" text-preservation formula wrapper", () => {
    const text = '="0123",="-4.50"\n="9999",="100.00"\n';
    expect(parseCsvRows(text)).toEqual([
      ["0123", "-4.50"],
      ["9999", "100.00"],
    ]);
  });

  it("handles CRLF and bare LF line endings in the same file", () => {
    expect(parseCsvRows("a,b\r\n1,2\n3,4\r\n")).toEqual([
      ["a", "b"],
      ["1", "2"],
      ["3", "4"],
    ]);
  });

  it("strips a leading BOM", () => {
    expect(parseCsvRows("﻿a,b\n1,2\n")).toEqual([
      ["a", "b"],
      ["1", "2"],
    ]);
  });

  it("drops trailing wholly-empty lines", () => {
    expect(parseCsvRows("a,b\n1,2\n\n\n")).toEqual([
      ["a", "b"],
      ["1", "2"],
    ]);
  });

  it("handles a file with no trailing newline at all", () => {
    expect(parseCsvRows("a,b\n1,2")).toEqual([
      ["a", "b"],
      ["1", "2"],
    ]);
  });

  it("returns an empty array for empty input", () => {
    expect(parseCsvRows("")).toEqual([]);
  });
});

describe("detectCsv: preview + heuristic guesses", () => {
  it("guesses columns on a realistic Chase-shaped header (signed amount)", () => {
    const text =
      "Transaction Date,Post Date,Description,Category,Type,Amount,Memo\n" +
      "01/15/2026,01/16/2026,STARBUCKS STORE,Food,Sale,-4.50,\n" +
      "01/17/2026,01/18/2026,PAYROLL,Income,Payment,1500.00,\n";
    const d = detectCsv(text);
    expect(d.headerRow).toEqual([
      "Transaction Date",
      "Post Date",
      "Description",
      "Category",
      "Type",
      "Amount",
      "Memo",
    ]);
    expect(d.dateCol).toBe(0); // first header containing "date" wins
    expect(d.descriptionCol).toBe(2);
    expect(d.amountCol).toBe(5);
    expect(d.dateFormat).toBe("MDY");
    expect(d.sampleRows).toHaveLength(2);
    expect(d.columnCount).toBe(7);
  });

  it("guesses columns on a realistic Wells Fargo-shaped header (debit/credit pair)", () => {
    const text =
      "Date,Description,Debit,Credit,Balance\n" +
      "2026-01-05,CHECK 204,100.00,,4300.00\n" +
      "2026-01-06,DEPOSIT,,250.00,4550.00\n";
    const d = detectCsv(text);
    expect(d.dateCol).toBe(0);
    expect(d.descriptionCol).toBe(1);
    expect(d.debitCol).toBe(2);
    expect(d.creditCol).toBe(3);
    expect(d.amountCol).toBeUndefined();
    expect(d.dateFormat).toBe("YMD");
  });

  it("guesses withdrawal/deposit headers as debit/credit", () => {
    const text = "Date,Memo,Withdrawal,Deposit\n15/01/2026,Rent,900.00,\n";
    const d = detectCsv(text);
    expect(d.debitCol).toBe(2);
    expect(d.creditCol).toBe(3);
    expect(d.dateFormat).toBe("DMY");
  });

  it("returns empty detection for an empty file", () => {
    expect(detectCsv("")).toEqual({ headerRow: null, sampleRows: [], columnCount: 0 });
  });

  it("detects a headerless file (row 1 is itself data) and guesses columns by shape only", () => {
    const text =
      "01/15/2026,Coffee Shop,-4.50\n" +
      "01/16/2026,Payroll,1500.00\n" +
      "01/17/2026,Rent,-900.00\n";
    const d = detectCsv(text);
    expect(d.headerRow).toBeNull();
    expect(d.sampleRows).toHaveLength(3);
    expect(d.sampleRows[0]).toEqual(["01/15/2026", "Coffee Shop", "-4.50"]);
    expect(d.dateCol).toBe(0);
    expect(d.descriptionCol).toBe(1);
    expect(d.amountCol).toBe(2);
    expect(d.dateFormat).toBe("MDY");
  });

  it("suppresses the amountCol guess when debit/credit columns are also detected (mutually exclusive modes)", () => {
    const text =
      "Date,Description,Debit,Credit,Amount\n" +
      "2026-01-05,CHECK 204,100.00,,\n";
    const d = detectCsv(text);
    expect(d.debitCol).toBe(2);
    expect(d.creditCol).toBe(3);
    expect(d.amountCol).toBeUndefined();
  });

  it("caps sampleRows at 6 data rows even when more are present", () => {
    const rows = ["Date,Amount", "2026-01-01,1.00", "2026-01-02,2.00", "2026-01-03,3.00",
      "2026-01-04,4.00", "2026-01-05,5.00", "2026-01-06,6.00", "2026-01-07,7.00"].join("\n");
    const d = detectCsv(rows);
    expect(d.sampleRows).toHaveLength(6);
  });
});

describe("applyCsvMapping: signed amount mode", () => {
  const rows = parseCsvRows(
    "Date,Description,Amount\n" +
      "01/15/2026,Coffee Shop,-4.50\n" +
      "01/16/2026,Payroll,1500.00\n",
  );

  it("maps signed-amount rows into statement lines", () => {
    const mapping: CsvMapping = {
      headerRows: 1,
      dateCol: 0,
      descriptionCol: 1,
      amountMode: "signed",
      amountCol: 2,
      dateFormat: "MDY",
    };
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(errors).toEqual([]);
    expect(lines).toEqual([
      { postedDate: "2026-01-15", description: "Coffee Shop", amount: "-4.50" },
      { postedDate: "2026-01-16", description: "Payroll", amount: "1500.00" },
    ]);
  });

  it("negateAmount flips every sign", () => {
    const mapping: CsvMapping = {
      headerRows: 1,
      dateCol: 0,
      descriptionCol: 1,
      amountMode: "signed",
      amountCol: 2,
      dateFormat: "MDY",
      negateAmount: true,
    };
    const { lines } = applyCsvMapping(rows, mapping);
    expect(lines.map((l) => l.amount)).toEqual(["4.50", "-1500.00"]);
  });

  it("throws a configuration error when signed mode has no amountCol", () => {
    const mapping = {
      headerRows: 1,
      dateCol: 0,
      descriptionCol: 1,
      amountMode: "signed",
      dateFormat: "MDY",
    } as CsvMapping;
    expect(() => applyCsvMapping(rows, mapping)).toThrow(/amountCol/);
  });
});

describe("applyCsvMapping: debit/credit pair mode", () => {
  const rows = parseCsvRows(
    "Date,Description,Debit,Credit\n" +
      "2026-01-05,CHECK 204,100.00,\n" +
      "2026-01-06,DEPOSIT,,250.00\n",
  );
  const mapping: CsvMapping = {
    headerRows: 1,
    dateCol: 0,
    descriptionCol: 1,
    amountMode: "debit-credit",
    debitCol: 2,
    creditCol: 3,
    dateFormat: "YMD",
  };

  it("debit column becomes negative, credit column becomes positive", () => {
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(errors).toEqual([]);
    expect(lines).toEqual([
      { postedDate: "2026-01-05", description: "CHECK 204", amount: "-100.00" },
      { postedDate: "2026-01-06", description: "DEPOSIT", amount: "250.00" },
    ]);
  });

  it("errors per-row when both debit and credit are set", () => {
    const bothRows = parseCsvRows("Date,Description,Debit,Credit\n2026-01-05,Weird,10.00,5.00\n");
    const { lines, errors } = applyCsvMapping(bothRows, mapping);
    expect(lines).toEqual([]);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(2);
    expect(errors[0]!.message).toMatch(/both debit.*credit/);
  });

  it("errors per-row when neither debit nor credit is set", () => {
    const neitherRows = parseCsvRows("Date,Description,Debit,Credit\n2026-01-05,Weird,,\n");
    const { lines, errors } = applyCsvMapping(neitherRows, mapping);
    expect(lines).toEqual([]);
    expect(errors[0]!.message).toMatch(/neither debit nor credit/);
  });

  it("throws a configuration error when debit-credit mode is missing a column", () => {
    const bad = { ...mapping, creditCol: undefined } as CsvMapping;
    expect(() => applyCsvMapping(rows, bad)).toThrow(/creditCol/);
  });
});

describe("applyCsvMapping: date formats", () => {
  it.each([
    ["MDY" as const, "01/15/2026", "2026-01-15"],
    ["DMY" as const, "15/01/2026", "2026-01-15"],
    ["YMD" as const, "2026-01-15", "2026-01-15"],
    ["MDY" as const, "01-15-26", "2026-01-15"], // 2-digit year -> 20xx
    ["MDY" as const, "01.15.2026", "2026-01-15"], // dot separator
  ])("parses %s date %s -> %s", (dateFormat, raw, expected) => {
    const rows = [["Date", "Description", "Amount"], [raw, "x", "1.00"]];
    const mapping: CsvMapping = {
      headerRows: 1,
      dateCol: 0,
      descriptionCol: 1,
      amountMode: "signed",
      amountCol: 2,
      dateFormat,
    };
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(errors).toEqual([]);
    expect(lines[0]!.postedDate).toBe(expected);
  });

  it("collects a per-row error for an invalid month", () => {
    const rows = [["Date", "Description", "Amount"], ["13/01/2026", "x", "1.00"]];
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(lines).toEqual([]);
    expect(errors[0]!.message).toMatch(/invalid month/);
  });

  it("collects a per-row error for an invalid day", () => {
    const rows = [["Date", "Description", "Amount"], ["02/30/2026", "x", "1.00"]];
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(lines).toEqual([]);
    expect(errors[0]!.message).toMatch(/invalid day/);
  });

  it("collects a per-row error for an unparseable date shape", () => {
    const rows = [["Date", "Description", "Amount"], ["not-a-date", "x", "1.00"]];
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    const { errors } = applyCsvMapping(rows, mapping);
    expect(errors[0]!.message).toMatch(/unparseable date/);
  });
});

describe("applyCsvMapping: amount formats", () => {
  it.each([
    ["(123.45)", "-123.45"], // parentheses negative
    ["$1,234.56", "1234.56"], // dollar sign + comma thousands
    ["123.45-", "-123.45"], // trailing minus
    ["-$50.00", "-50.00"],
    ["  75.00  ", "75.00"],
  ])("parses amount %s -> %s", (raw, expected) => {
    const rows = [["Date", "Description", "Amount"], ["2026-01-01", "x", raw]];
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "YMD",
    };
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(errors).toEqual([]);
    expect(lines[0]!.amount).toBe(expected);
  });

  it("collects a per-row error for an unparseable amount", () => {
    const rows = [["Date", "Description", "Amount"], ["2026-01-01", "x", "not money"]];
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "YMD",
    };
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(lines).toEqual([]);
    expect(errors[0]!.message).toMatch(/unparseable amount/);
  });
});

describe("applyCsvMapping: Excel formula-wrapped cells", () => {
  it("imports a whole file of =\"...\"-wrapped dates and amounts cleanly", () => {
    // Excel's "keep as text" export trick: every cell forced to a literal
    // string via a formula whose result is that string, so zero-padded dates
    // and account numbers survive a re-open in Excel unmangled.
    const text =
      '="Date",="Description",="Amount"\n' +
      '="01/15/2026",="Coffee Shop",="-4.50"\n' +
      '="01/16/2026",="Payroll",="1500.00"\n';
    const rows = parseCsvRows(text);
    expect(rows[0]).toEqual(["Date", "Description", "Amount"]);

    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(errors).toEqual([]);
    expect(lines).toEqual([
      { postedDate: "2026-01-15", description: "Coffee Shop", amount: "-4.50" },
      { postedDate: "2026-01-16", description: "Payroll", amount: "1500.00" },
    ]);
  });
});

describe("applyCsvMapping: row handling", () => {
  it("skips blank rows and collects errors without aborting the rest of the file", () => {
    const rows = [
      ["Date", "Description", "Amount"],
      ["2026-01-01", "Good Row", "10.00"],
      ["", "", ""],
      ["not-a-date", "Bad Row", "5.00"],
      ["2026-01-03", "Another Good Row", "20.00"],
    ];
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "YMD",
    };
    const { lines, errors } = applyCsvMapping(rows, mapping);
    expect(lines).toHaveLength(2);
    expect(lines.map((l) => l.description)).toEqual(["Good Row", "Another Good Row"]);
    expect(errors).toHaveLength(1);
    expect(errors[0]!.row).toBe(4); // 1-indexed row within the raw parsed rows
  });

  it("respects headerRows > 1 (title rows before the real header)", () => {
    const rows = [
      ["My Bank Statement"],
      ["Date", "Description", "Amount"],
      ["2026-01-01", "Coffee", "-4.50"],
    ];
    const mapping: CsvMapping = {
      headerRows: 2, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "YMD",
    };
    const { lines } = applyCsvMapping(rows, mapping);
    expect(lines).toEqual([{ postedDate: "2026-01-01", description: "Coffee", amount: "-4.50" }]);
  });
});

describe("validateMapping", () => {
  it("passes a valid signed-amount mapping", () => {
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    expect(validateMapping(mapping, 3)).toEqual([]);
  });

  it("passes a valid debit-credit mapping", () => {
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "debit-credit",
      debitCol: 2, creditCol: 3, dateFormat: "YMD",
    };
    expect(validateMapping(mapping, 4)).toEqual([]);
  });

  it("flags a missing amountCol in signed mode", () => {
    const mapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "signed", dateFormat: "MDY",
    } as CsvMapping;
    expect(validateMapping(mapping, 3)).toContainEqual(expect.stringMatching(/Amount column/));
  });

  it("flags a missing debitCol/creditCol in debit-credit mode", () => {
    const mapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 1, amountMode: "debit-credit", dateFormat: "MDY",
    } as CsvMapping;
    const errors = validateMapping(mapping, 3);
    expect(errors).toContainEqual(expect.stringMatching(/Debit column/));
    expect(errors).toContainEqual(expect.stringMatching(/Credit column/));
  });

  it("flags an out-of-range column", () => {
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 5, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    expect(validateMapping(mapping, 3)).toContainEqual(expect.stringMatching(/out of range/));
  });

  it("flags a negative column index", () => {
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: -1, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    expect(validateMapping(mapping, 3)).toContainEqual(expect.stringMatching(/out of range/));
  });

  it("flags the same column mapped to two fields", () => {
    const mapping: CsvMapping = {
      headerRows: 1, dateCol: 0, descriptionCol: 0, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    expect(validateMapping(mapping, 3)).toContainEqual(
      expect.stringMatching(/mapped to more than one field/),
    );
  });

  it("flags a negative headerRows", () => {
    const mapping: CsvMapping = {
      headerRows: -1, dateCol: 0, descriptionCol: 1, amountMode: "signed", amountCol: 2,
      dateFormat: "MDY",
    };
    expect(validateMapping(mapping, 3)).toContainEqual(expect.stringMatching(/headerRows/));
  });
});

describe("money hygiene", () => {
  it("never calls parseFloat on a raw amount string in csv-map-logic.ts", () => {
    const src = readFileSync(path.join(__dirname, "../../tools/csv-map-logic.ts"), "utf8");
    // Calls only — the doc comments above mention "parseFloat" by name to
    // explain what NOT to do, so we can't ban the bare word.
    expect(src).not.toMatch(/parseFloat\s*\(/);
    // Every actual Number(...) call site is on a pure-digit substring
    // (wholePart/fractionPart/month/day/year), never on the raw amount text.
    const numberCalls = [...src.matchAll(/Number\(([^)]+)\)/g)].map((m) => m[1]!.trim());
    expect(numberCalls).toEqual([
      "a", "b", // guessDateFormat's date-shape disambiguation (not money)
      "yearStr", "monthStr", "dayStr", // parseCsvDate (not money)
      "wholePart", "fractionPart", // parseCsvAmountCents — the only money call sites
    ]);
  });
});
