#!/usr/bin/env python3
"""Assert the prototype references every field the build manual defines.

    python3 verify-fields.py          # summary + any failures
    python3 verify-fields.py -v       # also list where each field was found

A field counts as REFERENCED when a built page carries its machine name in a
`data-field` attribute (display surfaces) or a `name` attribute (form
controls). Prose does not count: "Price" appears on nine pages and proves
nothing about whether field_prop_price was modelled.

Exit status is 1 on any failure, so this can gate a commit.

WHY MACHINE NAMES ARE IN THE HTML AT ALL
----------------------------------------
The prototype's job is to be converted to Drupal SDCs. Stamping the field a
block renders makes that conversion mechanical instead of interpretive, and
makes this check possible. The attributes are inert — no CSS or JS selects on
them (js/main.js and friends use their own data- hooks), so they cost nothing
at runtime and lift out cleanly if the Drupal build wants them gone.
"""
import pathlib
import re
import sys

import link_fields as L

ROOT = pathlib.Path(__file__).parent
VERBOSE = "-v" in sys.argv or "--verbose" in sys.argv

REF = re.compile(r'(?:data-field|name)="([a-z0-9_ ]+)"')

# Fields the manual itself says never reach a page. Each one still has to be
# accounted for, so they are listed by name rather than skipped by rule.
SERVER_ONLY = {
    "field_prop_sig_meta":     "ECA writes timestamp + IP; admin-only view",
    "field_bid_sig_meta":      "ECA writes timestamp + IP; admin-only view",
    "field_prop_strength_score": "computed 0-100 by ECA, hidden from the form",
    "field_geolocation":       "auto-populated by Geocoder, hidden from the form",
    "field_sp_rating_cache":   "Voting API writes it; the UI shows rating-stars",
    "field_sp_review_count":   "Voting API writes it; drives the DR-8 threshold",
    "field_bid_seller_viewed": "ECA stamps it on first render",
    "field_oa_invited":        "ECA sets it when the invite email goes out",
}


def scan():
    """{machine name: [pages]} for every reference in the built HTML."""
    found = {}
    for page in sorted(ROOT.glob("*.html")):
        text = page.read_text(encoding="utf-8")
        for match in REF.findall(text):
            for name in match.split():
                if name.startswith("field_"):
                    found.setdefault(name, set()).add(page.name)
    return {k: sorted(v) for k, v in found.items()}


def main():
    found = scan()
    failures = []

    # ---------------------------------------------------- 1. field coverage
    by_entity = {}
    for f in L.FIELDS:
        by_entity.setdefault(f["entity"], []).append(f)

    print(f"LiNK field conformance — {L.REVISION}")
    print(f"source: {L.SOURCE}\n")
    print(f"{'entity':<26}{'fields':>7}{'referenced':>12}{'server-only':>13}{'missing':>9}")

    missing_all = []
    for entity in sorted(by_entity, key=lambda e: -len(by_entity[e])):
        fields = by_entity[entity]
        hit = [f for f in fields if f["machine"] in found]
        server = [f for f in fields
                  if f["machine"] not in found and f["machine"] in SERVER_ONLY]
        missing = [f for f in fields
                   if f["machine"] not in found and f["machine"] not in SERVER_ONLY]
        missing_all += missing
        flag = "" if not missing else "  <-"
        print(f"{entity:<26}{len(fields):>7}{len(hit):>12}{len(server):>13}{len(missing):>9}{flag}")

    total = len(L.FIELDS)
    covered = total - len(missing_all)
    print(f"{'TOTAL':<26}{total:>7}{covered:>12}{'':>13}{len(missing_all):>9}")
    print(f"\ncoverage: {covered}/{total} = {100 * covered / total:.1f}%")

    if missing_all:
        failures.append(f"{len(missing_all)} manual field(s) appear on no page")
        print("\nNOT REFERENCED ANYWHERE:")
        for f in missing_all:
            print(f"  {f['entity']:<22} {f['machine']:<32} {f['label']}")

    # ------------------------------------------- 2. nothing invented locally
    stray = sorted(name for name in found if name not in L.BY_MACHINE)
    if stray:
        failures.append(f"{len(stray)} field name(s) not in the manual")
        print("\nIN THE HTML BUT NOT IN THE MANUAL:")
        for name in stray:
            print(f"  {name:<34} {', '.join(found[name])}")

    # ----------------------------------------------- 3. v6 domain invariants
    #
    # v6 (2026-08-12) split the market lifecycle out of the editorial
    # moderation workflow. Conflating them was the single most consequential
    # error the prototype could inherit, so it is checked, not trusted.
    listings = (ROOT / "dashboard-seller-listings.html").read_text(encoding="utf-8")
    for needle, why in [
        ("field_prop_market_status", "market status column missing from My Listings"),
        ("data-moderation-state", "moderation state column missing from My Listings"),
    ]:
        if needle not in listings:
            failures.append(f"9-Views: {why}")

    wizard = ROOT / "dashboard-seller-listing-wizard.html"
    if not wizard.exists():
        failures.append("link_listing_wizard: dashboard-seller-listing-wizard.html missing")
    else:
        text = wizard.read_text(encoding="utf-8")
        # Count the step PANELS, not data-step-state — the step-tabs row carries
        # that attribute too, so counting it double-counts every step.
        steps = len(re.findall(r'data-wizard-step=', text))
        if steps != 9:
            failures.append(f"link_listing_wizard: {steps} steps rendered, manual specifies 9")
        if "data-wizard-resume" not in text:
            failures.append("link_listing_wizard: no save-and-continue (resumable) affordance")

    # DR-8 / manual 5g: the public aggregate stays hidden below five published
    # reviews. components.py owns the threshold; assert it has not drifted.
    import components
    if components.DR8_MIN_REVIEWS != 5:
        failures.append(f"rating-stars threshold is {components.DR8_MIN_REVIEWS}, manual says 5")

    # --------------------------------------------------------------- verdict
    print()
    if failures:
        print(f"FAIL — {len(failures)} problem(s):")
        for f in failures:
            print(f"  ✗ {f}")
        return 1

    print("PASS — every manual field is referenced and the v6 invariants hold.")
    if VERBOSE:
        print()
        for f in L.FIELDS:
            where = ", ".join(found.get(f["machine"], [])) or SERVER_ONLY.get(f["machine"], "")
            print(f"  {f['machine']:<32} {where}")
    return 0


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