#!/usr/bin/env python3
"""ncp - the NexusOS management CLI, one implementation for Linux and Windows.

This replaces the logic that lived in management/nexus-cli.sh. That script was
bash + pkill + fuser + /proc, so the Windows box had no `ncp` at all - the same
split that made bin/install.sh rot until it was rsyncing from a path retired
months earlier. Same fix as bin/sync.py: the portable half is Python, and the
per-platform pieces are small and explicit.

nexus-cli.sh is now a two-line wrapper, so `ncp`, launch_nexus.sh,
bin/restore-linux.sh, controlpanel.py, nexus-popup.py and nexus-app.sh all keep
calling what they always called.

psutil does the process work (cmdlines, pattern sweeps, port owners). It is
declared in requirements-base.txt AND requirements-windows.txt, so both boxes have
it - but it is imported lazily so `ncp backup`/`restore` still run before the
venv exists, exactly as they did when they shelled out to sync.py.
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

from synapse import proc_util
from synapse.nexus_config import SOURCE_CHECKOUT, settings

ROOT = settings.project_root
PID_DIR = settings.runtime_dir / "pids"
LOG_DIR = settings.runtime_dir
FRONTEND_DIR = settings.frontend_source_dir
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
OLLAMA_MODELS_DIR = settings.models_dir

WINDOWS = os.name == "nt"
_VENV_PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
PYTHON = Path(os.getenv("NEXUS_PYTHON", "")) if os.getenv("NEXUS_PYTHON") else (
    _VENV_PYTHON if _VENV_PYTHON.exists() else Path(sys.executable)
)

PID_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)

# The output carries em-dashes and check marks (matching what nexus-cli.sh
# printed). On Windows a redirected stdout defaults to cp1252, which raises
# UnicodeEncodeError on both - so pin UTF-8 rather than degrade the output.
for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(encoding="utf-8", errors="replace")
    except (AttributeError, ValueError):
        pass


# -- small helpers -------------------------------------------------------------

def http_ok(url: str, timeout: float = 1.0) -> bool:
    """True if the URL answers at all. Any HTTP status counts - a 404 still
    proves something is listening, which is all the port checks care about."""
    try:
        urllib.request.urlopen(url, timeout=timeout).read(1)
        return True
    except urllib.error.HTTPError:
        return True
    except Exception:
        return False


def ollama_bin() -> str | None:
    """The bundled binary if we have one, else whatever is on PATH.

    Only Linux ships a copy in the repo (bin/fetch-ollama.sh); on Windows the
    installer gets Ollama from winget, which puts it in %LOCALAPPDATA%\\Programs
    and on PATH. Checking only the bundled path made `ncp doctor` report a red
    "Ollama binary missing" on every Windows box, and made `ncp models install`
    refuse to run there at all. Mirrors _ollama_bin() in ollama_manager.py."""
    if OLLAMA_BIN.exists():
        return str(OLLAMA_BIN)
    return shutil.which("ollama")


def npm() -> str | None:
    """npm, resolving through nvm. The bash version sourced ~/.nvm/nvm.sh; a
    non-login shell has neither, so fall back to globbing the nvm install."""
    found = shutil.which("npm")        # resolves npm.cmd on Windows
    if found:
        return found
    versions = sorted((Path.home() / ".nvm" / "versions" / "node").glob("*/bin/npm"))
    return str(versions[-1]) if versions else None


def _psutil():
    try:
        import psutil
        return psutil
    except ImportError:
        return None


# -- services ------------------------------------------------------------------

class Service:
    def __init__(self, key, label, port, cwd, patterns, argv=None):
        self.key, self.label, self.port = key, label, port
        self.cwd, self.patterns, self._argv = cwd, patterns, argv
        self.pid_file = PID_DIR / f"{key}.pid"
        self.log_file = LOG_DIR / f"{key}.log"

    @property
    def url(self) -> str:
        return f"http://localhost:{self.port}/"

    def argv(self):
        return self._argv() if callable(self._argv) else self._argv


# Bind loopback by default: the backend/memory REST APIs are unauthenticated, so
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
BIND_HOST = settings.bind_host


def _uvicorn(app: str, port: int):
    # No --reload. It is a dev-loop flag: uvicorn's reloader runs a supervisor
    # that spawns the real server as a CHILD, so every service became two
    # processes - and on Windows that child, spawned from a parent with no
    # console, got handed a brand new console WINDOW. `ncp web` popped two black
    # terminals for what should have been a silent start. launch_nexus.sh still
    # passes --reload for the Linux dev loop, where a visible console is the
    # point; this launcher is the one users run.
    return [str(PYTHON), "-m", "uvicorn", app, "--host", BIND_HOST,
            "--port", str(port)]


SERVICES = {
    "memory": Service("memory", "NEXUS MEMORY SERVICE", settings.memory_port, settings.state_dir,
                      ["uvicorn synapse.memory"],
                      lambda: _uvicorn("synapse.memory.service:app", settings.memory_port)),
    "backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
                       ["uvicorn synapse.main"],
                       lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
    "frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
                        ["vite --host", "npm run dev"],
                        lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
}


def read_pid(svc: Service):
    try:
        return int(svc.pid_file.read_text().strip())
    except (OSError, ValueError):
        return None


def alive(pid) -> bool:
    ps = _psutil()
    if pid is None:
        return False
    if ps is not None:
        return ps.pid_exists(pid)
    # Not os.kill(pid, 0): that reports False for a live process owned by
    # another user, and Windows has no signals to fall back on.
    return proc_util.pid_alive(pid)


def pid_is_ours(pid, patterns) -> bool:
    """True only if the live PID's command line matches one of the service
    patterns. PID files outlive reboots and the OS recycles the number onto an
    unrelated process - often a desktop-session one - so a bare liveness check
    is not enough. TERMing a recycled PID can log the user out."""
    ps = _psutil()
    try:
        if ps is not None:
            cmd = " ".join(ps.Process(pid).cmdline())
        else:
            cmd = proc_util.pid_cmdline(pid)
    except Exception:
        return False
    return any(p in cmd for p in patterns)


def launch(svc: Service) -> None:
    pid = read_pid(svc)
    if alive(pid) and pid_is_ours(pid, svc.patterns):
        return
    argv = svc.argv()
    if argv[0] is None:
        print(f"  {svc.label}: npm not found - install Node, or run ./install.sh")
        return
    svc.log_file.write_text("")
    with open(svc.log_file, "ab") as log:
        # Detach so the service outlives this process, on both platforms.
        # CREATE_NO_WINDOW, not DETACHED_PROCESS: detached means the process has
        # NO console, and Windows then gives a console window to any console
        # program it starts in turn. CREATE_NO_WINDOW gives it a console that is
        # never shown, which its children inherit - so nothing flashes up.
        kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
                   | getattr(subprocess, "CREATE_NO_WINDOW", 0)}
                  if WINDOWS else {"start_new_session": True})
        proc = subprocess.Popen(argv, cwd=str(svc.cwd), stdout=log,
                                stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
                                **kwargs)
    svc.pid_file.write_text(str(proc.pid))


def check(svc: Service) -> bool:
    pid = read_pid(svc)
    if alive(pid):
        print(f"{svc.label} STARTED")
        return True
    print(f"{svc.label} FAILED TO START")
    try:
        tail = svc.log_file.read_text(errors="replace").splitlines()[-8:]
        for line in tail:
            print(f"  {line}")
    except OSError:
        pass
    svc.pid_file.unlink(missing_ok=True)
    return False


# Poll granularity for both waiters below. 1s steps used to mean every
# start/stop paid up to a full second of dead latency per service on top of
# however long the process actually took - three services in sequence could
# lose several seconds to nothing but sleep(). 0.25s still amounts to one
# cheap local HTTP HEAD every quarter second, not a busy-loop.
_POLL_STEP = 0.25


def wait_for_port(svc: Service, timeout: int = 30) -> bool:
    if http_ok(svc.url):
        # "READY", not "already running": `ncp start` launches memory and backend
        # together and only then waits on each, so by the time the backend's turn
        # comes it is normally up - and reporting "already running" for a service
        # this same command started two seconds ago reads like a stale process.
        print(f"  {svc.label} READY (:{svc.port})")
        return True
    for _ in range(int(timeout / _POLL_STEP)):
        time.sleep(_POLL_STEP)
        if http_ok(svc.url):
            print(f"{svc.label} STARTED")
            return True
    print(f"  {svc.label} timed out after {timeout}s")
    return check(svc)


def wait_for_port_close(port: int, timeout: int = 15) -> bool:
    for _ in range(int(timeout / _POLL_STEP)):
        if not http_ok(f"http://localhost:{port}/"):
            return True
        time.sleep(_POLL_STEP)
    return False


def kill_matching(patterns, force=False) -> int:
    """The pkill -f sweep: catches reparented grandchildren (npm -> sh -> node
    vite), uvicorn --reload workers, and instances started outside this CLI.
    Returns how many processes it signalled, so callers can stay quiet when
    there was nothing to kill."""
    ps = _psutil()
    me = os.getpid()
    hit = 0
    if ps is None:
        for pid, cmd in proc_util.iter_processes():
            if pid == me or not any(pattern in cmd for pattern in patterns):
                continue
            if proc_util.terminate_pid(pid, force=force):
                hit += 1
        return hit
    for proc in ps.process_iter(["pid", "cmdline"]):
        if proc.info["pid"] == me:
            continue
        cmd = " ".join(proc.info["cmdline"] or [])
        if any(p in cmd for p in patterns):
            try:
                proc.kill() if force else proc.terminate()
                hit += 1
            except Exception:
                pass
    return hit


def kill_port(port: int) -> bool:
    """Whatever holds the port IS the service - this is the backstop that makes
    stop reliable regardless of process-tree shape or PID-file accuracy."""
    ps = _psutil()
    if ps is None:
        killed = False
        for pid in proc_util.pids_listening_on(port):
            if pid != os.getpid() and proc_util.terminate_pid(pid, force=True):
                killed = True
        return killed
    killed = False
    try:
        conns = ps.net_connections(kind="inet")
    except Exception:
        return False
    for conn in conns:
        if conn.laddr and conn.laddr.port == port and conn.status == "LISTEN" and conn.pid:
            try:
                ps.Process(conn.pid).kill()
                killed = True
            except Exception:
                pass
    return killed


def stop_service(svc: Service) -> bool:
    """Three escalating passes, with the PORT as the source of truth for
    whether the service is actually down."""
    pid = read_pid(svc)
    if pid is not None:
        if alive(pid) and pid_is_ours(pid, svc.patterns):
            ps = _psutil()
            try:
                if ps is not None:
                    proc = ps.Process(pid)
                    for child in proc.children(recursive=True):
                        try:
                            child.terminate()
                        except Exception:
                            pass
                    proc.terminate()
                else:
                    # No psutil means no process tree; the kill_matching sweep
                    # below is what catches reparented children here.
                    proc_util.terminate_pid(pid)
            except Exception:
                pass
        svc.pid_file.unlink(missing_ok=True)

    kill_matching(svc.patterns)

    if not wait_for_port_close(svc.port):
        kill_port(svc.port)
        kill_matching(svc.patterns, force=True)
        wait_for_port_close(svc.port)
    if http_ok(svc.url):
        print(f"{svc.label} STILL RUNNING (:{svc.port}) — try 'ncp kill'")
        return False
    print(f"{svc.label} STOPPED")
    return True


# -- ollama --------------------------------------------------------------------

def start_ollama(background: bool = False) -> None:
    """Driven through the backend endpoint (the path the control panel uses)
    rather than launching the binary, because OllamaManager owns model and GPU
    selection. Requires the backend to be up.

    background=True (`ncp start`): the endpoint returns as soon as `ollama serve`
    is up and warms the model in a background task, so boot finishes in seconds
    and the model loads concurrently into the first chat.
    background=False (`ncp start --ai`): blocks until the model is warmed - weights
    read off disk into RAM/VRAM, routinely about a minute - so "started" means the
    AI can actually answer."""
    if not settings.manage_ollama:
        running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags", timeout=3)
        print(
            f"REMOTE OLLAMA {'REACHABLE' if running else 'UNREACHABLE'} "
            f"({settings.ollama_host})"
        )
        return
    print("Starting OLLAMA...")
    url = settings.api_url + "/ollama/start" + ("?background=true" if background else "")
    req = urllib.request.Request(url, method="POST")
    try:
        urllib.request.urlopen(req, timeout=180).read(1)
        print("NEXUS OLLAMA WARMING (background)" if background else "NEXUS OLLAMA STARTED")
    except Exception as e:
        print(f"  OLLAMA start failed: {e}")


def stop_ollama() -> None:
    """Prefer the backend endpoint for a clean OllamaManager shutdown; if the
    backend is already down, kill `ollama serve` directly so it never lingers
    holding VRAM/RAM. Must run BEFORE the backend is torn down."""
    if not settings.manage_ollama:
        print(f"REMOTE OLLAMA IS EXTERNALLY MANAGED ({settings.ollama_host})")
        return
    req = urllib.request.Request(settings.api_url + "/ollama/stop", method="POST")
    try:
        urllib.request.urlopen(req, timeout=5).read(1)
        print("NEXUS OLLAMA STOPPED")
        return
    except Exception:
        pass
    # Silent when there was nothing to stop - the bash version only announced a
    # direct kill if `pgrep ollama serve` actually matched, and claiming to have
    # stopped a service that was never running is worse than saying nothing.
    if kill_matching(["ollama serve"]):
        print("NEXUS OLLAMA STOPPED (direct)")


# -- commands ------------------------------------------------------------------

def cmd_start(target) -> None:
    if target in ("--memory", "-m"):
        launch(SERVICES["memory"]); wait_for_port(SERVICES["memory"])
    elif target in ("--backend", "-b"):
        launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
        start_ollama()
    elif target in ("--frontend", "-f"):
        if not SOURCE_CHECKOUT:
            print("Vite source is unavailable in wheel installs; the backend serves the bundled UI.")
            return
        launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
    elif target in ("--ai", "-a"):
        start_ollama()
    elif target in (None, "", "all"):
        # Bring the UI up first, then kick off Ollama and (on Linux) Vite in the
        # background without waiting on either — neither gates the app being
        # usable. The backend already serves the built interface/web/dist at
        # :8000 on both platforms (single-process design), and that's the URL
        # nexus-app.sh/ncp web actually opens; nothing points a user at :5173.
        # Vite only exists for whoever is hot-reload-editing the frontend, and
        # they'll open :5173 themselves once it's ready - polling for it here
        # just delayed "boot done" for a benefit nobody in the critical path
        # gets. Skipped outright on Windows, where it's not part of the normal
        # workflow at all and is the slow part of `ncp stop` to boot (npm's
        # cmd.exe -> node -> esbuild tree doesn't die from a plain terminate()
        # and falls through to the ~30s force-kill path). Still available on
        # demand via `ncp start --frontend`.
        t0 = time.perf_counter()
        launch(SERVICES["memory"])
        launch(SERVICES["backend"])
        wait_for_port(SERVICES["memory"])
        wait_for_port(SERVICES["backend"])
        t_services = time.perf_counter()
        start_ollama(background=True)
        if SOURCE_CHECKOUT and not WINDOWS:
            launch(SERVICES["frontend"])
        t_bg = time.perf_counter()
        print("\nBoot timing:")
        print(f"  services (memory+backend)      : {t_services - t0:5.1f}s")
        print(f"  ollama + frontend (bg kickoff) : {t_bg - t_services:5.1f}s")
        print(f"  total to interactive           : {t_bg - t0:5.1f}s")
    else:
        show_help()


def cmd_stop(target) -> None:
    if target in ("--memory", "-m"):
        stop_service(SERVICES["memory"])
    elif target in ("--backend", "-b"):
        stop_ollama(); stop_service(SERVICES["backend"])
    elif target in ("--frontend", "-f"):
        stop_service(SERVICES["frontend"])
    elif target in ("--ai", "-a"):
        stop_ollama()
    elif target in (None, "", "all"):
        stop_service(SERVICES["memory"])
        stop_ollama()
        stop_service(SERVICES["backend"])
        stop_service(SERVICES["frontend"])
    else:
        show_help()


def cmd_kill() -> None:
    print("Force-killing all Nexus processes...")
    targets = [
        (settings.backend_port, "SYNAPSE"),
        (settings.memory_port, "MEMORY"),
        (5173, "INTERFACE"),
    ]
    patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
    if settings.manage_ollama:
        targets.append((11434, "OLLAMA"))
        patterns.append("ollama serve")
    for port, name in targets:
        if kill_port(port):
            print(f"  KILLED:      {name} (:{port})")
        else:
            print(f"  NOT RUNNING: {name} (:{port})")
    kill_matching(patterns, force=True)
    for pid_file in PID_DIR.glob("*.pid"):
        pid_file.unlink(missing_ok=True)
    print("Done.")


def cmd_status() -> None:
    print("Nexus Service Status:\n")

    def one(name, svc):
        pid = read_pid(svc)
        if alive(pid):
            print(f"  {name}: RUNNING (PID {pid})")
        elif http_ok(svc.url):
            print(f"  {name}: RUNNING (port :{svc.port}, PID stale — consider ncp kill)")
        else:
            print(f"  {name}: STOPPED")

    print("Backend:")
    one("Synapse       ", SERVICES["backend"])
    one("Memory service", SERVICES["memory"])
    print("\nFrontend:")
    one("Vite          ", SERVICES["frontend"])
    print("\nModel server:")
    running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags")
    mode = "remote" if not settings.manage_ollama else "local"
    print(f"  Ollama ({mode}) : {'RUNNING' if running else 'STOPPED'} ({settings.ollama_host})")


def _tail(path: Path, n: int) -> None:
    try:
        for line in path.read_text(errors="replace").splitlines()[-n:]:
            print(line)
    except OSError:
        print(f"  (no log at {path})")


LOG_HEADINGS = {"memory": "MEMORY SERVICE", "backend": "BACKEND", "frontend": "FRONTEND"}


def cmd_logs(target) -> None:
    named = {"--frontend": "frontend", "-f": "frontend",
             "--backend": "backend", "-b": "backend",
             "--memory": "memory", "-m": "memory"}
    if target in named:
        key = named[target]
        print(f"=== {LOG_HEADINGS[key]} LOGS ===")
        _tail(SERVICES[key].log_file, 50)
    elif target in (None, "", "all"):
        for i, key in enumerate(("memory", "backend", "frontend")):
            if i:
                print()
            print(f"=== {LOG_HEADINGS[key]} LOGS ===")
            _tail(SERVICES[key].log_file, 30)
    else:
        print(f"Unknown logs target: '{target}'")
        print("Usage: ncp logs [frontend|backend|memory|all]")


def _apply_fixes() -> None:
    """Opt-in safe repairs (ncp doctor --fix). Only idempotent, non-destructive
    actions: build the UI, install deps, fetch the Ollama binary. Anything that
    could lose data or needs a decision is left to the user with a hint."""
    print("\nApplying safe fixes...\n")
    did = False
    web, npm_path = FRONTEND_DIR, npm()

    if not PYTHON.exists():
        print("  ✘ Promethean venv missing — run ./install.sh to build it (skipped: heavy).")
    else:
        importable = subprocess.run(
            [str(PYTHON), "-c", "from synapse.main import sio_app"],
            cwd=str(ROOT), capture_output=True).returncode == 0
        if not importable:
            print("  Backend import failed — reinstalling Python dependencies...")
            sys.path.insert(0, str(ROOT / "bin"))
            import importlib.util
            spec = importlib.util.spec_from_file_location("sync", ROOT / "bin" / "sync.py")
            sync = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(sync)
            subprocess.run([str(PYTHON), "-m", "pip", "install", "-r", sync.requirements()], cwd=str(ROOT))
            did = True

    if npm_path and web.is_dir() and not (web / "node_modules").is_dir():
        print("  node_modules missing — running npm install...")
        subprocess.run([npm_path, "install"], cwd=str(web))
        did = True

    if npm_path and web.is_dir() and not (web / "dist" / "index.html").exists():
        print("  Built UI missing — running npm run build...")
        subprocess.run([npm_path, "run", "build"], cwd=str(web))
        did = True

    if not ollama_bin() and os.name != "nt" and (ROOT / "bin" / "fetch-ollama.sh").exists():
        print("  Ollama binary missing — fetching...")
        subprocess.run(["bash", str(ROOT / "bin" / "fetch-ollama.sh")])
        did = True

    print("\n" + ("Fixes applied — re-run 'ncp doctor' to confirm." if did
                  else "Nothing to fix (all safe-repairable checks already pass)."))


def cmd_doctor(fix: bool = False) -> None:
    def mark(ok, good, bad):
        print(f"  {'✔' if ok else '✘'} {good if ok else bad}")

    print("Running Nexus Diagnostics...\n")
    print("Checking directories...")
    mark(ROOT.is_dir(), "Nexus root found", "Missing Nexus root")
    mark(FRONTEND_DIR.is_dir(), "Frontend directory found", "Missing frontend directory")

    print("\nChecking Python venv...")
    if PYTHON.exists():
        ver = subprocess.run([str(PYTHON), "--version"], capture_output=True,
                             text=True).stdout.strip()
        mark(True, f"Promethean venv found ({ver})", "")
    else:
        mark(False, "", f"Promethean venv missing at {PYTHON}")

    print("\nChecking Node & npm...")

    def version(exe):
        if not exe:
            return ""
        try:
            return subprocess.run([exe, "-v"], capture_output=True, text=True).stdout.strip()
        except OSError:
            return ""

    def node_ok(ver: str) -> bool:
        # Vite needs Node 20.19+ (or 22.12+); Debian's apt 'nodejs' is EOL 18.
        try:
            parts = ver.lstrip("v").split(".")
            major, minor = int(parts[0]), int(parts[1])
        except (ValueError, IndexError):
            return False
        return major >= 21 or (major == 20 and minor >= 19)

    node, npm_path = shutil.which("node"), npm()
    node_ver = version(node)
    mark(bool(node), f"Node installed ({node_ver})", "Node missing")
    if node:
        mark(node_ok(node_ver),
             f"Node version OK ({node_ver} >= 20.19, Vite requirement)",
             f"Node {node_ver} too old for Vite (needs 20.19+). Install Node 20 "
             f"(re-run ./install.sh, or NodeSource), then rebuild with: ncp update")
    mark(bool(npm_path), f"npm installed ({version(npm_path)})", "npm missing")
    mark((FRONTEND_DIR / "node_modules").is_dir(),
         "Frontend node_modules installed",
         "Frontend node_modules missing (run: ncp update)")

    print("\nChecking Uvicorn...")
    uvicorn_ok = subprocess.run([str(PYTHON), "-m", "uvicorn", "--version"],
                                capture_output=True).returncode == 0
    mark(uvicorn_ok, "Uvicorn installed", "Uvicorn missing")

    def importable(stmt):
        return subprocess.run([str(PYTHON), "-c", stmt], cwd=str(ROOT),
                              capture_output=True).returncode == 0

    print("\nChecking backend service...")
    mark(importable("from synapse.main import sio_app"),
         "Backend module importable", "Backend module failed to import")

    print("\nChecking memory service...")
    mark((ROOT / "synapse" / "memory").is_dir(),
         "Memory module directory found", "Missing memory module directory")
    mark(importable("from synapse.memory.service import app"),
         "Memory service module importable", "Memory service module failed to import")
    mark(os.access(ROOT / "synapse" / "memory", os.W_OK),
         "Memory database directory writable", "Memory database directory not writable")

    print("\nChecking Ollama...")
    _obin = ollama_bin()
    mark(bool(_obin), f"Ollama binary found ({_obin})",
         f"Ollama binary missing (not at {OLLAMA_BIN}, not on PATH)")
    mark(OLLAMA_MODELS_DIR.is_dir(), f"Ollama models directory found ({OLLAMA_MODELS_DIR})",
         f"Ollama models directory missing at {OLLAMA_MODELS_DIR}")
    print()
    cmd_status()
    if fix:
        _apply_fixes()


def cmd_update() -> None:
    print("Updating Project Nexus...\n")
    print("Skipping git pull — update only refreshes dependencies.\n")
    # Same overlay choice sync.py makes, so update and restore cannot install
    # different PyTorch builds on the same host.
    sys.path.insert(0, str(ROOT / "bin"))
    import importlib.util
    spec = importlib.util.spec_from_file_location("sync", ROOT / "bin" / "sync.py")
    sync = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(sync)
    req = sync.requirements()
    print(f"Updating backend Python dependencies ({req})...")
    subprocess.run([str(PYTHON), "-m", "pip", "install", "-r", req], cwd=str(ROOT))

    print("\nUpdating frontend dependencies...")
    npm_path = npm()
    if npm_path and FRONTEND_DIR.is_dir():
        subprocess.run([npm_path, "install"], cwd=str(FRONTEND_DIR))
    else:
        print("npm or frontend directory missing — skipping npm install.")

    print("\nRunning post-update diagnostics...")
    cmd_doctor()


def cmd_clean() -> None:
    print("Cleaning Nexus runtime files...\n")
    print("Removing PID files...")
    for f in PID_DIR.glob("*.pid"):
        f.unlink(missing_ok=True)
    print("Removing logs...")
    for f in LOG_DIR.glob("*.log"):
        f.unlink(missing_ok=True)
    print("Removing Python cache...")
    for d in ROOT.rglob("__pycache__"):
        shutil.rmtree(d, ignore_errors=True)
    print("Removing Node/Vite cache...")
    for d in FRONTEND_DIR.rglob(".vite"):
        shutil.rmtree(d, ignore_errors=True)
    print("\nCleanup complete.")


MODEL_CATALOG = [
    ("gemma3:1b", "~815 MB", "Google Gemma 3 — fast, lightweight"),
    ("gemma3:4b", "~3.3 GB", "Google Gemma 3 — balanced"),
    ("gemma3:12b", "~8.1 GB", "Google Gemma 3 — capable"),
    ("llama3.2:1b", "~1.3 GB", "Meta Llama 3.2 — fast, lightweight"),
    ("llama3.2:3b", "~2.0 GB", "Meta Llama 3.2 — compact, capable"),
    ("llama3.1:8b", "~4.7 GB", "Meta Llama 3.1 — strong general use"),
    ("mistral:latest", "~4.1 GB", "Mistral 7B — solid all-rounder"),
    ("mistral-nemo", "~7.1 GB", "Mistral Nemo 12B — strong reasoning"),
    ("qwen2.5:3b", "~2.0 GB", "Alibaba Qwen 2.5 — great at code"),
    ("qwen2.5:7b", "~4.7 GB", "Alibaba Qwen 2.5 — strong coder"),
    ("phi4-mini", "~2.5 GB", "Microsoft Phi-4 Mini — efficient"),
    ("phi4:14b", "~8.9 GB", "Microsoft Phi-4 — strong reasoning"),
    ("deepseek-r1:7b", "~4.7 GB", "DeepSeek R1 — reasoning model"),
    ("deepseek-r1:14b", "~9.0 GB", "DeepSeek R1 — strong reasoning"),
    ("codellama:7b", "~3.8 GB", "Meta Code Llama — code focused"),
    ("nomic-embed-text", "~274 MB", "Text embeddings model"),
]


def cmd_models(action, name) -> None:
    if action == "list":
        if not http_ok(settings.ollama_host.rstrip("/") + "/api/tags"):
            print("Ollama is not running. Start the backend first with: ncp start -b")
            return
        raw = urllib.request.urlopen(settings.ollama_host.rstrip("/") + "/api/tags", timeout=5).read()
        models = json.loads(raw).get("models", [])
        print("Installed models:\n")
        if not models:
            print("  No models installed.")
            return
        for m in models:
            mb = m["size"] // 1024 // 1024
            size = f"{mb / 1024:.1f} GB" if mb >= 1024 else f"{mb} MB"
            print(f"  {m['name']:<35} {size}")
    elif action in ("available", "search"):
        print("Available models (via Ollama library):\n")
        print(f"  {'MODEL':<30} {'SIZE':<10} DESCRIPTION")
        print(f"  {'-----':<30} {'----':<10} -----------")
        for model, size, desc in MODEL_CATALOG:
            print(f"  {model:<30} {size:<10} {desc}")
        print("\nInstall any model with: ncp models install <model>")
        print("Browse more at: https://ollama.com/library")
    elif action == "install":
        if not name:
            print("Usage: ncp models install <model>")
            print("Run 'ncp models available' to see options.")
            return
        obin = ollama_bin()
        if not obin:
            print(f"Ollama binary not found at {OLLAMA_BIN}, and no 'ollama' on PATH")
            return
        print(f"Pulling '{name}' into {OLLAMA_MODELS_DIR} ...\n")
        subprocess.run([obin, "pull", name],
                       env={**os.environ, "OLLAMA_MODELS": str(OLLAMA_MODELS_DIR)})
        print("\nDone. Run 'ncp models list' to verify.")
    else:
        print("Usage: ncp models <list|available|install <model>>")


def sync_py(*args) -> int:
    """sync.py is stdlib-only, so system python works before the venv exists."""
    py = str(PYTHON) if PYTHON.exists() else sys.executable
    return subprocess.run([py, str(ROOT / "bin" / "sync.py"), *args]).returncode


def cmd_restore(flag) -> int:
    if flag in ("-c", "--claude", "check"):
        return sync_py("restore", "--check")     # dry run: no prompt, changes nothing
    print("This pulls the latest backup from Gitea and rebuilds the environment")
    print("(venv, npm, theme, panel). Local commits must be pushed or stashed first.")
    if input("Continue? [y/N] ").strip().lower() != "y":
        print("Restore cancelled.")
        return 0
    return sync_py("restore")


def cmd_web() -> int:
    """Per-platform UI launcher. On Linux nexus-app.sh already raises an existing
    window, acts as a viewer when the stack is up, and only stops services it
    started. Windows uses the pywebview window launch_nexus.ps1 opens."""
    if WINDOWS:
        if not http_ok(SERVICES["backend"].url):
            launch(SERVICES["memory"])
            launch(SERVICES["backend"])
            wait_for_port(SERVICES["backend"])
        return subprocess.run([str(PYTHON), str(ROOT / "bin" / "nexus_window.py")]).returncode
    return subprocess.run([str(ROOT / "management" / "nexus-app.sh")]).returncode


def show_help() -> None:
    print("""Nexus Command Tree

Usage: ncp <command> [options]

Commands:
  panel                 Launch the Nexus Control Panel (tkinter)
  web                   Open the web interface (starts the stack if needed)

  chat <message>        Send a message, stream the reply
  memory list           List memory facts
    add <text>          Add a fact (--section <name>)
    rm <id>             Delete a fact by id
  playbook list         List playbooks (* = active)
    show <id>           Print a playbook's goal + instructions
  history [query]       Recent conversations (optional keyword)

  start                 Start ALL Nexus services (memory + backend + frontend)
   --memory,  -m        Start only the memory service
   --frontend,-f        Start only the frontend
   --backend, -b        Start only the backend
   --ai,      -a        Start only the AI (Ollama); `start`/`start -b` already include it

  stop                  Stop ALL Nexus services
   --memory,  -m        Stop only the memory service
   --frontend,-f        Stop only the frontend
   --backend, -b        Stop only the backend

  kill                  Force-kill all Nexus processes by port (nuclear option)
  refresh               Restart all services

  status                Show service status
  logs                  Show logs for all services
   --memory,  -m        Memory service logs
   --frontend,-f        Frontend logs
   --backend, -b        Backend logs

  doctor [--fix]        Run Nexus diagnostics (--fix applies safe repairs)
  update                Update Nexus dependencies
  clean                 Remove runtime files and caches

  models
    list                List installed models
    available           Show models available to install
    install <name>      Pull a model into Nexus

  backup                Backup Nexus to Gitea (git commit + push)
  backup -f, full       Backup + snapshot live desktop wiring/notes
  backup -c             Dry run: what a backup would commit/push
  restore               Restore Nexus from Gitea (git pull + rebuild)
  restore -c            Dry run: what a restore would apply

  help, -h              Show this help message""")


def main(argv) -> int:
    cmd = argv[0] if argv else ""
    rest = argv[1:]
    arg = rest[0] if rest else None

    if cmd in ("help", "-h", ""):
        show_help()
    elif cmd == "panel":
        return subprocess.run([str(PYTHON), str(ROOT / "management" / "controlpanel.py")]).returncode
    elif cmd == "web":
        return cmd_web()
    elif cmd in ("chat", "memory", "playbook", "history"):
        return subprocess.run([str(PYTHON), str(ROOT / "management" / "nexus_api.py"),
                               cmd, *rest]).returncode
    elif cmd == "start":
        cmd_start(arg)
    elif cmd == "stop":
        cmd_stop(arg)
    elif cmd == "refresh":
        cmd_stop(None)
        cmd_start(None)
    elif cmd == "kill":
        cmd_kill()
    elif cmd == "status":
        cmd_status()
    elif cmd == "logs":
        cmd_logs(arg)
    elif cmd == "doctor":
        cmd_doctor(fix="--fix" in rest)
    elif cmd == "update":
        cmd_update()
    elif cmd == "clean":
        cmd_clean()
    elif cmd == "nvidia-reqs":
        return subprocess.run([str(PYTHON), str(ROOT / "bin" / "gen-nvidia-reqs.py")]).returncode
    elif cmd == "backup":
        if arg in ("-f", "full"):
            return sync_py("backup", "--full")
        if arg in ("-c", "--claude", "check"):
            return sync_py("backup", "--check")
        if arg in (None, ""):
            return sync_py("backup")
        print("Usage: ncp backup [-f|full|-c]")
    elif cmd == "restore":
        if arg in (None, "", "-f", "full", "-c", "--claude", "check"):
            return cmd_restore(arg)
        print("Usage: ncp restore [-c]")
    elif cmd == "models":
        cmd_models(arg, rest[1] if len(rest) > 1 else None)
    else:
        print(f"Unknown Nexus command: '{cmd}'")
        print("Use 'ncp help' for available commands.")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv[1:]))
    except KeyboardInterrupt:
        # Ctrl+C is how you quit `ncp web` - it waits on the UI window. Without
        # this it printed a six-frame traceback ending in WaitForSingleObject,
        # which reads as a crash rather than "you pressed Ctrl+C". 130 is the
        # conventional exit status for SIGINT.
        print()
        sys.exit(130)
