#!/usr/bin/env python3
"""Orze CLI — GPU experiment orchestrator.

Usage:
    python -m orze.cli                          # all GPUs, continuous
    python -m orze.cli -c orze.yaml --gpus 0,1  # with project config
    python -m orze.cli --once                   # one cycle then exit
    python -m orze.cli --report-only            # regenerate report
    python -m orze.cli --init                   # initialize new project
    python -m orze.cli --admin                  # launch admin panel
"""

import argparse
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path

from orze import __version__
from orze.core.config import load_project_config
from orze.hardware.gpu import detect_all_gpus

logger = logging.getLogger("orze")

_STAR_REPO = "erikhenriksson/orze"
_STAR_MARKER = Path.home() / ".orze_starred"


def _maybe_star():
    """Prompt user to star the repo on first run (once only)."""
    if _STAR_MARKER.exists():
        return
    if not shutil.which("gh"):
        _STAR_MARKER.touch()
        return
    # Check if gh is authenticated
    try:
        subprocess.run(
            ["gh", "auth", "status"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=5,
        )
    except Exception:
        _STAR_MARKER.touch()
        return
    try:
        answer = input(
            f"\n\033[1mEnjoy Orze?\033[0m Press Enter to \u2b50 us on GitHub, "
            f"or type N to skip: "
        ).strip().lower()
    except (EOFError, KeyboardInterrupt):
        _STAR_MARKER.touch()
        return
    if answer in ("", "y", "yes"):
        try:
            subprocess.run(
                ["gh", "api", "-X", "PUT", f"/user/starred/{_STAR_REPO}"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                timeout=10,
            )
            print("\033[32mThanks for starring!\033[0m\n")
        except Exception:
            pass
    _STAR_MARKER.touch()

# ---------------------------------------------------------------------------
# Template for --init scaffolding
# ---------------------------------------------------------------------------

BASELINE_TRAIN_PY = '''\
#!/usr/bin/env python3
"""Baseline training script generated by orze --init.

Demonstrates the full orze contract with a task-agnostic demo
(linear regression on synthetic data). Only depends on pyyaml.

Replace the training logic with your own — keep the argument
parsing and metrics output as-is.

Orze calls this script as:
    python train.py --idea-id <id> --results-dir <dir> --ideas-md <file> --config <yaml>

CONTRACT:
  1. Read your idea config from results-dir/idea-id/idea_config.yaml
     (orze writes this file before launching)
  2. Do your training
  3. Write metrics to results-dir/idea-id/metrics.json with at minimum:
     {"status": "COMPLETED", "<primary_metric>": <value>, "training_time": <seconds>}
  4. Exit 0 on success, non-zero on failure
"""
import argparse
import json
import math
import random
import re
import time
from pathlib import Path

import yaml


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def deep_merge(base: dict, override: dict) -> dict:
    """Recursively merge *override* into *base* (returns new dict)."""
    merged = dict(base)
    for k, v in override.items():
        if k in merged and isinstance(merged[k], dict) and isinstance(v, dict):
            merged[k] = deep_merge(merged[k], v)
        else:
            merged[k] = v
    return merged


def load_idea_config(ideas_md: str, idea_id: str) -> dict:
    """Parse ideas.md and return the YAML config block for *idea_id*."""
    text = Path(ideas_md).read_text(encoding="utf-8")
    pattern = rf"^##\\s+{re.escape(idea_id)}\\b.*?```ya?ml\\s*\\n(.*?)```"
    m = re.search(pattern, text, re.MULTILINE | re.DOTALL)
    if not m:
        raise ValueError(f"Idea {idea_id} not found in {ideas_md}")
    return yaml.safe_load(m.group(1)) or {}


# ---------------------------------------------------------------------------
# Demo training loop — linear regression on synthetic data
# ---------------------------------------------------------------------------

def _generate_data(n=200, noise=0.1, seed=42):
    """y = 3*x + 2 + noise."""
    rng = random.Random(seed)
    xs = [rng.gauss(0, 1) for _ in range(n)]
    ys = [3.0 * x + 2.0 + rng.gauss(0, noise) for x in xs]
    return xs, ys


def _train_linear(xs, ys, lr=0.01, epochs=50):
    """Gradient descent on y = w*x + b.  Returns (w, b, history)."""
    w, b = 0.0, 0.0
    n = len(xs)
    history = []
    for epoch in range(1, epochs + 1):
        # forward
        preds = [w * x + b for x in xs]
        loss = sum((p - y) ** 2 for p, y in zip(preds, ys)) / n
        # gradients
        dw = sum(2 * (p - y) * x for p, y, x in zip(preds, ys, xs)) / n
        db = sum(2 * (p - y) for p, y in zip(preds, ys)) / n
        w -= lr * dw
        b -= lr * db
        history.append(loss)
    return w, b, history


def _r2_score(ys, preds):
    mean_y = sum(ys) / len(ys)
    ss_res = sum((y - p) ** 2 for y, p in zip(ys, preds))
    ss_tot = sum((y - mean_y) ** 2 for y in ys)
    return 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--idea-id", required=True)
    parser.add_argument("--results-dir", required=True)
    parser.add_argument("--ideas-md", required=True)
    parser.add_argument("--config", required=True, help="Path to base config YAML")
    args = parser.parse_args()

    idea_dir = Path(args.results_dir) / args.idea_id
    idea_dir.mkdir(parents=True, exist_ok=True)

    # 1. Load base config
    base_cfg = {}
    if Path(args.config).exists():
        base_cfg = yaml.safe_load(Path(args.config).read_text()) or {}

    # 2. Load idea-specific config (written by orze before launch)
    idea_cfg_path = idea_dir / "idea_config.yaml"
    idea_cfg = {}
    if idea_cfg_path.exists():
        idea_cfg = yaml.safe_load(idea_cfg_path.read_text()) or {}

    # 3. Merge: idea overrides base
    cfg = deep_merge(base_cfg, idea_cfg)
    print(f"Training {args.idea_id} with config: {cfg}")

    # 4. Extract hyperparameters (with defaults)
    lr = float(cfg.get("learning_rate", 0.01))
    epochs = int(cfg.get("epochs", 50))
    noise = float(cfg.get("noise", 0.1))
    seed = int(cfg.get("seed", 42))

    # 5. Train
    t0 = time.time()
    xs_train, ys_train = _generate_data(n=160, noise=noise, seed=seed)
    xs_val, ys_val = _generate_data(n=40, noise=noise, seed=seed + 1)

    w, b, history = _train_linear(xs_train, ys_train, lr=lr, epochs=epochs)
    training_time = time.time() - t0

    # 6. Evaluate
    val_preds = [w * x + b for x in xs_val]
    val_loss = sum((p - y) ** 2 for p, y in zip(val_preds, ys_val)) / len(ys_val)
    train_loss = history[-1] if history else float("inf")
    r2 = _r2_score(ys_val, val_preds)

    # 7. Write metrics (orze reads this to determine success/failure)
    metrics = {
        "status": "COMPLETED",
        "val_loss": round(val_loss, 6),
        "train_loss": round(train_loss, 6),
        "r2": round(r2, 6),
        "epochs": epochs,
        "training_time": round(training_time, 2),
    }
    (idea_dir / "metrics.json").write_text(json.dumps(metrics, indent=2))
    print(f"Done. Metrics: {metrics}")


if __name__ == "__main__":
    main()
'''

RESEARCH_RULES_TEMPLATE = '''\
# Research Rules
<!-- Edit this file to steer the research agent. -->

## Goal
Improve performance on the task defined in this project.
Read `{results_dir}/report.md` for current results.

## Current state
- Cycle: {cycle}
- Completed experiments: {completed}
- Queued experiments: {queued}

## Strategy
1. Start with simple baselines (different learning rates, epochs).
2. Try architectural changes one at a time.
3. Combine the best-performing ideas.
4. Avoid changing more than two hyperparameters at once.

## Idea format
Append new ideas to `{ideas_file}` using this format:

```markdown
## idea-XXXX: Short descriptive title
- **Priority**: high|medium|low

\\```yaml
learning_rate: 0.001
epochs: 20
# ... any hyperparameters your train script accepts
\\```
```

## Rules
- **Append-only** — never edit or delete existing ideas.
- **Unique IDs** — increment from the highest existing idea number.
- **Complete configs** — every idea must have all required hyperparameters.
- **One change at a time** — isolate variables for clear attribution.
'''


# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------

def setup_logging(verbose: bool = False):
    """Configure logging with timestamps."""
    level = logging.DEBUG if verbose else logging.INFO
    fmt = "%(asctime)s [%(levelname)s] %(message)s"
    datefmt = "%Y-%m-%d %H:%M:%S"
    logging.basicConfig(level=level, format=fmt, datefmt=datefmt)


# ---------------------------------------------------------------------------
# Uninstall
# ---------------------------------------------------------------------------

# Files inside each idea dir to keep (research results)
_IDEA_KEEP = {
    "metrics.json",
    "eval_report.json",
    "eval_output.log",
    "best_model.pt",
    "best_model.pth",
    "model.pt",
    "model.pth",
    "checkpoint.pt",
    "checkpoint.pth",
}

# Top-level results_dir files to keep
_RESULTS_KEEP = {"report.md", "status.json"}


def _do_uninstall(cfg: dict):
    """Full uninstall: stop orze, strip runtime files, pip uninstall.

    Preserves research results:
        - results/{idea-*}/metrics.json  (core experiment data)
        - results/{idea-*}/eval_report.json, eval_output.log
        - results/report.md, status.json
        - ideas.md, idea_lake.db
    """
    import shutil
    import subprocess

    results_dir = Path(cfg["results_dir"])
    print("\n\033[1mOrze — Uninstall\033[0m")
    print("-----------------")
    print(f"Results dir : {results_dir.resolve()}")
    print(f"Config      : {cfg.get('_config_path', 'orze.yaml')}")
    print()

    # --- 1. Stop running instances -----------------------------------
    print("[1/5] Stopping running orze instances...")
    stop_path = results_dir / ".orze_stop_all"
    if results_dir.exists():
        stop_path.write_text("uninstall", encoding="utf-8")
        # Also send SIGTERM to any PID files we find
        for pid_file in results_dir.glob(".orze.pid*"):
            try:
                pid = int(pid_file.read_text(encoding="utf-8").strip())
                os.kill(pid, 15)  # SIGTERM
                print(f"  Sent SIGTERM to PID {pid}")
            except (ValueError, ProcessLookupError, PermissionError, OSError):
                pass
    import time
    time.sleep(2)  # give processes a moment to exit

    # --- 2. Clean results directory ----------------------------------
    print("[2/5] Cleaning runtime files from results/...")
    if results_dir.exists():
        # Fast path: rename the whole dir, create a fresh one, move only
        # keeper files back (O(1) renames), then bulk-delete the old tree.
        stale = results_dir.with_name(results_dir.name + "._orze_uninstall_tmp")
        if stale.exists():
            shutil.rmtree(stale, ignore_errors=True)
        results_dir.rename(stale)
        results_dir.mkdir()

        # Restore top-level keeper files (report.md, status.json)
        for name in _RESULTS_KEEP:
            src = stale / name
            if src.exists():
                src.rename(results_dir / name)

        # Restore keeper files and subdirs from idea directories
        for p in stale.iterdir():
            if not p.is_dir() or p.name.startswith((".","_")):
                continue
            keep_files = [c for c in p.iterdir()
                          if c.is_file() and c.name in _IDEA_KEEP]
            keep_dirs = [c for c in p.iterdir() if c.is_dir()]
            if keep_files or keep_dirs:
                dest = results_dir / p.name
                dest.mkdir()
                for f in keep_files:
                    f.rename(dest / f.name)
                for d in keep_dirs:
                    d.rename(dest / d.name)

        # Bulk-delete the old tree (fast, handled in C)
        shutil.rmtree(stale, ignore_errors=True)

    print("  Cleaned runtime files")

    # --- 3. Remove venv directory ------------------------------------
    print("[3/5] Removing venv...")
    venv_dir = Path("venv")
    if venv_dir.is_dir():
        shutil.rmtree(venv_dir, ignore_errors=True)
        print("  Removed venv/")
    else:
        print("  No venv/ found")

    # --- 4. Remove orze config file ----------------------------------
    print("[4/5] Removing orze config...")
    config_path = Path(cfg.get("_config_path", "orze.yaml"))
    if config_path.exists():
        config_path.unlink()
        print(f"  Removed {config_path}")
    else:
        print("  No config file found")

    # --- 5. Uninstall orze package (try uv first, fall back to pip) --
    print("[5/5] Uninstalling orze package...")
    uninstalled = False
    if shutil.which("uv"):
        try:
            subprocess.run(
                ["uv", "tool", "uninstall", "orze"],
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            print("  orze package removed (uv)")
            uninstalled = True
        except (subprocess.CalledProcessError, FileNotFoundError):
            pass
    if not uninstalled:
        try:
            subprocess.run(
                [sys.executable, "-m", "pip", "uninstall", "orze", "-y"],
                check=True,
            )
            print("  orze package removed (pip)")
        except subprocess.CalledProcessError:
            print("  WARNING: uninstall failed — you may need to run manually:")
            print("    uv tool uninstall orze  OR  pip uninstall orze")

    # --- Summary -----------------------------------------------------
    print()
    print("\033[1mUninstall complete.\033[0m")
    print("Preserved research results:")
    if results_dir.exists():
        kept = list(results_dir.rglob("metrics.json"))
        print(f"  {len(kept)} experiment result(s) in {results_dir}/")
    ideas_file = Path(cfg.get("ideas_file", "ideas.md"))
    if ideas_file.exists():
        print(f"  {ideas_file} (experiment definitions)")
    lake_path = ideas_file.parent / "idea_lake.db"
    if lake_path.exists():
        print(f"  {lake_path} (idea archive database)")
    project_dir = Path.cwd()
    print()
    print(f"  To remove everything: rm -rf {project_dir}")


def _stop_running_instance(results_dir: Path) -> bool:
    """Stop a running orze instance via PID file. Returns True if one was stopped."""
    import time as _time
    pid_file = results_dir / ".orze.pid"
    if not pid_file.exists():
        return False
    try:
        old_pid = int(pid_file.read_text(encoding="utf-8").strip())
    except (ValueError, OSError):
        return False
    print(f"Stopping orze (PID {old_pid})...")
    try:
        os.kill(old_pid, 15)  # SIGTERM
    except ProcessLookupError:
        print("  Process already exited.")
        return False
    for _ in range(30):  # 15s
        try:
            os.kill(old_pid, 0)
            _time.sleep(0.5)
        except ProcessLookupError:
            break
    else:
        print("  Still running after 15s, sending SIGKILL...")
        try:
            os.kill(old_pid, 9)
            _time.sleep(1)
        except ProcessLookupError:
            pass
    print("  Stopped.")
    return True


def _do_upgrade(cfg: dict):
    """Upgrade orze to the latest version from PyPI, then restart if running."""
    import subprocess

    print(f"\n\033[1mOrze — Upgrade\033[0m")
    print("---------------")
    print(f"Current version: {__version__}")

    # 1. Stop running instance first (so new code loads on restart)
    results_dir = Path(cfg["results_dir"])
    was_running = _stop_running_instance(results_dir)

    # 2. Upgrade (try uv first, fall back to pip)
    upgraded = False
    if shutil.which("uv"):
        print("Upgrading via uv...\n")
        try:
            subprocess.run(
                ["uv", "tool", "upgrade", "orze"],
                check=True,
            )
            upgraded = True
        except (subprocess.CalledProcessError, FileNotFoundError):
            print("  uv upgrade failed, falling back to pip...")

    if not upgraded:
        print("Upgrading via pip...\n")
        try:
            subprocess.run(
                [sys.executable, "-m", "pip", "install", "--upgrade", "orze"],
                check=True,
            )
        except subprocess.CalledProcessError:
            print("\n\033[31mUpgrade failed.\033[0m Try manually:")
            print("  uv tool upgrade orze  OR  pip install --upgrade orze")
            return

    # Report the new version
    result = subprocess.run(
        [sys.executable, "-c", "from orze import __version__; print(__version__)"],
        capture_output=True, text=True,
    )
    new_ver = result.stdout.strip() if result.returncode == 0 else "unknown"
    if new_ver == __version__:
        print(f"\nAlready up-to-date (v{__version__}).")
    else:
        print(f"\n\033[32mUpgraded: v{__version__} -> v{new_ver}\033[0m")

    # 3. Restart if it was running
    if was_running:
        print("\nRestarting orze with new version...")
        os.execv(sys.executable, [sys.executable, "-m", "orze.cli",
                                  "-c", str(cfg.get("_config_path", "orze.yaml"))])


# ---------------------------------------------------------------------------
# Shared storage auto-detection
# ---------------------------------------------------------------------------

def _find_shared_mounts() -> list:
    """Find shared/network filesystem mounts. Returns [(path, fs_type), ...]."""
    import shutil

    _NETWORK_FS = {"lustre", "nfs", "nfs4", "efs", "fuse.s3fs", "fuse.goofys",
                   "ceph", "gpfs", "beegfs", "pvfs2", "glusterfs"}
    _FAST_FS = {"lustre", "gpfs", "beegfs", "pvfs2"}

    results = []
    seen_devs = set()
    try:
        for line in Path("/proc/mounts").read_text().splitlines():
            parts = line.split()
            if len(parts) < 3:
                continue
            device, mount_point, fs_type = parts[0], parts[1], parts[2]
            if fs_type not in _NETWORK_FS and "@tcp:" not in device:
                continue
            if not os.access(mount_point, os.W_OK):
                continue
            try:
                dev = os.stat(mount_point).st_dev
                if dev in seen_devs:
                    continue
                seen_devs.add(dev)
            except OSError:
                continue
            try:
                free_gb = shutil.disk_usage(mount_point).free / (1024**3)
                if free_gb < 20:
                    continue
            except (PermissionError, OSError):
                continue
            results.append((mount_point, fs_type))
    except Exception:
        pass

    results.sort(key=lambda x: x[1] in _FAST_FS, reverse=True)
    return results


def _resolve_init_path(explicit_path: str) -> str:
    """Resolve the project directory for --init.

    Explicit path → use it. No path → detect shared mount, prompt once.
    """
    if explicit_path != ".":
        return str(Path(explicit_path).resolve())

    # Auto-detect best shared mount as default
    shared = _find_shared_mounts()
    default = shared[0][0] if shared else str(Path.cwd())

    print()
    print("\033[1mOrze needs a shared storage path for the project.\033[0m")
    print("  All cluster nodes must be able to read/write this path.")
    if shared:
        print(f"  Detected: \033[36m{default}\033[0m ({shared[0][1]})")
    path = input(f"  Project path [{default}]: ").strip()

    return str(Path(path or default).resolve())


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main():
    _maybe_star()

    parser = argparse.ArgumentParser(
        description="orze: GPU experiment orchestrator",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python -m orze.cli                             # all GPUs, continuous
  python -m orze.cli -c orze.yaml --gpus 0,1     # with project config
  python -m orze.cli --once                      # one cycle then exit
  python -m orze.cli --report-only               # regenerate report
        """,
    )
    parser.add_argument("-V", "--version", action="version",
                        version=f"orze {__version__}")
    parser.add_argument("-c", "--config-file", type=str, default=None,
                        help="Path to orze.yaml project config")
    parser.add_argument("--gpus", type=str, default=None,
                        help="Comma-separated GPU IDs (default: auto-detect)")
    parser.add_argument("--timeout", type=int, default=None,
                        help="Max training time in seconds")
    parser.add_argument("--poll", type=int, default=None,
                        help="Seconds between iterations")
    parser.add_argument("--once", action="store_true",
                        help="Run one cycle and exit")
    parser.add_argument("--stop", action="store_true",
                        help="Gracefully stop a running orze instance")
    parser.add_argument("--restart", action="store_true",
                        help="Stop the running instance and start a new one")
    parser.add_argument("--disable", action="store_true",
                        help="Stop and persistently disable Orze (survives restarts)")
    parser.add_argument("--enable", action="store_true",
                        help="Remove persistent disable flag to allow Orze to run")
    parser.add_argument("--report-only", action="store_true",
                        help="Only regenerate report")
    parser.add_argument("--role-only", type=str, default=None, metavar="NAME",
                        help="Run a single agent role once and exit")
    parser.add_argument("--research-only", action="store_true",
                        help="Alias for --role-only research")
    parser.add_argument("--ideas-md", type=str, default=None,
                        help="Path to ideas markdown file")
    parser.add_argument("--base-config", type=str, default=None,
                        help="Path to base config YAML")
    parser.add_argument("--results-dir", type=str, default=None,
                        help="Directory for results")
    parser.add_argument("--train-script", type=str, default=None,
                        help="Training script to run per idea")
    parser.add_argument("--init", nargs="?", const=".", default=None, metavar="PATH",
                        help="Initialize a new orze project (default: current directory)")
    parser.add_argument("--admin", action="store_true",
                        help="Launch admin panel instead of farm loop")
    parser.add_argument("--upgrade", action="store_true",
                        help="Upgrade orze to the latest version from PyPI")
    parser.add_argument("--check", action="store_true",
                        help="Validate config, check files, API keys, GPUs, .env — then exit")
    parser.add_argument("--uninstall", action="store_true",
                        help="Full uninstall: stop orze, remove runtime files, "
                             "pip uninstall — keeps only research results")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Debug logging")

    # --- subcommands ---
    subparsers = parser.add_subparsers(dest="command")

    # stop
    stop_parser = subparsers.add_parser(
        "stop", help="Stop orze: kill orchestrator + children, "
                     "disable watchdog, clean up GPUs")
    stop_parser.add_argument("-c", "--config-file", type=str, default=None,
                             help="Path to orze.yaml")
    stop_parser.add_argument("--timeout", type=int, default=60,
                             help="Timeout for child processes (default: 60)")
    stop_parser.add_argument("-v", "--verbose", action="store_true")

    # start
    start_parser = subparsers.add_parser(
        "start", help="Start orze as a background daemon")
    start_parser.add_argument("-c", "--config-file", type=str, default=None,
                              help="Path to orze.yaml")
    start_parser.add_argument("--foreground", action="store_true",
                              help="Run in foreground instead of daemonizing")
    start_parser.add_argument("-v", "--verbose", action="store_true")

    # restart
    restart_parser = subparsers.add_parser(
        "restart", help="Stop then start orze")
    restart_parser.add_argument("-c", "--config-file", type=str, default=None,
                                help="Path to orze.yaml")
    restart_parser.add_argument("--timeout", type=int, default=60,
                                help="Timeout for child processes (default: 60)")
    restart_parser.add_argument("--foreground", action="store_true",
                                help="Run in foreground after restart")
    restart_parser.add_argument("-v", "--verbose", action="store_true")

    # service
    svc_parser = subparsers.add_parser("service", help="Manage orze watchdog service")
    svc_sub = svc_parser.add_subparsers(dest="service_action")

    svc_install = svc_sub.add_parser("install", help="Install watchdog service")
    svc_install.add_argument("-c", "--config-file", type=str, default="orze.yaml",
                             help="Path to orze.yaml")
    svc_install.add_argument("--method", choices=["auto", "crontab", "systemd"],
                             default="auto", help="Service method (default: auto)")
    svc_install.add_argument("--stall-threshold", type=int, default=1800,
                             help="Seconds before heartbeat considered stale (default: 1800)")

    svc_sub.add_parser("uninstall", help="Uninstall watchdog service")
    svc_sub.add_parser("status", help="Show watchdog service status")

    svc_logs = svc_sub.add_parser("logs", help="Show watchdog logs")
    svc_logs.add_argument("-n", type=int, default=50,
                          help="Number of log lines (default: 50)")

    args = parser.parse_args()

    setup_logging(args.verbose)

    # --- subcommand dispatch ---
    command = getattr(args, "command", None)

    if command == "stop":
        from orze.lifecycle import do_stop
        cfg = load_project_config(args.config_file)
        do_stop(cfg, timeout=args.timeout)
        return

    if command == "start":
        from orze.lifecycle import do_start
        cfg = load_project_config(args.config_file)
        config_path = args.config_file or cfg.get("_config_path", "orze.yaml")
        # do_start with foreground=True replaces the process via os.execv
        do_start(cfg, foreground=args.foreground, config_path=config_path)
        return

    if command == "restart":
        from orze.lifecycle import do_restart
        cfg = load_project_config(args.config_file)
        config_path = args.config_file or cfg.get("_config_path", "orze.yaml")
        do_restart(cfg, timeout=args.timeout, foreground=args.foreground,
                   config_path=config_path)
        return

    if command == "service":
        action = getattr(args, "service_action", None)
        if action == "install":
            from orze.service.install import install
            install(args.config_file, method=args.method,
                    stall_threshold=args.stall_threshold)
        elif action == "uninstall":
            from orze.service.install import uninstall
            uninstall()
        elif action == "status":
            from orze.service.status import show_status
            show_status()
        elif action == "logs":
            from orze.service.status import show_logs
            show_logs(n=args.n)
        else:
            parser.parse_args(["service", "--help"])
        return

    # Load project config, then apply CLI overrides
    cfg = load_project_config(args.config_file)
    cfg["_config_path"] = args.config_file or "orze.yaml"  # stored for mode: research

    # --admin: launch web panel
    if args.admin:
        from orze.admin.server import run_admin
        run_admin(cfg)
        return

    # --upgrade: upgrade orze from PyPI (stops + restarts if running)
    if args.upgrade:
        _do_upgrade(cfg)
        return

    # --uninstall: full cleanup, keep only research results
    if args.uninstall:
        _do_uninstall(cfg)
        return

    # --init: initialize a new project
    if args.init is not None:
        init_path = _resolve_init_path(args.init)

        project_dir = Path(init_path).resolve()
        project_dir.mkdir(parents=True, exist_ok=True)
        os.chdir(project_dir)

        print(f"\n\033[1mOrze — Initialization\033[0m")
        print(f"  Project : {project_dir}")
        print("---------------------")

        created = []

        def _create(path, content, label=None):
            p = Path(path)
            if p.exists():
                print(f"  exists   {p}")
                return False
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(content, encoding="utf-8")
            created.append(label or str(p))
            print(f"  \033[32mcreated\033[0m  {p}")
            return True

        print()

        # .env template (project-local — each project has its own API keys)
        env_content = """\
# Orze auto-loads this file on startup. No need to export.
# Uncomment and fill in the key for your preferred LLM backend:

# ANTHROPIC_API_KEY=sk-ant-...
# GEMINI_API_KEY=AI...
# OPENAI_API_KEY=sk-...

# Telegram notifications (optional):
# TELEGRAM_BOT_TOKEN=123456:ABC...
# TELEGRAM_CHAT_ID=-100...
"""
        _create(".env", env_content)

        # ORZE-AGENT.md and ORZE-RULES.md (project-local copies)
        pkg_dir = Path(__file__).resolve().parent
        for src_name, dst_name in {"AGENT.md": "ORZE-AGENT.md", "RULES.md": "ORZE-RULES.md"}.items():
            src = pkg_dir / src_name
            if src.exists():
                _create(dst_name, src.read_text(encoding="utf-8"))

        # 1. Train script stub
        train_script = "train.py"
        _create(train_script, BASELINE_TRAIN_PY.strip() + "\n", "train.py (stub)")

        # 2. Create venv and install pyyaml (if no venv exists)
        venv_dir = project_dir / "venv"
        venv_python = venv_dir / "bin" / "python3"
        venv_ok = False
        if venv_dir.is_dir() and venv_python.exists():
            # Health check: can the venv import yaml?
            try:
                subprocess.run(
                    [str(venv_python), "-c", "import yaml"],
                    check=True,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    timeout=10,
                )
                venv_ok = True
                print(f"  exists   venv/ (healthy)")
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
                    FileNotFoundError, OSError):
                print(f"  \033[33mbroken\033[0m   venv/ — recreating...")
                shutil.rmtree(venv_dir, ignore_errors=True)

        if not venv_ok:
            print(f"  \033[32mcreating\033[0m venv/...")
            try:
                subprocess.run(
                    [sys.executable, "-m", "venv", str(venv_dir)],
                    check=True, timeout=60,
                )
                subprocess.run(
                    [str(venv_python), "-m", "pip", "install", "--quiet", "pyyaml"],
                    check=True, timeout=120,
                )
                created.append("venv/ (with pyyaml)")
                print(f"  \033[32mcreated\033[0m  venv/ (with pyyaml)")
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
                    FileNotFoundError) as exc:
                print(f"  \033[33mwarning\033[0m  venv creation failed: {exc}")
                print(f"           You can create one manually: python3 -m venv venv && venv/bin/pip install pyyaml")

        # Determine python path for orze.yaml
        if (venv_dir / "bin" / "python3").exists():
            python_for_yaml = str(venv_dir / "bin" / "python3")
        else:
            python_for_yaml = sys.executable

        # 3. orze.yaml
        backend = ("anthropic" if os.environ.get("ANTHROPIC_API_KEY") else
                   "gemini" if os.environ.get("GEMINI_API_KEY") else
                   "openai" if os.environ.get("OPENAI_API_KEY") else "ollama")

        yaml_content = f"""\
# orze.yaml — Project configuration
# Docs: https://github.com/erikhenriksson/orze

# --- REQUIRED ---
train_script: {train_script}
ideas_file: ideas.md
results_dir: results
python: {python_for_yaml}

# --- EVALUATION (optional) ---
# eval_script: evaluate_dataset.py
# eval_args: [--split, test]
# eval_timeout: 3600
# eval_output: eval_report.json

# --- RESEARCH AGENT (optional) ---
# Auto-generates ideas. Requires API key in .env or environment.
# If you have an API key set, orze auto-discovers it — no config needed.
# Uncomment below only to customize settings:
#
# roles:
#   research:
#     mode: research
#     backend: {backend}              # anthropic | gemini | openai | ollama
#     cooldown: 600
#     timeout: 300

# --- NOTIFICATIONS (optional) ---
# notifications:
#   enabled: true
#   on: [completed, failed, new_best]
#   channels:
#     - type: telegram
#       bot_token: "${{TELEGRAM_BOT_TOKEN}}"
#       chat_id: "${{TELEGRAM_CHAT_ID}}"

# --- ADVANCED ---
# timeout: 3600
# poll: 30
# stall_minutes: 0
# max_idea_failures: 0
# max_fix_attempts: 0
"""
        _create("orze.yaml", yaml_content)

        # 4. ideas.md with task-agnostic seed experiments
        ideas_content = """\
# Ideas
<!-- Orze reads ideas from this file. Format:
     ## idea-XXXX: Title
     - **Priority**: high|medium|low
     ```yaml
     key: value        <- passed to your train script
     ```
     Orze consumes ideas on startup (moves them to idea_lake.db).
-->

## idea-0001: Baseline — default hyperparameters
- **Priority**: high

```yaml
learning_rate: 0.001
epochs: 10
```

## idea-0002: Higher learning rate
- **Priority**: medium

```yaml
learning_rate: 0.01
epochs: 10
```

## idea-0003: Longer training
- **Priority**: medium

```yaml
learning_rate: 0.001
epochs: 100
```
"""
        _create("ideas.md", ideas_content)

        # 5. configs/base.yaml (task-agnostic defaults)
        base_content = """\
# Base config — shared defaults for all experiments.
# Your train script receives this via --config <path>.
# Add any project-wide settings here (model architecture, data paths, etc).

# Example defaults (used by the demo train.py):
seed: 42
noise: 0.1
"""
        _create("configs/base.yaml", base_content)

        # 6. RESEARCH_RULES.md (if it doesn't exist)
        _create("RESEARCH_RULES.md", RESEARCH_RULES_TEMPLATE, "RESEARCH_RULES.md")

        # 7. results directory
        Path("results").mkdir(exist_ok=True)

        # 8. Strategy plugin system (Phase 2 support)
        Path("strategies").mkdir(exist_ok=True)
        _create("strategies/__init__.py", '''\
"""Strategy registry for Phase 2 algorithmic experiments.

Strategies define custom inference/evaluation logic beyond YAML configs.
Three levels:
  Level 1 (sample):  run(pipeline, cfg, dataset_name, sample) -> str
  Level 2 (dataset): run_dataset(pipeline, cfg, dataset_name, dataset, compute_wer_fn) -> dict
  Level 3 (full):    run_experiment(cfg, results_dir) -> dict
"""
import importlib
import importlib.util
from pathlib import Path

LEVEL_SAMPLE, LEVEL_DATASET, LEVEL_FULL = 1, 2, 3
_CACHE = {}

def _load_module(name):
    if name in _CACHE:
        return _CACHE[name]
    strategy_file = Path(__file__).parent / f"{name}.py"
    if not strategy_file.exists():
        return None
    spec = importlib.util.spec_from_file_location(name, strategy_file)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    _CACHE[name] = mod
    return mod

def get(name):
    mod = _load_module(name)
    return getattr(mod, "run", None) if mod else None

def get_level(name):
    mod = _load_module(name)
    if not mod:
        return None, None
    if hasattr(mod, "run_experiment"):
        return LEVEL_FULL, mod
    if hasattr(mod, "run_dataset"):
        return LEVEL_DATASET, mod
    if hasattr(mod, "run"):
        return LEVEL_SAMPLE, mod
    return None, None

def list_strategies():
    names = set(_CACHE.keys())
    for f in Path(__file__).parent.glob("*.py"):
        if f.name != "__init__.py":
            names.add(f.stem)
    return sorted(names)
''')

        # 9. Engineer agent (producer-consumer pipeline)
        # Detect which API keys are available for role assignment
        has_anthropic = bool(os.environ.get("ANTHROPIC_API_KEY"))
        has_gemini = bool(os.environ.get("GEMINI_API_KEY"))
        has_openai = bool(os.environ.get("OPENAI_API_KEY"))

        # Also check .env file for keys
        env_path = project_dir / ".env"
        if env_path.exists():
            env_text = env_path.read_text()
            if not has_anthropic and "ANTHROPIC_API_KEY=" in env_text:
                for line in env_text.splitlines():
                    if line.strip().startswith("ANTHROPIC_API_KEY="):
                        has_anthropic = True
            if not has_gemini and "GEMINI_API_KEY=" in env_text:
                for line in env_text.splitlines():
                    if line.strip().startswith("GEMINI_API_KEY="):
                        has_gemini = True
            if not has_openai and "OPENAI_API_KEY=" in env_text:
                for line in env_text.splitlines():
                    if line.strip().startswith("OPENAI_API_KEY="):
                        has_openai = True

        # Determine role assignment:
        # - If both Gemini + Claude available: Gemini=researcher, Claude=engineer
        # - If only Claude: Claude does both (with mode: claude)
        # - If only Gemini: Gemini=researcher, engineer uses Gemini too
        # - If only OpenAI: OpenAI does both
        has_claude_cli = bool(shutil.which("claude"))

        if has_gemini and has_claude_cli:
            # Best setup: Gemini researches, Claude engineers
            research_mode = "script"
            engineer_mode = "claude"
        elif has_claude_cli:
            # Claude only: does both roles
            research_mode = "claude"
            engineer_mode = "claude"
        elif has_gemini:
            # Gemini only: does both via script
            research_mode = "script"
            engineer_mode = "script"
        elif has_anthropic:
            # Anthropic API but no CLI
            research_mode = "script"
            engineer_mode = "script"
        else:
            research_mode = None
            engineer_mode = None

        if research_mode:
            # Create engineer script
            engineer_content = '''\
#!/usr/bin/env python3
"""Engineer agent: implements strategy code for ideas with strategy: field.

Reads ideas from idea_lake.db, finds ones with unimplemented strategies,
and generates the strategy Python files.

Called by orze as a role:
    python engineer.py --ideas-md ideas.md --results-dir results \\
        --cycle N --completed N --queued N
"""
import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
from pathlib import Path


def find_unimplemented():
    """Find ideas with strategy: field but no matching strategy file."""
    strategies_dir = Path("strategies")
    existing = {f.stem for f in strategies_dir.glob("*.py") if f.name != "__init__.py"}
    unimplemented = []
    try:
        conn = sqlite3.connect("idea_lake.db")
        c = conn.cursor()
        c.execute("SELECT idea_id, config, raw_markdown FROM ideas WHERE status = \'queued\'")
        for idea_id, config_yaml, raw_md in c.fetchall():
            if not config_yaml:
                continue
            try:
                import yaml
                config = yaml.safe_load(config_yaml) or {}
            except Exception:
                continue
            strategy = config.get("strategy")
            if strategy and strategy not in existing:
                hypothesis = ""
                if raw_md:
                    m = re.search(r"\\*\\*Hypothesis\\*\\*:\\s*(.+?)(?=\\n\\n|\\n-|\\n```|\\Z)",
                                  raw_md, re.DOTALL)
                    if m:
                        hypothesis = m.group(1).strip()
                unimplemented.append({
                    "idea_id": idea_id, "strategy": strategy,
                    "hypothesis": hypothesis, "config": config,
                })
        conn.close()
    except Exception as e:
        print(f"[engineer] DB error: {e}")
    return unimplemented


def implement(strategy_info):
    """Call Claude CLI to implement a strategy."""
    name = strategy_info["strategy"]
    hypothesis = strategy_info["hypothesis"]
    config = strategy_info["config"]
    level = config.get("strategy_level", 1)

    prompt = f"""Implement this strategy as a Python file.

Strategy: {name}
Hypothesis: {hypothesis}
Level: {level}

Level 1: def run(pipeline, cfg, dataset_name, sample) -> str
Level 2: def run_dataset(pipeline, cfg, dataset_name, dataset, compute_wer_fn) -> dict
Level 3: def run_experiment(cfg, results_dir) -> dict

sample = {{"audio_np": np.ndarray, "audio_duration": float, "reference": str}}
Available: from train import transcribe_sample, setup_pipeline; numpy, torch

Output ONLY Python code. Include a docstring. Be robust (try/except)."""

    try:
        result = subprocess.run(
            ["claude", "-p", prompt, "--model", "sonnet",
             "--allowedTools", "", "--max-turns", "1"],
            capture_output=True, text=True, timeout=120)
        code = result.stdout.strip()
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return None

    # Extract code from response
    m = re.search(r"```python\\s*\\n(.*?)```", code, re.DOTALL)
    if m:
        code = m.group(1).strip()

    # Validate
    try:
        compile(code, f"strategies/{name}.py", "exec")
    except SyntaxError:
        return None
    if not any(sig in code for sig in ("def run(", "def run_dataset(", "def run_experiment(")):
        return None
    return code


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--ideas-md", required=True)
    parser.add_argument("--results-dir", required=True)
    parser.add_argument("--cycle", type=int, default=0)
    parser.add_argument("--completed", type=int, default=0)
    parser.add_argument("--queued", type=int, default=0)
    args = parser.parse_args()

    unimplemented = find_unimplemented()
    if not unimplemented:
        print("[engineer] No unimplemented strategies.")
        return

    print(f"[engineer] {len(unimplemented)} strategies to implement")
    for s in unimplemented[:3]:
        code = implement(s)
        if code:
            path = Path("strategies") / f"{s[\'strategy\']}.py"
            path.write_text(code + "\\n")
            print(f"[engineer] Saved {path}")
        else:
            print(f"[engineer] Failed: {s[\'strategy\']}")


if __name__ == "__main__":
    main()
'''
            _create("engineer.py", engineer_content, "engineer.py (strategy implementer)")

            # Update orze.yaml to include both roles
            # Re-read the yaml we just created and append roles
            yaml_path = project_dir / "orze.yaml"
            if yaml_path.exists():
                yaml_text = yaml_path.read_text()
                # Only add roles if not already present
                # Check for uncommented roles: (not "# roles:")
                has_active_roles = any(
                    line.strip().startswith("roles:") for line in yaml_text.splitlines()
                )
                if not has_active_roles:
                    roles_block = "\n# --- AGENT ROLES (producer-consumer pipeline) ---\n"
                    roles_block += "# Research agent proposes ideas, engineer implements strategy code.\n"
                    roles_block += "roles:\n"

                    if research_mode == "claude":
                        roles_block += "  research:\n"
                        roles_block += "    mode: claude\n"
                        roles_block += "    rules_file: RESEARCH_RULES.md\n"
                        roles_block += "    cooldown: 1800\n"
                        roles_block += "    timeout: 600\n"
                        roles_block += "    model: sonnet\n"
                        roles_block += '    allowed_tools: "Read,Write,Edit,Glob,Grep,Bash"\n'
                        roles_block += "    log_dir: _research_logs\n"
                    else:
                        roles_block += "  research:\n"
                        roles_block += "    mode: script\n"
                        roles_block += "    script: research_agent.py\n"
                        roles_block += '    args: ["--ideas-md", "{ideas_file}", "--results-dir", "{results_dir}", "--cycle", "{cycle}", "--completed", "{completed}", "--queued", "{queued}"]\n'
                        roles_block += "    cooldown: 1800\n"
                        roles_block += "    timeout: 600\n"
                        roles_block += "    log_dir: _research_logs\n"

                    if engineer_mode == "claude":
                        roles_block += "\n  engineer:\n"
                        roles_block += "    mode: claude\n"
                        roles_block += "    rules_file: ENGINEER_RULES.md\n"
                        roles_block += "    cooldown: 300\n"
                        roles_block += "    timeout: 180\n"
                        roles_block += "    model: sonnet\n"
                        roles_block += '    allowed_tools: "Read,Write,Edit,Glob,Grep,Bash"\n'
                        roles_block += "    log_dir: _engineer_logs\n"
                    else:
                        roles_block += "\n  engineer:\n"
                        roles_block += "    mode: script\n"
                        roles_block += "    script: engineer.py\n"
                        roles_block += '    args: ["--ideas-md", "{ideas_file}", "--results-dir", "{results_dir}", "--cycle", "{cycle}", "--completed", "{completed}", "--queued", "{queued}"]\n'
                        roles_block += "    cooldown: 300\n"
                        roles_block += "    timeout: 180\n"
                        roles_block += "    log_dir: _engineer_logs\n"

                    yaml_text = yaml_text.replace(
                        "# --- ADVANCED ---",
                        roles_block + "\n# --- ADVANCED ---"
                    )
                    yaml_path.write_text(yaml_text)
                    print(f"  \033[32mupdated\033[0m orze.yaml (added research + engineer roles)")

        # =================================================================
        # Summary
        # =================================================================
        print()
        if created:
            print(f"\033[32m Created {len(created)} file(s). Project ready.\033[0m")
        else:
            print("\033[32m All files already exist.\033[0m")

        # API key status
        _detected = []
        if os.environ.get("ANTHROPIC_API_KEY"): _detected.append("Anthropic")
        if os.environ.get("GEMINI_API_KEY"): _detected.append("Gemini")
        if os.environ.get("OPENAI_API_KEY"): _detected.append("OpenAI")

        print()
        if _detected:
            print(f"  API keys: \033[32m{', '.join(_detected)}\033[0m (auto-discovered)")
        else:
            print(f"  API keys: \033[33mnone found\033[0m — add to {project_dir}/.env")

        cfg_path = project_dir / "orze.yaml"
        print()
        print("\033[1mNext steps:\033[0m")
        print(f"  1. Edit \033[36m{project_dir}/train.py\033[0m with your training/eval logic")
        print(f"  2. Add API key to \033[36m{project_dir}/.env\033[0m (enables auto-research)")
        print(f"  3. Run: \033[36morze --check -c {cfg_path}\033[0m to validate")
        print(f"  4. Run: \033[36morze -c {cfg_path}\033[0m to start")
        if research_mode:
            print()
            print("\033[1mResearch pipeline:\033[0m")
            print(f"  Research agent → proposes ideas with hypotheses")
            print(f"  Engineer agent → implements strategy code")
            print(f"  Executor       → runs experiments on GPUs")
            print(f"  strategies/    → plugin directory for custom algorithms")
        return

    # --check: validate config and environment, then exit
    if args.check:
        from orze.core.config import _validate_config, find_dotenv

        print(f"\n\033[1mOrze v{__version__} — Config Check\033[0m")
        print("=" * 50)

        ok = "\033[32m[x]\033[0m"
        no = "\033[31m[ ]\033[0m"
        warn_mark = "\033[33m[-]\033[0m"

        # --- Files ---
        print("  \033[1mFiles:\033[0m")
        cp = Path(cfg.get("_config_path", "orze.yaml"))
        cp_ok = cp.exists()
        print(f"    {ok if cp_ok else no} orze.yaml: {cp}")
        if not cp_ok:
            print(f"      hint: run \033[36morze --init\033[0m to create a project")

        env_path = find_dotenv(str(cp) if cp_ok else None)
        if env_path:
            print(f"    {ok} .env: {env_path}")
        else:
            print(f"    {warn_mark} .env: not found (optional — needed for API keys)")

        ts = cfg.get("train_script", "train.py")
        ts_ok = Path(ts).exists()
        print(f"    {ok if ts_ok else no} train_script: {ts}")
        if not ts_ok and not cp_ok:
            print(f"      hint: run \033[36morze --init\033[0m to scaffold a project")

        ideas_path = cfg.get("ideas_file", "ideas.md")
        ideas_exists = Path(ideas_path).exists()
        print(f"    {ok if ideas_exists else no} ideas_file: {ideas_path}")

        # Validate ideas format
        ideas_valid = False
        ideas_count = 0
        if ideas_exists:
            from orze.core.ideas import parse_ideas
            try:
                parsed = parse_ideas(ideas_path)
                ideas_count = len(parsed)
                ideas_valid = ideas_count > 0
            except Exception:
                pass
        if ideas_exists:
            if ideas_valid:
                print(f"      {ok} {ideas_count} idea(s) parsed OK")
            else:
                print(f"      {warn_mark} no valid ideas found (may be empty if using idea_lake)")

        rdir = cfg.get("results_dir", "results")
        print(f"    {ok if Path(rdir).is_dir() else warn_mark} results_dir: {rdir}")

        bc = cfg.get("base_config")
        if bc:
            print(f"    {ok if Path(bc).exists() else warn_mark} base_config: {bc}")

        es = cfg.get("eval_script")
        if es:
            print(f"    {ok if Path(es).exists() else no} eval_script: {es}")

        lake_path = Path(ideas_path).parent / "idea_lake.db"
        print(f"    {ok if lake_path.exists() else warn_mark} idea_lake.db: {lake_path}")

        # --- Filesystem writability ---
        print()
        print("  \033[1mFilesystem:\033[0m")
        rdir_p = Path(rdir)
        fs_ok = False
        if rdir_p.is_dir():
            probe = rdir_p / ".orze_probe"
            try:
                probe.write_text("1")
                probe.unlink()
                fs_ok = True
            except Exception:
                pass
        print(f"    {ok if fs_ok else no} results_dir writable" +
              ("" if fs_ok else " (create it or check mount)"))

        disk = shutil.disk_usage(rdir_p if rdir_p.is_dir() else Path.cwd())
        free_gb = disk.free / (1024**3)
        print(f"    {ok if free_gb > 5 else warn_mark} disk: {free_gb:.1f} GB free")

        # --- API keys ---
        print()
        print("  \033[1mAPI Keys:\033[0m")
        any_key = False
        for env_var, label in [("ANTHROPIC_API_KEY", "Anthropic"),
                                ("GEMINI_API_KEY", "Gemini"),
                                ("OPENAI_API_KEY", "OpenAI")]:
            present = bool(os.environ.get(env_var))
            if present:
                any_key = True
            print(f"    {ok if present else warn_mark} {label} ({env_var})")
        if not any_key:
            print(f"      hint: add a key to .env for auto-research")

        # --- GPUs ---
        print()
        print("  \033[1mGPUs:\033[0m")
        gpus = detect_all_gpus()
        if gpus:
            print(f"    {ok} {len(gpus)} GPU(s) detected: {gpus}")
        else:
            print(f"    {no} No GPUs detected")

        # --- Roles ---
        roles = cfg.get("roles") or {}
        if roles:
            print()
            print("  \033[1mRoles:\033[0m")
            for rname, rcfg in roles.items():
                if isinstance(rcfg, dict):
                    mode = rcfg.get("mode", "script")
                    backend = rcfg.get("backend", "")
                    detail = f"mode={mode}" + (f", backend={backend}" if backend else "")
                    print(f"    {ok} {rname}: {detail}")

        # --- Validation ---
        errors, warnings = _validate_config(cfg)
        if errors or warnings:
            print()
        if errors:
            print("  \033[31mErrors:\033[0m")
            for e in errors:
                print(f"    \033[31m✗\033[0m {e}")
        if warnings:
            print("  \033[33mWarnings:\033[0m")
            for w in warnings:
                print(f"    \033[33m!\033[0m {w}")

        print()
        if errors:
            print(f"\033[31m✗ {len(errors)} error(s) — fix before running.\033[0m")
            if not cp_ok:
                print(f"  hint: run \033[36morze --init\033[0m to create a new project")
            sys.exit(1)
        else:
            print(f"\033[32m✔ Ready to run.\033[0m")
        return

    # Apply CLI overrides
    if args.timeout is not None:
        cfg["timeout"] = args.timeout
    if args.poll is not None:
        cfg["poll"] = args.poll
    if args.ideas_md:
        cfg["ideas_file"] = args.ideas_md
    if args.base_config:
        cfg["base_config"] = args.base_config
    if args.results_dir:
        cfg["results_dir"] = args.results_dir
    if args.train_script:
        cfg["train_script"] = args.train_script

    # --stop
    if args.stop:
        import datetime
        from orze.core.fs import atomic_write
        stop_path = Path(cfg["results_dir"]) / ".orze_stop_all"
        atomic_write(stop_path,
                     f"kill {datetime.datetime.now().isoformat()}")
        print(f"Stop signal written to {stop_path}. "
              f"All nodes sharing this results directory will stop within "
              f"~30 seconds. Training, evaluation, and research processes "
              f"will be terminated (SIGTERM, then SIGKILL after 10s). "
              f"The sentinel is cleared automatically on next startup.")
        return

    # --restart: stop running instance, then continue to start a new one
    if args.restart:
        _stop_running_instance(Path(cfg["results_dir"]))
        print("Starting new orze instance...")

    # --disable
    if args.disable:
        import datetime
        from orze.core.fs import atomic_write
        disable_path = Path(cfg["results_dir"]) / ".orze_disabled"
        atomic_write(disable_path, f"Disabled at {datetime.datetime.now().isoformat()}")
        print(f"Orze disabled. Remove {disable_path} to re-enable.")
        return

    # --enable
    if args.enable:
        disable_path = Path(cfg["results_dir"]) / ".orze_disabled"
        if disable_path.exists():
            disable_path.unlink()
            print("Orze re-enabled.")
        else:
            print("Orze was not disabled.")
        return

    # --report-only
    if args.report_only:
        from orze.core.ideas import parse_ideas
        from orze.reporting.leaderboard import update_report
        ideas = parse_ideas(cfg["ideas_file"])
        results_dir = Path(cfg["results_dir"])
        lake = None
        lake_path = Path(cfg["ideas_file"]).parent / "idea_lake.db"
        if lake_path.exists():
            from orze.idea_lake import IdeaLake
            lake = IdeaLake(str(lake_path))
        update_report(results_dir, ideas, cfg, lake=lake)
        print("Report updated.")
        return

    # --research-only is an alias for --role-only research
    if args.research_only:
        args.role_only = "research"

    # Detect GPUs
    if args.gpus:
        gpu_ids = [int(g.strip()) for g in args.gpus.split(",")]
    else:
        gpu_ids = detect_all_gpus()

    if not gpu_ids:
        logger.error("No GPUs detected. Use --gpus to specify manually.")
        sys.exit(1)

    # Start admin panel in background thread (unless --role-only or --admin-off)
    if not args.role_only and not getattr(args, 'no_admin', False):
        try:
            import threading
            from orze.admin.server import run_admin as _run_admin_server
            admin_port = int(os.environ.get("ORZE_ADMIN_PORT", "8787"))

            def _kill_port_holder(port):
                """Kill a previous orze process holding the port."""
                import subprocess
                try:
                    out = subprocess.check_output(
                        ["fuser", f"{port}/tcp"], stderr=subprocess.DEVNULL,
                    ).decode().strip()
                    for pid_str in out.split():
                        pid = int(pid_str)
                        if pid == os.getpid():
                            continue
                        # Only kill if it's an orze process
                        try:
                            cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().decode(
                                "utf-8", errors="replace")
                            if "orze" not in cmdline:
                                logger.warning(
                                    "Port %d held by non-orze process %d, skipping", port, pid)
                                continue
                        except OSError:
                            continue
                        logger.info("Killing stale orze process %d on port %d", pid, port)
                        os.kill(pid, 15)
                except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
                    pass

            def _admin_thread():
                import time as _time
                for attempt in range(3):
                    try:
                        _run_admin_server(cfg, port=admin_port)
                        return
                    except OSError as e:
                        if "address already in use" in str(e).lower() and attempt < 2:
                            logger.warning("Admin port %d in use, killing holder and retrying...", admin_port)
                            _kill_port_holder(admin_port)
                            _time.sleep(2)
                        else:
                            logger.warning("Admin panel failed to start: %s", e)
                            return
                    except Exception as e:
                        logger.warning("Admin panel failed to start: %s", e)
                        return

            t = threading.Thread(target=_admin_thread, daemon=True)
            t.start()
            logger.info("Admin panel starting on http://0.0.0.0:%d", admin_port)
        except Exception as e:
            logger.warning("Could not start admin panel: %s", e)

    # Launch orchestrator
    from orze.engine.orchestrator import Orze
    orze = Orze(gpu_ids, cfg, once=args.once)

    if args.role_only:
        orze._run_role_once(args.role_only)
    else:
        orze.run()


if __name__ == "__main__":
    main()
