#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Mechanical validator for the Agentic SDLC skill.

Commands:
  check      single closure gate: validate + stale in one command (exit 1 if either fails)
  validate   verify the structural coherence of ai_docs/ (exit 1 on errors; --strict also
             fails on warnings or on a missing ai_docs/, for CI)
  index      regenerate the generated indexes: strategic/features_history.md (from the
             frontmatter of ANALYSIS_*.md files) and ai_docs/INDEX.md (manifest of canonical docs)
  stale      list areas modified after the last analysis recorded in audit_plan.md (exit 1 if any)
  mark       record paths as ANALYZED with the current reference (git hash, else UTC timestamp)
  gate       PreToolUse hook: block writes on protected paths without an IN_PROGRESS ANALYSIS (exit 2)

Hybrid/devPNT mode: pass --hybrid explicitly on check/stale (skips audit-plan
staleness, delegated to devPNT/KL) and on gate (also unlocks when an approved
E-TDD shadow, solutions/SHADOW_*tdd*.md, exists).

Canonical language is English. Legacy Italian frontmatter keys (stato, livello,
data_inizio, data_fine) and section headings are still accepted for existing projects,
but are deprecated: new documents should use the English forms.

Standard library only (Python >= 3.8). Windows and POSIX compatible.
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

VALID_STATES = {"PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}
VALID_LEVELS = {"L1", "L2", "L3", "SPIKE"}
VISION_FILES = ("project_vision.md", "roadmap.md", "principles.md")
SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
             "dist", "build", ".idea", ".vs", "ai_docs"}
INDEX_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
                "Source of truth: frontmatter of the ANALYSIS_*.md files -->")
MANIFEST_HEADER = ("<!-- GENERATED by sdlc_check.py index - do not edit by hand. "
                   "Source of truth: the headers of the canonical documents in ai_docs/. -->")
# Directories whose .md files are durable canonical documents: manifested in INDEX.md.
# audit/ and solutions/ stay discovery-by-grep (session / process artifacts), not manifested.
MANIFEST_DIRS = ("vision", "reference", "architecture", "functional", "strategic")
# Recognized states: canonical docs (CURRENT/SUPERSEDED/...), vision (DRAFT/APPROVED),
# ADR (Accepted/Proposed/Rejected). Union, to avoid false warnings on conventions in use.
CANONICAL_STATES = {"CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED",
                    "APPROVED", "ACCEPTED", "PROPOSED", "REJECTED"}
GENERATED_DOCS = {"features_history.md", "INDEX.md"}  # generated: never manifest entries
MTIME_GRACE = timedelta(seconds=2)

# Deprecated Italian frontmatter keys, mapped to the canonical English ones.
LEGACY_KEYS = {"stato": "status", "livello": "level",
               "data_inizio": "start_date", "data_fine": "end_date"}

# ANALYSIS sections: (canonical English heading, legacy Italian heading).
SECURITY_SECTION = ("## Security", "## Sicurezza")
ANALYSIS_SECTIONS = (
    ("## Objective", "## Obiettivo"),
    ("## Feature Vision", "## Vision della Feature"),
    ("## Impact", "## Impatto"),
    ("## Action Plan", "## Piano d'Azione"),
    ("## Test Strategy", "## Strategia di Test"),
    ("## Diary", "## Diario"),
)

try:
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
    pass


# ----------------------------------------------------------------- utilities

def utc_now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def find_project_root(start=None):
    cur = Path(start or os.getcwd()).resolve()
    for p in [cur] + list(cur.parents):
        if (p / "ai_docs").is_dir():
            return p
    return cur


def require_ai_docs(root, command):
    """Fail fast when ai_docs/ is missing: prevents silently creating a second
    documentation root in the wrong working directory."""
    if not (root / "ai_docs").is_dir():
        print(f"[ERROR] {root / 'ai_docs'} not found: refusing to run '{command}' here. "
              "Run agentic-sdlc-init first, or pass --root <project_root>.")
        return False
    return True


def read_text(path):
    # utf-8-sig: strips a leading BOM (files authored on Windows) so the
    # frontmatter '---' on line 0 stays recognizable; reads plain utf-8 otherwise.
    return path.read_text(encoding="utf-8-sig", errors="replace")


def parse_iso(value):
    if not value:
        return None
    try:
        dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except ValueError:
        return None


def norm_text(s):
    return "\n".join(line.rstrip() for line in s.strip().splitlines())


def load_frontmatter(lines):
    meta = {}
    if not lines or lines[0].strip() != "---":
        return meta
    for line in lines[1:60]:
        if line.strip() == "---":
            break
        m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
        if m:
            meta[m.group(1).strip().lower()] = m.group(2).strip()
    # Legacy Italian keys: accepted, normalized to canonical English (deprecated).
    for legacy, canon in LEGACY_KEYS.items():
        if legacy in meta and canon not in meta:
            meta[canon] = meta[legacy]
    return meta


def is_shadow(path, first_line):
    """A shadow mirror of a devPNT-governed document, not an authoritative ANALYSIS.
    Recognized by filename (SHADOW_*) or by the marker comment on the FIRST line
    (legacy shadows saved under an ANALYSIS_* name)."""
    return path.name.startswith("SHADOW") or first_line.lstrip().startswith("<!-- SHADOW")


def list_analyses(root):
    """Returns [(path, frontmatter, text)] for the ANALYSIS_*.md files (shadows excluded)."""
    sol = root / "ai_docs" / "solutions"
    out = []
    if not sol.is_dir():
        return out
    for p in sorted(sol.glob("ANALYSIS_*.md")):
        text = read_text(p)
        first_line = text.splitlines()[0] if text else ""
        if is_shadow(p, first_line):
            continue
        out.append((p, load_frontmatter(text.splitlines()), text))
    return out


def has_etdd_shadow(root):
    """True if an E-TDD shadow exported from devPNT exists in solutions/.
    In Hybrid mode the approved E-TDD (exported BEFORE implementation) is the
    design authorization that replaces the IN_PROGRESS ANALYSIS."""
    sol = root / "ai_docs" / "solutions"
    if not sol.is_dir():
        return False
    return any("tdd" in p.name.lower() for p in sol.glob("SHADOW_*.md"))


def iter_files(target):
    if target.is_file():
        yield target
        return
    for dirpath, dirnames, filenames in os.walk(target):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
        for name in filenames:
            yield Path(dirpath) / name


# ---------------------------------------------------------------------- git

def git_available(root):
    try:
        r = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
                           cwd=str(root), capture_output=True, text=True, timeout=10)
        return r.returncode == 0 and r.stdout.strip() == "true"
    except Exception:
        return False


def git_head(root):
    try:
        r = subprocess.run(["git", "rev-parse", "--short=12", "HEAD"],
                           cwd=str(root), capture_output=True, text=True, timeout=10)
        return r.stdout.strip() if r.returncode == 0 else ""
    except Exception:
        return ""


def git_has_changes(root, rel_path):
    """True if there are tracked/untracked changes under rel_path."""
    try:
        rel = rel_path.replace("\\", "/")
        r = subprocess.run(["git", "status", "--porcelain", "--", rel],
                           cwd=str(root), capture_output=True, text=True, timeout=30)
        return r.returncode == 0 and bool(r.stdout.strip())
    except Exception:
        return False


def git_changed_since(root, ref, rel_path):
    """Files changed (tracked + untracked) under rel_path since ref. None if ref unresolvable."""
    try:
        r = subprocess.run(["git", "diff", "--name-only", ref, "--", rel_path],
                           cwd=str(root), capture_output=True, text=True, timeout=30)
        if r.returncode != 0:
            return None
        changed = [l.strip() for l in r.stdout.splitlines() if l.strip()]
        r2 = subprocess.run(["git", "ls-files", "--others", "--exclude-standard", "--", rel_path],
                            cwd=str(root), capture_output=True, text=True, timeout=30)
        if r2.returncode == 0:
            changed += [l.strip() for l in r2.stdout.splitlines() if l.strip()]
        return sorted(set(changed))
    except Exception:
        return None


# -------------------------------------------------------------------- index

def build_index(root):
    rows = []
    for p, meta, _ in list_analyses(root):
        rows.append((
            meta.get("id", "?"),
            meta.get("feature", p.stem.replace("ANALYSIS_", "")),
            meta.get("level", ""),
            meta.get("status", "?"),
            meta.get("start_date", ""),
            meta.get("end_date", ""),
            "solutions/" + p.name,
        ))
    rows.sort(key=lambda r: r[0])
    lines = [INDEX_HEADER,
             "# Feature History (generated)",
             "",
             "| ID | Feature | Level | Status | Started | Finished | Doc |",
             "|---|---|---|---|---|---|---|"]
    for r in rows:
        lines.append("| " + " | ".join(r) + " |")
    return "\n".join(lines) + "\n"


# "Status:"/"Stato:" line in the body (with or without ** **), prefix before the description
_STATUS_LINE = re.compile(r"^\**\s*(?:status|stato)\s*\**\s*:\s*\**\s*([A-Za-z][\w-]*)", re.I)
# pure metadata lines to skip when picking the fallback description
_META_LINE = re.compile(r"^\**\s*(date|data|task ref|version|versione|owner|autore|branch|agente|agent|created|creato|updated|aggiornato)\b", re.I)


def extract_doc_meta(path):
    """(title, description, status, supersedes) of a canonical doc.

    Recognizes TWO header conventions: the YAML-lite frontmatter
    (description/status/supersedes/title) and the in-body `**Status:** X`
    line (used by ADRs and legacy docs). As a fallback it derives the title
    from the first '# H1' and the description from the first prose line,
    skipping metadata lines.
    """
    text = read_text(path)
    lines = text.splitlines()
    meta = load_frontmatter(lines)
    body = lines
    if lines and lines[0].strip() == "---":
        for i in range(1, min(len(lines), 60)):
            if lines[i].strip() == "---":
                body = lines[i + 1:]
                break

    title = meta.get("title", "")
    if not title:
        for line in body:
            m = re.match(r"^#\s+(.*)$", line)
            if m:
                title = m.group(1).strip()
                break
    title = title or path.stem

    status = meta.get("status", "").upper()
    if not status:
        for line in body[:25]:
            m = _STATUS_LINE.match(line.strip())
            if m:
                status = m.group(1).upper()
                break

    desc = meta.get("description", "")
    if not desc:
        for line in body:
            s = line.strip()
            if not s or s.startswith("#") or s.startswith("<!--") or _META_LINE.match(s):
                continue
            if s.startswith(">"):
                s = s.lstrip(">").strip()
            m = _STATUS_LINE.match(s)
            if m:
                # "Status: X — description": keep the part after the status; if empty, skip
                rest = s[m.end():].strip(" *—–-:.")
                if not rest:
                    continue
                s = rest
            if s:
                desc = s
                break
    desc = re.sub(r"\s+", " ", desc).strip()
    if len(desc) > 160:
        desc = desc[:157].rstrip() + "..."
    return title, desc, status, meta.get("supersedes", "").strip()


def list_canonical_docs(root):
    """[(rel_to_ai_docs, path, (title, desc, status, supersedes))] for canonical docs."""
    ai = root / "ai_docs"
    out = []
    for d in MANIFEST_DIRS:
        base = ai / d
        if not base.is_dir():
            continue
        for p in sorted(base.rglob("*.md")):
            if p.name in GENERATED_DOCS or p.name == "README.md":
                continue
            out.append((p.relative_to(ai).as_posix(), p, extract_doc_meta(p)))
    return out


def build_manifest(root):
    docs = list_canonical_docs(root)
    lines = [MANIFEST_HEADER,
             "# `ai_docs/` document index (generated)",
             "",
             "Complete manifest of the canonical documents. For the reading priority",
             "(must-reads) see the hand-curated `README.md`. The ANALYSIS history is in",
             "`strategic/features_history.md`. `audit/` and `solutions/` are discovery-by-grep,",
             "not manifested here."]
    by_dir = {}
    for rel, _, meta in docs:
        by_dir.setdefault(rel.split("/", 1)[0], []).append((rel, meta))
    for top in MANIFEST_DIRS:
        rows = by_dir.get(top)
        if not rows:
            continue
        lines += ["", f"## {top}/", "",
                  "| Document | Status | Description |", "|---|---|---|"]
        for rel, (title, desc, status, _sup) in rows:
            d = (desc or title).replace("|", "\\|")
            lines.append(f"| `{rel}` | {status or '-'} | {d} |")
    return "\n".join(lines).rstrip() + "\n"


def cmd_index(root):
    if not require_ai_docs(root, "index"):
        return 1
    hist = root / "ai_docs" / "strategic" / "features_history.md"
    hist.parent.mkdir(parents=True, exist_ok=True)
    hist.write_text(build_index(root), encoding="utf-8")
    print(f"[ok] ANALYSIS index regenerated: {hist}")
    # INDEX.md only if canonical docs exist: no empty manifest on minimal projects
    if list_canonical_docs(root):
        manifest = root / "ai_docs" / "INDEX.md"
        manifest.write_text(build_manifest(root), encoding="utf-8")
        print(f"[ok] document manifest regenerated: {manifest}")
    else:
        print("[info] no canonical documents: INDEX.md not generated")
    return 0


# ----------------------------------------------------------------- validate

def has_section(text, aliases):
    return any(a in text for a in aliases)


def cmd_validate(root, strict=False):
    errors, warnings = [], []
    ai = root / "ai_docs"
    if not ai.is_dir():
        if strict:
            print(f"[ERROR] {ai} does not exist: nothing to validate. In --strict mode this "
                  "fails so a wrong working directory cannot produce a green pipeline.")
            return 1
        print(f"[info] {ai} does not exist: nothing to validate (project without SDLC docs).")
        return 0

    # Vision: presence and declared state
    for name in VISION_FILES:
        f = ai / "vision" / name
        if not f.is_file():
            warnings.append(f"vision/{name} missing")
            continue
        head = "\n".join(read_text(f).splitlines()[:12])
        m = re.search(r"(?:Status|Stato):\s*(DRAFT|APPROVED)", head)
        if not m:
            errors.append(f"vision/{name}: missing 'Status: DRAFT|APPROVED' in the first lines")
        elif m.group(1) == "DRAFT":
            warnings.append(f"vision/{name} is DRAFT: not a gating authority, have the user validate it")

    # ANALYSIS: frontmatter and mandatory sections
    seen_ids = {}
    analyses = list_analyses(root)
    for p, meta, text in analyses:
        rel = "solutions/" + p.name
        if not meta:
            errors.append(f"{rel}: frontmatter missing")
            continue
        fid = meta.get("id")
        if not fid:
            errors.append(f"{rel}: 'id' field missing")
        elif fid in seen_ids:
            errors.append(f"{rel}: id '{fid}' duplicated (already used in {seen_ids[fid]})")
        else:
            seen_ids[fid] = rel
        status = meta.get("status", "")
        if status not in VALID_STATES:
            errors.append(f"{rel}: status '{status}' not valid ({'/'.join(sorted(VALID_STATES))})")
        if not meta.get("start_date"):
            errors.append(f"{rel}: 'start_date' missing")
        if status == "COMPLETED" and not meta.get("end_date"):
            errors.append(f"{rel}: COMPLETED without 'end_date'")
        level = meta.get("level")
        if level and level.upper() not in VALID_LEVELS:
            warnings.append(f"{rel}: level '{level}' not recognized ({'/'.join(sorted(VALID_LEVELS))})")
        if not has_section(text, SECURITY_SECTION):
            errors.append(f"{rel}: section '## Security and Threat Model' missing (mandatory)")
        for en, it in ANALYSIS_SECTIONS:
            if not has_section(text, (en, it)):
                warnings.append(f"{rel}: section '{en}' missing")

    # Generated index aligned
    hist = ai / "strategic" / "features_history.md"
    if analyses:
        if not hist.is_file():
            errors.append("strategic/features_history.md missing: run 'sdlc_check.py index'")
        elif norm_text(read_text(hist)) != norm_text(build_index(root)):
            errors.append("strategic/features_history.md not aligned with the ANALYSIS files: run 'sdlc_check.py index'")

    # Canonical document manifest aligned (Poka-Yoke: unindexed file = dirty closure)
    docs = list_canonical_docs(root)
    manifest = ai / "INDEX.md"
    if docs:
        if not manifest.is_file():
            errors.append("ai_docs/INDEX.md missing: run 'sdlc_check.py index'")
        elif norm_text(read_text(manifest)) != norm_text(build_manifest(root)):
            errors.append("ai_docs/INDEX.md not aligned with the canonical documents: run 'sdlc_check.py index'")

    # Canonical document lifecycle: declared status + supersedes coherence
    canon_status = {rel: meta[2] for rel, _, meta in docs}
    for rel, _, (title, desc, status, supersedes) in docs:
        if not status:
            warnings.append(f"{rel}: missing 'status:' in the header (CURRENT/SUPERSEDED/DRAFT/DEPRECATED)")
        elif status not in CANONICAL_STATES:
            warnings.append(f"{rel}: status '{status}' not recognized ({'/'.join(sorted(CANONICAL_STATES))})")
        if supersedes:
            base = os.path.basename(supersedes)
            for other, ost in canon_status.items():
                if (other == supersedes or other.endswith("/" + supersedes)
                        or os.path.basename(other) == base) and ost == "CURRENT":
                    warnings.append(f"{other}: still CURRENT but superseded by {rel} (set status: SUPERSEDED)")

    # Handoff: header and freshness
    hand = ai / "audit" / "handoff.md"
    if hand.is_file():
        m = re.search(r"(?:Date|Data):\s*(\d{4}-\d{2}-\d{2})", read_text(hand))
        if not m:
            warnings.append("audit/handoff.md without a 'Date: YYYY-MM-DD' header")
        else:
            try:
                stamp = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
                age = (datetime.now(timezone.utc) - stamp).days
                if age > 14:
                    warnings.append(f"audit/handoff.md is {age} days old: treat it as history, not current state")
            except ValueError:
                warnings.append("audit/handoff.md: date not parseable")

    for w in warnings:
        print(f"[warn]  {w}")
    for e in errors:
        print(f"[ERROR] {e}")
    print(f"\nValidation: {len(errors)} errors, {len(warnings)} warnings.")
    if strict and warnings and not errors:
        print("[strict] warnings are failures in --strict mode.")
    return 1 if errors or (strict and warnings) else 0


# ------------------------------------------------------------- audit_plan

def parse_audit_plan(root):
    f = root / "ai_docs" / "audit" / "audit_plan.md"
    rows, lines = [], []
    if f.is_file():
        lines = read_text(f).splitlines()
        for i, line in enumerate(lines):
            if not line.strip().startswith("|"):
                continue
            cells = [c.strip() for c in line.strip().strip("|").split("|")]
            if len(cells) < 2:
                continue
            if cells[0].lower() in ("path", "percorso") or set(cells[0]) <= set("-: "):
                continue
            rows.append({
                "line": i,
                "path": cells[0],
                "status": cells[1].upper(),
                "ref": cells[2] if len(cells) > 2 else "",
                "note": cells[3] if len(cells) > 3 else "",
            })
    return f, lines, rows


def cmd_stale(root, hybrid=False):
    if hybrid:
        print("[info] hybrid mode: audit-plan staleness is delegated to devPNT/KL, skipping.")
        return 0
    f, _, rows = parse_audit_plan(root)
    if not rows:
        print(f"[info] no rows in {f}: nothing to check "
              "(audit not initialized, or Hybrid mode where mapping is delegated to devPNT).")
        return 0
    use_git = git_available(root)
    stale = []
    for row in rows:
        if row["status"] != "ANALYZED":
            continue
        rel, ref = row["path"], row["ref"]
        target = root / rel
        if not target.exists():
            print(f"[warn]  {rel}: path does not exist")
            continue
        changed = []
        if use_git and re.fullmatch(r"[0-9a-fA-F]{7,40}", ref or ""):
            res = git_changed_since(root, ref, rel.replace("\\", "/"))
            if res is None:
                print(f"[warn]  {rel}: git ref '{ref}' unresolvable, cannot evaluate")
                continue
            changed = res
        else:
            ts = parse_iso(ref)
            if ts is None:
                print(f"[warn]  {rel}: reference '{ref}' not parseable (neither git hash nor ISO UTC)")
                continue
            for fp in iter_files(target):
                mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=timezone.utc)
                if mtime > ts + MTIME_GRACE:
                    changed.append(str(fp.relative_to(root)).replace("\\", "/"))
        if changed:
            stale.append((rel, changed))

    if not stale:
        print("[ok] no analyzed area was modified after its last recorded analysis.")
        return 0
    print("Areas modified after the last recorded analysis:")
    for rel, changed in stale:
        print(f"  {rel}  ({len(changed)} files)")
        for c in changed[:10]:
            print(f"    - {c}")
        if len(changed) > 10:
            print(f"    ... and {len(changed) - 10} more")
    print("\nAfter re-analyzing, record it with: sdlc_check.py mark <path>")
    return 1


def cmd_mark(root, paths):
    if not require_ai_docs(root, "mark"):
        return 1
    f, lines, rows = parse_audit_plan(root)
    use_git_ref = git_available(root) and not any(
        git_has_changes(root, raw.replace("\\", "/").rstrip("/")) for raw in paths
    )
    ref = git_head(root) if use_git_ref else utc_now_iso()
    by_path = {r["path"].replace("\\", "/").rstrip("/"): r for r in rows}

    if not lines:
        lines = ["# Audit Plan", "",
                 "| Path | Status | Reference | Notes |",
                 "|---|---|---|---|"]
        rows = []

    def row_text(path, note):
        return f"| {path} | ANALYZED | {ref} | {note} |"

    appended = []
    for raw in paths:
        key = raw.replace("\\", "/").rstrip("/")
        display = key + ("/" if (root / key).is_dir() else "")
        existing = by_path.get(key)
        if existing:
            lines[existing["line"]] = row_text(existing["path"], existing["note"])
            print(f"[ok] {existing['path']} -> ANALYZED ({ref})")
        else:
            appended.append(row_text(display, ""))
            print(f"[ok] {display} added as ANALYZED ({ref})")

    if appended:
        insert_at = (max(r["line"] for r in rows) + 1) if rows else len(lines)
        lines[insert_at:insert_at] = appended

    f.parent.mkdir(parents=True, exist_ok=True)
    f.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return 0


def cmd_check(root, strict=False, hybrid=False):
    print("===== validate =====")
    rc_v = cmd_validate(root, strict=strict)
    print("\n===== stale =====")
    rc_s = cmd_stale(root, hybrid=hybrid)
    print(f"\ncheck: {'CLEAN' if not (rc_v or rc_s) else 'NOT CLEAN'} "
          f"(validate rc={rc_v}, stale rc={rc_s})")
    return 1 if (rc_v or rc_s) else 0


# --------------------------------------------------------------------- gate

def cmd_gate(args):
    file_path = args.file or ""
    if args.hook:
        try:
            # bytes -> utf-8-sig: the hook payload is UTF-8 JSON regardless of the
            # console code page; '-sig' strips the BOM (PowerShell pipes)
            raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
            payload = json.loads(raw)
            file_path = (payload.get("tool_input") or {}).get("file_path") or ""
        except Exception:
            return 0  # unparseable input: do not block
    if not file_path:
        return 0
    root = Path(args.root).resolve() if args.root else find_project_root()
    try:
        rel = str(Path(file_path).resolve().relative_to(root)).replace("\\", "/")
    except ValueError:
        return 0  # outside the project: not this gate's concern
    if rel.startswith(("ai_docs/", "tests/", "test/")):
        return 0
    protected = [p.strip().replace("\\", "/").rstrip("/")
                 for p in (args.protected or "").split(";") if p.strip()]
    if not protected:
        return 0
    if not any(rel == p or rel.startswith(p + "/") for p in protected):
        return 0
    for _, meta, _ in list_analyses(root):
        if meta.get("status") == "IN_PROGRESS":
            return 0
    if args.hybrid and has_etdd_shadow(root):
        return 0  # Hybrid design gate: an approved E-TDD shadow authorizes the change
    if args.hybrid:
        sys.stderr.write(
            f"[sdlc gate] '{rel}' is on a protected path but no E-TDD shadow "
            "(solutions/SHADOW_*tdd*.md) exists and no ANALYSIS_*.md is IN_PROGRESS. "
            "In Hybrid mode, export the approved E-TDD shadow from devPNT before implementing.\n")
        return 2
    sys.stderr.write(
        f"[sdlc gate] '{rel}' is on a protected path but no ANALYSIS_*.md is IN_PROGRESS. "
        "Create or reactivate the analysis (agentic-sdlc Phase 3) before modifying this file.\n")
    return 2


# --------------------------------------------------------------------- main

def main(argv=None):
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--root", help="project root (default: walk up until ai_docs/ is found)")

    strict_opt = argparse.ArgumentParser(add_help=False)
    strict_opt.add_argument("--strict", action="store_true",
                            help="fail on warnings and on missing ai_docs/ (for CI)")

    hybrid_opt = argparse.ArgumentParser(add_help=False)
    hybrid_opt.add_argument("--hybrid", action="store_true",
                            help="Hybrid/devPNT mode: audit-plan staleness is delegated to devPNT/KL; "
                                 "the gate also unlocks on an E-TDD shadow")

    ap = argparse.ArgumentParser(prog="sdlc_check.py",
                                 description="Mechanical validator for Agentic SDLC")
    sub = ap.add_subparsers(dest="cmd", required=True)
    sub.add_parser("check", parents=[common, strict_opt, hybrid_opt],
                   help="closure gate: validate + stale in one command")
    sub.add_parser("validate", parents=[common, strict_opt], help="verify ai_docs/ coherence")
    sub.add_parser("index", parents=[common], help="regenerate features_history.md + ai_docs/INDEX.md")
    sub.add_parser("stale", parents=[common, hybrid_opt], help="areas modified after the last analysis")
    mp = sub.add_parser("mark", parents=[common], help="record paths as ANALYZED")
    mp.add_argument("paths", nargs="+", help="paths relative to the project root")
    gp = sub.add_parser("gate", parents=[common, hybrid_opt], help="PreToolUse hook (exit 2 = block)")
    gp.add_argument("--hook", action="store_true", help="read the hook JSON payload from stdin")
    gp.add_argument("--file", help="file path to evaluate (alternative to --hook)")
    gp.add_argument("--protected", default="", help="protected prefixes separated by ';' (e.g. \"src/auth;src/crypto\")")

    args = ap.parse_args(argv)
    if args.cmd == "gate":
        return cmd_gate(args)

    root = Path(args.root).resolve() if args.root else find_project_root()
    if args.cmd == "check":
        return cmd_check(root, strict=args.strict, hybrid=args.hybrid)
    if args.cmd == "validate":
        return cmd_validate(root, strict=args.strict)
    if args.cmd == "index":
        return cmd_index(root)
    if args.cmd == "stale":
        return cmd_stale(root, hybrid=args.hybrid)
    if args.cmd == "mark":
        return cmd_mark(root, args.paths)
    return 0


if __name__ == "__main__":
    sys.exit(main())
