"""hermes pm: lock / install / repair / env / doctor / gc / bundle."""

from __future__ import annotations

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

from pm import termux_libs
from pm.install import _facts, _lockfile, _store, ensure, stage_only
from pm.operations import lock_project
from pm.package import InstallError
from pm.paths import repo_root
from pm.registry import get_package, source_install_packages, tool_roots
from pm.store import ALL_TARGETS, current_target, hash_url
from pm.update import Resolved, resolve_package, reuse_index_responses


def cmd_lock(args) -> int:
    """No arguments: relock uv.lock from pyproject.toml. --bump: pin a tool in pm/lock.json."""
    if args.name is None:
        return _relock_project(repo_root())
    return _pin_tool(args)


def _relock_project(root: Path) -> int:
    """The contributor's one step after editing pyproject.toml.

    The same PM operations as `python -m pm.build_env --source . --check-lock`
    and `--lock-only`, so the exclude-newer quarantine and resolver settings
    cannot drift between the two entry points. No environment changes here:
    activation owns syncing, and a relock that also installed would hide
    which of the two failed.
    """
    import pm

    print("→ Checking uv.lock…", flush=True)
    try:
        # Quiet: stale is the expected case here, so uv's failure tail would
        # read like an error. An unreachable index fails the relock below too,
        # and that error names the index knobs.
        pm.check_project_lock(root, explicit=True, quiet=True)
    except InstallError:
        print("uv.lock is out of date with pyproject.toml; relocking", flush=True)
    else:
        print("✓ uv.lock is already current with pyproject.toml; nothing written")
        return 0
    before = _locked_extras(root)
    try:
        pm.lock_project(root, explicit=True)
    except InstallError as exc:
        print(f"✗ uv.lock refresh failed: {exc}")
        return 1
    print("✓ uv.lock updated from pyproject.toml")
    windows = sys.platform == "win32"
    activate = r". .\activate.ps1" if windows else "source ./activate"
    print(f"Next: re-source activation to sync the environments: {activate}")
    opt_in = _new_opt_in_extras(root, before)
    if opt_in:
        # Activation syncs [all] plus extras already recorded, and --test-extras
        # REPLACES the default rather than adding to it, so [all] stays listed.
        names = ",".join(["all", *opt_in])
        flag = f"-TestExtras '{names}'" if windows else f"--test-extras {names}"
        print(f"New extras outside [all] ({', '.join(opt_in)}) need: {activate} {flag}")
    print("Then commit pyproject.toml and uv.lock together.")
    return 0


def _locked_extras(root: Path) -> set[str]:
    """Extras the current uv.lock already resolves for the root project."""
    import tomllib

    try:
        with (root / "uv.lock").open("rb") as f:
            lock = tomllib.load(f)
        with (root / "pyproject.toml").open("rb") as f:
            name = tomllib.load(f)["project"]["name"]
    except (OSError, ValueError, KeyError):
        return set()
    for package in lock.get("package", ()):
        if package.get("name") == name:
            return set(package.get("metadata", {}).get("provides-extras", ()))
    return set()


def _new_opt_in_extras(root: Path, before: set[str]) -> list[str]:
    """Extras this relock introduced that the default [all] closure does not reach."""
    import re
    import tomllib

    with (root / "pyproject.toml").open("rb") as f:
        project = tomllib.load(f)["project"]
    extras = project.get("optional-dependencies", {})
    self_ref = re.compile(rf"\s*{re.escape(project['name'])}\s*\[([^\]]+)\]")
    covered, pending = set(), ["all"]
    while pending:
        extra = pending.pop()
        if extra in covered:
            continue
        covered.add(extra)
        for requirement in extras.get(extra, ()):
            match = self_ref.match(requirement)
            if match:
                pending.extend(part.strip() for part in match.group(1).split(","))
    return sorted(set(extras) - before - covered)


@reuse_index_responses()
def _pin_tool(args) -> int:
    """--bump <name> <version>: resolve every target's archives, hash them,
    write. A target with one archive pins the object; several pin a list.
    Target-independent urls collapse to one "any" artifact."""
    lockfile = _lockfile()
    package = get_package(args.name)
    artifacts: dict[str, object] = {}

    def pin(url: str) -> dict:
        print(f"    {url}")
        digest = package.known_sha256(args.version, url) or hash_url(url)
        print(f"      sha256 {digest}")
        return {"url": url, "sha256": digest}

    urls = {
        target: package.fetch_urls(args.version, target)
        for target in ALL_TARGETS
        if package.missing_reason(target) is None
    }
    distinct = {tuple(u) for u in urls.values()}
    if len(distinct) == 1:
        print("  any:")
        pinned = [pin(url) for url in next(iter(urls.values()))]
        artifacts["any"] = pinned[0] if len(pinned) == 1 else pinned
    else:
        for target, target_urls in urls.items():
            print(f"  {target}:")
            pinned = [pin(url) for url in target_urls]
            artifacts[target] = pinned[0] if len(pinned) == 1 else pinned
    lockfile.set_pin(args.name, args.version, artifacts)
    lockfile.save()
    print(f"pinned {args.name} {args.version} ({len(artifacts)} targets)")
    return 0


def _fmt_bytes(n: int) -> str:
    return f"{n / (1024 * 1024):.1f} MiB"


def _enable_vt(stream) -> bool:
    """A Windows console prints ESC as a glyph (`←[2K`) until its handle opts
    in to VT processing, and PowerShell hands children a console with VT off.
    NUL also reports isatty() but is no console, so it fails here too."""
    if sys.platform != "win32":
        return True
    import ctypes
    import msvcrt
    from ctypes import wintypes

    try:
        handle = msvcrt.get_osfhandle(stream.fileno())
    except (AttributeError, OSError, ValueError):
        return False
    kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
    mode = wintypes.DWORD()
    if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
        return False
    vt = 0x0004  # ENABLE_VIRTUAL_TERMINAL_PROCESSING
    return bool(mode.value & vt or kernel32.SetConsoleMode(handle, mode.value | vt))


def _progress_stream():
    """Stream for in-place progress, or None off a terminal. Prefer stdout;
    fall back to stderr because activate.ps1 pipes stdout through Out-Host
    while stderr stays on the console."""
    for stream in (sys.stdout, sys.stderr):
        try:
            if stream.isatty() and _enable_vt(stream):
                return stream
        except (AttributeError, ValueError):
            continue
    return None


def _interactive() -> bool:
    return _progress_stream() is not None


def _live_progress(name: str):
    """Per-package progress for ensure(): download as % + MiB, unpack as a
    phase line. On a terminal the line redraws in place (~10 Hz); in a piped
    (CI) log each tick prints its own line, throttled to ~4 MiB steps so a
    slow line proves it's moving without flooding the log (a 1 MiB tick on a
    1.5 GiB model would be ~1,500 lines). ``finish()`` returns the cursor to
    a clean line so the caller's status glyph starts on its own row."""
    last = 0
    last_time = 0.0
    stream = _progress_stream()

    def write(line: str) -> None:
        if stream is not None:
            stream.write("\r\x1b[2K" + line)
            stream.flush()
        else:
            print(line, flush=True)

    def finish() -> None:
        if stream is not None:
            stream.write("\r\x1b[2K")
            stream.flush()

    def report(stage: str, done: int, total: int, label: str) -> None:
        nonlocal last, last_time
        if stage == "unpack":
            last = 0
            last_time = 0.0
            write(f"  {name}: unpacking{(' ' + label) if label else ''}")
            return
        if total <= 0:
            return
        if done < total:
            if stream is not None:
                now = time.monotonic()
                if now - last_time < 0.1:
                    return
                last_time = now
            elif done - last < 4 * 1024 * 1024:
                return
        last = done
        write(f"  {name}: {done / total * 100:5.1f}%  {_fmt_bytes(done)} / {_fmt_bytes(total)}")

    report.finish = finish  # type: ignore[attr-defined]
    return report


def _install_names(names: list[str], target: str | None = None, *, verify: bool = True) -> int:
    from pm.install import _install_operation

    failed = 0
    with _install_operation() as operation:
        for name in names:
            progress = _live_progress(name)
            try:
                if target is not None:
                    # Cross-target staging: publish the entry, touch no facts.
                    entry = stage_only(name, target)
                    print(f"✓ {name} (staged for {target}: {entry.name})")
                else:
                    ensure(name, explicit=True, verify=verify, progress=progress, _operation=operation)
                    if name == "python":
                        from hermes_cli.venv_sync import publish_launchers

                        publish_launchers(repo_root(), create=False)
                    progress.finish()
                    print(f"✓ {name}", flush=True)
            except InstallError as e:
                progress.finish()
                print(f"✗ {e}", flush=True)
                failed += 1
    return failed




def _install_flag_error(args, *, extras: list[str], cross_target, tools_only: bool,
                        trust_recorded: bool, test_environment) -> str | None:
    """The first incompatible flag combination, as its user-facing message."""
    if cross_target:
        if cross_target not in ALL_TARGETS:
            return f"unknown target {cross_target!r}; known: {', '.join(ALL_TARGETS)}"
        if not args.names:
            return "--target requires explicit package names"
    if test_environment is not None and (extras or cross_target or args.names or tools_only):
        return "--test-environment builds beside the default closure; it does not take names, --extra, --target, or --tools-only"
    if tools_only and (extras or cross_target or args.names):
        return "--tools-only installs the tool closure and then stops; it does not take names, --extra, or --target"
    if trust_recorded and (extras or cross_target or args.names):
        return "--trust-recorded installs the default closure and then stops; it does not take names, --extra, or --target"
    if extras and cross_target:
        return "--extra syncs this install's venv and cannot combine with --target"
    without = getattr(args, "without", None) or ()
    if without and (extras or cross_target or args.names):
        return "--without shapes the default closure; it does not take names, --extra, or --target"
    if without:
        from pm.defaults import default_package_names

        allowed = default_package_names()
        unknown = [name for name in without if name not in allowed]
        if unknown:
            return (f"--without accepts only optional default packages ({', '.join(allowed)}); "
                    f"not {', '.join(unknown)}")
    return None


def _install_defaults(names: list[str], *, verify: bool) -> None:
    """Install the optional defaults; a failure warns and never fails the install.

    They are the browser and computer-use tools, not what Hermes needs to run:
    a Chromium download that fails behind a proxy must not abort an install
    whose required closure and venv are fine.
    """
    for name in names:
        if _install_names([name], verify=verify):
            print(f"⚠ optional {name} was not installed; its tools stay unavailable until "
                  f"`hermes pm install {name}` succeeds", flush=True)


def _install_python_environments(extras: list[str], *, sync: bool, test_environment) -> int:
    """Sync the venv and build the side test environment; return the failure count."""
    failed = 0
    if sync:
        from pm.install import sync_venv

        try:
            # Default the venv to the [all] feature set — the same thing
            # `hermes update` force-syncs on every run (update_cmd.py) and
            # the installers' old `--extra all` did. sync_venv unions, so
            # any lazy extras already recorded survive this; it only makes
            # a fresh bootstrap match what the first update would do.
            sync_venv(extras or ["all"], explicit=True)
            print(f"✓ venv{' +' + ' +'.join(extras) if extras else ''}")
        except InstallError as e:
            print(f"✗ {e}")
            failed += 1
    if test_environment is not None and not failed:
        from pm import check_project_lock
        from pm.testenv import ensure_testenv, parse_extras

        # Before the input stamps: they then cover this environment too, so
        # the shebang/run_tests.sh staleness check rebuilds it when it drifts.
        try:
            check_project_lock(repo_root(), explicit=True)
            ensure_testenv(repo_root(), parse_extras(test_environment))
            print("✓ test environment")
        except InstallError as e:
            print(f"✗ {e}")
            failed += 1
    return failed


def cmd_install(args) -> int:
    cross_target = getattr(args, "target", None)
    # Source-install launchers require the store interpreter, even though
    # Python remains optional when provisioning individual tools.
    extras = list(dict.fromkeys(getattr(args, "extra", None) or ()))
    tools_only = bool(getattr(args, "tools_only", False))
    trust_recorded = bool(getattr(args, "trust_recorded", False))
    test_environment = getattr(args, "test_environment", None)
    error = _install_flag_error(args, extras=extras, cross_target=cross_target, tools_only=tools_only,
                                trust_recorded=trust_recorded, test_environment=test_environment)
    if error:
        print(f"✗ {error}")
        return 1
    names = args.names if args.names or extras else source_install_packages(_lockfile().names())
    without = list(dict.fromkeys(getattr(args, "without", None) or ()))
    if without:
        from pm.defaults import record_declined

        # Persisted before anything installs: later bare installs and
        # `hermes update` read the same record, so the opt-out sticks.
        record_declined(add=without)
    defaults: list[str] = []
    if not (args.names or extras):
        from pm.defaults import default_packages

        defaults = default_packages(_lockfile().names())
    # Only the whole default closure verifies everything an activated shell
    # composes from, so only it advances the prologue's input stamps.
    full_closure = not (args.names or tools_only or cross_target)
    from pm.environments import activation_input_mtimes

    input_mtimes = activation_input_mtimes(repo_root()) if full_closure else {}
    # Tools before the venv. A bare `pm install` used to install tools and
    # sync the venv in one breath, so a native build (Windows ARM64 source
    # wheels) resolved compilers and git from the host PATH. Publish every
    # tool first and put it on PATH; sync only after that.
    tool_names = names if args.names else tool_roots(names)
    if _install_names(tool_names, target=cross_target, verify=not trust_recorded):
        return 1
    if args.names and not cross_target:
        from pm.defaults import record_declined

        # Naming a declined default is the opt-back-in: updates carry it again.
        record_declined(remove=args.names)
    if not cross_target and (not args.names or tools_only):
        from pm.install import activate

        problems = activate(allow_incomplete=True)
        if problems:
            print(f"✗ tools not on PATH before venv sync: {'; '.join(problems)}", flush=True)
            return 1
    if tools_only:
        _install_defaults(defaults, verify=not trust_recorded)
        return 0
    failed = _install_python_environments(extras, sync=bool(extras or not args.names),
                                          test_environment=test_environment)
    # Defaults are optional and large (agent-browser + Chromium, cua-driver): fetch them
    # only once the venv, and on Windows ARM64 its build tools, succeeded.
    if not failed:
        _install_defaults(defaults, verify=not trust_recorded)
    if full_closure and not failed:
        from pm.environments import activation_inputs_dir, record_activation_inputs

        record_activation_inputs(activation_inputs_dir(repo_root()), input_mtimes, repo_root(),
                                 test_environment=test_environment is not None)
    return 1 if failed else 0


def cmd_env(args) -> int:
    from pm.install import env_for

    names = args.names or _lockfile().names()
    # Show only package exports, never the caller's environment (which can
    # contain credentials and is commonly pasted into support reports).
    print(json.dumps(env_for(*names, base_env={}), indent=2, sort_keys=True))
    return 0


def cmd_doctor(args) -> int:
    from pm.install import _identity, _installed_location
    from pm.store import tree_digest

    lockfile = _lockfile()
    facts = _facts()
    store = _store()
    target = current_target()
    bad = 0
    for name in lockfile.names():
        package = get_package(name)
        reason = package.missing_reason(target)
        if reason is not None:
            print(f"- {name}: n/a on {target} ({reason})")
            continue
        facts, store = _installed_location(package, lockfile, target) or (_facts(), _store())
        fact = facts.get(name)
        soft = package.optional or package.internal
        identity = _identity(lockfile, name, target)
        if (
            fact is not None
            and identity is not None
            and ("target" not in fact or "artifacts" not in fact)
        ):
            # Legacy fact: pre-dates digest-bound identity; installed()
            # treats it as not installed and forces one reinstall.
            print(f"{'?' if soft else '✗'} {name}: legacy fact: no recorded identity, run `hermes pm install`")
            bad += 0 if soft else 1
            continue
        if not facts.installed(name, lockfile.version(name), store.root, identity):
            state = "not installed" if fact is None else "outdated"
            print(f"{'?' if soft else '✗'} {name}: {state}")
            bad += 0 if soft else 1
            continue
        entry = store.entry(fact["entry"])
        reason = package.verify(entry, target)
        if reason:
            print(f"✗ {name}: installed but failed verification: {reason}")
            bad += 1
            continue
        recorded = fact.get("digest")
        if recorded is not None and tree_digest(entry) != recorded:
            # Doctor is the expensive-path tool: re-hash the realized
            # bytes. Boot checks stay O(1) json compares.
            print(f"✗ {name}: realized bytes do not match recorded digest")
            bad += 1
            continue
        print(f"✓ {name} {fact['version']}")
    return 1 if bad else 0


def _gc_store(store, facts) -> tuple[int, int]:
    """The sweep core shared by `pm gc` and `pm bundle`.

    Removes every store entry nothing references: fetch-<sha> download-cache
    dirs (the raw archives — needed only at install time, dead weight in a
    staged payload or a CI cache), orphaned package versions from an older
    lock, and expired partials. Keeps live package entries (recorded in
    facts) and partials an in-flight download still owns. Returns
    (removed, kept).
    """
    from pm.download_state import collect_partials
    from pm import paths

    partials_dir = paths.partials_root()
    if not store.root.is_dir() and not partials_dir.is_dir():
        return (0, 0)
    removed = 0
    with store.install_lock():
        facts.reload()
        keep = facts.entries_in_use()
        collect_partials(partials_dir)
        for item in sorted(store.root.iterdir()):
            if not item.is_dir():
                continue
            # Scratch dirs are created and removed under this same lock, so any
            # that remain belong to a killed installer. Other dot-dirs stay:
            # .previous-* is the restore point the next install of that entry
            # consumes, and it is only safe to drop after that verification.
            if item.name.startswith(".") and not item.name.startswith(".staging-"):
                continue
            if item.name in keep:
                continue
            print(f"removing {item.name}")
            shutil.rmtree(item, ignore_errors=True)
            removed += 1
    return (removed, len(keep))


def cmd_gc(args) -> int:
    from pm.paths import writable_store_root
    from pm.lock import Facts
    from pm.store import Store
    store = Store(writable_store_root())
    facts = _facts() if store.root == _store().root else Facts(store.root / "facts.json")
    removed, kept = _gc_store(store, facts)
    from hermes_cli.runtime_state import collect_generations
    from pm.environments import install_state_dir
    from pm.paths import repo_root
    from pm.runtime import collect_runtime_generations
    generations = collect_generations(repo_root())
    runtimes = collect_runtime_generations(install_state_dir(repo_root()) / "pm-runtime")
    print(f"gc: removed {removed}, kept {kept}; removed {len(generations)} dependency generations, "
          f"{len(runtimes)} PM runtime generations")
    return 0


def _run_live(cmd: list[str], *, cwd, env, timeout: int = 3600) -> tuple[int, str]:
    """Stream CI progress with the Python engine's bounded drain and diagnostic tail."""
    from pm.environment import _run_streaming

    try:
        result = _run_streaming(cmd, cwd=cwd, env=env, timeout=timeout, output=sys.stdout)
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(f"{cmd[0]} timed out after {timeout}s") from exc
    return result.returncode, result.stderr


def _resolve_updates(names: list[str], lockfile, only_target: str | None) -> tuple[list, list[str]]:
    """Resolve each package's latest; an upstream outage records a failure instead of aborting."""
    resolved = []
    failures = []
    for name in names:
        package = get_package(name)
        targets = [t for t in ALL_TARGETS if package.missing_reason(t) is None]
        if only_target:  # cross-target check: only the requested target matters
            targets = [t for t in targets if t == only_target]
        if not targets:
            continue
        try:
            decision = resolve_package(package, targets, lockfile.version(name),
                                       artifacts=lockfile.pinned_artifacts(name))
        except Exception as e:  # an upstream index outage must not kill the whole check
            decision = Resolved(name, lockfile.version(name), package.version_style, reason=f"resolve failed: {e}")
            failures.append(name)
        resolved.append(decision)
    return resolved, failures


def _report_update(decision, width: int) -> None:
    d = decision
    if d.version is None:
        print(f"{d.name:<{width}}  {d.reason or 'up to date'}")
    elif not d.changed:
        print(f"{d.name:<{width}}  {d.locked} up to date")
    elif d.version == d.locked and d.artifact_updates:
        print(f"{d.name:<{width}}  {d.version}: newer artifacts for {', '.join(sorted(d.artifact_updates))}")
    else:
        per = ""
        if d.per_target and len(set(d.per_target.values())) > 1:
            per = " (" + ", ".join(f"{t}={v}" for t, v in sorted(d.per_target.items())) + ")"
        print(f"{d.name:<{width}}  {d.locked or '—'} → {d.version}{per}")


def _sync_venv_step() -> bool:
    from pm.install import sync_venv

    try:
        sync_venv(explicit=True)
    except InstallError as e:
        print(f"✗ {e}")
        return False
    print("✓ venv")
    return True


def _apply_pins(changed: list, lockfile) -> int:
    if not changed:
        print("pm update: nothing to update")
        return 0
    for d in changed:
        package = get_package(d.name)
        artifacts = _pin_artifacts(package, d, lockfile.pinned_artifacts(d.name))
        lockfile.set_pin(d.name, d.version, artifacts)
        print(f"✓ {d.name} pinned {d.locked or '—'} → {d.version}")
    lockfile.save()
    if _install_names([d.name for d in changed]):
        return 1
    return 0 if _sync_venv_step() else 1


def _refresh_uv_lock() -> int:
    try:
        lock_project(repo_root(), upgrade=True, explicit=True)
    except InstallError as exc:
        print(f"✗ Python lock refresh failed: {exc}")
        return 1
    print("✓ uv.lock refreshed")
    return 0 if _sync_venv_step() else 1


def _refresh_npm_lock() -> int:
    from pm.install import env_for, installed_package
    from pm.packages import npm_env
    from pm.paths import writable_store_root

    npm = installed_package("npm")
    node = installed_package("node")
    if npm is None or npm.binary is None or node is None or node.binary is None:
        print("✗ npm or Node: not installed; run `hermes pm install`")
        return 1
    env = npm_env(writable_store_root() / ".npm-cache", env_for("npm"))
    code, tail = _run_live([str(npm.binary), "update"], cwd=str(repo_root()), env=env)
    if code != 0:
        print(f"✗ npm update failed:\n{tail}")
        return 1
    print("✓ package-lock.json refreshed")
    return 0


def cmd_update(args) -> int:
    """`hermes pm update [names...] [--check] [--target T] [--uv] [--npm] [--termux]`.

    Resolve each package's latest via its own latest_versions() hook,
    intersect across targets, and (real mode) re-pin the lockfile + install
    the changed ones. --check is dry-run: hits upstream indexes, writes
    nothing. --uv / --npm also refresh uv.lock (+sync venv) / package-lock.
    --termux is its own repair pass: it repins only the pool archives the
    rolling termux-main pool has retired under our pins.
    """
    if args.termux:
        ignored = [name for name, given in (("names", args.names), ("--target", args.target),
                                            ("--uv", args.uv), ("--npm", args.npm)) if given]
        if ignored:
            print(f"::warning::--termux repairs the termux pool pins only; ignoring {', '.join(ignored)}")
        return _termux_pass(check=args.check)
    lockfile = _lockfile()
    names = args.names or [n for n in lockfile.names() if not get_package(n).internal or n == "uv"]
    if args.target and not args.check:
        print("::warning::--target is a CHECK-only cross-resolution flag; ignoring it for apply (the lockfile pins every target)")
        args.target = None

    resolved, failures = _resolve_updates(names, lockfile, args.target)
    changed = [d for d in resolved if d.changed]
    if not resolved:
        print("pm update: nothing to check (no resolvable packages)")
        return 0
    width = max(len(d.name) for d in resolved)
    for d in resolved:
        _report_update(d, width)
    if failures:
        print(f"pm update: resolution failed for {', '.join(failures)}; no changes applied")
        return 1
    if args.check:
        if args.uv:
            print("uv deps: would run `uv lock --upgrade` + venv sync")
        if args.npm:
            print("npm deps: would run `npm update`")
        return 1 if changed else 0

    if _apply_pins(changed, lockfile):
        return 1
    if args.uv and _refresh_uv_lock():
        return 1
    if args.npm:
        return _refresh_npm_lock()
    return 0


def _termux_pass(*, check: bool) -> int:
    """Repin the pool archives Termux has retired under our pins.

    The bionic lock rows and the runtime-lib table have no shared version axis
    to resolve (each pins what its own supplier ships), so this is a repair,
    not an update: only rows whose archive is gone are touched.
    """
    table = termux_libs.load_table()
    lockfile = _lockfile()
    total = len(termux_libs.pins(table, lockfile))
    stale = termux_libs.retired(table, lockfile, termux_libs.index())
    if not stale:
        print(f"termux pins: {total} rows still served by the pool")
        return 0
    unresolved = [entry for entry in stale if entry.replacement is None]
    width = max(len(entry.pin.name) for entry in stale)
    for entry in stale:
        if entry.replacement is None:
            print(f"{entry.pin.name:<{width}}  {entry.pin.version}: the pool no longer carries it")
        else:
            print(f"{entry.pin.name:<{width}}  {entry.pin.version} → {entry.replacement.version}")
    if check:
        return 1
    applied = termux_libs.repair(table, lockfile, stale)
    if applied:
        termux_libs.save_table(table)
        lockfile.save()
        print(f"✓ {applied} termux pins repinned; restage the bionic payload to pick them up")
    return 1 if unresolved else 0


@reuse_index_responses()
def _pin_artifacts(package, decision, current: dict) -> dict:
    """Retain unresolved targets and reuse hashes for unchanged artifact URLs."""
    per_target = decision.per_target or {t: decision.version for t in ALL_TARGETS}
    artifacts = dict(current)
    hashes: dict[str, str] = {}
    for target, version in per_target.items():
        if package.missing_reason(target) is not None:
            continue
        old = current.get(target, current.get("any", []))
        old = old if isinstance(old, list) else [old]
        known = {row["url"]: row["sha256"] for row in old}
        urls = decision.artifact_updates.get(target)
        if urls is None:
            urls = package.fetch_urls(version, target)
        if urls == [row["url"] for row in old]:
            continue
        pinned = []
        for url in urls:
            digest = known.get(url) or hashes.get(url)
            if not digest:
                digest = package.known_sha256(version, url) or hash_url(url)
                hashes[url] = digest
            pinned.append({"url": url, "sha256": digest})
        artifacts[target] = pinned[0] if len(pinned) == 1 else pinned
    return artifacts


def cmd_status(args) -> int:
    """Print the latest pm sync receipt — the reader surface for the
    CLI/TUI/desktop (same schema as update receipts; a failed venv
    rebuild or a plugin bisect is as reportable as a failed update)."""
    import json as _json

    from pm import receipt

    data = receipt.latest()
    if data is None:
        print("no pm sync receipt yet (no venv operation has run)")
        return 0
    print(_json.dumps(data, indent=2))
    return 0


def cmd_repair(args) -> int:
    from hermes_cli._early_recovery import recover_if_needed
    from pm.paths import repo_root

    if not recover_if_needed(repo_root(), explicit=True):
        return 1
    print("Restart Hermes to use the repaired dependency environment.")
    return 0


def cmd_bundle(args) -> int:
    from scripts.bundles.native import stage_native
    return stage_native(args)



def main(argv=None) -> int:
    # Windows consoles default to cp1252; pm prints ✓/✗. Never let the
    # status glyphs crash the command reporting them. line_buffering:
    # pm output must stream live in a piped (CI) log, not sit in a block
    # buffer and flush only at exit.
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(errors="replace", line_buffering=True)
        except (AttributeError, OSError):
            pass
    parser = argparse.ArgumentParser(prog="hermes pm")
    sub = parser.add_subparsers(dest="cmd", required=True)

    lock_parser = p = sub.add_parser(
        "lock", help="relock uv.lock from pyproject.toml (or --bump a tool pin in pm/lock.json)",
        description="With no arguments: re-resolve uv.lock from pyproject.toml; changes no "
                    "environment and writes nothing when the lock is current. With --bump NAME "
                    "VERSION: pin a pm tool's artifacts in pm/lock.json; uv.lock is untouched.")
    p.add_argument("--bump", dest="name", metavar="NAME",
                   help="pin tool NAME at VERSION in pm/lock.json instead of relocking uv.lock")
    p.add_argument("version", nargs="?", metavar="VERSION", help="the tool version (only with --bump)")
    p.set_defaults(func=cmd_lock)

    p = sub.add_parser("install", help="install packages (default: all required + optional defaults)")
    p.add_argument("names", nargs="*")
    p.add_argument("--extra", action="append", default=[], metavar="NAME",
                   help="enable a declared dependency extra in the venv (repeatable)")
    p.add_argument("--without", action="append", default=[], metavar="NAME",
                   help="leave an optional default package (agent-browser, cua-driver) out of this and every later "
                        "default install and update; `hermes pm install NAME` opts back in (repeatable)")
    p.add_argument("--tools-only", action="store_true",
                   help="install the tool closure, put it on PATH, and stop before the venv sync")
    p.add_argument("--trust-recorded", action="store_true",
                   help="trust the recorded tool digest instead of re-hashing every entry. "
                        "shell activation only. a deliberate install re-checks the bytes")
    p.add_argument("--test-environment", nargs="?", const="", default=None, metavar="EXTRAS",
                   help="also make this checkout's isolated test environment current (activation). "
                        "EXTRAS is comma-separated; omitted selects [all]")
    p.add_argument(
        "--target",
        help="stage for a cross target (e.g. linux-arm64-bionic on a glibc "
        "CI host); requires explicit package names",
    )
    p.set_defaults(func=cmd_install)

    p = sub.add_parser("env", help="print composed env of installed packages")
    p.add_argument("names", nargs="*")
    p.set_defaults(func=cmd_env)

    p = sub.add_parser("doctor", help="check installed state against the lockfile")
    p.set_defaults(func=cmd_doctor)

    p = sub.add_parser("repair", help="rebuild the recorded dependency environment without changing its graph")
    p.set_defaults(func=cmd_repair)

    p = sub.add_parser("gc", help="remove store entries nothing references")
    p.set_defaults(func=cmd_gc)

    p = sub.add_parser("bundle", help="stage a payload (repo+store+facts+relocatable venv) into --out")
    p.add_argument("--out", required=True)
    p.add_argument("--ref", help="git ref for the repo snapshot (default HEAD)")
    p.add_argument("--cache", type=Path, help="persistent build cache (default: UV_CACHE_DIR or PM's shared cache)")
    p.set_defaults(func=cmd_bundle)

    p = sub.add_parser("status", help="print the latest pm sync receipt (machine-readable)")
    p.set_defaults(func=cmd_status)
    p = sub.add_parser("update", help="resolve latest versions and re-pin the lockfile")
    p.add_argument("names", nargs="*", help="packages to check/update (default: all with a latest source)")
    p.add_argument("--check", action="store_true",
                   help="dry-run: print what would change, write nothing (exit 1 if updates exist)")
    p.add_argument("--target", help="resolve for a different target instead of this machine (e.g. win32-arm64)")
    p.add_argument("--uv", action="store_true", help="also refresh uv.lock + venv (uv update + sync)")
    p.add_argument("--npm", action="store_true", help="also refresh package-lock.json (npm update)")
    p.add_argument("--termux", action="store_true",
                   help="repin the termux pool archives the pool has retired (the runtime-lib "
                        "table + the bionic lock rows); --check reports without writing")
    p.set_defaults(func=cmd_update)

    args = parser.parse_args(argv)
    if args.cmd == "lock" and (args.name is None) != (args.version is None):
        lock_parser.error("--bump NAME and VERSION go together; run with neither to relock uv.lock")
    from pm.runtime import is_runtime, run_cli

    try:
        if not is_runtime():
            return run_cli(list(sys.argv[1:] if argv is None else argv))
        return args.func(args)
    except InstallError as exc:
        print(f"✗ {exc}", file=sys.stderr)
        return 1
    except PermissionError as exc:
        from pm.environments import install_state_permission_message

        if message := install_state_permission_message(repo_root(), exc):
            print(f"✗ {message}", file=sys.stderr)
            return 1
        raise


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