import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseOfx } from "../../tools/ofx-logic.js";

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

// A real-world-shaped OFX 1.x SGML export: colon-delimited header block,
// blank line, then aggregates that ARE closed but leaf elements that are
// NOT — the standard shape banks actually emit.
const OFX_V1_SGML = `OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII
CHARSET:1252
COMPRESSION:NONE
OLDFILEUID:NONE
NEWFILEUID:NONE

<OFX>
<SIGNONMSGSRSV1>
<SONRS>
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<DTSERVER>20260130120000
<LANGUAGE>ENG
</SONRS>
</SIGNONMSGSRSV1>
<BANKMSGSRSV1>
<STMTTRNRS>
<TRNUID>1
<STATUS>
<CODE>0
<SEVERITY>INFO
</STATUS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<BANKID>123456789
<ACCTID>987654321
<ACCTTYPE>CHECKING
</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>20260101
<DTEND>20260131
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20260105120000[-5:EST]
<TRNAMT>-45.67
<FITID>1001
<NAME>Coffee Shop
<MEMO>card purchase
</STMTTRN>
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20260110
<TRNAMT>+1,250.00
<FITID>1002
<NAME>Payroll Deposit
</STMTTRN>
<STMTTRN>
<TRNTYPE>CHECK
<DTPOSTED>20260115
<TRNAMT>-100.00
<NAME>Check 204
</STMTTRN>
</BANKTRANLIST>
<LEDGERBAL>
<BALAMT>3204.33
<DTASOF>20260131
</LEDGERBAL>
</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>
`;

const OFX_V2_XML = `<?xml version="1.0" encoding="UTF-8"?>
<?OFX OFXHEADER="200" VERSION="200" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?>
<OFX>
  <SIGNONMSGSRSV1>
    <SONRS>
      <STATUS>
        <CODE>0</CODE>
        <SEVERITY>INFO</SEVERITY>
      </STATUS>
      <DTSERVER>20260130120000</DTSERVER>
      <LANGUAGE>ENG</LANGUAGE>
    </SONRS>
  </SIGNONMSGSRSV1>
  <BANKMSGSRSV1>
    <STMTTRNRS>
      <TRNUID>1</TRNUID>
      <STATUS>
        <CODE>0</CODE>
        <SEVERITY>INFO</SEVERITY>
      </STATUS>
      <STMTRS>
        <CURDEF>USD</CURDEF>
        <BANKACCTFROM>
          <BANKID>123456789</BANKID>
          <ACCTID>555000111</ACCTID>
          <ACCTTYPE>CHECKING</ACCTTYPE>
        </BANKACCTFROM>
        <BANKTRANLIST>
          <DTSTART>20260201</DTSTART>
          <DTEND>20260228</DTEND>
          <STMTTRN>
            <TRNTYPE>DEBIT</TRNTYPE>
            <DTPOSTED>20260203</DTPOSTED>
            <TRNAMT>-12.34</TRNAMT>
            <FITID>2001</FITID>
            <NAME>Gas Station</NAME>
          </STMTTRN>
        </BANKTRANLIST>
      </STMTRS>
    </STMTTRNRS>
  </BANKMSGSRSV1>
</OFX>
`;

function wrapStmttrn(inner: string, extra = ""): string {
  return `<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><BANKTRANLIST>${extra}<STMTTRN>${inner}</STMTTRN></BANKTRANLIST></STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>`;
}

describe("parseOfx: OFX 1.x SGML (real-world shaped)", () => {
  it("parses account, currency, ledger balance, and all transactions", () => {
    const result = parseOfx(OFX_V1_SGML);

    expect(result.accountId).toBe("987654321");
    expect(result.currency).toBe("USD");
    expect(result.ledgerBalance).toEqual({ amount: "3204.33", asOf: "2026-01-31" });
    expect(result.periodStart).toBe("2026-01-01");
    expect(result.periodEnd).toBe("2026-01-31");

    expect(result.transactions).toHaveLength(3);
    expect(result.transactions[0]).toEqual({
      fitid: "1001",
      postedDate: "2026-01-05",
      amount: "-45.67",
      name: "Coffee Shop",
      memo: "card purchase",
      type: "DEBIT",
    });
    expect(result.transactions[1]).toEqual({
      fitid: "1002",
      postedDate: "2026-01-10",
      amount: "1250.00",
      name: "Payroll Deposit",
      memo: null,
      type: "CREDIT",
    });
    expect(result.transactions[2]).toEqual({
      fitid: null,
      postedDate: "2026-01-15",
      amount: "-100.00",
      name: "Check 204",
      memo: null,
      type: "CHECK",
    });
  });

  it("tolerates a container (STMTTRN) whose closing tag is entirely missing", () => {
    const text = `<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><BANKTRANLIST>
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20260101
<TRNAMT>-10.00
<FITID>A1
<STMTTRN>
<TRNTYPE>CREDIT
<DTPOSTED>20260102
<TRNAMT>20.00
<FITID>A2
</STMTTRN>
</BANKTRANLIST></STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>`;

    const result = parseOfx(text);
    expect(result.transactions).toHaveLength(2);
    expect(result.transactions.map((t) => t.fitid)).toEqual(["A1", "A2"]);
    expect(result.transactions[0]!.amount).toBe("-10.00");
    expect(result.transactions[1]!.amount).toBe("20.00");
  });
});

describe("parseOfx: OFX 2.x XML", () => {
  it("parses a well-formed XML statement with a declaration and PI", () => {
    const result = parseOfx(OFX_V2_XML);
    expect(result.accountId).toBe("555000111");
    expect(result.currency).toBe("USD");
    expect(result.ledgerBalance).toBeNull();
    expect(result.periodStart).toBe("2026-02-01");
    expect(result.periodEnd).toBe("2026-02-28");
    expect(result.transactions).toEqual([
      {
        fitid: "2001",
        postedDate: "2026-02-03",
        amount: "-12.34",
        name: "Gas Station",
        memo: null,
        type: "DEBIT",
      },
    ]);
  });
});

describe("parseOfx: periodStart/periodEnd (DTSTART/DTEND)", () => {
  it("returns null for both when there is no BANKTRANLIST at all", () => {
    const text = `<OFX><LEDGERBAL><BALAMT>1.00\n<DTASOF>20260101\n</LEDGERBAL></OFX>`;
    const result = parseOfx(text);
    expect(result.periodStart).toBeNull();
    expect(result.periodEnd).toBeNull();
  });

  it("returns null for whichever of DTSTART/DTEND is missing, independently", () => {
    const text = `<OFX><BANKTRANLIST><DTEND>20260131\n</BANKTRANLIST></OFX>`;
    const result = parseOfx(text);
    expect(result.periodStart).toBeNull();
    expect(result.periodEnd).toBe("2026-01-31");
  });
});

describe("parseOfx: XML entity decoding (case-insensitive)", () => {
  it("decodes &AMP; (uppercase, as real SGML files send it)", () => {
    const text = wrapStmttrn(
      `<DTPOSTED>20260101\n<TRNAMT>1.00\n<NAME>AT&AMP;T\n`,
    );
    expect(parseOfx(text).transactions[0]!.name).toBe("AT&T");
  });

  it("decodes lowercase &amp; too", () => {
    const text = wrapStmttrn(`<DTPOSTED>20260101\n<TRNAMT>1.00\n<NAME>Bell &amp; Howell\n`);
    expect(parseOfx(text).transactions[0]!.name).toBe("Bell & Howell");
  });

  it("decodes &lt; and &gt;", () => {
    const text = wrapStmttrn(`<DTPOSTED>20260101\n<TRNAMT>1.00\n<MEMO>&lt;/&gt;\n`);
    expect(parseOfx(text).transactions[0]!.memo).toBe("</>");
  });

  it("decodes &quot;, &apos;, and &#39;", () => {
    const text = wrapStmttrn(
      `<DTPOSTED>20260101\n<TRNAMT>1.00\n<MEMO>&quot;Bob&apos;s&quot; and &#39;Sue&#39;s&#39;\n`,
    );
    expect(parseOfx(text).transactions[0]!.memo).toBe("\"Bob's\" and 'Sue's'");
  });

  it("decodes entities in ACCTID too, not just transaction fields", () => {
    const text = `<OFX><BANKACCTFROM><ACCTID>AT&AMP;T-001\n</BANKACCTFROM></OFX>`;
    expect(parseOfx(text).accountId).toBe("AT&T-001");
  });
});

describe("parseOfx: ghost STMTTRN wrapper (two-sibling collapse)", () => {
  it("does not abort when an outer STMTTRN carries no fields of its own and only wraps a nested real one", () => {
    const text = `<OFX><BANKTRANLIST>
<STMTTRN>
<STMTTRN>
<TRNTYPE>DEBIT
<DTPOSTED>20260101
<TRNAMT>-10.00
<FITID>REAL1
</STMTTRN>
</BANKTRANLIST></OFX>`;

    const result = parseOfx(text);
    expect(result.transactions).toHaveLength(1);
    expect(result.transactions[0]).toEqual({
      fitid: "REAL1",
      postedDate: "2026-01-01",
      amount: "-10.00",
      name: null,
      memo: null,
      type: "DEBIT",
    });
  });

  it("still throws for a genuinely empty STMTTRN with no data and no nested wrapper", () => {
    const text = `<OFX><BANKTRANLIST><STMTTRN></STMTTRN></BANKTRANLIST></OFX>`;
    expect(() => parseOfx(text)).toThrow(/missing DTPOSTED/);
  });
});

describe("parseOfx: DTPOSTED variants", () => {
  it.each([
    ["20260115", "2026-01-15"],
    ["20260115143000", "2026-01-15"],
    ["20260115143000.500[0:GMT]", "2026-01-15"],
    ["20260115143000[-5:EST]", "2026-01-15"],
  ])("accepts %s -> %s", (raw, expected) => {
    const text = wrapStmttrn(`<DTPOSTED>${raw}\n<TRNAMT>1.00\n`);
    const result = parseOfx(text);
    expect(result.transactions[0]!.postedDate).toBe(expected);
  });

  it("rejects a DTPOSTED that isn't 8 leading digits", () => {
    const text = wrapStmttrn(`<DTPOSTED>2026-01-15\n<TRNAMT>1.00\n`);
    expect(() => parseOfx(text)).toThrow(/DTPOSTED/);
  });

  it("rejects an out-of-range month", () => {
    const text = wrapStmttrn(`<DTPOSTED>20261315\n<TRNAMT>1.00\n`);
    expect(() => parseOfx(text)).toThrow(/invalid month/);
  });

  it("rejects an out-of-range day", () => {
    const text = wrapStmttrn(`<DTPOSTED>20260231\n<TRNAMT>1.00\n`);
    expect(() => parseOfx(text)).toThrow(/invalid day/);
  });

  it("rejects a transaction missing DTPOSTED entirely", () => {
    const text = wrapStmttrn(`<TRNAMT>1.00\n<FITID>Z9\n`);
    expect(() => parseOfx(text)).toThrow(/FITID Z9.*DTPOSTED|DTPOSTED/);
  });
});

describe("parseOfx: TRNAMT variants", () => {
  it.each([
    ["-45.67", "-45.67"],
    ["1234.56", "1234.56"],
    ["+100", "100.00"],
    ["1,234.56", "1234.56"],
    [" 12.00 ", "12.00"],
    ["0.00", "0.00"],
    ["-0.00", "0.00"],
  ])("accepts %s -> %s", (raw, expected) => {
    const text = wrapStmttrn(`<DTPOSTED>20260101\n<TRNAMT>${raw}\n`);
    const result = parseOfx(text);
    expect(result.transactions[0]!.amount).toBe(expected);
  });

  it("rejects a non-numeric TRNAMT", () => {
    const text = wrapStmttrn(`<DTPOSTED>20260101\n<TRNAMT>abc\n<FITID>Z1\n`);
    expect(() => parseOfx(text)).toThrow(/Z1.*unparseable|unparseable/);
  });

  it("rejects a TRNAMT with more than 2 decimal places", () => {
    const text = wrapStmttrn(`<DTPOSTED>20260101\n<TRNAMT>12.345\n`);
    expect(() => parseOfx(text)).toThrow(/unparseable/);
  });

  it("rejects a transaction missing TRNAMT entirely", () => {
    const text = wrapStmttrn(`<DTPOSTED>20260101\n<FITID>Z2\n`);
    expect(() => parseOfx(text)).toThrow(/TRNAMT/);
  });
});

describe("parseOfx: duplicate FITID", () => {
  it("throws a loud error naming the duplicated FITID", () => {
    const text = wrapStmttrn(
      `<DTPOSTED>20260102\n<TRNAMT>5.00\n<FITID>DUPE\n`,
      `<STMTTRN><DTPOSTED>20260101\n<TRNAMT>1.00\n<FITID>DUPE\n</STMTTRN>`,
    );
    expect(() => parseOfx(text)).toThrow(/Duplicate FITID "DUPE"/);
  });

  it("allows a missing FITID on some lines (does not treat null as a duplicate)", () => {
    const text = `<OFX><BANKTRANLIST>
<STMTTRN><DTPOSTED>20260101\n<TRNAMT>1.00\n</STMTTRN>
<STMTTRN><DTPOSTED>20260102\n<TRNAMT>2.00\n</STMTTRN>
</BANKTRANLIST></OFX>`;
    const result = parseOfx(text);
    expect(result.transactions.map((t) => t.fitid)).toEqual([null, null]);
  });
});

describe("parseOfx: rejects non-OFX text", () => {
  it("throws a clear English error for plain text", () => {
    expect(() => parseOfx("Date,Description,Amount\n2026-01-01,Coffee,-4.50\n")).toThrow(
      /doesn't look like an OFX\/QFX file/,
    );
  });

  it("throws for empty input", () => {
    expect(() => parseOfx("")).toThrow(/doesn't look like an OFX\/QFX file/);
  });
});

describe("parseOfx: LEDGERBAL present/absent", () => {
  it("returns null when there is no LEDGERBAL section", () => {
    const text = `<OFX><BANKTRANLIST><STMTTRN><DTPOSTED>20260101\n<TRNAMT>1.00\n</STMTTRN></BANKTRANLIST></OFX>`;
    expect(parseOfx(text).ledgerBalance).toBeNull();
  });

  it("throws when LEDGERBAL is present but missing BALAMT or DTASOF", () => {
    const text = `<OFX><LEDGERBAL><BALAMT>100.00\n</LEDGERBAL></OFX>`;
    expect(() => parseOfx(text)).toThrow(/LEDGERBAL/);
  });
});

describe("money hygiene", () => {
  it("never calls parseFloat/Number on a raw amount string in ofx-logic.ts", () => {
    const src = readFileSync(path.join(__dirname, "../../tools/ofx-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(["mo", "d", "y", "wholePart", "fractionPart"]);
  });
});
