#!/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


# Proc state files + log paths live in bgo_cli._state. Re-export so the
# script's own namespace keeps the historical bindings; the test fixture
# also patches the source module to keep them in sync.
from bgo_cli._state import (  # noqa: E402
    BGO_DIR,
    PROCS_DIR,
    LOGS_DIR,
    init_dirs,
    proc_file,
    log_path,
    watcher_log_path,
    watcher_log,
    load_proc,
    save_proc,
    delete_proc,
    load_all_procs,
)

# Terminal capability detection, ANSI colors, and glyph sets all live
# in bgo_cli._term — see that module for the resolution cascade. We
# re-bind every public symbol into the script's namespace so the test
# fixture (which loads this file as a module) and any external scripts
# importing from the bare ``bgo`` path keep working unchanged.
from bgo_cli._term import (  # noqa: E402 — module-level import after constants is fine
    COLORS,
    ANSI_RE,
    color,
    strip_ansi,
    truncate,
    LEVEL_PLAIN,
    LEVEL_NORMAL,
    LEVEL_FANCY,
    _detect_table_level,
    GLYPHS,
    glyphs,
)


# Process inspection + lifecycle helpers live in bgo_cli._proc.
from bgo_cli._proc import (  # noqa: E402
    _is_zombie,
    is_running,
    _BLANK_PINFO,
    get_process_info,
    get_process_info_batch,
    _looks_like_command,
    derive_name,
    resolve_command,
    kill_process,
)


# Watcher loop + watch-config helpers live in bgo_cli._watcher.
from bgo_cli._watcher import (  # noqa: E402
    WATCH_DEFAULTS,
    BACKOFF_SCHEDULE,
    TAIL_BYTES,
    _notify_errored,
    _resolve_watch_block,
    _default_watch_config,
    _spawn_watcher,
    _kill_watcher,
    _tail_stderr,
    _restart_proc_inplace,
    cmd_watcher_loop,
)


# --- Commands ---


def cmd_start(args: argparse.Namespace) -> int:
    """Start a process in the background.

    Reads the command from ``args.cmd`` (REMAINDER form) or
    ``args.command`` (programmatic call). Refuses to overwrite a
    proc that's already running under the same name. Writes the
    initial state, spawns the watcher if requested, prints a brief
    confirmation line plus log-path hint.

    :returns: 0 on success, 1 on any failure (no command supplied,
              duplicate name, FileNotFoundError, etc).
    """
    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: argparse.Namespace) -> int:
    """Stop a running process.

    Marks the state as stopped BEFORE issuing the kill so a watching
    sidecar sees the flag and exits without trying to restart. Kills
    the watcher first, then the target, escalating to SIGKILL on
    timeout unless ``--force`` was passed (which goes straight to it).

    :returns: 0 if the process is stopped (or was already), 1 on
              kill failure.
    """
    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: argparse.Namespace) -> int:
    """Restart a process; clears errored state and re-spawns the watcher.

    Restart counters are preserved by default so chronic crashers stay
    visible across manual restarts. Pass ``--reset-counters`` to wipe
    the count to zero explicitly. The watcher (if any) is killed
    before the target so it doesn't observe the death and race us.

    :returns: ``cmd_start``'s return code (0 on success, 1 otherwise).
    """
    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: argparse.Namespace) -> int:
    """Attach a watcher to an existing running process.

    If ``--reset`` is set OR the proc has no prior watch block, a
    fresh default config is built from overrides. Otherwise the
    prior config is carried forward with overrides merged in,
    errored fields cleared, and ``enabled=True`` re-asserted.

    :returns: 0 on success, 1 if the proc is unknown / not running
              / the watcher failed to spawn.
    """
    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: argparse.Namespace) -> int:
    """Detach the watcher from a process. The target keeps running.

    :returns: 0 always (no-op when nothing is being watched).
    """
    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() -> None:
    """Clear the terminal and home the cursor. TTY-only; no-op otherwise."""
    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) -> None:
    """Render the status table for a snapshot at the given rendering level.

    Columns: NAME / STATUS / PID / CPU / MEM / UPTIME / WATCH /
    COMMAND. The COMMAND column flexes to fill the terminal width;
    the rest have fixed widths sized to fit their longest expected
    value. Fancy mode draws Unicode box-drawing borders around the
    table; normal mode prints ASCII rules instead.
    """
    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: argparse.Namespace) -> int:
    """Show status of all processes (or detail for one).

    Modes (mutually exclusive, evaluated in order):

    1. ``--json``        — emit JSON for ``args.name`` or all procs.
    2. ``-w`` / ``--watch`` — full-screen auto-refresh until Ctrl-C.
    3. ``args.name`` set  — detailed single-proc view.
    4. default           — one-shot table for every registered proc.

    :returns: 0 on success, 1 if ``--json`` was requested for a
              specific name that doesn't exist.
    """
    # 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) -> None:
    """Print the detail card for a single process state ``info`` dict.

    Lists status / PID / command / cwd / start time / live CPU+MEM
    (when running) / log sizes / watch config + restart counters /
    errored reason + last stderr tail (when errored).
    """
    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: argparse.Namespace) -> int:
    """Show logs for a process.

    Stream selection: ``--watcher`` -> watcher event log;
    ``--stderr`` -> stderr; ``--stdout`` (or default) -> stdout.
    With ``--follow`` / ``-f``, exec ``tail -f`` for live streaming;
    otherwise tail the last ``-n`` lines (default 50) and exit.

    :returns: 0 on success, 1 if the proc is unknown or the log
              file disappeared between stat and open.
    """
    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: argparse.Namespace) -> int:
    """Follow logs for a process. Shorthand for ``bgo logs <name> -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: argparse.Namespace) -> int:
    """Drop every stopped proc from state (keeps log files in place)."""
    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: argparse.Namespace) -> int:
    """Restart every proc whose last recorded status was ``running``.

    Used by the systemd / launchd autostart entry to bring back
    managed procs after a reboot or session login. Procs that the
    user manually stopped (status=stopped) are left alone.

    :returns: 0 if all candidates restarted (or none existed), 1 if
              any failed to start.
    """
    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: argparse.Namespace) -> int:
    """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: argparse.Namespace) -> int:
    """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: argparse.Namespace) -> int:
    """Delete a process from state (stop it first if running).

    Drops log files too unless ``--keep-logs`` is passed. Asks for
    confirmation when stopping a running proc, unless ``-y``.

    :returns: 0 on success, 1 if the proc is unknown.
    """
    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


# --- Optional feature handlers ---


def cmd_autostart(args: argparse.Namespace) -> int:
    """Install / uninstall / inspect login autostart for ``bgo resurrect``.

    Subcommands (``args.action``):
        install   — write the unit and enable it.
        uninstall — disable and remove the unit.
        status    — print install state as table or JSON.

    The ``--tray`` flag pivots all three actions to the tray entry
    instead of the resurrect entry. Both can be installed; they're
    independent.
    """
    try:
        from bgo_cli import _autostart
    except ImportError as exc:
        print(f"{color('red', '❌')} autostart module unavailable: {exc}")
        return 1

    target = "tray" if getattr(args, "tray", False) else "resurrect"
    action = args.action

    if action == "status":
        s = _autostart.status()
        tray_only = getattr(args, "tray", False)
        if getattr(args, "json", False):
            if tray_only:
                print(json.dumps({
                    "backend": s.get("backend"),
                    "tray": s.get("tray"),
                    "tray_path": s.get("tray_path"),
                }, indent=2))
            else:
                print(json.dumps(s, indent=2))
            return 0
        if s["backend"] is None:
            print(f"{color('yellow', '⚠')}  autostart not supported on this platform")
            return 1
        print(f"backend:   {s['backend']}")
        keys = ("tray",) if tray_only else ("resurrect", "tray")
        for key in keys:
            mark = color("green", "✓") if s[key] else color("gray", "·")
            path = s.get(f"{key}_path") or "(not installed)"
            print(f"{mark} {key:<10} {path}")
        return 0

    if action == "install":
        ok, msg = _autostart.install(target)
        if not ok:
            print(f"{color('red', '❌')} install failed: {msg}")
            return 1
        print(f"{color('green', '✅')} installed {target} autostart at {msg}")
        if target == "resurrect" and _autostart.detect_backend() == "systemd-user":
            print(f"   {color('gray', 'hint: run')} "
                  f"loginctl enable-linger $USER "
                  f"{color('gray', 'to run before login')}")
        return 0

    if action == "uninstall":
        ok, msg = _autostart.uninstall(target)
        if not ok:
            print(f"{color('red', '❌')} uninstall failed: {msg}")
            return 1
        print(f"{color('green', '✅')} uninstalled {target} autostart")
        return 0

    print(f"{color('red', '❌')} unknown autostart action: {action}")
    return 1


def cmd_tray(args: argparse.Namespace) -> int:
    """Launch the system-tray icon (optional extra ``bgo-cli[tray]``).

    If PySide6 is not importable, delegates to
    ``_tray_install.ensure_installed`` which detects the install
    context (uv tool / pipx / pip) and offers to inject the deps.
    """
    try:
        from bgo_cli import _tray
    except ImportError:
        try:
            from bgo_cli import _tray_install
        except ImportError as exc:
            print(f"{color('red', '❌')} tray module unavailable: {exc}")
            return 1
        ok = _tray_install.ensure_installed(
            auto=getattr(args, "auto_install", False)
            or os.environ.get("BGO_TRAY_AUTOINSTALL") == "1"
        )
        if not ok:
            return 1
        # Re-exec via the installed entrypoint so the freshly-injected
        # deps are picked up by a clean interpreter. We prefer
        # shutil.which("bgo") over sys.argv[0] because argv[0] may be
        # the bare script path ("./bgo") or "python", neither of which
        # have the new PySide6 on their import path after a uv/pipx
        # injection completed. Pass the original argv[1:] through so
        # flags like --poll survive the re-exec.
        bgo_bin = shutil.which("bgo") or sys.argv[0]
        os.execvp(bgo_bin, [bgo_bin, *sys.argv[1:]])
        return 0  # unreachable

    return _tray.run(poll_seconds=getattr(args, "poll", None))


# --- CLI ---


def main() -> int:
    """Entry point. Returns the process exit code for ``sys.exit``."""
    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", "autostart", "tray",
        "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",
    )

    # autostart
    autostart_parser = subparsers.add_parser(
        "autostart",
        help="Install login autostart for `bgo resurrect` (and optionally tray)",
    )
    autostart_sub = autostart_parser.add_subparsers(
        dest="action", required=True, help="Autostart action"
    )
    for act in ("install", "uninstall"):
        sp = autostart_sub.add_parser(act, help=f"{act} the autostart entry")
        sp.add_argument(
            "--tray", action="store_true",
            help="Target the tray autostart entry instead of resurrect",
        )
    sp_status = autostart_sub.add_parser("status", help="Show install state")
    sp_status.add_argument(
        "--tray", action="store_true",
        help="Show status for the tray entry only (default: both)",
    )
    sp_status.add_argument(
        "--json", action="store_true", help="Machine-readable output",
    )

    # tray (optional extra)
    tray_parser = subparsers.add_parser(
        "tray", help="Launch the system-tray icon (requires bgo-cli[tray])"
    )
    tray_parser.add_argument(
        "--poll", type=int, default=None,
        help="State poll interval in seconds (default: 3 or $BGO_TRAY_POLL)",
    )
    tray_parser.add_argument(
        "--auto-install", dest="auto_install", action="store_true",
        help="Install PySide6 without confirmation if missing",
    )

    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,
        "autostart": cmd_autostart,
        "tray": cmd_tray,
    }

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

    parser.print_help()
    return 0


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