#!/usr/bin/env python3
"""record-lint — mechanical gate for app-planning's Orientation Record.

The record is app-planning's typed deliverable (SKILL.md §The Orientation
Record) and the app-planning-agent → screen-composition-agent handoff artifact. Until
this linter existed, nothing checked a record's shape — and the record's
three in-tree consumers had already drifted from the contract (the
factory-audit finding this closes, gh#259 Wave 2).

Checks (the contract's mechanizable slice — shape, enums, evidence,
route-legality; whether a SIGNAL is genuinely load-bearing stays the
model's judgment):
  1. Required lines present: Rendering mode / Project shape / Shell /
     Task / → Route / Verify target / Open questions. Screen plan is
     conditional (start mode) — its absence is never an error.
  2. Axis enums: mode ∈ {SPA, SSR, hybrid} · shape ∈ {single-surface,
     rollup, shared-foundation} · shell ∈ {admin, chat, editor, simple,
     embed, none}.
  3. Every axis line carries a non-empty `— signal:` clause that isn't a
     bare restatement of the value.
  4. Route names only skills that exist in this plugin (the task table's
     roster), or app-planning itself.
  5. Every Open-questions entry names its fallback (`fallback:`).
  6. Verify target non-empty and not TBD-ish.
  7. Domain Plan (gh#1207, REQ-02) — CONDITIONAL: absent is never an
     error (spec-shaped input only, mirrors Screen plan's conditionality).
     When a `## Domain Plan` heading IS present, its required sub-fields
     (Intent / Domain / Roles / Tasks / Decisions / Wireframe) must each
     appear non-empty, and Wireframe must name a checkpoint verdict
     (PASSED or FAILED).

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

AXES = {
    'Rendering mode': {'SPA', 'SSR', 'hybrid'},
    'Project shape': {'single-surface', 'rollup', 'shared-foundation'},
    'Shell': {'admin', 'chat', 'editor', 'simple', 'embed', 'none'},
}
REQUIRED = ['Rendering mode', 'Project shape', 'Shell', 'Task', '→ Route', 'Verify target', 'Open questions']
ROUTE_SKILLS = {
    'app-planning', 'project-scaffolding', 'screen-composition', 'shell-selection', 'host-wiring',
    'data-wiring', 'llm-wiring', 'gen-ui-wiring', 'surface-qa', 'app-migration',
}
TBD_RE = re.compile(r'^\s*(tbd|todo|\?+|—|-)\s*$', re.I)

# gh#1207 REQ-02 — the Orientation Record's CONDITIONAL Domain Plan block
# (spec-shaped input only; app-planning SKILL.md §The Orientation Record).
# Shape (sketch: .claude/docs/specs/factory-roster-completion.md):
#   ## Domain Plan (rungs 0–19, ladder vX)
#   - Intent: ... - Domain: ... - Roles: ... - Tasks: ... - Decisions: ...
#   - Wireframe: ... checkpoint PASSED|FAILED
DOMAIN_PLAN_HEADING_RE = re.compile(r'^##\s*Domain Plan\b.*$', re.M)
DOMAIN_PLAN_FIELDS = ['Intent', 'Domain', 'Roles', 'Tasks', 'Decisions', 'Wireframe']
DOMAIN_PLAN_FIELD_RE = re.compile(
    r'-\s*(' + '|'.join(DOMAIN_PLAN_FIELDS) + r')\s*:[ \t]*(.*?)'
    r'(?=(?:[ \t]+-\s*(?:' + '|'.join(DOMAIN_PLAN_FIELDS) + r')\s*:)|\n|$)'
)


def lint(text):
    findings = []
    lines = {}
    for raw in text.splitlines():
        m = re.match(r'^\s*(→ Route|[A-Z][\w ]+?):\s*(.*)$', raw)
        if m:
            lines.setdefault(m.group(1).strip(), []).append(m.group(2).strip())

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

    for axis, enum in AXES.items():
        for value in lines.get(axis, []):
            head = value.split('—')[0].strip()
            sig = re.search(r'—\s*signal:\s*(.*)$', value)
            if head not in enum:
                findings.append(f"{axis}: '{head}' not in {sorted(enum)}")
            if not sig or not sig.group(1).strip():
                findings.append(f"{axis}: no '— signal:' clause (Evidence gate)")
            elif head and head.lower() in sig.group(1).strip().lower() and len(sig.group(1).strip()) <= len(head) + 12:
                findings.append(f"{axis}: signal restates the value ('{sig.group(1).strip()}')")

    for value in lines.get('→ Route', []):
        # kebab tokens that are either current factory skills or legacy
        # adia-* names (the latter flag as not-a-factory-skill below, so an
        # un-migrated record fails loudly instead of passing silently)
        tokens = set(re.findall(r'[a-z][a-z0-9-]*[a-z0-9]', value))
        named = {t for t in tokens if t in ROUTE_SKILLS or t.startswith('adia-')}
        for skill in named - ROUTE_SKILLS:
            findings.append(f"Route: '{skill}' is not a factory skill (Route-legal gate)")
        if not named:
            findings.append("Route: names no skill")

    for value in lines.get('Verify target', []):
        if not value or TBD_RE.match(value):
            findings.append("Verify target: empty or TBD")

    for value in lines.get('Open questions', []):
        if value and not TBD_RE.match(value) and value.lower() not in ('none', 'blank if none', ''):
            if 'fallback' not in value.lower():
                findings.append(f"Open questions: entry lacks a named fallback: '{value[:50]}'")

    findings.extend(_lint_domain_plan(text))
    return findings


def _lint_domain_plan(text):
    """gh#1207 REQ-02 — conditional Domain Plan block. Absence is never a
    finding (never required); presence gates its own required sub-fields."""
    heading = DOMAIN_PLAN_HEADING_RE.search(text)
    if not heading:
        return []
    start = heading.end()
    next_heading = re.search(r'\n##\s', text[start:])
    block = text[start:start + next_heading.start()] if next_heading else text[start:]

    found = {}
    for fm in DOMAIN_PLAN_FIELD_RE.finditer(block):
        found.setdefault(fm.group(1), fm.group(2).strip())

    findings = []
    for field in DOMAIN_PLAN_FIELDS:
        if field not in found:
            findings.append(f"Domain Plan: missing '{field}:' field")
        elif not found[field]:
            findings.append(f"Domain Plan: '{field}:' is empty")

    wireframe = found.get('Wireframe', '')
    if wireframe and not re.search(r'\b(PASSED|FAILED)\b', wireframe, re.I):
        findings.append("Domain Plan: 'Wireframe:' names no checkpoint verdict (PASSED/FAILED)")

    return findings


GOOD = """\
Rendering mode:  SPA — signal: no framework dep in package.json; index.html present
Project shape:   single-surface — signal: one entry under app/claims/
Shell:           admin — signal: sidebar + topbar + command palette in the brief
Task:            build a claims-review screen — signal: the request
Screen plan:     1. Claims list — reviewer scans and opens a claim
→ Route:         project-scaffolding, screen-composition, in order per the task table
Verify target:   composed screen renders; adia-lint clean; browser gate
Open questions:  auth model unclear — fallback: mock session until the API lands
"""

BAD = """\
Rendering mode:  Static — signal: SPA
Shell:           admin
→ Route:         adia-scaffolding
Verify target:   TBD
Open questions:  auth model unclear
"""

# gh#1207 REQ-02 — GOOD_WITH_DOMAIN_PLAN proves a spec-shaped record with a
# complete, well-formed Domain Plan block stays clean (the block is
# conditional, never required — GOOD above already proves a plain record
# with NO block is clean). Uses the spec sketch's own compressed
# "field: value    - field: value" line shape for Roles/Tasks to prove the
# parser handles it, not just one-field-per-line.
GOOD_WITH_DOMAIN_PLAN = GOOD + """
## Domain Plan (rungs 0–19, ladder v1)
- Intent: reviewers triage claims fast · cut time-to-resolution
- Domain: claim, adjuster, SLA · open-claims-count, avg-resolution-hours
- Roles: adjuster, supervisor           - Tasks: triage queue · escalate stuck claims
- Decisions: assign / escalate / close, on priority + SLA risk
- Wireframe: sidebar + queue + drawer, D1-D6 all ≥ 3, checkpoint PASSED
"""

# BAD_DOMAIN_PLAN: axis lines are fine (reuses GOOD) but the Domain Plan
# block is missing Decisions entirely, leaves Roles empty, and never names
# a checkpoint verdict — each must be caught independently.
BAD_DOMAIN_PLAN = GOOD + """
## Domain Plan (rungs 0–19, ladder v1)
- Intent: reviewers triage claims fast · cut time-to-resolution
- Domain: claim, adjuster, SLA · open-claims-count, avg-resolution-hours
- Roles:
- Tasks: triage queue · escalate stuck claims
- Wireframe: sidebar + queue + drawer, D1-D6 all ≥ 3
"""

# gh#1215 review F1 — the spec sketch (above, "Shape") separates compressed
# fields with a SINGLE space; the boundary lookahead must accept one-or-more
# spaces/tabs, not two-or-more, or a sketch-faithful record falsely fails.
GOOD_SINGLE_SPACE_PLAN = GOOD + """
## Domain Plan (rungs 0–19, ladder v1)
- Intent: reviewers triage claims fast - Domain: claim, adjuster, SLA - Roles: adjuster, supervisor - Tasks: triage queue - Decisions: assign / escalate, on SLA risk
- Wireframe: sidebar + queue + drawer, checkpoint PASSED
"""

GOOD_SINGLE_TAB_PLAN = GOOD + """
## Domain Plan (rungs 0–19, ladder v1)
- Intent: reviewers triage claims fast	- Domain: claim, adjuster, SLA	- Roles: adjuster, supervisor	- Tasks: triage queue	- Decisions: assign / escalate, on SLA risk
- Wireframe: sidebar + queue + drawer, checkpoint PASSED
"""


def _selftest():
    import io

    fails = []
    if lint(GOOD):
        fails.append(f"good fixture flagged: {lint(GOOD)}")
    bad = lint(BAD)
    expect = ['missing line', "not in", "no '— signal:'", 'not a factory skill', 'empty or TBD', 'lacks a named fallback']
    for e in expect:
        if not any(e in f for f in bad):
            fails.append(f"bad fixture missed: {e} (got {bad})")

    # gh#1207 REQ-02 — Domain Plan block: conditional-pass + presence-checks.
    if lint(GOOD_WITH_DOMAIN_PLAN):
        fails.append(f"good-with-domain-plan fixture flagged: {lint(GOOD_WITH_DOMAIN_PLAN)}")
    # gh#1215 review F1 — single-space and single-tab compressed records
    # (the sketch's own separator) must parse clean.
    for name, fixture in (('single-space', GOOD_SINGLE_SPACE_PLAN), ('single-tab', GOOD_SINGLE_TAB_PLAN)):
        got = lint(fixture)
        if got:
            fails.append(f'{name} compressed Domain Plan fixture flagged: {got}')
    bad_plan = lint(BAD_DOMAIN_PLAN)
    expect_plan = ["missing 'Decisions:'", "'Roles:' is empty", "names no checkpoint verdict"]
    for e in expect_plan:
        if not any(e in f for f in bad_plan):
            fails.append(f"bad-domain-plan fixture missed: {e} (got {bad_plan})")

    # REQ-05 (gh#1136): -h/--help exits 0 and prints usage — never a crash.
    for flag in ('-h', '--help'):
        saved_out = sys.stdout
        sys.stdout = io.StringIO()
        try:
            rc_help = main(['record-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 fixture clean, bad fixture {len(bad)} findings, Domain Plan bad fixture {len(bad_plan)} findings, -h/--help contract holds')
    return 0


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


def main(argv):
    if len(argv) > 1 and argv[1] == 'selftest':
        return _selftest()
    # REQ-05 (gh#1136, factory-dx-ws5-consumer-verify): -h/--help must print
    # usage and exit 0. Previously unhandled — `-h` fell through to the
    # positional-file branch and crashed with an uncaught FileNotFoundError
    # (exit 1, a traceback dumped to stderr), the gh#1122 class taken further.
    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'record-lint · {len(findings)} finding(s):')
        for f in findings:
            print(f'  {f}')
        print('Contract: app-planning SKILL.md §The Orientation Record.')
        return 1
    print('record-lint · clean')
    return 0


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