#!/usr/bin/env python3
"""pi-ignore — single-source rule parser & matcher (zero dependencies).

Unified ignore rules for pi: any skill's guard extension is automatically
skipped for ignored paths / disabled capabilities. Rules live in `.piignore`
files (gitignore syntax + capability prefixes).

Three rule sources (priority high → low):
  1. project   .pi/.piignore            (relative to project root)
  2. global    ~/.pi/.piignore          (relative to $HOME)
  3. capability-owned: <skill dir>/.piignore, <plugin dir>/.piignore
              (patterns are unanchored: match at any depth)

Rule types:
  node_modules/          path rule   → guards are skipped for matching paths
  skill:wekan-task-manager             → that skill is fully disabled
  mcp:github                           → all tools of that MCP server blocked
  tool:github__*                       → matching tools blocked
  !rule                  negation (same file, later wins)
  # comment / blank lines ignored

Precedence: ! negation > positive; project > global > owned; later wins
within the same file. Block (tool/mcp) wins over skip (path/skill).

CLI:
  parse        [--cwd DIR]  → normalized JSON rules (regexes precompiled here,
                              single source of truth — JS side only executes)
  list         [--cwd DIR]  → human-readable dump
  check        <path> [--cwd DIR]  → {"ignored": bool, "rule": {...}|null}
  check-tool   <name> [--cwd DIR]  → {"block": bool, "rule": {...}|null}
  check-event  <json> [--cwd DIR]  → full arbitration for one tool call
"""
import fnmatch
import json
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path

# Priorities (higher wins)
P_PROJECT = 30
P_GLOBAL = 20
P_OWNED = 10

# Overridable home for tests / isolation: PI_IGNORE_HOME (same knob as arbiter.mjs)
_HOME = Path(os.environ.get("PI_IGNORE_HOME") or Path.home())
GLOBAL_FILE = _HOME / ".pi" / ".piignore"
SKILL_DIRS = [
    _HOME / ".pi" / "agent" / "skills",
    _HOME / ".agents" / "skills",
]
PLUGIN_DIRS = [
    _HOME / ".pi" / "agent" / "plugins",
    _HOME / ".agents" / "plugins",
]


# ============================================================
# Rule discovery
# ============================================================

def find_project_root(cwd: Path) -> Path | None:
    """Walk up from cwd to find the first directory containing a `.pi` dir."""
    for parent in [cwd] + list(cwd.parents):
        if (parent / ".pi").is_dir():
            return parent
    return None


@dataclass
class Source:
    id: str
    path: Path
    priority: int
    scope: str | None = None       # "skill" | "mcp" | None
    scope_name: str | None = None  # e.g. "wekan-task-manager"
    root: Path | None = None       # anchor base for anchored patterns


def _owned_sources() -> list[Source]:
    """Capability-owned .piignore files: <skill dir>/.piignore, <plugin dir>/.piignore."""
    found: list[Source] = []
    seen: set[Path] = set()
    for base in SKILL_DIRS + PLUGIN_DIRS:
        if not base.is_dir():
            continue
        for child in sorted(base.iterdir()):
            ignore = child / ".piignore"
            if not ignore.is_file():
                continue
            if ignore in seen:
                continue
            seen.add(ignore)
            scope = "skill" if child.parent in SKILL_DIRS else "mcp"
            found.append(Source(
                id=f"{scope}:{child.name}",
                path=ignore,
                priority=P_OWNED,
                scope=scope,
                scope_name=child.name,
                root=None,  # unanchored: patterns match at any depth
            ))
    return found


def load_sources(cwd: Path) -> list[Source]:
    """All rule sources, ordered low→high priority (owned → global → project)."""
    sources: list[Source] = []
    root = find_project_root(cwd)
    if root is not None:
        sources.append(Source(
            id="project", path=root / ".pi" / ".piignore",
            priority=P_PROJECT, root=root,
        ))
    if GLOBAL_FILE.is_file():
        sources.append(Source(
            id="global", path=GLOBAL_FILE,
            priority=P_GLOBAL, root=Path.home(),
        ))
    for s in _owned_sources():
        sources.append(s)
    # stable: owned (10) → global (20) → project (30)
    sources.sort(key=lambda s: s.priority)
    return sources


# ============================================================
# gitignore-style pattern → regex
# ============================================================

@dataclass
class Rule:
    source: str
    priority: int
    type: str                # "path" | "skill" | "mcp" | "tool"
    negated: bool
    # path rules:
    pattern: str | None = None
    regex: str | None = None
    dir_only: bool = False
    anchored: bool = False
    # capability rules:
    name: str | None = None  # skill/mcp name or tool glob

    def to_json(self) -> dict:
        d = {
            "source": self.source,
            "priority": self.priority,
            "type": self.type,
            "negated": self.negated,
        }
        if self.type == "path":
            d.update(pattern=self.pattern, regex=self.regex,
                     dir_only=self.dir_only, anchored=self.anchored)
        else:
            d.update(name=self.name)
        return d


def _translate(pattern: str, dir_only: bool, anchored: bool) -> str:
    """Convert a gitignore path pattern into a full-match regex string."""
    parts = pattern.split("/")
    body = ""
    for i, part in enumerate(parts):
        if part == "**":
            # leading/middle '**' → any-depth prefix; trailing '**' → any-depth suffix
            frag = "(?:/.*)?" if (i == len(parts) - 1 and i > 0) else "(?:.*/)?"
            if body.endswith("/"):
                body = body[:-1] + frag
            else:
                body += frag
        else:
            seg = re.escape(part)
            seg = seg.replace(r"\*", "[^/]*").replace(r"\?", "[^/]")
            if body and not body.endswith("(?:.*/)?") and not body.endswith("(?:/.*)?"):
                body += "/"
            body += seg
    if not anchored:
        body = "(?:.*/)?" + body
    if dir_only:
        body += "(?:/.*)?"
    return body


def parse_pattern(line: str, source: Source) -> Rule | None:
    """Parse one non-comment .piignore line into a Rule."""
    raw = line.rstrip("\n")
    # capability prefixes
    for prefix, rtype in (("skill:", "skill"), ("mcp:", "mcp"), ("tool:", "tool")):
        if raw.startswith(prefix):
            name = raw[len(prefix):].strip()
            if not name:
                return None
            return Rule(source=source.id, priority=source.priority, type=rtype,
                        negated=False, name=name)
        if raw.startswith("!" + prefix):
            name = raw[len(prefix) + 1:].strip()
            if not name:
                return None
            return Rule(source=source.id, priority=source.priority, type=rtype,
                        negated=True, name=name)
    # path rule
    negated = False
    p = raw
    if p.startswith("!"):
        negated = True
        p = p[1:]
    if not p:
        return None
    dir_only = p.endswith("/")
    p = p.rstrip("/")
    if not p:
        return None
    anchored = p.startswith("/")
    p = p.lstrip("/")
    base = source.root or find_project_root(Path.cwd()) or Path.home()
    _ = base  # keep anchor semantics: anchored relative to root; unanchored = any depth
    regex = _translate(p, dir_only, anchored)
    return Rule(source=source.id, priority=source.priority, type="path",
                negated=negated, pattern=p, regex=regex,
                dir_only=dir_only, anchored=anchored)


def load_rules(cwd: Path) -> list[Rule]:
    """All rules from all sources, in application order (low→high priority,
    file order within a source; later rules win, negation supported)."""
    rules: list[Rule] = []
    for source in load_sources(cwd):
        if not source.path.is_file():
            continue
        for line in source.path.read_text(encoding="utf-8").splitlines():
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            rule = parse_pattern(line.rstrip("\n"), source)
            if rule is not None:
                rules.append(rule)
    return rules


# ============================================================
# Matching
# ============================================================

def _rel_path(path: str, root: Path) -> str | None:
    """Project-relative POSIX path for matching, or None if outside root.

    Uses os.path.realpath (loose: resolves symlinks for the existing
    prefix even when the path itself doesn't exist yet). This keeps the
    python and JS sides consistent on macOS where cwd is reported as
    /private/var/... while user-facing paths are /var/...
    """
    p = Path(os.path.realpath(path))
    root = Path(os.path.realpath(root))
    try:
        rel = p.relative_to(root)
    except ValueError:
        return None
    return rel.as_posix()


def match_path(path: str, rules: list[Rule], root: Path) -> tuple[bool, Rule | None]:
    """Last matching rule wins (gitignore semantics). Path must be absolute."""
    ignored = False
    hit: Rule | None = None
    rel = _rel_path(path, root)
    if rel is None:
        # outside project root: try matching against the absolute path too
        rel = Path(os.path.realpath(path)).as_posix().lstrip("/")
        if not rel:
            return False, None
    ignored = False
    hit: Rule | None = None
    for r in rules:
        if r.type != "path" or r.regex is None:
            continue
        if re.fullmatch(r.regex, rel):
            ignored = not r.negated
            hit = r
    return ignored, hit


def match_capability(name: str, rules: list[Rule]) -> tuple[str | None, Rule | None]:
    """Match a tool name against skill/mcp/tool rules.
    Returns ("block", rule) for mcp:/tool:, ("skip", rule) for skill:."""
    blocked = False
    skip = False
    block_rule: Rule | None = None
    skip_rule: Rule | None = None
    for r in rules:
        if r.type == "tool" and r.name:
            if fnmatch.fnmatch(name, r.name):
                blocked = not r.negated
                block_rule = r
        elif r.type == "mcp" and r.name:
            if name == r.name or name.startswith(r.name + "__"):
                blocked = not r.negated
                block_rule = r
        elif r.type == "skill" and r.name:
            if name == r.name:
                skip = not r.negated
                skip_rule = r
    if blocked:
        return "block", block_rule
    if skip:
        return "skip", skip_rule
    return None, None


def skill_roots(cwd: Path) -> dict[str, str]:
    """skill name → skill root dir (from sources with scope=skill)."""
    roots: dict[str, str] = {}
    for s in load_sources(cwd):
        if s.scope == "skill" and s.scope_name:
            roots.setdefault(s.scope_name, str(s.path.parent))
    return roots


# ============================================================
# Event arbitration (used by patch.mjs via parse output; also CLI)
# ============================================================

def arbitrate(tool_name: str, args: dict, cwd: Path) -> dict:
    """Full decision for one tool call:
      {"skip": bool, "block": bool, "reason": str, "rule": dict|None,
       "skill_read": bool, "skill_root": str|None}
    skip  → guards must not run, operation proceeds
    block → tool is disabled by a tool:/mcp: rule
    """
    rules = load_rules(cwd)
    root = find_project_root(cwd) or Path.home()
    result: dict = {"skip": False, "block": False, "reason": "",
                    "rule": None, "skill_read": False, "skill_root": None}

    # 1. tool/mcp/skill capability rules
    kind, rule = match_capability(tool_name, rules)
    if kind == "block":
        result["block"] = True
        result["rule"] = rule.to_json() if rule else None
        result["reason"] = f"⛔ 工具 {tool_name} 已被 pi-ignore 禁用（{rule.source if rule else '?'}: {rule.name if rule else '?'}）"
        return result
    if kind == "skip":
        result["skip"] = True
        result["rule"] = rule.to_json() if rule else None
        result["reason"] = f"pi-ignore: skill:{rule.name if rule else '?'} 已忽略，跳过 guard"

    # 1b. any active skill: rule → guards are skipped entirely (operations
    # cannot be attributed to a skill, so a disabled skill's global guard
    # effect is approximated by skipping all guards; its SKILL.md reads are
    # blocked below). Last matching skill rule wins: !skill:x re-enables.
    last_skill = next((r for r in reversed(rules) if r.type == "skill"), None)
    if last_skill and not last_skill.negated and not result["skip"]:
        result["skip"] = True
        result["rule"] = last_skill.to_json()
        result["reason"] = f"pi-ignore: skill:{last_skill.name} 已忽略，跳过 guard"

    # 2. path rules (edit/write/read/... with a file path arg)
    path_arg = None
    for key in ("file_path", "path", "filePath"):
        v = args.get(key)
        if isinstance(v, str) and v:
            path_arg = v
            break
    if path_arg:
        # skill SKILL.md read interception
        roots = skill_roots(cwd)
        abs_p = str(Path(os.path.realpath(path_arg)))
        for sname, sroot in roots.items():
            sroot_p = Path(os.path.realpath(sroot))
            if abs_p == str(sroot_p / "SKILL.md") or abs_p.startswith(str(sroot_p) + os.sep):
                result["block"] = True
                result["reason"] = f"⛔ skill:{sname} 已被 pi-ignore 忽略，指令不可加载"
                result["rule"] = {"type": "skill", "name": sname, "source": "pi-ignore"}
                result["skill_read"] = True
                result["skill_root"] = sroot
                return result
        ignored, hit = match_path(path_arg, rules, root)
        if ignored and not result["skip"]:
            result["skip"] = True
            result["rule"] = hit.to_json() if hit else None
            result["reason"] = f"pi-ignore: 路径命中规则（{hit.source if hit else '?'}: {hit.pattern if hit else '?'}），跳过 guard"
    return result


# ============================================================
# CLI
# ============================================================

def _emit(data: dict) -> None:
    print(json.dumps(data, ensure_ascii=False))


def cmd_parse(cwd: Path) -> dict:
    rules = load_rules(cwd)
    root = find_project_root(cwd)
    return {
        "version": 1,
        "cwd": str(cwd.resolve()),
        "project_root": str(root) if root else None,
        "home": str(Path.home()),
        "sources": [{
            "id": s.id, "path": str(s.path), "priority": s.priority,
            "scope": s.scope, "scope_name": s.scope_name,
        } for s in load_sources(cwd)],
        "skill_roots": skill_roots(cwd),
        "rules": [r.to_json() for r in rules],
    }


def cmd_list(cwd: Path) -> None:
    for r in load_rules(cwd):
        tag = "!" if r.negated else " "
        if r.type == "path":
            print(f"{tag} [{r.source:^14}] {r.pattern}{'/' if r.dir_only else ''}")
        else:
            print(f"{tag} [{r.source:^14}] {r.type}:{r.name}")


def main() -> int:
    argv = sys.argv[1:]
    cwd = Path.cwd()
    if "--cwd" in argv:
        i = argv.index("--cwd")
        cwd = Path(os.path.realpath(argv[i + 1]))
        argv = argv[:i] + argv[i + 2:]
    if not argv:
        print(__doc__)
        return 1
    sub = argv[0]
    if sub == "parse":
        _emit(cmd_parse(cwd))
        return 0
    if sub == "list":
        cmd_list(cwd)
        return 0
    if sub == "check":
        if len(argv) < 2:
            print("usage: piignore.py check <path> [--cwd DIR]", file=sys.stderr)
            return 2
        rules = load_rules(cwd)
        root = find_project_root(cwd) or Path.home()
        ignored, hit = match_path(argv[1], rules, root)
        _emit({"ignored": ignored, "rule": hit.to_json() if hit else None})
        return 0
    if sub == "check-tool":
        if len(argv) < 2:
            print("usage: piignore.py check-tool <tool_name> [--cwd DIR]", file=sys.stderr)
            return 2
        rules = load_rules(cwd)
        kind, rule = match_capability(argv[1], rules)
        _emit({"block": kind == "block", "skip": kind == "skip",
               "rule": rule.to_json() if rule else None})
        return 0
    if sub == "check-event":
        try:
            event = json.loads(argv[1]) if len(argv) > 1 else json.loads(sys.stdin.read())
        except json.JSONDecodeError:
            print('{"error": "bad json"}', file=sys.stderr)
            return 2
        _emit(arbitrate(event.get("toolName", ""), event.get("input", {}), cwd))
        return 0
    print(f"unknown subcommand: {sub}", file=sys.stderr)
    return 2


if __name__ == "__main__":
    sys.exit(main())
