"""
OpenCastor CLI entry point.
Provides a unified command interface for the OpenCastor runtime.

Usage:
    castor run      --config robot.rcan.yaml          # Run the robot
    castor gateway  --config robot.rcan.yaml          # Start the API gateway
    castor mcp      --host 127.0.0.1 --port 8765      # Start MCP tool server
    castor wizard                                      # Interactive setup
    castor dashboard                                   # Launch CastorDash
    castor status                                      # Check provider/channel readiness
    castor doctor                                      # System health checks
    castor demo                                        # Simulated demo (no hardware)
    castor test-hardware --config robot.rcan.yaml      # Test motors individually
    castor calibrate --config robot.rcan.yaml          # Interactive calibration
    castor logs                                        # View logs
    castor backup                                      # Back up configs
    castor restore backup.tar.gz                       # Restore configs
    castor migrate --config robot.rcan.yaml            # Migrate RCAN config
    castor upgrade                                     # Self-update + doctor
    castor install-service --config robot.rcan.yaml    # Generate systemd unit
    castor shell --config robot.rcan.yaml              # Interactive command shell
    castor watch --gateway http://127.0.0.1:8000       # Live telemetry dashboard
    castor fix                                         # Auto-fix common issues
    castor repl --config robot.rcan.yaml               # Python REPL with robot objects
    castor record --config robot.rcan.yaml             # Record a session
    castor replay session.jsonl                        # Replay a recorded session
    castor benchmark --config robot.rcan.yaml          # Performance profiling
    castor lint --config robot.rcan.yaml               # Deep config validation
    castor validate --config bot.rcan.yaml             # RCAN conformance check
    castor improve --enable                            # Enable self-improving loop
    castor improve --disable                           # Disable self-improving loop
    castor improve --episodes 10                       # Analyze last 10 episodes
    castor improve --status                            # Improvement history
    castor learn                                       # Interactive tutorial
    castor fleet status                                # Multi-robot status
    castor export --config robot.rcan.yaml             # Export config bundle
    castor agents list                                 # List active agents
    castor agents status                               # Agent health report
    castor agents spawn --name observer                # Spawn an agent
    castor init     [--output robot.rcan.yaml]         # Scaffold starter config (non-interactive)
"""

import argparse
import hashlib
import hmac
import os
import sys
import traceback

# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------


def _launch_with_dashboard(args) -> None:
    """Re-launch inside a tmux session with the dashboard alongside the robot."""
    import shutil

    if not shutil.which("tmux"):
        print("  ⚠️  tmux not found. Install it with: sudo apt install tmux")
        print("  Falling back to plain run...\n")
        return  # caller will continue without dashboard

    from castor.dashboard_tui import SESSION_NAME, kill_existing_session, launch_dashboard

    # Kill any stale session
    kill_existing_session()

    layout = getattr(args, "layout", "full")
    config = args.config
    simulate_flag = "--simulate" if getattr(args, "simulate", False) else ""

    # Build the castor run command (without --dashboard to avoid recursion)
    run_cmd = f"castor run --config {config} {simulate_flag}".strip()

    # Launch dashboard session; the "logs" pane will run the robot
    print(f"\n  🚀 Launching OpenCastor dashboard (layout: {layout})...")
    print(f"     Robot command: {run_cmd}")
    print(f"     Attach with:   tmux attach -t {SESSION_NAME}\n")

    launch_dashboard(
        config_path=config,
        layout_name=layout,
        simulate=getattr(args, "simulate", False),
        run_command=run_cmd,
    )
    raise SystemExit(0)


def cmd_run(args) -> None:
    """Run the main perception-action loop."""
    # --dashboard: hand off to tmux session before doing anything else
    if getattr(args, "dashboard", False):
        _launch_with_dashboard(args)
        # If tmux not available, _launch_with_dashboard returns (falls through)

    config_path = args.config

    # Guided first-run: if no config exists, offer to run the wizard
    if not os.path.exists(config_path):
        import glob

        rcan_files = glob.glob("*.rcan.yaml")
        if rcan_files:
            print(f"\n  Config '{config_path}' not found, but found: {', '.join(rcan_files)}")
            print(f"  Try: castor run --config {rcan_files[0]}\n")
        else:
            print(f"\n  No config file found ({config_path}).")
            print("  Would you like to run the setup wizard first?\n")
            try:
                answer = input("  Run wizard? [Y/n]: ").strip().lower()
            except (EOFError, KeyboardInterrupt):
                answer = "n"
            if answer != "n":
                from castor.wizard import main as run_wizard

                sys.argv = ["castor.wizard"]
                run_wizard()
                return
            else:
                print("  Exiting. Create a config with: castor wizard\n")
                return

    # --behavior: load and run a behavior script, skip the perception loop
    behavior_path = getattr(args, "behavior", None)
    if behavior_path:
        from castor.behaviors import BehaviorRunner

        runner = BehaviorRunner(config={})
        behavior = runner.load(behavior_path)
        runner.run(behavior)
        return

    from castor.main import main as run_main

    sys.argv = ["castor.main", "--config", config_path]
    if args.simulate:
        sys.argv.append("--simulate")
    run_main()


def cmd_gateway(args) -> None:
    """Start the FastAPI gateway server."""
    from castor.api import main as run_gateway

    sys.argv = [
        "castor.api",
        "--config",
        args.config,
        "--host",
        args.host,
        "--port",
        str(args.port),
    ]
    run_gateway()


def cmd_mcp(args) -> None:
    """Start the MCP server."""
    from castor.mcp_server import main as run_mcp

    run_mcp(host=args.host, port=args.port)


def cmd_wizard(args) -> None:
    """Run the interactive setup wizard."""
    # Web-based wizard
    if getattr(args, "web", False):
        from castor.web_wizard import launch_web_wizard

        launch_web_wizard(port=getattr(args, "web_port", 8765))
        return

    from castor.wizard import main as run_wizard

    # Forward CLI flags to the wizard's own argparse
    wizard_args = ["castor.wizard"]
    if getattr(args, "simple", False):
        wizard_args.append("--simple")
    if getattr(args, "accept_risk", False):
        wizard_args.append("--accept-risk")
    sys.argv = wizard_args
    run_wizard()


def cmd_init(args) -> None:
    """Scaffold a minimal starter robot.rcan.yaml without an interactive TTY."""
    from castor.init_config import generate_config, write_config

    if getattr(args, "print", False):
        print(generate_config(robot_name=args.name))
        return

    try:
        path = write_config(args.output, robot_name=args.name, overwrite=args.overwrite)
        print(f"✓ Config written to: {path}")
        print(f"\nNext: edit {path}, then run: castor run --config {path}")
    except FileExistsError as e:
        print(f"✗ {e}", file=sys.stderr)
        raise SystemExit(1) from e


def _auto_detect_config(specified: str = "robot.rcan.yaml") -> str:
    """Return the RCAN config to use.

    Priority:
      1. Explicitly specified path (if it exists)
      2. Single *.rcan.yaml in cwd
      3. Fallback to the specified path (let caller handle missing file)
    """
    import glob as _glob

    if os.path.exists(specified):
        return specified
    matches = sorted(_glob.glob("*.rcan.yaml"))
    if len(matches) == 1:
        return matches[0]
    if len(matches) > 1:
        print(f"  Multiple configs found: {matches}")
        print(f"  Using: {matches[0]}  (pass --config to choose)")
        return matches[0]
    return specified


def cmd_dashboard(args) -> None:
    """Launch the tmux terminal dashboard — starts the robot immediately."""
    from castor.dashboard_tui import kill_existing_session, launch_dashboard

    if getattr(args, "kill", False):
        kill_existing_session()
        print("  Dashboard session killed.")
        return

    config = _auto_detect_config(getattr(args, "config", "robot.rcan.yaml"))
    layout = getattr(args, "layout", "full")
    simulate = getattr(args, "simulate", False)
    launch_dashboard(config, layout, simulate)


def cmd_dashboard_tui(args) -> None:
    """Launch the tmux terminal dashboard."""
    from castor.dashboard_tui import kill_existing_session, launch_dashboard

    if args.kill:
        kill_existing_session()
        print("  Dashboard session killed.")
        return

    config = _auto_detect_config(args.config)
    launch_dashboard(config, args.layout, args.simulate)


def cmd_token(args) -> None:
    """Issue a JWT token for RCAN API access."""
    from castor.auth import load_dotenv_if_available
    from castor.secret_provider import get_jwt_secret_provider

    load_dotenv_if_available()

    import os

    provider = get_jwt_secret_provider()
    provider.invalidate()
    if getattr(args, "rotate", False):
        bundle = provider.rotate(
            new_secret=getattr(args, "new_secret", None),
            new_kid=getattr(args, "kid", None),
        )
        print("\n  JWT key rotated\n")
        print(f"  active_kid:   {bundle.active.kid}")
        print(f"  previous_kid: {bundle.previous.kid if bundle.previous else 'none'}\n")
        return

    if getattr(args, "kid", None):
        os.environ["OPENCASTOR_JWT_KID"] = args.kid
        provider.invalidate()

    bundle = provider.get_bundle()
    jwt_secret = bundle.active.secret
    if not jwt_secret or bundle.source == "ephemeral":
        print("Error: OPENCASTOR_JWT_SECRET is not set in environment or .env file.")
        print("Generate one with: openssl rand -hex 32")
        raise SystemExit(1)

    try:
        from castor.rcan.jwt_auth import RCANTokenManager
        from castor.rcan.rbac import RCANRole, resolve_role_name

        role_name = resolve_role_name(args.role)
        role = RCANRole[role_name]
        scopes = args.scope.split(",") if args.scope else None

        ruri = os.getenv("OPENCASTOR_RURI", "rcan://opencastor.unknown.00000000")
        mgr = RCANTokenManager(secret=jwt_secret, issuer=ruri)
        token = mgr.issue(
            subject=args.subject or "cli-user",
            role=role,
            scopes=scopes,
            ttl_seconds=int(args.ttl) * 3600,
        )
        print(f"\n  RCAN JWT Token (role={role.name}, ttl={args.ttl}h, kid={bundle.active.kid})\n")
        print(f"  {token}\n")
    except ImportError as exc:
        print("Error: PyJWT is not installed. Install with: pip install PyJWT")
        raise SystemExit(1) from exc
    except KeyError as exc:
        print(f"Error: Invalid role '{args.role}'. Valid: GUEST, USER, LEASEE, OWNER, CREATOR")
        raise SystemExit(1) from exc


def cmd_discover(args) -> None:
    """Discover RCAN peers on the local network."""
    print("\n  Scanning for RCAN peers (5 seconds)...\n")

    try:
        from castor.rcan.mdns import RCANServiceBrowser
    except ImportError as exc:
        print("  Error: zeroconf is not installed.")
        print("  Install with: pip install opencastor[rcan]")
        raise SystemExit(1) from exc

    import time

    found = []

    def on_found(peer):
        found.append(peer)

    browser = RCANServiceBrowser(on_found=on_found)
    browser.start()
    time.sleep(float(args.timeout))
    browser.stop()

    if not found:
        print("  No RCAN peers found on the local network.\n")
    else:
        print(f"  Found {len(found)} peer(s):\n")
        for peer in found:
            print(f"    RURI:    {peer.get('ruri', '?')}")
            print(f"    Name:    {peer.get('robot_name', '?')}")
            print(f"    Model:   {peer.get('model', '?')}")
            print(f"    Caps:    {', '.join(peer.get('capabilities', []))}")
            print(f"    Address: {', '.join(peer.get('addresses', []))}:{peer.get('port', '?')}")
            print(f"    Status:  {peer.get('status', '?')}")
            print()


def cmd_snapshot(args) -> None:
    """Take, list, or show diagnostic snapshots (Issue #348).

    Sub-commands:
        castor snapshot take           — Capture a snapshot immediately.
        castor snapshot latest         — Show the most recent snapshot.
        castor snapshot history [N]    — Show the last N snapshots (default 5).

    Examples::

        castor snapshot take
        castor snapshot latest
        castor snapshot history 10
    """
    import json as _json

    sub = getattr(args, "snapshot_action", None) or (
        args.snapshot_args[0] if getattr(args, "snapshot_args", []) else "latest"
    )

    from castor.snapshot import get_manager

    mgr = get_manager()

    if sub == "take":
        snap = mgr.take()
        print("\n  Snapshot taken:\n")
        print("  " + _json.dumps(snap, indent=2, default=str).replace("\n", "\n  "))
        print()
    elif sub == "history":
        limit = 5
        extra = getattr(args, "snapshot_args", [])
        if extra and len(extra) > 1:
            try:
                limit = int(extra[1])
            except ValueError:
                pass
        history = mgr.history(limit=limit)
        print(f"\n  Last {len(history)} snapshots:\n")
        for i, snap in enumerate(history):
            ts = snap.get("timestamp", "?")
            cpu = snap.get("system", {}).get("cpu_percent", "?")
            print(f"  [{i + 1}] ts={ts} cpu={cpu}%")
        print()
    else:  # latest
        snap = mgr.latest()
        if snap is None:
            print("\n  No snapshots available. Run `castor snapshot take` first.\n")
        else:
            print("\n  Latest snapshot:\n")
            print("  " + _json.dumps(snap, indent=2, default=str).replace("\n", "\n  "))
            print()


def cmd_fleet(args) -> None:
    """Handle castor fleet subcommands."""
    import json as _json

    import yaml

    fleet_cmd = getattr(args, "fleet_cmd", None)
    if fleet_cmd is None:
        from castor.fleet import fleet_status

        timeout_raw = getattr(args, "timeout", "5")
        fleet_status(timeout=float(timeout_raw))
        return

    config_path = getattr(args, "config", None) or _find_default_config()
    config: dict = {}
    if config_path:
        try:
            with open(config_path) as f:
                config = yaml.safe_load(f) or {}
        except Exception as exc:
            print(f"⚠️  Could not load config: {exc}")

    from castor.fleet.group_policy import FleetManager

    fm = FleetManager.from_config(config)

    if fleet_cmd == "list":
        print(fm.summary())
        for g in fm.list_groups():
            if g.robots:
                print(f"\n  {g.name}:")
                for rrn in g.robots:
                    print(f"    • {rrn}")
                if g.policy:
                    keys = list(g.policy.keys())
                    print(f"    policy keys: {', '.join(keys)}")

    elif fleet_cmd == "resolve":
        rrn = args.rrn
        merged = fm.resolve_config(rrn, config)
        groups = fm.get_robot_groups(rrn)
        if getattr(args, "output_json", False):
            print(_json.dumps(merged, indent=2))
        else:
            group_names = [g.name for g in groups] or ["(none)"]
            print(f"\n🤖 {rrn}")
            print(f"  Groups: {', '.join(group_names)}")
            agent = merged.get("agent", {})
            print(f"  Provider: {agent.get('provider', '?')} / {agent.get('model', '?')}")
            cg = agent.get("confidence_gates", [])
            if cg:
                print(f"  Confidence gate: {cg[0].get('threshold', '?')}")
            hitl = agent.get("hitl_gates", [])
            if hitl:
                print(f"  HiTL gates: {len(hitl)}")

    elif fleet_cmd == "status":
        all_rrns: list[str] = []
        for g in fm.list_groups():
            all_rrns.extend(g.robots)
        all_rrns = sorted(set(all_rrns))
        if not all_rrns:
            print("No robots defined in any group.")
            return
        print(f"\n{'RRN':<20}  Groups")
        print("-" * 50)
        for rrn in all_rrns:
            groups = fm.get_robot_groups(rrn)
            print(f"{rrn:<20}  {', '.join(g.name for g in groups) or '(none)'}")


def _find_default_config() -> str | None:
    """Look for a .rcan.yaml in the current directory."""
    import glob

    candidates = glob.glob("*.rcan.yaml") + glob.glob("*.rcan.yml")
    return candidates[0] if candidates else None


def cmd_verification(args) -> None:
    """Check verification tier for an RRN."""
    from castor.rcan.verification import get_tier_from_rrn

    status = get_tier_from_rrn(args.rrn)
    if status:
        print(f"{status.display} — {args.rrn}")
        if status.evidence_url:
            print(f"  Evidence: {status.evidence_url}")
        if status.verified_at:
            print(f"  Verified: {status.verified_at}")
    else:
        print(f"⬜ Unknown — could not fetch tier for {args.rrn}")


def cmd_inspect(args) -> None:
    """Query a robot's live RCAN profile, safety state, and telemetry."""
    import json as _json

    output: dict = {}
    rrn = args.rrn

    # --- Registry lookup ---
    registry_data: dict = {}
    if rrn:
        try:
            import asyncio

            from rcan.registry import RegistryClient

            async def _lookup():
                async with RegistryClient() as c:
                    entry = await c.get_robot(rrn)
                    return entry.to_dict()

            registry_data = asyncio.run(_lookup())
            output["registry"] = registry_data
        except Exception as exc:
            output["registry"] = {"error": str(exc), "rrn": rrn}

    # --- Local config ---
    config_path = args.config
    config_data: dict = {}
    if config_path:
        try:
            import yaml

            with open(config_path) as f:
                config_data = yaml.safe_load(f) or {}
            meta = config_data.get("metadata", {})
            output["config"] = {
                "file": config_path,
                "robot_name": meta.get("robot_name", ""),
                "manufacturer": meta.get("manufacturer", ""),
                "model": meta.get("model", ""),
                "version": meta.get("version", ""),
                "rrn": meta.get("rrn", ""),
                "rcan_uri": meta.get("rcan_uri", ""),
                "rcan_version": config_data.get("rcan_version", ""),
                "provider": config_data.get("agent", {}).get("provider", ""),
                "ai_model": config_data.get("agent", {}).get("model", ""),
            }
        except Exception as exc:
            output["config"] = {"error": str(exc)}

    # --- Gateway live status ---
    gateway_url = args.gateway or (config_data or {}).get("gateway", {}).get("url", "")
    if not gateway_url:
        # Try default local gateway
        gateway_url = "http://localhost:8080"

    try:
        import os
        import urllib.request

        token = os.environ.get("OPENCASTOR_API_TOKEN", "")
        req = urllib.request.Request(
            f"{gateway_url}/api/status",
            headers={"Authorization": f"Bearer {token}"} if token else {},
        )
        with urllib.request.urlopen(req, timeout=3) as resp:
            status_data = _json.loads(resp.read())
        output["live"] = {
            "gateway": gateway_url,
            "uptime_s": status_data.get("uptime_s"),
            "brain": status_data.get("brain"),
            "driver": status_data.get("driver"),
            "channels": status_data.get("channels_active", []),
            "safety_state": status_data.get("safety", {}).get("state", "unknown"),
            "ruri": status_data.get("ruri"),
        }
    except Exception as exc:
        output["live"] = {"gateway": gateway_url, "error": str(exc)}

    # --- Commitment chain ---
    try:
        from castor.rcan.commitment_chain import get_commitment_chain

        cc = get_commitment_chain()
        valid, count, errors = cc.verify_log()
        output["commitment_chain"] = {
            "records": count,
            "valid": valid,
            "errors": errors[:3] if errors else [],
        }
    except Exception:
        pass

    # --- Compliance ---
    if config_data:
        try:
            from castor.rcan.sdk_bridge import check_compliance

            issues = check_compliance(config_data)
            l1_ok = not any(i.startswith("L1") for i in issues)
            l2_ok = l1_ok and not any(i.startswith("L2") for i in issues)
            l3_ok = l2_ok and not any(i.startswith("L3") for i in issues)
            output["compliance"] = {
                "level": "L3" if l3_ok else "L2" if l2_ok else "L1" if l1_ok else "FAIL",
                "issues": issues,
            }
        except Exception:
            pass

    if getattr(args, "output_json", False):
        print(_json.dumps(output, indent=2))
        return

    # --- Human-readable output ---
    try:
        from rich.console import Console

        con = Console()
        HAS_RICH = True
    except ImportError:
        con = None
        HAS_RICH = False

    def _pr(text, **kw):
        if HAS_RICH and con:
            con.print(text, **kw)
        else:
            import re

            print(re.sub(r"\[/?[a-z_ ]+\]", "", text))

    _pr("\n🤖 [bold]castor inspect[/bold]" + (f" {rrn}" if rrn else "") + "\n")

    if "registry" in output:
        reg = output["registry"]
        _pr("[bold]Registry (rcan.dev)[/bold]")
        if "error" in reg:
            _pr(f"  ⚠️  {reg['error']}", style="yellow")
        else:
            _pr(f"  RRN:    {reg.get('rrn', '?')}")
            _pr(f"  URI:    {reg.get('uri', reg.get('rcan_uri', '?'))}")
            _pr(f"  Tier:   {reg.get('verification_tier', '?')}")
            _pr(f"  View:   https://robotregistryfoundation.org/registry/{reg.get('rrn', '')}")

    if "config" in output:
        cfg = output["config"]
        _pr("\n[bold]Local Config[/bold]")
        if "error" in cfg:
            _pr(f"  ⚠️  {cfg['error']}", style="yellow")
        else:
            _pr(f"  File:     {cfg.get('file', '')}")
            _pr(
                f"  Robot:    {cfg.get('robot_name', '')} ({cfg.get('manufacturer', '')}/{cfg.get('model', '')} {cfg.get('version', '')})"
            )
            _pr(f"  Provider: {cfg.get('provider', '')} / {cfg.get('ai_model', '')}")
            if cfg.get("rrn"):
                _pr(f"  RRN:      {cfg['rrn']}")

    if "live" in output:
        live = output["live"]
        _pr("\n[bold]Live Gateway[/bold]")
        if "error" in live:
            _pr(f"  ⚠️  {live['gateway']}: {live['error']}", style="yellow")
        else:
            _pr(f"  Gateway:  {live.get('gateway', '')}")
            _pr(f"  Uptime:   {live.get('uptime_s', '?')}s")
            _pr(f"  Brain:    {'✅' if live.get('brain') else '❌'}")
            _pr(f"  Driver:   {'✅' if live.get('driver') else '❌'}")
            _pr(f"  Safety:   {live.get('safety_state', 'unknown')}")
            if live.get("channels"):
                _pr(f"  Channels: {', '.join(live['channels'])}")

    if "commitment_chain" in output:
        cc = output["commitment_chain"]
        valid_str = "✅" if cc.get("valid") else "⚠️"
        _pr(f"\n[bold]Commitment Chain[/bold]  {valid_str} {cc.get('records', 0)} records")

    if "compliance" in output:
        comp = output["compliance"]
        level = comp.get("level", "?")
        color = "green" if level == "L3" else "yellow" if level in ("L1", "L2") else "red"
        _pr(f"\n[bold]RCAN Compliance[/bold]  [{color}]{level}[/{color}]")
        for issue in comp.get("issues", [])[:5]:
            _pr(f"  ⚠️  {issue}", style="yellow")

    _pr("")


def cmd_node(args) -> None:
    """castor node — manage RCAN namespace delegation for this robot fleet."""
    node_cmd = getattr(args, "node_cmd", None)

    if node_cmd == "status":
        try:
            from castor.rcan.node_broadcaster import NodeBroadcaster, NodeConfig

            config = NodeConfig()
            broadcaster = NodeBroadcaster(config)
            manifest = broadcaster.get_manifest()
            print("RCAN Node Status:")
            print(f"  Type:         {manifest['node_type']}")
            print(f"  Operator:     {manifest['operator'] or '(not set)'}")
            print(f"  Namespace:    {manifest['namespace_prefix']}")
            print(f"  API Base:     {manifest['api_base'] or '(not set)'}")
            print(f"  Capabilities: {', '.join(manifest['capabilities'])}")
            print(f"  Last sync:    {manifest['last_sync']}")
        except Exception as e:
            print(f"❌ Error: {e}", file=__import__("sys").stderr)

    elif node_cmd == "manifest":
        import json

        try:
            from castor.rcan.node_broadcaster import NodeBroadcaster, NodeConfig

            config = NodeConfig()
            broadcaster = NodeBroadcaster(config)
            manifest = broadcaster.get_manifest()
            print(json.dumps(manifest, indent=2))
        except Exception as e:
            print(f"❌ Error: {e}", file=__import__("sys").stderr)

    elif node_cmd == "resolve":
        rrn = getattr(args, "rrn", None)
        if not rrn:
            print("  Usage: castor node resolve <RRN>")
            return
        try:
            from castor.rcan.node_resolver import NodeResolver

            resolver = NodeResolver()
            robot = resolver.resolve(rrn)
            source = "stale cache" if robot.stale else ("cache" if robot.from_cache else "live")
            print(f"✅ {rrn}")
            print(f"  Manufacturer: {robot.manufacturer}")
            print(f"  Model:        {robot.model}")
            print(f"  Attestation:  {robot.attestation}")
            print(f"  Resolved by:  {robot.resolved_by} ({source})")
        except Exception as e:
            print(f"❌ {e}", file=__import__("sys").stderr)
            raise SystemExit(1) from e

    elif node_cmd == "ping":
        try:
            from castor.rcan.node_resolver import NodeResolver

            resolver = NodeResolver()
            ok, latency_ms = resolver.is_reachable()
            if ok:
                print(f"✅ rcan.dev reachable ({latency_ms:.0f}ms)")
            else:
                print(f"❌ rcan.dev unreachable ({latency_ms:.0f}ms)")
                raise SystemExit(1)
        except ImportError as e:
            print(f"❌ {e}", file=__import__("sys").stderr)

    else:
        print("Usage: castor node <status|manifest|resolve|ping>")


def cmd_register(args) -> None:
    """Register this robot with rcan.dev and get a globally unique RRN."""
    import os
    import sys

    # Load config
    try:
        import yaml

        with open(args.config) as f:
            config = yaml.safe_load(f) or {}
    except FileNotFoundError:
        print(f"❌ Config not found: {args.config}")
        print("   Run: castor wizard  to create one first.")
        sys.exit(1)
    except Exception as e:
        print(f"❌ Failed to load config: {e}")
        sys.exit(1)

    meta = config.get("metadata", {})

    # --dry-run: validate and show what would be registered, no API calls
    if getattr(args, "dry_run", False):
        from castor.rcan.sdk_compat import validate_before_register

        ok, issues = validate_before_register(config, strict=False)
        for issue in issues:
            print(f"  ⚠️  {issue}", file=sys.stderr)

        print("\n🔍 Dry run — would register:")
        print(f"  Name:         {meta.get('robot_name', meta.get('name', 'unnamed'))}")
        print(f"  Manufacturer: {meta.get('manufacturer', 'unknown')}")
        print(f"  Model:        {meta.get('model', meta.get('robot_name', 'unknown'))}")
        print(f"  Version:      {meta.get('version', meta.get('firmware_version', 'v1'))}")
        print(f"  RCAN version: {config.get('rcan_version', 'unknown')}")
        print("  Registry:     https://rcan.dev/api/v1/robots")
        print("\n✅ Dry run complete — no API calls made.")
        return

    # Resolve fields (CLI args > config > prompts)
    manufacturer = args.manufacturer or meta.get("manufacturer") or ""
    model = args.model or meta.get("model") or ""
    version = args.version or meta.get("version") or meta.get("firmware_version") or "v1"
    device_id = args.device_id or meta.get("robot_uuid", "")[:8] or "unit-001"
    api_key = (
        args.api_key
        or os.environ.get("RCAN_API_KEY")
        or os.environ.get("OPENCASTOR_RCAN_KEY")
        or ""
    )

    if not manufacturer:
        manufacturer = input("Manufacturer / org name [opencastor]: ").strip() or "opencastor"
    if not model:
        robot_name = meta.get("robot_name", "robot")
        model = input(f"Model name [{robot_name}]: ").strip() or robot_name

    # Check for existing RRN
    existing_rrn = meta.get("rrn")
    if existing_rrn:
        print(f"\n⚠️  This robot already has an RRN: {existing_rrn}")
        print(f"   View at: https://robotregistryfoundation.org/registry/{existing_rrn}")
        ans = input("Re-register anyway? [y/N]: ").strip().lower()
        if ans not in ("y", "yes"):
            sys.exit(0)

    if not api_key:
        print("\n🔑 No RCAN API key found.")
        print("   Get a free key at: https://rcan.dev/register")
        print("   Or set: export RCAN_API_KEY=<your-key>\n")
        api_key = input("Paste API key (Enter to open browser instead): ").strip()
        if not api_key:
            try:
                import urllib.parse

                params = urllib.parse.urlencode(
                    {
                        "manufacturer": manufacturer,
                        "model": model,
                        "version": version,
                        "source": "castor-cli",
                    }
                )
                url = f"https://robotregistryfoundation.org/registry/register?{params}"
                import webbrowser

                webbrowser.open(url)
                print(f"\n   Opened: {url}")
            except Exception:
                print("\n   Register at: https://robotregistryfoundation.org/registry")
            sys.exit(0)

    # SDK compat pre-registration check
    from castor.rcan.sdk_compat import validate_before_register

    ok, issues = validate_before_register(config, strict=False)
    if issues:
        for issue in issues:
            print(f"  ⚠️  {issue}", file=sys.stderr)
    if not ok:
        print(
            "❌ Pre-registration validation failed. Fix issues above before registering.",
            file=sys.stderr,
        )
        sys.exit(1)

    # Register
    print(
        f"\n📡 Registering {manufacturer}/{model} {version} with rcan.dev...", end=" ", flush=True
    )
    try:
        import asyncio

        from rcan.registry import RegistryClient

        async def _register():
            async with RegistryClient(api_key=api_key) as client:
                return await client.register(
                    manufacturer=manufacturer,
                    model=model,
                    version=version,
                    device_id=device_id,
                    metadata={
                        "robot_name": meta.get("robot_name", model),
                        "rcan_version": "1.3",
                        "opencastor": True,
                    },
                )

        result = asyncio.run(_register())
        rrn = result.get("rrn", "")

        if rrn:
            print("✅")
            print("\n🤖 Robot registered!")
            print(f"   RRN:  {rrn}")
            print(f"   URI:  {result.get('uri', '')}")
            print(f"   View: https://robotregistryfoundation.org/registry/{rrn}\n")

            # Patch config file
            meta["rrn"] = rrn
            meta["rcan_uri"] = result.get("uri", "")
            config["metadata"] = meta
            with open(args.config, "w") as f:
                yaml.dump(config, f, sort_keys=False, default_flow_style=False)
            print(f"   ✓ RRN written to {args.config}")

            # Save API key for future use
            try:
                from castor.wizard import _write_env_var

                _write_env_var("RCAN_API_KEY", api_key)
                print("   ✓ API key saved to ~/.opencastor/env")
            except Exception:
                pass
        else:
            print("⚠️  Unexpected response — no RRN returned")
            sys.exit(1)

    except ImportError:
        print("❌  rcan package not installed. Run: pip install rcan")
        sys.exit(1)
    except Exception as e:
        print(f"❌  Registration failed: {e}")
        print("   Try manually at: https://robotregistryfoundation.org/registry")
        sys.exit(1)


def cmd_compliance(args) -> None:
    """Check RCAN v1.2 conformance for a robot config."""
    import json as _json
    import sys

    config_path = args.config
    output_json = getattr(args, "output_json", False)
    check_commitments = getattr(args, "commitments", False)
    fmt = getattr(args, "format", None)
    output_file = getattr(args, "output", None)

    # If --format is specified, use the new ComplianceReport path
    if fmt in ("json", "text"):
        import contextlib

        from castor.compliance import generate_report, print_report_json, print_report_text

        try:
            report = generate_report(config_path=config_path)
        except FileNotFoundError:
            print(f"❌ Config not found: {config_path}", file=sys.stderr)
            sys.exit(1)
        except Exception as exc:
            print(f"❌ Failed to generate report: {exc}", file=sys.stderr)
            sys.exit(1)

        ctx = open(output_file, "w") if output_file else contextlib.nullcontext(sys.stdout)
        with ctx as fh:
            if fmt == "json":
                print_report_json(report, file=fh)
            else:
                print_report_text(report, file=fh)
        sys.exit(0 if report.compliant else 1)

    # Load config
    try:
        import yaml

        with open(config_path) as f:
            config = yaml.safe_load(f)
    except FileNotFoundError:
        print(f"❌ Config not found: {config_path}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"❌ Failed to load config: {e}", file=sys.stderr)
        sys.exit(1)

    from castor.rcan.sdk_bridge import check_compliance

    issues = check_compliance(config)

    # Categorise by level
    l1 = [i for i in issues if i.startswith("L1")]
    l2 = [i for i in issues if i.startswith("L2")]
    l3 = [i for i in issues if i.startswith("L3")]

    l1_pass = len(l1) == 0
    l2_pass = l1_pass and len(l2) == 0
    l3_pass = l2_pass and len(l3) == 0

    # Commitment chain verification
    chain_ok: bool | None = None
    chain_count = 0
    chain_errors: list[str] = []
    if check_commitments:
        try:
            from castor.rcan.commitment_chain import get_commitment_chain

            cc = get_commitment_chain()
            chain_ok, chain_count, chain_errors = cc.verify_log()
        except Exception as e:
            chain_errors = [str(e)]
            chain_ok = False

    if output_json:
        result = {
            "config": config_path,
            "rcan_version": "1.3",
            "L1": {"pass": l1_pass, "issues": l1},
            "L2": {"pass": l2_pass, "issues": l2},
            "L3": {"pass": l3_pass, "issues": l3},
            "overall": "L3" if l3_pass else "L2" if l2_pass else "L1" if l1_pass else "FAIL",
        }
        if check_commitments:
            result["commitment_chain"] = {
                "valid": chain_ok,
                "records": chain_count,
                "errors": chain_errors,
            }
        print(_json.dumps(result, indent=2))
        sys.exit(0 if l1_pass else 1)

    # Human-readable output
    try:
        from rich.console import Console

        con = Console()
        HAS_RICH = True
    except ImportError:
        con = None
        HAS_RICH = False

    def _tick(ok):
        return "✅" if ok else "❌"

    def _pr(text, style=None):
        if HAS_RICH and con:
            con.print(text, style=style)
        else:
            print(text)

    _pr(f"\n🤖 [bold]RCAN Conformance Check[/bold] — {config_path}\n")

    for level, level_issues, level_pass in [
        ("L1", l1, l1_pass),
        ("L2", l2, l2_pass),
        ("L3", l3, l3_pass),
    ]:
        level_label = f"{_tick(level_pass)} [bold]{level}[/bold]"
        _pr(level_label)
        if level_issues:
            for issue in level_issues:
                _pr(f"   ⚠️  {issue}", style="yellow")
        else:
            _pr(f"   All {level} checks passed", style="green")

    if check_commitments:
        _pr(
            f"\n{_tick(chain_ok)} Commitment chain: {chain_count} records",
        )
        for err in chain_errors:
            _pr(f"   ⚠️  {err}", style="yellow")

    overall = "L3" if l3_pass else "L2" if l2_pass else "L1" if l1_pass else "FAIL"
    color = "green" if l3_pass else "yellow" if l2_pass else "red"
    _pr(f"\n[{color}]Result: {overall}[/{color}] ({len(issues)} issue(s))\n")
    sys.exit(0 if l1_pass else 1)


def cmd_logs(args) -> None:
    """castor logs — stream or tail the castor runtime log."""
    from castor.logs import view_logs

    view_logs(
        follow=getattr(args, "follow", False),
        level=getattr(args, "level", None),
        module=getattr(args, "module", None),
        lines=getattr(args, "lines", 50),
        no_color=getattr(args, "no_color", False),
    )


def cmd_memory(args) -> None:
    """castor memory — placeholder."""
    print("castor memory: coming soon.")


def cmd_migrate(args) -> None:
    """castor migrate — migrate a RCAN config to the latest schema version."""
    from castor.migrate import migrate_file

    config = getattr(args, "config", "robot.rcan.yaml")
    dry_run = getattr(args, "dry_run", False)
    migrate_file(config, dry_run=dry_run)


def cmd_network(args) -> None:
    """castor network — manage robot network exposure and status."""
    from castor.network import expose, network_status

    action = getattr(args, "action", None)
    config_path = getattr(args, "config", None)

    if action == "expose":
        mode = getattr(args, "mode", None) or "serve"
        port = getattr(args, "port", 8000)
        expose(mode=mode, port=port)
    else:
        network_status(config_path=config_path)


def cmd_plugin(args) -> None:
    """castor plugin — placeholder."""
    print("castor plugin: coming soon.")


def cmd_plugins(args) -> None:
    """castor plugins — list installed castor plugins."""
    from castor.plugins import list_plugins, load_plugins, print_plugins

    load_plugins()
    plugins = list_plugins()
    print_plugins(plugins)


def cmd_privacy(args) -> None:
    """castor privacy — display the data privacy policy for this robot config."""
    from castor.privacy import print_privacy_policy

    config_path = getattr(args, "config", None)
    config: dict = {}
    if config_path:
        try:
            import yaml

            with open(config_path) as f:
                config = yaml.safe_load(f) or {}
        except Exception:
            pass
    print_privacy_policy(config)


def cmd_profile(args) -> None:
    """castor profile — manage named personality/config profiles."""
    from castor.profiles import (
        list_profiles,
        print_profiles,
        remove_profile,
        save_profile,
        use_profile,
    )

    action = getattr(args, "action", "list")
    name = getattr(args, "name", None)
    config = getattr(args, "config", None)

    if action == "list":
        profiles = list_profiles()
        print_profiles(profiles)
    elif action == "save":
        if not name:
            print("  Usage: castor profile save --name <profile-name> --config <file>")
            return
        save_profile(name, config)
        print(f"  Profile '{name}' saved.")
    elif action == "use":
        if not name:
            print("  Usage: castor profile use --name <profile-name>")
            return
        try:
            use_profile(name)
            print(f"  Profile '{name}' activated.")
        except FileNotFoundError:
            print(f"  Profile '{name}' not found.")
    elif action == "remove":
        if not name:
            print("  Usage: castor profile remove --name <profile-name>")
            return
        ok = remove_profile(name)
        if ok:
            print(f"  Profile '{name}' removed.")
        else:
            print(f"  Profile '{name}' not found.")
    else:
        print("  Usage: castor profile <list|save|use|remove> [--name <name>]")


def cmd_quickstart(args) -> None:
    """castor quickstart — guided zero-to-running setup in two steps."""
    import subprocess
    import sys

    print("\n  🚀 OpenCastor QuickStart\n")
    print("  Step 1: Running setup wizard...")
    result = subprocess.run([sys.executable, "-m", "castor", "wizard"])
    if result.returncode != 0:
        print("\n  Wizard failed. Fix the issues above and re-run `castor quickstart`.")
        return

    print("\n  Step 2: Launching demo...")
    subprocess.run([sys.executable, "-m", "castor", "demo"])
    print("\n  QuickStart complete. Run `castor gateway` to start the full runtime.\n")


def cmd_record(args) -> None:
    """castor record — placeholder."""
    print("castor record: coming soon.")


def cmd_repl(args) -> None:
    """castor repl — interactive RCAN command REPL."""
    import os

    config_path = getattr(args, "config", "robot.rcan.yaml")
    if not os.path.exists(config_path):
        print(f"  Config not found: {config_path}")
        return
    from castor.repl import launch_repl

    launch_repl(config_path=config_path)


def cmd_replay(args) -> None:
    """castor replay — replay a recorded session from JSONL or via API."""
    recording = getattr(args, "recording", None)
    url = getattr(args, "url", None)

    if not recording and not url:
        print(
            "  Error: provide a recording file or --url for API replay.",
            file=__import__("sys").stderr,
        )
        print("  Usage: castor replay session.jsonl  OR  castor replay --url http://localhost:8000")
        raise SystemExit(1)

    if recording:
        from castor.record import replay_session

        execute = getattr(args, "execute", False)
        config_path = getattr(args, "config", None)
        replay_session(recording_path=recording, execute=execute, config_path=config_path)
    else:
        print(f"  API replay from {url} — use castor replay --url {url} --list to see recordings.")


def cmd_restore(args) -> None:
    """castor restore — restore a config backup archive."""
    from castor.backup import print_restore_summary, restore_backup

    archive = getattr(args, "archive", None)
    dry_run = getattr(args, "dry_run", False)
    if dry_run:
        restore_backup(archive, dry_run=True)
    else:
        files = restore_backup(archive)
        if files:
            print_restore_summary(files)


def cmd_safety(args) -> None:
    """castor safety — placeholder."""
    print("castor safety: coming soon.")


def cmd_scan(args) -> None:
    """castor scan — detect connected hardware and suggest a preset."""
    import json as _json

    from castor.hardware_detect import (
        detect_hardware,
        invalidate_hardware_cache,
        print_scan_results,
        suggest_extras,
        suggest_preset,
    )

    refresh = getattr(args, "refresh", False)
    json_out = getattr(args, "json", False)
    preset_only = getattr(args, "preset_only", False)

    if refresh:
        invalidate_hardware_cache()

    hw = detect_hardware()
    preset, confidence, reason = suggest_preset(hw)

    if json_out:
        print(
            _json.dumps(
                {
                    **hw,
                    "suggested_preset": {
                        "preset": preset,
                        "confidence": confidence,
                        "reason": reason,
                    },
                },
                default=str,
                indent=2,
            )
        )
        return

    if preset_only:
        print(f"{preset} ({confidence}): {reason}")
        return

    print_scan_results(hw)
    print(f"\n  Suggested preset: {preset} ({confidence})")
    print(f"  Reason: {reason}")

    extras = suggest_extras(hw)
    if extras:
        print("\n  Hardware detected — consider installing:")
        for pkg in extras:
            print(f"    pip install {pkg}")

    print("\n  Run 'castor wizard' to generate a full RCAN config.\n")


def cmd_schedule(args) -> None:
    """castor schedule — manage cron-based task scheduling for castor commands."""
    from castor.schedule import add_task, install_crontab, list_tasks, print_schedule, remove_task

    action = getattr(args, "action", "list")
    name = getattr(args, "name", None)
    task_command = getattr(args, "task_command", None)
    cron = getattr(args, "cron", None)

    if action == "list":
        tasks = list_tasks()
        print_schedule(tasks)
    elif action == "add":
        if not name or not task_command or not cron:
            print("  Usage: castor schedule add --name <name> --command <cmd> --cron <expr>")
            return
        task = add_task(name, task_command, cron)
        print(f"  Added scheduled task '{task.get('name', name)}'.")
    elif action == "remove":
        if not name:
            print("  Usage: castor schedule remove --name <name>")
            return
        ok = remove_task(name)
        if not ok:
            print(f"  Task '{name}' not found.")
    elif action == "install":
        install_crontab()
    else:
        print("  Usage: castor schedule <list|add|remove|install>")


def cmd_stop(args) -> None:
    """castor stop — gracefully stop the running gateway via PID file (#556)."""
    import signal as _signal
    from pathlib import Path

    pid_file = Path.home() / ".opencastor" / "gateway.pid"
    if not pid_file.exists():
        print("  No gateway.pid found — gateway may not be running.")
        return
    try:
        pid = int(pid_file.read_text().strip())
    except (ValueError, OSError) as exc:
        print(f"  Could not read PID file: {exc}")
        return
    try:
        os.kill(pid, _signal.SIGTERM)
        print(f"  Sent SIGTERM to gateway (pid {pid}).")
        pid_file.unlink(missing_ok=True)
    except ProcessLookupError:
        print(f"  Gateway (pid {pid}) is not running — cleaning up stale PID file.")
        pid_file.unlink(missing_ok=True)
    except PermissionError:
        print(f"  Permission denied when stopping pid {pid}.")


def cmd_search(args) -> None:
    """castor search — full-text search over stored robot memory logs."""
    from castor.memory_search import print_search_results, search_logs

    query = getattr(args, "query", "")
    log_file = getattr(args, "log_file", None)
    since = getattr(args, "since", None)
    max_results = getattr(args, "max_results", 20)
    results = search_logs(query=query, log_file=log_file, since=since, max_results=max_results)
    print_search_results(results)


def cmd_shell(args) -> None:
    """castor shell — open an interactive shell with the robot runtime loaded."""
    import os

    config_path = getattr(args, "config", "robot.rcan.yaml")
    if not os.path.exists(config_path):
        print(f"  Config not found: {config_path}")
        return
    from castor.shell import launch_shell

    launch_shell(config_path=config_path)


def cmd_status(args) -> None:
    """castor status — show AI provider and messaging channel readiness."""
    from castor.auth import (
        list_available_channels,
        list_available_providers,
        load_dotenv_if_available,
    )

    load_dotenv_if_available()
    providers = list_available_providers()
    channels = list_available_channels()

    print("\n  AI Providers")
    print(f"  {'─' * 24}")
    for name, ready in sorted(providers.items()):
        icon = "[+]" if ready else "[-]"
        print(f"  {icon} {name}")

    print("\n  Messaging Channels")
    print(f"  {'─' * 24}")
    if channels:
        for name, ready in sorted(channels.items()):
            icon = "[+]" if ready else "[-]"
            print(f"  {icon} {name}")
    else:
        print("  (none configured)")
    print()


def cmd_swarm(args) -> None:
    """castor swarm — placeholder."""
    print("castor swarm: coming soon.")


def cmd_test(args) -> None:
    """castor test — run the castor test suite via pytest."""
    import subprocess
    import sys

    cmd = [sys.executable, "-m", "pytest"]
    if getattr(args, "verbose", False):
        cmd.append("-v")
    keyword = getattr(args, "keyword", None)
    if keyword:
        cmd.extend(["-k", keyword])
    result = subprocess.run(cmd)
    raise SystemExit(result.returncode)


def cmd_test_hardware(args) -> None:
    """castor test hardware — run hardware connectivity tests."""
    import os

    config_path = getattr(args, "config", "robot.rcan.yaml")
    if not os.path.exists(config_path):
        print(f"  Config not found: {config_path}")
        return

    skip_confirm = getattr(args, "yes", False)
    from castor.test_hardware import run_test

    run_test(config_path=config_path, skip_confirm=skip_confirm)


def cmd_update_check(args) -> None:
    """castor update-check — check PyPI for a newer castor release."""
    from castor.update_check import print_update_status

    print_update_status()


def cmd_upgrade(args) -> None:
    """castor upgrade — upgrade OpenCastor to the latest version (#554)."""
    import subprocess
    import sys
    from pathlib import Path

    import castor

    verbose = getattr(args, "verbose", False)
    check_only = getattr(args, "check_only", False)
    venv = getattr(args, "venv", None)

    print(f"  Current version: {castor.__version__}")

    # Detect git install
    repo = Path(__file__).parent.parent
    is_git = (repo / ".git").exists()

    if check_only:
        if is_git:
            result = subprocess.run(
                ["git", "fetch", "origin", "main", "--dry-run"],
                cwd=repo,
                capture_output=True,
                text=True,
            )
            print(f"  Repo: {repo}")
            result2 = subprocess.run(
                ["git", "log", "HEAD..origin/main", "--oneline"],
                cwd=repo,
                capture_output=True,
                text=True,
            )
            commits = result2.stdout.strip()
            if commits:
                print(f"  Pending commits:\n{commits}")
            else:
                print("  Already up to date.")
        else:
            print("  Not a git install — check PyPI for latest version.")
        return

    if is_git:
        print("  Pulling latest from origin/main...")
        pull = subprocess.run(
            ["git", "pull", "origin", "main"],
            cwd=repo,
            capture_output=not verbose,
            text=True,
        )
        if pull.returncode != 0:
            print(f"  git pull failed: {pull.stderr}")
            return
        if verbose and pull.stdout:
            print(pull.stdout)

    python = (venv.rstrip("/") + "/bin/python") if venv else sys.executable
    pip_cmd = [python, "-m", "pip", "install", "-e", str(repo)]
    if verbose:
        pip_cmd.append("-v")
    else:
        pip_cmd.append("-q")

    print(f"  Installing with: {python}")
    result = subprocess.run(pip_cmd, capture_output=not verbose)
    if result.returncode != 0:
        print("  Upgrade failed. Re-run with --verbose for details.")
        return

    # Restart services (best-effort)
    subprocess.run(
        ["systemctl", "--user", "restart", "castor-gateway.service"],
        capture_output=True,
    )
    subprocess.run(
        ["systemctl", "--user", "restart", "castor-dashboard.service"],
        capture_output=True,
    )

    # Reload to get new version string
    import importlib

    try:
        importlib.reload(castor)
    except Exception:
        pass

    print(f"  Upgraded to: {castor.__version__}")
    print("  Run 'castor doctor' to verify.")


def cmd_validate(args) -> None:
    """castor validate — run RCAN conformance checks on a config file."""
    import json as _json
    import os

    config_path = getattr(args, "config", "robot.rcan.yaml")
    if not os.path.exists(config_path):
        print(f"  Config not found: {config_path}")
        raise SystemExit(1)

    try:
        import yaml

        with open(config_path) as f:
            config = yaml.safe_load(f) or {}
    except Exception as exc:
        print(f"  Error reading config: {exc}")
        raise SystemExit(1) from None

    from castor.conformance import ConformanceChecker

    checker = ConformanceChecker(config, config_path=config_path)
    category = getattr(args, "category", None)
    json_out = getattr(args, "json", False) or getattr(args, "json_out", False)
    strict = getattr(args, "strict", False)

    if category:
        results = checker.run_category(category)
    else:
        results = checker.run_all()

    fails = [r for r in results if r.status == "fail"]
    warns = [r for r in results if r.status == "warn"]

    if json_out:
        print(
            _json.dumps(
                {
                    "config": config_path,
                    "results": [
                        {"id": r.check_id, "status": r.status, "message": r.detail} for r in results
                    ],
                    "ok": len(fails) == 0,
                },
                indent=2,
            )
        )
    else:
        cat_label = category.upper() if category else "ALL"
        print(f"\n  RCAN Conformance: {config_path} [{cat_label}]  safety\n")
        for r in results:
            icon = "✅" if r.status == "pass" else ("⚠️ " if r.status == "warn" else "❌")
            print(f"  {icon} [{r.check_id}] {r.detail}")
        summary = checker.summary(results)
        print(
            f"\n  Score: {summary['score']}/100  "
            f"pass={summary['pass']} warn={summary['warn']} fail={summary['fail']}\n"
        )

    if fails:
        raise SystemExit(1)
    if strict and warns:
        raise SystemExit(1)


def cmd_watch(args) -> None:
    """castor watch — live dashboard of robot telemetry in the terminal."""
    from castor.watch import launch_watch

    gateway_url = getattr(args, "gateway", "http://127.0.0.1:8000")
    refresh = getattr(args, "refresh", 2.0)
    launch_watch(gateway_url=gateway_url, refresh=refresh)


def _cmd_monitor(args) -> None:
    """Internal monitor placeholder."""
    pass


# ── Auto-generated placeholders ──────────────────────────────────────────────


def cmd_agents(args) -> None:
    """castor agents — list, status, spawn, or stop Layer-3 agents."""
    from castor.agents import AgentRegistry

    action = getattr(args, "action", "list")
    registry = AgentRegistry()

    if action in ("list", "status"):
        agents = registry.list_agents()
        if not agents:
            print("  No agents spawned.")
            return
        print(f"  {'Name':<20} {'Status':<12} {'Uptime (s)'}")
        print(f"  {'-' * 20} {'-' * 12} {'-' * 10}")
        for a in agents:
            print(f"  {a['name']:<20} {a['status']:<12} {a['uptime_s']}")

    elif action == "spawn":
        name = getattr(args, "name", None)
        if not name:
            print("  Usage: castor agents spawn --name <agent-name>")
            return
        config_path = getattr(args, "config", None)
        cfg = {}
        if config_path:
            import yaml

            with open(config_path) as f:
                cfg = yaml.safe_load(f) or {}
        agent = registry.spawn(name, config=cfg)
        print(f"  ✅ Spawned agent: {name} (status: {agent.status.value})")

    elif action == "stop":
        name = getattr(args, "name", None)
        if not name:
            print("  Usage: castor agents stop --name <agent-name>")
            return
        import asyncio

        agent = registry.get(name)
        if not agent:
            print(f"  ❌ Agent '{name}' not found.")
            return
        asyncio.run(agent.stop())
        print(f"  ✅ Stopped agent: {name}")

    else:
        print(f"  Unknown action: {action}")


def cmd_approvals(args) -> None:
    """castor approvals — manage the safety approval queue."""
    from castor.approvals import ApprovalGate, print_approvals

    gate = ApprovalGate()

    approve_id = getattr(args, "approve", None)
    deny_id = getattr(args, "deny", None)
    clear = getattr(args, "clear", False)

    if approve_id is not None:
        result = gate.approve(int(approve_id))
        if result is not None:
            print(f"  Approved action {approve_id}.")
        else:
            print(f"  Action {approve_id} not found.")
        return

    if deny_id is not None:
        ok = gate.deny(int(deny_id))
        if ok:
            print(f"  ✅ Denied action {deny_id}.")
        else:
            print(f"  ❌ Action {deny_id} not found.")
        return

    if clear:
        gate.clear()
        print("  ✅ Cleared all pending approvals.")
        return

    pending = gate.list_pending()
    print_approvals(pending)


def cmd_audit(args) -> None:
    """castor audit — view and verify the tamper-evident audit log."""
    from castor.audit import get_audit, print_audit

    audit = get_audit()

    if getattr(args, "verify", False):
        ok, broken_idx = audit.verify_chain()
        if ok:
            print("  ✅ Audit chain intact — no tampering detected.")
        else:
            print(f"  ❌ Chain broken at entry index {broken_idx}.")
        return

    entries = audit.read(
        since=getattr(args, "since", None),
        event=getattr(args, "event", None),
        limit=getattr(args, "limit", 50),
    )
    print_audit(entries)


def cmd_backup(args) -> None:
    """castor backup — create a backup archive of configs and credentials."""
    import tarfile

    from castor.backup import create_backup, print_backup_summary

    output = getattr(args, "output", None)
    archive = create_backup(output_path=output)
    if not archive:
        return
    # Read back the list of archived files for the summary
    try:
        with tarfile.open(archive, "r:gz") as tf:
            files = [m.name for m in tf.getmembers()]
    except Exception:
        files = []
    print_backup_summary(archive, files)


def cmd_benchmark(args) -> None:
    """castor benchmark — measure AI provider latency/throughput or hardware loop timing."""
    providers_arg = getattr(args, "providers", None)

    if providers_arg:
        # Provider latency benchmark: N think() calls per provider
        import asyncio
        import json as _json

        from castor.benchmarker import BenchmarkResult, print_results, run_benchmark
        from castor.providers import get_provider

        provider_names = [p.strip() for p in providers_arg.split(",") if p.strip()]
        n = getattr(args, "rounds", 3)
        results: list[BenchmarkResult] = []

        async def _bench_all() -> None:
            for pname in provider_names:
                cfg: dict = {"provider": pname}
                model_arg = getattr(args, "model", None)
                if model_arg:
                    cfg["model"] = model_arg
                try:
                    brain = get_provider(cfg)
                    model_name = getattr(brain, "model", pname) or pname

                    async def _think(prompt: str, _b=brain) -> object:
                        return await asyncio.to_thread(_b.think, None, prompt)

                    result = await run_benchmark(_think, n=n, provider=pname, model=model_name)
                    results.append(result)
                except Exception as exc:  # noqa: BLE001
                    print(f"  Skipping {pname}: {exc}")

        asyncio.run(_bench_all())
        print_results(results)

        output = getattr(args, "output", None)
        if output and results:
            data = [
                {
                    "provider": r.provider,
                    "model": r.model,
                    "n": r.n,
                    "mean_ms": round(r.mean_ms, 1),
                    "min_ms": round(r.min_ms, 1),
                    "max_ms": round(r.max_ms, 1),
                    "p95_ms": round(r.p95_ms, 1),
                    "errors": r.errors,
                    "success_rate": round(r.success_rate, 3),
                }
                for r in results
            ]
            with open(output, "w") as fh:
                _json.dump(data, fh, indent=2)
            print(f"  Results written to {output}")
    else:
        # Hardware perception-action loop benchmark
        import os

        config_path = getattr(args, "config", "robot.rcan.yaml")
        if not os.path.exists(config_path):
            print(f"  Config not found: {config_path}")
            return
        iterations = getattr(args, "iterations", 3)
        simulate = getattr(args, "simulate", False)
        from castor.benchmark import run_benchmark as hw_run

        hw_run(config_path=config_path, iterations=iterations, simulate=simulate)


def cmd_calibrate(args) -> None:
    """castor calibrate — interactive servo/motor calibration."""
    import os

    config_path = getattr(args, "config", "robot.rcan.yaml")
    if not os.path.exists(config_path):
        print(f"  Config not found: {config_path}")
        return

    from castor.calibrate import run_calibration

    run_calibration(config_path=config_path)


def cmd_configure(args) -> None:
    """castor configure — interactive post-wizard config editor."""
    from castor.configure import run_configure

    run_configure(config_path=getattr(args, "config", "robot.rcan.yaml"))


def cmd_daemon(args) -> None:
    """castor daemon — manage the OpenCastor auto-start systemd service."""
    from castor.daemon import (
        daemon_logs,
        daemon_status,
        disable_daemon,
        enable_daemon,
    )

    action = getattr(args, "action", "status")

    if action == "enable":
        config_path = getattr(args, "config", "robot.rcan.yaml")
        user = getattr(args, "user", None)
        result = enable_daemon(config_path=config_path, user=user)
        icon = "✅" if result.get("ok") else "❌"
        print(f"  {icon} {result.get('message', '')}")
        if result.get("service_path"):
            print(f"  Service file: {result['service_path']}")

    elif action == "disable":
        result = disable_daemon()
        icon = "✅" if result.get("ok") else "❌"
        print(f"  {icon} {result.get('message', 'Disabled.')}")

    elif action == "status":
        result = daemon_status()
        active = result.get("active", "unknown")
        icon = "✅" if active == "active" else "⏸️"
        print(f"  {icon} Status: {active}")
        for key in ("description", "pid", "started"):
            if result.get(key):
                print(f"     {key}: {result[key]}")

    elif action == "logs":
        lines = getattr(args, "lines", 50)
        output = daemon_logs(lines=lines)
        print(output)

    elif action == "restart":
        import subprocess

        rc = subprocess.call(["sudo", "systemctl", "restart", "castor"])
        if rc == 0:
            print("  ✅ Service restarted.")
        else:
            print(f"  ❌ Restart failed (exit {rc}).")


def cmd_demo(args) -> None:
    """castor demo — run a simulated full-stack demo (no hardware/API keys needed)."""
    from castor.demo import run_demo

    run_demo(
        steps=getattr(args, "steps", 10),
        delay=getattr(args, "delay", 0.8),
        layout=getattr(args, "layout", "full"),
        no_color=getattr(args, "no_color", False),
    )


def cmd_deploy(args) -> None:
    """castor deploy — SSH-push RCAN config and restart service on remote Pi."""
    import shlex
    import subprocess

    host = getattr(args, "host", None)
    if not host:
        print("  Usage: castor deploy <user@host> --config robot.rcan.yaml")
        return

    config_path = getattr(args, "config", "robot.rcan.yaml")
    full = getattr(args, "full", False)
    status_only = getattr(args, "status", False)
    dry_run = getattr(args, "dry_run", False)
    port = getattr(args, "port", 22)
    key = getattr(args, "key", None)
    no_restart = getattr(args, "no_restart", False)

    ssh_opts = ["-p", str(port), "-o", "StrictHostKeyChecking=accept-new"]
    if key:
        ssh_opts += ["-i", key]

    def _run(cmd: list[str]) -> int:
        display = " ".join(shlex.quote(c) for c in cmd)
        print(f"  $ {display}")
        if dry_run:
            return 0
        return subprocess.call(cmd)

    if status_only:
        _run(
            ["ssh"]
            + ssh_opts
            + [host, "systemctl status castor --no-pager 2>&1 || echo 'castor service not found'"]
        )
        return

    # SCP config to remote
    rc = _run(
        ["scp", "-P", str(port)]
        + (["-i", key] if key else [])
        + [config_path, f"{host}:~/robot.rcan.yaml"]
    )
    if rc != 0:
        print(f"  ❌ SCP failed (exit {rc})")
        return
    print(f"  ✅ Config pushed to {host}:~/robot.rcan.yaml")

    # Optional: pip install
    if full:
        rc = _run(["ssh"] + ssh_opts + [host, "pip install -q --upgrade opencastor"])
        if rc != 0:
            print(f"  ⚠️  pip install returned exit {rc}")

    # Restart service
    if not no_restart:
        rc = _run(
            ["ssh"]
            + ssh_opts
            + [
                host,
                "systemctl restart castor 2>/dev/null || "
                "(pkill -f 'castor gateway' 2>/dev/null; "
                "nohup castor gateway --config ~/robot.rcan.yaml &>/tmp/castor.log &)",
            ]
        )
        if rc == 0:
            print("  ✅ Service restarted on remote.")
        else:
            print(f"  ⚠️  Restart returned exit {rc} — check remote logs.")


def cmd_diff(args) -> None:
    """castor diff — compare two RCAN config files."""
    import os

    from castor.diff import diff_configs, print_diff

    config_a = getattr(args, "config", "robot.rcan.yaml")
    config_b = getattr(args, "baseline", None)
    if not config_b:
        print("  Usage: castor diff --config current.rcan.yaml --baseline old.rcan.yaml")
        return
    if not os.path.exists(config_a):
        print(f"  Config not found: {config_a}")
        return
    if not os.path.exists(config_b):
        print(f"  Baseline not found: {config_b}")
        return

    diffs = diff_configs(config_a, config_b)
    print_diff(diffs, config_a, config_b)


def cmd_doctor(args) -> None:
    """castor doctor — run system health checks."""
    from castor.doctor import print_report, run_all_checks

    print("  🩺 OpenCastor Doctor\n")
    config_path = getattr(args, "config", None)
    results = run_all_checks(config_path=config_path)
    print_report(results)

    if getattr(args, "auto_fix", False):
        from castor.doctor import run_auto_fix

        print("\n  Running auto-fix...")
        run_auto_fix(results)


def cmd_export(args) -> None:
    """castor export — export config bundle with secrets redacted."""
    import os

    from castor.export import export_bundle, export_bundle_tgz, print_export_summary

    fmt = getattr(args, "format", "zip")
    config_path = getattr(args, "config", "robot.rcan.yaml")
    if not os.path.exists(config_path):
        print(f"  Config not found: {config_path}")
        return
    output = getattr(args, "output", None)
    episodes = getattr(args, "episodes", 100)

    if fmt == "tgz":
        out = export_bundle_tgz(config_path, output_path=output, max_episodes=episodes)
    else:
        out = export_bundle(config_path=config_path, output_path=output, fmt=fmt)

    print_export_summary(out, fmt)


def cmd_export_finetune(args) -> None:
    """castor export-finetune — export episode memory as a fine-tuning dataset."""
    from castor.finetune import export_episodes
    from castor.memory import EpisodeMemory

    fmt = getattr(args, "format", "chatml")
    limit = getattr(args, "limit", 1000)
    require_action = getattr(args, "require_action", False)
    output = getattr(args, "output", None)

    if output is None:
        output = f"robot_dataset_{fmt}.jsonl"

    mem = EpisodeMemory()

    if require_action:
        # Filter to episodes that have a parsed action in their metadata
        original_recent = mem.recent

        def _filtered_recent(n: int):
            all_eps = original_recent(n=n * 4)
            return [e for e in all_eps if e.get("action") or e.get("parsed_action")][:n]

        mem.recent = _filtered_recent  # type: ignore[method-assign]

    n = export_episodes(mem, output, fmt=fmt, limit=limit)
    print(f"  ✅ Exported {n} episodes → {output} (format: {fmt})")


def cmd_fix(args) -> None:
    """castor fix — auto-fix common issues detected by castor doctor."""
    from castor.fix import run_fix

    run_fix(config_path=getattr(args, "config", None))


def cmd_flash(args) -> None:
    """castor flash — flash ACB v2.0 firmware via DFU-util (#523).

    WARNING: Use a current-limiting PSU during firmware flashing.
    High current MOSFETs are present on the ACB v2.0 board.
    """
    import subprocess
    import urllib.request

    firmware_path = getattr(args, "firmware", None)
    version = getattr(args, "version", None) or "latest"
    confirm = getattr(args, "confirm", False)
    driver_id = getattr(args, "id", "acb") or "acb"

    print()
    print("  \u26a0\ufe0f  WARNING: Use a current-limiting PSU during firmware flashing.")
    print("        High current MOSFETs are present on the ACB v2.0 board.")
    print()

    # Validate driver_id against the loaded RCAN config (if available)
    config_path = getattr(args, "config", None)
    if config_path:
        try:
            import yaml as _yaml

            with open(config_path) as _fh:
                _rcan = _yaml.safe_load(_fh)
            _driver_ids = [d.get("id") for d in (_rcan or {}).get("drivers", [])]
            if driver_id not in _driver_ids:
                print(
                    f"  Warning: driver id '{driver_id}' not found in {config_path}."
                    f"  Known driver IDs: {_driver_ids}"
                )
                print("  Proceeding anyway — ensure the correct device is connected.")
                print()
        except Exception as _exc:
            print(f"  Warning: could not validate driver id against config: {_exc}")
            print()

    if not confirm:
        try:
            ans = input("  Proceed with flash? [y/N] ").strip().lower()
        except (EOFError, KeyboardInterrupt):
            print("\n  Aborted.")
            return
        if ans not in ("y", "yes"):
            print("  Aborted.")
            return

    # Check DFU device
    try:
        result = subprocess.run(["dfu-util", "-l"], capture_output=True, text=True, timeout=10)
        if "0483:df11" not in result.stdout.lower() and "0483:df11" not in result.stderr.lower():
            print("  No DFU device found (VID:0483 PID:DF11).")
            print("  Hold the BOOT button on the ACB, then connect USB — then re-run this command.")
            return
    except FileNotFoundError:
        print("  dfu-util not found.  Install with: sudo apt install dfu-util")
        return
    except Exception as exc:
        print(f"  dfu-util error: {exc}")
        return

    import pathlib

    cache_dir = pathlib.Path.home() / ".opencastor" / "firmware"
    cache_dir.mkdir(parents=True, exist_ok=True)

    if firmware_path:
        fw_path = pathlib.Path(firmware_path)
    else:
        print(f"  Fetching latest ACB firmware release (version={version})...")
        try:
            api_url = "https://api.github.com/repos/h-laboratories/acb-v2.0/releases/latest"
            with urllib.request.urlopen(api_url, timeout=10) as resp:  # noqa: S310
                import json as _json

                release_data = _json.loads(resp.read())
            assets = release_data.get("assets", [])
            bin_assets = [a for a in assets if a.get("name", "").endswith(".bin")]
            if not bin_assets:
                print("  No .bin asset found in latest GitHub release.")
                return
            fw_url = bin_assets[0]["browser_download_url"]
            tag = release_data.get("tag_name", "latest")
            fw_path = cache_dir / f"acb-v2.0-{tag}.bin"
            if not fw_path.exists():
                print(f"  Downloading {fw_url} ...")
                with urllib.request.urlopen(fw_url, timeout=30) as resp:  # noqa: S310
                    fw_path.write_bytes(resp.read())
                print(f"  Cached to {fw_path}")
            else:
                print(f"  Using cached {fw_path}")

            # --- Firmware integrity check (#562) ---
            all_assets = release_data.get("assets", [])
            sha_assets = [a for a in all_assets if a["name"].endswith(".sha256")]
            if sha_assets:
                sha_url = sha_assets[0]["browser_download_url"]
                with urllib.request.urlopen(sha_url, timeout=15) as r:  # noqa: S310
                    expected_hash = r.read().decode().split()[0].strip()
                actual_hash = hashlib.sha256(fw_path.read_bytes()).hexdigest()
                if not hmac.compare_digest(actual_hash, expected_hash):
                    fw_path.unlink(missing_ok=True)  # delete corrupted cache
                    print("  ERROR: Firmware checksum mismatch!")
                    print(f"    expected: {expected_hash}")
                    print(f"    got:      {actual_hash}")
                    print("  Aborting flash — firmware may be corrupted or tampered.")
                    return
                print(f"  SHA-256 verified: {actual_hash[:16]}...")
            else:
                print(
                    "  WARNING: No .sha256 asset found for this release — skipping checksum verification."
                )
        except Exception as exc:
            print(f"  Failed to fetch firmware: {exc}")
            return

    print(f"  Flashing {fw_path} to device...")
    try:
        proc = subprocess.run(
            [
                "dfu-util",
                "-d",
                "0483:DF11",
                "-a",
                "0",
                "-s",
                "0x08000000:leave",
                "-D",
                str(fw_path),
            ],
            timeout=120,
        )
        if proc.returncode == 0:
            print("  Flash complete.  Waiting for device to reconnect...")
        else:
            print(f"  Flash failed (exit code {proc.returncode}).")
    except Exception as exc:
        print(f"  Flash error: {exc}")


def cmd_hub(args) -> None:
    """castor hub — community recipe hub: browse, share, and install robot configs."""
    from castor.hub import (
        get_recipe,
        install_recipe,
        list_recipes,
        package_recipe,
        print_recipe_card,
        submit_recipe_pr,
    )

    action = getattr(args, "action", "browse")
    query = getattr(args, "query", None)
    verbose = getattr(args, "verbose", False)

    if action in ("browse", "list"):
        recipes = list_recipes(
            category=getattr(args, "category", None),
            difficulty=getattr(args, "difficulty", None),
            provider=getattr(args, "provider", None),
            search=query if action == "list" else None,
        )
        if not recipes:
            print("  No recipes found.")
            return
        for r in recipes:
            print_recipe_card(r, verbose=verbose)

    elif action == "search":
        if not query:
            print("  Usage: castor hub search <query>")
            return
        recipes = list_recipes(search=query)
        if not recipes:
            print(f"  No recipes matching '{query}'.")
            return
        for r in recipes:
            print_recipe_card(r, verbose=verbose)

    elif action == "show":
        if not query:
            print("  Usage: castor hub show <recipe-id>")
            return
        recipe = get_recipe(query)
        if not recipe:
            print(f"  Recipe '{query}' not found.")
            return
        print_recipe_card(recipe, verbose=True)

    elif action == "install":
        if not query:
            print("  Usage: castor hub install <recipe-id>")
            return
        dest = getattr(args, "output", ".") or "."
        result = install_recipe(query, dest=dest)
        if result:
            print(f"  ✅ Installed to {result}")
        else:
            print(f"  ❌ Recipe '{query}' not found.")

    elif action == "share":
        config = getattr(args, "config", None)
        if not config:
            print("  Usage: castor hub share --config robot.rcan.yaml")
            return
        docs = getattr(args, "docs", None) or []
        dry_run = getattr(args, "dry_run", False)
        submit = getattr(args, "submit", False)
        output = getattr(args, "output", None)
        recipe_dir = package_recipe(config, output_dir=output, docs=docs, dry_run=dry_run)
        if not dry_run:
            print(f"  ✅ Recipe packaged at: {recipe_dir}")
            if submit:
                submit_recipe_pr(recipe_dir)

    elif action == "categories":
        from castor.hub import CATEGORIES

        for key, label in CATEGORIES.items():
            print(f"  {key:<20} {label}")

    else:
        print(f"  Unknown hub action: {action}")


def cmd_improve(args) -> None:
    """castor improve — self-improving loop: analyze episodes and apply improvements."""
    # --enable / --disable toggle learner in RCAN config
    if _improve_toggle(args):
        return

    status = getattr(args, "status", False)
    improvements = getattr(args, "improvements", False)
    rollback_id = getattr(args, "rollback", None)
    episodes_n = getattr(args, "episodes", 5)
    dry_run = getattr(args, "dry_run", False)

    try:
        from castor.learner.sisyphus import SisyphusLoop
        from castor.memory import EpisodeMemory

        loop = SisyphusLoop()

        if status:
            stats = loop.stats()
            print(f"  Total runs:        {stats.total_runs}")
            print(f"  Improvements:      {stats.improvements_applied}")
            print(f"  Rollbacks:         {stats.rollbacks}")
            print(f"  Last run:          {stats.last_run or 'never'}")
            return

        if improvements:
            applied = loop.list_improvements()
            if not applied:
                print("  No improvements applied yet.")
                return
            for imp in applied:
                print(f"  [{imp.id[:8]}] {imp.description} ({imp.applied_at})")
            return

        if rollback_id:
            ok = loop.rollback(rollback_id)
            if ok:
                print(f"  ✅ Rolled back improvement {rollback_id}")
            else:
                print(f"  ❌ Rollback failed: improvement {rollback_id} not found")
            return

        # Analyze episodes
        mem = EpisodeMemory()
        eps = mem.recent(n=episodes_n)
        if not eps:
            print("  No episodes found. Run castor run first to collect data.")
            return

        print(f"  Analyzing {len(eps)} episodes...")
        results = loop.run_batch(eps)
        applied = [r for r in results if r.applied and not dry_run]
        print(f"  {len(results)} improvements identified, {len(applied)} applied.")
        for r in results:
            status_icon = "✅" if (r.applied and not dry_run) else ("🔍" if dry_run else "⏭️")
            print(f"  {status_icon} {r.description}")

    except ImportError:
        print("  Learner not available. Install with: pip install opencastor[learner]")


def cmd_install_service(args) -> None:
    """castor install-service — install systemd services for gateway and dashboard."""
    import os

    from castor.daemon import (
        DASHBOARD_SERVICE_NAME,
        DASHBOARD_SERVICE_PATH,
        SERVICE_NAME,
        SERVICE_PATH,
        enable_daemon,
        enable_dashboard,
    )

    config_path = getattr(args, "config", "robot.rcan.yaml")
    dashboard_port = getattr(args, "dashboard_port", 8501)
    dry_run = getattr(args, "dry_run", False)

    abs_config = os.path.abspath(config_path)
    if not os.path.exists(abs_config):
        print(f"  Config not found: {config_path}")
        return

    if dry_run:
        from castor.daemon import generate_dashboard_service_file, generate_service_file

        print(f"  [dry-run] Gateway service → {SERVICE_PATH}")
        print(generate_service_file(abs_config))
        print(f"  [dry-run] Dashboard service → {DASHBOARD_SERVICE_PATH}")
        print(generate_dashboard_service_file(port=dashboard_port))
        return

    print("  Installing gateway service...")
    gw = enable_daemon(abs_config)
    if gw["ok"]:
        print(f"  Gateway service installed: {gw['service_path']}")
    else:
        print(f"  Gateway service failed: {gw['message']}")

    print("  Installing dashboard service...")
    dash = enable_dashboard(port=dashboard_port)
    if dash["ok"]:
        print(f"  Dashboard service installed: {dash['service_path']}")
        print(f"  Dashboard available at http://localhost:{dashboard_port}")
    else:
        print(f"  Dashboard service failed: {dash['message']}")

    if gw["ok"] and dash["ok"]:
        print("\n  Both services are enabled and will start automatically on boot.")
        print("  Manage with:")
        print(f"    sudo systemctl status {SERVICE_NAME}")
        print(f"    sudo systemctl status {DASHBOARD_SERVICE_NAME}")


def cmd_learn(args) -> None:
    """castor learn — interactive step-by-step tutorial."""
    from castor.learn import run_learn

    run_learn(lesson=getattr(args, "lesson", None))


def cmd_lint(args) -> None:
    """castor lint — deep semantic validation of RCAN config."""
    import os

    from castor.lint import print_lint_report, run_lint

    config_path = getattr(args, "config", "robot.rcan.yaml")
    if not os.path.exists(config_path):
        print(f"  Config not found: {config_path}")
        return
    issues = run_lint(config_path)
    print_lint_report(issues)


def _update_env_var(env_file: str, key: str, value: str) -> None:
    """Write or update KEY=VALUE in an .env file, preserving other lines."""
    from pathlib import Path as _Path

    p = _Path(env_file)
    lines = p.read_text().splitlines() if p.exists() else []
    prefix = f"{key}="
    new_line = f"{key}={value}"
    updated = False
    result = []
    for line in lines:
        if line.startswith(prefix):
            result.append(new_line)
            updated = True
        else:
            result.append(line)
    if not updated:
        result.append(new_line)
    p.write_text("\n".join(result) + "\n")


def cmd_login(args) -> None:
    """castor login — authenticate with AI provider services."""
    service = getattr(args, "service", "huggingface")
    token = getattr(args, "token", None)
    list_models = getattr(args, "list_models", False)
    task = getattr(args, "task", "text-generation")

    if service in ("huggingface", "hf"):
        try:
            from huggingface_hub import list_models as hf_list_models
            from huggingface_hub import login

            if token:
                login(token=token)
                print("  ✅ Logged in to Hugging Face.")
            else:
                login()  # interactive prompt

            if list_models:
                print(f"\n  Trending {task} models:\n")
                for i, m in enumerate(hf_list_models(task=task, limit=10)):
                    print(f"  {i + 1:2}. {m.modelId}")
        except ImportError:
            print("  huggingface_hub not installed. Run: pip install huggingface_hub")

    elif service == "ollama":
        import subprocess

        rc = subprocess.call(["ollama", "list"])
        if rc != 0:
            print("  Ollama not found. Install from https://ollama.com")

    else:
        print(f"  Unknown service: {service}")


def cmd_streaming(args) -> None:
    """castor streaming — start WebRTC / MJPEG camera stream."""
    config_path = getattr(args, "config", "robot.rcan.yaml")
    port = getattr(args, "port", 8001)

    try:
        import yaml

        with open(config_path) as f:
            config = yaml.safe_load(f) or {}
    except FileNotFoundError:
        config = {}

    try:
        from castor.stream import StreamServer

        server = StreamServer(config=config, port=port)
        print(f"  Starting stream server on port {port} …")
        print(f"  MJPEG stream: http://0.0.0.0:{port}/stream")
        server.serve_forever()
    except ImportError:
        # aiortc / cv2 not installed — show install hint
        print(
            "  WebRTC stream requires additional packages:\n"
            "    pip install opencastor[webrtc]\n"
            "  or: pip install aiortc opencv-python\n"
            "\n  For MJPEG only: pip install opencv-python"
        )


def cmd_update(args) -> None:
    """castor update — self-update OpenCastor from PyPI."""
    from castor.updater import do_upgrade, get_version_info

    info = get_version_info()
    if info.up_to_date:
        print(f"  ✅ Already up to date: {info.current}")
        return
    print(f"  Current: {info.current}  →  Latest: {info.latest}")
    yes = getattr(args, "yes", False)
    rc = do_upgrade(yes=yes)
    raise SystemExit(rc)


def _improve_toggle(args) -> bool:
    """Toggle learner.enabled in RCAN YAML. Returns True if a change was made."""
    enable = getattr(args, "enable", False)
    disable = getattr(args, "disable", False)
    if not enable and not disable:
        return False

    config_path = getattr(args, "config", None)
    if not config_path:
        config_path = _find_default_config()
    if not config_path:
        print("No RCAN config found — cannot toggle learner.")
        return True

    from pathlib import Path as _Path

    import yaml as _yaml

    p = _Path(config_path)
    if not p.exists():
        print(f"Config not found: {config_path}")
        return True

    data = _yaml.safe_load(p.read_text()) or {}
    learner = data.setdefault("learner", {})

    if enable:
        learner["enabled"] = True
        if "provider" not in learner:
            learner["provider"] = "huggingface"
        if "auto_apply_code" not in learner:
            learner["auto_apply_code"] = False
    else:
        learner["enabled"] = False

    p.write_text(_yaml.dump(data, default_flow_style=False))
    action = "enabled" if enable else "disabled"
    print(f"Learner {action} in {config_path}")
    return True


def main() -> None:
    parser = argparse.ArgumentParser(
        prog="castor",
        description="OpenCastor - The Universal Runtime for Embodied AI",
        epilog=(
            "Command groups:\n"
            "  Setup:       wizard, quickstart, configure, install-service, learn\n"
            "  Run:         run, gateway, dashboard, demo, shell, repl\n"
            "  Diagnostics: doctor, fix, status, logs, lint, benchmark, test\n"
            "  Hardware:    test-hardware, calibrate, record, replay, watch\n"
            "  Config:      migrate, backup, restore, export, diff, profile\n"
            "  Safety:      approvals, privacy, audit\n"
            "  Network:     discover, fleet, network, schedule\n"
            "  Advanced:    token, search, plugins, upgrade, update-check\n"
            "\n"
            "Quick start:\n"
            "  castor wizard                                 # First-time setup\n"
            "  castor run --config robot.rcan.yaml           # Start the robot\n"
            "  castor run --config robot.rcan.yaml --dashboard  # Robot + tmux dashboard\n"
            "  castor demo                                   # Try without hardware\n"
            "  castor doctor                                 # Check system health\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    sub = parser.add_subparsers(dest="command")

    # castor run
    p_run = sub.add_parser(
        "run",
        help="Run the robot perception-action loop",
        epilog=(
            "Examples:\n"
            "  castor run --config robot.rcan.yaml\n"
            "  castor run --config robot.rcan.yaml --dashboard\n"
            "  castor run --config robot.rcan.yaml --dashboard --layout minimal\n"
            "  castor run --config robot.rcan.yaml --simulate --dashboard\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_run.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_run.add_argument("--simulate", action="store_true", help="Run without hardware")
    p_run.add_argument(
        "--behavior",
        default=None,
        metavar="BEHAVIOR_FILE",
        help="Run a behavior script instead of the perception loop (e.g. patrol.behavior.yaml)",
    )
    p_run.add_argument(
        "--dashboard",
        action="store_true",
        help="Launch tmux dashboard alongside the robot (requires tmux)",
    )
    p_run.add_argument(
        "--layout",
        default="full",
        choices=["full", "minimal", "debug"],
        help="Dashboard layout when --dashboard is used (default: full)",
    )

    # castor gateway
    p_gw = sub.add_parser(
        "gateway",
        help="Start the API gateway server",
        epilog="Example: castor gateway --config robot.rcan.yaml --host 0.0.0.0 --port 8080",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_gw.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_gw.add_argument("--host", default="127.0.0.1", help="Bind address")
    p_gw.add_argument("--port", type=int, default=8000, help="Port number")

    # castor mcp
    p_mcp = sub.add_parser(
        "mcp",
        help="Start the MCP server for tool-based agent integration",
        epilog="Example: castor mcp --host 127.0.0.1 --port 8765",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_mcp.add_argument("--host", default="127.0.0.1", help="Bind address")
    p_mcp.add_argument("--port", type=int, default=8765, help="Port number")

    # castor wizard
    p_wizard = sub.add_parser(
        "wizard",
        help="Interactive setup wizard",
        epilog="Example: castor wizard --simple --accept-risk",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_wizard.add_argument("--simple", action="store_true", help="QuickStart mode")
    p_wizard.add_argument("--accept-risk", action="store_true", help="Skip safety prompt")
    p_wizard.add_argument("--web", action="store_true", help="Open browser-based wizard")
    p_wizard.add_argument("--web-port", type=int, default=8765, help="Port for web wizard")

    # castor dashboard — starts robot + tmux TUI immediately
    p_dash = sub.add_parser(
        "dashboard",
        help="Launch tmux dashboard and start the robot immediately",
    )
    p_dash.add_argument(
        "--config",
        default="robot.rcan.yaml",
        help="RCAN config file (auto-detected if omitted)",
    )
    p_dash.add_argument(
        "--layout",
        default="full",
        choices=["full", "minimal", "debug"],
        help="Dashboard layout (default: full)",
    )
    p_dash.add_argument("--simulate", action="store_true", help="Simulation mode (no hardware)")
    p_dash.add_argument("--kill", action="store_true", help="Kill existing dashboard session")

    # castor dashboard-tui
    p_tui = sub.add_parser(
        "dashboard-tui",
        help="Terminal dashboard (tmux multi-pane robot monitor)",
    )
    p_tui.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_tui.add_argument(
        "--layout",
        default="full",
        choices=["full", "minimal", "debug"],
        help="Dashboard layout (default: full)",
    )
    p_tui.add_argument("--simulate", action="store_true", help="Simulation mode")
    p_tui.add_argument("--kill", action="store_true", help="Kill existing dashboard")

    # castor token
    p_token = sub.add_parser("token", help="Issue a JWT token for RCAN API access")
    p_token.add_argument(
        "--role", default="user", help="RCAN role (guest/user/operator/admin/creator)"
    )
    p_token.add_argument(
        "--scope", default=None, help="Comma-separated scopes (e.g. status,control)"
    )
    p_token.add_argument("--ttl", default="24", help="Token lifetime in hours (default: 24)")
    p_token.add_argument("--subject", default=None, help="Principal name (default: cli-user)")
    p_token.add_argument("--rotate", action="store_true", help="Rotate JWT signing key")
    p_token.add_argument(
        "--new-secret", default=None, help="Explicit replacement secret for --rotate"
    )
    p_token.add_argument("--kid", default=None, help="Key ID (kid) for issued or rotated key")

    # castor discover
    p_discover = sub.add_parser("discover", help="Discover RCAN peers on the local network")
    p_discover.add_argument("--timeout", default="5", help="Scan duration in seconds (default: 5)")

    # castor doctor
    # Issue #348: castor snapshot
    p_snapshot = sub.add_parser(
        "snapshot",
        help="Take or inspect diagnostic snapshots",
        description="Capture and view system diagnostic snapshots.",
        epilog="Examples: castor snapshot take | castor snapshot latest | castor snapshot history 10",
    )
    p_snapshot.add_argument(
        "snapshot_action",
        nargs="?",
        choices=["take", "latest", "history"],
        default="latest",
        help="Snapshot sub-command (default: latest)",
    )
    p_snapshot.add_argument(
        "snapshot_args",
        nargs="*",
        help="Extra arguments (e.g. limit N for history)",
    )

    p_doctor = sub.add_parser(
        "doctor",
        help="Run system health checks",
        epilog="Example: castor doctor --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_doctor.add_argument("--config", default=None, help="RCAN config file to validate")
    p_doctor.add_argument(
        "--auto-fix",
        action="store_true",
        help="Attempt to auto-fix common issues (e.g. missing .env, large memory DB)",
    )

    # castor compliance
    p_compliance = sub.add_parser(
        "compliance",
        help="Check RCAN v1.2 conformance (L1/L2/L3) for a robot config",
        epilog="Example: castor compliance --config bob.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_compliance.add_argument(
        "--config", default="robot.rcan.yaml", help="RCAN config file to check"
    )
    p_compliance.add_argument(
        "--level",
        choices=["L1", "L2", "L3"],
        default=None,
        help="Only check up to this conformance level",
    )
    p_compliance.add_argument(
        "--json", action="store_true", dest="output_json", help="Output results as JSON"
    )
    p_compliance.add_argument(
        "--commitments",
        action="store_true",
        help="Also verify the on-disk commitment chain log",
    )
    p_compliance.add_argument(
        "--format",
        choices=["json", "text"],
        default=None,
        help="Output format: json or text (uses ComplianceReport module)",
    )
    p_compliance.add_argument(
        "--output",
        default=None,
        metavar="FILE",
        help="Write output to FILE instead of stdout",
    )

    # castor demo
    p_demo = sub.add_parser(
        "demo",
        help="Run a simulated demo (no hardware/API keys)",
        epilog="Example: castor demo --steps 5 --delay 2.0",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_demo.add_argument("--steps", type=int, default=10, help="Number of loop iterations")
    p_demo.add_argument("--delay", type=float, default=0.8, help="Seconds between steps")
    p_demo.add_argument(
        "--layout",
        default="full",
        choices=["full", "minimal"],
        help="Demo depth: full (all 5 acts) or minimal (skip Acts 3 & 4)",
    )
    p_demo.add_argument("--no-color", action="store_true", help="Disable rich output")

    # castor test-hardware
    p_test = sub.add_parser(
        "test-hardware",
        help="Test each motor/servo individually",
        epilog="Example: castor test-hardware --config robot.rcan.yaml -y",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_test.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_test.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompts")

    # castor calibrate
    p_cal = sub.add_parser(
        "calibrate",
        help="Interactive servo/motor calibration",
        epilog="Example: castor calibrate --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_cal.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")

    # castor logs
    p_logs = sub.add_parser(
        "logs",
        help="View structured OpenCastor logs",
        epilog="Example: castor logs -f --level WARNING --module providers",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_logs.add_argument("--follow", "-f", action="store_true", help="Follow log output")
    p_logs.add_argument(
        "--level", default=None, help="Minimum level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"
    )
    p_logs.add_argument(
        "--module", default=None, help="Filter by module name (e.g. providers, Gateway)"
    )
    p_logs.add_argument("--lines", "-n", type=int, default=50, help="Number of recent lines")
    p_logs.add_argument("--no-color", action="store_true", help="Disable color output")

    # castor backup
    p_backup = sub.add_parser(
        "backup",
        help="Back up configs and credentials",
        epilog="Example: castor backup -o my_backup.tar.gz",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_backup.add_argument("--output", "-o", default=None, help="Output archive path")

    # castor restore
    p_restore = sub.add_parser(
        "restore",
        help="Restore configs from a backup archive",
        epilog="Example: castor restore opencastor_backup_20260216.tar.gz",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_restore.add_argument("archive", help="Path to the backup .tar.gz file")
    p_restore.add_argument(
        "--dry-run", action="store_true", help="List contents without extracting"
    )

    # castor migrate
    p_migrate = sub.add_parser("migrate", help="Migrate RCAN config to current schema version")
    p_migrate.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_migrate.add_argument("--dry-run", action="store_true", help="Show changes without modifying")

    # castor upgrade
    p_upgrade = sub.add_parser("upgrade", help="Upgrade OpenCastor to latest version")
    p_upgrade.add_argument("--verbose", "-v", action="store_true", help="Show pip output")
    p_upgrade.add_argument(
        "--check",
        action="store_true",
        dest="check_only",
        help="Show available updates without upgrading",
    )
    p_upgrade.add_argument(
        "--venv", default=None, metavar="PATH", help="Path to venv (default: current Python)"
    )

    # castor install-service
    p_svc = sub.add_parser(
        "install-service",
        help="Install systemd services for gateway and dashboard (auto-start on boot)",
        epilog="Example: castor install-service --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_svc.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_svc.add_argument(
        "--dashboard-port", type=int, default=8501, help="Dashboard port (default: 8501)"
    )
    p_svc.add_argument(
        "--dry-run", action="store_true", help="Print generated service files without installing"
    )

    # castor status
    sub.add_parser("status", help="Show provider and channel readiness")

    # --- New commands (batch 3) ---

    # castor shell
    p_shell = sub.add_parser(
        "shell",
        help="Interactive command shell with robot objects",
        epilog="Example: castor shell --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_shell.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")

    # castor watch
    p_watch = sub.add_parser(
        "watch",
        help="Live telemetry dashboard (Rich)",
        epilog="Example: castor watch --gateway http://192.168.1.100:8000",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_watch.add_argument(
        "--gateway",
        default="http://127.0.0.1:8000",
        help="Gateway URL (default: http://127.0.0.1:8000)",
    )
    p_watch.add_argument(
        "--refresh", type=float, default=2.0, help="Refresh interval in seconds (default: 2.0)"
    )

    # castor fit
    sub.add_parser(
        "fit",
        help="Show which LLM models fit your robot's hardware (via llmfit)",
        epilog="Example: castor fit",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    # castor memory
    p_memory = sub.add_parser(
        "memory",
        help="Manage robot memory and episode consolidation",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Examples:\n  castor memory replay --since 2026-01-01 --dry-run",
    )
    memory_sub = p_memory.add_subparsers(dest="memory_cmd")
    p_mem_replay = memory_sub.add_parser(
        "replay", help="Replay historical episodes through updated consolidation pipeline"
    )
    p_mem_replay.add_argument(
        "--since",
        default=None,
        metavar="DATE",
        help="Only replay episodes on/after this date (YYYY-MM-DD)",
    )
    p_mem_replay.add_argument("--episode-id", default=None, help="Replay a specific episode by ID")
    p_mem_replay.add_argument(
        "--episodes-dir", default=None, help="Path to L0-episodic/episodes/ directory"
    )
    p_mem_replay.add_argument(
        "--dry-run", action="store_true", help="Simulate without writing changes"
    )
    p_mem_replay.add_argument(
        "--verbose", "-v", action="store_true", help="Show each episode being replayed"
    )

    # castor fleet
    p_fleet = sub.add_parser(
        "fleet",
        help="Manage robot group policies",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Examples:\n  castor fleet list --config bob.rcan.yaml\n  castor fleet resolve RRN-00000042 --config bob.rcan.yaml\n  castor fleet apply-all --config bob.rcan.yaml",
    )
    p_fleet.add_argument(
        "--timeout", default="5", help="mDNS scan duration in seconds (default: 5)"
    )
    fleet_sub = p_fleet.add_subparsers(dest="fleet_cmd")

    p_fl_list = fleet_sub.add_parser("list", help="List all defined groups")
    p_fl_list.add_argument("--config", default=None)

    p_fl_resolve = fleet_sub.add_parser("resolve", help="Show resolved config for a robot")
    p_fl_resolve.add_argument("rrn", help="Robot Registry Number")
    p_fl_resolve.add_argument("--config", default=None)
    p_fl_resolve.add_argument("--json", action="store_true", dest="output_json")

    p_fl_status = fleet_sub.add_parser("status", help="Show which groups each robot belongs to")
    p_fl_status.add_argument("--config", default=None)

    # castor node — RCAN §17 namespace delegation
    p_node = sub.add_parser(
        "node",
        help="Manage RCAN namespace delegation for this robot fleet",
        epilog=(
            "Examples:\n"
            "  castor node status        # Show node broadcaster status\n"
            "  castor node manifest      # Print /.well-known/rcan-node.json\n"
            "  castor node resolve <RRN> # Resolve an RRN via federated registry\n"
            "  castor node ping          # Check rcan.dev reachability\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    node_sub = p_node.add_subparsers(dest="node_cmd")

    node_sub.add_parser("status", help="Show current node broadcaster status and manifest")
    node_sub.add_parser("manifest", help="Print the /.well-known/rcan-node.json manifest")
    p_node_resolve = node_sub.add_parser(
        "resolve", help="Resolve an RRN via the federated registry"
    )
    p_node_resolve.add_argument("rrn", help="Robot Registry Number (e.g. RRN-AB-00000042)")
    node_sub.add_parser("ping", help="Check rcan.dev registry reachability")

    # castor inspect
    p_inspect = sub.add_parser(
        "inspect",
        help="Query a robot's live RCAN profile, safety state, and telemetry",
        epilog="Examples:\n  castor inspect RRN-00000042\n  castor inspect --config bob.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_inspect.add_argument(
        "rrn", nargs="?", default=None, help="Robot Registry Number (e.g. RRN-00000042)"
    )
    p_inspect.add_argument("--config", default=None, help="Local RCAN config file to inspect")
    p_inspect.add_argument(
        "--gateway", default=None, help="Gateway URL (e.g. http://localhost:8080)"
    )
    p_inspect.add_argument("--json", action="store_true", dest="output_json", help="JSON output")

    # castor verification
    p_verification = sub.add_parser(
        "verification",
        help="Check verification tier for an RRN via rcan.dev",
        epilog="Example: castor verification RRN-AB-00000042",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_verification.add_argument("rrn", help="Robot Registry Number (e.g. RRN-AB-00000042)")

    # castor register
    p_register = sub.add_parser(
        "register",
        help="Register your robot with rcan.dev and get a globally unique RRN",
        epilog="Example: castor register --config bob.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_register.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_register.add_argument(
        "--api-key", default=None, help="rcan.dev API key (or set RCAN_API_KEY env var)"
    )
    p_register.add_argument("--manufacturer", default=None, help="Override manufacturer slug")
    p_register.add_argument("--model", default=None, help="Override model slug")
    p_register.add_argument("--version", default=None, help="Override version string")
    p_register.add_argument(
        "--device-id", default=None, dest="device_id", help="Override device ID"
    )
    p_register.add_argument(
        "--dry-run",
        action="store_true",
        dest="dry_run",
        help="Validate config and show what would be registered without making API calls",
    )

    # castor fix
    p_fix = sub.add_parser(
        "fix",
        help="Auto-fix common issues found by doctor",
        epilog="Example: castor fix --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_fix.add_argument("--config", default=None, help="RCAN config file (optional)")

    # castor repl
    p_repl = sub.add_parser(
        "repl",
        help="Python REPL with brain, driver, camera pre-loaded",
        epilog="Example: castor repl --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_repl.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")

    # castor record
    p_record = sub.add_parser(
        "record",
        help="Record a perception-action session to JSONL",
        epilog="Example: castor record --config robot.rcan.yaml --output session.jsonl",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_record.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_record.add_argument("--output", "-o", default="session.jsonl", help="Output JSONL file")
    p_record.add_argument("--simulate", action="store_true", help="Run without hardware")

    # castor replay
    p_replay = sub.add_parser(
        "replay",
        help="Replay a recorded session from JSONL or via gateway API",
        epilog=(
            "Examples:\n"
            "  castor replay session.jsonl --execute --config robot.rcan.yaml\n"
            "  castor replay --url http://localhost:8000 --list\n"
            "  castor replay --url http://localhost:8000 --last 20 --dry-run\n"
            "  castor replay --url http://localhost:8000 --start <id> --end <id> --speed 2"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_replay.add_argument(
        "recording",
        nargs="?",
        default=None,
        help="Path to the .jsonl recording file (omit for API mode with --url)",
    )
    p_replay.add_argument("--execute", action="store_true", help="Re-execute actions on hardware")
    p_replay.add_argument("--config", default=None, help="RCAN config file (required if --execute)")
    # API replay flags (#328)
    p_replay.add_argument("--url", default=None, help="Gateway base URL for API replay mode")
    p_replay.add_argument("--token", default=None, help="Bearer token for gateway auth")
    p_replay.add_argument("--start", default=None, help="Start episode ID for trajectory replay")
    p_replay.add_argument("--end", default=None, help="End episode ID for trajectory replay")
    p_replay.add_argument(
        "--last", type=int, default=None, metavar="N", help="Replay last N episodes"
    )
    p_replay.add_argument(
        "--speed", type=float, default=1.0, help="Playback speed multiplier (default 1.0)"
    )
    p_replay.add_argument(
        "--dry-run", action="store_true", help="Show replay plan without executing"
    )
    p_replay.add_argument(
        "--list", action="store_true", help="List recent episodes from the gateway"
    )

    # castor benchmark
    p_bench = sub.add_parser(
        "benchmark",
        help="Profile perception-action loop performance, or compare provider latency/cost",
        epilog=(
            "Examples:\n"
            "  castor benchmark --config robot.rcan.yaml --iterations 5\n"
            "  castor benchmark --providers google,openai --rounds 3\n"
            "  castor benchmark --providers anthropic --rounds 5 --output results.json\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_bench.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_bench.add_argument(
        "--iterations", type=int, default=3, help="Number of iterations (default: 3)"
    )
    p_bench.add_argument("--simulate", action="store_true", help="Skip hardware driver")
    p_bench.add_argument(
        "--providers",
        default=None,
        help="Comma-separated provider names to benchmark (e.g. google,openai,anthropic). "
        "Omit to use the single-config hardware benchmark.",
    )
    p_bench.add_argument(
        "--rounds",
        type=int,
        default=3,
        help="Number of prompt-suite rounds per provider (default: 3; used with --providers)",
    )
    p_bench.add_argument(
        "--output",
        default=None,
        help="Write benchmark results to this JSON file (used with --providers)",
    )

    # castor lint
    p_lint = sub.add_parser(
        "lint",
        help="Deep config validation beyond JSON schema",
        epilog="Example: castor lint --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_lint.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")

    # castor validate
    p_validate = sub.add_parser(
        "validate",
        help="Run RCAN conformance checks",
        epilog=(
            "Examples:\n"
            "  castor validate --config bot.rcan.yaml       # RCAN conformance check\n"
            "  castor validate --config bot.rcan.yaml --category safety\n"
            "  castor validate --config bot.rcan.yaml --json\n"
            "  castor validate --config bot.rcan.yaml --strict\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_validate.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_validate.add_argument(
        "--category",
        default=None,
        help="Only run checks in this category (safety/provider/protocol/performance/hardware)",
    )
    p_validate.add_argument("--json", action="store_true", help="Output results as JSON")
    p_validate.add_argument("--strict", action="store_true", help="Exit with non-zero if any WARN")
    p_validate.set_defaults(func=cmd_validate)

    # castor swarm
    p_swarm = sub.add_parser(
        "swarm",
        help="Multi-robot swarm management",
        epilog=(
            "Examples:\n"
            "  castor swarm status\n"
            "  castor swarm status --json\n"
            '  castor swarm command "move forward"\n'
            '  castor swarm command "turn" --node alex\n'
            "  castor swarm stop\n"
            "  castor swarm sync config/robot.rcan.yaml\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_swarm.add_argument(
        "--swarm-config",
        dest="swarm_config",
        default=None,
        help="Path to swarm.yaml (default: config/swarm.yaml)",
    )
    p_swarm.add_argument(
        "--json",
        action="store_true",
        help="Output raw JSON instead of formatted table",
    )
    p_swarm.add_argument(
        "--timeout",
        type=float,
        default=3.0,
        help="Per-node HTTP timeout in seconds (default: 3.0)",
    )
    p_swarm_sub = p_swarm.add_subparsers(dest="swarm_subcmd")

    # castor swarm status
    p_swarm_status = p_swarm_sub.add_parser("status", help="Show health table for all nodes")
    p_swarm_status.add_argument("--json", action="store_true", help="Output raw JSON")
    p_swarm_status.add_argument("--swarm-config", dest="swarm_config", default=None)
    p_swarm_status.add_argument("--timeout", type=float, default=3.0)

    # castor swarm command
    p_swarm_cmd = p_swarm_sub.add_parser("command", help="Send instruction to all or one node")
    p_swarm_cmd.add_argument("instruction", help="Natural-language instruction to send")
    p_swarm_cmd.add_argument("--node", default=None, help="Target a specific node by name")
    p_swarm_cmd.add_argument("--json", action="store_true", help="Output raw JSON")
    p_swarm_cmd.add_argument("--swarm-config", dest="swarm_config", default=None)
    p_swarm_cmd.add_argument("--timeout", type=float, default=10.0)

    # castor swarm stop
    p_swarm_stop = p_swarm_sub.add_parser("stop", help="Emergency stop all nodes")
    p_swarm_stop.add_argument("--json", action="store_true", help="Output raw JSON")
    p_swarm_stop.add_argument("--swarm-config", dest="swarm_config", default=None)
    p_swarm_stop.add_argument("--timeout", type=float, default=5.0)

    # castor swarm sync
    p_swarm_sync = p_swarm_sub.add_parser("sync", help="Push RCAN config reload to all nodes")
    p_swarm_sync.add_argument("config_path", help="Path to RCAN config file to push")
    p_swarm_sync.add_argument("--json", action="store_true", help="Output raw JSON")
    p_swarm_sync.add_argument("--swarm-config", dest="swarm_config", default=None)
    p_swarm_sync.add_argument("--timeout", type=float, default=10.0)

    # castor swarm update
    p_swarm_update = p_swarm_sub.add_parser(
        "update", help="Update OpenCastor on all swarm nodes via SSH"
    )
    p_swarm_update.add_argument(
        "--dry-run", dest="dry_run", action="store_true", help="Print commands without executing"
    )
    p_swarm_update.add_argument("--swarm-config", dest="swarm_config", default=None)

    p_swarm.set_defaults(func=cmd_swarm)

    # castor update
    p_update = sub.add_parser(
        "update",
        help="Update OpenCastor to the latest version",
        epilog=(
            "Examples:\n"
            "  castor update                     # Update to latest\n"
            "  castor update --dry-run           # Preview without changing\n"
            "  castor update --version 2026.2.0  # Pin to a specific version\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_update.add_argument(
        "--dry-run", dest="dry_run", action="store_true", help="Print commands without executing"
    )
    p_update.add_argument(
        "--version",
        default=None,
        metavar="X.Y.Z",
        help="Pin to a specific version tag or pip specifier",
    )

    # castor learn
    p_learn = sub.add_parser(
        "learn",
        help="Interactive step-by-step tutorial",
        epilog="Example: castor learn --lesson 3",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_learn.add_argument("--lesson", type=int, default=None, help="Jump to a specific lesson (1-7)")

    # castor improve
    p_improve = sub.add_parser(
        "improve",
        help="Self-improving loop (Sisyphus pattern) — analyze episodes and apply improvements",
        epilog=(
            "Examples:\n"
            "  castor improve --enable              # Enable self-improving loop\n"
            "  castor improve --disable             # Disable self-improving loop\n"
            "  castor improve --episodes 10         # Analyze last 10 episodes\n"
            "  castor improve --status              # Show improvement history\n"
            "  castor improve --improvements         # List all applied patches\n"
            "  castor improve --rollback abc123      # Rollback a specific patch\n"
            "  castor improve --batch --config bot.rcan.yaml  # Batch analysis\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_improve.add_argument("--config", help="RCAN config file")
    p_improve.add_argument(
        "--episodes", type=int, default=5, help="Number of episodes to analyze (default: 5)"
    )
    p_improve.add_argument("--status", action="store_true", help="Show improvement stats")
    p_improve.add_argument("--improvements", action="store_true", help="List applied improvements")
    p_improve.add_argument("--rollback", type=str, help="Rollback a specific improvement by ID")
    p_improve.add_argument("--batch", action="store_true", help="Run ALMA batch consolidation")
    p_improve.add_argument("--dry-run", action="store_true", help="Analyze but don't apply patches")
    p_improve.add_argument(
        "--enable", action="store_true", help="Enable self-improving loop in RCAN config"
    )
    p_improve.add_argument(
        "--disable", action="store_true", help="Disable self-improving loop in RCAN config"
    )

    # castor deploy (issue #103)
    p_deploy = sub.add_parser(
        "deploy",
        help="SSH-push RCAN config and restart service on remote Pi",
        epilog=(
            "Examples:\n"
            "  castor deploy pi@192.168.1.10 --config robot.rcan.yaml\n"
            "  castor deploy pi@192.168.1.10 --full\n"
            "  castor deploy pi@192.168.1.10 --status\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_deploy.add_argument("host", help="Remote host (user@hostname or hostname)")
    p_deploy.add_argument("--config", default="robot.rcan.yaml", help="RCAN config to push")
    p_deploy.add_argument("--full", action="store_true", help="Also run pip install on remote")
    p_deploy.add_argument(
        "--status", action="store_true", dest="status", help="Show remote service status only"
    )
    p_deploy.add_argument(
        "--dry-run", action="store_true", dest="dry_run", help="Preview without executing"
    )
    p_deploy.add_argument("--port", type=int, default=22, help="SSH port (default: 22)")
    p_deploy.add_argument("--key", default=None, help="SSH private key file path")
    p_deploy.add_argument(
        "--no-restart",
        action="store_true",
        dest="no_restart",
        help="Push config only, skip restart",
    )

    # castor agents
    p_agents = sub.add_parser("agents", help="Manage robot agents")
    p_agents.add_argument(
        "action",
        choices=["list", "status", "spawn", "stop"],
        nargs="?",
        default="list",
    )
    p_agents.add_argument("--name", help="Agent name for spawn/stop")
    p_agents.add_argument("--config", help="RCAN config path")
    p_agents.set_defaults(func=cmd_agents)

    # castor export
    p_export = sub.add_parser(
        "export",
        help="Export config bundle (secrets redacted)",
        epilog="Example: castor export --config robot.rcan.yaml --format json",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_export.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_export.add_argument("--output", "-o", default=None, help="Output file path")
    p_export.add_argument(
        "--format",
        choices=["zip", "json", "tgz"],
        default="zip",
        help="Export format: zip (default), json, or tgz (includes episodes)",
    )
    p_export.add_argument(
        "--episodes",
        type=int,
        default=100,
        metavar="N",
        help="Max episodes to include in tgz bundle (default: 100)",
    )
    p_export.set_defaults(func=cmd_export)

    p_finetune = sub.add_parser(
        "export-finetune",
        help="Export episode memory as a fine-tuning dataset (Alpaca / ChatML / ShareGPT)",
        epilog=(
            "Examples:\n"
            "  castor export-finetune --format chatml --output dataset.jsonl\n"
            "  castor export-finetune --format alpaca --limit 500 --require-action\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_finetune.add_argument(
        "--format",
        choices=["jsonl", "alpaca", "sharegpt", "chatml"],
        default="chatml",
        help="Fine-tuning format (default: chatml)",
    )
    p_finetune.add_argument("--output", "-o", default=None, help="Output file path")
    p_finetune.add_argument(
        "--limit", type=int, default=1000, help="Max episodes to export (default: 1000)"
    )
    p_finetune.add_argument(
        "--require-action",
        dest="require_action",
        action="store_true",
        help="Only export episodes that have a parsed action",
    )

    # --- OpenClaw-inspired commands (batch 4) ---

    # castor approvals
    p_approvals = sub.add_parser(
        "approvals",
        help="Manage approval queue for dangerous commands",
        epilog="Example: castor approvals --approve 1",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_approvals.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")
    p_approvals.add_argument("--approve", default=None, help="Approve a pending action by ID")
    p_approvals.add_argument("--deny", default=None, help="Deny a pending action by ID")
    p_approvals.add_argument("--clear", action="store_true", help="Clear resolved approvals")

    # castor schedule
    p_sched = sub.add_parser(
        "schedule",
        help="Manage scheduled/recurring tasks",
        epilog=(
            "Examples:\n"
            "  castor schedule list\n"
            "  castor schedule add --name patrol --command 'castor run ...' --cron '*/30 * * * *'\n"
            "  castor schedule remove --name patrol\n"
            "  castor schedule install\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_sched.add_argument(
        "action",
        nargs="?",
        default="list",
        choices=["list", "add", "remove", "install"],
        help="Action to perform",
    )
    p_sched.add_argument("--name", default=None, help="Task name")
    p_sched.add_argument("--command", dest="task_command", default=None, help="Command to run")
    p_sched.add_argument("--cron", default=None, help="Cron expression (e.g. '*/30 * * * *')")
    p_sched.add_argument("--config", default=None, help="RCAN config file (optional)")

    # castor configure
    p_conf = sub.add_parser(
        "configure",
        help="Interactive config editor (post-wizard tweaks)",
        epilog="Example: castor configure --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_conf.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")

    # castor search
    p_search = sub.add_parser(
        "search",
        help="Search operational logs and session recordings",
        epilog="Example: castor search 'battery low' --since 7d",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_search.add_argument("query", help="Search query (keywords or phrases)")
    p_search.add_argument("--since", default=None, help="Time window (e.g. 7d, 24h, 1w)")
    p_search.add_argument("--log-file", default=None, help="Specific log file to search")
    p_search.add_argument(
        "--max-results", type=int, default=20, help="Maximum results (default: 20)"
    )

    # castor network
    p_net = sub.add_parser(
        "network",
        help="Network config and VPN/Tailscale exposure",
        epilog=(
            "Examples:\n"
            "  castor network status\n"
            "  castor network expose --mode serve\n"
            "  castor network expose --mode funnel\n"
            "  castor network expose --mode off\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_net.add_argument(
        "action",
        nargs="?",
        default="status",
        choices=["status", "expose"],
        help="Action to perform",
    )
    p_net.add_argument(
        "--mode",
        default=None,
        choices=["serve", "funnel", "off"],
        help="Exposure mode (for expose action)",
    )
    p_net.add_argument("--port", type=int, default=8000, help="Gateway port")
    p_net.add_argument("--config", default=None, help="RCAN config file (optional)")

    # castor privacy
    p_priv = sub.add_parser(
        "privacy",
        help="Show privacy policy (sensor access controls)",
        epilog="Example: castor privacy --config robot.rcan.yaml",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_priv.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")

    # --- Batch 5: Polish & quality-of-life ---

    # castor update-check
    sub.add_parser(
        "update-check",
        help="Check PyPI for newer versions",
        epilog="Example: castor update-check",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    # castor profile
    p_profile = sub.add_parser(
        "profile",
        help="Manage named config profiles",
        epilog=(
            "Examples:\n"
            "  castor profile list\n"
            "  castor profile save indoor --config robot.rcan.yaml\n"
            "  castor profile use indoor\n"
            "  castor profile remove indoor\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_profile.add_argument(
        "action",
        nargs="?",
        default="list",
        choices=["list", "save", "use", "remove"],
        help="Action to perform",
    )
    p_profile.add_argument("name", nargs="?", default=None, help="Profile name")
    p_profile.add_argument("--config", default="robot.rcan.yaml", help="RCAN config file")

    # castor test
    p_pytest = sub.add_parser(
        "test",
        help="Run the test suite (pytest wrapper)",
        epilog="Example: castor test -v -k test_doctor",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_pytest.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
    p_pytest.add_argument("--keyword", "-k", default=None, help="pytest -k filter")

    # castor diff
    p_diff = sub.add_parser(
        "diff",
        help="Compare two RCAN config files",
        epilog="Example: castor diff --config robot.rcan.yaml --baseline robot.rcan.yaml.bak",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_diff.add_argument("--config", default="robot.rcan.yaml", help="Current config file")
    p_diff.add_argument("--baseline", required=True, help="Baseline config to compare against")

    # castor quickstart
    sub.add_parser(
        "quickstart",
        help="One-command setup: wizard + demo",
        epilog="Example: castor quickstart",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    # castor plugins [install <source>]
    p_plugins = sub.add_parser(
        "plugins",
        help="List loaded plugins or install a new one",
        epilog=(
            "Examples:\n"
            "  castor plugins\n"
            "  castor plugins install ./my_plugin.py\n"
            "  castor plugins install https://github.com/user/my-plugin\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_plugins_sub = p_plugins.add_subparsers(dest="plugin_subcmd")
    p_plugin_install = p_plugins_sub.add_parser("install", help="Install a plugin")
    p_plugin_install.add_argument("source", help="Local .py file path or git URL")

    # castor plugin install <url-or-path>
    p_plugin = sub.add_parser(
        "plugin",
        help="Manage plugins (install with provenance tracking)",
        epilog=(
            "Examples:\n"
            "  castor plugin install https://example.com/my_plugin.py\n"
            "  castor plugin install /local/path/my_plugin.py\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_plugin_sub = p_plugin.add_subparsers(dest="plugin_subcommand")
    p_plugin_install = p_plugin_sub.add_parser(
        "install",
        help="Install a plugin from a URL or local path",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_plugin_install.add_argument(
        "source",
        help="URL or local file path to the plugin .py file",
    )

    # castor scan — detect connected peripherals
    p_scan = sub.add_parser(
        "scan",
        help="Auto-detect connected hardware peripherals",
        epilog=(
            "Examples:\n"
            "  castor scan\n"
            "  castor scan --json\n"
            "  castor scan --no-color\n"
            "  castor scan --i2c-bus 1\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_scan.add_argument("--json", action="store_true", help="Output as JSON")
    p_scan.add_argument("--no-color", action="store_true", help="Plain text output")
    p_scan.add_argument(
        "--i2c-bus",
        type=int,
        default=1,
        dest="i2c_bus",
        help="I2C bus to scan (default: 1)",
    )
    p_scan.add_argument(
        "--suggest",
        action="store_true",
        default=True,
        help="Print suggested RCAN config snippets (default: true)",
    )
    p_scan.add_argument(
        "--refresh",
        action="store_true",
        help="Force fresh scan, bypass TTL cache",
    )
    p_scan.add_argument(
        "--preset-only",
        action="store_true",
        dest="preset_only",
        help="Only print suggested preset name + confidence",
    )

    # castor stop — send SIGTERM to running gateway (#556)
    sub.add_parser("stop", help="Stop the running gateway (reads ~/.opencastor/gateway.pid)")

    # castor daemon — systemd auto-start service management
    p_daemon = sub.add_parser(
        "daemon",
        help="Manage the auto-start system service (systemd)",
        epilog=(
            "Examples:\n"
            "  castor daemon enable --config bob.rcan.yaml   # Install + start on boot\n"
            "  castor daemon status                           # Is it running?\n"
            "  castor daemon logs                             # Recent journal output\n"
            "  castor daemon restart                          # Restart the service\n"
            "  castor daemon disable                          # Remove auto-start\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_daemon.add_argument(
        "action",
        nargs="?",
        default="status",
        choices=["enable", "disable", "status", "logs", "restart"],
        help="Action to perform (default: status)",
    )
    p_daemon.add_argument(
        "--config",
        default="robot.rcan.yaml",
        help="RCAN config file the daemon should start with (enable only)",
    )
    p_daemon.add_argument(
        "--user",
        default=None,
        help="System user to run the service as (default: current user)",
    )
    p_daemon.add_argument(
        "--lines",
        type=int,
        default=50,
        help="Number of log lines to show (logs action)",
    )

    # castor hub
    p_flash = sub.add_parser(
        "flash",
        help="Flash ACB v2.0 firmware via DFU-util",
        epilog=(
            "Examples:\n"
            "  castor flash --id motor_0\n"
            "  castor flash --id motor_0 --version latest --confirm\n"
            "  castor flash --id motor_0 --firmware acb-v1.2.bin --confirm\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_flash.add_argument("--id", default="acb", help="Driver ID to flash (default: acb)")
    p_flash.add_argument("--firmware", default=None, help="Path to .bin firmware file")
    p_flash.add_argument(
        "--version", default="latest", help="Firmware version tag to fetch (default: latest)"
    )
    p_flash.add_argument(
        "--confirm", action="store_true", help="Skip interactive confirmation prompt"
    )

    p_hub = sub.add_parser(
        "hub",
        help="Community recipe hub — browse, share, and install configs",
        epilog=(
            "Examples:\n"
            "  castor hub browse\n"
            "  castor hub browse --category home --provider huggingface\n"
            "  castor hub search 'outdoor patrol'\n"
            "  castor hub show picar-patrol-a1b2c3\n"
            "  castor hub install picar-patrol-a1b2c3\n"
            "  castor hub share --config robot.rcan.yaml --docs BUILD.md LESSONS.md\n"
            "  castor hub share --config robot.rcan.yaml --submit\n"
            "  castor hub share --config robot.rcan.yaml --dry-run\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_hub.add_argument(
        "action",
        nargs="?",
        default="browse",
        choices=[
            "browse",
            "search",
            "show",
            "install",
            "share",
            "rate",
            "categories",
            "list",
            "publish",
        ],
        help="Action to perform (default: browse). Use 'list'/'search'/'install'/'publish' for hub index.",
    )
    p_hub.add_argument(
        "--rating", type=int, choices=[1, 2, 3, 4, 5], help="Star rating (1-5) for hub rate"
    )
    p_hub.add_argument("query", nargs="?", default=None, help="Search query or recipe ID")
    p_hub.add_argument("--config", default=None, help="RCAN config to share")
    p_hub.add_argument(
        "--docs", nargs="*", default=None, help="Markdown docs to include with shared recipe"
    )
    p_hub.add_argument(
        "--category",
        default=None,
        choices=[
            "home",
            "outdoor",
            "service",
            "industrial",
            "education",
            "agriculture",
            "security",
            "companion",
            "art",
            "custom",
        ],
        help="Filter by category",
    )
    p_hub.add_argument(
        "--difficulty",
        default=None,
        choices=["beginner", "intermediate", "advanced"],
        help="Filter by difficulty",
    )
    p_hub.add_argument("--provider", default=None, help="Filter by AI provider")
    p_hub.add_argument("--dry-run", action="store_true", help="Preview share without writing")
    p_hub.add_argument(
        "--submit",
        action="store_true",
        help="Auto-create a GitHub PR after packaging (requires gh CLI)",
    )
    p_hub.add_argument("--verbose", "-v", action="store_true", help="Show full details")
    p_hub.add_argument("--output", "-o", default=None, help="Output directory for install/share")

    # castor audit
    # castor safety
    p_safety = sub.add_parser(
        "safety",
        help="Safety protocol management",
        epilog="Examples:\n  castor safety rules\n  castor safety rules --category motion\n",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_safety.add_argument(
        "safety_action",
        nargs="?",
        default="rules",
        choices=["rules"],
        help="Safety sub-command (default: rules)",
    )
    p_safety.add_argument("--category", default=None, help="Filter by category")
    p_safety.add_argument(
        "--config",
        default=None,
        help="Path to safety protocol YAML config",
    )

    p_audit = sub.add_parser(
        "audit",
        help="View the append-only audit log",
        epilog=(
            "Examples:\n"
            "  castor audit\n"
            "  castor audit --since 24h\n"
            "  castor audit --event motor_command\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_audit.add_argument("--since", default=None, help="Time window (e.g. 24h, 7d)")
    p_audit.add_argument(
        "--event", default=None, help="Filter by event type (motor_command, approval, error, etc.)"
    )
    p_audit.add_argument("--limit", type=int, default=50, help="Max entries to show (default: 50)")
    p_audit.add_argument("--verify", action="store_true", help="Verify hash chain integrity")

    # castor monitor
    p_monitor = sub.add_parser(
        "monitor",
        help="Show sensor readings (CPU temp, memory, disk, load)",
    )
    p_monitor.add_argument("--watch", action="store_true", help="Continuous monitoring")
    p_monitor.add_argument(
        "--interval", type=float, default=5.0, help="Seconds between readings (default: 5)"
    )

    # castor login
    p_login = sub.add_parser(
        "login",
        help="Authenticate with AI providers",
        epilog=(
            "Examples:\n"
            "  castor login anthropic     # Setup-token or API key\n"
            "  castor login claude        # Same as anthropic\n"
            "  castor login huggingface\n"
            "  castor login hf --token hf_xxxx\n"
            "  castor login hf --list-models\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p_login.add_argument(
        "service",
        nargs="?",
        default="huggingface",
        choices=["huggingface", "hf", "ollama"],
        help="Service to authenticate with (default: huggingface)",
    )
    p_login.add_argument("--token", default=None, help="API token (prompted if not provided)")
    p_login.add_argument(
        "--list-models",
        action="store_true",
        help="List trending models after login",
    )
    p_login.add_argument(
        "--task",
        default="text-generation",
        help="Model task filter for --list-models (default: text-generation)",
    )

    # castor init — generate starter config (non-interactive)
    p_init = sub.add_parser(
        "init",
        help="Generate a minimal starter robot.rcan.yaml config",
        description="Scaffold a minimal valid robot.rcan.yaml without the interactive wizard.",
    )
    p_init.add_argument(
        "--output", "-o", default="robot.rcan.yaml", help="Output path (default: robot.rcan.yaml)"
    )
    p_init.add_argument("--name", "-n", default=None, help="Robot name (default: hostname)")
    p_init.add_argument("--overwrite", action="store_true", help="Overwrite existing config file")
    p_init.add_argument(
        "--print", action="store_true", help="Print config to stdout instead of writing to file"
    )

    # Shell completions (argcomplete)
    try:
        import argcomplete

        argcomplete.autocomplete(parser)
    except ImportError:
        pass

    args = parser.parse_args()

    commands = {
        "run": cmd_run,
        "gateway": cmd_gateway,
        "mcp": cmd_mcp,
        "wizard": cmd_wizard,
        "dashboard": cmd_dashboard,
        "dashboard-tui": cmd_dashboard_tui,
        "token": cmd_token,
        "discover": cmd_discover,
        "memory": cmd_memory,
        "fleet": cmd_fleet,
        "logs": cmd_logs,
        "streaming": cmd_streaming,
        "doctor": cmd_doctor,
        "update": cmd_update,
        "node": cmd_node,
        "inspect": cmd_inspect,
        "verification": cmd_verification,
        "register": cmd_register,
        "compliance": cmd_compliance,
        "demo": cmd_demo,
        "test-hardware": cmd_test_hardware,
        "calibrate": cmd_calibrate,
        "backup": cmd_backup,
        "restore": cmd_restore,
        "migrate": cmd_migrate,
        "upgrade": cmd_upgrade,
        "install-service": cmd_install_service,
        "status": cmd_status,
        # Batch 3
        "shell": cmd_shell,
        "watch": cmd_watch,
        "fix": cmd_fix,
        "repl": cmd_repl,
        "record": cmd_record,
        "replay": cmd_replay,
        "benchmark": cmd_benchmark,
        "lint": cmd_lint,
        "validate": cmd_validate,
        "swarm": cmd_swarm,
        "learn": cmd_learn,
        "improve": cmd_improve,
        "agents": cmd_agents,
        "export": cmd_export,
        "export-finetune": cmd_export_finetune,
        # Batch 4 (OpenClaw-inspired)
        "approvals": cmd_approvals,
        "schedule": cmd_schedule,
        "configure": cmd_configure,
        "search": cmd_search,
        "network": cmd_network,
        "privacy": cmd_privacy,
        # Batch 5 (polish & quality-of-life)
        "update-check": cmd_update_check,
        "profile": cmd_profile,
        "test": cmd_test,
        "diff": cmd_diff,
        "quickstart": cmd_quickstart,
        "plugins": cmd_plugins,
        "plugin": cmd_plugin,
        "audit": cmd_audit,
        "monitor": _cmd_monitor,
        "safety": cmd_safety,
        "login": cmd_login,
        "flash": cmd_flash,
        "hub": cmd_hub,
        "scan": cmd_scan,
        "stop": cmd_stop,
        "daemon": cmd_daemon,
        "deploy": cmd_deploy,
        # Issue #348
        "snapshot": cmd_snapshot,
        # llmfit model fit analysis
        "fit": lambda _args: __import__(
            "castor.llmfit_helper", fromlist=["run_fit_command"]
        ).run_fit_command(),
        "init": cmd_init,
    }

    # Load plugins and merge any plugin-provided commands
    try:
        from castor.plugins import load_plugins

        registry = load_plugins()
        for name, (handler_fn, _) in registry.commands.items():
            if name not in commands:
                commands[name] = lambda a, fn=handler_fn: fn(a)
    except Exception:
        pass

    handler = commands.get(args.command)
    if handler:
        handler(args)
    else:
        parser.print_help()


def _friendly_error_handler() -> None:
    """Wrap main() with user-friendly error handling and contextual suggestions."""
    try:
        main()
    except KeyboardInterrupt:
        print("\n  Interrupted.\n")
        sys.exit(130)
    except SystemExit:
        raise
    except FileNotFoundError as exc:
        fname = exc.filename or str(exc)
        print(f"\n  File not found: {fname}")
        if ".rcan.yaml" in str(fname):
            print("  Hint: Run `castor wizard` to create a config file.")
        elif ".env" in str(fname):
            print("  Hint: Run `cp .env.example .env` to create your env file.")
        else:
            print("  Check the path and try again.")
        print()
        sys.exit(1)
    except ImportError as exc:
        dep = exc.name or str(exc)
        print(f"\n  Missing dependency: {dep}")
        # Contextual suggestions based on which package is missing
        suggestions = {
            "dynamixel_sdk": "pip install dynamixel-sdk",
            "cv2": "pip install opencv-python-headless",
            "rich": "pip install rich",
            "yaml": "pip install pyyaml",
            "fastapi": "pip install fastapi uvicorn",
            "streamlit": "pip install streamlit",
            "neonize": "pip install opencastor[whatsapp]",
            "telegram": "pip install opencastor[telegram]",
            "discord": "pip install opencastor[discord]",
            "slack_bolt": "pip install opencastor[slack]",
        }
        hint = suggestions.get(dep)
        if hint:
            print(f"  Hint: {hint}")
        else:
            print("  Hint: pip install -e '.[dev]' or castor fix")
        print()
        sys.exit(1)
    except ConnectionError:
        print("\n  Connection error: Could not reach the server.")
        print("  Hint: Check your network, or run `castor network status`.\n")
        sys.exit(1)
    except Exception as exc:
        print(f"\n  Unexpected error: {exc}")
        print("  Suggestions:")
        print("    1. Run `castor doctor` to check your setup")
        print("    2. Run `castor fix` to auto-repair common issues")
        print("    3. Set LOG_LEVEL=DEBUG and try again for details")
        print()
        if os.getenv("LOG_LEVEL", "").upper() == "DEBUG":
            traceback.print_exc()
        sys.exit(1)


if __name__ == "__main__":
    _friendly_error_handler()
