#!/usr/bin/env python3
"""verify-proof-lint — mechanical gate for surface-qa's VerifyProof.

The record is surface-qa's typed deliverable (`skills/surface-qa/SKILL.md`
§Deliverable — the VerifyProof) — the isolated QA pass's closing artifact on
every runtime this plugin targets (ADR-0091, SPEC REQ-005). Structurally
parallel to `record-lint` (same CLI shape, same exit-code contract, same
"a slot may read `UNMEASURED — <reason>`; silent omission is not legal"
rule) — see that script's own header for the shared family's rationale.

Checks (the contract's mechanizable slice — shape, enums, required prose;
whether an `imageRead`/`a11y` claim is genuinely TRUE stays the model's
judgment, never this linter's):
  1. Required lines present, in any order: url / consoleErrors /
     boundingBoxes / screenshot / imageRead / perf / a11y / structure /
     verdict. Omitting a row is always a finding — even a row whose gate
     could not run must read `UNMEASURED — <reason>`, never be absent
     (surface-qa SKILL.md's own `perf` note, generalized to every slot).
  2. Enum-leading slots: `consoleErrors` / `a11y` ∈ {pass, fail, UNMEASURED};
     `boundingBoxes` ∈ {pass, fail, UNMEASURED}; `verdict` ∈ {ship, hold}
     (verdict is the one slot with no UNMEASURED escape — a QA pass that
     cannot reach a ship/hold call has not finished the deliverable).
  3. Every `UNMEASURED` value carries a non-empty `— <reason>` clause —
     bare `UNMEASURED` with no reason is a finding, matching the shared
     family's Evidence-gate discipline.
  4. `imageRead` is non-empty prose and is never the literal placeholder
     "looks fine" (surface-qa SKILL.md's own explicit callout) — a
     required-prose gate, not an enum.
  5. `verdict: hold` carries a one-line reason naming the blocker
     (surface-qa SKILL.md: "ship | hold — <one line naming the blocker if
     hold>"); `verdict: ship` needs no reason clause.
  6. `screenshot` is non-empty and, when not `UNMEASURED`, is expected to
     carry the `deviceScaleFactor` capture note (SKILL.md's own shape:
     `<path> @ deviceScaleFactor 2`) — a soft check (warns via the same
     finding channel; the path format itself is not re-validated here).

Usage:
  verify-proof-lint <file>       # lint a file containing a VerifyProof
  verify-proof-lint -            # lint stdin
  verify-proof-lint selftest     # embedded fixtures; exit 0 iff all pass
Exit 1 on findings, 0 clean. Stdlib only (Python 3.8+).
"""
import re
import sys

REQUIRED = [
    'url', 'consoleErrors', 'boundingBoxes', 'screenshot', 'imageRead',
    'perf', 'a11y', 'structure', 'verdict',
]
ENUM_SLOTS = {
    'consoleErrors': {'pass', 'fail', 'UNMEASURED'},
    'boundingBoxes': {'pass', 'fail', 'UNMEASURED'},
    'a11y': {'pass', 'fail', 'UNMEASURED'},
    'verdict': {'ship', 'hold'},
}
LOOKS_FINE_RE = re.compile(r'^\s*looks fine\.?\s*$', re.I)
LINE_RE = re.compile(r'^\s*([A-Za-z][A-Za-z0-9]*):\s*(.*)$')


def _head_and_reason(value):
    """Split 'head — reason' / 'head - reason' on the first em/en/hyphen
    dash surrounded by whitespace; head alone if no dash present."""
    m = re.match(r'^(.*?)(?:\s+[—–-]\s+(.*))?$', value.strip(), re.S)
    if not m:
        return value.strip(), None
    return m.group(1).strip(), (m.group(2).strip() if m.group(2) else None)


def lint(text):
    findings = []
    lines = {}
    for raw in text.splitlines():
        m = LINE_RE.match(raw)
        if m and m.group(1) in REQUIRED and m.group(1) not in lines:
            lines[m.group(1)] = m.group(2).strip()

    for label in REQUIRED:
        if label not in lines:
            findings.append(f"missing line: '{label}:'")

    for slot, enum in ENUM_SLOTS.items():
        if slot not in lines:
            continue
        value = lines[slot]
        head, reason = _head_and_reason(value)
        if head not in enum:
            findings.append(f"{slot}: '{head}' not in {sorted(enum)}")
        if head == 'UNMEASURED' and not reason:
            findings.append(f"{slot}: 'UNMEASURED' with no '— <reason>' clause")
        if slot == 'verdict' and head == 'hold' and not reason:
            findings.append("verdict: 'hold' with no one-line blocker reason")

    if 'imageRead' in lines:
        value = lines['imageRead']
        if not value:
            findings.append("imageRead: empty — required prose describing what the pixels show")
        elif LOOKS_FINE_RE.match(value):
            findings.append("imageRead: bare 'looks fine' is not evidence — describe what was seen")

    if 'screenshot' in lines:
        value = lines['screenshot']
        if not value:
            findings.append("screenshot: empty")
        elif not value.upper().startswith('UNMEASURED') and 'deviceScaleFactor' not in value:
            findings.append("screenshot: missing 'deviceScaleFactor' capture note (or UNMEASURED — <reason>)")

    if 'perf' in lines and not lines['perf']:
        findings.append("perf: empty — row must exist even when UNMEASURED (advisory, never gates)")

    if 'structure' in lines and not lines['structure']:
        findings.append("structure: empty")

    if 'consoleErrors' in lines and not lines['consoleErrors']:
        findings.append("consoleErrors: empty")

    if 'url' in lines and not lines['url']:
        findings.append("url: empty")

    return findings


GOOD = """\
VerifyProof
url:            http://localhost:5173/settings
consoleErrors:  pass — no console.error or pageerror on load
boundingBoxes:  pass — nav-ui: 240x800, form-ui: 640x480
screenshot:     ./qa/settings-01.png @ deviceScaleFactor 2
imageRead:      sidebar rail, two-column form, save button bottom-right, no overlap or clipping
perf:           420ms vs 800ms budget
a11y:           pass — region role/label present, heading roles correct, keyboard path clean, AA contrast holds
structure:      adia-lint clean on every written file
verdict:        ship
"""

BAD = """\
VerifyProof
url:
consoleErrors:  maybe
boundingBoxes:  pass
screenshot:
imageRead:      looks fine
perf:
a11y:           UNMEASURED
structure:
verdict:        hold
"""

GOOD_UNMEASURED = """\
VerifyProof
url:            http://localhost:5173/settings
consoleErrors:  UNMEASURED — no dev server running
boundingBoxes:  pass — nav-ui: 240x800
screenshot:     UNMEASURED — no dev server running
imageRead:      not captured this pass — dev server unavailable, see consoleErrors
perf:           UNMEASURED — no dev server running
a11y:           UNMEASURED — no dev server running
structure:      adia-lint clean on every written file
verdict:        hold — cannot verify render without a dev server
"""


def _selftest():
    import io

    fails = []
    if lint(GOOD):
        fails.append(f"good fixture flagged: {lint(GOOD)}")
    if lint(GOOD_UNMEASURED):
        fails.append(f"good-unmeasured fixture flagged: {lint(GOOD_UNMEASURED)}")

    bad = lint(BAD)
    expect = [
        "url: empty",
        "consoleErrors: 'maybe' not in",
        "screenshot: empty",
        "imageRead: bare 'looks fine'",
        "perf: empty",
        "a11y: 'UNMEASURED' with no",
        "structure: empty",
        "verdict: 'hold' with no one-line blocker reason",
    ]
    for e in expect:
        if not any(e in f for f in bad):
            fails.append(f"bad fixture missed: {e} (got {bad})")

    for flag in ('-h', '--help'):
        saved_out = sys.stdout
        sys.stdout = io.StringIO()
        try:
            rc_help = main(['verify-proof-lint', flag])
            help_out = sys.stdout.getvalue()
        finally:
            sys.stdout = saved_out
        if rc_help != 0:
            fails.append(f'{flag} did not exit 0')
        if not help_out.startswith('usage:'):
            fails.append(f'{flag} did not print usage')

    if fails:
        print('selftest FAIL: ' + ' | '.join(fails), file=sys.stderr)
        return 1
    print(f'selftest OK — good fixtures clean, bad fixture {len(bad)} findings, -h/--help contract holds')
    return 0


_USAGE = 'usage: verify-proof-lint <file>  |  verify-proof-lint -  |  verify-proof-lint selftest'


def main(argv):
    if len(argv) > 1 and argv[1] == 'selftest':
        return _selftest()
    if len(argv) > 1 and argv[1] in ('-h', '--help'):
        print(_USAGE)
        return 0
    text = sys.stdin.read() if (len(argv) < 2 or argv[1] == '-') else open(argv[1], encoding='utf-8').read()
    findings = lint(text)
    if findings:
        print(f'verify-proof-lint · {len(findings)} finding(s):')
        for f in findings:
            print(f'  {f}')
        print('Contract: surface-qa SKILL.md §Deliverable — the VerifyProof.')
        return 1
    print('verify-proof-lint · clean')
    return 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
