#!/usr/bin/env python3
"""Static pass over a Claude Code plugin's definitions -> one JSON document.

Usage: plugin-inventory <path|name> [--json] [--catalog PATH]

Reads a plugin's skills/, agents/, hooks/, and bin/, and reports the static
signals defined in design/spec.md §5.1 (verb ratios, procedural density,
duplicated command blocks, rule tables, script coverage, tool-allowlist
shape). Stdlib only, offline, no PyYAML — this file itself is the plugin's
own thesis (FR-8): reading a plugin's structure is a pure-script step by
every one of the five determinism tests in references/boundary-rubric.md.

No --json: pretty-printed (indent=2), for humans.
--json: compact single line, for piping into bin/offload-scan or tests.
"""
import argparse
import glob
import json
import os
import re
import sys

PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_CATALOG = os.path.join(PLUGIN_ROOT, "references", "signals-catalog.md")
CACHE_GLOB = os.path.expanduser("~/.claude/plugins/cache/*/{name}/*/")


# --------------------------------------------------------------------------
# signals-catalog.md loader
# --------------------------------------------------------------------------

KNOWN_SECTIONS = {"script-verbs", "judgment-verbs", "mechanical-tools", "thresholds"}


def load_catalog(path):
    """Reads only the fenced code block immediately following each
    recognized `## <section>` heading; everything else is prose for humans
    and is never parsed (see references/signals-catalog.md's own header for
    why: markdown and a hand-rolled comment syntax collide on bare `#`)."""
    with open(path, encoding="utf-8") as f:
        text = f.read()

    sections = {}
    heading_re = re.compile(r"^##\s+([a-z-]+)\s*$", re.MULTILINE)
    for m in heading_re.finditer(text):
        name = m.group(1)
        if name not in KNOWN_SECTIONS:
            continue
        block = re.search(r"```\S*\n(.*?)```", text[m.end():], re.DOTALL)
        if not block:
            continue
        body = block.group(1)
        if name == "thresholds":
            values = {}
            for raw in body.split("\n"):
                line = raw.split("#", 1)[0].strip()
                if ":" in line:
                    k, v = line.split(":", 1)
                    values[k.strip()] = float(v.strip())
            sections[name] = values
        else:
            items = set()
            for raw in body.split("\n"):
                for item in raw.split(","):
                    item = item.strip()
                    if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", item):
                        items.add(item)
            sections[name] = items
    missing = KNOWN_SECTIONS - sections.keys()
    if missing:
        sys.exit(f"error: {path} is missing section(s): {', '.join(sorted(missing))}")
    return sections


# --------------------------------------------------------------------------
# target resolution
# --------------------------------------------------------------------------

def semver_key(dirname):
    m = re.match(r"^(\d+)\.(\d+)\.(\d+)", dirname)
    return tuple(int(g) for g in m.groups()) if m else (0, 0, 0)


def resolve_target(target):
    if os.sep in target or os.path.isdir(target):
        path = os.path.abspath(target)
        if not os.path.isdir(path):
            sys.exit(f"error: no such directory: {path}")
        return path, "path"

    candidates = []
    for entry in glob.glob(CACHE_GLOB.format(name=re.escape(target)).replace(r"\*", "*")):
        if os.path.exists(os.path.join(entry, ".orphaned_at")):
            continue
        candidates.append(entry.rstrip("/"))
    if not candidates:
        sys.exit(f"error: no cached plugin found for name {target!r} "
                  f"(and not a directory)")
    candidates.sort(key=lambda p: semver_key(os.path.basename(p)))
    return candidates[-1], "oleks-local"


# --------------------------------------------------------------------------
# frontmatter (no PyYAML — this environment's SKILL.md/agent.md frontmatter
# is a narrow subset: scalar `key: value` lines and `key: |` block scalars)
# --------------------------------------------------------------------------

def parse_frontmatter(text):
    if not text.startswith("---"):
        return {}, text
    lines = text.split("\n")
    if lines[0].strip() != "---":
        return {}, text
    end = None
    for i in range(1, len(lines)):
        if lines[i].strip() == "---":
            end = i
            break
    if end is None:
        return {}, text
    meta = {}
    i = 1
    while i < end:
        line = lines[i]
        if not line.strip() or line.lstrip().startswith("#"):
            i += 1
            continue
        m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line)
        if not m:
            i += 1
            continue
        key, val = m.group(1), m.group(2).strip()
        if val in ("|", "|-", ">", ">-"):
            block = []
            i += 1
            while i < end and (lines[i].startswith(" ") or lines[i].startswith("\t") or not lines[i].strip()):
                block.append(lines[i].strip())
                i += 1
            meta[key] = " ".join(b for b in block if b)
            continue
        meta[key] = val.strip('"\'')
        i += 1
    body = "\n".join(lines[end + 1:])
    return meta, body


def split_list(value):
    if not value:
        return []
    return [v.strip() for v in value.split(",") if v.strip()]


# --------------------------------------------------------------------------
# signal computation
# --------------------------------------------------------------------------

def verb_counts(body, verbs):
    total = 0
    for verb in verbs:
        total += len(re.findall(r"\b" + re.escape(verb) + r"\b", body, re.IGNORECASE))
    return total


def procedural_density(body):
    list_item_re = re.compile(r"^\s*(?:\d+[.)]|[-*])\s+.+$", re.MULTILINE)
    items = list_item_re.findall(body)
    if not items:
        return 0.0
    with_command = sum(1 for item in items if "`" in item)
    return round(with_command / len(items), 3)


def extract_command_blocks(body):
    blocks = re.findall(r"```(?:bash|sh|shell)?\n(.*?)```", body, re.DOTALL)
    sigs = {}
    for block in blocks:
        first_line = next((ln.strip() for ln in block.splitlines() if ln.strip()), None)
        if not first_line:
            continue
        sig = re.sub(r"\s+", " ", first_line)
        sigs[sig] = sigs.get(sig, 0) + 1
    return [{"sig": sig, "count": count} for sig, count in sorted(sigs.items())]


def count_rule_tables(body):
    return len(re.findall(r"^\|.+\|\s*\n\|[\s:|-]+\|\s*$", body, re.MULTILINE))


def find_references(body, references_dir):
    if not os.path.isdir(references_dir):
        return []
    known = set(os.listdir(references_dir))
    found = set()
    for m in re.finditer(r"([A-Za-z0-9_-]+\.md)", body):
        if m.group(1) in known:
            found.add(m.group(1))
    return sorted(found)


def wc(text):
    return len(text.split())


# --------------------------------------------------------------------------
# per-plugin walk
# --------------------------------------------------------------------------

def walk_skills(plugin_path, catalog):
    """Returns (skills, bodies) — bodies maps skill name -> body text, kept
    out of the JSON output but needed by build_aggregate for drift detection."""
    skills = []
    bodies = {}
    skills_dir = os.path.join(plugin_path, "skills")
    if not os.path.isdir(skills_dir):
        return skills, bodies
    for name in sorted(os.listdir(skills_dir)):
        skill_md = os.path.join(skills_dir, name, "SKILL.md")
        if not os.path.isfile(skill_md):
            continue
        with open(skill_md, encoding="utf-8") as f:
            text = f.read()
        meta, body = parse_frontmatter(text)
        script_v = verb_counts(body, catalog["script-verbs"])
        judgment_v = verb_counts(body, catalog["judgment-verbs"])
        ratio = round(script_v / (script_v + judgment_v), 3) if (script_v + judgment_v) else 0.0
        skill_name = meta.get("name", name)
        bodies[skill_name] = body
        skills.append({
            "name": skill_name,
            "description_words": wc(meta.get("description", "")),
            "body_words": wc(body),
            "allowed_tools": split_list(meta.get("allowed-tools", meta.get("tools", ""))),
            "procedural_density": procedural_density(body),
            "script_verbs": script_v,
            "judgment_verbs": judgment_v,
            "verb_ratio": ratio,
            "command_blocks": extract_command_blocks(body),
            "rule_tables": count_rule_tables(body),
            "references": find_references(body, os.path.join(skills_dir, name, "references")),
        })
    return skills, bodies


def walk_agents(plugin_path, catalog):
    agents = []
    agents_dir = os.path.join(plugin_path, "agents")
    if not os.path.isdir(agents_dir):
        return agents
    mechanical = catalog["mechanical-tools"]
    for fname in sorted(os.listdir(agents_dir)):
        if not fname.endswith(".md"):
            continue
        with open(os.path.join(agents_dir, fname), encoding="utf-8") as f:
            text = f.read()
        meta, body = parse_frontmatter(text)
        tools = split_list(meta.get("tools", ""))
        mech = sum(1 for t in tools if t in mechanical)
        agents.append({
            "name": meta.get("name", fname[:-3]),
            "model": meta.get("model", ""),
            "tools": tools,
            "body_words": wc(body),
            "mechanical_tool_share": round(mech / len(tools), 3) if tools else 0.0,
        })
    return agents


def walk_hooks(plugin_path):
    """Returns (hooks, texts) — texts maps hook filename -> file content, kept
    out of the JSON output but needed by build_aggregate for drift detection.

    Only files that look like actual hook scripts are counted: referenced by
    hooks.json, executable, or shebang'd. `hooks.json` itself is always
    excluded — it is configuration, not logic — and anything else that fails
    all three checks (READMEs, notes) is prose, not shipped executable
    volume (kotkan/claude-plugin-inference-arbitrage#10)."""
    hooks = []
    texts = {}
    hooks_dir = os.path.join(plugin_path, "hooks")
    if not os.path.isdir(hooks_dir):
        return hooks, texts
    hooks_json = os.path.join(plugin_path, ".claude-plugin", "hooks.json")
    events_by_file = {}
    if os.path.isfile(hooks_json):
        try:
            with open(hooks_json, encoding="utf-8") as f:
                data = json.load(f)
            for event, entries in (data.get("hooks") or {}).items():
                for entry in entries:
                    for hook in entry.get("hooks", []):
                        cmd = hook.get("command", "")
                        m = re.search(r"([\w.-]+\.sh)\b", cmd)
                        if m:
                            events_by_file[m.group(1)] = event
        except (json.JSONDecodeError, OSError):
            pass
    for fname in sorted(os.listdir(hooks_dir)):
        if fname == "hooks.json":
            continue
        fpath = os.path.join(hooks_dir, fname)
        if not os.path.isfile(fpath):
            continue
        if fname not in events_by_file and not os.access(fpath, os.X_OK) \
                and detect_lang(fpath) == "unknown":
            continue
        with open(fpath, encoding="utf-8", errors="ignore") as f:
            content = f.read()
        texts[fname] = content
        hooks.append({"file": fname, "event": events_by_file.get(fname, "unknown"),
                       "loc": content.count("\n") + 1})
    return hooks, texts


def detect_lang(fpath):
    try:
        with open(fpath, encoding="utf-8", errors="ignore") as f:
            first = f.readline()
    except OSError:
        return "unknown"
    if first.startswith("#!"):
        if "python" in first:
            return "python"
        if "bash" in first:
            return "bash"
        if "sh" in first:
            return "sh"
    if fpath.endswith(".py"):
        return "python"
    return "unknown"


def walk_scripts(plugin_path):
    scripts = []
    bin_dir = os.path.join(plugin_path, "bin")
    if not os.path.isdir(bin_dir):
        return scripts
    for fname in sorted(os.listdir(bin_dir)):
        fpath = os.path.join(bin_dir, fname)
        if not os.path.isfile(fpath):
            continue
        with open(fpath, encoding="utf-8", errors="ignore") as f:
            loc = sum(1 for _ in f)
        scripts.append({"file": f"bin/{fname}", "loc": loc, "lang": detect_lang(fpath)})
    return scripts


def distinctive_tokens(text):
    """Identifier-shaped tokens only (snake_case, SCREAMING_CASE, or
    file/tool names with an underscore or a dot) — deliberately NOT plain
    English words, which overlap constantly between topically-related prose
    and produce false positives (e.g. every worktree-discipline skill shares
    "already"/"session"/"branch" with every worktree-discipline hook,
    regardless of whether any actual logic is restated). Real drift shows up
    as the same *specific* identifier — an env var, a function name, a
    literal filename — named in both places."""
    snake = re.findall(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+){1,}\b", text)
    screaming = re.findall(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+){1,}\b", text)
    dotted = re.findall(r"\b[a-zA-Z][a-zA-Z0-9_-]{3,}\.(?:sh|py|json|md)\b", text)
    return {t.lower() for t in snake + screaming + dotted}


def build_aggregate(skills, agents, hooks, scripts, catalog, bodies=None, hook_texts=None):
    skill_body_words = sum(s["body_words"] for s in skills)
    agent_body_words = sum(a["body_words"] for a in agents)
    script_loc = sum(s["loc"] for s in scripts) + sum(h["loc"] for h in hooks)
    total_words = skill_body_words + agent_body_words
    coverage = round(script_loc / total_words, 3) if total_words else 0.0

    sig_skills = {}
    for skill in skills:
        for block in skill["command_blocks"]:
            sig_skills.setdefault(block["sig"], set()).add(skill["name"])
    min_skills = int(catalog["thresholds"].get("duplicate_block_min_skills", 2))
    duplicated = [
        {"sig": sig, "skills": sorted(names)}
        for sig, names in sorted(sig_skills.items())
        if len(names) >= min_skills
    ]

    drift = []
    bodies = bodies or {}
    hook_texts = hook_texts or {}
    min_shared = int(catalog["thresholds"].get("hook_prose_drift_min_shared_tokens", 3))
    for hook in hooks:
        hook_tokens = distinctive_tokens(hook_texts.get(hook["file"], ""))
        if not hook_tokens:
            continue
        for skill_name, body in bodies.items():
            shared = hook_tokens & distinctive_tokens(body)
            if len(shared) >= min_shared:
                drift.append({
                    "hook": hook["file"],
                    "skill": skill_name,
                    "shared_tokens": sorted(shared)[:10],
                })

    return {
        "skill_body_words": skill_body_words,
        "agent_body_words": agent_body_words,
        "script_loc": script_loc,
        "script_coverage": coverage,
        "duplicated_command_blocks": duplicated,
        "hook_prose_drift": drift,
    }


def main():
    parser = argparse.ArgumentParser(prog="plugin-inventory")
    parser.add_argument("target", help="plugin directory path, or a bare name to resolve from the plugin cache")
    parser.add_argument("--json", action="store_true", help="compact single-line JSON instead of pretty-printed")
    parser.add_argument("--catalog", default=DEFAULT_CATALOG, help="path to signals-catalog.md")
    args = parser.parse_args()

    catalog = load_catalog(args.catalog)
    plugin_path, source = resolve_target(args.target)

    manifest_path = os.path.join(plugin_path, ".claude-plugin", "plugin.json")
    manifest = {}
    if os.path.isfile(manifest_path):
        with open(manifest_path, encoding="utf-8") as f:
            manifest = json.load(f)

    skills, bodies = walk_skills(plugin_path, catalog)
    agents = walk_agents(plugin_path, catalog)
    hooks, hook_texts = walk_hooks(plugin_path)
    scripts = walk_scripts(plugin_path)
    aggregate = build_aggregate(skills, agents, hooks, scripts, catalog, bodies, hook_texts)

    doc = {
        "plugin": {
            "name": manifest.get("name", os.path.basename(plugin_path)),
            "version": manifest.get("version", "unknown"),
            "source": source,
            "path": plugin_path,
        },
        "skills": skills,
        "agents": agents,
        "hooks": hooks,
        "scripts": scripts,
        "aggregate": aggregate,
    }

    if args.json:
        print(json.dumps(doc))
    else:
        print(json.dumps(doc, indent=2))


if __name__ == "__main__":
    main()
