// Pure hand-rolled parser for bank statement files in OFX/QFX format. No I/O,
// no XML/SGML library — banks export both OFX 1.x SGML (aggregates closed,
// leaf elements NOT closed, preceded by a colon-delimited header block) and
// OFX 2.x XML (an <?xml?> declaration + <?OFX ...?> processing instruction,
// then well-formed XML where every element is closed). See
// docs/superpowers/specs/2026-08-01-bank-rec-design.md ("Formats") and
// docs/superpowers/plans/2026-08-01-bank-rec.md, Task 2.
//
// Strategy: strip everything before the real <OFX> tag (discards the SGML
// header block AND the XML declaration/PI uniformly), then tokenize the body
// into open/close tags and build a lightweight tree: a tag with an inline
// value is treated as a leaf (never pushed onto the open-element stack, so a
// missing closing tag for it — the normal OFX 1.x case — is harmless); a tag
// with an empty inline value is treated as a container and pushed. Because
// extraction below (findFirst/findAll) searches the WHOLE tree regardless of
// depth, the result is tolerant even of a real-world file that fails to close
// an aggregate: subsequent siblings simply nest one level deeper, and are
// still found. Never uses parseFloat/Number on a raw amount string — amounts
// go through integer-cents arithmetic via journal-logic.ts's centsToStr,
// exactly like every other money value in this codebase.

import { centsToStr } from "./journal-logic.js";

export interface OfxTransaction {
  fitid: string | null;
  /** ISO yyyy-mm-dd — the DTPOSTED date part only, no timezone math. */
  postedDate: string;
  /** Signed 2dp string (bank convention: positive = money in). */
  amount: string;
  name: string | null;
  memo: string | null;
  /** TRNTYPE, e.g. "DEBIT" / "CREDIT" / "CHECK" — passed through verbatim. */
  type: string | null;
}

export interface OfxStatement {
  /** BANKACCTFROM/CCACCTFROM's ACCTID, when present. */
  accountId?: string;
  /** CURDEF, when present. */
  currency?: string;
  /** null when the file has no LEDGERBAL section at all. */
  ledgerBalance: { amount: string; asOf: string } | null;
  /** BANKTRANLIST's DTSTART, ISO yyyy-mm-dd; null when absent. Named
   *  periodStart/periodEnd rather than the plan's literal dtEnd — clearer
   *  once there are two of them (approved deviation, see report). */
  periodStart: string | null;
  /** BANKTRANLIST's DTEND, ISO yyyy-mm-dd; null when absent. */
  periodEnd: string | null;
  transactions: OfxTransaction[];
}

interface OfxNode {
  tag: string;
  /** Trimmed inline value captured at the moment the tag was opened. Only
   *  meaningful for leaves (non-empty means "this is a leaf, not a container"). */
  text: string;
  children: OfxNode[];
}

function stripBom(text: string): string {
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}

/**
 * Parses a bank statement file in OFX/QFX format (either OFX 1.x SGML or
 * OFX 2.x XML — both are tolerated by the same code path). Throws a loud
 * English error if the text doesn't contain an <OFX> tag at all, if a
 * transaction is missing DTPOSTED/TRNAMT, if TRNAMT/BALAMT is unparseable, if
 * DTPOSTED/DTASOF is malformed, or if two transactions share a non-null
 * FITID.
 */
export function parseOfx(text: string): OfxStatement {
  const stripped = stripBom(text);
  const startIdx = stripped.search(/<ofx[\s>]/i);
  if (startIdx === -1) {
    throw new Error(
      "This doesn't look like an OFX/QFX file — no <OFX> tag was found.",
    );
  }
  const root = buildOfxTree(stripped.slice(startIdx));

  const acctNode = findFirst(root, "BANKACCTFROM") ?? findFirst(root, "CCACCTFROM");
  const accountId = (acctNode && directChildText(acctNode, "ACCTID")) ?? undefined;

  const currNode = findFirst(root, "CURDEF");
  const currency = (currNode && currNode.text !== "" ? currNode.text : undefined) ?? undefined;

  const ledgerNode = findFirst(root, "LEDGERBAL");
  let ledgerBalance: OfxStatement["ledgerBalance"] = null;
  if (ledgerNode) {
    const amtText = directChildText(ledgerNode, "BALAMT");
    const asOfText = directChildText(ledgerNode, "DTASOF");
    if (amtText === null || asOfText === null) {
      throw new Error("LEDGERBAL is present but is missing BALAMT or DTASOF.");
    }
    ledgerBalance = {
      amount: centsToStr(parseOfxAmountCents(amtText, "LEDGERBAL BALAMT")),
      asOf: parseOfxDate(asOfText, "LEDGERBAL DTASOF"),
    };
  }

  const tranListNode = findFirst(root, "BANKTRANLIST");
  const periodStart = tranListNode
    ? parseOptionalOfxDate(directChildText(tranListNode, "DTSTART"), "BANKTRANLIST DTSTART")
    : null;
  const periodEnd = tranListNode
    ? parseOptionalOfxDate(directChildText(tranListNode, "DTEND"), "BANKTRANLIST DTEND")
    : null;

  // A malformed file can leave an outer STMTTRN as a pure "ghost" wrapper
  // around a nested STMTTRN (e.g. two <STMTTRN> tags opened back to back
  // with no fields of the outer one before the inner one starts). Such a
  // ghost has no leaf children of its own — every child is itself a
  // STMTTRN — so treating it as a transaction would throw "missing
  // DTPOSTED" and abort the whole file over a container that never carried
  // any data. Skip those; the real data is in the innermost node either way.
  const trnNodes = findAll(root, "STMTTRN").filter(
    (node) =>
      node.children.length === 0 ||
      !node.children.every((c) => c.tag.toUpperCase() === "STMTTRN"),
  );
  const seenFitids = new Set<string>();
  const transactions: OfxTransaction[] = trnNodes.map((node, idx) => {
    const label = `transaction ${idx + 1}`;
    const fitid = directChildText(node, "FITID");
    if (fitid !== null) {
      if (seenFitids.has(fitid)) {
        throw new Error(
          `Duplicate FITID "${fitid}" appears more than once in this file — each transaction ` +
            `must have a unique FITID.`,
        );
      }
      seenFitids.add(fitid);
    }
    const ident = fitid !== null ? `${label} (FITID ${fitid})` : label;

    const dtText = directChildText(node, "DTPOSTED");
    if (dtText === null) {
      throw new Error(`${ident}: missing DTPOSTED.`);
    }
    const postedDate = parseOfxDate(dtText, `${ident}'s DTPOSTED`);

    const amtText = directChildText(node, "TRNAMT");
    if (amtText === null) {
      throw new Error(`${ident}: missing TRNAMT.`);
    }
    const amount = centsToStr(parseOfxAmountCents(amtText, `${ident}'s TRNAMT`));

    return {
      fitid,
      postedDate,
      amount,
      name: directChildText(node, "NAME"),
      memo: directChildText(node, "MEMO"),
      type: directChildText(node, "TRNTYPE"),
    };
  });

  return { accountId, currency, ledgerBalance, periodStart, periodEnd, transactions };
}

function parseOptionalOfxDate(raw: string | null, label: string): string | null {
  return raw === null ? null : parseOfxDate(raw, label);
}

/**
 * Tokenizes `body` into open/close tags (`<TAG>value` or `</TAG>`) and builds
 * a tree. A tag whose inline value trims to non-empty is a leaf (its close
 * tag, if any, is a harmless no-op — real OFX 1.x SGML never sends one). A
 * tag whose inline value trims to empty is a container and is pushed onto the
 * open-element stack; its close tag (when present) pops back to its parent.
 * An unmatched close tag is ignored rather than throwing — malformed nesting
 * degrades to "extraction still works via deep search", never to a crash.
 */
function buildOfxTree(body: string): OfxNode {
  const tagRe = /<(\/?[A-Za-z0-9_.]+)>([^<]*)/g;
  const root: OfxNode = { tag: "#ROOT", text: "", children: [] };
  const stack: OfxNode[] = [root];
  let m: RegExpExecArray | null;

  while ((m = tagRe.exec(body)) !== null) {
    const rawTag = m[1]!;
    const rawValue = m[2] ?? "";

    if (rawTag.startsWith("/")) {
      const closeName = rawTag.slice(1).toUpperCase();
      for (let i = stack.length - 1; i > 0; i--) {
        if (stack[i]!.tag.toUpperCase() === closeName) {
          stack.length = i;
          break;
        }
      }
      continue;
    }

    const trimmed = rawValue.trim();
    const value = decodeOfxEntities(trimmed);
    const node: OfxNode = { tag: rawTag, text: value, children: [] };
    stack[stack.length - 1]!.children.push(node);
    if (trimmed === "") {
      stack.push(node);
    }
  }

  return root;
}

/** OFX leaf text carries the five standard XML entities, case-INSENSITIVELY
 *  — real SGML files use both &amp; and &AMP;. Decoded once here so every
 *  leaf value (NAME, MEMO, ACCTID, ...) downstream sees plain text. */
function decodeOfxEntities(value: string): string {
  return value.replace(/&(amp|lt|gt|quot|apos|#39);/gi, (_match, entity: string) => {
    switch (entity.toLowerCase()) {
      case "amp":
        return "&";
      case "lt":
        return "<";
      case "gt":
        return ">";
      case "quot":
        return '"';
      case "apos":
      case "#39":
        return "'";
      default:
        return _match;
    }
  });
}

function findFirst(node: OfxNode, tag: string): OfxNode | null {
  for (const child of node.children) {
    if (child.tag.toUpperCase() === tag) return child;
    const found = findFirst(child, tag);
    if (found) return found;
  }
  return null;
}

function findAll(node: OfxNode, tag: string): OfxNode[] {
  const results: OfxNode[] = [];
  for (const child of node.children) {
    if (child.tag.toUpperCase() === tag) results.push(child);
    results.push(...findAll(child, tag));
  }
  return results;
}

/** First direct (non-recursive) child with this tag name; null if absent or
 *  present-but-empty (both mean "no information" for our purposes). */
function directChildText(node: OfxNode, tag: string): string | null {
  for (const child of node.children) {
    if (child.tag.toUpperCase() === tag) {
      return child.text !== "" ? child.text : null;
    }
  }
  return null;
}

/**
 * DTPOSTED/DTASOF: YYYYMMDD or YYYYMMDDHHMMSS, optionally followed by
 * fractional seconds and/or a "[offset:TZ]" suffix (e.g.
 * "20260115120000.000[-5:EST]"). Only the date part is used — bank-posted
 * date is a calendar fact, not a timestamp to convert across zones.
 */
function parseOfxDate(raw: string, label: string): string {
  const s = raw.trim();
  const m = /^(\d{4})(\d{2})(\d{2})/.exec(s);
  if (!m) {
    throw new Error(
      `${label}: invalid date "${raw}" — expected YYYYMMDD, optionally followed by a time.`,
    );
  }
  const [, y, mo, d] = m as unknown as [string, string, string, string];
  const month = Number(mo);
  const day = Number(d);
  if (month < 1 || month > 12) {
    throw new Error(`${label}: invalid month in date "${raw}".`);
  }
  const maxDay = new Date(Date.UTC(Number(y), month, 0)).getUTCDate();
  if (day < 1 || day > maxDay) {
    throw new Error(`${label}: invalid day in date "${raw}".`);
  }
  return `${y}-${mo}-${d}`;
}

/**
 * TRNAMT/BALAMT: signed decimal, possibly with a leading "+", comma
 * thousands separators, or surrounding whitespace. Never parsed with
 * parseFloat/Number on the raw string — only the pure-digit whole/fraction
 * substrings are ever handed to Number(), exactly like journal-logic.ts's
 * centsFromAmountString.
 */
function parseOfxAmountCents(raw: string, label: string): number {
  let s = raw.trim();
  if (s === "") {
    throw new Error(`${label} is blank.`);
  }
  s = s.replace(/,/g, "");
  if (s.startsWith("+")) s = s.slice(1);

  if (!/^-?\d+(\.\d{1,2})?$/.test(s)) {
    throw new Error(`${label}: unparseable amount "${raw}".`);
  }
  const negative = s.startsWith("-");
  const unsigned = negative ? s.slice(1) : s;
  const [wholePart, fractionPartRaw] = unsigned.split(".");
  const fractionPart = (fractionPartRaw ?? "").padEnd(2, "0");
  const cents = Number(wholePart) * 100 + Number(fractionPart);
  return negative ? -cents : cents;
}
