"""PRD Plugin enforcement gate.

One validator that other layers (a git pre-commit hook, CI, host-agent hooks)
all call, so enforcement logic lives in exactly one place. It composes the
existing read-only validators and adds checks seeded by real failures, and it
makes the dormant `.prd_plugin/config.json` policy flags actually enforced.

Two modes:

  prd_gate.py check     repo-state gate: scan canonical state + version markers
                        and fail (exit 1) on any error-severity finding. For
                        pre-commit hooks and CI.

  prd_gate.py decision  given a proposed action, consult automation.autonomy_level
                        and the floor and print allow | ask | block. For
                        host-agent action hooks (advisory).
"""

import argparse
import json
import re
import sys
from pathlib import Path

_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
    sys.path.insert(0, str(_HERE))


# --- helpers ---------------------------------------------------------------

STATE_COLLECTIONS = {
    ".prd_plugin/state/requests.json": "requests",
    ".prd_plugin/state/tracking.json": "records",
    ".prd_plugin/state/decisions.json": "decisions",
    ".prd_plugin/state/health.json": "findings",
    ".prd_plugin/state/changelog.json": "changes",
    ".prd_plugin/state/evidence.json": "records",
}

# Action classes that always require explicit human consent — the floor. These
# hold in every autonomy tier (see project-decision-policy / blocker-resolution).
FLOOR_ACTIONS = frozenset({
    "commit_secret",
    "push",
    "merge_main",
    "force_push",
    "publish",
    "edit_other_repo",
    "irreversible_external",
    "spend_money",
    "delete_foreign_data",
})

AUTONOMY_LEVELS = ("autonomous", "key_decision", "guided")

# Decisions that a key_decision tier must surface even when confident.
KEY_ACTIONS = frozenset({
    "architecture",
    "scope_change",
    "irreversible",
    "outward_facing",
    "high_risk",
    "release",
})


def _read_json(path):
    return json.loads(Path(path).read_text(encoding="utf-8-sig"))


def _load_config(root):
    path = Path(root) / ".prd_plugin" / "config.json"
    if path.is_file():
        try:
            return _read_json(path)
        except Exception:
            return {}
    return {}


def _finding(check, severity, summary, action=""):
    return {"check": check, "severity": severity, "summary": summary, "required_action": action}


# --- checks ----------------------------------------------------------------

def check_duplicate_ids(root):
    """Duplicate `id` within any state collection (the REQ-002 bug class)."""
    findings = []
    for rel, key in STATE_COLLECTIONS.items():
        path = Path(root) / rel
        if not path.is_file():
            continue
        try:
            data = _read_json(path)
        except Exception:
            continue
        items = data.get(key, []) if isinstance(data, dict) else []
        seen, dupes = set(), set()
        for item in items:
            if isinstance(item, dict) and "id" in item:
                rid = item["id"]
                if rid in seen:
                    dupes.add(rid)
                seen.add(rid)
        for rid in sorted(dupes):
            findings.append(_finding(
                "duplicate_id", "error",
                f"Duplicate id {rid} in {rel}.",
                f"Renumber one record so {rid} is unique. Allocate IDs with the MCP tool "
                "prd_next_id (or the record tools like prd_open_goal) instead of hand-computing them.",
            ))
    return findings


def check_implemented_requires_graduated_to(root, config):
    """Honor requests.implemented_requires_graduated_to (previously unenforced)."""
    reqs_cfg = (config.get("requests") or {})
    if not reqs_cfg.get("implemented_requires_graduated_to"):
        return []
    path = Path(root) / ".prd_plugin" / "state" / "requests.json"
    if not path.is_file():
        return []
    try:
        data = _read_json(path)
    except Exception:
        return []
    findings = []
    for r in data.get("requests", []):
        if not isinstance(r, dict):
            continue
        if r.get("status") == "implemented" and not r.get("graduated_to"):
            findings.append(_finding(
                "implemented_without_graduated_to", "error",
                f"{r.get('id')} is implemented but has no graduated_to links.",
                "Add graduated_to artifacts or change the status.",
            ))
    return findings


def check_stranded_outbox(root):
    """Outbox package that was never carried through to the hub (ai-collab-v3 class)."""
    outbox = Path(root) / ".prd_plugin" / "outbox"
    if not outbox.is_dir():
        return []
    findings = []
    for pkg in sorted(outbox.rglob("*.json")):
        findings.append(_finding(
            "stranded_outbox", "warning",
            f"Request package {pkg.relative_to(Path(root))} is in the outbox.",
            "Complete the import into the hub inbox and verify arrival, or report the blocker; the outbox is not a destination.",
        ))
    return findings


# Surfaces owned by the plugin. A downstream request that names one of these is
# a PRD Plugin bug, not a project bug — it belongs upstream. Deliberately narrow
# (script/hook filenames, skill names, the product name) so ordinary requests
# that merely mention .prd_plugin state are not flagged.
PLUGIN_SURFACE_RE = re.compile(
    r"\bprd[_-](?:gate|graph|status|install|doctor|stop_guard|precommit_gate|nudge|"
    r"log_skill|session_report|hooks|reflections?|install_skills)\b"
    r"|\bprd-plugin\b|\bPRD Plugin\b"
    r"|\bproject-(?:decision-policy|request-intake|git-workflow|memory|health|"
    r"session-close|test-driven-implementation|verification-before-completion|"
    r"change-request|local-integration|fold-it-in|planning-lifecycle)\b",
    re.IGNORECASE,
)
HUB_MARKERS = ("scripts/prd_install.py", "templates/repo-skeleton")


def _is_hub_repo(root):
    return all((Path(root) / m).exists() for m in HUB_MARKERS)


def check_unsubmitted_plugin_requests(root):
    """Downstream request about plugin-owned surfaces that was never submitted upstream.

    A plugin bug living only in local state is an unreported plugin bug. The
    stranded_outbox check catches packages that reached the outbox; this catches
    the ones that never got exported at all (ai-collab-v3 REQ-010/REQ-038).
    """
    root = Path(root)
    if _is_hub_repo(root):
        return []  # every hub request is about the plugin, by definition
    requests_path = root / ".prd_plugin" / "state" / "requests.json"
    if not requests_path.is_file():
        return []
    try:
        data = _read_json(requests_path)
    except Exception:
        return []
    records = data.get("requests", []) if isinstance(data, dict) else []
    findings = []
    for rec in records:
        if not isinstance(rec, dict):
            continue
        if rec.get("status") in ("rejected", "superseded"):
            continue
        if rec.get("upstream_submission") or rec.get("upstream_request_id"):
            continue
        text = " ".join(s for s in _strings_in(rec) if isinstance(s, str))
        if not PLUGIN_SURFACE_RE.search(text):
            continue
        findings.append(_finding(
            "unsubmitted_plugin_request", "warning",
            f"{rec.get('id', '<no id>')} is about PRD Plugin itself but was never submitted upstream.",
            "Submit it now: python scripts/request_autosubmit.py --request-id "
            f"{rec.get('id', '<id>')} — it flags the record, exports the package, and "
            "delivers it to the hub when one is configured. A plugin bug that lives only "
            "in local state is an unreported plugin bug. Requests filed through the "
            "prd_file_request MCP tool submit themselves; this one predates that, or was "
            "hand-written into requests.json.",
        ))
    return findings


TIMESCALE_RE = re.compile(
    r"\b\d+\s*(?:-|–|—|to)\s*\d+\s*(?:hour|day|week|month|quarter|year|sprint)s?\b"
    r"|\bin\s+\d+\s+(?:hour|day|week|month|quarter|year|sprint)s?\b"
    r"|\ba few\s+(?:hour|day|week|month)s?\b",
    re.IGNORECASE,
)
TIMESCALE_FILES = {
    ".prd_plugin/state/requests.json": "requests",
    ".prd_plugin/state/tracking.json": "records",
    ".prd_plugin/state/decisions.json": "decisions",
}


def _strings_in(value):
    if isinstance(value, str):
        yield value
    elif isinstance(value, dict):
        for v in value.values():
            yield from _strings_in(v)
    elif isinstance(value, list):
        for v in value:
            yield from _strings_in(v)


def check_timescales(root):
    """Warn when project truth contains a time/duration estimate (estimation.md)."""
    findings = []
    for rel, key in TIMESCALE_FILES.items():
        path = Path(root) / rel
        if not path.is_file():
            continue
        try:
            data = _read_json(path)
        except Exception:
            continue
        for item in (data.get(key, []) if isinstance(data, dict) else []):
            if not isinstance(item, dict):
                continue
            rid = item.get("id", "?")
            for text in _strings_in(item):
                m = TIMESCALE_RE.search(text)
                if m:
                    findings.append(_finding(
                        "timescale_estimate", "warning",
                        f"{rid} in {rel} contains a time estimate: \"{m.group(0).strip()}\".",
                        "Use complexity + confidence ratings instead (.prd_plugin/method/estimation.md).",
                    ))
                    break
    return findings


def check_version_markers(root):
    """All in-repo version markers must agree (the 0.5.x marker-drift class)."""
    try:
        from release_check import discover_installed_version_markers
    except Exception:
        return []
    markers = discover_installed_version_markers(str(root))
    versions = {str(v) for v in markers.values() if v is not None}
    if len(versions) > 1:
        detail = ", ".join(f"{k}={v}" for k, v in sorted(markers.items()))
        return [_finding(
            "version_marker_drift", "error",
            f"Version markers disagree: {detail}.",
            "Align all plugin manifests and installed_version markers to one version.",
        )]
    return []


def check_state_consistency(root):
    """Compose the existing state consistency validator."""
    try:
        from state_consistency_check import build_consistency_report
    except Exception as exc:
        return [_finding(
            "state_consistency_validator_unavailable", "error",
            f"Required state consistency validator is unavailable: {type(exc).__name__}: {exc}",
            "Install state_consistency_check.py beside prd_gate.py with the downstream runtime scripts.",
        )]
    try:
        report = build_consistency_report(str(root))
    except Exception as exc:  # malformed state
        return [_finding("state_consistency", "error", f"State could not be analyzed: {exc}", "Fix state inputs.")]
    out = []
    for f in report.get("findings", []):
        out.append(_finding("state_consistency", f.get("severity", "error"),
                            f.get("summary", "state consistency finding"),
                            f.get("required_action", "")))
    return out


def check_wiki_inline_markdown_links(root, config):
    wiki_config = ((config.get("knowledge") or {}).get("llm_wiki") or {})
    if wiki_config.get("enabled", True) is False:
        return []
    if wiki_config.get("require_inline_md_links", True) is False:
        return []
    try:
        from prd_wiki_backfill import wiki_link_report
    except (ImportError, ModuleNotFoundError):
        return [_finding(
            "wiki_inline_link_validator_unavailable", "error",
            "Wiki inline-link policy is enabled but prd_wiki_backfill.py is unavailable.",
            "Install prd_wiki_backfill.py beside prd_gate.py with the downstream runtime scripts.",
        )]
    report = wiki_link_report(root, wiki_dir=wiki_config.get("wiki_dir", "wiki"))
    return [_finding(
        "wiki_inline_markdown_link", finding.get("severity", "error"),
        finding["summary"],
        "Run prd_wiki_backfill.py --lint-links --fix, then resolve any ambiguous or missing targets.",
    ) for finding in report.get("findings", [])]


def check_reflection_config(root, config=None):
    """Reject malformed direct edits to the reflection question bank."""
    config = _load_config(root) if config is None else config
    if "reflection" not in config:
        return []
    try:
        from prd_reflections import validate_reflection
        validate_reflection(config["reflection"])
        return []
    except Exception as exc:
        return [_finding(
            "reflection_config", "error",
            f"Reflection configuration is invalid: {exc}",
            "Repair .prd_plugin/config.json#reflection or use prd_reflections.py/MCP reflection CRUD tools.",
        )]


def run_checks(root="."):
    root = Path(root).resolve()
    config = _load_config(root)
    findings = []
    findings += check_duplicate_ids(root)
    findings += check_implemented_requires_graduated_to(root, config)
    findings += check_stranded_outbox(root)
    findings += check_unsubmitted_plugin_requests(root)
    findings += check_timescales(root)
    findings += check_version_markers(root)
    findings += check_reflection_config(root, config)
    findings += check_state_consistency(root)
    findings += check_wiki_inline_markdown_links(root, config)
    status = "fail" if any(f["severity"] == "error" for f in findings) else (
        "attention" if findings else "ok")
    return {
        "status": status,
        "repo_root": str(root),
        "summary": {
            "errors": sum(1 for f in findings if f["severity"] == "error"),
            "warnings": sum(1 for f in findings if f["severity"] == "warning"),
        },
        "findings": findings,
    }


# --- decision (tier) gate --------------------------------------------------

def _set_autonomy(repo_root, level):
    """Set (or show) automation.autonomy_level in .prd_plugin/config.json."""
    path = Path(repo_root) / ".prd_plugin" / "config.json"
    if not path.is_file():
        print(f"error: {path} not found", file=sys.stderr)
        return 1
    try:
        config = _read_json(path)
    except Exception as exc:
        print(f"error: could not read config: {exc}", file=sys.stderr)
        return 1
    automation = config.setdefault("automation", {})
    if level is None:
        print(automation.get("autonomy_level", "key_decision"))
        return 0
    automation["autonomy_level"] = level
    path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print(f"autonomy_level set to {level}")
    return 0


def gate_decision(action, reversible=True, autonomy_level="key_decision", floor=False):
    """Return 'ask' or 'allow' for a proposed action under the configured tier.

    The floor always returns 'ask' (escalate for explicit consent). Otherwise the
    tier sets the threshold for the discretionary middle.
    """
    if floor or action in FLOOR_ACTIONS:
        return "ask"
    if autonomy_level == "autonomous":
        return "allow"
    if autonomy_level == "guided":
        return "allow" if (reversible and action in ("trivial", "read_only")) else "ask"
    # key_decision (default)
    if action in KEY_ACTIONS or not reversible:
        return "ask"
    return "allow"


# --- CLI -------------------------------------------------------------------

def _format_markdown(report):
    lines = ["# PRD Plugin Gate", "", f"Status: `{report['status']}`", "", "## Findings", ""]
    if not report["findings"]:
        lines.append("- None")
    for f in report["findings"]:
        lines.append(f"- `{f['severity']}` [{f['check']}]: {f['summary']}"
                     + (f" — {f['required_action']}" if f.get("required_action") else ""))
    return "\n".join(lines) + "\n"


def main(argv=None):
    parser = argparse.ArgumentParser(description="PRD Plugin enforcement gate.")
    sub = parser.add_subparsers(dest="mode")

    pc = sub.add_parser("check", help="Repo-state gate (pre-commit / CI).")
    pc.add_argument("--repo-root", default=".")
    pc.add_argument("--format", choices=("json", "markdown"), default="markdown")
    pc.add_argument("--output")

    pd = sub.add_parser("decision", help="Tier decision for a proposed action.")
    pd.add_argument("--action", required=True)
    pd.add_argument("--autonomy-level", default=None)
    pd.add_argument("--repo-root", default=".")
    rev = pd.add_mutually_exclusive_group()
    rev.add_argument("--reversible", dest="reversible", action="store_true", default=True,
                     help="treat the action as reversible (default)")
    rev.add_argument("--irreversible", dest="reversible", action="store_false",
                     help="treat the action as irreversible")
    pd.add_argument("--floor", action="store_true")

    ps = sub.add_parser("set-autonomy", help="Set or show automation.autonomy_level in config.")
    ps.add_argument("level", nargs="?", choices=AUTONOMY_LEVELS,
                    help="Omit to show the current level.")
    ps.add_argument("--repo-root", default=".")

    args = parser.parse_args(argv)

    if args.mode == "set-autonomy":
        return _set_autonomy(args.repo_root, args.level)

    if args.mode == "decision":
        level = args.autonomy_level
        if level is None:
            cfg = _load_config(args.repo_root)
            level = (cfg.get("automation") or {}).get("autonomy_level", "key_decision")
        verdict = gate_decision(args.action, reversible=args.reversible,
                                autonomy_level=level, floor=args.floor)
        print(json.dumps({"action": args.action, "autonomy_level": level, "verdict": verdict}))
        return 0

    # default: check
    repo_root = getattr(args, "repo_root", ".")
    report = run_checks(repo_root)
    text = json.dumps(report, indent=2) if getattr(args, "format", "markdown") == "json" else _format_markdown(report)
    out = getattr(args, "output", None)
    if out:
        Path(out).parent.mkdir(parents=True, exist_ok=True)
        Path(out).write_text(text, encoding="utf-8")
    print(text)
    return 1 if report["status"] == "fail" else 0


if __name__ == "__main__":
    raise SystemExit(main())
