#!/usr/bin/env python3
"""Auto plugin-version-available detection (REQ-068).

Downstream agents should automatically know when a newer PRD Plugin is published
on npm. This queries the registry for the latest prd-plugin version, compares it
to the installed version, and reports whether an update is available. The result
is cached with a TTL so it is not a network call every turn, and the whole thing
is fail-open: no network, no npm, or a bad response resolves to "no update known",
never an error that breaks a hook.

- `check()` — TTL-gated; queries npm only when the cache is stale (or forced).
- `status()` — cache-only, no network (for the nudge and /prd-status).
- CLI: --check | --refresh/--force | --status | --on | --off, with --json.

version_advice.py (hub-only) compares against the hub checkout; this is the
downstream-installable, npm-aware counterpart.
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import time
from pathlib import Path

CACHE_REL = Path(".prd_plugin") / "local" / "version-check.json"
# Responsive by default: re-check npm at most once an hour so a freshly published
# version surfaces in-session, not a day later. Tunable via
# automation.version_check.ttl_hours (fractional hours allowed).
DEFAULT_TTL_HOURS = 1
DEFAULT_PACKAGE = "prd-plugin"


def parse_version(text):
    """Loose semver to a comparable tuple. Non-numeric parts sort as 0."""
    parts = []
    for chunk in str(text or "0").split("-")[0].split("."):
        try:
            parts.append(int(chunk))
        except ValueError:
            parts.append(0)
    while len(parts) < 3:
        parts.append(0)
    return tuple(parts[:3])


def _read_json(path):
    try:
        return json.loads(Path(path).read_text(encoding="utf-8-sig"))
    except Exception:
        return None


def _config(root):
    cfg = _read_json(Path(root) / ".prd_plugin" / "config.json")
    return cfg if isinstance(cfg, dict) else {}


def _settings(root):
    vc = _config(root).get("automation", {}).get("version_check", {})
    if not isinstance(vc, dict):
        vc = {}
    return {
        "enabled": bool(vc.get("enabled", True)),
        "ttl_hours": vc.get("ttl_hours", DEFAULT_TTL_HOURS),
        "package": vc.get("package", DEFAULT_PACKAGE),
    }


def installed_version(root):
    plugin = _config(root).get("plugin", {})
    if isinstance(plugin, dict):
        v = plugin.get("installed_version") or plugin.get("version")
        if v:
            return str(v)
    return None


def host_plugin_version(package=DEFAULT_PACKAGE, home=None):
    """The version of the plugin installed into the HOST, not into this repo.

    PRD Plugin reaches an agent through two independent channels (REQ-140):

    - `npx prd-install` writes into the repo and sets
      `plugin.installed_version` in `.prd_plugin/config.json`; and
    - the Claude marketplace installs a whole plugin tree under
      `~/.claude/plugins/`, recorded in `installed_plugins.json`.

    The check only ever read the first, so it reported "up to date" while the
    host plugin sat four versions behind and nothing said so. Fail-open by
    design: most repos have no marketplace install (CI, other hosts,
    downstream servers), and absent must be silent rather than a failure.
    """
    base = Path(home) if home else Path.home()
    record = _read_json(base / ".claude" / "plugins" / "installed_plugins.json")
    if not isinstance(record, dict):
        return None
    entries = (record.get("plugins") or {}).get(f"{package}@{package}")
    if not isinstance(entries, list):
        return None
    for entry in entries:
        if not isinstance(entry, dict) or not entry.get("version"):
            continue
        return {"version": str(entry["version"]),
                "scope": entry.get("scope", "user"),
                "install_path": str(entry.get("installPath", "")),
                "commit": entry.get("gitCommitSha", "")}
    return None


def with_host_plugin(status, package=DEFAULT_PACKAGE, home=None):
    """Add the host-plugin channel to a status dict, when one is installed.

    Leaves `status` untouched when there is no marketplace install, and never
    invents a verdict: with no known latest version there is nothing to
    compare against, so `update_available` stays False rather than guessing.
    """
    found = host_plugin_version(package=package, home=home)
    if not found:
        return status
    latest = status.get("latest")
    behind = bool(latest) and parse_version(found["version"]) < parse_version(latest)
    status["host_plugin"] = {
        "version": found["version"],
        "scope": found["scope"],
        "install_path": found["install_path"],
        "update_available": behind,
        "reason": (
            f"the {found['scope']} plugin install is on {found['version']} but "
            f"{latest} is published. Run `claude plugin update "
            f"{package}@{package}` and restart the host to apply it."
            if behind else ""),
    }
    return status


def _cache_path(root):
    return Path(root) / CACHE_REL


def read_cache(root):
    return _read_json(_cache_path(root))


def _write_cache(root, data):
    try:
        p = _cache_path(root)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
    except Exception:
        pass  # cache is best-effort


def _npm_executable():
    """Resolve the npm launcher cross-platform (npm.cmd on Windows)."""
    import shutil
    for name in ("npm", "npm.cmd", "npm.exe"):
        found = shutil.which(name)
        if found:
            return found
    return None


def fetch_latest(package, timeout=15):
    """Latest published version from npm. Returns a version string or raises.
    A short timeout keeps an in-session refresh from stalling a prompt."""
    npm = _npm_executable()
    if not npm:
        raise RuntimeError("npm not found on PATH")
    r = subprocess.run([npm, "view", package, "version"],
                       capture_output=True, text=True, timeout=timeout)
    if r.returncode != 0:
        raise RuntimeError((r.stderr or r.stdout or "npm view failed").strip()[:200])
    out = r.stdout.strip()
    if not out:
        raise RuntimeError("npm returned no version")
    return out


def _status_from(installed, latest, checked_at, source, error=None):
    update = bool(installed and latest and parse_version(installed) < parse_version(latest))
    result = {
        "installed": installed, "latest": latest,
        "update_available": update, "checked_at": checked_at, "source": source,
    }
    if error:
        result["error"] = error
    return result


def check(root, fetch=fetch_latest, now=None, force=False):
    """Return the version status, querying npm only when the cache is stale.
    Fail-open: any failure resolves to a no-update status with an error note."""
    root = Path(root)
    now = time.time() if now is None else now
    settings = _settings(root)
    installed = installed_version(root)
    if not settings["enabled"]:
        return {"enabled": False, "installed": installed, "latest": None,
                "update_available": False}

    cache = read_cache(root)
    ttl = float(settings["ttl_hours"]) * 3600.0
    if (not force and cache and isinstance(cache, dict)
            and (now - float(cache.get("checked_at", 0))) < ttl
            and cache.get("latest")):
        return with_host_plugin(
            _status_from(installed, cache.get("latest"),
                         cache.get("checked_at"), "cache"),
            package=settings["package"])

    try:
        latest = fetch(settings["package"])
        result = with_host_plugin(_status_from(installed, latest, now, "network"),
                                  package=settings["package"])
        _write_cache(root, {"package": settings["package"], "installed": installed,
                            "latest": latest, "checked_at": now})
        return result
    except Exception as exc:
        # fail-open: keep the last known latest if we have it
        last = cache.get("latest") if isinstance(cache, dict) else None
        return with_host_plugin(
            _status_from(installed, last, cache.get("checked_at") if cache else None,
                         "cache" if last else "error", error=str(exc)[:200]),
            package=settings["package"])


def status(root, home=None):
    """Cache-only status (no network) for fast callers (nudge, /prd-status)."""
    root = Path(root)
    installed = installed_version(root)
    cache = read_cache(root)
    # The host-plugin channel is read from disk, not the network, so the
    # cache-only path carries it too — this is the surface the nudge and
    # /prd-status actually render (REQ-140).
    if isinstance(cache, dict) and cache.get("latest"):
        return with_host_plugin(
            _status_from(installed, cache.get("latest"), cache.get("checked_at"), "cache"),
            home=home)
    return with_host_plugin({"installed": installed, "latest": None,
                             "update_available": False,
                             "checked_at": None, "source": "none"}, home=home)


def set_enabled(root, enabled):
    path = Path(root) / ".prd_plugin" / "config.json"
    cfg = _read_json(path) or {}
    cfg.setdefault("automation", {}).setdefault("version_check", {})["enabled"] = bool(enabled)
    try:
        path.write_text(json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    except Exception:
        pass
    return {"enabled": bool(enabled)}


def _line(s):
    if s.get("update_available"):
        return (f"update available: {s['latest']} (you're on {s['installed']}). "
                "Run: npm update prd-plugin && npx prd-install . --force")
    if s.get("enabled") is False:
        return "version check disabled"
    if s.get("latest"):
        return f"up to date ({s['installed']}, latest {s['latest']})"
    return f"latest unknown (installed {s.get('installed')})"


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--repo-root", default=".")
    parser.add_argument("--check", action="store_true", help="TTL-gated npm check.")
    parser.add_argument("--refresh", "--force", dest="force", action="store_true",
                        help="Force an npm check, ignoring the TTL cache.")
    parser.add_argument("--status", action="store_true", help="Cache-only status (no network).")
    parser.add_argument("--on", action="store_true", help="Enable version checking.")
    parser.add_argument("--off", action="store_true", help="Disable version checking.")
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args(argv)
    root = Path(args.repo_root)

    if args.on or args.off:
        result = set_enabled(root, args.on)
    elif args.status:
        result = status(root)
    else:
        result = check(root, force=args.force)

    print(json.dumps(result, indent=2) if args.json else f"[PRD Plugin] {_line(result)}")
    return 0


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