#!/usr/bin/env python3
"""Evidence package for the Phayan post thai-stroke-order.

python3 check.py            re-read the cited pages, re-measure, compare with dataset.json
python3 check.py --recheck   same; skips a lab step that took over a minute when recorded
python3 check.py --write    regenerate dataset.json and dataset.csv from this run

The school model divides a letter's body (baseline to top of the body) into four parts: the
head is one part (25%) and the foot of ฎ ฏ ฐ goes two parts (50%) below the baseline. Each
face's body height is the median height above the baseline across the 42 letters.

PDFs whose text the built-in extractor cannot recover are read with pdftotext when it is
installed, and reported as not checked otherwise.

Exit 0 when every quote is still on its page and every measurement still matches.
"""

import argparse
import csv
import hashlib
import html
import io
import json
import os
import re
import shutil
import statistics
import subprocess
import sys
import tempfile
import time
import unicodedata
import urllib.request
import zlib

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

BASE = os.path.dirname(os.path.abspath(__file__))
DATASET_JSON = os.path.join(BASE, "dataset.json")
DATASET_CSV = os.path.join(BASE, "dataset.csv")
CHECKED_AT = "2026-10-26"
UA = {"User-Agent": "Mozilla/5.0"}

# ---------------------------------------------------------------- the letters

LETTERS_44 = "กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรลวศษสหฬอฮ"
IN_USE = [c for c in LETTERS_44 if c not in "ฃฅ"]
DESCENDER_LETTERS = "ฎฏฐ"

# ---------------------------------------------------------------- the fonts

FONTS = [
    {"face": "Sarabun-Regular", "family": "Sarabun", "weight": "Regular",
     "url": "https://raw.githubusercontent.com/google/fonts/main/ofl/sarabun/Sarabun-Regular.ttf",
     "sha256": "226d4f368fbc0457990ddef2692679badfd2c1a4e89e5ac4d43c10ba7743b2f1"},
    {"face": "Sarabun-Bold", "family": "Sarabun", "weight": "Bold",
     "url": "https://raw.githubusercontent.com/google/fonts/main/ofl/sarabun/Sarabun-Bold.ttf",
     "sha256": "d38308ca27d067b9a1b79006337c1f9a66c29aa016d96a9d88d3801da7fa83b9"},
    {"face": "Niramit-Regular", "family": "Niramit", "weight": "Regular",
     "url": "https://raw.githubusercontent.com/google/fonts/main/ofl/niramit/Niramit-Regular.ttf",
     "sha256": "04148fad64b055f6a062604c983be26409bfd3e4e1390279288a4eb839909ce0"},
    {"face": "Maitree-Regular", "family": "Maitree", "weight": "Regular",
     "url": "https://raw.githubusercontent.com/google/fonts/main/ofl/maitree/Maitree-Regular.ttf",
     "sha256": "0b175f9b468961d37faf21bfbf27207c5aeda9f2b029841bccaf2747c2c269be"},
]

RENDER_PX = 200
BASELINE_PX = 230
SCHOOL_HEAD_PCT = 25.0
SCHOOL_DESCENDER_PCT = 50.0

# ---------------------------------------------------------------- the pages

KRUACHIEVE_MOE = ("https://www.kruachieve.com/wp-content/uploads/2022/12/"
                  "%e0%b8%84%e0%b8%b3%e0%b8%ad%e0%b8%98%e0%b8%b4%e0%b8%9a%e0%b8%b2%e0%b8%a2"
                  "%e0%b9%81%e0%b8%a5%e0%b8%b0%e0%b9%81%e0%b8%9a%e0%b8%9a%e0%b8%ad%e0%b8%b1"
                  "%e0%b8%81%e0%b8%a9%e0%b8%a3%e0%b8%97%e0%b8%b5%e0%b9%88%e0%b9%83%e0%b8%8a"
                  "%e0%b9%89%e0%b8%84%e0%b8%b1%e0%b8%94%e0%b8%82%e0%b8%ad%e0%b8%87%e0%b8%81"
                  "%e0%b8%a3%e0%b8%b0%e0%b8%97%e0%b8%a3%e0%b8%a7%e0%b8%87%e0%b8%a8%e0%b8%b6"
                  "%e0%b8%81%e0%b8%a9%e0%b8%b2%e0%b8%98%e0%b8%b4%e0%b8%81%e0%b8%b2%e0%b8%a3.pdf")

KRUACHIEVE_READWRITE = ("https://www.kruachieve.com/wp-content/uploads/2020/10/"
                        "%E0%B8%84%E0%B8%B9%E0%B9%88%E0%B8%A1%E0%B8%B7%E0%B8%AD%E0%B8%81"
                        "%E0%B8%B2%E0%B8%A3%E0%B8%AA%E0%B8%AD%E0%B8%99%E0%B8%AD%E0%B9%88"
                        "%E0%B8%B2%E0%B8%99%E0%B9%80%E0%B8%82%E0%B8%B5%E0%B8%A2%E0%B8%99"
                        "%E0%B9%82%E0%B8%94%E0%B8%A2%E0%B8%81%E0%B8%B2%E0%B8%A3%E0%B9%81"
                        "%E0%B8%88%E0%B8%81%E0%B8%A5%E0%B8%B9%E0%B8%81%E0%B8%AA%E0%B8%B0"
                        "%E0%B8%81%E0%B8%94%E0%B8%84%E0%B8%B3-4.pdf")

READTHAI = "https://readthai.fun/"
READTHAI_SITEMAP = "https://readthai.fun/sitemap.xml"
THAIPOD101_LESSON = ("https://www.thaipod101.com/lesson/"
                     "thai-alphabet-made-easy-1-maaw-m%C3%A1a-naaw-n%C7%94u-and-long-a")

PAGES = [
    {"id": "moe_four_parts", "kind": "pdf", "url": KRUACHIEVE_MOE,
     "property": "the Thai school letter model divides a consonant into four parts",
     "quote": "สัดส่วนของพยัญชนะ กำหนดเป็น ๔ ส่วน"},
    {"id": "moe_head_one_part", "kind": "pdf", "url": KRUACHIEVE_MOE,
     "property": "the round head measures one of the four parts",
     "quote": "หัวกลมมีขนาด ๑ ส่วน"},
    {"id": "moe_descender_two_parts", "kind": "pdf", "url": KRUACHIEVE_MOE,
     "property": "the lower part of ฎ ฏ ฐ descends two parts below the letter",
     "quote": "ส่วนล่างของ ฎ ฏ ฐ เลยตัวอักษรลงมา ๒ ส่วน"},
    {"id": "moe_primary_school_nationwide", "kind": "pdf", "url": KRUACHIEVE_MOE,
     "property": "the Ministry of Education letter model is the handwriting model for primary schools nationwide",
     "quote": "ทำเป็นแบบฝึกหัดคัดลายมือสำหรับใช้กับนักเรียนโรงเรียนประถมศึกษาทั่วประเทศตั้งแต่ปี พ.ศ. ๒๕๒๐"},
    {"id": "moe_body_before_tail", "kind": "pdf", "url": KRUACHIEVE_READWRITE, "extractor": "pdftotext",
     "property": "the letter body is written first, then the foot, tail or inner line, in one continuous stroke",
     "quote": "การเขียนพยัญชนะไทยต้องเน้นให้นักเรียนเขียนตัวพยัญชนะก่อนแล้วจึงเขียนเชิง หาง หรือไส้ และให้เริ่มเขียนที่ต้นตัวพยัญชนะแล้วลากเส้นติดต่อกันไปจนจบที่ปลายพยัญชนะ"},
    {"id": "moe_head_round", "kind": "pdf", "url": KRUACHIEVE_READWRITE, "extractor": "pdftotext",
     "property": "the head must be written round",
     "quote": "การเขียนหัวพยัญชนะ ต้องเขียนให้กลม"},
    {"id": "readthai_self_description", "kind": "html", "url": READTHAI,
     "property": "ReadThai.Fun describes itself as a tool for reading Thai",
     "quote": "ReadThai.Fun is a free interactive web app for learning to read Thai."},
    {"id": "readthai_typing_not_handwriting", "kind": "html", "url": READTHAI,
     "property": "ReadThai.Fun's feature list offers typing practice, not handwriting practice",
     "quote": "Thai keyboard writing"},
    {"id": "readthai_no_handwriting_page", "kind": "sitemap", "url": READTHAI_SITEMAP,
     "property": "no page in ReadThai.Fun's sitemap is about handwriting or stroke order",
     "quote": "0 of {n} sitemap URLs match handwrit|stroke"},
    {"id": "thaipod101_head_clockwise", "kind": "html", "url": THAIPOD101_LESSON,
     "property": "ThaiPod101's Thai Alphabet Made Easy shows the stroke for each letter",
     "quote": "The head of ม is written clockwise, and then the rest of the letter is written with one continuous line."},
    {"id": "thaipod101_direction_differs", "kind": "html", "url": THAIPOD101_LESSON,
     "property": "stroke direction differs by letter",
     "quote": "Some are written clockwise and some are written counter-clockwise."},
]

PRODUCT_CHECKS = [
    {"claim": "Phayan is a Thai-script learning app that teaches the letters in their real printed forms",
     "how": "git -C ~/Repositories/my/phayan show origin/main:apps/web/src/components/learn/letter/LetterGlyphStatic.tsx",
     "found": "renders each symbol as one filled SVG path from GLYPH_PATHS, described in the file as \"the typeface's outline\"",
     "verdict": "supported"},
    {"claim": "Phayan's letter cards show each letter in its real printed form",
     "how": "git -C ~/Repositories/my/phayan show origin/main:apps/web/src/components/learn/letter/LetterGlyph.tsx",
     "found": "\"The letter, rendered from the typeface's outline in its class colour\" (Z5 comment: the stroke-by-stroke animation was removed because a font outline has no stroke order)",
     "verdict": "supported; the cards do not teach stroke order, and the post does not say they do"},
    {"claim": "free in the first levels / the letter cards live inside the free levels of the learning path",
     "how": "hub/projects/phayan.mjs positioning.pricingFacts (verified live 2026-08-20 against https://api.phayan.com/subscriptions/plans)",
     "found": "free: L0-L4 - the five tones, first words, more vowels, finals, mid class + tone marks",
     "verdict": "supported for the level range; the post's wording \"free in the first levels\" is looser than the recorded L0-L4"},
    {"claim": "Phayan pairs each letter card with the words that use the letter",
     "how": "hub/projects/phayan.mjs positioning.verifiedDifferentiators",
     "found": "\"audio on every word\" and \"letter-by-letter progression from zero\" are the recorded differentiators; the word-per-card pairing is not itself in the record",
     "verdict": "not checked against code in this run"},
]


# ---------------------------------------------------------------- text helpers

def normalise(s):
    return unicodedata.normalize("NFKC", s)


def squash(s):
    return "".join(normalise(s).split())


def html_to_text(raw):
    s = raw.decode("utf-8", "replace")
    s = re.sub(r"(?is)<(script|style).*?</\1>", " ", s)
    s = re.sub(r"(?s)<[^>]+>", " ", s)
    return html.unescape(s)


# ---------------------------------------------------------------- pdf text

_OBJ_RE = re.compile(rb"(?<![0-9])(\d+)\s+0\s+obj")


def _top_objects(data):
    objs = {}
    pos = [(m.start(), m.end(), int(m.group(1))) for m in _OBJ_RE.finditer(data)]
    for i, (_, end, num) in enumerate(pos):
        stop = data.find(b"endobj", end)
        nxt = pos[i + 1][0] if i + 1 < len(pos) else len(data)
        objs.setdefault(num, data[end:stop if stop != -1 and stop < nxt else nxt])
    return objs


def _objstm_objects(data):
    objs = {}
    for m in re.finditer(rb"stream\r?\n", data):
        head = data[max(0, m.start() - 400):m.start()]
        if b"/ObjStm" not in head:
            continue
        end = data.find(b"endstream", m.start())
        if end == -1:
            continue
        try:
            raw = zlib.decompress(data[m.end():end])
            first = int(re.search(rb"/First\s+(\d+)", head).group(1))
            count = int(re.search(rb"/N\s+(\d+)", head).group(1))
            nums = [int(x) for x in raw[:first].split()]
            pairs = list(zip(nums[0::2], nums[1::2]))[:count]
        except Exception:
            continue
        for i, (num, off) in enumerate(pairs):
            start = first + off
            stop = first + pairs[i + 1][1] if i + 1 < len(pairs) else len(raw)
            objs.setdefault(num, raw[start:stop])
    return objs


def _objects(data):
    objs = _top_objects(data)
    for k, v in _objstm_objects(data).items():
        objs.setdefault(k, v)
    return objs


def _stream_of(body):
    m = re.search(rb"stream\r?\n", body)
    if not m:
        return None
    raw = body[m.end():]
    end = raw.rfind(b"endstream")
    if end != -1:
        raw = raw[:end]
    if b"/FlateDecode" in body[:m.start()]:
        try:
            return zlib.decompress(raw)
        except Exception:
            try:
                return zlib.decompressobj().decompress(raw)
            except Exception:
                return None
    return raw


def _parse_cmap(cm):
    table = {}
    for blk in re.findall(rb"beginbfchar(.*?)endbfchar", cm, re.S):
        for a, b in re.findall(rb"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", blk):
            try:
                table[int(a, 16)] = bytes.fromhex(b.decode()).decode("utf-16-be", "ignore")
            except Exception:
                pass
    for blk in re.findall(rb"beginbfrange(.*?)endbfrange", cm, re.S):
        for lo, hi, dst in re.findall(rb"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>", blk):
            lo_i, hi_i = int(lo, 16), int(hi, 16)
            base = bytes.fromhex(dst.decode()).decode("utf-16-be", "ignore")
            if not base:
                continue
            start = ord(base[0])
            for k in range(min(hi_i - lo_i + 1, 65536)):
                table[lo_i + k] = chr(start + k) + base[1:]
    return table


def _font_maps(objs):
    by_obj = {}
    two_byte = {}
    for num, body in objs.items():
        two_byte[num] = b"/Type0" in body
        ref = re.search(rb"/ToUnicode\s+(\d+)\s+0\s+R", body)
        if ref:
            sub = objs.get(int(ref.group(1)))
            stream = _stream_of(sub) if sub else None
            if stream:
                by_obj[num] = _parse_cmap(stream)
    names = {}
    for body in objs.values():
        dicts = re.findall(rb"/Font\s*<<(.*?)>>", body, re.S)
        for ref in re.findall(rb"/Font\s+(\d+)\s+0\s+R", body):
            sub = objs.get(int(ref))
            if sub:
                dicts.append(sub)
        for fd in dicts:
            for nm, ref in re.findall(rb"/([A-Za-z0-9]+)\s+(\d+)\s+0\s+R", fd):
                names.setdefault(nm.decode(), int(ref))
    maps = {nm: by_obj[r] for nm, r in names.items() if r in by_obj}
    widths = {nm: two_byte.get(r, True) for nm, r in names.items() if r in by_obj}
    return maps, widths


_TOKEN = re.compile(rb"/([A-Za-z0-9]+)\s+[-\d.]+\s+Tf|\((?:[^()\\]|\\.)*\)|<([0-9A-Fa-f\s]+)>")


def pdf_to_text(data):
    objs = _objects(data)
    maps, widths = _font_maps(objs)
    out = []
    for body in objs.values():
        stream = _stream_of(body)
        if not stream or (b"Tj" not in stream and b"TJ" not in stream):
            continue
        cmap, two = {}, True
        for tok in _TOKEN.finditer(stream):
            if tok.group(1):
                name = tok.group(1).decode()
                cmap, two = maps.get(name, {}), widths.get(name, True)
                continue
            if tok.group(2):
                hx = re.sub(rb"\s", b"", tok.group(2))
                if len(hx) % 2:
                    hx += b"0"
                try:
                    raw = bytes.fromhex(hx.decode())
                except Exception:
                    continue
            else:
                raw = re.sub(rb"\\([()\\])", rb"\1", tok.group(0)[1:-1])
            if two:
                out.append("".join(cmap.get((raw[i] << 8) | raw[i + 1], "")
                                   for i in range(0, len(raw) - 1, 2)))
            else:
                out.append("".join(cmap.get(b, chr(b)) for b in raw))
    return "".join(out)


# ---------------------------------------------------------------- fetching

def fetch(url, timeout=120):
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read()


def pdftotext(data):
    exe = shutil.which("pdftotext")
    if not exe:
        raise RuntimeError("pdftotext not installed")
    with tempfile.NamedTemporaryFile(suffix=".pdf") as fh:
        fh.write(data)
        fh.flush()
        out = subprocess.run([exe, "-enc", "UTF-8", fh.name, "-"], capture_output=True, check=True)
    return out.stdout.decode("utf-8", "replace")


def page_text(row, cache):
    url = row["url"]
    if url not in cache:
        cache[url] = fetch(url)
    raw = cache[url]
    if row["kind"] == "pdf":
        if row.get("extractor") == "pdftotext":
            return pdftotext(raw)
        return pdf_to_text(raw)
    if row["kind"] == "sitemap":
        return raw.decode("utf-8", "replace")
    return html_to_text(raw)


# ---------------------------------------------------------------- the lab

def _flood(mask, seed):
    cur = seed & mask
    while True:
        nxt = cur.copy()
        nxt[1:, :] |= cur[:-1, :]
        nxt[:-1, :] |= cur[1:, :]
        nxt[:, 1:] |= cur[:, :-1]
        nxt[:, :-1] |= cur[:, 1:]
        nxt &= mask
        if (nxt == cur).all():
            return cur
        cur = nxt


def _holes(ink):
    white = ~ink
    border = np.zeros_like(white)
    border[0, :] = border[-1, :] = border[:, 0] = border[:, -1] = True
    inner = white & ~_flood(white, border)
    found = []
    while inner.any():
        ys, xs = np.where(inner)
        seed = np.zeros_like(inner)
        seed[ys[0], xs[0]] = True
        comp = _flood(inner, seed)
        inner &= ~comp
        cy, cx = np.where(comp)
        found.append({"top": int(cy.min()), "bot": int(cy.max()),
                      "left": int(cx.min()), "right": int(cx.max()),
                      "h": int(cy.max() - cy.min() + 1),
                      "w": int(cx.max() - cx.min() + 1),
                      "area": int(comp.sum())})
    return found


def _ring_runs(ink, hole):
    runs = []
    cx = (hole["left"] + hole["right"]) // 2
    cy = (hole["top"] + hole["bot"]) // 2
    for dx, dy, x, y in ((0, -1, cx, hole["top"] - 1), (0, 1, cx, hole["bot"] + 1),
                         (-1, 0, hole["left"] - 1, cy), (1, 0, hole["right"] + 1, cy)):
        n = 0
        while 0 <= y < ink.shape[0] and 0 <= x < ink.shape[1] and ink[y, x]:
            n += 1
            x += dx
            y += dy
        runs.append(n)
    return runs


def measure_face(font_path, family, weight):
    """Head diameter as a share of the letter's ink height, per in-use consonant.

    The head is the topmost closed white counter inside the glyph. Its outer diameter is
    the counter plus twice the stroke drawn above it, so the metric measures the drawn
    ring and not the hole.
    """
    font = ImageFont.truetype(font_path, RENDER_PX)
    rows = []
    for ch in IN_USE:
        img = Image.new("L", (RENDER_PX * 2, RENDER_PX * 2), 255)
        ImageDraw.Draw(img).text((60, BASELINE_PX), ch, font=font, fill=0, anchor="ls")
        ink = np.array(img) < 128
        ys, _ = np.where(ink)
        top, bot = int(ys.min()), int(ys.max())
        height = bot - top + 1
        row = {"face": family + "-" + weight, "family": family, "weight": weight,
               "letter": ch, "ink_height_px": height,
               "descender_px": max(0, bot - BASELINE_PX),
               "height_above_baseline_px": BASELINE_PX - top,
               "head_counter_px": "", "head_ring_px": "", "head_outer_px": "",
               "head_share_pct": "", "descender_share_of_above": ""}
        if ch in DESCENDER_LETTERS and row["descender_px"]:
            row["descender_share_of_above"] = round(row["descender_px"] / row["height_above_baseline_px"], 3)
        candidates = [h for h in _holes(ink)
                      if h["area"] >= 30 and h["h"] <= 0.25 * height and 0.45 < h["w"] / h["h"] < 2.2]
        if not candidates:
            row["status"] = "no_head"
            rows.append(row)
            continue
        head = min(candidates, key=lambda h: h["top"])
        valid = [r for r in _ring_runs(ink, head) if 0 < r <= head["h"]]
        if len(valid) < 2:
            row["status"] = "unreliable"
            rows.append(row)
            continue
        ring = statistics.median(valid)
        outer_v = head["h"] + 2 * ring
        outer_h = head["w"] + 2 * ring
        if not (0.7 <= outer_v / outer_h <= 1.4):
            row["status"] = "unreliable"
            rows.append(row)
            continue
        outer = (outer_v + outer_h) / 2.0
        row.update(status="ok", head_counter_px=head["h"], head_ring_px=int(ring),
                   head_outer_px=round(outer, 1),
                   head_share_pct=round(100.0 * outer / height, 1))
        rows.append(row)
    return rows


def download_fonts(tmpdir):
    paths = {}
    for f in FONTS:
        dest = os.path.join(tmpdir, f["face"] + ".ttf")
        with open(dest, "wb") as fh:
            fh.write(fetch(f["url"]))
        digest = hashlib.sha256(open(dest, "rb").read()).hexdigest()
        if digest != f["sha256"]:
            raise RuntimeError("font changed upstream: %s sha256 %s, expected %s"
                               % (f["face"], digest, f["sha256"]))
        paths[f["face"]] = dest
    return paths


def add_body_shares(rows):
    body = int(statistics.median(r["height_above_baseline_px"] for r in rows))
    for r in rows:
        r["body_height_px"] = body
        r["head_share_of_body_pct"] = (round(100.0 * r["head_outer_px"] / body, 1)
                                       if r["head_outer_px"] != "" else "")
        r["descender_share_of_body_pct"] = (round(100.0 * r["descender_px"] / body, 1)
                                            if r["letter"] in DESCENDER_LETTERS else "")
    return rows


def run_lab(tmpdir):
    paths = download_fonts(tmpdir)
    rows = []
    for f in FONTS:
        rows.extend(add_body_shares(measure_face(paths[f["face"]], f["family"], f["weight"])))
    return rows


def summarise(rows):
    common = None
    for f in FONTS:
        face = f["family"] + "-" + f["weight"]
        ok = {r["letter"] for r in rows if r["face"] == face and r["status"] == "ok"}
        common = ok if common is None else (common & ok)
    common = sorted(common or [])
    summary = []
    for f in FONTS:
        face = f["family"] + "-" + f["weight"]
        mine = [r for r in rows if r["face"] == face]
        shares = [r["head_share_pct"] for r in mine if r["letter"] in common]
        desc = [r["descender_share_of_above"] for r in mine if r["letter"] in DESCENDER_LETTERS
                and r["descender_share_of_above"] != ""]
        body = [r["head_share_of_body_pct"] for r in mine if r["letter"] in common]
        dbody = [r["descender_share_of_body_pct"] for r in mine if r["letter"] in DESCENDER_LETTERS]
        summary.append({
            "face": face,
            "family": f["family"],
            "weight": f["weight"],
            "letters_measured": len(shares),
            "letters_without_head": " ".join(r["letter"] for r in mine if r["status"] != "ok"),
            "body_height_px": mine[0]["body_height_px"],
            "head_share_of_body_min_pct": min(body),
            "head_share_of_body_median_pct": round(statistics.median(body), 1),
            "head_share_of_body_max_pct": max(body),
            "letters_at_or_below_school_head": sum(1 for v in body if v <= SCHOOL_HEAD_PCT),
            "school_head_pct": SCHOOL_HEAD_PCT,
            "descender_share_of_body_min_pct": min(dbody),
            "descender_share_of_body_max_pct": max(dbody),
            "school_descender_pct": SCHOOL_DESCENDER_PCT,
            "head_share_min_pct": min(shares),
            "head_share_median_pct": round(statistics.median(shares), 1),
            "head_share_max_pct": max(shares),
            "descender_share_min": min(desc) if desc else "",
            "descender_share_max": max(desc) if desc else "",
        })
    return summary, common


# ---------------------------------------------------------------- dataset

def all_faces_row(rows, common):
    body = [r["head_share_of_body_pct"] for r in rows if r["letter"] in common]
    dbody = [r["descender_share_of_body_pct"] for r in rows if r["letter"] in DESCENDER_LETTERS]
    shares = [r["head_share_pct"] for r in rows if r["letter"] in common]
    desc = [r["descender_share_of_above"] for r in rows
            if r["letter"] in DESCENDER_LETTERS and r["descender_share_of_above"] != ""]
    return {
        "face": "All four faces", "family": "", "weight": "",
        "letters_measured": len(body),
        "letters_without_head": " ".join(sorted({r["letter"] for r in rows if r["status"] != "ok"})),
        "body_height_px": "",
        "head_share_of_body_min_pct": min(body),
        "head_share_of_body_median_pct": round(statistics.median(body), 1),
        "head_share_of_body_max_pct": max(body),
        "letters_at_or_below_school_head": sum(1 for v in body if v <= SCHOOL_HEAD_PCT),
        "school_head_pct": SCHOOL_HEAD_PCT,
        "descender_share_of_body_min_pct": min(dbody),
        "descender_share_of_body_max_pct": max(dbody),
        "school_descender_pct": SCHOOL_DESCENDER_PCT,
        "head_share_min_pct": min(shares),
        "head_share_median_pct": round(statistics.median(shares), 1),
        "head_share_max_pct": max(shares),
        "descender_share_min": min(desc),
        "descender_share_max": max(desc),
    }


SUMMARY_FIELDS = ["face", "family", "weight", "letters_measured", "letters_without_head",
                  "body_height_px", "head_share_of_body_min_pct", "head_share_of_body_median_pct",
                  "head_share_of_body_max_pct", "letters_at_or_below_school_head", "school_head_pct",
                  "descender_share_of_body_min_pct", "descender_share_of_body_max_pct",
                  "school_descender_pct", "head_share_min_pct", "head_share_median_pct",
                  "head_share_max_pct"]


def build(tmpdir):
    started = time.time()
    lab_rows = run_lab(tmpdir)
    lab_seconds = round(time.time() - started, 1)
    summary, common = summarise(lab_rows)
    shares = [r["head_share_pct"] for r in lab_rows if r["letter"] in common]
    desc = [r["descender_share_of_above"] for r in lab_rows
            if r["letter"] in DESCENDER_LETTERS and r["descender_share_of_above"] != ""]

    pages = []
    cache = {}
    for row in PAGES:
        entry = {"id": row["id"], "url": row["url"], "property": row["property"],
                 "quote": row["quote"], "kind": row["kind"],
                 "extractor": row.get("extractor", "built-in"), "status": "not checked"}
        if row["kind"] == "sitemap":
            urls = re.findall(r"<loc>([^<]+)</loc>", page_text(row, cache))
            hits = [u for u in urls if re.search(r"handwrit|stroke", u, re.I)]
            entry["quote"] = row["quote"].format(n=len(urls))
            entry["value"] = "0 of %d sitemap URLs match handwrit|stroke" % len(urls)
            entry["status"] = "holds" if not hits else "fails: " + ", ".join(hits)
        else:
            text = page_text(row, cache)
            entry["status"] = "holds" if squash(row["quote"]) in squash(text) else "quote gone"
        pages.append(entry)

    dataset = {
        "method": "lab",
        "checkedAt": CHECKED_AT,
        "lab_seconds": lab_seconds,
        "tools": {"python": sys.version.split()[0], "Pillow": Image.__version__,
                  "numpy": np.__version__,
                  "pdftotext": subprocess.run(["pdftotext", "-v"], capture_output=True, text=True)
                  .stderr.splitlines()[0]},
        "inputs": {
            "fonts": [{"face": f["face"], "family": f["family"], "weight": f["weight"],
                       "url": f["url"], "sha256": f["sha256"]} for f in FONTS],
            "letters": "".join(IN_USE),
            "render": {"size_px": RENDER_PX, "baseline_px": BASELINE_PX,
                       "head_counter_ceiling": "0.25 x ink height",
                       "ring": "median of the four ink runs out of the counter, runs longer than the counter dropped"},
            "pages": [{"id": p["id"], "url": p["url"], "kind": p["kind"]} for p in PAGES],
            "not_checked": [
                {"url": "https://ssrudlp.ssru.ac.th/data-file/teacher_work/file/f9d424fe449d2dc01625ae949412783f.pdf",
                 "why": "teacher-work PDF said to carry the หัวไม่บอด handwriting rubric; on 2026-10-26 the server ended both downloads early (7.4 MB and 6.8 MB, no trailer), so the quote could not be read and the post's assessment sentence was cut"},
                {"url": "https://www.dltv.ac.th/utils/files/download/82248",
                 "why": "22 MB DLTV lesson plan; the หัวไม่บอด rubric is in object streams and was not extracted"},
                {"url": "https://fliphtml5.com/olhid/aqio/",
                 "why": "HTTP 403 Cloudflare challenge, no text served"},
            ],
            "generated": ["dataset.json", "dataset.csv"],
        },
        "results": {
            "page_quotes": pages,
            "lab": lab_rows,
            "lab_summary": summary + [all_faces_row(lab_rows, common)],
            "lab_all": {"letters_compared": len(common),
                        "head_share_min_pct": min(shares),
                        "head_share_max_pct": max(shares),
                        "head_share_median_pct": round(statistics.median(shares), 1),
                        "descender_share_min": min(desc),
                        "descender_share_max": max(desc)},
        },
        "product_checks": PRODUCT_CHECKS,
        "notes": [
            "The measurement is on typefaces, not on handwriting. It can show how far a printed "
            "letter sits from the school model; it cannot show what students are taught.",
            "Letters compared across all four faces: " + " ".join(common) + ".",
            "Measured against ink height, %d of the %d letter-face pairs fall in the 20-25%% "
            "band, %d in 30-35%%." % (
                sum(1 for r in lab_rows if r["letter"] in common and r["head_share_pct"] != ""
                    and 20 <= r["head_share_pct"] <= 25),
                len(shares),
                sum(1 for r in lab_rows if r["letter"] in common and r["head_share_pct"] != ""
                    and 30 < r["head_share_pct"] <= 35)),
            "Every letter at or below 25% of ink height carries a descender or a tall ascender, "
            "so its ink height is longer than its body. The head_share_of_body columns divide by "
            "the face's body height instead, which is what the school model's four parts measure.",
            "The ring (stroke) estimate over-reads in heavy weights, which inflates Sarabun Bold.",
            "%d of the %d letter-face pairs have a head of at least 30%% of body height; none is "
            "at or below the school model's 25%%." % (
                sum(1 for r in lab_rows if r["letter"] in common and r["head_share_of_body_pct"] >= 30),
                len(shares)),
            "The two highest values (%s) are outliers well above every face's median; the "
            "medians are the figures to quote." % ", ".join(
                "%s %s %.1f%%" % (r["face"], r["letter"], r["head_share_of_body_pct"])
                for r in sorted((r for r in lab_rows if r["letter"] in common),
                                key=lambda r: -r["head_share_of_body_pct"])[:2]),
            "Kruachieve reproduces the Ministry of Education letter model "
            "(แบบตัวอักษรของกระทรวงศึกษาธิการ); it is a teacher site, not a ministry URL, and no "
            "government copy of the same four-part rule was reachable.",
            "No page stating the หัวไม่บอด assessment criterion could be read on 2026-10-26 "
            "(see inputs.not_checked), so the post no longer makes that claim.",
            "Prompt and Kanit from the same font repository render Thai as .notdef boxes in "
            "Pillow and were dropped.",
            "Variable-font builds were not used; Pillow loads the default instance, and the Noto "
            "Sans Thai variable file rendered no Thai ink at all.",
        ],
    }
    return dataset


def write(dataset):
    with open(DATASET_JSON, "w", encoding="utf-8") as fh:
        json.dump(dataset, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    with open(DATASET_CSV, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=SUMMARY_FIELDS)
        w.writeheader()
        for row in dataset["results"]["lab_summary"]:
            w.writerow({k: row.get(k, "") for k in SUMMARY_FIELDS})


# ---------------------------------------------------------------- verifying

def verify(recheck):
    with open(DATASET_JSON, encoding="utf-8") as fh:
        stored = json.load(fh)
    problems = []
    checked = []

    cache = {}
    for row in PAGES:
        if row.get("extractor") == "pdftotext" and not shutil.which("pdftotext"):
            problems.append("%s: pdftotext not installed, quote not checked" % row["id"])
            checked.append("%-32s not checked (needs pdftotext)" % row["id"])
            continue
        try:
            if row["kind"] == "sitemap":
                urls = re.findall(r"<loc>([^<]+)</loc>", page_text(row, cache))
                hits = [u for u in urls if re.search(r"handwrit|stroke", u, re.I)]
                if hits:
                    problems.append("%s: handwriting page appeared in the sitemap: %s"
                                    % (row["id"], ", ".join(hits)))
                    checked.append("%-32s FAIL" % row["id"])
                else:
                    checked.append("%-32s holds (%d URLs, 0 handwriting)" % (row["id"], len(urls)))
            else:
                text = squash(page_text(row, cache))
                if squash(row["quote"]) in text:
                    checked.append("%-32s holds" % row["id"])
                else:
                    problems.append("%s: quote no longer on %s" % (row["id"], row["url"]))
                    checked.append("%-32s FAIL" % row["id"])
        except Exception as exc:
            problems.append("%s: fetch failed (%s: %s)" % (row["id"], type(exc).__name__, exc))
            checked.append("%-32s not checked" % row["id"])

    if recheck and stored.get("lab_seconds", 0) > 60:
        checked.append("lab                              skipped in --recheck (last run %.0fs)"
                       % stored["lab_seconds"])
    else:
        started = time.time()
        tmpdir = tempfile.mkdtemp(prefix="phayan-stroke-order-")
        try:
            fresh = run_lab(tmpdir)
        except Exception as exc:
            problems.append("lab: %s: %s" % (type(exc).__name__, exc))
            fresh = []
        finally:
            for name in os.listdir(tmpdir):
                os.unlink(os.path.join(tmpdir, name))
            os.rmdir(tmpdir)
        if fresh:
            want = {(r["face"], r["letter"]): r for r in stored["results"]["lab"]}
            drift = 0
            for r in fresh:
                old = want.get((r["face"], r["letter"]))
                if old is None:
                    problems.append("lab: %s %s not in dataset" % (r["face"], r["letter"]))
                    drift += 1
                    continue
                for key in ("status", "head_share_pct", "head_outer_px", "ink_height_px",
                            "descender_share_of_above", "body_height_px", "head_share_of_body_pct",
                            "descender_share_of_body_pct"):
                    if old.get(key, "") != r.get(key, ""):
                        problems.append("lab: %s %s %s %r now %r"
                                        % (r["face"], r["letter"], key, old.get(key), r.get(key)))
                        drift += 1
            checked.append("lab                              %d rows re-measured, %d drifted, %.0fs"
                           % (len(fresh), drift, time.time() - started))

    print("checked %s" % CHECKED_AT)
    for line in checked:
        print("  " + line)
    if problems:
        print("\nFAIL")
        for p in problems:
            print("  " + p)
        return 1
    print("\nOK: every page quote holds and every measurement reproduces.")
    return 0


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--write", action="store_true", help="regenerate dataset.json and dataset.csv")
    ap.add_argument("--recheck", action="store_true", help="skip a lab step that took over a minute")
    args = ap.parse_args()
    if args.write:
        tmpdir = tempfile.mkdtemp(prefix="phayan-stroke-order-")
        try:
            dataset = build(tmpdir)
        finally:
            for name in os.listdir(tmpdir):
                os.unlink(os.path.join(tmpdir, name))
            os.rmdir(tmpdir)
        write(dataset)
        print("wrote %s and %s" % (DATASET_JSON, DATASET_CSV))
        return 0
    return verify(args.recheck)


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