#!/usr/bin/env python3
"""adia-info — one-shot project-context probe for adia-ui consumer repos.

Prints a compact JSON snapshot of the facts the factory skills otherwise
re-discover by hand every session: which @adia-ai/* packages the project
declares (and which version is actually installed), the rendering-mode and
framework signals app-planning's classifiers cite, which shells the source
uses, theming setup, MCP wiring, and whether the cwd is actually the
framework monorepo itself (a misroute signal — that work belongs to the
adia-ui-forge plugin).

Designed for dynamic context injection from a SKILL.md body:

    !`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/adia-info"`

so every field carries the SIGNAL it was derived from (file path or dep
name) — a skill consuming this output can cite signals per app-planning's
evidence gate instead of asserting axes bare.

Contract: NEVER exits non-zero, never prints to stderr on the happy path —
a context-injection command that fails or noises breaks the skill it feeds.
Any probe error degrades that field to null with the error noted in
`probeErrors`. Bounded: scans at most MAX_SCAN_FILES source files, skipping
node_modules/dist/.git.

Usage:
  adia-info [dir]              # probe dir (default: cwd); print JSON to stdout
  adia-info [dir] --staleness  # ALSO query npm for latest (network; never default)
  adia-info selftest           # run built-in fixtures; exit 0 iff all pass
Stdlib only (Python 3.8+).
"""
import json
import os
import re
import sys

MAX_SCAN_FILES = 400
SOURCE_EXTS = (".html", ".js", ".mjs", ".ts", ".jsx", ".tsx", ".css")
SKIP_DIRS = {"node_modules", "dist", ".git", ".next", ".nuxt", ".svelte-kit", "build", "coverage"}

SSR_FRAMEWORKS = {  # dep name -> framework label
    "next": "next", "nuxt": "nuxt", "@sveltejs/kit": "sveltekit", "astro": "astro",
}
SHELL_TAGS = ("admin-shell", "chat-shell", "editor-shell", "simple-shell", "embed-shell")


def _read_json(path):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def _iter_source_files(root):
    count = 0
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
        for name in filenames:
            if name.endswith(SOURCE_EXTS):
                yield os.path.join(dirpath, name)
                count += 1
                if count >= MAX_SCAN_FILES:
                    return


def probe(root):
    info = {"probeRoot": os.path.abspath(root), "probeErrors": []}

    # ── package.json: adia deps, framework, package manager ──
    pkg = _read_json(os.path.join(root, "package.json")) or {}
    deps = {}
    for key in ("dependencies", "devDependencies"):
        deps.update(pkg.get(key) or {})
    info["adiaPackages"] = {k: v for k, v in sorted(deps.items()) if k.startswith("@adia-ai/")}

    installed = _read_json(os.path.join(root, "node_modules", "@adia-ai", "web-components", "package.json"))
    info["installedVersion"] = installed.get("version") if installed else None

    # ── split-lockstep detection (offline): all installed @adia-ai/* versions ──
    # The 11 @adia-ai packages version in lockstep upstream; a consumer whose
    # installed copies diverge (partial upgrade, stale lockfile entry) sees
    # cross-package breakage that presents as component bugs.
    installed_versions = {}
    adia_dir = os.path.join(root, "node_modules", "@adia-ai")
    if os.path.isdir(adia_dir):
        for name in sorted(os.listdir(adia_dir)):
            p = _read_json(os.path.join(adia_dir, name, "package.json"))
            if p and p.get("version"):
                installed_versions[f"@adia-ai/{name}"] = p["version"]
    distinct = sorted(set(installed_versions.values()))
    info["installedAdiaVersions"] = installed_versions or None
    info["splitLockstep"] = (
        {"value": len(distinct) > 1, "versions": distinct,
         "signal": "node_modules/@adia-ai/*/package.json version fields"}
        if installed_versions else None
    )

    framework = next((label for dep, label in SSR_FRAMEWORKS.items() if dep in deps), None)
    if framework is None and "vite" in deps:
        framework = "vite"
    info["framework"] = framework

    pm = pkg.get("packageManager")
    if pm:
        info["packageManager"] = {"value": pm.split("@")[0], "signal": "package.json packageManager field"}
    else:
        for lockfile, name in (("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
                               ("yarn.lock", "yarn"), ("package-lock.json", "npm")):
            if os.path.exists(os.path.join(root, lockfile)):
                info["packageManager"] = {"value": name, "signal": lockfile}
                break
        else:
            info["packageManager"] = None

    # ── framework-monorepo misroute signal ──
    info["isFrameworkMonorepo"] = (
        os.path.isdir(os.path.join(root, "packages", "web-components", "components"))
        and os.path.isdir(os.path.join(root, "packages", "a2ui"))
    )

    # ── rendering mode (app-planning classifier 1, same signals) ──
    if framework in SSR_FRAMEWORKS.values():
        route_dirs = [d for d in ("app", "pages", "src/routes", "src/pages") if os.path.isdir(os.path.join(root, d))]
        info["renderingMode"] = {"value": "ssr", "signal": f"{framework} in package.json" + (f" + {route_dirs[0]}/" if route_dirs else "")}
    elif info["adiaPackages"] or os.path.exists(os.path.join(root, "index.html")):
        info["renderingMode"] = {"value": "spa", "signal": "no SSR framework dep" + ("; index.html present" if os.path.exists(os.path.join(root, "index.html")) else "")}
    else:
        info["renderingMode"] = {"value": "unknown", "signal": "no package.json deps or index.html found"}

    # ── source scan: shells, registration, theming, custom tags ──
    shells, register_files, theme = set(), [], {"themesCss": False, "dataScheme": False, "namedTheme": False}
    scanned = 0
    try:
        for path in _iter_source_files(root):
            scanned += 1
            try:
                with open(path, encoding="utf-8", errors="ignore") as f:
                    text = f.read()
            except Exception:
                continue
            for tag in SHELL_TAGS:
                if "<" + tag in text or "adia-" + tag in text:
                    shells.add(tag)
            if "@adia-ai/web-components" in text or "@adia-ai/web-modules" in text:
                register_files.append(os.path.relpath(path, root))
            if "themes.css" in text:
                theme["themesCss"] = True
            if "data-scheme" in text:
                theme["dataScheme"] = True
            if re.search(r'\btheme="[a-z]', text):
                theme["namedTheme"] = True
    except Exception as e:  # pragma: no cover — never let a scan error kill the probe
        info["probeErrors"].append(f"source scan: {e}")
    info["shellsUsed"] = sorted(shells)
    info["registrationFiles"] = sorted(register_files)[:10]
    info["theme"] = theme
    info["scannedFiles"] = scanned

    # ── MCP wiring ──
    mcp = _read_json(os.path.join(root, ".mcp.json"))
    servers = (mcp or {}).get("mcpServers") or {}
    a2ui = next((v for k, v in servers.items() if "a2ui" in k), None)
    if a2ui:
        args = " ".join(a2ui.get("args") or [])
        pin = re.search(r"@adia-ai/a2ui-mcp@([\w.\-]+)", args)
        info["a2uiMcp"] = {"configured": True, "pin": pin.group(1) if pin else None}
    else:
        info["a2uiMcp"] = {"configured": False, "pin": None}

    info["typescript"] = os.path.exists(os.path.join(root, "tsconfig.json"))
    return info


def _selftest():
    import shutil
    import tempfile

    failures = []

    def check(name, cond):
        if not cond:
            failures.append(name)

    # Fixture 1: SPA consumer with adia deps, shells, theme, MCP pin.
    tmp = tempfile.mkdtemp(prefix="adia-info-spa-")
    try:
        with open(os.path.join(tmp, "package.json"), "w") as f:
            json.dump({"dependencies": {"@adia-ai/web-components": "^0.8.0", "vite": "^5.0.0"}}, f)
        with open(os.path.join(tmp, "package-lock.json"), "w") as f:
            f.write("{}")
        with open(os.path.join(tmp, "index.html"), "w") as f:
            f.write('<html data-scheme="dark" theme="ocean"><link href="themes.css">'
                    '<script type="module">import "@adia-ai/web-components";</script>'
                    "<admin-shell></admin-shell></html>")
        with open(os.path.join(tmp, ".mcp.json"), "w") as f:
            json.dump({"mcpServers": {"a2ui": {"args": ["-y", "@adia-ai/a2ui-mcp@0.8.0"]}}}, f)
        r = probe(tmp)
        check("spa: mode", r["renderingMode"]["value"] == "spa")
        check("spa: adia dep", "@adia-ai/web-components" in r["adiaPackages"])
        check("spa: framework vite", r["framework"] == "vite")
        check("spa: pm npm via lockfile", r["packageManager"] == {"value": "npm", "signal": "package-lock.json"})
        check("spa: shell found", r["shellsUsed"] == ["admin-shell"])
        check("spa: registration file", r["registrationFiles"] == ["index.html"])
        check("spa: theme trio", r["theme"] == {"themesCss": True, "dataScheme": True, "namedTheme": True})
        check("spa: mcp pin", r["a2uiMcp"] == {"configured": True, "pin": "0.8.0"})
        check("spa: not monorepo", r["isFrameworkMonorepo"] is False)
    finally:
        shutil.rmtree(tmp, ignore_errors=True)

    # Fixture 2: SSR (next) consumer.
    tmp = tempfile.mkdtemp(prefix="adia-info-ssr-")
    try:
        with open(os.path.join(tmp, "package.json"), "w") as f:
            json.dump({"dependencies": {"next": "15.0.0", "@adia-ai/web-components": "^0.8.0"},
                       "packageManager": "pnpm@9.0.0"}, f)
        os.makedirs(os.path.join(tmp, "app"))
        r = probe(tmp)
        check("ssr: mode", r["renderingMode"]["value"] == "ssr")
        check("ssr: signal cites next", "next" in r["renderingMode"]["signal"])
        check("ssr: pm field wins", r["packageManager"]["value"] == "pnpm")
        check("ssr: mcp unconfigured", r["a2uiMcp"]["configured"] is False)
    finally:
        shutil.rmtree(tmp, ignore_errors=True)

    # Fixture 3: empty dir never crashes, mode unknown.
    tmp = tempfile.mkdtemp(prefix="adia-info-empty-")
    try:
        r = probe(tmp)
        check("empty: mode unknown", r["renderingMode"]["value"] == "unknown")
        check("empty: no errors", r["probeErrors"] == [])
        json.dumps(r)  # must be serializable
    finally:
        shutil.rmtree(tmp, ignore_errors=True)

    # Fixture 4 (gh#1122, REQ-06): -h/--help must print usage and exit 0 —
    # NEVER silently probe a literal "-h" directory and return a degenerate
    # JSON payload with exit 0 (the actual repro this fixture locks shut).
    import contextlib
    import io
    for flag in ("-h", "--help"):
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc = main(["adia-info", flag])
        out = buf.getvalue()
        check(f"help: {flag} exits 0", rc == 0)
        check(f"help: {flag} prints usage", out.startswith("usage:"))
        check(f"help: {flag} is not a JSON probe payload", "probeRoot" not in out)

    if failures:
        print("selftest FAIL: " + ", ".join(failures), file=sys.stderr)
        return 1
    print("selftest OK — 4 fixtures")
    return 0


def _probe_staleness(info):
    """Opt-in (--staleness): compare installed web-components against npm latest.

    Network + subprocess, so NEVER part of the default probe — the default
    output feeds skill-load context injection and must stay offline-fast.
    Fail-soft like every other field."""
    import subprocess
    try:
        out = subprocess.run(
            ["npm", "view", "@adia-ai/web-components", "version"],
            capture_output=True, text=True, timeout=10,
        )
        if out.returncode != 0:
            raise RuntimeError((out.stderr or out.stdout or "npm view exited nonzero").strip())
        latest = out.stdout.strip() or None
        info["npmLatest"] = latest
        installed = info.get("installedVersion")
        info["staleInstall"] = (
            {"value": installed != latest, "installed": installed, "latest": latest,
             "signal": "npm view @adia-ai/web-components version"}
            if latest and installed else None
        )
    except Exception as e:
        info["npmLatest"] = None
        info["staleInstall"] = None
        info["probeErrors"].append(f"staleness: {e}")


def main(argv):
    rest = argv[1:]
    # REQ-06 (gh#1122): -h/--help must print usage and exit 0 BEFORE any
    # positional/root resolution — the bug was that only "--"-prefixed
    # tokens were filtered into `flags`, so single-dash "-h" fell through
    # into `args` and became `root`, silently probing a literal directory
    # named "-h". Checked first, in any position, so it can never fall
    # through regardless of what else is on the command line.
    if any(a in ("-h", "--help") for a in rest):
        print("usage: adia-info [dir] [--staleness]   probe an adia-ui consumer app (default: cwd)")
        return 0
    if rest and rest[0] == "selftest":
        return _selftest()
    args = [a for a in rest if not a.startswith("-")]
    flags = {a for a in rest if a.startswith("-")}
    root = args[0] if args else "."
    try:
        info = probe(root)
        if "--staleness" in flags:
            _probe_staleness(info)
        print(json.dumps(info, indent=1))
    except Exception as e:  # ultimate fail-soft: emit the error as JSON, exit 0
        print(json.dumps({"probeErrors": [str(e)]}))
    return 0


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