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

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

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

Replace this with your real training logic.

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 time
from pathlib import Path

import yaml


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)

    # Read the idea config (written by orze before launch)
    idea_cfg_path = idea_dir / "idea_config.yaml"
    cfg = {}
    if idea_cfg_path.exists():
        cfg = yaml.safe_load(idea_cfg_path.read_text()) or {}
    print(f"Training {args.idea_id} with config: {cfg}")

    # --- Replace with your training logic ---
    time.sleep(2)
    accuracy = 0.5
    loss = 1.0
    # -----------------------------------------

    # Write metrics (orze reads this to determine success/failure)
    metrics = {
        "status": "COMPLETED",
        "test_accuracy": accuracy,
        "test_loss": loss,
        "training_time": 2.0,
    }
    (idea_dir / "metrics.json").write_text(json.dumps(metrics, indent=2))
    print(f"Done. Metrics: {metrics}")


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


# ---------------------------------------------------------------------------
# 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",
}

# 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/4] 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/4] 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 orze config file ----------------------------------
    print("[3/4] 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")

    # --- 4. Pip uninstall --------------------------------------------
    print("[4/4] Uninstalling orze package...")
    try:
        subprocess.run(
            [sys.executable, "-m", "pip", "uninstall", "orze", "-y"],
            check=True,
        )
        print("  orze package removed")
    except subprocess.CalledProcessError:
        print("  WARNING: pip uninstall failed — you may need to run manually:")
        print("    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)")


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
    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("  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():
    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")
    args = parser.parse_args()

    setup_logging(args.verbose)

    # 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. 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: {sys.executable}

# --- 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)

        # 3. ideas.md with format documentation
        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
- **Priority**: high

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

        # 4. configs/base.yaml (empty stub so --config arg doesn't point to nothing)
        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).
"""
        _create("configs/base.yaml", base_content)

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

        # =================================================================
        # 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 logic")
        print(f"  2. Add API key to \033[36m{project_dir}/.env\033[0m (optional, for 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")
        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)"))

        import shutil
        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, datetime.datetime.now().isoformat())
        print(f"Stop sentinel written to {stop_path}")
        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()
