#!/usr/bin/env python3
"""
check-adia-versions.py — verify your @adia-ai/* install is sane.

Run this from any consumer project (not just the monorepo). Walks three
sources and reports drift:

  1. Installed (node_modules/@adia-ai/<pkg>/package.json)
  2. Declared (your package.json `dependencies` + `devDependencies`)
  3. Latest published (npm view @adia-ai/<pkg> version — needs network)

Exits 0 when everything lines up. Exits 1 on drift. Exits 2 on
unrecoverable error (missing node_modules, malformed package.json).

Usage
-----
  ./check-adia-versions.py                 # full check (network)
  ./check-adia-versions.py --offline       # skip npm view, lockstep only
  ./check-adia-versions.py --json          # machine-readable output
  ./check-adia-versions.py --root .        # explicit consumer root

Why this exists
---------------
The 9 `@adia-ai/*` packages ship in lockstep — one stale install desyncs
the whole bundle. Plus the skill documents fixes that only apply at
specific versions (e.g. F-014/F-015: v0.6.7 ships broken absolute-path
imports in `@adia-ai/web-modules`, fix lands in v0.6.8). Without a
verification step, agents trust their installed snapshot blindly and
ship advice that's wrong for the user's actual install.

Output legend
-------------
  ✓ ok          installed == declared, and matches latest (or --offline)
  🔗 linked     declared as `file:...`, `link:...`, or `workspace:*` —
                local development link, not an npm version (informational)
  ⚠ stale       installed < latest published — `npm update` recommended
  ⚠ split       lockstep broken — packages disagree on version
  ⚠ undeclared  installed in node_modules but missing from package.json
  ⚠ uninstalled declared but missing from node_modules — `npm install`
  ✗ ahead       installed > latest published — local source build /
                pre-publish state (informational, not an error in this
                monorepo where you may dev against unpublished cuts)
"""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional


PACKAGE_NAMES = [
    "@adia-ai/web-components",
    "@adia-ai/web-modules",
    "@adia-ai/llm",
    "@adia-ai/a2ui-runtime",
    "@adia-ai/a2ui-compose",
    "@adia-ai/a2ui-corpus",
    "@adia-ai/a2ui-mcp",
    "@adia-ai/a2ui-retrieval",
    "@adia-ai/a2ui-validator",
]


@dataclass
class PackageStatus:
    name: str
    installed: Optional[str] = None
    declared: Optional[str] = None          # raw range from package.json
    declared_normalized: Optional[str] = None  # version stripped of ^~
    latest: Optional[str] = None
    symlinked: bool = False                  # node_modules/<name> is a symlink
    issues: list[str] = field(default_factory=list)


def read_json(path: Path) -> Optional[dict]:
    try:
        return json.loads(path.read_text())
    except (FileNotFoundError, json.JSONDecodeError):
        return None


def is_link_spec(spec: Optional[str]) -> bool:
    """Detect local-development link specs (file:, link:, workspace:)."""
    if not spec:
        return False
    return spec.startswith(("file:", "link:", "workspace:", "portal:"))


def normalize_range(spec: str) -> str:
    """Strip leading ^ ~ >= etc. from a semver range to get a version."""
    if not spec:
        return ""
    # Link specs aren't semver — leave them alone for display
    if is_link_spec(spec):
        return spec
    m = re.match(r"^[\^~><=\s]*([\d]+\.[\d]+\.[\d]+(?:-[\w.]+)?)", spec)
    return m.group(1) if m else spec


def parse_semver(v: Optional[str]) -> tuple[int, int, int]:
    if not v:
        return (0, 0, 0)
    m = re.match(r"^(\d+)\.(\d+)\.(\d+)", v)
    if not m:
        return (0, 0, 0)
    return (int(m.group(1)), int(m.group(2)), int(m.group(3)))


def get_installed(root: Path, name: str) -> Optional[str]:
    pkg = root / "node_modules" / name / "package.json"
    data = read_json(pkg)
    return data.get("version") if data else None


def is_symlinked(root: Path, name: str) -> bool:
    """Check whether node_modules/<name> is a symlink (linked) vs a real dir."""
    pkg_dir = root / "node_modules" / name
    try:
        return pkg_dir.is_symlink()
    except OSError:
        return False


def get_declared(consumer_pkg: dict, name: str) -> Optional[str]:
    for field_name in ("dependencies", "devDependencies", "peerDependencies"):
        spec = (consumer_pkg.get(field_name) or {}).get(name)
        if spec:
            return spec
    return None


def get_latest(name: str, timeout: int = 10) -> Optional[str]:
    """Query npm registry for the dist-tag latest version. Network call."""
    try:
        result = subprocess.run(
            ["npm", "view", name, "version"],
            capture_output=True, text=True, timeout=timeout,
        )
        if result.returncode == 0:
            return result.stdout.strip() or None
    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass
    return None


def check_lockstep(statuses: list[PackageStatus]) -> Optional[str]:
    """Return the consensus installed version, or None if split."""
    versions = {s.installed for s in statuses if s.installed}
    if len(versions) == 0:
        return None
    if len(versions) == 1:
        return versions.pop()
    return None  # split


def run_audit(root: Path, offline: bool) -> tuple[list[PackageStatus], dict]:
    consumer_pkg = read_json(root / "package.json") or {}

    # Monorepo-root detection: if this package.json declares "workspaces",
    # we're inside the @adia-ai/* source monorepo, not a consumer project.
    # In that case, "undeclared" is expected (the packages are workspaces,
    # not external deps) — suppress the noise.
    is_monorepo_root = bool(consumer_pkg.get("workspaces"))

    # "undeclared" classification — only flag when it likely reflects a
    # mistake. In a consumer project, sub-packages like @adia-ai/llm,
    # a2ui-runtime, a2ui-mcp are commonly transitive deps pulled in by
    # @adia-ai/web-components or web-modules; "undeclared" there is noise.
    # We only flag undeclared when:
    #   (a) we are NOT in the monorepo root (workspaces detected), AND
    #   (b) the package is one of the user-facing entry points
    #       (web-components, web-modules) AND it's installed.
    # This catches the real failure mode (someone deleted the dep but
    # node_modules still has it = phantom install) without spamming on
    # legitimate transitive deps.
    USER_FACING = {"@adia-ai/web-components", "@adia-ai/web-modules"}

    statuses: list[PackageStatus] = []
    for name in PACKAGE_NAMES:
        s = PackageStatus(name=name)
        s.installed = get_installed(root, name)
        s.declared = get_declared(consumer_pkg, name)
        s.declared_normalized = normalize_range(s.declared) if s.declared else None
        s.symlinked = is_symlinked(root, name)
        if not offline:
            s.latest = get_latest(name)

        declared_is_link = is_link_spec(s.declared)

        # Per-package classification
        if s.declared and not s.installed:
            s.issues.append("uninstalled")
        if (s.installed and not s.declared
                and not is_monorepo_root
                and name in USER_FACING):
            s.issues.append("undeclared")
        # Link-spec packages get a "linked" marker; suppress version-drift
        # warnings since semver comparison doesn't apply to file:/link:/workspace:.
        if declared_is_link:
            s.issues.append("linked")
            # Pitfall warning: declared as link but installed as real dir
            # (npm sometimes copies instead of linking)
            if s.installed and not s.symlinked:
                s.issues.append("link-copied-not-symlinked")
        elif s.installed and s.latest:
            inst = parse_semver(s.installed)
            late = parse_semver(s.latest)
            if inst < late:
                s.issues.append("stale")
            elif inst > late:
                s.issues.append("ahead")

        statuses.append(s)

    consensus = check_lockstep(statuses)
    summary = {
        "consumer_root": str(root.resolve()),
        "consensus_installed": consensus,
        "split_lockstep": consensus is None and any(s.installed for s in statuses),
        "offline": offline,
        "is_monorepo_root": is_monorepo_root,
    }
    return statuses, summary


def render_text(statuses: list[PackageStatus], summary: dict) -> int:
    """Human-readable output. Returns process exit code."""
    print(f"@adia-ai install audit — {summary['consumer_root']}")
    if summary.get("is_monorepo_root"):
        print("(monorepo-root mode — workspaces detected, 'undeclared' suppressed)")
    print("─" * 78)

    any_issues = False
    consensus_latest = None
    for s in statuses:
        if s.latest:
            consensus_latest = s.latest
            break  # first one with a latest is enough for the header line

    if consensus_latest:
        print(f"Latest published (npm view): {consensus_latest}")
    if summary["consensus_installed"]:
        print(f"Consensus installed:        {summary['consensus_installed']}")
    elif summary["split_lockstep"]:
        print("Consensus installed:        ⚠ SPLIT — packages disagree")
        any_issues = True
    print()

    # Per-package table
    name_width = max(len(s.name) for s in statuses) + 2
    print(f"  {'package'.ljust(name_width)}  {'installed':<10}  {'declared':<26}  {'latest':<10}  status")
    print(f"  {'-' * name_width}  {'-' * 10}  {'-' * 26}  {'-' * 10}  {'-' * 24}")

    for s in statuses:
        installed = s.installed or "—"
        declared = s.declared or "—"
        # Truncate long file: specs for table readability
        if declared and len(declared) > 26:
            declared = declared[:23] + "..."
        latest = s.latest or ("(offline)" if summary["offline"] else "—")

        if not s.issues:
            status = "✓ ok"
        else:
            status_parts = []
            for i in s.issues:
                if i == "linked":
                    status_parts.append("🔗 linked")
                elif i == "ahead":
                    status_parts.append("ⓘ ahead")
                elif i == "link-copied-not-symlinked":
                    status_parts.append("⚠ copied-not-linked")
                else:
                    status_parts.append(f"⚠ {i}")
            status = " · ".join(status_parts)
            # Only count as a real failure if it's not purely informational
            real_issues = [i for i in s.issues
                           if i not in ("ahead", "linked")]
            if real_issues:
                any_issues = True

        print(f"  {s.name.ljust(name_width)}  {installed:<10}  {declared:<26}  {latest:<10}  {status}")

    print()

    # Diagnostics
    if summary["split_lockstep"]:
        print("✗ Lockstep broken — the 9 @adia-ai/* packages must share a version.")
        print("  Fix: `npm install @adia-ai/web-components@<v> @adia-ai/web-modules@<v> ...`")
        print("  or delete node_modules + package-lock.json and `npm install`.")
        print()

    stale_pkgs = [s for s in statuses if "stale" in s.issues]
    if stale_pkgs:
        print(f"⚠ Stale: {len(stale_pkgs)} package(s) behind latest. Suggested:")
        print(f"  npm install {' '.join(f'{s.name}@latest' for s in stale_pkgs)}")
        print()

    ahead_pkgs = [s for s in statuses if "ahead" in s.issues]
    if ahead_pkgs:
        print(f"ⓘ Ahead: {len(ahead_pkgs)} package(s) installed past latest published.")
        print("  This is normal in the monorepo (pre-publish dev cut) or if you")
        print("  installed from a local tarball / git tag. Not an error.")
        print()

    linked_pkgs = [s for s in statuses if "linked" in s.issues]
    if linked_pkgs:
        all_linked = len(linked_pkgs) == sum(1 for s in statuses if s.declared)
        print(f"🔗 Linked: {len(linked_pkgs)} package(s) declared as local "
              f"dev link ({'all declared' if all_linked else 'subset'}).")
        print("  Version comparison disabled for these — they resolve to source")
        print("  on disk, not an npm-published version. The 'installed' column")
        print("  shows the version from the linked source's package.json.")
        if not all_linked and sum(1 for s in statuses if s.declared) > len(linked_pkgs):
            print()
            print("  ⚠ Mixed link + npm declarations risk split-lockstep when the")
            print("    linked source's transitive deps disagree with what npm")
            print("    resolved. Recommended: link ALL 9 @adia-ai/* packages, or")
            print("    link NONE. See §LocalLink in the adia-ui-kit skill.")
        print()

    copied_pkgs = [s for s in statuses if "link-copied-not-symlinked" in s.issues]
    if copied_pkgs:
        print(f"⚠ Link copied, not symlinked: {len(copied_pkgs)} package(s).")
        print("  npm sometimes copies file: deps instead of symlinking — edits")
        print("  to source won't be reflected. Fix:")
        print("    rm -rf node_modules/@adia-ai")
        print("    npm install --install-links=false")
        print("  Then re-run this script to confirm symlinks are in place.")
        print()

    uninstalled = [s for s in statuses if "uninstalled" in s.issues]
    if uninstalled:
        print(f"⚠ Declared but uninstalled: {len(uninstalled)} package(s).")
        print("  Fix: `npm install`")
        print()

    if not any_issues:
        print("✓ All clean — lockstep aligned, declarations match installs, "
              "and (if online) installs match latest published.")
        return 0

    return 1


def render_json(statuses: list[PackageStatus], summary: dict) -> int:
    payload = {
        "summary": summary,
        "packages": [
            {
                "name": s.name,
                "installed": s.installed,
                "declared": s.declared,
                "declared_normalized": s.declared_normalized,
                "latest": s.latest,
                "symlinked": s.symlinked,
                "issues": s.issues,
            }
            for s in statuses
        ],
    }
    print(json.dumps(payload, indent=2))
    # 'ahead' and 'linked' are informational, not failures
    real_issues = summary["split_lockstep"] or any(
        i for s in statuses for i in s.issues if i not in ("ahead", "linked")
    )
    return 1 if real_issues else 0


def main(argv: Optional[list[str]] = None) -> int:
    parser = argparse.ArgumentParser(
        description="Verify @adia-ai/* install lockstep + freshness.",
    )
    parser.add_argument("--root", default=".",
                        help="Consumer project root (default: cwd).")
    parser.add_argument("--offline", action="store_true",
                        help="Skip npm view (no network).")
    parser.add_argument("--json", action="store_true",
                        help="Machine-readable JSON output.")
    args = parser.parse_args(argv)

    root = Path(args.root).resolve()
    if not (root / "package.json").exists():
        print(f"✗ No package.json at {root}", file=sys.stderr)
        return 2
    if not (root / "node_modules").exists():
        print(f"⚠ No node_modules at {root} — run `npm install` first", file=sys.stderr)
        return 2

    statuses, summary = run_audit(root, args.offline)

    if args.json:
        return render_json(statuses, summary)
    return render_text(statuses, summary)


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