#!/usr/bin/env python3
"""Mechanical scope-cap check for plans and staged diffs.

Implements the thresholds defined in ``.cursor/rules/plan-size-cap.mdc``:

  * More than 3 canonical surfaces touched.
  * More than 600 net non-test, non-pbxproj lines.
  * Any new top-level module (new dir under MugiApp/Mugi/, tools/, utils/,
    backend/, or new *.py at repo root).

Usage:
    python scripts/check_plan_scope.py --diff
        Run against the staged diff (git diff --cached).

    python scripts/check_plan_scope.py --plan path/to/plan.md
        Estimate scope from a plan file. Counts file-path mentions in code
        fences and prose, then flags surfaces and likely thresholds.

    python scripts/check_plan_scope.py --files file1 file2 ...
        Score an explicit file list (pre-computed by tooling).

Exit codes:
    0 — within cap
    1 — exceeds cap
    2 — script error
"""
from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
from typing import Iterable

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

MAX_SURFACES = 3
MAX_NET_LINES = 600

# (surface_name, list of path-prefix matchers)
SURFACE_MATCHERS: list[tuple[str, list[str]]] = [
    ("chat", ["MugiApp/Mugi/ContentView.swift", "MugiApp/Mugi/ConversationService.swift",
              "MugiApp/Mugi/ChatBubble", "MugiApp/Mugi/MessageList"]),
    ("sidebar", ["MugiApp/Mugi/Sidebar", "utils/conversation_sidebar.py"]),
    ("kanban", ["MugiApp/Mugi/Kanban/", "kanban_storage.py", "utils/kanban_"]),
    ("preferences", ["MugiApp/Mugi/Preferences/", "/preferences"]),
    ("vault", ["MugiApp/Mugi/Vault", "tools/vault_tool.py", "utils/vault"]),
    ("liveview", ["MugiApp/Mugi/LiveView", "live_view_events.py"]),
    ("activity", ["MugiApp/Mugi/Activity/", "activity_bus.py"]),
    ("mesh", ["MugiApp/Mugi/Preferences/MeshPrefsPane.swift", "mesh/", "utils/mesh_"]),
    ("mcp", ["MugiApp/Mugi/Preferences/MCPPrefsPane.swift", "tools/mcp_"]),
    ("plugins", ["MugiApp/Mugi/Preferences/PluginsPrefsPane.swift",
                  "MugiApp/Mugi/Preferences/DashboardPluginsPrefsPane.swift", "plugins/"]),
    ("knowledge", ["MugiApp/Mugi/Preferences/KnowledgePrefsPane.swift", "knowledge/"]),
    ("audio", ["MugiApp/Mugi/Preferences/AudioPrefsPane.swift", "MugiApp/Mugi/TTSManager.swift",
                "audio/", "tts"]),
    ("diagnostics", ["MugiApp/Mugi/Preferences/DiagnosticsPrefsPane.swift", "doctor"]),
    ("subagent", ["agent_loop.py", "agent_loop_config.py", "subagent"]),
]

EXEMPT_PATH_RE = re.compile(
    r"(\.pbxproj$|/tests?/|/test_|^docs/|README|/__pycache__/|\.lock$)"
)


def classify_surfaces(paths: Iterable[str]) -> set[str]:
    hits: set[str] = set()
    for p in paths:
        for surface, matchers in SURFACE_MATCHERS:
            if any(m in p for m in matchers):
                hits.add(surface)
    return hits


def is_new_top_level_module(path: str) -> bool:
    """Heuristic: a path that introduces a new top-level dir under one of the
    monitored roots, OR a brand-new *.py at the repo root.

    Does NOT flag:
      * bare filenames (when running in plan-file mode without a path prefix);
        we'd produce false positives because the plan often mentions test files
        without their dir.
      * paths that already exist anywhere in the repo (matched by basename).
      * paths under tests/, scripts/, docs/, or any *test* / *fixture* dir.
    """
    if "/" not in path:
        return False
    if any(seg in path for seg in ("tests/", "scripts/", "docs/", "fixtures/")):
        return False
    parts = path.split("/")
    if len(parts) >= 2 and parts[0] in ("MugiApp", "tools", "utils", "backend"):
        candidate_dir = os.path.join(REPO_ROOT, parts[0], parts[1])
        if os.path.isdir(candidate_dir) or os.path.isfile(candidate_dir):
            return False
        # Last-chance: maybe basename exists somewhere already (file moved).
        basename = os.path.basename(path)
        for root, _dirs, files in os.walk(REPO_ROOT):
            if basename in files:
                return False
        return True
    return False


def staged_files_with_line_counts() -> list[tuple[str, int]]:
    """Return [(path, net_added_lines)] for the current staged diff."""
    out = subprocess.run(
        ["git", "diff", "--cached", "--numstat"],
        capture_output=True, text=True, check=False, cwd=REPO_ROOT,
    )
    if out.returncode != 0:
        return []
    rows: list[tuple[str, int]] = []
    for line in out.stdout.splitlines():
        parts = line.split("\t")
        if len(parts) != 3:
            continue
        added_str, _removed_str, path = parts
        try:
            added = int(added_str)
        except ValueError:
            added = 0
        rows.append((path, added))
    return rows


def parse_plan_file_paths(plan_path: str) -> list[str]:
    """Extract file path mentions from a plan markdown file (very loose heuristic)."""
    try:
        text = open(plan_path, "r", encoding="utf-8").read()
    except OSError as exc:
        print(f"ERROR: cannot read {plan_path}: {exc}", file=sys.stderr)
        return []
    candidates = set()
    # Match backtick-quoted file-ish strings.
    for m in re.finditer(r"`([^`\s]+\.(?:swift|py|md|json|toml))`", text):
        candidates.add(m.group(1))
    # Match bare paths in code fences and prose (best effort).
    for m in re.finditer(r"(MugiApp/[\w/.-]+\.\w+|tools/[\w/.-]+\.py|utils/[\w/.-]+\.py|backend/[\w/.-]+\.py|tests/[\w/.-]+\.py)", text):
        candidates.add(m.group(1))
    return sorted(candidates)


def score(paths: list[str], net_lines_per_path: dict[str, int] | None = None) -> dict:
    countable = [p for p in paths if not EXEMPT_PATH_RE.search(p)]
    surfaces = classify_surfaces(countable)
    new_modules = [p for p in countable if is_new_top_level_module(p)]
    if net_lines_per_path:
        net_lines = sum(net_lines_per_path.get(p, 0) for p in countable)
    else:
        net_lines = -1  # unknown when scoring a plan-file estimate

    breaches: list[str] = []
    if len(surfaces) > MAX_SURFACES:
        breaches.append(f"surfaces: {len(surfaces)} > {MAX_SURFACES} ({sorted(surfaces)})")
    if net_lines > MAX_NET_LINES:
        breaches.append(f"net_lines: {net_lines} > {MAX_NET_LINES}")
    if new_modules:
        breaches.append(f"new top-level modules: {new_modules[:5]}{'…' if len(new_modules) > 5 else ''}")

    return {
        "surfaces": sorted(surfaces),
        "net_lines": net_lines,
        "new_modules": new_modules,
        "breaches": breaches,
        "files_counted": len(countable),
        "files_total": len(paths),
    }


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description="Mugi plan-size cap checker.")
    g = parser.add_mutually_exclusive_group(required=True)
    g.add_argument("--diff", action="store_true",
                   help="Score the current staged diff.")
    g.add_argument("--plan", type=str, help="Path to a plan markdown file.")
    g.add_argument("--files", nargs="+", help="Explicit list of file paths.")
    args = parser.parse_args(argv)

    if args.diff:
        rows = staged_files_with_line_counts()
        paths = [p for p, _ in rows]
        per = {p: n for p, n in rows}
        result = score(paths, per)
    elif args.plan:
        paths = parse_plan_file_paths(args.plan)
        if not paths:
            print(f"WARN: no file paths found in plan {args.plan}", file=sys.stderr)
        result = score(paths, None)
    else:
        result = score(args.files, None)

    print(f"surfaces touched: {len(result['surfaces'])} {result['surfaces']}")
    print(f"net lines:        {result['net_lines'] if result['net_lines'] >= 0 else 'unknown (plan estimate)'}")
    print(f"new modules:      {result['new_modules'] or 'none'}")
    print(f"files counted:    {result['files_counted']} / {result['files_total']} (after exemptions)")
    if result["breaches"]:
        print("")
        print("BREACHES (split required, see .cursor/rules/plan-size-cap.mdc):")
        for b in result["breaches"]:
            print(f"  - {b}")
        return 1
    print("\nWithin cap.")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv[1:]))
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        sys.exit(2)
