"""Portable NexusOS command line used by the ``nexus`` and ``ncp`` scripts."""
from __future__ import annotations

import argparse
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import webbrowser
from pathlib import Path
from urllib.parse import urlparse

from synapse import nexus_config as config
from synapse.nexus_config import settings

from . import ncp as services


CONFIG_SCHEMA = {
    "home": "path",
    "api_url": "url",
    "memory_url": "url",
    "bind_host": "text",
    "backend_port": "port",
    "memory_port": "port",
    "provider": "provider",
    "provider_url": "url",
    "provider_timeout": "positive_int",
    "data_dir": "path",
    "models_dir": "path",
    "runtime_dir": "path",
    "memory_dir": "path",
    "memory_db": "path",
}

LEGACY_TARGETS = {
    "-m": "memory",
    "--memory": "memory",
    "-b": "backend",
    "--backend": "backend",
    "-f": "frontend",
    "--frontend": "frontend",
    "-a": "ai",
    "--ai": "ai",
}


def _emit(payload, json_output: bool = False) -> None:
    if json_output:
        print(json.dumps(payload, indent=2, sort_keys=True))
    elif isinstance(payload, str):
        print(payload)
    else:
        for key, value in payload.items():
            print(f"{key}: {value}")


def _http_ok(url: str, timeout: float = 1.0) -> bool:
    try:
        urllib.request.urlopen(url, timeout=timeout).read(1)
        return True
    except urllib.error.HTTPError:
        return True
    except Exception:
        return False


def _is_termux() -> bool:
    prefix = os.getenv("PREFIX", "")
    return "com.termux" in prefix or bool(os.getenv("TERMUX_VERSION"))


def _validate_config(key: str, raw: str):
    kind = CONFIG_SCHEMA[key]
    value = raw.strip()
    if kind == "url":
        parsed = urlparse(value)
        if parsed.scheme not in ("http", "https") or not parsed.netloc:
            raise ValueError(f"{key} must be an http(s) URL")
        return value.rstrip("/")
    if kind == "port":
        number = int(value)
        if not 1 <= number <= 65535:
            raise ValueError(f"{key} must be between 1 and 65535")
        return number
    if kind == "positive_int":
        number = int(value)
        if number <= 0:
            raise ValueError(f"{key} must be greater than zero")
        return number
    if kind == "provider":
        if value not in ("ollama", "ollama-remote"):
            raise ValueError("provider must be ollama or ollama-remote")
        return value
    if kind == "path":
        return str(Path(value).expanduser().resolve())
    if not value:
        raise ValueError(f"{key} cannot be empty")
    return value


def cmd_init(args) -> int:
    config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    copied = list(config.INITIALIZED_FILES) + config.init_state()
    payload = {
        "status": "initialized",
        "state_dir": str(settings.state_dir),
        "config_file": str(settings.config_file),
        "data_dir": str(settings.data_dir),
        "models_dir": str(settings.models_dir),
        "runtime_dir": str(settings.runtime_dir),
        "seeded_playbooks": len(copied),
    }
    _emit(payload, args.json)
    return 0


def cmd_paths(args) -> int:
    payload = {
        "install_mode": "checkout" if settings.source_checkout else "wheel",
        "project_root": str(settings.project_root),
        "resource_root": str(settings.resource_root),
        "state_dir": str(settings.state_dir),
        "config_file": str(settings.config_file),
        "data_dir": str(settings.data_dir),
        "memory_db": str(settings.memory_db),
        "models_dir": str(settings.models_dir),
        "runtime_dir": str(settings.runtime_dir),
        "web_dist_dir": str(settings.web_dist_dir),
    }
    _emit(payload, args.json)
    return 0


# Keys whose value decides where the SQLite database is looked up. Changing one
# does not move the file, so the next start would quietly open a fresh, empty
# database and the user's conversations and memories would look deleted.
_DB_LOCATION_KEYS = ("memory_db", "memory_dir", "data_dir", "home")


def _relocation_warning(key: str, value) -> str | None:
    """Warn when a config change points the database somewhere with no data."""
    if key not in _DB_LOCATION_KEYS:
        return None
    current = settings.memory_db
    if not current.is_file():
        return None
    if key == "memory_db":
        new_db = Path(str(value))
    elif key == "memory_dir":
        new_db = Path(str(value)) / current.name
    else:
        # data_dir/home only decide the DB location when nothing more specific
        # does, and only in the layout where the DB actually sits under them -
        # a source checkout keeps it in synapse/memory/ regardless.
        existing = config.read_user_config()
        if "memory_dir" in existing or "memory_db" in existing:
            return None
        anchor = settings.data_dir if key == "data_dir" else settings.state_dir
        try:
            tail = current.resolve().relative_to(anchor.resolve())
        except ValueError:
            return None
        new_db = Path(str(value)) / tail
    if new_db.resolve() == current.resolve() or new_db.is_file():
        return None
    return (
        f"{current} holds your existing conversations and memories, but this "
        f"change points NexusOS at {new_db}, which does not exist yet - it will "
        "start with an empty database. Stop NexusOS and move the .db (plus any "
        "-wal/-shm files) to the new path to keep your history."
    )


def cmd_config(args) -> int:
    values = config.read_user_config()
    if args.action == "path":
        print(config.CONFIG_FILE)
        return 0
    if args.action == "list":
        _emit(values, args.json)
        return 0
    if args.action == "get":
        if args.key not in CONFIG_SCHEMA:
            print(f"Unknown configuration key: {args.key}", file=sys.stderr)
            return 2
        value = values.get(args.key, getattr(settings, args.key, None))
        _emit({args.key: value}, args.json)
        return 0
    if args.action == "set":
        if args.key not in CONFIG_SCHEMA:
            print(f"Unknown configuration key: {args.key}", file=sys.stderr)
            print("Known keys: " + ", ".join(CONFIG_SCHEMA), file=sys.stderr)
            return 2
        try:
            values[args.key] = _validate_config(args.key, args.value)
        except (TypeError, ValueError) as exc:
            print(f"Invalid value: {exc}", file=sys.stderr)
            return 2
        payload = {"updated": args.key, "value": values[args.key], "restart_required": True}
        moved = _relocation_warning(args.key, values[args.key])
        if moved:
            payload["warning"] = moved
        config.write_user_config(values)
        _emit(payload, args.json)
        if moved and not args.json:
            print(f"\nWARNING: {moved}", file=sys.stderr)
        return 0
    if args.action == "unset":
        if args.key not in CONFIG_SCHEMA:
            print(f"Unknown configuration key: {args.key}", file=sys.stderr)
            return 2
        values.pop(args.key, None)
        config.write_user_config(values)
        _emit({"removed": args.key, "restart_required": True}, args.json)
        return 0
    return 2


def _provider_payload() -> dict:
    url = settings.ollama_host.rstrip("/")
    return {
        "provider": settings.provider,
        "url": url,
        "managed_by_nexus": settings.manage_ollama,
        "reachable": _http_ok(url + "/api/tags", timeout=2.0),
    }


def cmd_provider(args) -> int:
    if args.action == "show":
        _emit(_provider_payload(), args.json)
        return 0

    values = config.read_user_config()
    if args.mode == "local":
        values["provider"] = "ollama"
        try:
            values["provider_url"] = _validate_config(
                "provider_url", args.url or "http://127.0.0.1:11434"
            )
        except ValueError as exc:
            print(f"Invalid value: {exc}", file=sys.stderr)
            return 2
    else:
        if not args.url:
            print("Remote provider setup requires --url", file=sys.stderr)
            return 2
        try:
            values["provider_url"] = _validate_config("provider_url", args.url)
        except ValueError as exc:
            print(f"Invalid value: {exc}", file=sys.stderr)
            return 2
        values["provider"] = "ollama-remote"
    config.write_user_config(values)
    _emit({
        "provider": values["provider"],
        "url": values["provider_url"],
        "restart_required": True,
    }, args.json)
    return 0


def _check_import(module: str) -> bool:
    return importlib.util.find_spec(module) is not None


def _writable(path: Path) -> bool:
    try:
        path.mkdir(parents=True, exist_ok=True)
        probe = path / ".nexus-write-test"
        probe.write_text("ok", encoding="utf-8")
        probe.unlink()
        return True
    except OSError:
        return False


def diagnostics() -> dict:
    checks: list[dict] = []

    def add(name: str, ok: bool, detail: str, required: bool = True):
        checks.append({
            "name": name,
            "status": "pass" if ok else ("fail" if required else "warn"),
            "detail": detail,
            "required": required,
        })

    add("python", sys.version_info >= (3, 11), sys.version.split()[0])
    add("state", _writable(settings.state_dir), str(settings.state_dir))
    add("database directory", _writable(settings.memory_db.parent), str(settings.memory_db.parent))
    add("web assets", (settings.web_dist_dir / "index.html").is_file(), str(settings.web_dist_dir))
    add(
        "provider mode",
        settings.provider in ("ollama", "ollama-remote"),
        settings.provider,
    )
    add(
        "service ports",
        all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)),
        f"backend={settings.backend_port}, memory={settings.memory_port}",
    )
    for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
        add(f"import:{module}", _check_import(module), module)

    add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
    add("memory service", _http_ok(settings.memory_url + "/"), settings.memory_url, required=False)
    provider = _provider_payload()
    add("provider", provider["reachable"], provider["url"], required=False)
    if settings.manage_ollama:
        add("ollama executable", bool(services.ollama_bin()), services.ollama_bin() or "not on PATH", required=False)

    for label, module in (
        ("process control", "psutil"),
        ("vector search", "sqlite_vec"),
        ("voice transcription", "faster_whisper"),
        ("PDF documents", "pypdf"),
        ("Word documents", "docx"),
        ("web search", "duckduckgo_search"),
    ):
        add(label, _check_import(module), module, required=False)

    return {
        "ok": not any(c["status"] == "fail" for c in checks),
        "version": settings.version,
        "install_mode": "checkout" if settings.source_checkout else "wheel",
        "platform": sys.platform,
        "termux": _is_termux(),
        "checks": checks,
    }


def _doctor_fix() -> None:
    config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    config.init_state()
    index = settings.web_dist_dir / "index.html"
    npm = shutil.which("npm.cmd" if os.name == "nt" else "npm")
    if settings.source_checkout and not index.exists() and npm:
        subprocess.run([npm, "run", "build"], cwd=str(settings.frontend_source_dir), check=False)


def cmd_doctor(args) -> int:
    if args.fix:
        _doctor_fix()
    result = diagnostics()
    if args.json:
        _emit(result, True)
    else:
        print(f"NexusOS {result['version']} diagnostics ({result['install_mode']})\n")
        marks = {"pass": "OK", "warn": "WARN", "fail": "FAIL"}
        for check in result["checks"]:
            print(f"  {marks[check['status']]:<4} {check['name']:<20} {check['detail']}")
        print("\nCore runtime is ready." if result["ok"] else "\nCore runtime has required failures.")
    return 0 if result["ok"] else 1


def service_status() -> dict:
    payload = {}
    for key in ("backend", "memory", "frontend"):
        svc = services.SERVICES[key]
        pid = services.read_pid(svc)
        payload[key] = {
            "running": services.alive(pid) or _http_ok(svc.url),
            "pid": pid if services.alive(pid) else None,
            "url": svc.url,
        }
    payload["provider"] = _provider_payload()
    return payload


def cmd_status(args) -> int:
    payload = service_status()
    if args.json:
        _emit(payload, True)
        return 0
    print("Nexus Service Status:\n")
    for key in ("backend", "memory", "frontend"):
        info = payload[key]
        suffix = f" (PID {info['pid']})" if info["pid"] else ""
        print(f"  {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix}  {info['url']}")
    p = payload["provider"]
    print(f"  provider   {'RUNNING' if p['reachable'] else 'STOPPED'}  {p['provider']} @ {p['url']}")
    return 0


def cmd_monitor(args) -> int:
    from .monitor import run_monitor
    return run_monitor(
        interval=getattr(args, "interval", 1.5),
        once=bool(getattr(args, "once", False) or getattr(args, "json", False)),
        json_output=bool(getattr(args, "json", False)),
    )


def cmd_tui(args) -> int:
    """Interactive Hermes/OpenClaw-style chat TUI (requires nexusos-ai[tui])."""
    if not sys.stdin.isatty() or not sys.stdout.isatty():
        print(
            "The TUI needs a terminal. Use: nexus chat send \"…\"\n"
            "Or run `nexus` in an interactive shell.",
            file=sys.stderr,
        )
        return 2
    try:
        from .tui_app import run_tui
    except ImportError as exc:
        print(str(exc), file=sys.stderr)
        return 2
    api = getattr(args, "api_url", None) or settings.api_url
    return run_tui(api_url=api)


def _target_flag(target: str | None):
    return {
        "memory": "--memory",
        "backend": "--backend",
        "frontend": "--frontend",
        "ai": "--ai",
    }.get(target or "all")


def cmd_start(args) -> int:
    services.cmd_start(_target_flag(args.target))
    return 0


def cmd_stop(args) -> int:
    services.cmd_stop(_target_flag(args.target))
    return 0


def cmd_refresh(args) -> int:
    services.cmd_stop(None)
    services.cmd_start(None)
    return 0


def _lan_hostnames(host: str) -> list[str]:
    """Every name/address a --allow-lan bind should accept in a Host header.

    A wildcard bind answers on all interfaces, so enumerate them; an explicit
    address answers only as itself. The machine hostname comes along because
    that is what people actually type."""
    import socket

    names: list[str] = []

    def add(value: str) -> None:
        if value and value not in names:
            names.append(value)

    if host in ("0.0.0.0", "::", "*"):
        hostname = socket.gethostname()
        add(hostname)
        add(hostname.split(".")[0] + ".local")
        for family in (socket.AF_INET, socket.AF_INET6):
            try:
                for info in socket.getaddrinfo(hostname, None, family):
                    add(info[4][0])
            except OSError:
                pass
        # getaddrinfo(hostname) misses the routable address on hosts that map
        # their own name to loopback; a connectionless UDP socket finds it.
        for probe, family in (("8.8.8.8", socket.AF_INET), ("2001:4860:4860::8888", socket.AF_INET6)):
            sock = socket.socket(family, socket.SOCK_DGRAM)
            try:
                sock.connect((probe, 80))
                add(sock.getsockname()[0])
            except OSError:
                pass
            finally:
                sock.close()
    else:
        add(host.strip("[]"))
    # A Host header carries an IPv6 literal bracketed; allow both spellings so
    # the check matches however the client wrote it.
    for value in list(names):
        if ":" in value:
            add(f"[{value}]")
    return names


def _origin_host(name: str) -> str:
    """Origin-safe spelling: IPv6 literals must be bracketed in a URL."""
    if ":" in name and not name.startswith("["):
        return f"[{name}]"
    return name


def cmd_serve(args) -> int:
    host = args.host or settings.bind_host
    if host not in ("127.0.0.1", "localhost", "::1") and not args.allow_lan:
        print("Refusing an unauthenticated LAN bind. Add --allow-lan to acknowledge the exposure.", file=sys.stderr)
        return 2
    if _http_ok(f"http://127.0.0.1:{args.port}/"):
        print(f"Port {args.port} is already serving HTTP.", file=sys.stderr)
        return 2

    settings.backend_port = args.port
    settings.memory_port = args.memory_port
    settings.bind_host = host
    settings.api_url = f"http://127.0.0.1:{args.port}"
    settings.memory_url = f"http://127.0.0.1:{args.memory_port}"
    os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
    os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
    os.environ["NEXUS_BIND_HOST"] = host
    for origin_host in ("localhost", "127.0.0.1"):
        for port in (args.port, args.memory_port):
            origin = f"http://{origin_host}:{port}"
            if origin not in config.ALLOWED_ORIGINS:
                config.ALLOWED_ORIGINS.append(origin)
    if args.allow_lan:
        # Widen to the addresses this bind actually answers on - NOT "*".
        # ALLOWED_HOSTS drives TrustedHostMiddleware, which is the DNS-rebinding
        # defense: with "*" any site the user browses could resolve a name it
        # controls to this machine and drive the unauthenticated API. Naming the
        # real addresses keeps that check doing its job. An explicitly exported
        # NEXUS_ALLOWED_HOSTS still wins, for anyone who needs the old blanket.
        names = _lan_hostnames(host)
        for name in names:
            if name not in config.ALLOWED_HOSTS:
                config.ALLOWED_HOSTS.append(name)
            for port in (args.port, args.memory_port):
                origin = f"http://{_origin_host(name)}:{port}"
                if origin not in config.ALLOWED_ORIGINS:
                    config.ALLOWED_ORIGINS.append(origin)
        os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS))
        os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", ",".join(config.ALLOWED_ORIGINS))
        print(
            "LAN exposure enabled for: " + ", ".join(names)
            + "\nThe REST API is unauthenticated - anyone who can reach this port"
            " has full admin and data access."
        )

    memory_proc = None
    memory_log = None
    try:
        if not args.no_memory and not _http_ok(settings.memory_url + "/"):
            log_path = settings.runtime_dir / "memory.log"
            log_path.parent.mkdir(parents=True, exist_ok=True)
            memory_log = open(log_path, "ab")
            memory_proc = subprocess.Popen(
                [sys.executable, "-m", "uvicorn", "synapse.memory.service:app",
                 "--host", host, "--port", str(args.memory_port)],
                stdout=memory_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
            )
            print(f"Memory service starting on {host}:{args.memory_port} (log: {log_path})")
        print(f"NexusOS serving on http://{host}:{args.port}")
        import uvicorn
        uvicorn.run(
            "synapse.main:sio_app", host=host, port=args.port,
            reload=bool(args.reload and settings.source_checkout),
            log_level=args.log_level,
        )
    finally:
        if memory_proc is not None and memory_proc.poll() is None:
            memory_proc.terminate()
            try:
                memory_proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                memory_proc.kill()
        if memory_log is not None:
            memory_log.close()
    return 0


def cmd_open(args) -> int:
    if not _http_ok(settings.api_url + "/status") and not args.no_start:
        services.cmd_start(None)
    url = args.url or settings.api_url
    opener = shutil.which("termux-open-url") if _is_termux() else None
    opened = False
    if opener:
        opened = subprocess.run([opener, url], check=False).returncode == 0
    else:
        opened = webbrowser.open(url)
    print(f"{'Opened' if opened else 'NexusOS is available at'} {url}")
    return 0


def cmd_web(args) -> int:
    """Preserve ``ncp web`` for desktop checkouts; wheels use the browser UI."""
    if settings.source_checkout:
        return services.cmd_web()
    return cmd_open(argparse.Namespace(url=None, no_start=False))


def cmd_panel(_args) -> int:
    if not settings.source_checkout:
        print("The legacy Tk control panel is only available in a desktop source install.", file=sys.stderr)
        return 2
    return services.main(["panel"])


def cmd_backup(args) -> int:
    if not settings.source_checkout:
        print("Backup is a source-checkout command; wheel state should be backed up from nexus paths.", file=sys.stderr)
        return 2
    if args.check:
        return services.sync_py("backup", "--check")
    if args.full:
        return services.sync_py("backup", "--full")
    return services.sync_py("backup")


def cmd_restore(args) -> int:
    if not settings.source_checkout:
        print("Restore is a source-checkout command; reinstall the wheel and restore its state directory.", file=sys.stderr)
        return 2
    return services.cmd_restore("--check" if args.check else None)


def cmd_nvidia_reqs(_args) -> int:
    if not settings.source_checkout:
        print("nvidia-reqs is only available in a desktop source install.", file=sys.stderr)
        return 2
    return subprocess.run(
        [sys.executable, str(settings.project_root / "bin" / "gen-nvidia-reqs.py")]
    ).returncode


def cmd_logs(args) -> int:
    keys = ("backend", "memory", "frontend") if args.target == "all" else (args.target,)
    paths = [services.SERVICES[key].log_file for key in keys]
    for path in paths:
        print(f"=== {path.name} ===")
        services._tail(path, args.lines)
    if not args.follow:
        return 0
    offsets = {path: path.stat().st_size if path.exists() else 0 for path in paths}
    try:
        while True:
            for path in paths:
                if not path.exists():
                    continue
                size = path.stat().st_size
                if size < offsets[path]:
                    offsets[path] = 0
                if size > offsets[path]:
                    with open(path, encoding="utf-8", errors="replace") as stream:
                        stream.seek(offsets[path])
                        sys.stdout.write(stream.read())
                        sys.stdout.flush()
                        offsets[path] = stream.tell()
            time.sleep(0.5)
    except KeyboardInterrupt:
        print()
        return 130


def cmd_clean(args) -> int:
    for path in (settings.runtime_dir / "pids", settings.runtime_dir / "logs"):
        if path.is_dir():
            for file in path.glob("*"):
                if file.is_file():
                    file.unlink(missing_ok=True)
    for pattern in ("*.log", "*.pid"):
        for file in settings.runtime_dir.glob(pattern):
            file.unlink(missing_ok=True)
    print(f"Cleaned runtime files under {settings.runtime_dir}")
    return 0


def cmd_update(args) -> int:
    if settings.source_checkout:
        services.cmd_update()
        return 0
    command = [sys.executable, "-m", "pip", "install", "--upgrade", "nexusos-ai"]
    if args.pre:
        command.append("--pre")
    return subprocess.run(command).returncode


def cmd_models(args) -> int:
    import httpx

    base = settings.ollama_host.rstrip("/")
    try:
        if args.action == "list":
            response = httpx.get(base + "/api/tags", timeout=5.0)
            response.raise_for_status()
            models = response.json().get("models", [])
            if args.json:
                _emit(models, True)
            elif not models:
                print("No models installed.")
            else:
                for model in models:
                    print(f"{model.get('name', ''):<36} {model.get('size', 0) / 1024**3:5.1f} GB")
        elif args.action == "available":
            services.cmd_models("available", None)
        elif args.action in ("pull", "install"):
            with httpx.stream("POST", base + "/api/pull", json={"name": args.name}, timeout=None) as response:
                response.raise_for_status()
                for line in response.iter_lines():
                    if line:
                        try:
                            item = json.loads(line)
                            status = item.get("status") or item.get("error")
                            if status:
                                print(status)
                        except ValueError:
                            print(line)
        elif args.action in ("remove", "rm"):
            response = httpx.request("DELETE", base + "/api/delete", json={"name": args.name}, timeout=30.0)
            response.raise_for_status()
            print(f"Removed {args.name}")
        return 0
    except httpx.HTTPError as exc:
        print(f"Provider request failed at {base}: {exc}", file=sys.stderr)
        return 1


def cmd_api(args) -> int:
    from . import nexus_api
    nexus_api.BASE = args.api_url or settings.api_url
    rest = list(args.rest)
    if args.command == "chat" and rest[:1] == ["send"]:
        rest.pop(0)
    if args.command == "history" and rest[:1] == ["list"]:
        rest.pop(0)
    return nexus_api.main([args.command, *rest], prog=f"nexus {args.command}")


def _add_json(parser) -> None:
    parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")


def _port(value: str) -> int:
    try:
        return _validate_config("backend_port", value)
    except (TypeError, ValueError) as exc:
        raise argparse.ArgumentTypeError(str(exc)) from exc


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="nexus",
        description=(
            "NexusOS local AI runtime and API client. "
            "With no subcommand, opens the interactive TUI (needs nexusos-ai[tui])."
        ),
    )
    parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
    parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
    # Bare `nexus` → TUI. Subcommands remain for scripts and one-shots.
    sub = parser.add_subparsers(dest="command", required=False)

    p = sub.add_parser("tui", help="interactive chat TUI (default when no subcommand)")
    p.set_defaults(fn=cmd_tui)

    p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
    p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)

    p = sub.add_parser("config", help="manage persistent CLI/runtime configuration")
    p.add_argument("action", choices=["list", "get", "set", "unset", "path"])
    p.add_argument("key", nargs="?"); p.add_argument("value", nargs="?"); _add_json(p); p.set_defaults(fn=cmd_config)

    p = sub.add_parser("provider", help="configure the Ollama-compatible model provider")
    provider_sub = p.add_subparsers(dest="action", required=True)
    show = provider_sub.add_parser("show"); _add_json(show); show.set_defaults(fn=cmd_provider)
    use = provider_sub.add_parser("use"); use.add_argument("mode", choices=["local", "remote"])
    use.add_argument("--url"); _add_json(use); use.set_defaults(fn=cmd_provider)

    p = sub.add_parser("doctor", help="check the core runtime and optional capabilities")
    p.add_argument("--fix", action="store_true"); _add_json(p); p.set_defaults(fn=cmd_doctor)
    p = sub.add_parser("status", help="show service and provider status"); _add_json(p); p.set_defaults(fn=cmd_status)
    p = sub.add_parser("monitor", help="ASCII dashboard for services, resources, and tool stats")
    p.add_argument("--once", action="store_true", help="print one frame and exit")
    p.add_argument("--interval", type=float, default=1.5, help="refresh seconds (live mode)")
    _add_json(p)
    p.set_defaults(fn=cmd_monitor)

    p = sub.add_parser("serve", help="run NexusOS in the foreground")
    p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port)
    p.add_argument("--memory-port", type=_port, default=settings.memory_port)
    p.add_argument("--no-memory", action="store_true"); p.add_argument("--allow-lan", action="store_true")
    p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
    p.set_defaults(fn=cmd_serve)

    for name, fn, help_text in (
        ("start", cmd_start, "start services in the background"),
        ("stop", cmd_stop, "stop background services"),
    ):
        p = sub.add_parser(name, help=help_text)
        p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend", "ai"], default="all")
        p.set_defaults(fn=fn)
    sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
    sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0)

    p = sub.add_parser("open", help="open the web interface")
    p.add_argument("--url"); p.add_argument("--no-start", action="store_true"); p.set_defaults(fn=cmd_open)
    sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web)
    sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel)
    p = sub.add_parser("logs", help="read or follow service logs")
    p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend"], default="all")
    p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000")
    p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs)
    sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean)
    p = sub.add_parser("update", help="update dependencies or the installed wheel"); p.add_argument("--pre", action="store_true"); p.set_defaults(fn=cmd_update)

    p = sub.add_parser("backup", help="back up a desktop source checkout")
    p.add_argument("--full", "-f", action="store_true"); p.add_argument("--check", "-c", action="store_true")
    p.set_defaults(fn=cmd_backup)
    p = sub.add_parser("restore", help="restore a desktop source checkout")
    p.add_argument("--check", "-c", action="store_true"); p.set_defaults(fn=cmd_restore)
    sub.add_parser("nvidia-reqs", help="regenerate source-checkout NVIDIA requirements").set_defaults(fn=cmd_nvidia_reqs)

    p = sub.add_parser("models", help="list, pull, and remove provider models")
    model_sub = p.add_subparsers(dest="action", required=True)
    item = model_sub.add_parser("list"); _add_json(item); item.set_defaults(fn=cmd_models)
    model_sub.add_parser("available", aliases=["search"]).set_defaults(fn=cmd_models, action="available")
    item = model_sub.add_parser("pull", aliases=["install"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="pull")
    item = model_sub.add_parser("remove", aliases=["rm"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="remove")

    for command in ("chat", "memory", "playbook", "history"):
        p = sub.add_parser(command, add_help=False, help=f"use the {command} API from the terminal")
        p.add_argument("rest", nargs=argparse.REMAINDER)
        p.set_defaults(fn=cmd_api)
    return parser


def _normalize_legacy_argv(argv) -> list[str]:
    """Translate the old shell CLI spelling before argparse sees it."""
    normalized = list(argv or [])
    if normalized == ["help"]:
        return ["--help"]
    # start/stop only. `logs` registers -f as the short form of --follow, so
    # translating it here would silently rewrite `logs -f` to `logs frontend`
    # - a tail of the wrong file instead of a follow, with no error.
    if normalized and normalized[0] in ("start", "stop"):
        normalized[1:] = [LEGACY_TARGETS.get(value, value) for value in normalized[1:]]
    elif normalized[:1] == ["logs"]:
        normalized[1:] = [
            value if value in ("-f", "--follow") else LEGACY_TARGETS.get(value, value)
            for value in normalized[1:]
        ]
    if normalized[:2] == ["backup", "full"]:
        normalized[1] = "--full"
    elif len(normalized) > 1 and normalized[0] == "backup" and normalized[1] in ("check", "--claude"):
        normalized[1] = "--check"
    elif len(normalized) > 1 and normalized[0] == "restore":
        if normalized[1] in ("check", "--claude"):
            normalized[1] = "--check"
        elif normalized[1] in ("full", "-f", "--full"):
            normalized.pop(1)
    return normalized


def main(argv=None) -> int:
    parser = build_parser()
    argv = _normalize_legacy_argv(argv)
    args = parser.parse_args(argv)
    if not getattr(args, "command", None):
        # Bare `nexus` / `ncp` / `nexusos` → interactive TUI.
        args.command = "tui"
        args.fn = cmd_tui
    if args.command == "config":
        if args.action in ("get", "unset") and not args.key:
            parser.error(f"config {args.action} requires KEY")
        if args.action == "set" and (not args.key or args.value is None):
            parser.error("config set requires KEY VALUE")
    try:
        result = args.fn(args)
        return result if isinstance(result, int) else 0
    except KeyboardInterrupt:
        print()
        return 130


def entrypoint() -> int:
    return main(sys.argv[1:])


if __name__ == "__main__":
    raise SystemExit(entrypoint())
