#!/usr/bin/env python3
"""Re-run the evidence check behind the post "The best way to organize photos".

Rows whose source is "product-data" cannot reach the production database from here: they
are checked against the query output stored in dataset.json, so every published count,
share and sum has to agree with it. Rows whose source is "web" are re-checked by fetching
the cited page and looking for the recorded quote. Rows whose source is "code" are read
out of the Viallo repository at origin/main, and skipped outside a checkout. Rows whose
source is "lab" rebuild their input with Pillow and re-measure it.

Exit 0 when every row still holds, 1 otherwise.

    python3 check.py            # everything
    python3 check.py --recheck  # re-read the pages; skips a lab step flagged slow
"""

import argparse
import html
import json
import re
import subprocess
import sys
import time
import unicodedata
import urllib.error
import urllib.request
from pathlib import Path

HERE = Path(__file__).resolve().parent
DATASET = HERE / "dataset.json"
UA = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
)
TIMEOUT = 150


def normalise(text):
    """Collapse what a page and a quote spell differently, not what they say."""
    text = html.unescape(text)
    for src, dst in (
        ("\u2018", "'"),
        ("\u2019", "'"),
        ("\u201a", "'"),
        ("\u201c", '"'),
        ("\u201d", '"'),
        ("\u2013", "-"),
        ("\u2014", "-"),
        ("\u2212", "-"),
        ("\u00a0", " "),
        ("\u2009", " "),
        ("\u202f", " "),
        ("\u2026", "..."),
        ("\u00ad", ""),
    ):
        text = text.replace(src, dst)
    text = unicodedata.normalize("NFKC", text)
    return re.sub(r"\s+", " ", text).strip()


def page_text(raw):
    text = re.sub(r"(?is)<(script|style)[^>]*>.*?</\1>", " ", raw)
    text = re.sub(r"(?s)<[^>]+>", " ", text)
    return normalise(text)


def fetch(url):
    request = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
        raw = response.read()
        charset = response.headers.get_content_charset() or "utf-8"
    return page_text(raw.decode(charset, "ignore"))


def repo_root():
    result = subprocess.run(
        ["git", "-C", str(HERE), "rev-parse", "--show-toplevel"],
        capture_output=True,
        text=True,
        check=False,
    )
    return result.stdout.strip() if result.returncode == 0 else None


def repo_file(root, path):
    result = subprocess.run(
        ["git", "-C", root, "show", "origin/main:" + path],
        capture_output=True,
        text=True,
        check=False,
    )
    if result.returncode != 0:
        raise RuntimeError(result.stderr.strip() or "git show failed")
    return normalise(result.stdout)


def lab_resize_2048():
    """A 12 MP phone-sized photo reduced to the long edge Apple used for shared albums."""
    from PIL import Image, ImageDraw

    width, height = 4032, 3024
    image = Image.new("RGB", (width, height))
    draw = ImageDraw.Draw(image)
    for y in range(0, height, 8):
        shade = (y * 255) // height
        draw.line([(0, y), (width, y)], fill=(shade, shade // 2, 255 - shade))
    for x in range(0, width, 97):
        draw.line([(x, 0), (x, height)], fill=(255, 255, 255))

    long_edge = 2048
    out_w = long_edge
    out_h = round(height * out_w / width)
    image.resize((out_w, out_h), Image.LANCZOS)

    original_px = width * height
    kept_px = out_w * out_h
    return {
        "input_px": original_px,
        "input_size": f"{width}x{height}",
        "output_size": f"{out_w}x{out_h}",
        "output_px": kept_px,
        "kept_pct": round(kept_px / original_px * 100, 2),
    }


LAB_STEPS = {"resize_2048": lab_resize_2048}


def pct(n, d):
    return f"{n / d * 100:.1f}%"


def lookup(window, dotted):
    value = window
    for part in dotted.split("."):
        value = value[part]
    return value


def check_window(window):
    """The stored query output has to be internally consistent before any row can quote it."""
    buckets = window["accountsByAlbumsInWindow"]
    tiers = window["accountsByCurrentTier"]
    counts = list(buckets.values()) + list(tiers.values())
    if any(not isinstance(n, int) or n < 10 for n in counts):
        return False
    return (
        sum(buckets.values()) == window["accounts"]
        and sum(tiers.values()) == window["accounts"]
        and window["accounts"] - buckets["0"] == window["accountsWithAtLeastOneAlbum"]
        and window["albumsWithPhotoCount"] == window["albumsInWindow"]
        and window["emptyAlbums"] <= window["albumsInWindow"]
    )


def check_product_row(row, dataset):
    window = next(w for w in dataset["productData"]["windows"] if w["windowDays"] == row["window"])
    if not check_window(window):
        return False
    if "field" in row:
        return row["value"] == str(window[row["field"]])
    if "bucket" in row:
        n = window["accountsByAlbumsInWindow"][row["bucket"]]
        return row["value"] == f"{n} ({pct(n, window['accounts'])})"
    if "ratio" in row:
        n, d = (lookup(window, key) for key in row["ratio"])
        return row["value"] == f"{n} of {d} ({pct(n, d)})"
    raise ValueError("product-data row without a field, bucket or ratio: " + row["id"])


def check_row(row, cache, recheck, dataset):
    source = row["source"]
    if source == "product-data":
        return check_product_row(row, dataset)
    if source == "web":
        url = row["url"]
        if url not in cache:
            cache[url] = fetch(url)
        return normalise(row["quote"]) in cache[url]

    if source == "code":
        if "root" not in cache:
            cache["root"] = repo_root()
        if cache["root"] is None:
            return None
        key = "code:" + row["path"]
        if key not in cache:
            cache[key] = repo_file(cache["root"], row["path"])
        text = cache[key]
        if "count_regex" in row:
            return len(re.findall(row["count_regex"], text)) == row["count"]
        return normalise(row["quote"]) in text

    if source == "lab":
        if recheck and row.get("slow"):
            return None
        key = "lab:" + row["lab"]
        if key not in cache:
            cache[key] = LAB_STEPS[row["lab"]]()
        measured = cache[key]
        return all(measured[k] == v for k, v in row["expect"].items())

    raise ValueError("unknown source: " + source)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--recheck", action="store_true")
    args = parser.parse_args()

    dataset = json.loads(DATASET.read_text(encoding="utf-8"))
    rows = dataset["results"]
    cache = {}
    failures = []
    skipped = []

    for row in rows:
        started = time.monotonic()
        try:
            ok = check_row(row, cache, args.recheck, dataset)
        except (urllib.error.URLError, urllib.error.HTTPError, OSError, RuntimeError) as error:
            failures.append((row["id"], "could not check: " + str(error)))
            continue
        elapsed = time.monotonic() - started
        if ok is None:
            skipped.append(row["id"])
            reason = "outside a Viallo checkout" if row["source"] == "code" else "lab step flagged slow, --recheck"
            print(f"SKIP {row['id']} ({reason})")
        elif ok:
            print(f"ok   {row['id']}  {elapsed:.1f}s")
        else:
            failures.append((row["id"], row.get("quote") or row.get("value") or ""))
            print(f"FAIL {row['id']}  {elapsed:.1f}s")

    print()
    print(f"{len(rows) - len(failures) - len(skipped)} of {len(rows)} rows hold"
          f" ({len(skipped)} skipped, {len(failures)} failed)")
    for row_id, detail in failures:
        print(f"  {row_id}: {detail[:200]}")
    return 0 if not failures else 1


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