#!/usr/bin/env python3
"""Re-runs the evidence check behind the Evidilo post `bank-statement-photo`.

Two halves:

* a lab: a statement page is rendered at 300 dpi with a withdrawal column and a deposit
  column, then put through a grid of photo conditions (the downscale a messaging app
  applies, JPEG quality, a shadow across the amount columns, hand-held tilt). For each
  condition the image itself is measured: how tall a figure stands, how wide its stroke is,
  how many grey levels separate ink from paper, and how far a column moves sideways because
  of the tilt, against the gap between the two amount columns.
* a page half: every quoted third-party sentence is re-fetched and compared, whitespace,
  dashes and HTML entities normalised.

Exit 0 when every recorded value still holds, 1 otherwise.
"""

import argparse
import csv
import html
import json
import math
import re
import sys
import tempfile
import time
import urllib.request
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw, ImageFont

HERE = Path(__file__).resolve().parent
DATASET = HERE / "dataset.json"
CSV_OUT = HERE / "dataset.csv"

FONT = "/System/Library/Fonts/Supplemental/Arial.ttf"
PAGE_W, PAGE_H = 2550, 3300  # US Letter at 300 dpi
BODY_PX = 38  # a 9 pt figure at 300 dpi
ROWS_PER_PAGE = 14
ROW_PITCH = 112

# Column edges, right-aligned, at the full page width.
WITHDRAWAL_X = 1580
DEPOSIT_X = 2080
BALANCE_X = 2440
DATE_X = 200
DESCRIPTION_X = 560
COLUMN_GAP_PX = DEPOSIT_X - WITHDRAWAL_X
FIRST_ROW_Y = 730
DIGIT_ROW = 3          # a deposit row: figures in the deposit column
DRIFT_TOP_ROW = 0      # a deposit row
DRIFT_BOTTOM_ROW = 12  # a deposit row

DESCRIPTIONS = [
    "MAPLE STREET BAKERY", "NORTHLINE UTILITIES", "CITY PARKING GARAGE",
    "HARBOR FREIGHT SUPPLY", "SUNRISE COFFEE ROASTERS", "REDWOOD HARDWARE CO",
    "LAKESIDE DENTAL GROUP", "TRAILHEAD OUTDOOR LLC", "MERIDIAN OFFICE RENT",
    "BLUEBIRD INSURANCE", "PINECREST PHARMACY", "GOLDEN GATE TRANSIT",
    "WESTBROOK PAYROLL", "CLEARVIEW SOFTWARE",
]


def money(cents):
    sign = "-" if cents < 0 else ""
    return "%s%s.%02d" % (sign, format(abs(cents) // 100, ","), abs(cents) % 100)


def rows():
    """Fourteen rows; every third one is a deposit, and no two amounts repeat."""
    amounts = [353472, 48615, 221503, 1298750, 672137, 424951, 714968, 233419,
               523416, 586086, 191227, 88306, 1042134, 298455]
    result = []
    for index, cents in enumerate(amounts):
        withdrawal = index % 3 != 0
        result.append({
            "date": "03/%02d" % (2 + index),
            "description": DESCRIPTIONS[index],
            "amount": -cents if withdrawal else cents,
        })
    return result


def render():
    image = Image.new("L", (PAGE_W, PAGE_H), 255)
    draw = ImageDraw.Draw(image)
    title = ImageFont.truetype(FONT, 62)
    head = ImageFont.truetype(FONT, 34)
    body = ImageFont.truetype(FONT, BODY_PX)

    draw.text((DATE_X, 190), "NORTHFIELD SAVINGS BANK", font=title, fill=0)
    draw.text((DATE_X, 275), "Business checking statement", font=head, fill=0)
    draw.text((DATE_X, 322), "Statement period 03/01/2026 to 03/31/2026", font=head, fill=0)
    draw.text((DATE_X, 369), "Account 0004 8821 3376", font=head, fill=0)

    header_y = 640
    draw.text((DATE_X, header_y), "DATE", font=head, fill=0)
    draw.text((DESCRIPTION_X, header_y), "DESCRIPTION", font=head, fill=0)
    draw.text((WITHDRAWAL_X, header_y), "WITHDRAWALS", font=head, fill=0, anchor="ra")
    draw.text((DEPOSIT_X, header_y), "DEPOSITS", font=head, fill=0, anchor="ra")
    draw.text((BALANCE_X, header_y), "BALANCE", font=head, fill=0, anchor="ra")
    draw.line((DATE_X, header_y + 50, BALANCE_X, header_y + 50), fill=0, width=3)

    balance = 1760291
    y = FIRST_ROW_Y
    for row in rows():
        draw.text((DATE_X, y), row["date"], font=body, fill=0)
        draw.text((DESCRIPTION_X, y), row["description"], font=body, fill=0)
        if row["amount"] < 0:
            draw.text((WITHDRAWAL_X, y), money(-row["amount"]), font=body, fill=0, anchor="ra")
        else:
            draw.text((DEPOSIT_X, y), money(row["amount"]), font=body, fill=0, anchor="ra")
        balance += row["amount"]
        draw.text((BALANCE_X, y), money(balance), font=body, fill=0, anchor="ra")
        y += ROW_PITCH
    return image


def degrade(source, width=None, quality=85, shadow=False, tilt=0.0):
    image = source
    if tilt:
        image = image.rotate(-tilt, resample=Image.BICUBIC, fillcolor=255)
    if width and width < image.width:
        image = image.resize((width, int(round(image.height * width / image.width))), Image.LANCZOS)
    if shadow:
        pixels = np.asarray(image).astype(np.float32)
        left, right = int(0.44 * image.width), int(0.90 * image.width)
        ramp = np.clip((np.arange(image.width, dtype=np.float32) - left) / float(max(1, right - left)), 0, 1)
        pixels = pixels * (1.0 - 0.45 * ramp)[None, :]
        image = Image.fromarray(np.clip(pixels, 0, 255).astype(np.uint8))
    scratch = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
    scratch.close()
    image.convert("L").save(scratch.name, format="JPEG", quality=quality)
    reopened = Image.open(scratch.name).convert("L").copy()
    Path(scratch.name).unlink()
    return reopened


CONDITIONS = [
    {"id": "baseline-2550", "width": None, "quality": 85, "shadow": False, "tilt": 0.0},
    {"id": "width-1600", "width": 1600, "quality": 85, "shadow": False, "tilt": 0.0},
    {"id": "width-1080", "width": 1080, "quality": 85, "shadow": False, "tilt": 0.0},
    {"id": "width-720", "width": 720, "quality": 85, "shadow": False, "tilt": 0.0},
    {"id": "width-480", "width": 480, "quality": 85, "shadow": False, "tilt": 0.0},
    {"id": "jpeg-q60", "width": None, "quality": 60, "shadow": False, "tilt": 0.0},
    {"id": "jpeg-q35", "width": None, "quality": 35, "shadow": False, "tilt": 0.0},
    {"id": "shadow", "width": None, "quality": 85, "shadow": True, "tilt": 0.0},
    {"id": "tilt-3", "width": None, "quality": 85, "shadow": False, "tilt": 3.0},
    {"id": "tilt-8", "width": None, "quality": 85, "shadow": False, "tilt": 8.0},
    {"id": "tilt-15", "width": None, "quality": 85, "shadow": False, "tilt": 15.0},
    {"id": "phone-typical", "width": 1600, "quality": 60, "shadow": True, "tilt": 5.0},
]


# ---------------------------------------------------------------- measurement

def scale_of(image):
    return image.width / float(PAGE_W)


def place(box, tilt):
    """A page-space box mapped into the rotated image, as an axis-aligned box."""
    if not tilt:
        return box
    left, top, right, bottom = box
    centre_x, centre_y = PAGE_W / 2.0, PAGE_H / 2.0
    theta = math.radians(tilt)
    cos, sin = math.cos(theta), math.sin(theta)
    xs, ys = [], []
    for x, y in ((left, top), (right, top), (left, bottom), (right, bottom)):
        dx, dy = x - centre_x, y - centre_y
        xs.append(dx * cos - dy * sin + centre_x)
        ys.append(dx * sin + dy * cos + centre_y)
    return (min(xs), min(ys), max(xs), max(ys))


def crop(image, box):
    scale = scale_of(image)
    left, top, right, bottom = [int(round(value * scale)) for value in box]
    left, top = max(0, left), max(0, top)
    right, bottom = min(image.width, right), min(image.height, bottom)
    if right <= left or bottom <= top:
        return np.zeros((0, 0), dtype=np.float32)
    return np.asarray(image.crop((left, top, right, bottom))).astype(np.float32)


def ink_mask(patch):
    """Ink against the paper of its own column, so a shadow gradient does not swamp the test."""
    if patch.size == 0:
        return None
    paper = np.percentile(patch, 90, axis=0)
    return patch < (paper * 0.55)[None, :]


def blob_metrics(patch):
    mask = ink_mask(patch)
    if mask is None or not mask.any():
        return None, None
    height = int(np.flatnonzero(mask.any(axis=1)).size)
    runs = []
    for row in mask:
        run = 0
        for value in row:
            if value:
                run += 1
            elif run:
                runs.append(run)
                run = 0
        if run:
            runs.append(run)
    return height, (float(np.median(runs)) if runs else None)


def ink_contrast(patch):
    if patch.size == 0:
        return None
    return float(np.percentile(patch, 95) - np.percentile(patch, 0.5))


def right_edge(image, box):
    """Absolute x of the rightmost ink in the box: an offset inside the box would move with it."""
    scale = scale_of(image)
    left, top = max(0, int(round(box[0] * scale))), max(0, int(round(box[1] * scale)))
    right, bottom = min(image.width, int(round(box[2] * scale))), min(image.height, int(round(box[3] * scale)))
    if right <= left or bottom <= top:
        return None
    patch = np.asarray(image.crop((left, top, right, bottom))).astype(np.float32)
    mask = ink_mask(patch)
    if mask is None or not mask.any():
        return None
    return left + int(np.flatnonzero(mask.any(axis=0)).max())


def measure(image, tilt):
    scale = scale_of(image)
    digit_y = FIRST_ROW_Y + DIGIT_ROW * ROW_PITCH
    if tilt:
        height = stroke = None  # a rotated glyph's axis-aligned box is not its height
    else:
        height, stroke = blob_metrics(crop(image, (DEPOSIT_X - 420, digit_y - 14, DEPOSIT_X + 8, digit_y + 58)))

    contrast = ink_contrast(crop(image, (DEPOSIT_X - 260, FIRST_ROW_Y - 20,
                                         DEPOSIT_X + 20, FIRST_ROW_Y + ROWS_PER_PAGE * ROW_PITCH)))

    edges = []
    for row in (DRIFT_TOP_ROW, DRIFT_BOTTOM_ROW):
        y = FIRST_ROW_Y + row * ROW_PITCH
        band = place((DEPOSIT_X - 430, y - 12, DEPOSIT_X + 12, y + 58), tilt)
        edges.append(right_edge(image, band))
    drift = abs(edges[0] - edges[1]) if None not in edges else None

    return {
        "digit_height_px": height,
        "stroke_width_px": None if stroke is None else round(stroke, 1),
        "ink_to_paper_grey_levels": None if contrast is None else round(contrast, 1),
        "column_drift_px": drift,
        "drift_percent_of_column_gap": None if drift is None else round(100.0 * drift / (COLUMN_GAP_PX * scale), 1),
    }


PROPERTIES = [
    ("digit_height_px", "height of the ink in one amount cell, in pixels"),
    ("stroke_width_px", "width of a figure's stroke, in pixels"),
    ("ink_to_paper_grey_levels", "grey levels between the paper and the darkest ink under the figures of the deposit column"),
    ("column_drift_px", "sideways movement of the deposit column between the first and the last row of the page, in pixels"),
    ("drift_percent_of_column_gap", "that movement as a percentage of the gap between the withdrawal and deposit columns"),
]


# ---------------------------------------------------------------- the page half

def fetch_text(url):
    request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(request, timeout=45) as response:
        body = response.read().decode("utf-8", "replace")
    body = re.sub(r"(?is)<(script|style|svg|noscript)[^>]*>.*?</\1>", " ", body)
    text = html.unescape(re.sub(r"(?s)<[^>]+>", " ", body))
    return re.sub(r"\s+", " ", text)


DASHES = "‐‑‒–—―−"


def normalise(text):
    for dash in DASHES:
        text = text.replace(dash, "-")
    text = html.unescape(text)
    text = text.replace("‘", "'").replace("’", "'")
    text = text.replace("“", '"').replace("”", '"')
    return re.sub(r"\s+", " ", text).strip()


PAGE_QUOTES = [
    {
        "id": "bankconvert-retention",
        "kind": "page",
        "property": "what bankconvert.com says it does with an uploaded file",
        "value": "states the data is never stored",
        "quote": "Your data is not the product, and it is never stored or sold.",
        "url": "https://bankconvert.com/faq",
    },
    {
        "id": "bankstatemently-integrity",
        "kind": "page",
        "property": "whether bankstatemently.com checks extracted rows against balances",
        "value": "states that scoring includes balance reconciliation",
        "quote": "Scores combine extraction accuracy with integrity checks — balance reconciliation, transaction totals, and direction validation.",
        "url": "https://bankstatemently.com/",
    },
    {
        "id": "bankstatemently-chain",
        "kind": "page",
        "property": "what bankstatemently.com says about chaining statements to balances",
        "value": "states statements are chained balance to balance",
        "quote": "Statements are chained balance to balance, so a missing month shows up as a gap instead of disappearing.",
        "url": "https://bankstatemently.com/",
    },
]


# ---------------------------------------------------------------- build and verify

def build_lab():
    results = []
    for condition in CONDITIONS:
        started = time.time()
        image = degrade(render(), width=condition["width"], quality=condition["quality"],
                        shadow=condition["shadow"], tilt=condition["tilt"])
        measured = measure(image, condition["tilt"])
        for name, label in PROPERTIES:
            results.append({
                "id": "%s/%s" % (condition["id"], name),
                "kind": "lab",
                "condition": condition["id"],
                "property": label,
                "value": measured[name],
                "quote": None,
                "url": None,
                "conditions": {"width_px": condition["width"] or PAGE_W,
                               "jpeg_quality": condition["quality"],
                               "shadow_on_amounts": condition["shadow"],
                               "tilt_degrees": condition["tilt"]},
                "seconds": round(time.time() - started, 2),
            })
    return results


def write_outputs(lab_rows):
    dataset = {
        "method": "lab",
        "slug": "bank-statement-photo",
        "checkedAt": "2026-10-26",
        "tools": {"python": "3.9.6", "pillow": "9.5.0", "numpy": "1.26.4", "font": FONT},
        "inputs": [
            {"what": "generated statement page",
             "detail": "%dx%d px, US Letter at 300 dpi, %d transaction rows, a withdrawal column and "
                       "a deposit column right-aligned at %d px and %d px of the %d px width, a "
                       "running balance column at %d px, figures set at %d px"
                       % (PAGE_W, PAGE_H, ROWS_PER_PAGE, WITHDRAWAL_X, DEPOSIT_X, PAGE_W,
                          BALANCE_X, BODY_PX)},
            {"what": "photo conditions",
             "detail": "downscale to a width with Lanczos resampling, JPEG quality, a linear shadow "
                       "gradient from 0.44 to 0.90 of the page width darkening the amount columns by "
                       "up to 45%, rotation about the page centre without expanding the frame"},
            {"what": "measurement",
             "detail": "digit height and stroke width from the deposit cell of row %d; contrast from "
                       "the 95th and 0.5th percentile of the 280 px under the deposit figures; drift from the right "
                       "edge of the deposit column in rows %d and %d, %d px apart"
                       % (DIGIT_ROW + 1, DRIFT_TOP_ROW + 1, DRIFT_BOTTOM_ROW + 1,
                          (DRIFT_BOTTOM_ROW - DRIFT_TOP_ROW) * ROW_PITCH)},
        ],
        "results": PAGE_QUOTES + lab_rows,
        "notes": [
            "The lab measures the image, not a reader. No OCR engine produced the published figures, "
            "so nothing here states how many rows any reader returned. A Tesseract read measured "
            "while this package was built (5.5.3, --psm 6) still returned most amounts from a 1080 px "
            "page and none under the shadow condition, so it would have described that "
            "engine rather than Evidilo, whose security page sends photographs to a model provider.",
            "The page is generated by this script. It is not one of the 260 statements in the "
            "product's corpus and it is not a bank's statement.",
            "Digit height and stroke width are not measured on a tilted page: the axis-aligned box "
            "of a rotated glyph is not its height, so those cells are null there.",
            "Tilt rotates about the page centre without expanding the canvas, so the corners leave "
            "the frame as they do in a hand-held photograph.",
            "bankstatemently.com/accuracy returns 404; /benchmark was used instead.",
            "Not checked: how many rows Evidilo's reader, or any other, returns from these images. "
            "The reader runs on the server behind an upload, and this check creates no account.",
            "Evidilo's own claims in the post (photo input, QBO output, the balance check, the "
            "encrypted archive, photos sent to a model provider, three free statements a day of up "
            "to 10 pages) were read from the product's source on 2026-10-26, not from the live site.",
            "Not checked: what a messaging app actually resizes a photograph to. The widths 1600, "
            "1080, 720 and 480 px are round values, not read from any app's documentation.",
        ],
    }
    DATASET.write_text(json.dumps(dataset, indent=2, ensure_ascii=False) + "\n")

    by_condition = {}
    for row in lab_rows:
        by_condition.setdefault(row["condition"], {})[row["id"].split("/")[1]] = row["value"]
    with CSV_OUT.open("w", newline="") as handle:
        writer = csv.writer(handle)
        writer.writerow(["condition", "width_px", "jpeg_quality", "shadow_on_amounts", "tilt_degrees",
                         "digit_height_px", "stroke_width_px", "ink_to_paper_grey_levels",
                         "column_drift_px", "drift_percent_of_column_gap"])
        for condition in CONDITIONS:
            values = by_condition[condition["id"]]
            writer.writerow([condition["id"], condition["width"] or PAGE_W, condition["quality"],
                             str(condition["shadow"]).lower(), condition["tilt"],
                             "" if values["digit_height_px"] is None else values["digit_height_px"],
                             "" if values["stroke_width_px"] is None else values["stroke_width_px"],
                             "" if values["ink_to_paper_grey_levels"] is None else values["ink_to_paper_grey_levels"],
                             "" if values["column_drift_px"] is None else values["column_drift_px"],
                             "" if values["drift_percent_of_column_gap"] is None else values["drift_percent_of_column_gap"]])


def verify(recheck):
    if not DATASET.exists():
        sys.exit("dataset.json is missing; run check.py --build first")
    dataset = json.loads(DATASET.read_text())
    failures = []
    checked = 0

    for row in dataset["results"]:
        if row["kind"] != "page":
            continue
        checked += 1
        try:
            page = normalise(fetch_text(row["url"]))
        except Exception as error:  # a page that refuses is not a page that agrees
            failures.append("%s: could not fetch %s (%s)" % (row["id"], row["url"], error))
            continue
        if normalise(row["quote"]) not in page:
            failures.append("%s: quote no longer on %s: %r" % (row["id"], row["url"], row["quote"]))

    lab_rows = [row for row in dataset["results"] if row["kind"] == "lab"]
    slowest = max([row.get("seconds", 0) for row in lab_rows] or [0])
    if recheck and slowest > 60:
        print("skipped: the lab took %.0fs per condition, over a minute" % slowest)
    else:
        fresh = {row["id"]: row["value"] for row in build_lab()}
        for row in lab_rows:
            checked += 1
            if row["id"] not in fresh:
                failures.append("%s: no longer measured" % row["id"])
            elif fresh[row["id"]] != row["value"]:
                failures.append("%s: was %s, is now %s" % (row["id"], row["value"], fresh[row["id"]]))

    print("checked %d rows, %d failures" % (checked, len(failures)))
    for failure in failures:
        print("FAIL " + failure)
    return 0 if not failures else 1


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--build", action="store_true", help="re-measure and rewrite dataset.json and dataset.csv")
    parser.add_argument("--recheck", action="store_true", help="re-read the cited pages, skip a lab step over a minute")
    arguments = parser.parse_args()
    if arguments.build:
        lab = build_lab()
        write_outputs(lab)
        print("wrote %s and %s" % (DATASET.name, CSV_OUT.name))
        return 0
    return verify(arguments.recheck)


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