#!/usr/bin/env python3
"""
bgo - A lightweight background process manager
Like pm2, but simpler, lighter, and without the forking headaches.
"""

import argparse
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import termios
import time
import tty
from datetime import datetime, timezone
from pathlib import Path

# Config
BGO_DIR = Path.home() / ".bgo"
PROCS_DIR = BGO_DIR / "procs"
LOGS_DIR = BGO_DIR / "logs"

# ANSI colors
COLORS = {
    "green": "\033[32m",
    "red": "\033[31m",
    "yellow": "\033[33m",
    "blue": "\033[34m",
    "gray": "\033[90m",
    "reset": "\033[0m",
    "bold": "\033[1m",
}

ANSI_RE = re.compile(r"\033\[[0-9;]*m")


def color(name: str, text: str) -> str:
    """Wrap text in color codes, only if stdout is a TTY."""
    if not sys.stdout.isatty():
        return str(text)
    return f"{COLORS.get(name, '')}{text}{COLORS['reset']}"


def strip_ansi(s: str) -> str:
    """Remove ANSI escape codes from a string."""
    return ANSI_RE.sub("", s)


def truncate(s: str, width: int) -> str:
    """Truncate string to fit in width, accounting for ANSI codes."""
    plain = strip_ansi(s)
    if len(plain) > width:
        return s[: width - 3] + "..."
    return s


# --- Terminal capability detection ---

# Three levels:
#   plain  — no color, ASCII-only dashes, no glyphs (CI logs, dumb terms,
#            non-TTY pipes)
#   normal — ANSI color + ASCII dashes (default for color-capable TTYs)
#   fancy  — ANSI color + Unicode box-drawing + heavier visual structure
#            (UTF-8-capable TTYs)
LEVEL_PLAIN = "plain"
LEVEL_NORMAL = "normal"
LEVEL_FANCY = "fancy"


def _detect_table_level(force: str | None = None) -> str:
    """Decide which rendering level to use.

    Resolution order:
      1. explicit `force` (from --plain/--fancy or BGO_TABLE env)
      2. non-TTY stdout         -> plain
      3. TERM=dumb              -> plain
      4. LANG/LC_* lacks UTF-8  -> normal
      5. otherwise              -> fancy
    """
    if force in (LEVEL_PLAIN, LEVEL_NORMAL, LEVEL_FANCY):
        return force
    env_force = os.environ.get("BGO_TABLE", "").strip().lower()
    if env_force in (LEVEL_PLAIN, LEVEL_NORMAL, LEVEL_FANCY):
        return env_force
    if not sys.stdout.isatty():
        return LEVEL_PLAIN
    if os.environ.get("TERM", "") == "dumb":
        return LEVEL_PLAIN
    lc = (
        os.environ.get("LC_ALL")
        or os.environ.get("LC_CTYPE")
        or os.environ.get("LANG")
        or ""
    ).upper()
    if "UTF-8" not in lc and "UTF8" not in lc:
        return LEVEL_NORMAL
    return LEVEL_FANCY


# Glyph set per level. Keys must be identical across levels so callers
# can index without branching.
GLYPHS = {
    LEVEL_PLAIN: {
        "hline": "-", "vline": "|", "cross": "+",
        "tl": "+", "tr": "+", "bl": "+", "br": "+",
        "tdown": "+", "tup": "+", "tleft": "+", "tright": "+",
        "online": "ON", "stopped": "OFF",
        "watching": "[W]", "errored": "[!]", "watcher_dead": "[?]",
        "watch_none": "-",
        "ok": "OK", "fail": "FAIL", "warn": "WARN",
        "tombstone": "[X]", "rocket": "[+]", "eye": "[W]",
    },
    LEVEL_NORMAL: {
        "hline": "─", "vline": "│", "cross": "┼",
        "tl": "┌", "tr": "┐", "bl": "└", "br": "┘",
        "tdown": "┬", "tup": "┴", "tleft": "┤", "tright": "├",
        "online": "online", "stopped": "stopped",
        "watching": "✓", "errored": "⚠", "watcher_dead": "!",
        "watch_none": "-",
        "ok": "✅", "fail": "❌", "warn": "⚠️",
        "tombstone": "🗑️", "rocket": "🚀", "eye": "👁",
    },
    LEVEL_FANCY: {
        "hline": "━", "vline": "┃", "cross": "╋",
        "tl": "┏", "tr": "┓", "bl": "┗", "br": "┛",
        "tdown": "┳", "tup": "┻", "tleft": "┫", "tright": "┣",
        "online": "● online", "stopped": "○ stopped",
        "watching": "✓", "errored": "⚠", "watcher_dead": "!",
        "watch_none": "·",
        "ok": "✅", "fail": "❌", "warn": "⚠️",
        "tombstone": "🗑️", "rocket": "🚀", "eye": "👁",
    },
}


def glyphs(level: str | None = None) -> dict:
    """Return the glyph set for a level (default: auto-detect)."""
    return GLYPHS[level or _detect_table_level()]


def init_dirs():
    """Initialize required directories."""
    PROCS_DIR.mkdir(parents=True, exist_ok=True)
    LOGS_DIR.mkdir(parents=True, exist_ok=True)


# --- State management (one file per process) ---


def proc_file(name: str) -> Path:
    return PROCS_DIR / f"{name}.json"


def log_path(name: str, stream: str = "out") -> Path:
    return LOGS_DIR / f"{name}.{stream}.log"


def watcher_log_path(name: str) -> Path:
    return LOGS_DIR / f"{name}.watcher.log"


def watcher_log(name: str, msg: str):
    """Append timestamped line to watcher log."""
    try:
        ts = datetime.now().isoformat(timespec="seconds")
        with open(watcher_log_path(name), "a") as f:
            f.write(f"[{ts}] {msg}\n")
    except OSError:
        pass


def load_proc(name: str) -> dict | None:
    pf = proc_file(name)
    if not pf.exists():
        return None
    try:
        return json.loads(pf.read_text())
    except (json.JSONDecodeError, OSError):
        return None


def save_proc(name: str, info: dict):
    """Atomic write: tmp file + os.replace.

    Prevents torn JSON if the process is killed mid-write or if the
    watcher and the foreground CLI both save concurrently. os.replace
    is atomic on POSIX when src and dst are on the same filesystem,
    which is guaranteed here (both under ~/.bgo/procs/).
    """
    pf = proc_file(name)
    tmp = pf.with_suffix(pf.suffix + ".tmp")
    tmp.write_text(json.dumps(info, indent=2))
    os.replace(tmp, pf)


def delete_proc(name: str, keep_logs: bool = False):
    """Remove proc state file. Logs removed unless keep_logs=True."""
    proc_file(name).unlink(missing_ok=True)
    if keep_logs:
        return
    for stream in ("out", "err"):
        log_path(name, stream).unlink(missing_ok=True)
    watcher_log_path(name).unlink(missing_ok=True)


def load_all_procs() -> dict[str, dict]:
    """Load all process states."""
    procs = {}
    for pf in sorted(PROCS_DIR.glob("*.json")):
        try:
            info = json.loads(pf.read_text())
            procs[info.get("name", pf.stem)] = info
        except (json.JSONDecodeError, OSError):
            continue
    return procs


# --- Utilities ---


def _is_zombie(pid: int) -> bool:
    """Return True if pid is a zombie/defunct process. Platform-aware."""
    if sys.platform.startswith("linux"):
        try:
            with open(f"/proc/{pid}/stat", "r") as f:
                stat = f.read()
            # stat format: pid (comm) state ...  comm may contain spaces/parens.
            rparen = stat.rfind(")")
            if rparen != -1:
                state = stat[rparen + 2 : rparen + 3]
                return state == "Z"
        except (OSError, IndexError):
            return False
        return False
    if sys.platform == "darwin":
        try:
            result = subprocess.run(
                ["ps", "-p", str(pid), "-o", "stat=", ],
                capture_output=True, text=True, timeout=2,
            )
            if result.returncode == 0:
                state = result.stdout.strip()
                # macOS ps stat: 'Z' is zombie; may be prefixed with flags
                return state.startswith("Z")
        except (subprocess.SubprocessError, OSError):
            return False
    return False


def is_running(pid: int | None) -> bool:
    """Return True if pid is alive AND not a zombie/defunct process."""
    if pid is None:
        return False
    try:
        os.kill(pid, 0)
    except (ProcessLookupError, OSError):
        return False
    return not _is_zombie(pid)


_BLANK_PINFO = {"cpu": "-", "mem": "-", "time": "-"}


def get_process_info(pid: int) -> dict:
    """Get CPU/MEM/uptime for a single pid via ps. Prefer batch lookup."""
    return get_process_info_batch([pid]).get(pid, dict(_BLANK_PINFO))


def get_process_info_batch(pids: list[int]) -> dict[int, dict]:
    """Single ps call for many pids. Returns {pid: {cpu, mem, time}}."""
    result_map: dict[int, dict] = {}
    if not pids:
        return result_map
    # ps -p accepts comma-separated pids on both Linux and macOS.
    pid_arg = ",".join(str(p) for p in pids)
    try:
        result = subprocess.run(
            ["ps", "-p", pid_arg, "-o", "pid,%cpu,%mem,etime", "--no-headers"],
            capture_output=True,
            text=True,
            timeout=4,
        )
        if result.returncode == 0:
            for line in result.stdout.splitlines():
                parts = line.split(None, 3)
                if len(parts) >= 4:
                    try:
                        result_map[int(parts[0])] = {
                            "cpu": parts[1], "mem": parts[2], "time": parts[3],
                        }
                    except ValueError:
                        continue
    except (subprocess.SubprocessError, OSError):
        pass
    # Fill misses with blanks
    for p in pids:
        result_map.setdefault(p, dict(_BLANK_PINFO))
    return result_map


def _looks_like_command(arg: str) -> bool:
    """Check if an argument looks like an executable command (not a plain name).
    Returns True if the arg is a path, has an extension, or resolves via which."""
    if os.sep in arg or arg.startswith("./"):
        return True
    if "." in arg:
        return True
    if shutil.which(arg):
        return True
    return False


def derive_name(cmd: list[str]) -> str:
    """Derive a process name from the command."""
    base = os.path.basename(cmd[0])
    for ext in (".py", ".sh", ".js", ".ts", ".rb", ".pl", ".exe"):
        if base.endswith(ext):
            base = base[: -len(ext)]
    return base


def resolve_command(cmd: list[str]) -> list[str]:
    """Resolve command to full path if possible."""
    binary = shutil.which(cmd[0])
    if binary:
        cmd = cmd[:]
        cmd[0] = binary
    return cmd


def kill_process(pid: int, pgid: int | None, force: bool = False) -> bool:
    """Kill a process (and its entire process group). Returns True if dead."""
    sig = signal.SIGKILL if force else signal.SIGTERM

    try:
        if pgid:
            os.killpg(pgid, sig)
        else:
            os.kill(pid, sig)
    except ProcessLookupError:
        return True
    except PermissionError:
        print(f"{color('red', '❌')} Permission denied killing PID {pid}")
        return False

    # Wait for termination
    for _ in range(50):  # 5 seconds
        if not is_running(pid):
            return True
        time.sleep(0.1)

    # Escalate to SIGKILL if SIGTERM didn't work
    if not force and is_running(pid):
        try:
            if pgid:
                os.killpg(pgid, signal.SIGKILL)
            else:
                os.kill(pid, signal.SIGKILL)
            time.sleep(0.3)
        except (ProcessLookupError, PermissionError):
            pass

    return not is_running(pid)


# --- Watch helpers ---


WATCH_DEFAULTS = {
    "interval": 3,
    "min_uptime": 2,
    "on_fast_crash": "backoff",  # backoff | stop | retry
}
BACKOFF_SCHEDULE = [2, 4, 8]
TAIL_BYTES = 2048


def _resolve_watch_block(
    want_watch: bool,
    overrides: dict | None,
    prior_watch: dict | None,
) -> dict | None:
    """Decide what watch block a (re)starting proc should have.

    Three paths, in priority order:
      1. want_watch=True  -> fresh default block, optionally with overrides
      2. prior_watch enabled (internal restart path) -> carry forward,
         clear runtime fields (watcher_pid, errored, error_reason,
         last_stderr_tail) but PRESERVE restart counters
      3. otherwise -> None (no watch)

    Pure: no side effects. Returns a new dict in all non-None cases.
    """
    if want_watch:
        return _default_watch_config(overrides)
    if prior_watch and prior_watch.get("enabled"):
        carried = dict(prior_watch)
        carried["watcher_pid"] = None
        carried["watcher_pgid"] = None
        carried["errored"] = False
        carried["error_reason"] = None
        carried["last_stderr_tail"] = None
        return carried
    return None


def _default_watch_config(overrides: dict | None = None) -> dict:
    """Build a fresh watch config block with defaults."""
    cfg = {
        "enabled": True,
        "interval": WATCH_DEFAULTS["interval"],
        "min_uptime": WATCH_DEFAULTS["min_uptime"],
        "on_fast_crash": WATCH_DEFAULTS["on_fast_crash"],
        "watcher_pid": None,
        "watcher_pgid": None,
        "restarts": 0,
        "last_restart_at": None,
        "errored": False,
        "error_reason": None,
        "last_stderr_tail": None,
    }
    if overrides:
        for k, v in overrides.items():
            if v is not None and k in cfg:
                cfg[k] = v
    return cfg


def _spawn_watcher(name: str) -> tuple[int | None, int | None]:
    """Detach a watcher process for `name`. Returns (pid, pgid)."""
    try:
        wlog = open(watcher_log_path(name), "a")
        proc = subprocess.Popen(
            [sys.executable, os.path.abspath(__file__), "__watcher__", name],
            stdout=wlog,
            stderr=wlog,
            stdin=subprocess.DEVNULL,
            start_new_session=True,
        )
        wlog.close()
    except Exception as e:
        watcher_log(name, f"failed to spawn watcher: {e}")
        return None, None
    try:
        pgid = os.getpgid(proc.pid)
    except OSError:
        pgid = None
    return proc.pid, pgid


def _kill_watcher(info: dict) -> None:
    """Kill the watcher associated with `info` (if any), in-place clears pids."""
    w = info.get("watch") or {}
    wpid = w.get("watcher_pid")
    wpgid = w.get("watcher_pgid")
    if wpid and is_running(wpid):
        kill_process(wpid, wpgid)
    if "watch" in info:
        info["watch"]["watcher_pid"] = None
        info["watch"]["watcher_pgid"] = None


def _tail_stderr(name: str, nbytes: int = TAIL_BYTES) -> str:
    """Return last nbytes of stderr log, stripped."""
    lf = log_path(name, "err")
    if not lf.exists():
        return ""
    try:
        with open(lf, "rb") as f:
            f.seek(0, os.SEEK_END)
            size = f.tell()
            f.seek(max(0, size - nbytes))
            data = f.read().decode("utf-8", errors="replace")
        # strip leading partial line
        if size > nbytes and "\n" in data:
            data = data[data.index("\n") + 1:]
        return data.strip()
    except OSError:
        return ""


def _restart_proc_inplace(info: dict) -> tuple[int | None, int | None, str | None]:
    """Spawn target command again, return (pid, pgid, err_msg)."""
    name = info["name"]
    command = info["command"]
    cwd = info.get("cwd") or os.getcwd()
    stdout_log = log_path(name, "out")
    stderr_log = log_path(name, "err")
    try:
        out_f = open(stdout_log, "a")
        err_f = open(stderr_log, "a")
        ts = datetime.now().isoformat()
        marker = f"\n=== [{ts}] [watch restart] {' '.join(command)} ===\n"
        out_f.write(marker)
        err_f.write(marker)
        out_f.flush()
        err_f.flush()
        proc = subprocess.Popen(
            command,
            stdout=out_f,
            stderr=err_f,
            stdin=subprocess.DEVNULL,
            start_new_session=True,
            cwd=cwd,
        )
        out_f.close()
        err_f.close()
    except FileNotFoundError:
        return None, None, f"command not found: {command[0]}"
    except PermissionError:
        return None, None, f"permission denied: {command[0]}"
    except Exception as e:
        return None, None, f"failed to start: {e}"
    try:
        pgid = os.getpgid(proc.pid)
    except OSError:
        pgid = None
    return proc.pid, pgid, None


def cmd_watcher_loop(name: str) -> int:
    """Internal: watcher loop entry point. Invoked as `bgo __watcher__ <name>`."""
    # Auto-reap children so dead procs disappear instead of lingering as zombies
    try:
        signal.signal(signal.SIGCHLD, signal.SIG_IGN)
    except (ValueError, OSError):
        pass

    info = load_proc(name)
    if not info or not info.get("watch", {}).get("enabled"):
        return 0

    watcher_log(name, f"watcher started for pid={info.get('pid')}")
    backoff_idx = 0
    current_started_at = info.get("started_at")
    needs_early_check = True

    def _start_epoch() -> float:
        try:
            s = current_started_at.replace("Z", "+00:00")
            return datetime.fromisoformat(s).timestamp()
        except Exception:
            return time.time()

    while True:
        interval = info.get("watch", {}).get("interval", WATCH_DEFAULTS["interval"])
        min_uptime_cfg = info.get("watch", {}).get("min_uptime", WATCH_DEFAULTS["min_uptime"])

        if needs_early_check:
            # High-frequency poll during the min_uptime window so fast-crashes
            # are caught with accurate short uptime readings (even when the
            # routine poll interval is larger than min_uptime).
            deadline = _start_epoch() + min_uptime_cfg
            died_early = False
            while time.time() < deadline:
                time.sleep(0.2)
                cur_pid = info.get("pid")
                if not is_running(cur_pid):
                    died_early = True
                    break
            needs_early_check = False
            if not died_early:
                # Sleep the remainder of the normal interval
                remaining = max(0.0, (_start_epoch() + min_uptime_cfg + interval) - time.time())
                if remaining > 0:
                    time.sleep(min(remaining, interval))
        else:
            time.sleep(max(1, interval))

        info = load_proc(name)
        if not info:
            watcher_log(name, "proc state vanished; exiting")
            return 0
        w = info.get("watch") or {}
        if not w.get("enabled"):
            watcher_log(name, "watch disabled; exiting")
            return 0
        if info.get("status") == "stopped":
            watcher_log(name, "proc manually stopped; exiting")
            return 0

        pid = info.get("pid")
        if is_running(pid):
            continue

        # Process died. Compute uptime.
        try:
            started = datetime.fromisoformat(current_started_at.replace("Z", "+00:00"))
            uptime = (datetime.now(timezone.utc) - started).total_seconds()
        except Exception:
            uptime = 0.0

        min_uptime = w.get("min_uptime", WATCH_DEFAULTS["min_uptime"])
        mode = w.get("on_fast_crash", WATCH_DEFAULTS["on_fast_crash"])
        fast = uptime < min_uptime

        if fast:
            tail = _tail_stderr(name)
            watcher_log(name, f"fast-crash: uptime={uptime:.2f}s mode={mode}")

            if mode == "stop":
                info["watch"]["errored"] = True
                info["watch"]["error_reason"] = f"fast-crash (uptime {uptime:.2f}s, mode=stop)"
                info["watch"]["last_stderr_tail"] = tail
                info["watch"]["watcher_pid"] = None
                info["watch"]["watcher_pgid"] = None
                info["status"] = "stopped"
                save_proc(name, info)
                watcher_log(name, "errored; exiting")
                return 0

            if mode == "backoff":
                if backoff_idx >= len(BACKOFF_SCHEDULE):
                    info["watch"]["errored"] = True
                    info["watch"]["error_reason"] = f"{len(BACKOFF_SCHEDULE) + 1} consecutive fast-crashes"
                    info["watch"]["last_stderr_tail"] = tail
                    info["watch"]["watcher_pid"] = None
                    info["watch"]["watcher_pgid"] = None
                    info["status"] = "stopped"
                    save_proc(name, info)
                    watcher_log(name, "backoff exhausted; errored; exiting")
                    return 0
                wait = BACKOFF_SCHEDULE[backoff_idx]
                watcher_log(name, f"backoff sleep {wait}s (step {backoff_idx + 1}/{len(BACKOFF_SCHEDULE)})")
                time.sleep(wait)
                backoff_idx += 1
            elif mode == "retry":
                wait = BACKOFF_SCHEDULE[min(backoff_idx, len(BACKOFF_SCHEDULE) - 1)]
                watcher_log(name, f"retry mode: sleep {wait}s")
                time.sleep(wait)
                backoff_idx = min(backoff_idx + 1, len(BACKOFF_SCHEDULE) - 1)
        else:
            backoff_idx = 0

        # Reload (state may have shifted during sleep)
        info = load_proc(name)
        if not info or info.get("status") == "stopped" or not info.get("watch", {}).get("enabled"):
            watcher_log(name, "state changed during backoff; exiting")
            return 0

        new_pid, new_pgid, err = _restart_proc_inplace(info)
        if err:
            watcher_log(name, f"restart failed: {err}")
            info["watch"]["errored"] = True
            info["watch"]["error_reason"] = err
            info["watch"]["last_stderr_tail"] = _tail_stderr(name)
            info["watch"]["watcher_pid"] = None
            info["watch"]["watcher_pgid"] = None
            info["status"] = "stopped"
            save_proc(name, info)
            return 0

        now_iso = datetime.now(timezone.utc).isoformat()
        info["pid"] = new_pid
        info["pgid"] = new_pgid
        info["started_at"] = now_iso
        info["status"] = "running"
        info["watch"]["restarts"] = info["watch"].get("restarts", 0) + 1
        info["watch"]["last_restart_at"] = now_iso
        save_proc(name, info)
        current_started_at = now_iso
        needs_early_check = True
        watcher_log(name, f"restart #{info['watch']['restarts']} pid={new_pid} (prev uptime {uptime:.2f}s)")


# --- Commands ---


def cmd_start(args):
    """Start a process in the background."""
    init_dirs()

    name = args.name
    command = getattr(args, "cmd", None) or getattr(args, "command", [])

    # Filter out leading '--' if present
    if command and command[0] == "--":
        command = command[1:]

    if not command:
        print(f"{color('red', '❌')} No command specified")
        print(f"   Usage: bgo start {name} -- <command> [args...]")
        return 1

    # Check if already running
    existing = load_proc(name)
    if existing and is_running(existing.get("pid")):
        print(
            f"{color('red', '❌')} Process '{name}' is already running (PID: {existing['pid']})"
        )
        print(f"   Use 'bgo stop {name}' first or choose a different name.")
        return 1

    command = resolve_command(command)

    # Prepare log files
    stdout_log = log_path(name, "out")
    stderr_log = log_path(name, "err")

    with open(stdout_log, "a") as out_f, open(stderr_log, "a") as err_f:
        # Write start marker
        timestamp = datetime.now().isoformat()
        marker = f"\n=== [{timestamp}] Starting: {' '.join(command)} ===\n"
        out_f.write(marker)
        err_f.write(marker)
        out_f.flush()
        err_f.flush()

        try:
            process = subprocess.Popen(
                command,
                stdout=out_f,
                stderr=err_f,
                stdin=subprocess.DEVNULL,
                start_new_session=True,
                cwd=args.cwd or os.getcwd(),
            )
        except FileNotFoundError:
            print(f"{color('red', '❌')} Command not found: {command[0]}")
            return 1
        except PermissionError:
            print(f"{color('red', '❌')} Permission denied: {command[0]}")
            return 1
        except Exception as e:
            print(f"{color('red', '❌')} Failed to start: {e}")
            return 1

    # Brief check for immediate crash
    time.sleep(0.1)
    if not is_running(process.pid):
        print(
            f"{color('red', '❌')} Process '{name}' failed to start (exited immediately)"
        )
        print(f"   Check logs: bgo logs {name}")
        return 1

    # Get pgid for process group killing
    try:
        pgid = os.getpgid(process.pid)
    except OSError:
        pgid = None

    prior_watch = (existing or {}).get("watch") if existing else None
    watch_block = _resolve_watch_block(
        want_watch=getattr(args, "watch", False),
        overrides={
            "interval": getattr(args, "interval", None),
            "min_uptime": getattr(args, "min_uptime", None),
            "on_fast_crash": getattr(args, "on_fast_crash", None),
        },
        prior_watch=prior_watch,
    )

    info = {
        "name": name,
        "pid": process.pid,
        "pgid": pgid,
        "command": command,
        "cwd": args.cwd or os.getcwd(),
        "started_at": datetime.now(timezone.utc).isoformat(),
        "status": "running",
    }
    if watch_block:
        info["watch"] = watch_block
    save_proc(name, info)

    print(f"{color('green', '✅')} Started '{name}' (PID: {process.pid})")

    if watch_block:
        wpid, wpgid = _spawn_watcher(name)
        if wpid:
            info["watch"]["watcher_pid"] = wpid
            info["watch"]["watcher_pgid"] = wpgid
            save_proc(name, info)
            print(
                f"   {color('blue', '👁')} Watching "
                f"(interval={watch_block['interval']}s, "
                f"min-uptime={watch_block['min_uptime']}s, "
                f"on-fast-crash={watch_block['on_fast_crash']})"
            )
        else:
            print(f"   {color('yellow', '⚠️')}  Failed to spawn watcher")

    print(f"   Logs: bgo logs {name}")
    return 0


def cmd_stop(args):
    """Stop a running process."""
    name = args.name
    info = load_proc(name)

    if not info:
        print(f"{color('red', '❌')} No process named '{name}' found")
        return 1

    pid = info.get("pid")

    if not is_running(pid):
        print(f"{color('yellow', '⚠️')}  Process '{name}' is not running")
        info["status"] = "stopped"
        save_proc(name, info)
        return 0

    force = getattr(args, "force", False)
    pgid = info.get("pgid")

    # Mark stopped BEFORE killing so watcher (if any) sees the flag and exits.
    info["status"] = "stopped"
    save_proc(name, info)

    # Kill watcher first so it doesn't observe the death and try to restart.
    _kill_watcher(info)

    killed = kill_process(pid, pgid, force=force)

    if killed:
        label = "💀 Killed" if force else "🛑 Stopped"
        print(f"{label} '{name}' (PID: {pid})")
        info["stopped_at"] = datetime.now(timezone.utc).isoformat()
        save_proc(name, info)
    else:
        print(
            f"{color('red', '❌')} Failed to stop '{name}' (PID: {pid}). Try --force"
        )
        return 1

    return 0


def cmd_restart(args):
    """Restart a process. Clears errored state and re-spawns watcher if enabled."""
    name = args.name
    info = load_proc(name)

    if not info:
        print(f"{color('red', '❌')} No process named '{name}' found")
        return 1

    # Clear errored state. Restart counter is preserved by default so
    # operators can see chronic crashers across manual restarts; pass
    # --reset-counters to wipe it explicitly.
    reset = getattr(args, "reset_counters", False)
    if "watch" in info:
        info["watch"]["errored"] = False
        info["watch"]["error_reason"] = None
        info["watch"]["last_stderr_tail"] = None
        if reset:
            info["watch"]["restarts"] = 0
            info["watch"]["last_restart_at"] = None
        save_proc(name, info)

    # Stop watcher first to prevent restart race
    _kill_watcher(info)
    save_proc(name, info)

    # Stop target if running
    pid = info.get("pid")
    if is_running(pid):
        print(f"🛑 Stopping '{name}'...")
        kill_process(pid, info.get("pgid"))

    # cmd_start picks up info["watch"] via load_proc(existing) and re-spawns watcher
    restart_args = argparse.Namespace(
        name=name,
        command=info["command"],
        cwd=info.get("cwd"),
    )
    return cmd_start(restart_args)


def cmd_watch(args):
    """Attach a watcher to an existing process (or replace its config)."""
    name = args.name
    info = load_proc(name)
    if not info:
        print(f"{color('red', '❌')} No process named '{name}' found")
        return 1

    pid = info.get("pid")
    if not is_running(pid):
        print(f"{color('yellow', '⚠️')}  Process '{name}' is not running. Start it first.")
        return 1

    # Kill any prior watcher
    _kill_watcher(info)

    reset = getattr(args, "reset", False)
    prior = info.get("watch") or {}
    overrides = {
        "interval": getattr(args, "interval", None),
        "min_uptime": getattr(args, "min_uptime", None),
        "on_fast_crash": getattr(args, "on_fast_crash", None),
    }

    if reset or not prior:
        watch_block = _default_watch_config(overrides)
    else:
        watch_block = dict(prior)
        watch_block["enabled"] = True
        watch_block["errored"] = False
        watch_block["error_reason"] = None
        watch_block["last_stderr_tail"] = None
        for k, v in overrides.items():
            if v is not None:
                watch_block[k] = v

    info["watch"] = watch_block
    save_proc(name, info)

    wpid, wpgid = _spawn_watcher(name)
    if not wpid:
        print(f"{color('red', '❌')} Failed to spawn watcher")
        return 1
    info["watch"]["watcher_pid"] = wpid
    info["watch"]["watcher_pgid"] = wpgid
    save_proc(name, info)
    print(
        f"{color('blue', '👁')} Watching '{name}' "
        f"(interval={watch_block['interval']}s, "
        f"min-uptime={watch_block['min_uptime']}s, "
        f"on-fast-crash={watch_block['on_fast_crash']})"
    )
    return 0


def cmd_unwatch(args):
    """Detach watcher from a process (keeps the process running)."""
    name = args.name
    info = load_proc(name)
    if not info:
        print(f"{color('red', '❌')} No process named '{name}' found")
        return 1
    if not info.get("watch", {}).get("enabled"):
        print(f"{color('yellow', '⚠️')}  '{name}' is not being watched")
        return 0
    _kill_watcher(info)
    info["watch"]["enabled"] = False
    save_proc(name, info)
    print(f"{color('gray', '👁  unwatched')} '{name}'")
    return 0


def _watch_cell(info: dict, level: str | None = None) -> str:
    """Render the WATCH column cell for the status table."""
    g = glyphs(level)
    w = info.get("watch")
    if not w or not w.get("enabled"):
        return g["watch_none"]
    if w.get("errored"):
        return color("red", f"{g['errored']} errored")
    wpid = w.get("watcher_pid")
    if wpid and not is_running(wpid):
        return color("yellow", f"{g['watcher_dead']} dead")
    return color("green", f"{g['watching']} {w.get('restarts', 0)}")


def _clear_screen():
    """ANSI clear-screen + home cursor. TTY safe."""
    if sys.stdout.isatty():
        sys.stdout.write("\033[2J\033[H")
        sys.stdout.flush()


def _status_snapshot(procs: dict) -> list[dict]:
    """Build a status snapshot for procs. Returns list of dicts keyed by name.

    Pure: no printing. Side effect: persists status='stopped' for procs
    whose pid is no longer running, so subsequent status calls stay
    consistent. Batches the ps lookup into a single subprocess call.
    """
    running_pids = []
    for info in procs.values():
        pid = info.get("pid")
        if is_running(pid):
            running_pids.append(pid)
    pinfo_map = get_process_info_batch(running_pids)

    rows = []
    for name, info in sorted(procs.items()):
        pid = info.get("pid")
        running = is_running(pid)
        if running:
            pinfo = pinfo_map.get(pid, dict(_BLANK_PINFO))
        else:
            pinfo = dict(_BLANK_PINFO)
            if info.get("status") != "stopped":
                info["status"] = "stopped"
                save_proc(name, info)
        rows.append({
            "name": name,
            "pid": pid,
            "status": "online" if running else "stopped",
            "cpu": pinfo["cpu"],
            "mem": pinfo["mem"],
            "uptime": pinfo["time"],
            "command": info.get("command", []),
            "cwd": info.get("cwd"),
            "started_at": info.get("started_at"),
            "watch": info.get("watch"),
        })
    return rows


def _visible_width(s: str) -> int:
    """Visible length of a string (ANSI codes stripped)."""
    return len(strip_ansi(s))


def _pad(text: str, width: int, align: str = "left") -> str:
    """Pad `text` to visible width using spaces. ANSI-safe."""
    pad = max(0, width - _visible_width(text))
    if align == "right":
        return " " * pad + text
    return text + " " * pad


def _print_status_table(rows: list[dict], level: str | None = None):
    """Render the status table for a snapshot."""
    if not rows:
        return
    level = level or _detect_table_level()
    g = GLYPHS[level]
    term_width = shutil.get_terminal_size().columns

    # column widths (visible chars only)
    name_w = max(12, max(len(r["name"]) for r in rows) + 1)
    status_w = max(len(g["online"]), len(g["stopped"])) + 1
    pid_w = 8
    cpu_w = 6
    mem_w = 6
    uptime_w = 12
    watch_w = 12

    fixed = name_w + status_w + pid_w + cpu_w + mem_w + uptime_w + watch_w
    # column separators: plain/normal use spaces; fancy uses vertical bars
    sep_w = 7  # 7 inter-column gaps
    cmd_w = max(20, term_width - fixed - sep_w - 2)

    # --- header ---
    headers = ["NAME", "STATUS", "PID", "CPU", "MEM", "UPTIME", "WATCH", "COMMAND"]
    widths = [name_w, status_w, pid_w, cpu_w, mem_w, uptime_w, watch_w, cmd_w]
    if level == LEVEL_FANCY:
        rule = g["hline"] * (sum(widths) + sep_w + 2)
        top = g["tl"] + rule[1:-1] + g["tr"]
        bottom = g["bl"] + rule[1:-1] + g["br"]
        mid_rule = g["hline"] * (sum(widths) + sep_w + 2)
        print(color("gray", top))
        cells = [color("bold", _pad(h, w)) for h, w in zip(headers, widths)]
        print(g["vline"] + " " + (" ").join(cells) + " " + g["vline"])
        print(color("gray", g["tright"] + mid_rule[1:-1] + g["tleft"]))
    else:
        cells = [color("bold", _pad(h, w)) for h, w in zip(headers, widths)]
        print(" ".join(cells))
        print(color("gray", g["hline"] * min(term_width, sum(widths) + sep_w)))

    running_count = 0
    stopped_count = 0
    errored_procs = []

    for r in rows:
        cmd_str = truncate(" ".join(r["command"]), cmd_w)
        watch_str = _watch_cell({"watch": r["watch"]}, level)
        w = r["watch"] or {}
        if w.get("errored"):
            errored_procs.append((r["name"], w.get("error_reason", "unknown")))

        if r["status"] == "online":
            running_count += 1
            status_str = color("green", g["online"])
            cells = [
                _pad(r["name"], name_w),
                _pad(status_str, status_w),
                _pad(color("gray", str(r["pid"])), pid_w),
                _pad(str(r["cpu"]), cpu_w),
                _pad(str(r["mem"]), mem_w),
                _pad(str(r["uptime"]), uptime_w),
                _pad(watch_str, watch_w),
                cmd_str,
            ]
        else:
            stopped_count += 1
            status_str = color("red", g["stopped"])
            cells = [
                _pad(r["name"], name_w),
                _pad(status_str, status_w),
                _pad("-", pid_w),
                _pad("-", cpu_w),
                _pad("-", mem_w),
                _pad("-", uptime_w),
                _pad(watch_str, watch_w),
                color("gray", cmd_str),
            ]

        if level == LEVEL_FANCY:
            print(g["vline"] + " " + " ".join(cells) + " " + g["vline"])
        else:
            print(" ".join(cells))

    if level == LEVEL_FANCY:
        print(color("gray", bottom))
    else:
        print(color("gray", g["hline"] * min(term_width, sum(widths) + sep_w)))

    summary = (
        f"Total: {len(rows)} | {color('green', g['online'])}: {running_count} | "
        f"{color('red', g['stopped'])}: {stopped_count}"
    )
    print(summary)
    if errored_procs:
        print()
        print(color("red", f"{g['errored']} {len(errored_procs)} errored:"))
        for n, reason in errored_procs:
            print(f"   {color('red', n)} — {reason}")
            print(f"     {color('gray', f'bgo logs {n} --watcher   |   bgo restart {n}')}")


def _level_from_args(args) -> str | None:
    """Resolve --plain / --fancy CLI flags into a level string, or None for auto."""
    if getattr(args, "plain", False):
        return LEVEL_PLAIN
    if getattr(args, "fancy", False):
        return LEVEL_FANCY
    return None


def cmd_status(args):
    """Show status of all processes."""
    # JSON output mode bypasses everything else
    if getattr(args, "json", False):
        procs = load_all_procs()
        if getattr(args, "name", None):
            info = load_proc(args.name)
            if not info:
                print(json.dumps({"error": f"no process named '{args.name}'"}))
                return 1
            rows = _status_snapshot({args.name: info})
            print(json.dumps(rows[0], indent=2, default=str))
            return 0
        rows = _status_snapshot(procs)
        print(json.dumps(rows, indent=2, default=str))
        return 0

    level_override = _level_from_args(args)

    # Watch mode: refresh until Ctrl-C
    if getattr(args, "watch", False):
        interval = max(1, getattr(args, "interval", None) or 2)
        try:
            while True:
                _clear_screen()
                procs = load_all_procs()
                if not procs:
                    print("No processes registered.")
                    print("Usage: bgo start <n> -- <command> [args...]")
                else:
                    rows = _status_snapshot(procs)
                    _print_status_table(rows, level=level_override)
                    print(color("gray", f"\nRefreshing every {interval}s. Ctrl-C to exit."))
                try:
                    time.sleep(interval)
                except KeyboardInterrupt:
                    return 0
        except KeyboardInterrupt:
            return 0

    # Default: one-shot table or detail view
    procs = load_all_procs()
    if not procs:
        print("No processes registered.")
        print("Usage: bgo start <n> -- <command> [args...]")
        print("       bgo <command> [args...]")
        return 0

    if getattr(args, "name", None):
        name = args.name
        info = load_proc(name)
        if not info:
            print(f"{color('red', '❌')} No process named '{name}' found")
            return 1
        _print_proc_detail(info)
        return 0

    rows = _status_snapshot(procs)
    _print_status_table(rows, level=level_override)
    return 0


def _print_proc_detail(info: dict):
    """Print detailed info for a single process."""
    pid = info["pid"]
    running = is_running(pid)
    status_str = color("green", "online") if running else color("red", "stopped")

    print(f"{color('bold', 'Process:')} {info['name']}")
    print(f"  Status:  {status_str}")
    print(f"  PID:     {pid}")
    print(f"  PGID:    {info.get('pgid', 'N/A')}")
    print(f"  Command: {' '.join(info['command'])}")
    print(f"  CWD:     {info.get('cwd', 'N/A')}")
    print(f"  Started: {info.get('started_at', 'N/A')}")

    if running:
        proc_info = get_process_info(pid)
        print(f"  CPU:     {proc_info['cpu']}%")
        print(f"  MEM:     {proc_info['mem']}%")
        print(f"  Uptime:  {proc_info['time']}")

    for stream, label in [("out", "stdout"), ("err", "stderr")]:
        lf = log_path(info["name"], stream)
        if lf.exists():
            size = lf.stat().st_size
            print(f"  {label}:  {_human_size(size)} ({lf})")

    w = info.get("watch")
    if w:
        print()
        print(f"{color('bold', 'Watch:')}")
        enabled = w.get("enabled")
        if enabled:
            print(
                f"  Enabled:   yes "
                f"(interval {w.get('interval')}s, "
                f"min-uptime {w.get('min_uptime')}s, "
                f"mode {w.get('on_fast_crash')})"
            )
        else:
            print(f"  Enabled:   no")
        wpid = w.get("watcher_pid")
        if wpid:
            alive = is_running(wpid)
            wstate = color("green", "alive") if alive else color("yellow", "dead")
            print(f"  Watcher:   PID {wpid} ({wstate})")
        else:
            print(f"  Watcher:   {color('gray', 'not running')}")
        print(f"  Restarts:  {w.get('restarts', 0)}")
        if w.get("last_restart_at"):
            print(f"  Last:      {w['last_restart_at']}")
        if w.get("errored"):
            print(f"  Errored:   {color('red', 'YES')} — {w.get('error_reason')}")
            tail = w.get("last_stderr_tail")
            if tail:
                print(f"  Last err:")
                for line in tail.splitlines()[-5:]:
                    print(f"    {color('gray', line)}")
            nm = info["name"]
            print(
                f"  Recover:   {color('gray', f'bgo logs {nm} --watcher   |   bgo restart {nm}')}"
            )
        else:
            print(f"  Errored:   no")
    wlog = watcher_log_path(info["name"])
    if wlog.exists() and wlog.stat().st_size > 0:
        print(f"  watcher:   {_human_size(wlog.stat().st_size)} ({wlog})")


def _human_size(n: int) -> str:
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024:
            return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
        n /= 1024
    return f"{n:.1f}TB"


def cmd_logs(args):
    """Show logs for a process."""
    name = args.name
    info = load_proc(name)

    if not info:
        print(f"{color('red', '❌')} No process named '{name}' found")
        return 1

    # Determine which log to show
    show_watcher = getattr(args, "watcher", False)
    if show_watcher:
        stream = "watcher"
        lf = watcher_log_path(name)
    elif getattr(args, "stderr", False):
        stream = "err"
        lf = log_path(name, stream)
    elif getattr(args, "stdout", False):
        stream = "out"
        lf = log_path(name, stream)
    else:
        stream = "out"
        lf = log_path(name, stream)

    if not lf.exists() or lf.stat().st_size == 0:
        label = {"err": "stderr", "out": "stdout", "watcher": "watcher"}.get(stream, stream)
        print(f"No {label} logs found for '{name}'")
        if not getattr(args, "follow", False):
            return 0

    lines_to_show = getattr(args, "lines", 50)
    follow = getattr(args, "follow", False)

    if follow:
        # Follow mode: use tail -f for reliability
        tail_args = ["tail", "-f", "-n", str(lines_to_show)]

        # Show both streams if not explicitly filtered (only for stdout/stderr default)
        files = []
        if (
            not show_watcher
            and not getattr(args, "stderr", False)
            and not getattr(args, "stdout", False)
        ):
            err_log = log_path(name, "err")
            files.append(str(lf))
            if err_log.exists() and err_log.stat().st_size > 0:
                files.append(str(err_log))
        else:
            files.append(str(lf))

        print(color("gray", "─" * 50))
        print(color("yellow", f"Following logs for '{name}' (Ctrl+C to exit)"))
        print(color("gray", "─" * 50))

        try:
            subprocess.run(tail_args + files)
        except KeyboardInterrupt:
            print(color("gray", "\n[stopped following]"))
    else:
        # Static view
        try:
            with open(lf) as f:
                lines = f.readlines()

            if lines_to_show > 0 and len(lines) > lines_to_show:
                lines = lines[-lines_to_show:]
                print(f"... (showing last {lines_to_show} lines)\n")

            for line in lines:
                print(line, end="")

            # Also show stderr if not filtered and has content
            if stream == "out" and not getattr(args, "stdout", False):
                err_log = log_path(name, "err")
                if err_log.exists() and err_log.stat().st_size > 0:
                    with open(err_log) as f:
                        err_lines = f.readlines()
                    if err_lines:
                        if lines_to_show > 0 and len(err_lines) > lines_to_show:
                            err_lines = err_lines[-lines_to_show:]
                        print(f"\n{color('gray', '─── stderr ───')}")
                        for line in err_lines:
                            print(line, end="")

        except FileNotFoundError:
            print(f"Log file not found: {lf}")
            return 1

    return 0


def cmd_logs_follow(args):
    """Follow logs for a process (shorthand for logs -f)."""
    args.follow = True
    if not hasattr(args, "lines") or args.lines is None:
        args.lines = 10
    return cmd_logs(args)


def cmd_clean(args):
    """Remove stopped processes from state."""
    procs = load_all_procs()
    cleaned = []

    for name, info in procs.items():
        pid = info.get("pid")
        if not is_running(pid):
            cleaned.append(name)
            delete_proc(name)

    if cleaned:
        print(
            f"🧹 Cleaned up {len(cleaned)} stopped process(es): {', '.join(cleaned)}"
        )
    else:
        print("Nothing to clean up.")

    return 0


def cmd_resurrect(args):
    """Restart all processes that were running before shutdown/reboot."""
    procs = load_all_procs()

    if not procs:
        print("No processes registered.")
        return 0

    candidates = []
    for name, info in procs.items():
        pid = info.get("pid")
        if not is_running(pid) and info.get("status") == "running":
            candidates.append((name, info))

    if not candidates:
        print("No processes to resurrect (all running or already stopped).")
        return 0

    print(f"🔄 Resurrecting {len(candidates)} process(es)...")
    failures = 0

    for name, info in candidates:
        restart_args = argparse.Namespace(
            name=name,
            command=info["command"],
            cwd=info.get("cwd"),
        )
        result = cmd_start(restart_args)
        if result != 0:
            failures += 1

    if failures:
        print(f"\n⚠️  {failures}/{len(candidates)} process(es) failed to start")
        return 1

    print(f"\n✅ All {len(candidates)} process(es) resurrected")
    return 0


def _interactive_multiselect(title: str, options: list[tuple[str, str]]) -> list[str] | None:
    """Render a TTY checkbox list. Returns selected keys, or None if cancelled.

    options: list of (key, label) tuples. Keys returned; labels displayed.
    Controls: ↑/↓ navigate, space toggle, a toggle-all, enter confirm, q/esc cancel.
    Falls back to a numbered prompt if stdin/stdout isn't a TTY.
    """
    if not options:
        return []

    if not (sys.stdin.isatty() and sys.stdout.isatty()):
        # Non-TTY fallback: numbered prompt
        print(title)
        for i, (_, label) in enumerate(options, 1):
            print(f"  {i}) {label}")
        print("Enter numbers (comma/space separated), 'a' for all, blank to cancel:")
        try:
            raw = input("> ").strip()
        except EOFError:
            return None
        if not raw:
            return None
        if raw.lower() == "a":
            return [k for k, _ in options]
        picks: list[str] = []
        for tok in re.split(r"[,\s]+", raw):
            if not tok:
                continue
            try:
                idx = int(tok) - 1
                if 0 <= idx < len(options):
                    picks.append(options[idx][0])
            except ValueError:
                pass
        return picks

    selected = [False] * len(options)
    cursor = 0
    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)

    def render(first: bool):
        if not first:
            # Move cursor up to redraw
            sys.stdout.write(f"\033[{len(options) + 3}A")
        sys.stdout.write("\033[J")  # clear from cursor down
        sys.stdout.write(f"{title}\n")
        sys.stdout.write(color("gray", "  ↑/↓ move · space toggle · a all · enter confirm · q cancel\n"))
        for i, (_, label) in enumerate(options):
            mark = color("green", "[x]") if selected[i] else "[ ]"
            line = f"  {mark} {label}"
            if i == cursor:
                line = color("blue", "▶ ") + line.lstrip()
            else:
                line = "  " + line
            sys.stdout.write(line + "\n")
        sys.stdout.write(color("gray", f"  ({sum(selected)} selected)\n"))
        sys.stdout.flush()

    try:
        tty.setcbreak(fd)
        render(first=True)
        while True:
            ch = sys.stdin.read(1)
            if ch == "\x1b":
                # Escape sequence (arrow key) or plain ESC
                next1 = sys.stdin.read(1)
                if next1 == "":
                    return None  # ESC alone
                if next1 == "[":
                    arrow = sys.stdin.read(1)
                    if arrow == "A":
                        cursor = (cursor - 1) % len(options)
                    elif arrow == "B":
                        cursor = (cursor + 1) % len(options)
                    render(first=False)
                continue
            if ch == " ":
                selected[cursor] = not selected[cursor]
                render(first=False)
            elif ch.lower() == "a":
                new_state = not all(selected)
                selected = [new_state] * len(options)
                render(first=False)
            elif ch in ("\r", "\n"):
                return [options[i][0] for i, s in enumerate(selected) if s]
            elif ch.lower() == "q" or ch == "\x03":  # q or Ctrl-C
                return None
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
        sys.stdout.write("\n")
        sys.stdout.flush()


def _restart_one(name: str, info: dict) -> int:
    """Stop (if running) then start a single proc. Returns 0 on success."""
    pid = info.get("pid")
    if is_running(pid):
        kill_process(pid, info.get("pgid"))
    restart_args = argparse.Namespace(
        name=name,
        command=info["command"],
        cwd=info.get("cwd"),
    )
    return cmd_start(restart_args)


def cmd_restart_stopped(args):
    """Restart procs that are not currently running.

    Includes both explicitly stopped procs AND procs whose state says
    'running' but whose PID is dead (e.g., killed by reboot).
    """
    procs = load_all_procs()

    if not procs:
        print("No processes registered.")
        return 0

    candidates = [
        (name, info) for name, info in procs.items()
        if not is_running(info.get("pid"))
    ]

    requested = getattr(args, "names", None) or []
    use_all = getattr(args, "all", False)

    if requested:
        cand_map = dict(candidates)
        unknown = [n for n in requested if n not in procs]
        not_stopped = [n for n in requested if n in procs and n not in cand_map]
        if unknown:
            print(f"{color('red', '❌')} Unknown process(es): {', '.join(unknown)}")
            return 1
        if not_stopped:
            print(f"{color('yellow', '⚠️')}  Skipping (already running): {', '.join(not_stopped)}")
        targets = [(n, cand_map[n]) for n in requested if n in cand_map]
        if not targets:
            print("Nothing to restart.")
            return 0
    elif not candidates:
        print("No stopped processes to restart.")
        return 0
    elif use_all:
        targets = candidates
    else:
        options = []
        for name, info in candidates:
            cmd_str = " ".join(info.get("command", []))
            stopped_at = info.get("stopped_at") or info.get("started_at") or "?"
            label = f"{name:<20} {color('gray', stopped_at[:19])}  {cmd_str[:60]}"
            options.append((name, label))
        picks = _interactive_multiselect(
            color("bold", f"Select stopped processes to restart ({len(candidates)} available):"),
            options,
        )
        if picks is None:
            print("Cancelled.")
            return 0
        if not picks:
            print("Nothing selected.")
            return 0
        cand_map = dict(candidates)
        targets = [(n, cand_map[n]) for n in picks]

    if not targets:
        print("No matching stopped processes.")
        return 0

    print(f"🔄 Restarting {len(targets)} process(es)...")
    failures = 0
    for name, info in targets:
        if _restart_one(name, info) != 0:
            failures += 1

    if failures:
        print(f"\n⚠️  {failures}/{len(targets)} process(es) failed to start")
        return 1
    print(f"\n✅ All {len(targets)} process(es) restarted")
    return 0


def cmd_restart_last(args):
    """Restart processes ordered by most-recent-first.

    With --all: restart every not-running proc in most-recent order.
    Otherwise: interactive menu sorted by most-recent-first (no pre-check).
    """
    procs = load_all_procs()

    if not procs:
        print("No processes registered.")
        return 0

    candidates = [
        (name, info) for name, info in procs.items()
        if not is_running(info.get("pid"))
    ]

    if not candidates:
        print("No stopped processes to restart.")
        return 0

    def sort_key(item):
        _, info = item
        return info.get("stopped_at") or info.get("started_at") or ""

    candidates.sort(key=sort_key, reverse=True)

    use_all = getattr(args, "all", False)

    if use_all:
        targets = candidates
    else:
        options = []
        for name, info in candidates:
            cmd_str = " ".join(info.get("command", []))
            ts = info.get("stopped_at") or info.get("started_at") or "?"
            label = f"{name:<20} {color('gray', ts[:19])}  {cmd_str[:60]}"
            options.append((name, label))
        picks = _interactive_multiselect(
            color("bold", f"Select processes to restart (most recent first, {len(candidates)} available):"),
            options,
        )
        if picks is None:
            print("Cancelled.")
            return 0
        if not picks:
            print("Nothing selected.")
            return 0
        cand_map = dict(candidates)
        targets = [(n, cand_map[n]) for n in picks]

    print(f"🔄 Restarting {len(targets)} process(es)...")
    failures = 0
    for name, info in targets:
        if _restart_one(name, info) != 0:
            failures += 1

    if failures:
        print(f"\n⚠️  {failures}/{len(targets)} process(es) failed to start")
        return 1
    print(f"\n✅ All {len(targets)} process(es) restarted")
    return 0


def cmd_delete(args):
    """Delete a process from state (stop if running first)."""
    name = args.name
    info = load_proc(name)

    if not info:
        print(f"{color('red', '❌')} No process named '{name}' found")
        return 1

    # Stop if running
    pid = info.get("pid")
    if is_running(pid):
        if not getattr(args, "yes", False):
            confirm = input(f"Process '{name}' is running. Stop and delete? [y/N] ")
            if confirm.lower() != "y":
                print("Cancelled.")
                return 0
        kill_process(pid, info.get("pgid"))

    keep_logs = getattr(args, "keep_logs", False)
    delete_proc(name, keep_logs=keep_logs)
    suffix = " (logs kept)" if keep_logs else ""
    print(f"🗑️  Deleted '{name}'{suffix}")
    return 0


# --- CLI ---


def main():
    init_dirs()

    # Hidden subcommand for internal watcher loop
    if len(sys.argv) >= 3 and sys.argv[1] == "__watcher__":
        return cmd_watcher_loop(sys.argv[2])

    known_commands = {
        "start", "stop", "restart", "restart-stopped", "restart-last",
        "status", "logs", "clean", "delete", "resurrect",
        "watch", "unwatch",
        "ls", "list", "follow", "tail", "-h", "--help",
        "open", "kill", "rm",
    }

    # Direct mode: bgo <n> -- <cmd>   OR   bgo <cmd> [args...]
    # Single-token invocation NEVER falls through to start — it routes to
    # status detail if it names a registered proc, or prints help.
    # Direct-mode start requires either an explicit '--' separator, a -w
    # flag, OR at least 2 positional tokens (name + command).
    if len(sys.argv) > 1 and sys.argv[1] not in known_commands:
        remaining = sys.argv[1:]

        # Single arg → status detail if registered, else help
        if len(remaining) == 1 and remaining[0] not in ("-w", "--watch"):
            tok = remaining[0]
            if tok.startswith("-"):
                # Unknown flag — argparse handles it (prints error + usage)
                pass
            elif load_proc(tok) is not None:
                args = argparse.Namespace(name=tok, watch=False, json=False, interval=None)
                return cmd_status(args)
            else:
                print(f"{color('red', '❌')} Unknown command or process: '{tok}'")
                print(f"   Run {color('bold', 'bgo --help')} for usage, or "
                      f"{color('bold', 'bgo start ' + tok + ' -- <command>')} to start a new process.")
                return 1

        # Strip -w / --watch in head (positions before '--', or first two positions if no '--').
        want_watch = False
        if "--" in remaining:
            sep = remaining.index("--")
            head, tail = remaining[:sep], remaining[sep:]
            new_head = []
            for tok in head:
                if tok in ("-w", "--watch"):
                    want_watch = True
                else:
                    new_head.append(tok)
            remaining = new_head + tail
        else:
            new_remaining = []
            for idx, tok in enumerate(remaining):
                if idx < 2 and tok in ("-w", "--watch"):
                    want_watch = True
                    continue
                new_remaining.append(tok)
            remaining = new_remaining

        if not remaining:
            print("Usage: bgo <n> -- <command> [args...]")
            return 1

        # Check if there's a '--' separator → explicit name mode
        if "--" in remaining:
            sep_idx = remaining.index("--")
            name = remaining[0] if sep_idx > 0 else None
            command = remaining[sep_idx + 1 :]

            if not name or not command:
                print("Usage: bgo [-w] <n> -- <command> [args...]")
                return 1

            args = argparse.Namespace(
                name=name, command=command, cwd=None, watch=want_watch,
                interval=None, min_uptime=None, on_fast_crash=None,
            )
            return cmd_start(args)
        else:
            # No '--' → heuristic: if first arg is NOT an executable but
            # second arg IS (or looks like a path/command), treat first as name.
            if len(remaining) >= 2 and not _looks_like_command(remaining[0]) and _looks_like_command(remaining[1]):
                name = remaining[0]
                command = remaining[1:]
            else:
                command = remaining
                name = derive_name(command)
            args = argparse.Namespace(
                name=name, command=command, cwd=None, watch=want_watch,
                interval=None, min_uptime=None, on_fast_crash=None,
            )
            return cmd_start(args)

    parser = argparse.ArgumentParser(
        prog="bgo",
        description="A lightweight background process manager",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  bgo python3 server.py                    # Start (auto-name: 'server')
  bgo myapp python3 server.py --port 8080  # Start with name + args
  bgo start myapp -- python3 server.py     # Start with explicit name
  bgo open myapp -- python3 server.py      # Alias for start
  bgo myapp -- ./api-server --port 8080    # Shorthand with name
  bgo status                               # Show all processes
  bgo status myapp                         # Detailed view of one process
  bgo logs myapp                           # View logs
  bgo logs myapp -f                        # Follow logs (tail -f)
  bgo follow myapp                         # Shorthand for logs -f
  bgo stop myapp                           # Stop (SIGTERM → SIGKILL)
  bgo kill myapp                           # Alias for stop
  bgo stop myapp --force                   # Force kill (SIGKILL)
  bgo restart myapp                        # Restart a process
  bgo restart-stopped                      # Pick stopped procs to restart (interactive)
  bgo restart-stopped --all                # Restart all stopped procs
  bgo restart-stopped foo bar              # Restart named stopped procs
  bgo restart-last                         # Pick from most-recent-first menu
  bgo restart-last --all                   # Restart all not-running, recent first
  bgo delete myapp                         # Remove from list + logs
  bgo rm myapp                             # Alias for delete
  bgo clean                                # Remove all stopped entries
  bgo resurrect                            # Restart all previously running procs

Watch mode (auto-restart on crash):
  bgo start -w myapp -- python3 server.py  # Start with watcher
  bgo -w myapp python3 server.py           # Direct mode with watcher
  bgo watch myapp                          # Attach watcher to existing process
  bgo watch myapp --interval 5 --min-uptime 3
  bgo unwatch myapp                        # Detach watcher (keeps proc)
  bgo logs myapp --watcher                 # View watcher events
        """,
    )

    subparsers = parser.add_subparsers(dest="command", help="Commands")

    # start
    start_parser = subparsers.add_parser(
        "start", aliases=["open"], help="Start a process"
    )
    start_parser.add_argument("name", help="Name for this process")
    start_parser.add_argument(
        "cmd", nargs=argparse.REMAINDER, help="Command to run (use -- before command)"
    )
    start_parser.add_argument("--cwd", help="Working directory for the process")
    start_parser.add_argument(
        "-w", "--watch", action="store_true",
        help="Auto-restart this process if it crashes",
    )
    start_parser.add_argument(
        "--interval", type=int, default=None,
        help=f"Watch poll interval in seconds (default: {WATCH_DEFAULTS['interval']})",
    )
    start_parser.add_argument(
        "--min-uptime", dest="min_uptime", type=int, default=None,
        help=f"Crash threshold in seconds (default: {WATCH_DEFAULTS['min_uptime']})",
    )
    start_parser.add_argument(
        "--on-fast-crash", dest="on_fast_crash",
        choices=["backoff", "stop", "retry"], default=None,
        help=f"Fast-crash policy (default: {WATCH_DEFAULTS['on_fast_crash']})",
    )

    # stop
    stop_parser = subparsers.add_parser(
        "stop", aliases=["kill"], help="Stop a process"
    )
    stop_parser.add_argument("name", help="Name of the process")
    stop_parser.add_argument(
        "-f", "--force", action="store_true", help="Force kill (SIGKILL)"
    )

    # restart
    restart_parser = subparsers.add_parser("restart", help="Restart a process")
    restart_parser.add_argument("name", help="Name of the process")
    restart_parser.add_argument(
        "--reset-counters", dest="reset_counters", action="store_true",
        help="Also zero the watch restart counter (default: preserve)",
    )

    # watch
    watch_parser = subparsers.add_parser(
        "watch", help="Attach a watcher to an existing running process",
    )
    watch_parser.add_argument("name", help="Name of the process")
    watch_parser.add_argument(
        "--interval", type=int, default=None,
        help=f"Poll interval in seconds (default: {WATCH_DEFAULTS['interval']})",
    )
    watch_parser.add_argument(
        "--min-uptime", dest="min_uptime", type=int, default=None,
        help=f"Crash threshold in seconds (default: {WATCH_DEFAULTS['min_uptime']})",
    )
    watch_parser.add_argument(
        "--on-fast-crash", dest="on_fast_crash",
        choices=["backoff", "stop", "retry"], default=None,
        help=f"Fast-crash policy (default: {WATCH_DEFAULTS['on_fast_crash']})",
    )
    watch_parser.add_argument(
        "--reset", action="store_true",
        help="Reset watch config to defaults (ignore prior settings)",
    )

    # unwatch
    unwatch_parser = subparsers.add_parser(
        "unwatch", help="Detach watcher from a process (keeps proc running)",
    )
    unwatch_parser.add_argument("name", help="Name of the process")

    # restart-stopped
    rs_parser = subparsers.add_parser(
        "restart-stopped",
        help="Restart stopped processes (interactive menu, --all, or by name)",
    )
    rs_parser.add_argument("names", nargs="*", help="Specific process names (optional)")
    rs_parser.add_argument(
        "-a", "--all", action="store_true", help="Restart all stopped processes without prompting"
    )

    # restart-last
    rl_parser = subparsers.add_parser(
        "restart-last",
        help="Restart processes ordered by most recent (interactive menu or --all)",
    )
    rl_parser.add_argument(
        "-a", "--all", action="store_true", help="Restart all not-running processes (most recent first)"
    )

    # status
    status_parser = subparsers.add_parser(
        "status", aliases=["ls", "list"], help="Show process status"
    )
    status_parser.add_argument(
        "name", nargs="?", default=None, help="Show details for specific process"
    )
    status_parser.add_argument(
        "-w", "--watch", action="store_true", help="Watch mode (auto-refresh)"
    )
    status_parser.add_argument(
        "--interval", type=int, default=None,
        help="Refresh interval in seconds for --watch (default: 2)",
    )
    status_parser.add_argument(
        "--json", action="store_true",
        help="Output as JSON (machine-readable, no colors)",
    )
    status_level_group = status_parser.add_mutually_exclusive_group()
    status_level_group.add_argument(
        "--plain", action="store_true",
        help="Force plain rendering (no color, ASCII only)",
    )
    status_level_group.add_argument(
        "--fancy", action="store_true",
        help="Force fancy rendering (Unicode box-drawing)",
    )

    # logs
    logs_parser = subparsers.add_parser("logs", help="View process logs")
    logs_parser.add_argument("name", help="Name of the process")
    logs_parser.add_argument(
        "-f", "--follow", action="store_true", help="Follow log output"
    )
    logs_parser.add_argument(
        "-n", "--lines", type=int, default=50, help="Number of lines (default: 50)"
    )
    logs_parser.add_argument("--stdout", action="store_true", help="Show only stdout")
    logs_parser.add_argument("--stderr", action="store_true", help="Show only stderr")
    logs_parser.add_argument(
        "--watcher", action="store_true",
        help="Show the watcher log (restart events, errors)",
    )

    # follow
    follow_parser = subparsers.add_parser(
        "follow", aliases=["tail"], help="Follow logs (shorthand for logs -f)"
    )
    follow_parser.add_argument("name", help="Name of the process")
    follow_parser.add_argument(
        "-n", "--lines", type=int, default=10, help="Initial lines (default: 10)"
    )
    follow_parser.add_argument("--stdout", action="store_true", help="Show only stdout")
    follow_parser.add_argument("--stderr", action="store_true", help="Show only stderr")

    # clean
    subparsers.add_parser("clean", help="Remove stopped processes from list")

    # resurrect
    subparsers.add_parser("resurrect", help="Restart all processes that were running before shutdown")

    # delete
    delete_parser = subparsers.add_parser(
        "delete", aliases=["rm"], help="Delete a process completely"
    )
    delete_parser.add_argument("name", help="Name of the process")
    delete_parser.add_argument(
        "-y", "--yes", action="store_true", help="Skip confirmation"
    )
    delete_parser.add_argument(
        "--keep-logs", dest="keep_logs", action="store_true",
        help="Preserve out/err/watcher log files after delete",
    )

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return 0

    handlers = {
        "start": cmd_start,
        "open": cmd_start,
        "stop": cmd_stop,
        "kill": cmd_stop,
        "restart": cmd_restart,
        "restart-stopped": cmd_restart_stopped,
        "restart-last": cmd_restart_last,
        "status": cmd_status,
        "ls": cmd_status,
        "list": cmd_status,
        "logs": cmd_logs,
        "follow": cmd_logs_follow,
        "tail": cmd_logs_follow,
        "clean": cmd_clean,
        "resurrect": cmd_resurrect,
        "delete": cmd_delete,
        "rm": cmd_delete,
        "watch": cmd_watch,
        "unwatch": cmd_unwatch,
    }

    handler = handlers.get(args.command)
    if handler:
        return handler(args)

    parser.print_help()
    return 0


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