#!/usr/bin/env python3
"""derive_counters.py — single source of truth for repository headline counters.

Walks the canonical tree (excluding sync copies, docs site, audit workspace,
and VCS/CI internals) and derives the headline numbers that README.md,
CLAUDE.md, marketplace.json, mkdocs.yml, and .codex-plugin/plugin.json claim:

  skills              count of SKILL.md files
  plugins_on_disk     count of **/.claude-plugin/plugin.json manifests
  plugins_registered  entries in .claude-plugin/marketplace.json `plugins` array
  python_tools        .py files in the canonical tree, excluding repo-root scripts/
                      (i.e. automation tools shipped inside skill/plugin folders)
  references          .md files under any references/ directory
  agents              .md files under any agents/ directory (excl. CLAUDE.md/README.md)
  commands            .md files under any commands/ directory (excl. CLAUDE.md/README.md)
  domains             top-level folders containing at least one SKILL.md

Modes:
  (default)   print a human-readable table
  --json      print the derived counters as JSON
  --check     exit 1 listing mismatches if the headline counters claimed in
              README.md, root CLAUDE.md ("Current Scope" / "Status:" lines),
              marketplace.json metadata.description, mkdocs.yml
              site_description, or .codex-plugin/plugin.json descriptions
              disagree with derived values. Also validates the README
              "Skills Overview" per-domain table: every domain row's count
              must equal the SKILL.md count in its linked folder, and every
              on-disk domain must have a row. CI gate G3.

Stdlib only. No writes ever.
"""

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

REPO_ROOT = Path(__file__).resolve().parent.parent

# Top-level directories excluded from the canonical tree.
EXCLUDED_TOP_LEVEL = {
    ".git",
    ".codex",
    ".codex-plugin",
    ".gemini",
    ".hermes",
    ".vibe",
    "docs",
    "audit",
    "node_modules",
    ".github",
    ".claude",
}

DOC_FILENAMES = {"CLAUDE.md", "README.md"}


def canonical_walk(root: Path):
    """Yield every file in the canonical tree (excluded top-level dirs pruned)."""
    stack = [root]
    while stack:
        current = stack.pop()
        try:
            entries = sorted(current.iterdir())
        except OSError:
            continue
        for entry in entries:
            if entry.is_symlink():
                continue
            if entry.is_dir():
                if current == root and entry.name in EXCLUDED_TOP_LEVEL:
                    continue
                stack.append(entry)
            elif entry.is_file():
                yield entry


def derive(root: Path) -> dict:
    counters = {
        "skills": 0,
        "plugins_on_disk": 0,
        "plugins_registered": 0,
        "python_tools": 0,
        "references": 0,
        "agents": 0,
        "commands": 0,
        "domains": 0,
    }
    domains = set()

    for path in canonical_walk(root):
        rel = path.relative_to(root)
        parts = rel.parts
        name = path.name

        if name == "SKILL.md":
            counters["skills"] += 1
            if len(parts) > 1:
                domains.add(parts[0])
        elif name == "plugin.json" and path.parent.name == ".claude-plugin":
            counters["plugins_on_disk"] += 1
        elif path.suffix == ".py":
            # Skill/plugin automation tools: every .py except repo-root scripts/.
            if parts[0] != "scripts":
                counters["python_tools"] += 1
        elif path.suffix == ".md":
            if "references" in parts[:-1]:
                counters["references"] += 1
            elif "agents" in parts[:-1] and name not in DOC_FILENAMES:
                counters["agents"] += 1
            elif "commands" in parts[:-1] and name not in DOC_FILENAMES:
                counters["commands"] += 1

    counters["domains"] = len(domains)
    counters["_domain_list"] = sorted(domains)

    marketplace = root / ".claude-plugin" / "marketplace.json"
    if marketplace.is_file():
        try:
            data = json.loads(marketplace.read_text(encoding="utf-8"))
            counters["plugins_registered"] = len(data.get("plugins", []))
        except (json.JSONDecodeError, OSError):
            counters["plugins_registered"] = -1

    return counters


# ---------------------------------------------------------------------------
# --check: parse the standardized claim patterns in the three headline files.
#
# Standardized claim patterns (keep docs in lockstep with these):
#   "<N> production-ready skills" (or "production-ready Claude Code skills")
#   "skills across <D> domains"
#   "<T> Python automation tools" or "<T> Python tools"
#   "<R> reference guides"
#   "<P> marketplace plugins"
# ---------------------------------------------------------------------------

CLAIM_PATTERNS = {
    "skills": re.compile(r"(\d+)\s+production-ready (?:Claude Code )?skills"),
    "domains": re.compile(r"skills across\s+(\d+)\s+domains"),
    "python_tools": re.compile(r"(\d+)\s+Python (?:automation )?tools"),
    "references": re.compile(r"(\d+)\s+reference guides"),
    "plugins_registered": re.compile(r"(\d+)\s+marketplace plugins"),
    # Anchored on the "(cs-" suffix so prose like "9 more coding agents" or a
    # skill's own "4 agents, 8 commands" inventory can't false-match.
    "agents": re.compile(r"(\d+)\s+agents \(cs-"),
    "commands": re.compile(r"(\d+)\s+slash commands"),
}


def extract_claims(text: str) -> dict:
    """Return {counter_name: [every claimed int]} for every pattern found in text.

    All occurrences are collected (not just the first) so a stale duplicate of a
    headline claim elsewhere in the same file — e.g. a section heading that
    repeats the skill count — is gated too (caught live on the v2.12.0
    promotion PR #985, where the README banner said 380 while a section
    heading still said 370)."""
    claims = {}
    for key, pattern in CLAIM_PATTERNS.items():
        values = [int(m.group(1)) for m in pattern.finditer(text)]
        if values:
            claims[key] = values
    return claims


# ---------------------------------------------------------------------------
# Per-domain table validation: the README "Skills Overview" table has one row
# per domain, each ending in a `[<folder>/](<folder>/)` link. Every row's count
# must equal the SKILL.md count in its folder, and every on-disk domain must
# have a row. This catches per-domain drift that the headline aggregates miss.
# ---------------------------------------------------------------------------

DOMAIN_ROW_RE = re.compile(r"^\|\s*\*\*")
DOMAIN_LINK_RE = re.compile(r"\]\(([^)]+?)/?\)")


def derive_per_domain(root: Path) -> dict:
    """Return {top_level_domain: SKILL.md count}."""
    counts = {}
    for path in canonical_walk(root):
        if path.name == "SKILL.md":
            rel = path.relative_to(root)
            if len(rel.parts) > 1:
                counts[rel.parts[0]] = counts.get(rel.parts[0], 0) + 1
    return counts


def parse_domain_table(root: Path, readme_text: str) -> dict:
    """Parse README domain rows -> {domain_folder: claimed_count}.

    Only rows whose trailing link resolves to a real top-level directory are
    treated as domain rows, so other bold-first-cell tables (install matrices,
    the skills-vs-agents table) are ignored.
    """
    table = {}
    for line in readme_text.splitlines():
        if not DOMAIN_ROW_RE.match(line):
            continue
        cells = [c.strip() for c in line.strip().strip("|").split("|")]
        if len(cells) < 3:
            continue
        count = next((int(c) for c in cells[1:] if c.isdigit()), None)
        link = DOMAIN_LINK_RE.search(cells[-1])
        if count is None or not link:
            continue
        folder = link.group(1).strip().rstrip("/").split("/")[0]
        if folder and (root / folder).is_dir():
            table[folder] = count
    return table


def check_domain_table(root: Path) -> list:
    """Return mismatch strings between README domain rows and on-disk SKILL.md counts."""
    readme = root / "README.md"
    if not readme.is_file():
        return []
    per_domain = derive_per_domain(root)
    table = parse_domain_table(root, readme.read_text(encoding="utf-8"))
    problems = []
    for folder, claimed in sorted(table.items()):
        actual = per_domain.get(folder, 0)
        if claimed != actual:
            problems.append(
                f"README domain table: '{folder}' row claims {claimed}, disk has {actual} skills"
            )
    for domain, actual in sorted(per_domain.items()):
        if domain not in table:
            problems.append(
                f"README domain table: domain '{domain}' ({actual} skills) has no row"
            )
    return problems


# README shields.io badges of the form ![Skills](.../Skills-355-brightgreen...).
# Validated separately from prose CLAIM_PATTERNS: badges are a fixed
# `<Label>-<count>-<color>` shape, so a per-label regex is exact and cannot
# over-match surrounding prose. Covers the counters that ship as badges.
BADGE_PATTERNS = {
    "skills": re.compile(r"Skills-(\d+)-"),
    "agents": re.compile(r"Agents-(\d+)-"),
    "commands": re.compile(r"Commands-(\d+)-"),
}


def check_readme_badges(root: Path, derived: dict) -> list:
    """Return mismatch strings between README shield badges and derived counts."""
    readme = root / "README.md"
    if not readme.is_file():
        return []
    text = readme.read_text(encoding="utf-8")
    out = []
    for key, pattern in BADGE_PATTERNS.items():
        match = pattern.search(text)
        if not match:
            # Fail loudly rather than skip: a renamed/removed badge would
            # otherwise silently drop out of the gate (mirrors run_check's
            # "no recognizable counter claims found" precedent).
            out.append(
                f"README badge: '{key}' badge not found "
                f"(expected a '{key.capitalize()}-<n>-' shield)"
            )
        elif int(match.group(1)) != derived[key]:
            out.append(
                f"README badge: '{key}' badge claims {match.group(1)}, "
                f"derived {key}={derived[key]}"
            )
    return out


def run_check(root: Path, derived: dict) -> int:
    sources = []

    readme = root / "README.md"
    if readme.is_file():
        # README is scanned in full (unlike CLAUDE.md below): it carries no
        # version-history prose, so every claim-pattern match in it is a live
        # headline that must agree with the derived values. If a history
        # section is ever added to README, restrict this the same way.
        sources.append(("README.md", readme.read_text(encoding="utf-8")))

    claude_md = root / "CLAUDE.md"
    if claude_md.is_file():
        text = claude_md.read_text(encoding="utf-8")
        # Restrict to the "Current Scope" and footer "Status:" lines so
        # history sections don't trip the gate.
        scope_lines = [ln for ln in text.splitlines()
                       if ln.startswith("**Current Scope:**") or ln.startswith("**Status:**")]
        sources.append(("CLAUDE.md (Current Scope / Status lines)", "\n".join(scope_lines)))

    marketplace = root / ".claude-plugin" / "marketplace.json"
    if marketplace.is_file():
        try:
            data = json.loads(marketplace.read_text(encoding="utf-8"))
            desc = data.get("metadata", {}).get("description", "")
            sources.append(("marketplace.json metadata.description", desc))
        except (json.JSONDecodeError, OSError) as exc:
            print(f"FAIL: cannot parse marketplace.json: {exc}")
            return 1

    # The two sites PR #940 found drifting ungated: the docs-site description
    # and the Codex plugin manifest (which had lagged nine releases behind).
    mkdocs = root / "mkdocs.yml"
    if mkdocs.is_file():
        # mkdocs.yml carries !!python tags safe_load rejects — read as text and
        # restrict to the site_description line, mirroring the CLAUDE.md approach.
        desc_lines = [ln for ln in mkdocs.read_text(encoding="utf-8").splitlines()
                      if ln.startswith("site_description:")]
        sources.append(("mkdocs.yml (site_description)", "\n".join(desc_lines)))

    codex_manifest = root / ".codex-plugin" / "plugin.json"
    if codex_manifest.is_file():
        try:
            data = json.loads(codex_manifest.read_text(encoding="utf-8"))
            # Include the interface descriptions too — they carry their own
            # counts; only standardized-phrasing claims in them are gated.
            iface = data.get("interface", {})
            codex_text = " ".join(str(s) for s in (
                data.get("description", ""),
                iface.get("shortDescription", ""),
                iface.get("longDescription", "")))
            sources.append((".codex-plugin/plugin.json descriptions", codex_text))
        except (json.JSONDecodeError, OSError) as exc:
            print(f"FAIL: cannot parse .codex-plugin/plugin.json: {exc}")
            return 1

    mismatches = []
    for label, text in sources:
        claims = extract_claims(text)
        if not claims:
            mismatches.append(f"{label}: no recognizable counter claims found")
            continue
        for key, values in claims.items():
            actual = derived[key]
            for claimed in values:
                if claimed != actual:
                    mismatches.append(
                        f"{label}: claims {key}={claimed}, derived {key}={actual}"
                    )

    mismatches.extend(check_domain_table(root))
    mismatches.extend(check_readme_badges(root, derived))

    if mismatches:
        print("COUNTER CHECK FAILED — headline claims disagree with derived values:")
        for line in mismatches:
            print(f"  - {line}")
        print("\nRun `python3 scripts/derive_counters.py` for the ground-truth table.")
        return 1

    print("Counter check passed: README.md, CLAUDE.md, marketplace.json, mkdocs.yml, .codex-plugin match derived values.")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Derive repository headline counters from the canonical tree."
    )
    parser.add_argument("--json", action="store_true", help="emit counters as JSON")
    parser.add_argument(
        "--check",
        action="store_true",
        help="exit 1 if claims in README.md / CLAUDE.md / marketplace.json / mkdocs.yml / .codex-plugin drift from derived values",
    )
    args = parser.parse_args()

    derived = derive(REPO_ROOT)
    domain_list = derived.pop("_domain_list")

    if args.json:
        out = dict(derived)
        out["domain_list"] = domain_list
        print(json.dumps(out, indent=2))
    else:
        width = max(len(k) for k in derived)
        print("Derived repository counters (canonical tree)")
        print("-" * 46)
        for key, value in derived.items():
            print(f"{key.ljust(width)}  {value}")
        print("-" * 46)
        print("domains: " + ", ".join(domain_list))

    if args.check:
        derived["_domain_list"] = domain_list
        return run_check(REPO_ROOT, derived)
    return 0


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