#!/usr/bin/env python3
"""Re-run the check behind the comparison table in the Viallo post
"Share Full Resolution Photos From iPhone" (/blog/share-full-resolution-photos-iphone).

Two parts:

* primary-docs: every row of dataset.json with a quote is re-read from its
  source, a vendor page fetched over HTTPS or a file of Viallo's code read with
  `git show origin/main:<path>`, and the exact supporting sentence must still be
  there;
* lab: an 8064 x 6048 test image is generated with Pillow, encoded to HEIC with
  macOS `sips`, tagged with exiftool, converted to JPEG with heic-convert at
  quality 0.92 (Viallo's converter and setting), and both files are measured
  with exiftool. Format, pixel dimensions and the four EXIF tags must match the
  dataset. It took 46 s on 26 October 2026, under the one-minute --recheck
  budget, so --recheck runs it too.

It also checks that dataset.csv is the table built from dataset.json.
Exit 0 when everything holds, 1 otherwise.

Usage:
    python3 check.py            # pages, code and lab
    python3 check.py --recheck  # the same; no lab step here takes over a minute

The lab needs macOS (sips), exiftool, node and Pillow, and heic-convert from
`pnpm install` in the Viallo repository or a node_modules directory named in
HEIC_CONVERT_DIR.
"""

import csv
import html
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path

HERE = Path(__file__).resolve().parent
DATASET = HERE / "dataset.json"
TABLE = HERE / "dataset.csv"
USER_AGENT = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/131.0 Safari/537.36"
)
TIMEOUT = 60
LAB_BUDGET_S = 60
TABLE_PROPS = (("What arrives", "what_arrives"), ("Documented limit", "documented_limit"), ("Catch", "catch"))
EXIF_TAGS = ["FileType", "ImageWidth", "ImageHeight", "Make", "Model", "DateTimeOriginal", "GPSLatitude"]

_TAG = re.compile(r"(?is)<(script|style|noscript)[^>]*>.*?</\1>")
_ANY_TAG = re.compile(r"(?s)<[^>]+>")
_ATTR = re.compile(r"""=\s*(?:"([^"]*)"|'([^']*)')""")
_WS = re.compile(r"[\s   ]+")
_SPACE_BEFORE_PUNCT = re.compile(r"\s+([.,;:!?])")
_QUOTES = str.maketrans({
    "‘": "'", "’": "'", "‚": "'", "‛": "'",
    "“": '"', "”": '"', "„": '"', "‟": '"',
    "–": "-", "—": "-", "−": "-", "­": "",
})


def normalise(text):
    text = html.unescape(text)
    text = _TAG.sub(" ", text)
    text = _ANY_TAG.sub(" ", text)
    text = html.unescape(text)
    text = text.translate(_QUOTES)
    text = _WS.sub(" ", text).strip()
    # A sentence split across inline tags comes back as "Share ."; the page reads "Share."
    return _SPACE_BEFORE_PUNCT.sub(r"\1", text)


def normalise_code(text):
    return _WS.sub(" ", text.translate(_QUOTES)).strip()


def attribute_text(body):
    """The Microsoft Store listing carries its description only in a meta tag."""
    values = [m.group(1) if m.group(1) is not None else m.group(2) for m in _ATTR.finditer(body)]
    return normalise(" ".join(v for v in values if v))


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
        body = resp.read()
        return resp.status, body.decode(resp.headers.get_content_charset() or "utf-8", errors="replace")


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


def read_git(path):
    root = repo_root()
    if not root:
        raise RuntimeError("check.py is not inside the Viallo repository")
    for ref in ("origin/main", "HEAD"):
        out = subprocess.run(["git", "-C", root, "show", "%s:%s" % (ref, path)], capture_output=True)
        if out.returncode == 0:
            return out.stdout.decode("utf-8", "replace"), ref
    raise RuntimeError(out.stderr.decode("utf-8", "replace").strip())


def source_text(url):
    """Return (ok, [haystacks], label). Never raises."""
    if url.startswith("git:"):
        try:
            text, ref = read_git(url[len("git:"):])
            return True, [normalise_code(text)], ref
        except Exception as exc:  # noqa: BLE001
            return False, [], "git: %s" % exc
    try:
        status, body = fetch(url)
    except urllib.error.HTTPError as exc:
        return False, [], "HTTP %s" % exc.code
    except Exception as exc:  # noqa: BLE001
        return False, [], "fetch error: %s" % exc
    if status != 200:
        return False, [], "HTTP %s" % status
    return True, [normalise(body), attribute_text(body)], "HTTP 200"


def heic_convert_dir():
    env = os.environ.get("HEIC_CONVERT_DIR")
    candidates = [Path(env)] if env else []
    root = repo_root()
    if root:
        candidates.append(Path(root) / "apps" / "api" / "node_modules")
    for c in candidates:
        if (c / "heic-convert").is_dir():
            return c
    return None


def exif(path):
    out = subprocess.run(["exiftool", "-j"] + ["-" + t for t in EXIF_TAGS] + [str(path)],
                         capture_output=True, check=True)
    row = json.loads(out.stdout)[0]
    return {t: row.get(t) for t in EXIF_TAGS}


MAKE_IMAGE = """
import sys
from PIL import Image
W, H = 8064, 6048
noise = Image.effect_noise((W, H), 24)
radial = Image.radial_gradient('L').resize((W, H))
linear = Image.linear_gradient('L').resize((W, H))
bands = [Image.blend(b, noise, 0.25) for b in (radial, linear, linear.rotate(90).resize((W, H)))]
Image.merge('RGB', bands).save(sys.argv[1])
"""

CONVERT = """
const fs = require('fs');
const path = require('path');
const convert = require(path.join(process.argv[4], 'heic-convert'));
convert({ buffer: fs.readFileSync(process.argv[2]), format: 'JPEG', quality: 0.92 })
  .then((out) => fs.writeFileSync(process.argv[3], Buffer.from(out)))
  .catch((e) => { console.error(e); process.exit(1); });
"""


def run_lab():
    """Return (measured {input, output}, (heic bytes, jpeg bytes), seconds) or raise."""
    for tool in ("sips", "exiftool", "node"):
        if not shutil.which(tool):
            raise RuntimeError("%s not found" % tool)
    modules = heic_convert_dir()
    if not modules:
        raise RuntimeError("heic-convert not found; run pnpm install or set HEIC_CONVERT_DIR")
    start = time.time()
    with tempfile.TemporaryDirectory() as tmp:
        tmp = Path(tmp)
        (tmp / "make.py").write_text(MAKE_IMAGE)
        (tmp / "convert.js").write_text(CONVERT)
        subprocess.run([sys.executable, str(tmp / "make.py"), str(tmp / "src.png")], check=True)
        subprocess.run(["sips", "-s", "format", "heic", str(tmp / "src.png"), "--out", str(tmp / "test.heic")],
                       check=True, capture_output=True)
        subprocess.run(["exiftool", "-q", "-overwrite_original", "-Make=Apple", "-Model=lab test",
                        "-DateTimeOriginal=2026:10:26 12:00:00", "-GPSLatitude=48.1486", "-GPSLatitudeRef=N",
                        "-GPSLongitude=17.1077", "-GPSLongitudeRef=E", str(tmp / "test.heic")], check=True)
        subprocess.run(["node", str(tmp / "convert.js"), str(tmp / "test.heic"), str(tmp / "out.jpg"), str(modules)],
                       check=True)
        measured = {"input": exif(tmp / "test.heic"), "output": exif(tmp / "out.jpg")}
        sizes = ((tmp / "test.heic").stat().st_size, (tmp / "out.jpg").stat().st_size)
    return measured, sizes, time.time() - start


def pivot(results, options):
    rows = []
    for opt in options:
        line = {"option": opt}
        sources = []
        for prop, col in TABLE_PROPS:
            cells = [r for r in results if r["option"] == opt and r["property"] == prop]
            line[col] = " ".join(r["value"] for r in cells)
            for r in cells:
                if r["quote"] and r["url"] not in sources:
                    sources.append(r["url"])
        line["sources"] = " ".join(sources)
        rows.append(line)
    return rows


def main(argv):
    recheck = "--recheck" in argv
    data = json.loads(DATASET.read_text(encoding="utf-8"))
    results = data["results"]
    print("method: %s   checkedAt: %s%s\n" % (
        "+".join(data.get("methods", [data.get("method")])), data.get("checkedAt"), "   (recheck)" if recheck else ""))

    failures = 0
    cache = {}
    lab = None
    for row in results:
        url, quote = row["url"], row["quote"]
        if not quote:
            status, detail = "n/a", "not documented on the page"
        elif url.startswith("lab:"):
            if lab is None:
                try:
                    measured, sizes, seconds = run_lab()
                    lab = (True, measured, sizes, "%.0f s" % seconds)
                    if seconds > LAB_BUDGET_S:
                        print("note: the lab took %.0f s, over the %d s --recheck budget" % (seconds, LAB_BUDGET_S))
                except Exception as exc:  # noqa: BLE001
                    lab = (False, None, None, str(exc))
            ok, measured, sizes, label = lab
            if not ok:
                status, detail = "LAB FAILED", label
            elif row.get("informational"):
                status, detail = "info", "not compared: test.heic %d bytes, out.jpg %d bytes" % sizes
            elif measured == row.get("measure"):
                status, detail = "ok", label
            else:
                status, detail = "MISMATCH", json.dumps(measured)
        else:
            if url not in cache:
                cache[url] = source_text(url)
            ok, haystacks, label = cache[url]
            needle = normalise_code(quote) if url.startswith("git:") else normalise(quote)
            if not ok:
                status, detail = "UNREACHABLE", label
            elif any(needle in hay for hay in haystacks):
                status, detail = "ok", ""
            else:
                status, detail = "MISSING", "quote not found: %s" % quote[:100]
        if status not in ("ok", "n/a", "info"):
            failures += 1
        print("%-12s %-28s %-20s %s" % (status, row["option"][:28], row["property"][:20], detail))

    options = []
    for r in results:
        if r["property"] in dict(TABLE_PROPS) and r["option"] not in options:
            options.append(r["option"])
    with TABLE.open(newline="", encoding="utf-8") as fh:
        table = list(csv.DictReader(fh))
    if table == pivot(results, options):
        print("\ncsv: %d rows, identical to the table built from dataset.json" % len(table))
    else:
        print("\ncsv: MISMATCH against dataset.json")
        failures += 1

    print("%d results, %d failed" % (len(results), failures))
    return 0 if failures == 0 else 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
