#!/usr/bin/env python3
"""Re-run the check behind the post share-multiple-albums-one-link.

Two kinds of row live in dataset.json:

- product-data: counts from Viallo's production database, produced by query.js.
  Production is not reachable from here, so the script checks that every
  published value equals the stored query output, that the output's internal
  sums hold, and that no published cell rests on fewer than 10 owners.
- primary-docs: an exact sentence from a vendor's page, or a line of Viallo's
  own code. The page is fetched again (the file is read from this repository)
  and the sentence must still be there.

It also checks that dataset.csv carries the same values as dataset.json.
Exit 0 when everything holds, 1 otherwise.

Usage:
    python3 check.py            # everything
    python3 check.py --recheck  # the same: there is no lab step to skip

Standard library only.
"""

import csv
import html
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path

HERE = Path(__file__).resolve().parent
DATASET = HERE / "dataset.json"
CSV_FILE = HERE / "dataset.csv"
# apps/web/public/blog/<slug>/check.py -> repository root
REPO = HERE.parents[4]
MIN_OWNERS = 10
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 = 45

_TAG = re.compile(r"(?is)<(script|style|noscript)[^>]*>.*?</\1>")
_ANY_TAG = 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 fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
        body = resp.read()
        charset = resp.headers.get_content_charset() or "utf-8"
        return resp.status, body.decode(charset, errors="replace")


def source_text(url):
    """Return (state, haystack, label); state is ok, skip or fail. Never raises."""
    if url.startswith("git:"):
        path = REPO / url[len("git:"):]
        if not path.is_file():
            return "skip", "", "needs the Viallo repository (%s)" % url[4:]
        return "ok", normalise(path.read_text(encoding="utf-8")), "file"
    try:
        status, body = fetch(url)
    except urllib.error.HTTPError as exc:
        return "fail", "", "HTTP %s" % exc.code
    except Exception as exc:  # noqa: BLE001 - reported, not swallowed
        return "fail", "", "fetch error: %s" % exc
    if status != 200:
        return "fail", "", "HTTP %s" % status
    return "ok", normalise(body), "HTTP 200"


def at(obj, path):
    for key in path.split("."):
        obj = obj[key]
    return obj


def product_checks(out):
    """Internal consistency of the stored query output. Returns a list of failures."""
    bad = []
    total = out["allLinks"]
    one = out["curatedOneAlbum"]
    one_add = out["curatedOneAlbumCanAddPhotos"]
    act = out["activeAllAlbumsLinks"]
    two, few = act["ownerHasTwoOrMoreLiveAlbums"], act["ownerHasOneLiveAlbumOrNone"]
    if not one_add["links"] <= one["links"] or not one_add["owners"] <= one["owners"]:
        bad.append("Can add photos one-album links exceed one-album links")
    if not one["links"] + two["links"] + few["links"] <= total["links"]:
        bad.append("curated one-album plus active all-albums links exceed all links")
    if not few["owners"] + two["owners"] >= act["owners"]:
        bad.append("active all-albums owner split does not cover its owners")
    for name, owners in [("all-albums", out["allAlbumsLinksOwners"]),
                         ("curated", out["curatedMedianAlbums"]["owners"])]:
        if not owners <= total["owners"]:
            bad.append("%s owners exceed all owners" % name)
    for cell in [total, one, one_add, two, few]:
        if cell["owners"] > cell["links"]:
            bad.append("a cell has more owners than links")

    def walk(node, path=""):
        if isinstance(node, dict):
            if "owners" in node and isinstance(node["owners"], int) and node["owners"] < MIN_OWNERS:
                bad.append("cell %s rests on %d owners" % (path, node["owners"]))
            for k, v in node.items():
                walk(v, path + "." + k if path else k)
    walk(out)
    return bad


def main(argv):
    recheck = "--recheck" in argv
    data = json.loads(DATASET.read_text(encoding="utf-8"))
    out = data["productData"]["output"]
    failures = 0
    skipped = 0
    print("methods: %s   checkedAt: %s%s" % (
        ", ".join(data.get("methods", [data.get("method")])), data.get("checkedAt"),
        "   (recheck)" if recheck else ""))

    with CSV_FILE.open(encoding="utf-8", newline="") as f:
        csv_rows = {(r["option"], r["property"]): r["value"] for r in csv.DictReader(f)}

    cache = {}
    for row in data["results"]:
        key = (row["option"], row["property"])
        status, detail = "ok", ""
        if csv_rows.get(key) != row["value"]:
            status, detail = "CSV-MISMATCH", "dataset.csv has %r" % csv_rows.get(key)
        elif row["source"] == "product-data":
            stored = at(out, row["outputPath"])
            if str(stored) != row["value"] and not (
                    isinstance(stored, str) and stored.startswith("fewer than") and row["value"].startswith("fewer than")):
                status, detail = "MISMATCH", "query output has %r" % stored
        elif row["source"] == "primary-docs":
            url = row["url"]
            if url not in cache:
                cache[url] = source_text(url)
            state, haystack, label = cache[url]
            if state == "skip":
                status, detail = "skipped", label
            elif state == "fail":
                status, detail = "UNREACHABLE", label
            elif normalise(row["quote"]) not in haystack:
                status, detail = "MISSING", "quote not found: %s" % row["quote"][:100]
        else:
            status = "not checked"
        if status == "skipped":
            skipped += 1
        elif status not in ("ok", "not checked"):
            failures += 1
        print("%-13s %-44s %-36s %s" % (status, row["option"][:44], row["property"][:36], detail))

    for problem in product_checks(out):
        failures += 1
        print("SUMS          %s" % problem)

    total = len(data["results"])
    print("\n%d rows, %d failed, %d skipped; internal sums %s" % (
        total, failures, skipped, "checked"))
    return 0 if failures == 0 else 1


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