#!/usr/bin/env python3
"""Check for the post /blog/pdf-bank-statement-quickbooks.

Lab: runs packages/parsers/src/txid.ts, the id builder behind every Evidilo export, over the five-row
checking statement of packages/exports/src/golden.test.ts, and counts how many of the five ids move
for each one-thing change. Docs: re-reads every page cited in dataset.json and looks for its quote;
a page-absent row passes while its phrase is still missing from the page.

  python3 check.py            lab + pages + PDF, compared with dataset.json and dataset.csv
  python3 check.py --recheck  the same: the lab step takes about a second, so it runs

Exit 0: everything in dataset.json still holds. Exit 1: a quote is gone, an absent phrase appeared,
a measurement differs, a page could not be read, or dataset.csv and dataset.json disagree.
"""
import csv
import hashlib
import html
import json
import re
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path

HERE = Path(__file__).resolve().parent
REPO = HERE.parents[4]
TXID = REPO / "packages" / "parsers" / "src" / "txid.ts"
COLUMNS = ["id", "kind", "option", "property", "value", "quote", "url"]
TIMEOUT = 40
BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"

# fixtureStatement() of packages/exports/src/golden.test.ts: ordinal, date, description, counterparty,
# amount and running balance in cents.
ROWS = [
    (1, "2026-09-01", "Purchase authorized on 08/30 Restaurant Depot Boston MA, card 4417", "Restaurant Depot", -231648, 168352),
    (2, "2026-09-01", "Square Inc 260901 Elm Street Bakery", "Square Inc", 187422, 355774),
    (3, "2026-09-04", "Check 2219", "Check 2219", -4500, 351274),
    (4, "2026-09-08", "Deposit 260908 payroll", "Elm Street Bakery LLC", 52000, 403274),
    (5, "2026-09-15", "AT&T Wireless Pmt Cafe Milano", "AT&T Wireless", -8810, 394464),
]
ACCOUNT = {"bankId": "wf", "accountId": "6302"}

LAB_JS = r"""
import { pathToFileURL } from 'node:url';

const [txidPath, rowsJson, accountJson] = process.argv.slice(2);
const { txids } = await import(pathToFileURL(txidPath).href);

const usd = (cents) => ({ currency: 'USD', minorUnits: BigInt(cents) });
const base = JSON.parse(rowsJson).map(([ordinal, date, description, counterparty, cents, balance]) => ({
  orig: ordinal, ordinal, date, description, counterparty,
  amount: usd(cents),
  direction: cents < 0 ? 'debit' : 'credit',
  runningBalance: usd(balance),
  symbols: { variable: null, constant: null, specific: null }, page: 1, note: null,
}));
const account = JSON.parse(accountJson);
const byOrig = (rows, ids) => new Map(rows.map((r, i) => [r.orig, ids[i]]));
const baseIds = txids(account, base);
const baseMap = byOrig(base, baseIds);
const moved = (rows, identity = account) => {
  const now = byOrig(rows, txids(identity, rows));
  const which = [...baseMap.keys()].filter((o) => now.get(o) !== baseMap.get(o));
  return { moved: which.length, rows: which };
};
const edit = (changes) => base.map((r, i) => ({ ...r, ...(changes[i] ?? {}) }));

process.stdout.write(JSON.stringify({
  ids: baseIds,
  lengths: [...new Set(baseIds.map((x) => x.length))],
  results: {
    same: moved(base),
    reversed: moved(base.slice().reverse().map((r, i) => ({ ...r, ordinal: i + 1 }))),
    description: moved(edit({ 1: { description: 'Square Inc 260901 Elm Street Bakery LLC' } })),
    amountOnly: moved(edit({ 2: { amount: usd(-4800) } })),
    amountAndBalances: moved(edit({
      2: { amount: usd(-4800), runningBalance: usd(350974) },
      3: { runningBalance: usd(402974) },
      4: { runningBalance: usd(394164) },
    })),
    account: moved(base, { ...account, accountId: '6303' }),
  },
}));
"""

LAB = {
    "lab-same-statement": ("same", []),
    "lab-reversed": ("reversed", []),
    "lab-description": ("description", []),
    "lab-amount-only": ("amountOnly", [3]),
    "lab-amount-and-balances": ("amountAndBalances", [3, 4, 5]),
    "lab-account": ("account", [1, 2, 3, 4, 5]),
}


def normalise(text):
    text = html.unescape(text)
    for a, b in (("‘", "'"), ("’", "'"), ("“", '"'), ("”", '"'), ("\xa0", " ")):
        text = text.replace(a, b)
    text = re.sub(r"[​-‍﻿]", "", text)
    text = re.sub(r"\s+", " ", text)
    # Inline tags around a word leave a space before a comma or inside a bracket.
    text = re.sub(r" ([,.):])", r"\1", text)
    return re.sub(r"\( ", "(", text).strip()


def page_text(raw):
    text = raw.decode("utf-8", errors="replace")
    text = re.sub(r"(?is)<(script|style)\b.*?</\1>", " ", text)
    return normalise(re.sub(r"<[^>]+>", " ", text))


def run_lab():
    with tempfile.TemporaryDirectory() as tmp:
        script = Path(tmp) / "lab.mjs"
        script.write_text(LAB_JS)
        result = subprocess.run(
            ["node", "--experimental-strip-types", "--no-warnings", str(script), str(TXID),
             json.dumps(ROWS), json.dumps(ACCOUNT)],
            capture_output=True, text=True, cwd=tmp,
        )
    if result.returncode != 0:
        raise SystemExit("The lab step failed:\n" + result.stderr.strip())
    return json.loads(result.stdout)


def fetch(url, cache):
    if url not in cache:
        # quickbooks.intuit.com stalls a browser user agent and financialdataexchange.org refuses Python's.
        headers = {} if "quickbooks.intuit.com" in url else {"User-Agent": BROWSER_UA}
        try:
            with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=TIMEOUT) as response:
                cache[url] = (response.read(), None)
        except Exception as error:
            cache[url] = (None, f"{type(error).__name__}: {error}")
    return cache[url]


def main():
    data = json.loads((HERE / "dataset.json").read_text())
    rows = data["results"]
    failures = []

    with (HERE / "dataset.csv").open(newline="") as handle:
        csv_rows = list(csv.DictReader(handle))
    if [[r.get(k) or "" for k in COLUMNS] for r in csv_rows] != [[r.get(k) or "" for k in COLUMNS] for r in rows]:
        failures.append("dataset.csv and dataset.json disagree")

    lab = run_lab()
    cache = {}
    for row in rows:
        rid, kind = row["id"], row["kind"]
        if rid == "lab-fitid-length":
            ok = lab["lengths"] == [int(row["value"])] and lab["ids"][0] == row["quote"]
            detail = f"{lab['lengths']} characters, first id {lab['ids'][0]}"
        elif kind == "lab":
            key, expected_rows = LAB[rid]
            result = lab["results"][key]
            ok = f"{result['moved']} of 5" == row["value"] and result["rows"] == expected_rows
            detail = f"{result['moved']} of 5 moved, rows {result['rows']}"
        else:
            body, error = fetch(row["url"], cache)
            if body is None:
                ok, detail = False, f"not checked, {row['url']} unreachable ({error})"
            elif kind == "pdf":
                digest = hashlib.sha256(body).hexdigest()
                ok = digest == row["sha256"]
                detail = f"document sha256 {digest[:12]} {'unchanged' if ok else 'changed'}"
            else:
                present = normalise(row["quote"]) in page_text(body)
                ok = present if kind == "page" else not present
                detail = f"'{row['quote'][:60]}' {'present' if present else 'absent'}"
        print(f"{'ok  ' if ok else 'FAIL'} {rid}: {detail}")
        if not ok:
            failures.append(f"{rid}: {detail}")

    if failures:
        print("\nFAILED:\n  " + "\n  ".join(failures))
        return 1
    print(f"\nAll {len(rows)} rows hold.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
