#!/usr/bin/env python3
"""Evidence check for the Phayan post `thai-vowels-32-forms`.

1. Corpus. The 32 vowels are the traditional list as English Wikipedia's
   Thai script vowel table sets it out: 9 short-long pairs, 3 diphthong pairs,
   and the extra vowels -ำ ใ- ไ- เ-า ฤ ฤๅ ฦ ฦๅ. Each vowel is counted in the
   Thai National Corpus word list as PyThaiNLP packages it (tnc_freq.txt,
   fetched and identified by sha256), under its open spelling and under every
   closed-syllable spelling the same table lists (กัน for short a, เป็น for
   short e, เกิด for long oe, ขวด for ua, ...). Per vowel: the frequency rank
   of the first word that spells it, how many of the 1,000 most frequent words
   spell it, and the share of those 1,000 words' token count they hold.

2. Parser. The same scanner, run with the Phayan seed's own mark-level symbol
   set, is compared with the per-word labels in the seed lexicon, so the
   spelling parser is validated against labels nobody wrote for this check.

3. Pages and product facts. Every third-party sentence the post relies on is
   fetched from its page and matched against the recorded quote; every Phayan
   fact is matched against the file or page that defines it.

    python3 check.py            measure everything, compare with dataset.json/csv
    python3 check.py --recheck  the same; no step takes a minute, so none is skipped
    python3 check.py --write    regenerate dataset.json and dataset.csv

Exit 0 when every value and quote holds, 1 otherwise. Standard library only.
"""

import argparse
import csv
import hashlib
import html
import json
import os
import re
import sys
import urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", "..", "..", "..", ".."))
DATASET_JSON = os.path.join(HERE, "dataset.json")
DATASET_CSV = os.path.join(HERE, "dataset.csv")

CHECKED_AT = "2026-10-26"
TNC_URL = (
    "https://raw.githubusercontent.com/PyThaiNLP/pythainlp/dev/"
    "pythainlp/corpus/tnc_freq.txt"
)
TNC_SHA = "554984d62e8735e2705bb30b06096d5710d0c9011e340ab717134e10d6dd0dda"
WIKI_URL = "https://en.wikipedia.org/wiki/Thai_script"
VOWEL_SEED = "apps/api/src/content/seed/vowel-forms.ts"
LEXICON = "apps/api/src/content/seed/kaikki-enriched.jsonl"
QA_SPEC = "apps/api/src/content/seed/curriculum-qa.spec.ts"
LEVELS = "apps/api/src/content/seed/levels.ts"
ACCESS = "packages/shared/src/learn-access.ts"
TOP_N = 1000
UA = "Mozilla/5.0"

# --------------------------------------------------------------- Thai spelling

C = "[ก-ฮ]"
# The onset a split vowel wraps: one consonant, a written cluster, a leading
# ห or อ. Without the clusters เวลา would read as เ + วล + า, and without the
# leading ห เหงา would read as เ- plus -า instead of เ-า.
ONSET = "(?:[กขคตปพผบดฟสจทศซ][รลว]|ห[งญนมยรลว]|อย|[ก-ฮ])"
DEPENDENT = "[ะ-ฺ็-์]"
AFTER_AW = "[ะ-ฺเ-ๅ็-์]"
TONE_MARKS = re.compile("[่-๋]")

# (vowel, ipa, length, group, [(spelling, regex), ...]) in the order of the
# Wikipedia table. A regex with "O" in it has the onset slot substituted.
VOWELS = [
    ("-ะ", "/a/", "short", "pair", [
        ("◌ะ", "(?<=C)ะ"),
        ("◌ั◌", "(?<=C)ั"),
    ]),
    ("-า", "/aː/", "long", "pair", [("◌า", "(?<=C)า")]),
    ("-ิ", "/i/", "short", "pair", [("◌ิ", "(?<=C)ิ")]),
    ("-ี", "/iː/", "long", "pair", [("◌ี", "(?<=C)ี")]),
    ("-ึ", "/ɯ/", "short", "pair", [("◌ึ", "(?<=C)ึ")]),
    ("-ือ", "/ɯː/", "long", "pair", [
        ("◌ือ", "(?<=C)ือ"),
        ("◌ื◌", "(?<=C)ื(?!อ)"),
    ]),
    ("-ุ", "/u/", "short", "pair", [("◌ุ", "(?<=C)ุ")]),
    ("-ู", "/uː/", "long", "pair", [("◌ู", "(?<=C)ู")]),
    ("เ-ะ", "/e/", "short", "pair", [
        ("เ◌ะ", "เOะ"),
        ("เ◌็◌", "เO็"),
    ]),
    ("เ-", "/eː/", "long", "pair", [("เ◌", "เO")]),
    ("แ-ะ", "/ɛ/", "short", "pair", [
        ("แ◌ะ", "แOะ"),
        ("แ◌็◌", "แO็"),
    ]),
    ("แ-", "/ɛː/", "long", "pair", [("แ◌", "แO")]),
    ("โ-ะ", "/o/", "short", "pair", [("โ◌ะ", "โOะ")]),
    ("โ-", "/oː/", "long", "pair", [("โ◌", "โO")]),
    ("เ-าะ", "/ɔ/", "short", "pair", [
        ("เ◌าะ", "เOาะ"),
        ("◌็อ◌", "(?<=C)็อ"),
    ]),
    ("-อ", "/ɔː/", "long", "pair", [
        ("◌อ", "(?<=C)อ(?!AFTER_AW)"),
        ("◌็", "(?<=C)็(?!อ)"),
    ]),
    ("เ-อะ", "/ɤ/", "short", "pair", [("เ◌อะ", "เOอะ")]),
    ("เ-อ", "/ɤː/", "long", "pair", [
        ("เ◌อ", "เOอ(?!ะ)"),
        ("เ◌ิ◌", "เOิ"),
        ("เ◌ย", "เOย(?!DEPENDENT)"),
    ]),
    ("เ-ียะ", "/iaʔ/", "short", "diphthong", [("เ◌ียะ", "เOียะ")]),
    ("เ-ีย", "/ia/", "long", "diphthong", [("เ◌ีย", "เOีย(?!ะ)")]),
    ("เ-ือะ", "/ɯaʔ/", "short", "diphthong", [("เ◌ือะ", "เOือะ")]),
    ("เ-ือ", "/ɯa/", "long", "diphthong", [("เ◌ือ", "เOือ(?!ะ)")]),
    ("-ัวะ", "/uaʔ/", "short", "diphthong", [("◌ัวะ", "(?<=C)ัวะ")]),
    ("-ัว", "/ua/", "long", "diphthong", [
        ("◌ัว", "(?<=C)ัว(?!ะ)"),
        ("◌ว◌", "(?<!ีย)(?<=C)ว(?=C(?!DEPENDENT))"),
    ]),
    ("-ำ", "/am/", "short", "extra", [("◌ำ", "(?<=C)ำ")]),
    ("ใ-", "/aj/", "short", "extra", [("ใ◌", "ใO")]),
    ("ไ-", "/aj/", "short", "extra", [("ไ◌", "ไO")]),
    ("เ-า", "/aw/", "short", "extra", [("เ◌า", "เOา(?!ะ)")]),
    ("ฤ", "/rɯ/", "short", "extra", [("ฤ", "ฤ(?!ๅ)")]),
    ("ฤๅ", "/rɯː/", "long", "extra", [("ฤๅ", "ฤๅ")]),
    ("ฦ", "/lɯ/", "short", "extra", [("ฦ", "ฦ(?!ๅ)")]),
    ("ฦๅ", "/lɯː/", "long", "extra", [("ฦๅ", "ฦๅ")]),
]

# Spellings counted as closed-syllable variants, each as it appears on the
# Wikipedia table; check.py fails if one of them leaves the page.
CLOSED_SPELLINGS = ["◌ั◌", "◌ื◌", "เ◌็◌", "แ◌็◌", "◌็อ◌", "เ◌ิ◌", "◌ว◌", "เ◌ย"]


def expand(regex):
    return (
        regex.replace("AFTER_AW", AFTER_AW)
        .replace("DEPENDENT", DEPENDENT)
        .replace("(?<=C)", "(?<=%s)" % C)
        .replace("(?=C", "(?=%s" % C)
        .replace("O", ONSET)
    )


class Scanner:
    """Leftmost scan; at one position the longest spelling wins, so เ-าะ beats เ-า beats เ-."""

    def __init__(self, entries):
        # entries: (label, spelling, regex) where label is what a match counts as
        ordered = sorted(entries, key=lambda e: (-len(e[1].replace("◌", "-")), e[1]))
        self.names = {}
        parts = []
        for i, (label, spelling, regex) in enumerate(ordered):
            self.names["g%d" % i] = (label, spelling)
            parts.append("(?P<g%d>%s)" % (i, regex))
        self.regex = re.compile("|".join(parts))

    def scan(self, word):
        return [self.names[m.lastgroup] for m in self.regex.finditer(TONE_MARKS.sub("", word))]

    def labels(self, word):
        return {label for label, _ in self.scan(word)}


def vowel_scanner():
    return Scanner(
        [(v[0], spelling, expand(regex)) for v in VOWELS for spelling, regex in v[4]]
    )


def seed_symbol_regex(symbol):
    if symbol == "-อ":
        return "(?<=%s)อ(?!%s)" % (C, AFTER_AW)
    if symbol.startswith("-"):
        return "(?<=%s)%s" % (C, "".join(re.escape(c) for c in symbol[1:]))
    return "".join(ONSET if c == "-" else re.escape(c) for c in symbol)


# ------------------------------------------------------------------- plumbing


def repo_text(rel):
    with open(os.path.join(REPO_ROOT, rel), encoding="utf-8") as fh:
        return fh.read()


def fetch_bytes(url, timeout=60):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read()


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


def normalise(text):
    text = html.unescape(text)
    text = text.replace("‘", "'").replace("’", "'")
    text = text.replace("“", '"').replace("”", '"')
    text = text.replace("\u2013", "-").replace("\u2014", "-")
    return re.sub(r"\s+", " ", text).strip().lower()


def normalise_code(text):
    lines = [re.sub(r"^\s*//\s?", "", line) for line in text.splitlines()]
    return normalise(" ".join(lines))


# --------------------------------------------------------------------- corpus


def load_frequency():
    blob = fetch_bytes(TNC_URL)
    digest = hashlib.sha256(blob).hexdigest()
    rows = []
    for line in blob.decode("utf-8").splitlines():
        parts = line.split("\t")
        if len(parts) == 2 and parts[1].strip().isdigit():
            rows.append((parts[0], int(parts[1])))
    return digest, rows


def measure_corpus(rows):
    scanner = vowel_scanner()
    first = {}
    for rank, (word, _count) in enumerate(rows, 1):
        for label in scanner.labels(word):
            first.setdefault(label, (rank, word))

    top = rows[:TOP_N]
    top_total = sum(count for _w, count in top)
    stats = {}
    closed_words = {}
    for word, count in top:
        found = scanner.scan(word)
        for label in {l for l, _ in found}:
            entry = stats.setdefault(label, {"words": 0, "count": 0, "examples": []})
            entry["words"] += 1
            entry["count"] += count
            if len(entry["examples"]) < 5:
                entry["examples"].append(word)
        for label, spelling in found:
            open_spelling = next(v[4][0][0] for v in VOWELS if v[0] == label)
            if spelling != open_spelling:
                bucket = closed_words.setdefault(spelling, [])
                if word not in bucket:
                    bucket.append(word)

    table = []
    for vowel, ipa, length, group, spellings in VOWELS:
        s = stats.get(vowel, {"words": 0, "count": 0, "examples": []})
        rank, word = first.get(vowel, (None, ""))
        table.append(
            {
                "vowel": vowel,
                "ipa": ipa,
                "length": length,
                "group": group,
                "spellings_counted": " ".join(sp for sp, _ in spellings),
                "first_rank": rank,
                "first_word": word,
                "top1000_words": s["words"],
                "top1000_token_share_pct": round(100 * s["count"] / top_total, 2),
                "example_words": " ".join(s["examples"]),
                "source_url": TNC_URL,
            }
        )
    return table, top_total, closed_words


def validation_result():
    """The same scanner on the seed's mark-level symbols, against the seed's own labels."""
    seed = repo_text(VOWEL_SEED)
    symbols = re.findall(r"^\s*form\('([^']*)'", seed, re.M)
    scanner = Scanner([(s, s, seed_symbol_regex(s)) for s in symbols])
    known = set(symbols)
    agree = 0
    disagreements = []
    rows = [json.loads(l) for l in repo_text(LEXICON).splitlines() if l.strip()]
    for row in rows:
        labelled = set(row.get("decodableFrom") or []) & known
        found = scanner.labels(row["word"])
        if labelled == found:
            agree += 1
        else:
            disagreements.append(
                {"word": row["word"], "found": sorted(found), "seed_labels": sorted(labelled)}
            )
    closed = [d for d in disagreements if not d["found"] and d["seed_labels"]]
    return {
        "seed_symbols": len(symbols),
        "words": len(rows),
        "agree": agree,
        "agree_pct": round(100 * agree / len(rows), 2),
        "disagreements": len(disagreements),
        "seed_labels_a_mark_the_spelling_drops": len(closed),
        "disagreement_rows": disagreements,
    }


# ---------------------------------------------------------------------- pages

PAGE_QUOTES = [
    {
        "id": "wiki-16-symbols-32-forms",
        "party": "Wikipedia",
        "claim": "The Thai script has 16 vowel symbols that combine into at least 32 vowel forms.",
        "url": WIKI_URL,
        "quote": "that combine into at least 32 vowel forms",
    },
    {
        "id": "wiki-not-among-32",
        "party": "Wikipedia",
        "claim": "Vowel plus final ว or ย combinations (เ-ียว, เ-ย, ...) are not among the 32.",
        "url": WIKI_URL,
        "quote": "and are not counted among the thirty-two vowels.",
    },
    {
        "id": "wiki-extra-vowels",
        "party": "Wikipedia",
        "claim": "The extra vowels are vowel-consonant combinations and some sources leave them out.",
        "url": WIKI_URL,
        "quote": "Extra vowels are not distinct vowel sounds, but are symbols that represent "
                 "certain vowel-consonant combinations. They are traditionally regarded as "
                 "vowels, although some sources do not.",
    },
    {
        "id": "wiki-rue-obsolete-lue",
        "party": "Wikipedia",
        "claim": "ฤ ฤๅ are usually considered vowels; ฦ ฦๅ are obsolete.",
        "url": WIKI_URL,
        "quote": "which are obsolete) are usually considered as vowels",
    },
    {
        "id": "wiki-roek",
        "party": "Wikipedia",
        "claim": "ฤกษ์ is the one word where ฤ reads like เรอ.",
        "url": WIKI_URL,
        "quote": "is a unique case where",
    },
    {
        "id": "rtf-32-vowels",
        "party": "ReadThai.Fun",
        "claim": "ReadThai.Fun teaches 44 consonants and 32 vowels with spaced repetition.",
        "url": "https://readthai.fun/",
        "quote": "Master all 44 Thai consonants and 32 vowels through bite-sized lessons, "
                 "native audio pronunciation, and spaced repetition.",
    },
    {
        "id": "rtf-frequency-order",
        "party": "ReadThai.Fun",
        "claim": "ReadThai.Fun orders characters by real-world frequency.",
        "url": "https://readthai.fun/",
        "quote": "Characters ordered by real-world frequency.",
    },
    {
        "id": "rtf-review-intervals",
        "party": "ReadThai.Fun",
        "claim": "ReadThai.Fun schedules reviews at 1, 3, 7 and 21 days.",
        "url": "https://readthai.fun/",
        "quote": "Smart review scheduling at 1, 3, 7, and 21 day intervals",
    },
    {
        "id": "rtf-free-platform",
        "party": "ReadThai.Fun",
        "claim": "ReadThai.Fun describes itself as a free interactive learning platform.",
        "url": "https://readthai.fun/",
        "quote": "ReadThai.Fun is a free interactive Thai language learning platform.",
    },
    {
        "id": "rtf-vowel-page-placement",
        "party": "ReadThai.Fun",
        "claim": "ReadThai.Fun's vowel guide teaches where vowels sit around the consonant.",
        "url": "https://readthai.fun/blog/post/thai-alphabet/thai-alphabet-vowels",
        "quote": "Instead, they often appear as symbols above, below, before, or after "
                 "consonants.",
    },
    {
        "id": "tp101-18-sounds",
        "party": "ThaiPod101",
        "claim": "ThaiPod101's vowel lesson teaches 18 vowel sounds.",
        "url": "https://www.thaipod101.com/lesson/ultimate-thai-pronunciation-guide-3-thai-vowels",
        "quote": "In this lesson, you'll learn all 18 Thai vowel sounds.",
    },
    {
        "id": "tp101-18-pairs",
        "party": "ThaiPod101",
        "claim": "ThaiPod101 frames the 18 sounds as short and long pairs.",
        "url": "https://www.thaipod101.com/lesson/ultimate-thai-pronunciation-guide-3-thai-vowels",
        "quote": "The 18 vowel sounds in Thai come in short and long pairs.",
    },
    {
        "id": "tp101-fifteen-characters",
        "party": "ThaiPod101",
        "claim": "ThaiPod101's alphabet page counts fifteen basic vowel characters.",
        "url": "https://www.thaipod101.com/thai-alphabet/",
        "quote": "Thai script consists of fifteen basic vowel characters that appear in front "
                 "of, above, below, or after the consonants they modify.",
    },
    {
        "id": "ling-pronunciation-first",
        "party": "Ling",
        "claim": "Ling's Thai track starts with pronunciation.",
        "url": "https://ling-app.com/learn-language/thai/",
        "quote": "Start with pronunciation, essential words, and simple greetings.",
    },
    {
        "id": "phayan-audio-every-free-word",
        "party": "Phayan",
        "claim": "Every free word carries audio.",
        "url": "https://www.phayan.com/pricing",
        "quote": "Audio for every free word",
    },
    {
        "id": "phayan-first-5-levels",
        "party": "Phayan",
        "claim": "The free tier is the first five levels.",
        "url": "https://www.phayan.com/pricing",
        "quote": "The first 5 levels",
    },
    {
        "id": "phayan-free-no-subscription",
        "party": "Phayan",
        "claim": "The free tier needs no subscription.",
        "url": "https://www.phayan.com/pricing",
        "quote": "Free no subscription needed",
    },
]


def page_result():
    cache = {}
    rows = []
    for item in PAGE_QUOTES:
        row = dict(item)
        try:
            if item["url"] not in cache:
                cache[item["url"]] = normalise(
                    html_to_text(fetch_bytes(item["url"], 45).decode("utf-8", "replace"))
                )
            row["holds"] = normalise(item["quote"]) in cache[item["url"]]
        except Exception as exc:  # noqa: BLE001 - an unreadable page is a failed check
            row["holds"] = False
            row["error"] = "%s: %s" % (type(exc).__name__, exc)
        rows.append(row)
    wiki = cache.get(WIKI_URL, "")
    for spelling in CLOSED_SPELLINGS:
        rows.append(
            {
                "id": "wiki-closed-spelling-%s" % spelling,
                "party": "Wikipedia",
                "claim": "The vowel table lists %s as a spelling of the vowel it is counted under."
                         % spelling,
                "url": WIKI_URL,
                "quote": spelling,
                "holds": spelling in wiki,
            }
        )
    return rows


# -------------------------------------------------------------- product facts

PRODUCT_FACTS = [
    {
        "id": "long-monophthongs-l2",
        "claim": "The long monophthongs arrive in level 2.",
        "file": VOWEL_SEED,
        "quote": "L2 - long monophthongs (step 1)",
    },
    {
        "id": "irregulars-l7",
        "claim": "The seed places ไ- ใ- เ-า in level 7.",
        "file": VOWEL_SEED,
        "quote": "irregulars (ไ ใ เ-า) L7",
    },
    {
        "id": "decodable-words",
        "claim": "Every word in a round is decodable from that round's cumulative inventory.",
        "file": QA_SPEC,
        "quote": "every word in a round is decodable from that round's cumulative inventory",
    },
    {
        "id": "first-paid-level",
        "claim": "Levels 0 to 4 are free; the first paid level is 5.",
        "file": ACCESS,
        "quote": "export const FIRST_PAID_LEVEL = 5;",
    },
]


def product_result():
    rows = []
    for fact in PRODUCT_FACTS:
        row = dict(fact)
        try:
            row["holds"] = normalise(fact["quote"]) in normalise_code(repo_text(fact["file"]))
        except Exception as exc:  # noqa: BLE001
            row["holds"] = False
            row["error"] = "%s: %s" % (type(exc).__name__, exc)
        rows.append(row)

    counts = [
        len([x for x in body.split(",") if x.strip()])
        for body in re.findall(r"newVowelForms:\s*\[([^\]]*)\]", repo_text(LEVELS))
    ]
    rows.append(
        {
            "id": "max-new-vowel-forms-per-round",
            "claim": "A round introduces at most four new vowel forms.",
            "file": LEVELS,
            "quote": "newVowelForms:",
            "holds": bool(counts) and max(counts) <= 4,
            "rounds_with_new_vowel_forms": len(counts),
            "max_new_vowel_forms": max(counts) if counts else None,
        }
    )
    return rows


# --------------------------------------------------------------------- record
CSV_FIELDS = [
    "vowel",
    "ipa",
    "length",
    "group",
    "spellings_counted",
    "first_rank",
    "first_word",
    "top1000_words",
    "top1000_token_share_pct",
    "example_words",
    "source_url",
]


def build():
    digest, rows = load_frequency()
    table, top_total, closed_words = measure_corpus(rows)
    validation = validation_result()
    pages = page_result()
    products = product_result()

    in_top = [r for r in table if r["top1000_words"] > 0]
    absent = sorted(
        (r for r in table if r["top1000_words"] == 0),
        key=lambda r: r["first_rank"] or 10**9,
    )
    by_first = sorted(table, key=lambda r: r["first_rank"] or 10**9)
    at_least_5 = [
        r["vowel"]
        for r in sorted(table, key=lambda r: -r["top1000_token_share_pct"])
        if r["top1000_token_share_pct"] >= 5
    ]
    summary = {
        "vowels_measured": len(table),
        "vowels_in_top1000": len(in_top),
        "vowels_absent_from_top1000": [r["vowel"] for r in absent],
        "first_rank_of_absent_vowels": {r["vowel"]: r["first_rank"] for r in absent},
        "vowels_in_top100_words": [r["vowel"] for r in by_first if (r["first_rank"] or 10**9) <= 100],
        "vowels_at_or_above_5pct_of_top1000_tokens": at_least_5,
        "order_of_first_appearance": [r["vowel"] for r in by_first],
    }

    return {
        "slug": "thai-vowels-32-forms",
        "question": "How many Thai vowels are there and how do I read them?",
        "method": "public-corpus",
        "checkedAt": CHECKED_AT,
        "methodDetail": (
            "The 32 vowels are the traditional list in English Wikipedia's Thai script vowel "
            "table (9 short-long pairs, 3 diphthong pairs, -ำ ใ- ไ- เ-า ฤ ฤๅ ฦ ฦๅ). Every word of "
            "the Thai National Corpus frequency list as PyThaiNLP packages it is scanned left "
            "to right with tone marks removed; at each position the longest spelling wins, so "
            "เ-าะ is one vowel and not เ- plus ะ. A vowel is counted under its open spelling and "
            "under each closed-syllable spelling the same table lists (column spellings_counted). "
            "Vowels that are not written at all (the o of คน, the a of ถนน) are not counted, so "
            "-ะ and โ-ะ are undercounts."
        ),
        "tools": {
            "python": sys.version.split()[0],
            "libraries": "standard library only (re, urllib.request, hashlib, json, csv)",
            "user_agent": UA,
        },
        "inputs": [
            {
                "what": "Thai National Corpus word list, as packaged by PyThaiNLP",
                "url": TNC_URL,
                "sha256": digest,
                "rows": len(rows),
                "count_sum": sum(c for _w, c in rows),
                "licence": "PyThaiNLP packaging CC0-1.0; the TNC's own terms are unstated, "
                           "recorded in docs/specs/FREQUENCY_SOURCING.md §3",
            },
            {"what": "the definition of the 32 and their closed spellings", "url": WIKI_URL},
            {"what": "Phayan's vowel seed, for the parser validation and level facts",
             "file": VOWEL_SEED},
            {"what": "Phayan's lexicon labels, for the parser validation", "file": LEXICON},
            {"what": "Phayan product facts", "file": [QA_SPEC, LEVELS, ACCESS]},
        ]
        + [
            {"what": "page fetched for a quoted sentence", "url": url}
            for url in sorted({r["url"] for r in PAGE_QUOTES} - {WIKI_URL})
        ],
        "results": {
            "summary": summary,
            "vowels": table,
            "corpus": {"top_n": TOP_N, "top_total": top_total, "rows": len(rows)},
            "top1000_words_counted_by_a_closed_spelling": closed_words,
            "parser_validation": validation,
            "third_party": pages,
            "product_facts": products,
        },
        "notes": [
            "Not measured: the loanword count the audit proposed. Neither the TNC list nor "
            "Phayan's lexicon carries an etymology field, so a label would be a judgement; the "
            "first-appearance rank answers the learner's question instead.",
            "Not counted: vowels with no written mark (short o in คน, short a in ถนน) and the "
            "◌ร spelling of long aw (พร, ละคร), which cannot be told from an onset ร without "
            "syllable division. -ะ, โ-ะ and -อ are therefore undercounts.",
            "The ◌ว◌ rule (ua spelled without ั, as in ขวด) can catch a word where ว starts a "
            "syllable. Every top-1,000 word a closed-spelling rule credited is listed in "
            "results.top1000_words_counted_by_a_closed_spelling; the 32 ◌ว◌ words there were "
            "read by eye on 2026-10-26 and all spell ua. Words below rank 1,000 were not read.",
            "เ◌ย (เลย) and เ◌ียว (เดียว) are a vowel plus a final ย or ว; Wikipedia's table does "
            "not count them among the 32, so they are credited to เ-อ and เ-ีย.",
            "Phayan's own seed (apps/api/src/content/seed/vowel-forms.ts) also has 32 forms, "
            "but a different 32: it lists -ั and เ-ียว as forms of their own and leaves out "
            "ฦ ฦๅ. The parser validation uses the seed's set because that is what its labels "
            "describe.",
            "Not checked: the English glosses of the Thai example words in the post; no "
            "dictionary was queried.",
            "Not checked: whether 'most textbooks' count 32. No textbook corpus is reachable, "
            "so the post no longer says it.",
            "The earlier version of the post called '9 pairs, 3 diphthong pairs, and the "
            "special four' the complete inventory, which is 28 forms under a 32-form title; "
            "the four missing were ฤ ฤๅ ฦ ฦๅ.",
            "No step takes a minute, so --recheck skips nothing; the corpus download is about "
            "1.6 MB.",
        ],
    }


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


def compare(data):
    failures = []
    try:
        with open(DATASET_JSON, encoding="utf-8") as fh:
            recorded = json.load(fh)
        with open(DATASET_CSV, encoding="utf-8", newline="") as fh:
            csv_rows = list(csv.DictReader(fh))
    except FileNotFoundError:
        return ["dataset.json or dataset.csv is missing; run with --write"]

    if data["inputs"][0]["sha256"] != TNC_SHA:
        failures.append("the corpus file changed (sha256 %s)" % data["inputs"][0]["sha256"])

    new = {r["vowel"]: r for r in data["results"]["vowels"]}
    old = {r["vowel"]: r for r in recorded["results"]["vowels"]}
    if set(new) != set(old):
        failures.append("the set of measured vowels changed")
    for vowel, row in new.items():
        for field in CSV_FIELDS:
            if vowel in old and row[field] != old[vowel][field]:
                failures.append("%s: %s was %r, now %r" % (vowel, field, old[vowel][field], row[field]))
    for row in csv_rows:
        current = new.get(row["vowel"])
        if not current or any(str(current[f] if current[f] is not None else "") != row[f]
                              for f in CSV_FIELDS):
            failures.append("dataset.csv row %s differs from the measurement" % row["vowel"])

    if recorded["results"]["summary"] != data["results"]["summary"]:
        failures.append("summary changed")
    if recorded["results"]["corpus"] != data["results"]["corpus"]:
        failures.append("corpus totals changed")
    for key in ("words", "agree", "agree_pct", "disagreements"):
        if recorded["results"]["parser_validation"].get(key) != data["results"]["parser_validation"].get(key):
            failures.append("parser validation %s changed" % key)

    for group in ("third_party", "product_facts"):
        for row in data["results"][group]:
            if not row["holds"]:
                failures.append("%s/%s: %s" % (group, row["id"], row.get("error") or "quote not found"))
    return failures


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

    data = build()
    s = data["results"]["summary"]
    v = data["results"]["parser_validation"]
    print("corpus: %d rows, sha256 %s" % (data["inputs"][0]["rows"], data["inputs"][0]["sha256"][:12]))
    print("vowels: %d measured, %d in the top %d words, absent %s"
          % (s["vowels_measured"], s["vowels_in_top1000"], TOP_N,
             ", ".join(s["vowels_absent_from_top1000"])))
    print("first appearance: " + " ".join(s["order_of_first_appearance"]))
    print("parser vs the seed's labels: %d of %d words agree (%.2f%%)"
          % (v["agree"], v["words"], v["agree_pct"]))
    for group in ("third_party", "product_facts"):
        for row in data["results"][group]:
            print("%-13s %-36s %s" % (group, row["id"], "holds" if row["holds"] else "FAILED"))

    if args.write:
        write(data)
        print("wrote dataset.json and dataset.csv")
        return 0

    failures = compare(data)
    for failure in failures:
        print("FAIL " + failure)
    if failures:
        return 1
    print(("recheck: " if args.recheck else "") + "every quote, value and total holds")
    return 0


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