"""
Brynq Runtime CLI — The terminal launcher for Brynq.

This is how users launch and interact with Brynq from their terminal.
Clean, fast, minimal — like ``claude`` for Claude Code.

Usage:
    brynq-runtime start              Start runtime + open browser
    brynq-runtime login claude       OAuth login for Claude
    brynq-runtime login gemini       OAuth login for Gemini
    brynq-runtime models             List available models
    brynq-runtime chat               Interactive terminal chat
    brynq-runtime chain "prompt"     Run a multi-model chain
    brynq-runtime status             Show runtime status
    brynq-runtime stop               Stop runtime

Environment:
    BRYNQ_RUNTIME_LOCAL_PORT    Runtime server port (default: 8003)
    BRYNQ_RUNTIME_OLLAMA_URL    Ollama URL (default: http://localhost:11434)
    BRYNQ_RUNTIME_DATA_DIR      Data directory (default: ~/.brynq)
"""

from __future__ import annotations

import argparse
import io
import json
import os
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
import webbrowser
from pathlib import Path
from typing import Any

from runtime.config import RuntimeConfig, load_config

# ── Constants ─────────────────────────────────────────────────────────

from runtime.version import get_version

VERSION = get_version()
APP_URL = "http://localhost:3000"
_PID_FILENAME = "runtime.pid"

# ANSI colors (disabled on Windows without VT100 support)
_NO_COLOR = os.environ.get("NO_COLOR", "")


def _supports_color() -> bool:
    """Detect whether the terminal supports ANSI color."""
    if _NO_COLOR:
        return False
    if sys.platform == "win32":
        return os.environ.get("WT_SESSION") is not None or "ANSICON" in os.environ
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()


_COLOR = _supports_color()


def _dim(text: str) -> str:
    return f"\033[2m{text}\033[0m" if _COLOR else text


def _bold(text: str) -> str:
    return f"\033[1m{text}\033[0m" if _COLOR else text


def _green(text: str) -> str:
    return f"\033[32m{text}\033[0m" if _COLOR else text


def _red(text: str) -> str:
    return f"\033[31m{text}\033[0m" if _COLOR else text


def _yellow(text: str) -> str:
    return f"\033[33m{text}\033[0m" if _COLOR else text


def _cyan(text: str) -> str:
    return f"\033[36m{text}\033[0m" if _COLOR else text


# ── Helpers ───────────────────────────────────────────────────────────


def _pid_file(config: RuntimeConfig) -> Path:
    return config.data_dir / _PID_FILENAME


def _is_runtime_running(config: RuntimeConfig) -> bool:
    """Check if the runtime server is responding on its port."""
    try:
        url = f"http://127.0.0.1:{config.local_port}/status"
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=3.0) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return data.get("status") == "ok"
    except Exception:
        return False


def _runtime_get(config: RuntimeConfig, path: str, timeout: float = 10.0) -> dict[str, Any]:
    """GET request to the running runtime server."""
    url = f"http://127.0.0.1:{config.local_port}{path}"
    req = urllib.request.Request(url, method="GET")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def _runtime_post(
    config: RuntimeConfig, path: str, body: dict[str, Any], timeout: float = 120.0,
) -> dict[str, Any]:
    """POST request to the running runtime server."""
    url = f"http://127.0.0.1:{config.local_port}{path}"
    data = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        url, data=data, headers={"Content-Type": "application/json"}, method="POST",
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def _ollama_is_running(ollama_url: str) -> bool:
    """Check if Ollama is reachable."""
    try:
        req = urllib.request.Request(f"{ollama_url}/api/tags", method="GET")
        with urllib.request.urlopen(req, timeout=3.0):
            return True
    except Exception:
        return False


def _ollama_list_models(ollama_url: str) -> list[str]:
    """Get local Ollama model names."""
    try:
        req = urllib.request.Request(f"{ollama_url}/api/tags", method="GET")
        with urllib.request.urlopen(req, timeout=5.0) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return [m["name"] for m in data.get("models", [])]
    except Exception:
        return []


def _read_keys(data_dir: Path) -> dict[str, str]:
    """Read stored API keys from disk."""
    path = data_dir / "keys.json"
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _read_tokens(data_dir: Path) -> dict[str, dict[str, Any]]:
    """Read stored OAuth tokens from disk."""
    path = data_dir / "oauth_tokens.json"
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _write_tokens(data_dir: Path, tokens: dict[str, dict[str, Any]]) -> None:
    """Write OAuth tokens to disk."""
    data_dir.mkdir(parents=True, exist_ok=True)
    (data_dir / "oauth_tokens.json").write_text(json.dumps(tokens, indent=2), "utf-8")


_CATALOG_CACHE: Any = None
_CATALOG_CACHE_TIME = 0.0

def _get_catalog_and_stats() -> tuple[Any, dict[str, Any]]:
    """Get the marketplace catalog and stats, caching for 60 seconds to avoid disk IO."""
    global _CATALOG_CACHE, _CATALOG_CACHE_TIME
    now = time.time()
    if _CATALOG_CACHE is not None and (now - _CATALOG_CACHE_TIME) < 60:
        return _CATALOG_CACHE

    try:
        from cloud.marketplace.catalog import MarketplaceCatalog
        for data_path in [
            Path(__file__).resolve().parent.parent / "cloud" / "data",
            Path.cwd() / "cloud" / "data",
            Path("cloud/data"),
        ]:
            catalog_file = data_path / "marketplace" / "catalog.json"
            if catalog_file.exists():
                cat = MarketplaceCatalog(data_path)
                stats = cat.stats()
                _CATALOG_CACHE = (cat, stats)
                _CATALOG_CACHE_TIME = now
                return _CATALOG_CACHE
    except Exception:
        pass

    _CATALOG_CACHE = (None, {"total_entries": 0, "by_category": {}})
    _CATALOG_CACHE_TIME = now
    return _CATALOG_CACHE


# ── Commands ──────────────────────────────────────────────────────────


def _get_installed_agents(data_dir: Path) -> list[dict]:
    agents_dir = data_dir / "agents"
    if not agents_dir.exists():
        return []
    installed = []
    for mf in agents_dir.glob("*/manifest.json"):
        try:
            installed.append(json.loads(mf.read_text(encoding="utf-8")))
        except Exception:
            pass
    return installed

def cmd_stats(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.learning.usage_tracker import UsageTracker
    tracker = UsageTracker(config.data_dir)
    stats = tracker.get_stats()
    print("--- Usage Stats (Last 30 Days) ---")
    for k, v in stats.items():
        print(f"{k}: {v}")
    return 0

def cmd_insights(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.learning.usage_tracker import UsageTracker
    from runtime.learning.patterns import PatternDetector
    tracker = UsageTracker(config.data_dir)
    detector = PatternDetector(tracker)
    report = detector.generate_report()
    import json
    print("--- Insights Report ---")
    print(json.dumps(report, indent=2))
    return 0

def cmd_learn(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.learning.usage_tracker import UsageTracker
    from runtime.learning.preferences import Preferences
    tracker = UsageTracker(config.data_dir)
    prefs = Preferences(config.data_dir)
    learned = prefs.auto_detect(tracker)
    print("--- Auto-Detected Preferences ---")
    for k, v in learned.items():
        print(f"{k}: {v}")
    return 0

def cmd_preferences(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.learning.preferences import Preferences
    prefs = Preferences(config.data_dir)
    if getattr(args, "set", None):
        if "=" in args.set:
            k, v = args.set.split("=", 1)
            prefs.save(k, v)
            print(f"Saved {k}={v}")
        else:
            print("Usage: --set key=value")
            return 1
    else:
        all_p = prefs.get_all()
        print("--- Preferences ---")
        for k, v in all_p.items():
            print(f"{k}: {v}")
    return 0

def cmd_reset_learning(args: argparse.Namespace, config: RuntimeConfig) -> int:
    print("Are you sure you want to clear all learning data? (y/n)")
    ans = input().strip().lower()
    if ans == 'y':
        import shutil
        ldir = config.data_dir / "learning"
        if ldir.exists():
            shutil.rmtree(ldir)
        print("Learning data cleared.")
    return 0

def cmd_trends(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.learning.usage_tracker import UsageTracker
    tracker = UsageTracker(config.data_dir)
    trend = tracker.daily_trend()
    print("--- 30 Day Trend ---")
    for d in trend:
        print(f"{d['date']}: {'*' * min(d['count'], 50)} ({d['count']})")
    return 0

def cmd_costs(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.learning.usage_tracker import UsageTracker
    from runtime.learning.cost_tracker import CostTracker
    tracker = UsageTracker(config.data_dir)
    costs = CostTracker(tracker)
    
    if getattr(args, "budget", None):
        costs.set_budget(args.budget)
        print(f"Daily budget set to ${args.budget}")
        return 0
        
    rep = costs.weekly_report()
    print("--- Weekly Cost Report ---")
    print(f"Total USD: ${rep['total_usd']:.4f}")
    print(f"Top Model: {rep['top_cost_model']}")
    print(f"Avg Daily: ${rep['avg_daily_cost']:.4f}")
    
    budget_status = costs.check_budget()
    print("\n--- Budget Status ---")
    print(f"Budget: ${budget_status['budget']:.4f}")
    print(f"Spent Today: ${budget_status['spent_today']:.4f}")
    print(f"Remaining: ${budget_status['remaining']:.4f}")
    return 0

def cmd_ab(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.learning.ab_testing import ABTestManager
    mgr = ABTestManager(config.data_dir)
    
    if getattr(args, "create", None):
        mgr.create_test(args.create, {"strategy": "sequential"}, {"strategy": "debate"})
        print(f"Created A/B test: {args.create}")
    elif getattr(args, "conclude", None):
        winner = mgr.conclude_test(args.conclude)
        print(f"Concluded {args.conclude}. Winner config: {winner}")
    elif getattr(args, "delete", None):
        mgr.delete_test(args.delete)
        print(f"Deleted test: {args.delete}")
    else:
        tests = mgr.list_tests()
        print("--- A/B Tests ---")
        for t in tests:
            print(f"{t['name']} - Status: {t['status']}")
    return 0

def cmd_network(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.network.telemetry import TelemetryManager
    from runtime.network.insights import NetworkInsights
    
    tm = TelemetryManager(config.data_dir)
    if not tm.is_opted_in():
        print("Telemetry is not opted in. Run 'brynq-runtime preferences --set telemetry_enabled=True' to opt in and see network insights.")
        return 1
        
    insights = NetworkInsights(config.data_dir, config.cloud_url)
    print(insights.display_insights())
    return 0

def cmd_leaderboard(args: argparse.Namespace, config: RuntimeConfig) -> int:
    from runtime.network.insights import NetworkInsights
    insights = NetworkInsights(config.data_dir, config.cloud_url)
    lb = insights.fetch_leaderboard()
    
    if not lb:
        print("Leaderboard is empty or unreachable.")
        return 1
        
    print(f"{'Rank':<5} | {'Model':<30} | {'Score':<6} | {'Popularity'}")
    print("-" * 65)
    for row in lb:
        print(f"{row['rank']:<5} | {row['model']:<30} | {row['score']:<6.2f} | {row['popularity']}")
    return 0

def cmd_rate_agent(args: argparse.Namespace, config: RuntimeConfig) -> int:
    import urllib.request
    import json
    
    if args.rating < 1 or args.rating > 5:
        print("Rating must be between 1 and 5.")
        return 1
        
    from runtime.network.telemetry import TelemetryManager
    tm = TelemetryManager(config.data_dir)
    instance_id = tm.get_instance_id()
    
    payload = {"rating": args.rating, "review": args.review, "instance_id": instance_id}
    try:
        req = urllib.request.Request(
            f"{config.cloud_url}/v1/agents/{args.agent_id}/rate",
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"},
            method="POST"
        )
        with urllib.request.urlopen(req, timeout=5) as resp:
            print("Rating submitted successfully.")
            return 0
    except Exception as e:
        print(f"Failed to submit rating: {e}")
        return 1

def cmd_agent_reviews(args: argparse.Namespace, config: RuntimeConfig) -> int:
    import urllib.request
    import json
    try:
        req = urllib.request.Request(f"{config.cloud_url}/v1/agents/{args.agent_id}/reviews")
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            
        print(f"--- Reviews for {args.agent_id} (Avg: {data.get('avg_rating', 0):.1f}) ---")
        for r in data.get("reviews", []):
            print(f"[{r['rating']} stars] - {r['review']}")
        return 0
    except Exception as e:
        print(f"Failed to fetch reviews: {e}")
        return 1

def cmd_start(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Start the runtime server and open the app in the browser."""
    # Check if already running
    if _is_runtime_running(config):
        print(f"Brynq is already running at http://127.0.0.1:{config.local_port}")
        print(f"App: {APP_URL}")
        if not args.no_browser:
            webbrowser.open(APP_URL)
        return 0

    # Check Ollama
    ollama_ok = _ollama_is_running(config.ollama_url)
    if ollama_ok:
        models = _ollama_list_models(config.ollama_url)
        if models:
            print(f"  Ollama: {_green('running')} ({', '.join(models)})")
        else:
            print(f"  Ollama: {_green('running')} (no models pulled)")
    else:
        print(f"  Ollama: {_dim('not running')} (local models unavailable)")

    # Check API keys
    keys = _read_keys(config.data_dir)
    if keys:
        providers = ", ".join(sorted(keys.keys()))
        print(f"  API keys: {_green(providers)}")
    else:
        print(f"  API keys: {_dim('none configured')}")

    # Start the server as a background process
    env = {**os.environ}
    env["BRYNQ_RUNTIME_LOCAL_PORT"] = str(config.local_port)
    if config.debug:
        env["BRYNQ_RUNTIME_DEBUG"] = "1"

    cmd = [
        sys.executable, "-m", "uvicorn",
        "runtime.app:app",
        "--host", "127.0.0.1",
        "--port", str(config.local_port),
    ]

    # Redirect output to log file
    log_dir = config.data_dir / "logs"
    log_dir.mkdir(parents=True, exist_ok=True)
    log_file = log_dir / "runtime.log"

    with open(log_file, "a") as lf:
        proc = subprocess.Popen(
            cmd,
            env=env,
            stdout=lf,
            stderr=lf,
            start_new_session=True,
        )

    # Save PID for later shutdown
    pid_file = _pid_file(config)
    config.data_dir.mkdir(parents=True, exist_ok=True)
    pid_file.write_text(str(proc.pid), "utf-8")

    # Wait for server to come up
    print()
    print("Starting Brynq runtime...", end="", flush=True)
    started = False
    for _ in range(30):
        time.sleep(0.5)
        if _is_runtime_running(config):
            started = True
            break
        print(".", end="", flush=True)

    if not started:
        print(f" {_red('failed')}")
        print(f"Server did not start. Check logs: {log_file}")
        return 1

    print(f" {_green('ready')}")
    print()
    print(f"  {_bold('Brynq is running')} at http://127.0.0.1:{config.local_port}")
    print(f"  App: {_cyan(APP_URL)}")
    print(f"  Logs: {_dim(str(log_file))}")
    print()
    print(f"  {_dim('Stop with:')} brynq-runtime stop")

    # Open browser
    if not args.no_browser:
        webbrowser.open(APP_URL)

    return 0


def cmd_stop(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Stop the runtime server."""
    pid_file = _pid_file(config)

    if not _is_runtime_running(config) and not pid_file.exists():
        print("Brynq is not running.")
        return 0

    # Try to kill by PID
    if pid_file.exists():
        try:
            pid = int(pid_file.read_text("utf-8").strip())
            os.kill(pid, signal.SIGTERM)
            print(f"Stopping Brynq (PID {pid})...", end="", flush=True)

            # Wait for shutdown
            for _ in range(20):
                time.sleep(0.25)
                try:
                    os.kill(pid, 0)  # Check if still alive
                except OSError:
                    break

            pid_file.unlink(missing_ok=True)
            print(f" {_green('stopped')}")
            return 0

        except (ValueError, OSError) as e:
            # PID file stale or process already dead
            pid_file.unlink(missing_ok=True)

    # Fallback: check if still responding
    if _is_runtime_running(config):
        print(f"{_yellow('Warning')}: Could not find server process. It may still be running on port {config.local_port}.")
        return 1

    print("Brynq stopped.")
    pid_file.unlink(missing_ok=True)
    return 0


def cmd_status(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Show full runtime status."""
    print(_bold("Brynq Runtime Status"))
    print()

    # Runtime server
    running = _is_runtime_running(config)
    if running:
        print(f"  Runtime:  {_green('running')} on port {config.local_port}")
    else:
        print(f"  Runtime:  {_red('stopped')}")

    # Ollama
    ollama_ok = _ollama_is_running(config.ollama_url)
    if ollama_ok:
        models = _ollama_list_models(config.ollama_url)
        if models:
            print(f"  Ollama:   {_green('running')} ({len(models)} models: {', '.join(models)})")
        else:
            print(f"  Ollama:   {_green('running')} (no models pulled)")
    else:
        print(f"  Ollama:   {_red('stopped')}")

    # Cloud connection (only if runtime is up)
    if running:
        try:
            status_data = _runtime_get(config, "/status")
            cloud = status_data.get("cloud", {})
            if cloud.get("connected"):
                print(f"  Cloud:    {_green('connected')} ({config.cloud_url})")
            else:
                print(f"  Cloud:    {_yellow('unreachable')}")
        except Exception:
            print(f"  Cloud:    {_dim('unknown')}")
    else:
        print(f"  Cloud:    {_dim('unknown (runtime stopped)')}")

    # API keys
    print()
    keys = _read_keys(config.data_dir)
    provider_labels = {
        "anthropic": "Claude",
        "openai": "ChatGPT",
        "google": "Gemini",
        "mistral": "Mistral",
        "cohere": "Cohere",
        "groq": "Groq",
    }

    print("  API Keys:")
    if keys:
        for provider, _key in sorted(keys.items()):
            label = provider_labels.get(provider, provider)
            print(f"    {label:<12} {_green('configured')}")
    else:
        print(f"    {_dim('None configured. Use the app or POST /keys to add API keys.')}")

    # OAuth tokens
    tokens = _read_tokens(config.data_dir)
    if tokens:
        print()
        print("  OAuth:")
        for provider, token_data in sorted(tokens.items()):
            label = provider_labels.get(provider, provider)
            email = token_data.get("email", "")
            if email:
                print(f"    {label:<12} {_green('connected')} as {email}")
            else:
                print(f"    {label:<12} {_green('connected')}")

    print()
    print(f"  Data dir: {_dim(str(config.data_dir))}")
    print(f"  Version:  {VERSION}")

    return 0


def cmd_models(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """List available models from all sources."""
    print(_bold("Available Models"))
    print()

    # Local models (Ollama)
    ollama_ok = _ollama_is_running(config.ollama_url)
    print("  Local (Ollama):")
    if ollama_ok:
        models = _ollama_list_models(config.ollama_url)
        if models:
            for m in models:
                print(f"    {m:<20} {_green('ready')}")
        else:
            print(f"    {_dim('No models pulled. Run: ollama pull llama3')}")
    else:
        print(f"    {_dim('Ollama not running. Start: ollama serve')}")

    # Cloud models (BYOK)
    print()
    print("  Cloud (BYOK):")

    keys = _read_keys(config.data_dir)
    cloud_providers = {
        "anthropic": [
            ("claude-sonnet-4-20250514", "Claude Sonnet 4"),
            ("claude-opus-4-20250514", "Claude Opus 4"),
            ("claude-haiku-3-5-20241022", "Claude Haiku 3.5"),
        ],
        "openai": [
            ("gpt-4o", "GPT-4o"),
            ("gpt-4o-mini", "GPT-4o Mini"),
            ("o3-mini", "o3 Mini"),
        ],
        "google": [
            ("gemini-2.0-flash", "Gemini 2.0 Flash"),
            ("gemini-2.5-pro", "Gemini 2.5 Pro"),
        ],
    }

    any_cloud = False
    for provider, model_list in cloud_providers.items():
        if provider in keys:
            any_cloud = True
            for model_id, label in model_list:
                print(f"    {label:<24} {_green('ready')}  {_dim(model_id)}")
        else:
            for _model_id, label in model_list:
                print(f"    {label:<24} {_red('no key')}  {_dim('POST /keys to add')}")

    if not any_cloud and not keys:
        print(f"    {_dim('No API keys configured.')}")

    return 0


def cmd_login(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Start OAuth flow for a cloud provider."""
    from runtime.auth.providers import PROVIDERS, get_provider

    provider_name = args.provider.lower()

    # Map common aliases
    aliases = {"claude": "anthropic", "chatgpt": "openai", "gpt": "openai", "gemini": "google"}
    provider_key = aliases.get(provider_name, provider_name)

    try:
        provider_config = get_provider(provider_key)
    except ValueError as e:
        print(f"{_red('Error')}: {e}")
        return 1

    client_id = provider_config.get_client_id()
    if not client_id:
        print(f"{_red('Error')}: No client ID configured for {provider_config.name}.")
        print(f"  Set the environment variable: {provider_config.client_id_env}")
        return 1

    # Build the authorization URL
    import hashlib
    import secrets

    state = secrets.token_urlsafe(32)
    callback_port = 9876
    redirect_uri = f"http://localhost:{callback_port}/callback"

    params = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": " ".join(provider_config.scopes),
        "state": state,
    }
    if provider_config.extra_auth_params:
        params.update(provider_config.extra_auth_params)

    # PKCE if required
    code_verifier = None
    if provider_config.requires_pkce:
        code_verifier = secrets.token_urlsafe(64)
        digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
        import base64
        code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
        params["code_challenge"] = code_challenge
        params["code_challenge_method"] = "S256"

    query = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in params.items())
    auth_url = f"{provider_config.auth_url}?{query}"

    print(f"Connecting to {_bold(provider_config.name)}...")
    print(f"Opening browser for authorization...")
    print()

    # Start a temporary local server to receive the callback
    from http.server import BaseHTTPRequestHandler, HTTPServer
    from urllib.parse import parse_qs, urlparse

    auth_code = None
    received_state = None
    server_error = None

    class CallbackHandler(BaseHTTPRequestHandler):
        def do_GET(self) -> None:
            nonlocal auth_code, received_state, server_error
            parsed = urlparse(self.path)
            qs = parse_qs(parsed.query)

            if parsed.path == "/callback":
                auth_code = qs.get("code", [None])[0]
                received_state = qs.get("state", [None])[0]
                error = qs.get("error", [None])[0]

                if error:
                    server_error = qs.get("error_description", [error])[0]
                    self.send_response(200)
                    self.send_header("Content-Type", "text/html")
                    self.end_headers()
                    self.wfile.write(b"<html><body><h2>Authorization failed.</h2><p>You can close this tab.</p></body></html>")
                else:
                    self.send_response(200)
                    self.send_header("Content-Type", "text/html")
                    self.end_headers()
                    self.wfile.write(b"<html><body><h2>Connected to Brynq!</h2><p>You can close this tab.</p></body></html>")
            else:
                self.send_response(404)
                self.end_headers()

        def log_message(self, format: str, *log_args: Any) -> None:
            pass  # Suppress request logging

    try:
        server = HTTPServer(("127.0.0.1", callback_port), CallbackHandler)
    except OSError:
        print(f"{_red('Error')}: Port {callback_port} is in use. Close other OAuth flows and try again.")
        return 1

    server.timeout = 120  # 2 minute timeout

    # Open browser
    webbrowser.open(auth_url)
    print(f"Waiting for authorization (timeout: 2 minutes)...")
    print(f"  {_dim('If the browser did not open, visit:')}")
    print(f"  {_dim(auth_url[:100])}{'...' if len(auth_url) > 100 else ''}")
    print()

    # Wait for callback
    server.handle_request()
    server.server_close()

    if server_error:
        print(f"{_red('Error')}: Authorization failed: {server_error}")
        return 1

    if not auth_code:
        print(f"{_red('Error')}: No authorization code received. Try again.")
        return 1

    if received_state != state:
        print(f"{_red('Error')}: State mismatch. Possible CSRF attack. Aborting.")
        return 1

    # Exchange code for token
    print("Exchanging authorization code for token...", end="", flush=True)

    token_body: dict[str, str] = {
        "grant_type": "authorization_code",
        "code": auth_code,
        "redirect_uri": redirect_uri,
        "client_id": client_id,
    }
    if code_verifier:
        token_body["code_verifier"] = code_verifier

    token_data = json.dumps(token_body).encode("utf-8")
    token_req = urllib.request.Request(
        provider_config.token_url,
        data=token_data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )

    try:
        with urllib.request.urlopen(token_req, timeout=30) as resp:
            token_resp = json.loads(resp.read().decode("utf-8"))
    except Exception as e:
        print(f" {_red('failed')}")
        print(f"Token exchange error: {e}")
        return 1

    print(f" {_green('done')}")

    # Store token
    tokens = _read_tokens(config.data_dir)
    tokens[provider_key] = {
        "access_token": token_resp.get("access_token", ""),
        "refresh_token": token_resp.get("refresh_token", ""),
        "token_type": token_resp.get("token_type", "bearer"),
        "scope": token_resp.get("scope", ""),
        "email": token_resp.get("email", ""),
        "connected_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    }
    _write_tokens(config.data_dir, tokens)

    email = token_resp.get("email", "")
    if email:
        print(f"\n{_green('Connected')} to {provider_config.name} as {_bold(email)}")
    else:
        print(f"\n{_green('Connected')} to {provider_config.name}")

    return 0


def cmd_chat(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Interactive orchestrated multi-model chat.

    Auto-detects available models, classifies each request, picks the
    right strategy (single/debate/refine/fan-out), and routes to the
    right models. Code/action tasks go to Claude only. Thinking tasks
    use multi-model collaboration.

    With --once: non-interactive mode. Runs one prompt and exits with
    output to stdout. Used by autopilot.py for autonomous operation.
    """
    # Handle --clipboard: read prompt from clipboard if no positional prompt given
    if getattr(args, "clipboard", False) and not getattr(args, "prompt", []):
        from runtime.integrations.clipboard import read_clipboard
        print("Reading prompt from clipboard...")
        clip_text = read_clipboard().strip()
        if clip_text:
            args.prompt = clip_text.split()
        else:
            print("Clipboard is empty.", file=sys.stderr)
            return 1

    # Handle --once mode (non-interactive, for autopilot)
    if getattr(args, "once", False) and getattr(args, "prompt", []):
        prompt_text = " ".join(args.prompt)
        # Use Claude Code with tool use so it can actually edit files
        import subprocess as _sp
        try:
            system = (
                "You are running inside Brynq autopilot. Your job is to edit "
                "files in the project to complete the task. Do NOT just output "
                "code — use your tools to Read, Edit, and Write files directly. "
                "Keep changes minimal and focused. Run existing tests if unsure.\n\n"
                "CRITICAL: Before implementing anything, READ the target file and "
                "SEARCH for the feature. If it already exists and works, output DONE "
                "and make NO changes. If it exists but is incomplete, fix it — do NOT "
                "add a duplicate. Only create new code if the feature is truly missing."
            )
            # Load architectural guardrails if available
            directive_path = Path(__file__).resolve().parent.parent / "AUTOPILOT_DIRECTIVE.md"
            if directive_path.exists():
                try:
                    directive = directive_path.read_text(encoding="utf-8")
                    system = directive + "\n\n" + system
                except Exception:
                    pass
            full_prompt = f"{system}\n\nTask: {prompt_text}"
            r = _sp.run(
                ["claude", "-p", full_prompt, "--allowedTools",
                 "Read,Write,Edit,Glob,Grep,Bash"],
                capture_output=True, text=True, timeout=300,
                encoding="utf-8", errors="replace",
            )
            if r.returncode == 0:
                print(r.stdout.strip() if r.stdout.strip() else "DONE")
                return 0
            else:
                print(r.stderr.strip() if r.stderr else "[no output]", file=sys.stderr)
                return 1
        except Exception as e:
            print(f"[error: {e}]", file=sys.stderr)
            return 1
    verbose = getattr(args, "verbose", False)
    import re as _re
    import shutil
    import subprocess
    import threading

    # ── Cloud skill count (non-blocking fetch at startup) ─────────
    _cloud_status: dict[str, Any] = {"skill_count": None, "online": None}

    def _fetch_skill_count() -> None:
        """Fetch skill count from cloud API. Runs in background thread."""
        try:
            url = f"{config.cloud_url}/v1/skills/categories"
            req = urllib.request.Request(url, method="GET")
            with urllib.request.urlopen(req, timeout=5.0) as resp:
                data = json.loads(resp.read().decode("utf-8"))
                total = 0
                if isinstance(data, dict):
                    # Sum across categories: {"code": 1200, "research": 800, ...}
                    for v in data.values():
                        if isinstance(v, int):
                            total += v
                        elif isinstance(v, list):
                            total += len(v)
                    # Also accept a top-level "total" or "count" field
                    total = data.get("total", data.get("count", total))
                elif isinstance(data, list):
                    total = len(data)
                _cloud_status["skill_count"] = total if total > 0 else None
                _cloud_status["online"] = True
        except Exception:
            _cloud_status["skill_count"] = None
            _cloud_status["online"] = False

    _skill_thread = threading.Thread(target=_fetch_skill_count, daemon=True)
    _skill_thread.start()

    # ── Detect available models ────────────────────────────────────
    active_models: list[str] = []

    if shutil.which("claude"):
        active_models.append("claude")
    if shutil.which("gemini"):
        active_models.append("gemini")

    ollama_ok = _ollama_is_running(config.ollama_url)
    if ollama_ok:
        for m in _ollama_list_models(config.ollama_url):
            active_models.append(f"ollama:{m}")

    all_detected = list(active_models)  # snapshot for /add validation

    # ── Setup guide ─────────────────────────────────────────────
    has_claude = shutil.which("claude") is not None
    has_gemini = shutil.which("gemini") is not None
    has_ollama = ollama_ok

    if not active_models:
        print()
        print(f"  {_bold('Welcome to Brynq!')}")
        print()
        print(f"  No AI models detected on your system.")
        print(f"  Brynq orchestrates models you already have — install at least one:")
        print()
        print(f"  {_bold('Free (runs locally, no account needed):')}")
        print(f"    1. Install Ollama        https://ollama.com/download")
        print(f"    2. Pull a model          ollama pull llama3")
        print(f"    3. Come back             python -m runtime chat")
        print()
        print(f"  {_bold('Cloud (use your existing subscriptions):')}")
        print(f"    Claude Code              npm install -g @anthropic-ai/claude-code")
        print(f"    Gemini CLI               npm install -g @anthropic-ai/gemini-cli")
        print()
        print(f"  Install any one and Brynq will detect it automatically.")
        print()
        return 1

    # Show what's connected and what's missing
    missing: list[str] = []
    if not has_claude:
        missing.append("Claude Code")
    if not has_gemini:
        missing.append("Gemini CLI")
    if not has_ollama:
        missing.append("Ollama")

    if missing:
        # Show a brief suggestion after the header, not a blocker
        _missing_hint = missing  # save for header display
    else:
        _missing_hint = []

    # ── ANSI helpers ───────────────────────────────────────────────
    _MAGENTA = "\033[35m"
    _BLUE = "\033[34m"
    _YELLOW = "\033[33m"
    _GREEN_C = "\033[32m"
    _RED_C = "\033[31m"
    _WHITE_BOLD = "\033[1;37m"
    _DIM = "\033[2m"
    _BG_PANEL = "\033[48;5;236m"
    _BG_HEADER = "\033[44;37m"
    _RESET = "\033[0m"

    def _model_label(model: str) -> str:
        if model == "claude":
            name, color = "Claude (Opus 4.6)", _MAGENTA
        elif model == "gemini":
            name, color = "Gemini 3.1", _BLUE
        elif model.startswith("ollama:"):
            name, color = f"Ollama/{model.split(':', 1)[1]}", _YELLOW
        else:
            name, color = model, ""
        return f"{color}{name}{_RESET}" if _COLOR and color else name

    # ── Conversation memory ───────────────────────────────────────
    conversation: list[dict[str, str]] = []  # [{role, content}, ...]
    _rag_enabled = True  # RAG context injection (toggle with /rag on/off)

    # ── File auto-reading ─────────────────────────────────────────
    _MAX_FILE_SIZE = 500 * 1024  # 500 KB
    _FILE_PATH_RE = _re.compile(
        r"""(?:^|(?<=\s))"""               # start of string or preceded by whitespace
        r"""(?!https?://)"""               # not a URL
        r"""(?!/[a-z]+(?:\s|$))"""         # not a /slash command
        r"""("""
        r"""(?:[A-Za-z]:)?"""              # optional drive letter (Windows)
        r"""[^\s"'<>|*?]+"""              # path chars (no spaces, no shell metachars)
        r""")"""
        r"""(?=\s|$)""",                   # followed by whitespace or end
        _re.MULTILINE,
    )

    def _scan_file_paths(text: str) -> list[Path]:
        """Extract tokens from *text* that resolve to existing files under 500 KB."""
        found: list[Path] = []
        seen: set[str] = set()
        for m in _FILE_PATH_RE.finditer(text):
            token = m.group(1)
            # Must contain a path separator or a dot-extension to look like a file
            if "/" not in token and "\\" not in token and "." not in token:
                continue
            p = Path(token).expanduser()
            if not p.is_absolute():
                p = Path.cwd() / p
            resolved = str(p.resolve())
            if resolved in seen:
                continue
            seen.add(resolved)
            try:
                if p.is_file() and p.stat().st_size <= _MAX_FILE_SIZE:
                    found.append(p)
            except OSError:
                pass
        return found

    def _prepend_file_contents(text: str, files: list[Path]) -> str:
        """Return *text* with the contents of *files* prepended as context."""
        if not files:
            return text
        parts: list[str] = []
        for fp in files:
            try:
                content = fp.read_text(encoding="utf-8", errors="replace")
                parts.append(f"--- Contents of {fp.name} ---\n{content}\n--- End of {fp.name} ---")
            except OSError:
                pass
        if not parts:
            return text
        return "\n\n".join(parts) + f"\n\n{text}"

    # ── Direct action executor ─────────────────────────────────────
    def _try_direct_action(text: str) -> str | None:
        """Try to execute simple system actions directly.

        Returns the result message, or None if it's not a simple action.
        """
        t = text.lower().strip()

        # Create folder
        m = _re.search(r"(?:create|make|new)\s+(?:a\s+)?(?:folder|directory|dir)\s+(?:called|named)?\s*[\"']?(\S+)[\"']?", t)
        if not m:
            m = _re.search(r"(?:mkdir)\s+[\"']?(\S+)[\"']?", t)
        if m:
            folder_name = m.group(1).strip("\"'")
            target = Path.home() / "Desktop" / folder_name
            if "desktop" not in t:
                target = Path.cwd() / folder_name
            try:
                target.mkdir(parents=True, exist_ok=True)
                return f"Created folder: {target}"
            except OSError as e:
                return f"Failed to create folder: {e}"

        # Delete folder/file
        m = _re.search(r"(?:delete|remove)\s+(?:the\s+)?(?:folder|directory|file)\s+(?:called|named)?\s*[\"']?(\S+)[\"']?", t)
        if m:
            name = m.group(1).strip("\"'")
            target = Path.home() / "Desktop" / name if "desktop" in t else Path.cwd() / name
            if target.exists():
                if target.is_dir():
                    import shutil as _shutil
                    _shutil.rmtree(target)
                else:
                    target.unlink()
                return f"Deleted: {target}"
            return f"Not found: {target}"

        # Open URL
        m = _re.search(r"(?:open)\s+(https?://\S+)", t)
        if m:
            import webbrowser
            webbrowser.open(m.group(1))
            return f"Opened: {m.group(1)}"

        return None  # Not a simple action — route to models

    # ── Task classification ────────────────────────────────────────
    _CODE_MODELS = {"claude", "gemini"}

    def _classify_local(text: str) -> tuple[str, str, str]:
        t_lower = text.lower()
        if any(w in t_lower for w in ["brynq", "marketplace", "mcp", "agent", "tool"]) and "what" in t_lower:
            return "general", "single", "question about Brynq"
        
        """Local keyword-based classification (fallback).

        Returns:
            task_type: action, code, analysis, creative, research, compare, general
            strategy: single, debate, refine, fan_out
            reason: human-readable explanation of why this strategy was chosen
        """
        t = text.lower()

        # Action tasks — try direct execution first
        action_kw = (
            "create ", "delete ", "make ", "open ", "run ", "install ",
            "move ", "copy ", "rename ", "download ", "mkdir", "folder",
            "file ", "save ", "write to ", "execute",
        )
        matched = [k.strip() for k in action_kw if k in t]
        if matched:
            return "action", "single", f"action keywords matched: {matched}; routed to single model for direct execution"

        # Code tasks — detect complexity to pick strategy
        code_keywords = (
            "code", "function", "class ", "debug", "fix ", "refactor",
            "implement", "script", "program", "api ", "endpoint",
            "bug ", "error ", "test ", "deploy",
        )
        matched = [k.strip() for k in code_keywords if k in t]
        if matched:
            # Measure complexity: long prompts or multiple requirements = multi-model
            complexity = 0
            breakdown: list[str] = []
            length_score = len(t) // 200
            if length_score:
                complexity += length_score
                breakdown.append(f"length={length_score}")
            kw_score = len(matched)
            complexity += kw_score
            breakdown.append(f"keywords({kw_score})={matched}")
            and_count = t.count(" and ")
            if and_count:
                complexity += and_count
                breakdown.append(f"'and'×{and_count}")
            then_count = t.count(" then ")
            if then_count:
                complexity += then_count
                breakdown.append(f"'then'×{then_count}")
            comma_count = t.count(",")
            if comma_count:
                complexity += comma_count
                breakdown.append(f"commas×{comma_count}")
            if "mode" in t or "feature" in t:
                complexity += 1
                breakdown.append("mode/feature")
            if "self" in t or "itself" in t or "own source" in t:
                complexity += 1
                breakdown.append("self-referential")

            detail = "; ".join(breakdown)
            if complexity >= 12:
                return "code", "swarm", f"complexity={complexity} (>=12 → swarm); {detail}"
            elif complexity >= 8:
                return "code", "refine", f"complexity={complexity} (>=8 → refine: draft→critique→revise); {detail}"
            elif complexity >= 5:
                return "code", "fan_out", f"complexity={complexity} (>=5 → fan_out: parallel models); {detail}"
            else:
                return "code", "single", f"complexity={complexity} (<5 → single model); {detail}"

        # Compare/debate tasks
        compare_kw = (
            "compare", "vs ", "versus", "better", "pros and cons",
            "debate", "argue", "which ", "should i use",
            "difference between", "advantages",
        )
        matched = [k.strip() for k in compare_kw if k in t]
        if matched:
            return "compare", "debate", f"compare keywords matched: {matched}; multiple models will argue pro/con"

        # Creative tasks — draft → critique → revise
        creative_kw = (
            "write ", "draft ", "compose", "design ", "story",
            "email ", "blog ", "essay ", "article ", "landing page",
            "headline", "slogan", "pitch",
        )
        matched = [k.strip() for k in creative_kw if k in t]
        if matched:
            return "creative", "refine", f"creative keywords matched: {matched}; draft→critique→revise cycle"

        # Research/analysis — multiple perspectives
        research_kw = (
            "analyze", "research", "explain", "how does",
            "what is", "why ", "investigate", "explore",
            "summarize", "overview",
        )
        matched = [k.strip() for k in research_kw if k in t]
        if matched:
            return "research", "fan_out", f"research keywords matched: {matched}; fan-out to multiple models for perspectives"

        return "general", "single", "no category keywords matched; defaulting to general/single"

    def _classify_cloud(text: str) -> tuple[str, str, str] | None:
        """Try cloud-based classification via /v1/plan endpoint.

        Returns (task_type, strategy, reason) on success, or None if unreachable.
        """
        if _cloud_status["online"] is False:
            return None
        try:
            url = f"{config.cloud_url}/v1/plan"
            body = json.dumps({"input": text, "classify_only": True}).encode("utf-8")
            req = urllib.request.Request(
                url, data=body,
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=3.0) as resp:
                data = json.loads(resp.read().decode("utf-8"))
                task_type = data.get("task_type", "").strip()
                strategy = data.get("strategy", "").strip()
                if task_type and strategy:
                    _cloud_status["online"] = True
                    reason = f"cloud /v1/plan classified as {task_type}/{strategy}"
                    cloud_reason = data.get("reason", "")
                    if cloud_reason:
                        reason += f": {cloud_reason}"
                    return task_type, strategy, reason
        except Exception:
            pass
        return None

    def _classify(text: str) -> tuple[str, str, str]:
        """Classify user input → (task_type, strategy, reason).

        Tries cloud /v1/plan first for full skill matching (7,889 skills),
        falls back to local keyword matching if cloud is unreachable.

        Returns:
            task_type: action, code, analysis, creative, research, compare, general
            strategy: single, debate, refine, fan_out
            reason: explanation of why this strategy was chosen
        """
        cloud_result = _classify_cloud(text)
        if cloud_result is not None:
            return cloud_result
        return _classify_local(text)

    def _pick_models(task_type: str, strategy: str) -> list[str]:
        """Pick which models to use based on task type."""
        code_capable = [m for m in active_models if m in _CODE_MODELS]
        all_m = active_models

        if task_type == "action":
            if "claude" in active_models:
                return ["claude"]
            return code_capable[:1] if code_capable else all_m[:1]

        if task_type == "code":
            if strategy == "single":
                if "claude" in active_models:
                    return ["claude"]
                return code_capable[:1] if code_capable else all_m[:1]
            elif strategy == "refine":
                drafter = "claude" if "claude" in active_models else (code_capable[0] if code_capable else all_m[0])
                critic = "gemini" if "gemini" in active_models else (all_m[1] if len(all_m) > 1 else drafter)
                return [drafter, critic, drafter]
            elif strategy == "swarm":
                # Heavy tasks: all available models, repeated for parallelism
                # Scale up to 20 agents based on available models
                pool = code_capable or all_m
                agents = []
                while len(agents) < 20 and len(agents) < len(pool) * 4:
                    agents.extend(pool)
                return agents[:20]
            else:
                # Fan-out: all code-capable models in parallel
                return (code_capable or all_m)[:len(all_m)]

        if strategy == "debate":
            # Need at least 2 different models + a judge
            if len(all_m) >= 3:
                return all_m[:3]
            return all_m[:2] if len(all_m) >= 2 else all_m[:1]

        if strategy == "refine":
            # drafter (fast) + critic (smart) + reviser (fast)
            fast = [m for m in all_m if m.startswith("ollama:")] or all_m[-1:]
            smart = code_capable[:1] or all_m[:1]
            return [fast[0], smart[0], fast[0]]

        if strategy == "fan_out":
            # Up to 3 different models
            return all_m[:3]

        # Single — best available
        return code_capable[:1] if code_capable else all_m[:1]

    # ── System prompt (tells models they're inside Brynq) ─────────
    _BRYNQ_SYSTEM = (
        "You are running inside Brynq, an AI orchestration platform on the user's computer. "
        "You are one of several AI models working together. The user's data stays on their machine. "
        "Brynq can chain multiple models using these strategies: "
        "Sequential (A->B->C with context passing), "
        "Fan-Out (models run in parallel, results merged), "
        "Debate (two models argue, a third judges), "
        "Refine (one drafts, another critiques, first revises), "
        "or Single-Model (one model, simple tasks). "
        "The user can trigger chains with /chain or the chain builder. "
        "Answer the user's question directly. Do not offer to build Brynq or suggest the user "
        "install tools — they already have everything set up."
    )

    # ── Model runners ──────────────────────────────────────────────
    def _call_claude(prompt: str) -> str:
        # Build context from last 5 conversation turns
        context_parts = []
        for msg in conversation[-10:]:
            role = "User" if msg["role"] == "user" else "Assistant"
            context_parts.append(f"{role}: {msg['content'][:500]}")

        system_prefix = f"[System: {_BRYNQ_SYSTEM}]\n\n"
        if context_parts:
            full_prompt = system_prefix + "Previous conversation:\n" + "\n".join(context_parts) + f"\n\nUser: {prompt}"
        else:
            full_prompt = system_prefix + prompt
        try:
            r = subprocess.run(
                ["claude", "--print"], input=full_prompt,
                capture_output=True, text=True, timeout=180,
                encoding="utf-8", errors="replace",
            )
            return r.stdout.strip() if r.returncode == 0 else f"[error: {r.stderr.strip()[:200]}]"
        except subprocess.TimeoutExpired:
            return "[timed out after 120s]"
        except Exception as e:
            return f"[error: {e}]"

    def _call_gemini(prompt: str) -> str:
        full_prompt = f"[System: {_BRYNQ_SYSTEM}]\n\n{prompt}"
        # Windows needs .cmd extension for npm-installed CLIs
        gemini_cmd = "gemini.cmd" if sys.platform == "win32" else "gemini"
        try:
            r = subprocess.run(
                [gemini_cmd, "-p", full_prompt], input=full_prompt,
                capture_output=True, text=True, timeout=180,
                encoding="utf-8", errors="replace",
            )
            return r.stdout.strip() if r.returncode == 0 else f"[error: {r.stderr.strip()[:200]}]"
        except subprocess.TimeoutExpired:
            return "[timed out after 120s]"
        except Exception as e:
            return f"[error: {e}]"

    def _call_ollama(prompt: str, model: str) -> str:
        try:
            body = {
                "model": model,
                "messages": [
                    {"role": "system", "content": _BRYNQ_SYSTEM},
                    {"role": "user", "content": prompt},
                ],
                "stream": False,
            }
            data = json.dumps(body).encode("utf-8")
            req = urllib.request.Request(
                f"{config.ollama_url}/api/chat",
                data=data,
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=config.ollama_timeout) as resp:
                result = json.loads(resp.read().decode("utf-8"))
                return result.get("message", {}).get("content", "")
        except urllib.error.HTTPError as e:
            if e.code == 404:
                # Model not found — list available models
                available = _list_ollama_models()
                if available:
                    names = ", ".join(available)
                    return f"[error: model '{model}' not found. Available models: {names}]"
                return f"[error: model '{model}' not found. Pull it with: ollama pull {model}]"
            return f"[error: {e}]"
        except urllib.error.URLError as e:
            reason = getattr(e, "reason", None)
            if isinstance(reason, ConnectionRefusedError) or (
                isinstance(reason, OSError) and "refused" in str(reason).lower()
            ):
                return "[error: Ollama is not running. Start it with: ollama serve]"
            return f"[error: {e}]"
        except Exception as e:
            return f"[error: {e}]"

    def _list_ollama_models() -> list[str]:
        """Fetch available model names from Ollama's /api/tags endpoint."""
        try:
            req = urllib.request.Request(
                f"{config.ollama_url}/api/tags",
                method="GET",
            )
            with urllib.request.urlopen(req, timeout=5) as resp:
                data = json.loads(resp.read().decode("utf-8"))
                return [m.get("name", "") for m in data.get("models", []) if m.get("name")]
        except Exception:
            return []

    def _call_model(model: str, prompt: str) -> str:
        if model == "claude":
            result = _call_claude(prompt)
        elif model == "gemini":
            result = _call_gemini(prompt)
        elif model.startswith("ollama:"):
            result = _call_ollama(prompt, model.split(":", 1)[1])
        else:
            return "[unknown model]"
        if not result or not result.strip():
            return f"[No response from {model}. Try rephrasing or using a different model.]"
        return result

    # ── Self-awareness: Brynq answers questions about itself ───────
    def _answer_about_brynq(text: str, cfg, models: list[str]) -> str | None:
        """If the user asks about Brynq's own capabilities, answer directly."""
        t = text.lower().strip()

        # Load marketplace stats lazily
        def _marketplace_stats() -> dict:
            return _get_catalog_and_stats()[1]

        def _installed_agents() -> list:
            return _get_installed_agents(cfg.data_dir)

        def _load_catalog():
            """Return a MarketplaceCatalog instance or None."""
            return _get_catalog_and_stats()[0]

        # Keyword marketplace search (e.g. "search marketplace for pdf", "find tool for csv")
        import re as _re
        _search_m = _re.match(
            r"(?:search\s+(?:marketplace|catalog|tools|agents)"
            r"|find\s+(?:a\s+)?(?:tool|agent|mcp)s?\s+for)"
            r"\s+(.+)",
            t,
        )
        if _search_m:
            keyword = _search_m.group(1).strip()
            cat = _load_catalog()
            if cat is not None:
                results = cat.search(keyword, limit=5)
                if results:
                    lines = [f"Top {len(results)} results for '{keyword}':"]
                    for i, e in enumerate(results, 1):
                        stars = f" ({e.rating:.1f}*)" if e.rating else ""
                        lines.append(f"  {i}. {e.name} — {e.description}{stars}")
                    return "\n".join(lines)
            return f"No marketplace results for '{keyword}'."

        # Category-specific tool/agent search (e.g. "what code tools", "show me data tools")
        _CATEGORY_NAMES = {
            "financial", "legal", "code", "research", "creative",
            "data", "productivity", "education", "other",
        }
        _cat_match = None
        for _cn in _CATEGORY_NAMES:
            if _cn + " tool" in t or _cn + " agent" in t or _cn + " mcp" in t:
                _cat_match = _cn
                break
        if _cat_match is not None:
            try:
                from cloud.marketplace.catalog import MarketplaceCatalog
                cat = None
                for data_path in [
                    Path(__file__).resolve().parent.parent / "cloud" / "data",
                    Path.cwd() / "cloud" / "data",
                    Path("cloud/data"),
                ]:
                    catalog_file = data_path / "marketplace" / "catalog.json"
                    if catalog_file.exists():
                        cat = MarketplaceCatalog(data_path)
                        break
                if cat is not None:
                    results = cat.search("", category=_cat_match, limit=5)
                    if results:
                        lines = [f"Top {len(results)} {_cat_match} tools:"]
                        for i, e in enumerate(results, 1):
                            stars = f" ({e.rating:.1f}*)" if e.rating else ""
                            lines.append(f"  {i}. {e.name} — {e.description}{stars}")
                        return "\n".join(lines)
                return f"No {_cat_match} tools found in the marketplace yet."
            except Exception:
                return f"No {_cat_match} tools found in the marketplace yet."

        # MCP / marketplace / tools questions

        # Check installed agents
        if "installed" in t or "what have i installed" in t:
            agents = _installed_agents()
            if not agents:
                return "You have no agents installed yet. Try /marketplace to find some!"
            lines = ["Installed Agents:"]
            for a in agents:
                name = a.get('name', 'Unknown')
                atype = a.get('type', 'mcp')
                install_date = a.get('install_date', 'Unknown')
                lines.append(f"  - {name} ({atype}) - Installed: {install_date}")
            return "\n".join(lines)

        # Help / Quickstart
        if "help me" in t or "what can i do" in t:
            return "Brynq Quick-Start:\n1. Just type your question to chat.\n2. Use /chain <query> to run an agent chain.\n3. Use /marketplace to discover tools.\n4. Use /install <agent-id> to add a tool.\n5. Use /help to see all commands."

        # Status
        if "status" in t or "system status" in t:
            active = ", ".join(models) if models else "None"
            agent_count = len(_installed_agents())
            return f"Brynq System Status:\n- Active Models: {active}\n- Installed Agents: {agent_count}\n- Cloud Connection: OK (via marketplace catalog)\n- Ollama: OK"

        if any(k in t for k in ["how many mcp", "how many tool", "how many agent",
                                 "marketplace", "what tools", "what agents",
                                 "list mcp", "list tool", "list agent",
                                 "available tool", "available agent"]):
            stats = _marketplace_stats()
            total = stats.get("total_entries", 0)
            cats = stats.get("by_category", {})
            installed = _installed_agents()
            lines = [f"Brynq marketplace: {total:,} tools available."]
            if cats:
                breakdown = ", ".join(f"{c}: {n}" for c, n in sorted(cats.items(), key=lambda x: -x[1]))
                lines.append(f"Categories: {breakdown}")
            if installed:
                lines.append(f"Installed on this machine: {len(installed)} ({', '.join(a.get('name', a.get('agent_id', '?')) for a in installed[:10])})")
            else:
                lines.append("None installed yet. Browse at /marketplace or brynq.ai/marketplace.")
            return "\n".join(lines)

        # Installed agents questions
        if any(k in t for k in ["installed", "what have i installed"]):
            installed = _installed_agents()
            if not installed:
                return "No agents installed yet. Browse at /marketplace or brynq.ai/marketplace."
            lines = [f"Installed agents ({len(installed)}):"]
            for a in installed:
                name = a.get("name", a.get("agent_id", "unknown"))
                atype = a.get("type", "unknown")
                date = a.get("install_date", a.get("installed_at", "unknown"))
                lines.append(f"  - {name} (type: {atype}, installed: {date})")
            return "\n".join(lines)

        # Status / system status
        if any(k in t for k in ["system status", "status"]):
            lines = ["Brynq system status:"]

            # Cloud connection
            try:
                url = f"{cfg.cloud_url}/health"
                req = urllib.request.Request(url, method="GET")
                with urllib.request.urlopen(req, timeout=5.0):
                    lines.append(f"  Cloud:    connected ({cfg.cloud_url})")
            except Exception:
                lines.append(f"  Cloud:    unreachable ({cfg.cloud_url})")

            # Ollama
            ollama_url = getattr(cfg, "ollama_url", "http://localhost:11434")
            if _ollama_is_running(ollama_url):
                ollama_models = _ollama_list_models(ollama_url)
                lines.append(f"  Ollama:   running ({len(ollama_models)} model{'s' if len(ollama_models) != 1 else ''})")
            else:
                lines.append("  Ollama:   stopped")

            # Installed agents
            installed = _installed_agents()
            if installed:
                names = ", ".join(
                    a.get("name", a.get("agent_id", "?")) for a in installed[:10]
                )
                lines.append(f"  Agents:   {len(installed)} installed ({names})")
            else:
                lines.append("  Agents:   none installed")

            # Active models
            lines.append(f"  Models:   {len(models)} active")
            for m in models:
                lines.append(f"    - {m}")

            return "\n".join(lines)

        # Model questions
        if any(k in t for k in ["how many model", "what model", "which model",
                                 "list model", "available model", "supported model"]):
            lines = [f"Active models: {len(models)}"]
            for m in models:
                lines.append(f"  - {m}")
            return "\n".join(lines)

        # Strategy questions
        if any(k in t for k in ["what strateg", "how many strateg", "chain strateg",
                                 "orchestrat", "how do you work", "how does brynq work"]):
            return (
                "Brynq uses 5 orchestration strategies:\n"
                "  1. Sequential — A->B->C with context passing\n"
                "  2. Fan-Out — models run in parallel, results merged\n"
                "  3. Debate — two models argue, a third judges\n"
                "  4. Refine — one drafts, another critiques, first revises\n"
                "  5. Single — one model for simple tasks\n\n"
                "Brynq picks the strategy automatically based on your prompt, "
                "or you can force one with /chain."
            )

        # Quick-start guide
        if any(k in t for k in ["help me", "what can i do", "how do i start",
                                 "getting started", "quick start", "quickstart"]):
            return (
                "Quick-start guide:\n\n"
                "  Chat      — just type a message and press Enter\n"
                "  Chain     — brynq-runtime chain \"summarize this with Claude then Gemini\"\n"
                "             or type /chain in chat to build a multi-model chain\n"
                "  Marketplace — type /marketplace to browse available agents and tools\n"
                "  Install   — brynq-runtime install <agent-name> to add an agent\n"
                "             or use /marketplace install <name> in chat\n"
                "  Models    — brynq-runtime models to see what's available\n"
                "  Status    — brynq-runtime status to check everything\n\n"
                "Tip: Ollama models work offline and free. Add Claude or Gemini\n"
                "via brynq-runtime login for more power."
            )

        # What is Brynq
        if any(k in t for k in ["what is brynq", "what are you", "who are you",
                                 "tell me about brynq", "about brynq", "what can you do"]):
            stats = _marketplace_stats()
            total = stats.get("total_entries", 0)
            return (
                f"Brynq is the operating system for AI workforces.\n\n"
                f"  Models: {len(models)} active ({', '.join(m.split('/')[-1].split(':')[0] for m in models)})\n"
                f"  Marketplace: {total:,} tools (MCP servers, agents, builtins)\n"
                f"  Strategies: Sequential, Fan-Out, Debate, Refine, Single\n"
                f"  Privacy: your data stays on this machine\n"
                f"  BYOK: uses your existing AI subscriptions\n\n"
                f"Type /help for commands, or just ask a question."
            )

        # Version
        if any(k in t for k in ["version", "what version"]):
            try:
                from runtime.version import __version__
                return f"Brynq v{__version__}"
            except Exception:
                return "Brynq v0.1.0"

        return None

    # ── Learning Subsystem Integration ─────────────────────────────
    import uuid
    from runtime.learning.usage_tracker import UsageTracker
    from runtime.learning.preferences import Preferences
    from runtime.learning.session_analytics import SessionAnalytics
    
    _tracker = UsageTracker(config.data_dir)
    _prefs = Preferences(config.data_dir)
    _session_analytics = SessionAnalytics(_tracker)
    _current_session_id = _session_analytics.current_session_id()
    
    def _track_interaction(model: str, strategy: str, prompt: str, result: str, elapsed: float, success: bool):
        # Estimate tokens roughly
        p_tokens = len(prompt) // 4
        r_tokens = len(result) // 4
        _tracker.log(
            model=model, 
            strategy=strategy, 
            prompt_tokens=p_tokens, 
            response_tokens=r_tokens, 
            latency_ms=int(elapsed * 1000), 
            status="success" if success else "error",
            session_id=_current_session_id
        )

    # ── Strategy executors ─────────────────────────────────────────
    def _run_single(user_input: str, models: list[str]) -> None:
        model = models[0]
        result, elapsed = _call_model_with_spinner(model, user_input)
        _last_timer.clear()
        _last_timer.append((model, elapsed))
        chat_history.append(("assistant", _model_label(model), result, f"{elapsed:.1f}s"))

    def _run_debate(user_input: str, models: list[str]) -> None:
        model_a = models[0]
        model_b = models[1] if len(models) > 1 else models[0]
        judge = models[2] if len(models) > 2 else models[0]
        _last_timer.clear()

        # Pro
        pro, elapsed = _call_model_with_spinner(model_a, f"Present a STRONG CASE IN FAVOR. Best arguments and reasoning.\n\nTopic: {user_input}")
        _last_timer.append((model_a, elapsed))
        chat_history.append(("assistant", f"{_GREEN_C}PRO{_RESET} {_model_label(model_a)}" if _COLOR else f"PRO {_model_label(model_a)}", pro, f"{elapsed:.1f}s"))
        _redraw()

        # Con
        con, elapsed = _call_model_with_spinner(model_b, f"Present a STRONG CASE AGAINST. Weaknesses and counterarguments.\n\nTopic: {user_input}")
        _last_timer.append((model_b, elapsed))
        chat_history.append(("assistant", f"{_RED_C}CON{_RESET} {_model_label(model_b)}" if _COLOR else f"CON {_model_label(model_b)}", con, f"{elapsed:.1f}s"))
        _redraw()

        # Judge
        verdict, elapsed = _call_model_with_spinner(judge,
            f"Two arguments on: {user_input}\n\n=== PRO ===\n{pro}\n\n=== CON ===\n{con}\n\n"
            f"Evaluate fairly. Strongest points from each. Clear verdict."
        )
        _last_timer.append((judge, elapsed))
        chat_history.append(("assistant", f"VERDICT {_model_label(judge)}", verdict, f"{elapsed:.1f}s"))

    def _run_refine(user_input: str, models: list[str]) -> None:
        drafter = models[0]
        critic = models[1] if len(models) > 1 else models[0]
        reviser = models[2] if len(models) > 2 else models[0]
        _last_timer.clear()

        # Draft
        draft, elapsed = _call_model_with_spinner(drafter, f"Produce a solid first draft.\n\n{user_input}")
        _last_timer.append((drafter, elapsed))
        chat_history.append(("assistant", f"DRAFT {_model_label(drafter)}", draft, f"{elapsed:.1f}s"))
        _redraw()

        # Critique
        critique, elapsed = _call_model_with_spinner(critic,
            f"Review critically. Strengths, weaknesses, specific improvements.\n\nRequest: {user_input}\n\nDraft:\n{draft}"
        )
        _last_timer.append((critic, elapsed))
        chat_history.append(("assistant", f"CRITIQUE {_model_label(critic)}", critique, f"{elapsed:.1f}s"))
        _redraw()

        # Revise
        revision, elapsed = _call_model_with_spinner(reviser,
            f"Revise incorporating the critique. Produce an improved version.\n\nRequest: {user_input}\n\nDraft:\n{draft}\n\nCritique:\n{critique}"
        )
        _last_timer.append((reviser, elapsed))
        chat_history.append(("assistant", f"FINAL {_model_label(reviser)}", revision, f"{elapsed:.1f}s"))

    def _run_fan_out(user_input: str, models: list[str]) -> None:
        results_map: dict[str, str] = {}
        timings_map: dict[str, float] = {}
        lock = threading.Lock()
        _last_timer.clear()

        model_names = ", ".join(m.split("/")[-1].split(":")[0] for m in models)
        print(f"  {', '.join(_model_label(m) for m in models)} are typing...")
        spinner = _Spinner(f"Fan-out to {len(models)} models ({model_names}) ")
        spinner.start()

        def _threaded(m: str) -> None:
            s = time.time()
            out = _call_model(m, user_input)
            with lock:
                results_map[m] = out
                timings_map[m] = time.time() - s

        threads = [threading.Thread(target=_threaded, args=(m,), daemon=True) for m in models]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        spinner.stop()

        for m in models:
            _last_timer.append((m, timings_map.get(m, 0)))
            chat_history.append(("assistant", _model_label(m), results_map.get(m, ""), f"{timings_map.get(m,0):.1f}s"))

        # Synthesize if 2+ models
        if len(models) >= 2:
            _redraw()
            synth_model = "claude" if "claude" in active_models else models[0]
            synthesis, elapsed = _call_model_with_spinner(synth_model,
                f"Multiple perspectives on: {user_input}\n\n"
                + "\n\n".join(f"=== {m} ===\n{results_map.get(m,'')}" for m in models)
                + "\n\nSynthesize into one coherent response. Best insights from each."
            )
            _last_timer.append((synth_model, elapsed))
            chat_history.append(("assistant", f"SYNTHESIS {_model_label(synth_model)}", synthesis, f"{elapsed:.1f}s"))

    def _run_swarm(user_input: str, models: list[str]) -> None:
        """Run up to 20 agents in parallel, each tackling a piece of the task."""
        # Break task into subtasks for each agent
        num_agents = len(models)
        results_map: dict[int, str] = {}
        timings_map: dict[int, float] = {}
        lock = threading.Lock()
        _last_timer.clear()

        agent_names = [f"{models[i % len(set(models))].split('/')[-1].split(':')[0]}-{i+1}" for i in range(num_agents)]
        unique_models = list(dict.fromkeys(models))
        print(f"  Swarm: {', '.join(_model_label(m) for m in unique_models)} ({num_agents} agents) are typing...")
        spinner = _Spinner(f"Swarm: {num_agents} agents working ")
        spinner.start()

        def _agent_work(idx: int, model: str) -> None:
            role = ["architect", "implementer", "tester", "reviewer", "optimizer",
                    "documenter", "security", "refactorer", "debugger", "integrator",
                    "validator", "planner", "analyzer", "builder", "checker",
                    "designer", "evaluator", "formatter", "generator", "inspector"][idx % 20]
            prompt = (
                f"You are agent #{idx+1} ({role}) in a swarm of {num_agents} agents.\n"
                f"Your specific role: {role}. Focus ONLY on the {role} aspect.\n"
                f"Be concise — other agents handle other aspects.\n\n"
                f"Task: {user_input}"
            )
            s = time.time()
            out = _call_model(model, prompt)
            with lock:
                results_map[idx] = out
                timings_map[idx] = time.time() - s

        threads = [
            threading.Thread(target=_agent_work, args=(i, models[i]), daemon=True)
            for i in range(num_agents)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        spinner.stop()

        # Show each agent's contribution
        for i in range(num_agents):
            role = ["architect", "implementer", "tester", "reviewer", "optimizer",
                    "documenter", "security", "refactorer", "debugger", "integrator",
                    "validator", "planner", "analyzer", "builder", "checker",
                    "designer", "evaluator", "formatter", "generator", "inspector"][i % 20]
            label = f"AGENT-{i+1} ({role}) {_model_label(models[i])}"
            _last_timer.append((models[i], timings_map.get(i, 0)))
            chat_history.append(("assistant", label, results_map.get(i, ""), f"{timings_map.get(i,0):.1f}s"))

        # Synthesize all contributions
        _redraw()
        synth_model = "claude" if "claude" in active_models else models[0]
        all_parts = "\n\n".join(
            f"=== Agent {i+1} ({['architect','implementer','tester','reviewer','optimizer','documenter','security','refactorer','debugger','integrator','validator','planner','analyzer','builder','checker','designer','evaluator','formatter','generator','inspector'][i%20]}) ===\n{results_map.get(i,'')}"
            for i in range(num_agents)
        )
        synthesis, elapsed = _call_model_with_spinner(synth_model,
            f"You are the lead synthesizer. {num_agents} agents worked on this task in parallel.\n"
            f"Combine their work into ONE coherent, complete solution.\n\n"
            f"Original task: {user_input}\n\n{all_parts}\n\n"
            f"Produce the final unified output. Remove duplicates, resolve conflicts, keep the best from each."
        )
        _last_timer.append((synth_model, elapsed))
        chat_history.append(("assistant", f"SYNTHESIS {_model_label(synth_model)}", synthesis, f"{elapsed:.1f}s"))

    def _auto_save_code(history: list) -> None:
        """Auto-save code blocks from model output to files. No permissions needed."""
        import re as _re
        output_dir = Path("brynq_output")
        saved_files = []

        # Collect all output from this round
        all_output = "\n\n".join(entry[2] for entry in history if entry[0] == "assistant")

        # Find code blocks with filenames (```python filename.py or ```filename.py)
        # Pattern: ```lang\n...code...\n```
        code_blocks = _re.findall(r"```(\w*)\n(.*?)```", all_output, _re.DOTALL)

        if not code_blocks:
            return

        # Save the full synthesis/output as a markdown file
        output_dir.mkdir(exist_ok=True)
        timestamp = int(time.time())

        # Save each code block
        for i, (lang, code) in enumerate(code_blocks):
            if len(code.strip()) < 20:
                continue  # Skip tiny snippets

            ext = {"python": ".py", "py": ".py", "javascript": ".js", "js": ".js",
                   "typescript": ".ts", "ts": ".ts", "go": ".go", "rust": ".rs",
                   "bash": ".sh", "sh": ".sh", "sql": ".sql", "json": ".json",
                   "yaml": ".yaml", "yml": ".yaml", "html": ".html", "css": ".css",
                   }.get(lang.lower(), ".txt")

            filename = f"brynq_{timestamp}_{i+1}{ext}"
            filepath = output_dir / filename
            filepath.write_text(code.strip(), encoding="utf-8")
            saved_files.append(str(filepath))

        # Also save the full conversation as markdown
        md_path = output_dir / f"brynq_{timestamp}_full.md"
        md_content = []
        for entry in history:
            if entry[0] == "assistant":
                md_content.append(f"## {entry[1]}\n\n{entry[2]}\n")
        md_path.write_text("\n---\n\n".join(md_content), encoding="utf-8")
        saved_files.append(str(md_path))

        if saved_files:
            print(f"\n  {_dim(f'Auto-saved {len(saved_files)} files to {output_dir}/')}")
            for f in saved_files[:5]:
                print(f"    {_dim(f)}")
            if len(saved_files) > 5:
                print(f"    {_dim(f'...and {len(saved_files) - 5} more')}")
            print()

    # ── Terminal drawing ───────────────────────────────────────────
    term = shutil.get_terminal_size((80, 24))
    cols = term.columns

    def _bar(char: str = "─") -> str:
        return f"{_DIM}{char * cols}{_RESET}" if _COLOR else char * cols

    def _draw_header() -> None:
        sys.stdout.write("\033[2J\033[H")
        title = " BRYNQ CHAT "
        pad = max(0, (cols - len(title)) // 2)
        right = max(0, cols - pad - len(title))
        if _COLOR:
            print(f"{_BG_HEADER}{'─' * pad}{title}{'─' * right}{_RESET}")
        else:
            print(f"{'─' * pad}{title}{'─' * right}")
        model_tags = "  ".join(_model_label(m) for m in active_models)
        print(f"  {_DIM}Models:{_RESET} {model_tags}")
        print(f"  {_DIM}Orchestrated: action→Claude | compare→debate | creative→refine | research→fan-out{_RESET}")
        # Show cloud skill count or offline status
        if _cloud_status["online"] is True and _cloud_status["skill_count"]:
            count = f"{_cloud_status['skill_count']:,}"
            if _COLOR:
                print(f"  {_GREEN_C}{count} skills loaded{_RESET} {_DIM}| Connected to {config.cloud_url.replace('https://', '')}{_RESET}")
            else:
                print(f"  {count} skills loaded | Connected to {config.cloud_url.replace('https://', '')}")
        elif _cloud_status["online"] is False:
            if _COLOR:
                print(f"  {_YELLOW}Offline mode{_RESET} {_DIM}| Local classification only{_RESET}")
            else:
                print(f"  Offline mode | Local classification only")
        # else: still loading, don't show anything yet
        if _missing_hint:
            installs = {
                "Claude Code": "npm i -g @anthropic-ai/claude-code",
                "Gemini CLI": "npm i -g @google/gemini-cli",
                "Ollama": "https://ollama.com/download",
            }
            tips = "  ".join(f"{n}: {installs[n]}" for n in _missing_hint if n in installs)
            print(f"  {_YELLOW}Add more models:{_RESET} {_DIM}{tips}{_RESET}" if _COLOR
                  else f"  Add more models: {tips}")
        print(_bar())

    def _draw_input_panel() -> str:
        """Read user input. Plain input() with no ANSI for Windows paste reliability."""
        print()
        print("─" * cols)
        try:
            user_input = input(" brynq> ")
        except (EOFError, KeyboardInterrupt):
            return ""
        print("─" * cols)
        return user_input.strip()

    def _print_message(sender: str, content: str, meta: str = "") -> None:
        print()
        header = f"  {sender}"
        if meta:
            header += f"  {_DIM}{meta}{_RESET}" if _COLOR else f"  {meta}"
        print(header)
        print(f"  {_bar('╌')}")
        for line in content.splitlines():
            print(f"  {line}")

    def _print_status(msg: str) -> None:
        print(f"\n  {_DIM}{msg}{_RESET}" if _COLOR else f"\n  {msg}")

    class _Spinner:
        """Live spinner that shows the user something is happening."""
        _FRAMES = [".", "..", "...", "....", "...."]

        def __init__(self, label: str):
            self.label = label
            self._stop = threading.Event()
            self._thread: threading.Thread | None = None

        def start(self):
            self._stop.clear()
            self._thread = threading.Thread(target=self._spin, daemon=True)
            self._thread.start()

        def _spin(self):
            i = 0
            while not self._stop.is_set():
                frame = self._FRAMES[i % len(self._FRAMES)]
                status = f"\r  {_DIM}{self.label}{frame}{_RESET}" if _COLOR else f"\r  {self.label}{frame}"
                sys.stdout.write(status)
                sys.stdout.flush()
                i += 1
                self._stop.wait(0.5)
            # Clear the spinner line
            sys.stdout.write(f"\r{' ' * (len(self.label) + 20)}\r")
            sys.stdout.flush()

        def stop(self):
            self._stop.set()
            if self._thread:
                self._thread.join(timeout=2)

    def _call_model_with_spinner(model: str, prompt: str) -> tuple[str, float]:
        """Call a model with a live spinner. Returns (result, elapsed_seconds)."""
        print(f"  {_model_label(model)} is typing...")
        label = f"Thinking: {model.split('/')[-1]} "
        spinner = _Spinner(label)
        spinner.start()
        start = time.time()
        try:
            result = _call_model(model, prompt)
        finally:
            spinner.stop()
        return result, time.time() - start

    chat_history: list[tuple[str, str, str, str]] = []
    _last_timer: list[tuple[str, float]] = []  # [(model, seconds), ...] from last exchange

    def _redraw() -> None:
        _draw_header()
        for role, sender, content, meta in chat_history:
            _print_message(sender, content, meta)
            if verbose and role == "assistant" and content:
                words = len(content.split())
                tokens = round(words * 1.3)
                print(f"  {_dim(f'{words} words | ~{tokens} tokens')}")

    _draw_header()

    # ── Compact startup banner ─────────────────────────────────────
    _n_models = len(active_models)
    _ollama_tag = f"{_GREEN_C}online{_RESET}" if ollama_ok else f"{_RED_C}offline{_RESET}"
    _ollama_tag_plain = "online" if ollama_ok else "offline"
    if _COLOR:
        print(f"  Brynq v0.3.2 | {_n_models} models active | Ollama: {_ollama_tag}")
    else:
        print(f"  Brynq v0.1.0 | {_n_models} models active | Ollama: {_ollama_tag_plain}")
    print(f"  Type /help for commands, /quit to exit")

    # ── Main loop ──────────────────────────────────────────────────
    while True:
        user_input = _draw_input_panel()
        if not user_input:
            continue

        # Handle commands
        if user_input.startswith("/"):
            parts = user_input.split(maxsplit=1)
            cmd = parts[0].lower()
            cmd_arg = parts[1].strip() if len(parts) > 1 else ""

            if cmd in ("/quit", "/exit", "/q"):
                sys.stdout.write("\033[2J\033[H")
                print(_dim("Goodbye from Brynq."))
                return 0
            elif cmd == "/add":
                if not cmd_arg:
                    print("  Usage: /add <model>")
                elif cmd_arg in active_models:
                    print(f"  {cmd_arg} is already active")
                elif cmd_arg not in all_detected:
                    print(f"  {cmd_arg} not found. Available: {', '.join(all_detected)}")
                else:
                    active_models.append(cmd_arg)
                    _redraw()
                continue
            elif cmd == "/remove":
                if cmd_arg in active_models and len(active_models) > 1:
                    active_models.remove(cmd_arg)
                _redraw()
                continue
            elif cmd == "/models":
                _redraw()
                print()
                for m in active_models:
                    code_tag = f" {_GREEN_C}[code]{_RESET}" if _COLOR and m in _CODE_MODELS else ""
                    print(f"  {_model_label(m)}{code_tag}")
                continue
            elif cmd == "/tools":
                installed = _get_installed_agents(config.data_dir)
                if not installed:
                    print("  No agents or tools installed yet. Browse at /marketplace.")
                else:
                    print()
                    print(f"  {_bold('Installed Tools & Agents')}")
                    for a in installed:
                        name = a.get("name", a.get("agent_id", "unknown"))
                        tools = a.get("tools", [])
                        print(f"  - {name}")
                        if tools:
                            for t in tools:
                                t_name = t.get("name", "unknown")
                                t_desc = t.get("description", "")
                                print(f"      {_dim(t_name)}: {_dim(t_desc)}")
                print()
                continue
            elif cmd == "/run":
                if not cmd_arg:
                    print("  Usage: /run <agent_name> <prompt>")
                    continue
                parts = cmd_arg.split(" ", 1)
                agent_name = parts[0]
                prompt = parts[1] if len(parts) > 1 else "Hello"
                
                print(f"  Running {agent_name}...")
                src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
                if src_path not in sys.path:
                    sys.path.insert(0, src_path)
                
                from runtime.executor.agent_bridge import AgentBridge
                bridge = AgentBridge()
                res = bridge.call_installed_agent(agent_name, prompt)
                
                if res.success:
                    print(f"\n  {_cyan(agent_name)}:\n  {res.output}\n")
                else:
                    print(f"\n  {_red('Error')}: {res.error}\n")
                continue
            elif cmd == "/status":
                print()
                print(f"  {_bold('Connected Models')}")
                print()
                for m in active_models:
                    if m == "claude":
                        ok = shutil.which("claude") is not None
                    elif m == "gemini":
                        ok = shutil.which("gemini") is not None
                    elif m.startswith("ollama:"):
                        ok = _ollama_is_running(config.ollama_url)
                    else:
                        ok = True
                    dot = _green("●") if ok else _red("●")
                    state = _green("online") if ok else _red("offline")
                    code_tag = f" {_GREEN_C}[code]{_RESET}" if _COLOR and m in _CODE_MODELS else ""
                    print(f"  {dot} {_model_label(m)}{code_tag}  {state}")
                print()
                # Ollama server
                ollama_up = _ollama_is_running(config.ollama_url)
                ollama_dot = _green("●") if ollama_up else _red("●")
                ollama_state = _green("running") if ollama_up else _red("not running")
                print(f"  {_bold('Services')}")
                print(f"  {ollama_dot} Ollama          {ollama_state}  ({config.ollama_url})")
                # Cloud brain
                cloud_up = _cloud_status.get("online")
                if cloud_up is True:
                    cloud_dot = _green("●")
                    cloud_state = _green("connected")
                elif cloud_up is False:
                    cloud_dot = _red("●")
                    cloud_state = _red("disconnected")
                else:
                    cloud_dot = _yellow("●")
                    cloud_state = _yellow("checking...")
                print(f"  {cloud_dot} Brynq Cloud     {cloud_state}")
                skill_count = _cloud_status.get("skill_count")
                if skill_count:
                    print(f"    Skills available: {skill_count:,}")
                print()
                continue
            elif cmd == "/clear":
                chat_history.clear()
                _redraw()
                continue
            elif cmd == "/history":
                print()
                recent = chat_history[-10:]
                if not recent:
                    print(f"  {_dim('No messages yet.')}" if _COLOR else "  No messages yet.")
                else:
                    for role, sender, content, meta in recent:
                        tag = _green(sender) if role == "user" else _cyan(sender) if _COLOR else sender
                        preview = content.replace("\n", " ")[:120]
                        print(f"  [{meta}] {tag}: {preview}")
                print()
                continue
            elif cmd == "/rag":
                from runtime.rag import add_file, list_files, remove_file
                if cmd_arg.startswith("add "):
                    filepath = cmd_arg[4:].strip().strip('"').strip("'")
                    try:
                        result = add_file(config.data_dir, filepath)
                        print(f"  Indexed: {result.get('chunks', 0)} chunks from {filepath}")
                    except Exception as e:
                        print(f"  {_red(f'Error: {e}')}")
                elif cmd_arg == "list":
                    files = list_files(config.data_dir)
                    if not files:
                        print("  No files indexed. Use /rag add <filepath>")
                    for f in files:
                        print(f"  {f['filepath']}  ({f.get('chunks', 0)} chunks)")
                elif cmd_arg.startswith("remove "):
                    filepath = cmd_arg[7:].strip().strip('"').strip("'")
                    if remove_file(config.data_dir, filepath):
                        print(f"  Removed: {filepath}")
                    else:
                        print(f"  Not found: {filepath}")
                elif cmd_arg == "off":
                    _rag_enabled = False
                    print("  RAG disabled for this session.")
                elif cmd_arg == "on":
                    _rag_enabled = True
                    print("  RAG enabled.")
                else:
                    print("  /rag add <filepath>  — index a file")
                    print("  /rag list            — show indexed files")
                    print("  /rag remove <file>   — remove from index")
                    print("  /rag on/off          — toggle RAG context")
                print()
                continue
            elif cmd == "/export":
                if not chat_history:
                    print("  Nothing to export — chat history is empty.")
                else:
                    from datetime import datetime as _dt
                    exports_dir = Path("exports")
                    exports_dir.mkdir(exist_ok=True)
                    stamp = _dt.now().strftime("%Y%m%d_%H%M%S")
                    out_path = exports_dir / f"chat_{stamp}.json"
                    entries = [
                        {"role": role, "sender": sender, "content": content, "meta": meta}
                        for role, sender, content, meta in chat_history
                    ]
                    out_path.write_text(json.dumps(entries, indent=2, ensure_ascii=False), encoding="utf-8")
                    print(f"  Exported {len(entries)} messages to {out_path}")
                print()
                continue
            elif cmd == "/config":
                print()
                print(f"  Cloud URL: {config.cloud_url}")
                print(f"  Ollama URL: {config.ollama_url}")
                print(f"  Runtime port: {config.local_port}")
                print(f"  Active models: {len(active_models)}")
                print(f"  Verbose mode: {'on' if verbose else 'off'}")
                print()
                continue
            elif cmd == "/timer":
                if not _last_timer:
                    print("  No timing data yet. Send a message first.")
                else:
                    print()
                    for model, seconds in _last_timer:
                        print(f"  {model}: {seconds:.1f}s")
                    print()
                continue
            elif cmd == "/usage":
                if not chat_history:
                    print("  No messages yet.")
                else:
                    role_tokens: dict[str, int] = {}
                    for role, _sender, content, _meta in chat_history:
                        est = len(content) // 4
                        role_tokens[role] = role_tokens.get(role, 0) + est
                    total = sum(role_tokens.values())
                    print()
                    print(f"  Estimated token usage: ~{total:,}")
                    for role, tokens in sorted(role_tokens.items()):
                        print(f"    {role}: ~{tokens:,}")
                    print()
                continue
            elif cmd == "/marketplace":
                stats = _marketplace_stats()
                total = stats.get("total_entries", 0)
                cats = stats.get("by_category", {})
                print()
                print(f"  {_bold('Brynq Marketplace')}: {total:,} tools")
                print()
                if cats:
                    for cat_name, count in sorted(cats.items(), key=lambda x: -x[1]):
                        bar = "#" * min(count // 20, 40)
                        print(f"    {cat_name:<14} {count:>5}  {_dim(bar)}" if _COLOR else f"    {cat_name:<14} {count:>5}  {bar}")
                print()
                print(f"  {_dim('Browse: brynq.ai/marketplace')}" if _COLOR else "  Browse: brynq.ai/marketplace")
                print(f"  {_dim('Install: /install <agent-id>')}" if _COLOR else "  Install: /install <agent-id>")
                print()
                continue
            elif cmd == "/install":
                if not cmd_arg:
                    print("  Usage: /install <agent-id>")
                    print()
                    continue
                agent_id = cmd_arg.strip()
                cat = _load_catalog()
                if cat is None:
                    print(f"  {_red('Marketplace catalog not available.')}" if _COLOR else "  Marketplace catalog not available.")
                    print()
                    continue
                entry = cat.get_entry(agent_id)
                if entry is None:
                    print(f"  {_red('Agent not found:')}" f" {agent_id}" if _COLOR else f"  Agent not found: {agent_id}")
                    print()
                    continue
                # Save manifest to ~/.brynq/agents/{id}/manifest.json
                agent_dir = config.data_dir / "agents" / agent_id
                agent_dir.mkdir(parents=True, exist_ok=True)
                manifest = {
                    "agent_id": entry.id,
                    "name": entry.name,
                    "description": entry.description,
                    "category": entry.category,
                    "install_type": entry.install_type,
                    "source": entry.source,
                    "pricing": entry.pricing,
                    "installed_at": time.time(),
                    "status": "installed",
                }
                if entry.mcp_command:
                    manifest["mcp_command"] = entry.mcp_command
                if entry.mcp_url:
                    manifest["mcp_url"] = entry.mcp_url
                if entry.endpoint:
                    manifest["endpoint"] = entry.endpoint
                if entry.package_url:
                    manifest["package_url"] = entry.package_url
                (agent_dir / "manifest.json").write_text(
                    json.dumps(manifest, indent=2), encoding="utf-8",
                )
                print()
                print(f"  {_green('Installed:')} {entry.name} ({agent_id})" if _COLOR else f"  Installed: {entry.name} ({agent_id})")
                print(f"  {_dim(entry.description)}" if _COLOR else f"  {entry.description}")
                print(f"  Manifest saved to {agent_dir / 'manifest.json'}")
                print()
                continue
            elif cmd == "/uninstall":
                if not cmd_arg:
                    print("  Usage: /uninstall <agent-id>")
                    print()
                    continue
                agent_id = cmd_arg.strip()
                agent_dir = config.data_dir / "agents" / agent_id
                if not agent_dir.exists():
                    print(f"  {_red('Agent not installed:')}" f" {agent_id}" if _COLOR else f"  Agent not installed: {agent_id}")
                    print()
                    continue
                import shutil
                shutil.rmtree(agent_dir)
                print()
                print(f"  {_green('Uninstalled:')} {agent_id}" if _COLOR else f"  Uninstalled: {agent_id}")
                print(f"  Removed {agent_dir}")
                print()
                continue
            elif cmd == "/help":
                print()
                print(f"  {_dim('Available commands:')}" if _COLOR else "  Available commands:")
                print("  /models          — list active models")
                print("  /status          — show models and service status")
                print("  /add <model>     — add a model to the team")
                print("  /remove <model>  — remove a model")
                print("  /rag add <file>  — index a file for context")
                print("  /rag list        — show indexed files")
                print("  /chain <prompt>  — run a multi-model chain")
                print("  /swarm <task>    — spawn persistent agent team")
                print("  /timer           — show timing for last exchange")
                print("  /usage           — estimate token usage for session")
                print("  /marketplace     — browse available tools and agents")
                print("  /install <id>    — install an agent from the marketplace")
                print("  /uninstall <id>  — uninstall an installed agent")
                print("  /export          — save chat history to JSON")
                print("  /config          — show runtime configuration")
                print("  /history         — show last 10 messages")
                print("  /clear           — clear chat history")
                print("  /quit            — exit")
                print()
                continue
            elif cmd == "/chain":
                if cmd_arg:
                    _run_chain_inline(config, cmd_arg)
                else:
                    print("  Usage: /chain <prompt>")
                continue
            elif cmd == "/swarm":
                if not cmd_arg:
                    print("  Usage: /swarm <task description>")
                    print("  Example: /swarm Review all Python files for bugs")
                else:
                    try:
                        from runtime.spawner import spawn_swarm
                        ollama_models = [m for m in active_models if m.startswith("ollama:")]
                        if not ollama_models:
                            print("  No Ollama models available for swarm. Install one: ollama pull llama3")
                        else:
                            model_name = ollama_models[0].split(":", 1)[1]
                            agents_cfg = [
                                {"name": f"swarm-{i+1}", "backend": "ollama", "model": model_name}
                                for i in range(min(3, len(ollama_models)))
                            ]
                            result = spawn_swarm(
                                swarm_name=f"chat-swarm-{int(time.time())}",
                                task=cmd_arg,
                                agents=agents_cfg,
                            )
                            spawned = [a["name"] for a in result.get("agents", []) if a.get("status") == "running"]
                            print(f"  Swarm launched: {len(spawned)} agents on #{result.get('channel', '?')}")
                            print(f"  Task: {cmd_arg}")
                            print(f"  Agents: {', '.join(spawned)}")
                    except Exception as e:
                        print(f"  Swarm error: {e}")
                continue
            else:
                _redraw()
                print(f"\n  {_dim('Unknown command. Type /help for available commands.')}")
                continue

        # ── Detect CLI commands typed in chat ─────────────────────
        if user_input.strip().startswith("brynq-runtime ") or user_input.strip().startswith("brynq "):
            print()
            print(f"  {_dim('That looks like a terminal command. Run it in PowerShell/Terminal, not inside chat.')}")
            print(f"  {_dim('Or use slash commands: /chain, /help')}")
            print()
            continue

        # ── Self-awareness: answer questions about Brynq itself ─────
        you_label = f"{_WHITE_BOLD}You{_RESET}" if _COLOR else "You"
        _self_answer = _answer_about_brynq(user_input, config, active_models)
        if _self_answer:
            brynq_label = f"{_GREEN_C}Brynq{_RESET}" if _COLOR else "Brynq"
            chat_history.append(("user", you_label, user_input, "[self]"))
            chat_history.append(("assistant", brynq_label, _self_answer, "instant"))
            conversation.append({"role": "user", "content": user_input})
            conversation.append({"role": "assistant", "content": _self_answer})
            _redraw()
            continue

        # ── Auto-install: if prompt mentions a tool we don't have, install it ──
        def _auto_resolve_tool(prompt_text: str) -> tuple[str | None, dict | None]:
            """Find, auto-install, and optionally call a marketplace tool matching the prompt.
            Returns (status_message, agent_manifest) or (None, None) if no match."""
            try:
                from cloud.marketplace.catalog import MarketplaceCatalog
                for data_path in [
                    Path(__file__).resolve().parent.parent / "cloud" / "data",
                    Path.cwd() / "cloud" / "data",
                ]:
                    if (data_path / "marketplace" / "catalog.json").exists():
                        cat = MarketplaceCatalog(data_path)
                        break
                else:
                    return None, None

                # Get installed agents
                agents_dir = config.data_dir / "agents"
                installed = {}  # id -> manifest
                if agents_dir.exists():
                    for mf in agents_dir.glob("*/manifest.json"):
                        try:
                            m = json.loads(mf.read_text(encoding="utf-8"))
                            installed[m.get("agent_id", "")] = m
                        except Exception:
                            pass

                # Search catalog for matching tools
                t = prompt_text.lower()
                hits = cat.search(prompt_text, limit=5)
                for hit in hits:
                    # Check relevance: tool name words appear in prompt
                    name_words = hit.name.lower().replace("-", " ").replace("_", " ").split()
                    if not any(w in t for w in name_words if len(w) > 2):
                        continue

                    # Already installed? Return manifest directly
                    if hit.id in installed:
                        return f"Using installed tool: {hit.name}", installed[hit.id]

                    # Auto-install
                    agent_dir = config.data_dir / "agents" / hit.id
                    agent_dir.mkdir(parents=True, exist_ok=True)
                    manifest = {
                        "agent_id": hit.id,
                        "name": hit.name,
                        "description": hit.description,
                        "install_type": hit.install_type,
                        "mcp_command": hit.mcp_command,
                        "endpoint": hit.endpoint,
                        "category": hit.category,
                        "auto_installed": True,
                    }
                    (agent_dir / "manifest.json").write_text(
                        json.dumps(manifest, indent=2), encoding="utf-8"
                    )
                    return f"Auto-installed: {hit.name}", manifest
            except Exception:
                pass
            return None, None

        tool_msg, tool_manifest = _auto_resolve_tool(user_input)
        if tool_msg:
            print(f"  {_dim(f'[{tool_msg}]')}" if _COLOR else f"  [{tool_msg}]")
            # If we have an MCP tool, try to call it directly
            if tool_manifest and tool_manifest.get("install_type") == "mcp_stdio" and tool_manifest.get("mcp_command"):
                try:
                    from runtime.mcp_adapter import MCPServerConnection, MCPAgentAdapter
                    with MCPServerConnection(command=tool_manifest["mcp_command"]) as conn:
                        conn.connect()
                        adapter = MCPAgentAdapter(conn, tool_manifest["agent_id"], tool_manifest["name"])
                        result = adapter.execute(user_input, {}, {})
                        if result.get("output"):
                            brynq_label = f"{_GREEN_C}{tool_manifest['name']}{_RESET}" if _COLOR else tool_manifest["name"]
                            chat_history.append(("user", you_label, user_input, f"[tool: {tool_manifest['name']}]"))
                            chat_history.append(("assistant", brynq_label, result["output"], f"{result.get('elapsed_ms', 0):.0f}ms"))
                            conversation.append({"role": "user", "content": user_input})
                            conversation.append({"role": "assistant", "content": result["output"]})
                            _redraw()
                            continue
                except Exception as e:
                    # Tool call failed — fall through to normal model routing
                    print(f"  {_dim(f'[Tool call failed: {e}. Routing to AI models instead.]')}" if _COLOR else f"  [Tool call failed, using models]")

        # ── Classify and orchestrate ───────────────────────────────
        task_type, strategy, reason = _classify(user_input)

        # Intercept if prompt matches an installed tool name
        installed = _get_installed_agents(config.data_dir)
        matched_tool_agent = None
        matched_tool_name = None
        for agent in installed:
            for t in agent.get("tools", []):
                if t.get("name") and t.get("name").lower() in user_input.lower():
                    matched_tool_agent = agent.get("agent_id") or agent.get("name")
                    matched_tool_name = t.get("name")
                    break
            if matched_tool_agent:
                break
        
        if matched_tool_agent:
            print(f"  {_dim(f'[Using installed tool: {matched_tool_name} from {matched_tool_agent}]')}")
            # We could route directly to the agent here, but for MVP we will just let the orchestrator 
            # proceed with the hint displayed. In a full implementation, we'd wrap this execution.
            
        # Add to conversation memory
        conversation.append({"role": "user", "content": user_input})

        you_label = f"{_WHITE_BOLD}You{_RESET}" if _COLOR else "You"

        # Try direct action first (create folder, open URL, etc.)
        if task_type == "action":
            direct_result = _try_direct_action(user_input)
            if direct_result:
                brynq_label = f"{_GREEN_C}Brynq{_RESET}" if _COLOR else "Brynq"
                chat_history.append(("user", you_label, user_input, "[action → direct]"))
                chat_history.append(("assistant", brynq_label, direct_result, "instant"))
                conversation.append({"role": "assistant", "content": direct_result})
                _redraw()
                continue

        # RAG context injection
        rag_prefix = ""
        if _rag_enabled:
            try:
                from runtime.rag import query, build_context
                chunks = query(config.data_dir, user_input, top_k=3)
                if chunks:
                    rag_prefix = build_context(chunks) + "\n\n"
            except Exception:
                pass

        # File auto-reading: detect file paths in user input and inline contents
        file_prefix = ""
        referenced_files = _scan_file_paths(user_input)
        if referenced_files:
            file_prefix = _prepend_file_contents("", referenced_files).strip() + "\n\n"
            names = ", ".join(fp.name for fp in referenced_files)
            print(f"  {_DIM}[Auto-read: {names}]{_RESET}" if _COLOR else f"  [Auto-read: {names}]")

        # Route to models
        models_for_task = _pick_models(task_type, strategy)
        strategy_label = f"[{task_type} → {strategy}]"
        if rag_prefix:
            strategy_label += " +RAG"
        if referenced_files:
            strategy_label += f" +{len(referenced_files)} file{'s' if len(referenced_files) != 1 else ''}"
        chat_history.append(("user", you_label, user_input, strategy_label))
        _redraw()
        _print_status(f"Strategy: {strategy} | Task: {task_type} | Models: {', '.join(models_for_task)}")
        if verbose:
            _print_status(f"Reason: {reason}")
        # Prepend file contents + RAG context to user input for models
        model_input = file_prefix + rag_prefix + user_input if (file_prefix or rag_prefix) else user_input

        if strategy == "debate":
            if len(models_for_task) < 2:
                print(f"  {_DIM}[Only 1 model available, using single strategy]{_RESET}")
                strategy = "single"
                _run_single(model_input, models_for_task)
            else:
                _run_debate(model_input, models_for_task)
        elif strategy == "refine":
            _run_refine(model_input, models_for_task)
        elif strategy == "fan_out":
            _run_fan_out(model_input, models_for_task)
        elif strategy == "swarm":
            _run_swarm(model_input, models_for_task)
        else:
            _run_single(model_input, models_for_task)

        # Save last response to conversation memory
        if chat_history:
            last = chat_history[-1]
            conversation.append({"role": "assistant", "content": last[2][:1000]})

        # Auto-save: extract code blocks and save to files
        if task_type == "code" and chat_history:
            _auto_save_code(chat_history)

        _redraw()

    return 0  # pragma: no cover


def _run_chain_inline(config: RuntimeConfig, prompt: str) -> None:
    """Execute a chain and print results inline in chat."""
    if not _is_runtime_running(config):
        print(f"  {_red('Error')}: Chains require the runtime server. Run: brynq-runtime start")
        return

    print()
    print(f"  {_dim('Running chain...')}")
    start = time.time()

    try:
        result = _runtime_post(config, "/chain", {"request": prompt})
        total_ms = result.get("total_duration_ms", 0)
        steps = result.get("steps", [])
        success = result.get("success", False)

        print()
        for i, step in enumerate(steps, 1):
            model = step.get("model", "unknown")
            content = step.get("content", "")
            step_ok = step.get("success", False)
            status = _green("done") if step_ok else _red("failed")

            print(f"  Step {i} [{_cyan(model)}] {status}")
            print(f"  {content[:200]}{'...' if len(content) > 200 else ''}")
            print()

        print(f"  {_dim(f'Chain complete: {len(steps)} steps, {total_ms}ms total')}")
        print()

    except urllib.error.HTTPError as e:
        try:
            error_body = json.loads(e.read().decode("utf-8"))
            detail = error_body.get("detail", str(e))
        except Exception:
            detail = str(e)
        print(f"  {_red('Error')}: {detail}")
        print()

    except Exception as e:
        print(f"  {_red('Error')}: {e}")
        print()


def cmd_chain(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Run a multi-model chain from the terminal."""
    if not _is_runtime_running(config):
        print(f"{_red('Error')}: Runtime is not running. Start it first:")
        print(f"  brynq-runtime start")
        return 1

    prompt = args.prompt
    print(f"{_bold('Chain')}: {prompt}")
    print()

    start = time.time()

    try:
        result = _runtime_post(config, "/chain", {"request": prompt})
    except urllib.error.HTTPError as e:
        try:
            error_body = json.loads(e.read().decode("utf-8"))
            detail = error_body.get("detail", str(e))
        except Exception:
            detail = str(e)
        print(f"{_red('Error')}: {detail}")
        return 1
    except Exception as e:
        print(f"{_red('Error')}: Could not reach runtime: {e}")
        return 1

    total_ms = result.get("total_duration_ms", 0)
    steps = result.get("steps", [])
    success = result.get("success", False)

    for i, step in enumerate(steps, 1):
        model = step.get("model", "unknown")
        content = step.get("content", "")
        step_ok = step.get("success", False)
        error = step.get("error")

        status = _green("done") if step_ok else _red("failed")
        print(f"  Step {i}/{len(steps)} [{_cyan(model)}] {status}")
        print()

        if content:
            # Indent content
            for line in content.splitlines():
                print(f"    {line}")
            print()

        if error:
            print(f"    {_red('Error')}: {error}")
            print()

    # Summary
    wall_ms = int((time.time() - start) * 1000)
    status_str = _green("success") if success else _red("failed")
    print(f"  {_dim('---')}")
    print(f"  Result: {status_str}  |  Steps: {len(steps)}  |  Time: {total_ms}ms (wall: {wall_ms}ms)")

    # Email results if requested
    email_addr = getattr(args, "email", None)
    if email_addr:
        from runtime.integrations.email_sender import EmailConfig, send_chain_result

        email_config = EmailConfig()
        sent = send_chain_result(email_config, email_addr, result)
        if sent:
            print(f"Results emailed to {email_addr}.")
        else:
            print(f"{_red('Error')}: Failed to email results to {email_addr}.")

    return 0 if success else 1


def cmd_batch(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Process a batch file of prompts through the runtime."""
    import asyncio as _asyncio

    from runtime.integrations.batch import (
        BatchProgress,
        load_batch_file,
        run_batch,
        write_batch_results,
    )

    if not _is_runtime_running(config):
        print(f"{_red('Error')}: Runtime is not running. Start it first:")
        print("  brynq-runtime start")
        return 1

    # Load prompts
    filepath = args.filepath
    try:
        prompts = load_batch_file(filepath)
    except FileNotFoundError as exc:
        print(f"{_red('Error')}: {exc}")
        return 1
    except ValueError as exc:
        print(f"{_red('Error')}: {exc}")
        return 1

    if not prompts:
        print(f"{_yellow('Warning')}: No prompts found in {filepath}")
        return 0

    model = getattr(args, "model", None)
    strategy = getattr(args, "strategy", None)
    output_path = getattr(args, "output", None)
    fmt = getattr(args, "format", "json") or "json"

    print(f"{_bold('Batch')}: {filepath}")
    print(f"  Prompts: {len(prompts)}")
    if model:
        print(f"  Model:   {model}")
    if strategy:
        print(f"  Strategy: {strategy}")
    print()

    endpoint_url = f"http://127.0.0.1:{config.local_port}/chat"
    progress = BatchProgress(len(prompts))

    start = time.time()
    try:
        results = _asyncio.run(
            run_batch(prompts, endpoint_url, model=model, strategy=strategy)
        )
    except Exception as exc:
        print(f"{_red('Error')}: Batch execution failed: {exc}")
        return 1

    wall_s = time.time() - start

    # Update progress with final counts
    failed = sum(1 for r in results if r.get("status", 0) != 200)
    succeeded = len(results) - failed
    progress.update(succeeded, failed)

    # Write output file if requested
    if output_path:
        try:
            write_batch_results(results, output_path, fmt)
            print(f"  Output written to {output_path} ({fmt})")
        except ValueError as exc:
            print(f"{_red('Error')}: {exc}")
            return 1

    # Print summary
    print(f"\n  {_dim('---')}")
    print(f"  Total:     {len(results)}")
    print(f"  Succeeded: {_green(str(succeeded))}")
    if failed:
        print(f"  Failed:    {_red(str(failed))}")
    avg_latency = (
        sum(r.get("latency_ms", 0) for r in results) / len(results)
        if results
        else 0
    )
    print(f"  Avg latency: {avg_latency:.0f}ms")
    print(f"  Wall time:   {wall_s:.1f}s")

    return 0 if failed == 0 else 1


def cmd_setup(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Create a desktop shortcut for Brynq Chat."""
    import shutil

    desktop = Path.home() / "Desktop"
    if not desktop.exists():
        # Try XDG desktop dir (Linux)
        desktop = Path.home()

    python = sys.executable

    if sys.platform == "win32":
        # Windows: create a .bat file
        bat_path = desktop / "Brynq Chat.bat"
        bat_path.write_text(
            f'@echo off\ntitle Brynq Chat\n"{python}" -m runtime chat\n',
            encoding="utf-8",
        )
        print(f"  {_green('Created:')} {bat_path}")
        print(f"  Double-click it to start Brynq Chat.")

    elif sys.platform == "darwin":
        # macOS: create a .command file
        cmd_path = desktop / "Brynq Chat.command"
        cmd_path.write_text(
            f'#!/bin/bash\n"{python}" -m runtime chat\n',
            encoding="utf-8",
        )
        cmd_path.chmod(0o755)
        print(f"  {_green('Created:')} {cmd_path}")

    else:
        # Linux: create a .desktop file
        desktop_path = desktop / "brynq-chat.desktop"
        desktop_path.write_text(
            f"[Desktop Entry]\n"
            f"Name=Brynq Chat\n"
            f"Comment=Multi-model AI chat\n"
            f"Exec={python} -m runtime chat\n"
            f"Terminal=true\n"
            f"Type=Application\n"
            f"Categories=Development;\n",
            encoding="utf-8",
        )
        desktop_path.chmod(0o755)
        print(f"  {_green('Created:')} {desktop_path}")

    return 0


def cmd_completions(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Print or install shell completion scripts."""
    from runtime.integrations.shell_completion import (
        generate_zsh_completion,
        generate_powershell_completion,
    )

    shell = args.shell

    if args.install:
        from runtime.integrations.shell_completion import install_completion

        install_completion(shell)
        return 0

    generators = {
        "zsh": generate_zsh_completion,
        "powershell": generate_powershell_completion,
    }

    gen = generators.get(shell)
    if gen is None:
        print(f"  {_red('Error:')} No completion script available for '{shell}'.")
        print(f"  Supported shells: {', '.join(generators)}")
        return 1

    print(gen())
    return 0


def cmd_monitor(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Live htop-style monitor for agents and chains."""
    from runtime.monitor import run_monitor
    run_monitor(config, interval=getattr(args, "interval", 2.0))
    return 0


def cmd_dashboard(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Open the operator dashboard in browser."""
    import threading
    try:
        from brynq.dashboard import run_dashboard
    except ImportError:
        print(f"{_red('Error')}: Dashboard requires full install: pip install -e '.[all]'")
        return 1
    if not getattr(args, "no_browser", False):
        import webbrowser
        port = getattr(args, "port", 9999)
        threading.Timer(0.8, lambda: webbrowser.open(f"http://127.0.0.1:{port}")).start()
    port = getattr(args, "port", 9999)
    print(f"  Dashboard: http://127.0.0.1:{port}")
    run_dashboard(port=port)
    return 0


def cmd_tui(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Launch the TUI chat (rich terminal UI with panels)."""
    try:
        from runtime.tui_chat import BrynqChat
        BrynqChat(config).run()
        return 0
    except ImportError:
        print(f"{_red('Error')}: TUI requires textual: pip install textual")
        return 1


def cmd_spawn(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Spawn AI agents to work on a task."""
    from runtime.spawner import spawn_agent, spawn_swarm, list_agents, kill_agent

    sub = getattr(args, "spawn_command", None)

    if sub == "list":
        agents = list_agents(config.data_dir)
        if not agents:
            print("  No agents running.")
        for a in agents:
            status = _green("alive") if a.get("alive") else _red("dead")
            print(f"  {a['name']:20s}  {a.get('backend','?'):8s}  {a.get('model','?'):15s}  {status}")
        return 0

    elif sub == "kill":
        name = getattr(args, "name", "")
        if kill_agent(config.data_dir, name):
            print(f"  Killed: {name}")
        else:
            print(f"  Not found: {name}")
        return 0

    elif sub == "run":
        task = getattr(args, "task", "")
        count = getattr(args, "count", 1)
        model = getattr(args, "model", "llama3")
        backend = getattr(args, "backend", "ollama")

        if count == 1:
            result = spawn_agent(
                config.data_dir,
                name=f"agent-{model}",
                backend=backend,
                model=model,
                channel="tasks",
                system_prompt=task,
            )
            print(f"  Spawned: {result.get('name', '?')} (PID {result.get('pid', '?')})")
        else:
            agents_config = [
                {"name": f"agent-{model}-{i+1}", "backend": backend, "model": model}
                for i in range(count)
            ]
            results = spawn_swarm(
                config.data_dir,
                swarm_name=f"swarm-{model}",
                task=task,
                agents=agents_config,
            )
            print(f"  Swarm spawned: {len(results)} agents on #swarm-{model}")
            for r in results:
                print(f"    {r.get('name', '?')} (PID {r.get('pid', '?')})")
        return 0

    else:
        print("  Usage: python -m runtime spawn run --task 'research X' --count 3")
        print("         python -m runtime spawn list")
        print("         python -m runtime spawn kill <name>")
        return 0


def cmd_workflow(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Manage and run DAG workflows."""
    from runtime.workflow_store import (
        create_workflow, start_workflow, get_run,
        list_runs, list_workflows,
    )

    sub = getattr(args, "workflow_command", None)

    if sub == "create":
        filepath = getattr(args, "file", "")
        if not filepath:
            print("  Usage: python -m runtime workflow create --file workflow.json")
            return 1
        data = json.loads(Path(filepath).read_text("utf-8"))
        wf = create_workflow(
            config.data_dir,
            name=data.get("name", "unnamed"),
            description=data.get("description", ""),
            steps=data.get("steps", []),
        )
        print(f"  Created workflow: {wf['workflow_id']}")
        return 0

    elif sub == "run":
        wf_id = getattr(args, "id", "")
        user_input = getattr(args, "input", "")
        run = start_workflow(config.data_dir, wf_id, user_input)
        print(f"  Run started: {run['run_id']}")
        return 0

    elif sub == "status":
        run_id = getattr(args, "run_id", "")
        run = get_run(config.data_dir, run_id)
        if not run:
            print(f"  Run not found: {run_id}")
            return 1
        print(f"  Run: {run_id}")
        print(f"  Status: {run.get('status', '?')}")
        for sid, step in run.get("step_statuses", {}).items():
            print(f"    {sid}: {step.get('status', '?')}")
        return 0

    elif sub == "list":
        workflows = list_workflows(config.data_dir)
        if not workflows:
            print("  No workflows defined.")
        for wf in workflows:
            print(f"  {wf['workflow_id']:12s}  {wf.get('name', '?')}")
        return 0

    else:
        print("  Usage: python -m runtime workflow create --file workflow.json")
        print("         python -m runtime workflow run <id> --input 'prompt'")
        print("         python -m runtime workflow status <run_id>")
        print("         python -m runtime workflow list")
        return 0


def _format_pipe_output(result_dict: dict, fmt: str) -> str:
    """Format pipe output as text, json, csv, or tsv."""
    if fmt == "json":
        import json as json_mod
        return json_mod.dumps(result_dict)
    elif fmt == "csv":
        headers = list(result_dict.keys())
        values = [str(v) if v is not None else "" for v in result_dict.values()]
        return ",".join(headers) + "\n" + ",".join(values)
    elif fmt == "tsv":
        headers = list(result_dict.keys())
        values = [str(v) if v is not None else "" for v in result_dict.values()]
        return "\t".join(headers) + "\n" + "\t".join(values)
    else:
        # text: just the output field
        return str(result_dict.get("output", ""))


def cmd_pipe(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Headless pipe mode. Reads stdin, sends to /chat, writes to stdout."""
    import socket
    import sys
    import time
    import urllib.error

    if sys.stdin.isatty():
        print("Error: No input provided on stdin. Usage: echo 'prompt' | brynq-runtime pipe", file=sys.stderr)
        return 1

    if not _is_runtime_running(config):
        print("Error: Runtime server is not running. Start it with 'brynq-runtime start'", file=sys.stderr)
        return 1

    raw = sys.stdin.read().strip()
    if not raw:
        return 0

    delimiter = getattr(args, "delimiter", "\n")
    chunks = [c.strip() for c in raw.split(delimiter) if c.strip()]
    if not chunks:
        return 0

    # Batch chunks: collect N lines into a single prompt
    _bs = getattr(args, "batch_size", 1)
    batch_size = _bs if isinstance(_bs, int) and _bs > 1 else 1
    if batch_size > 1:
        batched: list[str] = []
        for i in range(0, len(chunks), batch_size):
            batched.append("\n".join(chunks[i : i + batch_size]))
        chunks = batched

    # Determine output format once: --format takes precedence, --json is shorthand
    fmt = getattr(args, "format", None)
    if fmt is None and getattr(args, "json", False):
        fmt = "json"

    timeout = getattr(args, "timeout", 120)

    errors = 0
    for text in chunks:
        payload: dict[str, Any] = {"message": text}
        if getattr(args, "model", None):
            payload["model"] = args.model
        if getattr(args, "strategy", None):
            payload["strategy"] = args.strategy

        try:
            start = time.monotonic()
            result = _runtime_post(config, "/chat", payload, timeout=timeout)
            elapsed_ms = int((time.monotonic() - start) * 1000)

            # Extract response text from steps format
            steps = result.get("steps", [])
            response_text = steps[0]["content"] if steps else result.get("response", result.get("content", ""))

            if fmt and fmt != "text":
                model_used = getattr(args, "model", None) or (steps[0].get("model") if steps else None)
                tokens_used = (steps[0].get("tokens") if steps else None) or result.get("tokens")
                result_dict = {
                    "input": text,
                    "output": response_text,
                    "model": model_used,
                    "tokens": tokens_used,
                    "latency_ms": elapsed_ms,
                }
                print(_format_pipe_output(result_dict, fmt))
            else:
                print(response_text)

        except (urllib.error.URLError, socket.timeout) as e:
            is_timeout = isinstance(e, socket.timeout) or (
                isinstance(e, urllib.error.URLError) and isinstance(e.reason, socket.timeout)
            )
            if is_timeout:
                import json as json_mod
                print(json_mod.dumps({"error": "timeout", "input": text}))
                errors += 1
            else:
                print(f"Error: {e}", file=sys.stderr)
                errors += 1
        except Exception as e:
            print(f"Error: {e}", file=sys.stderr)
            errors += 1

    return 1 if errors == len(chunks) else 0


def cmd_watch(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Watch a directory for new files and process them through a chain."""
    from runtime.integrations.file_watcher import FileWatcher

    directory = args.directory
    if not os.path.isdir(directory):
        print(f"Error: '{directory}' is not a directory", file=sys.stderr)
        return 1

    pattern = getattr(args, "pattern", "*")
    chain = getattr(args, "chain", "default")
    interval = getattr(args, "interval", 5)

    watcher = FileWatcher(directory, interval=float(interval))
    watcher.register_pattern(pattern, chain)
    watcher.start()

    print(f"Watching {directory} for '{pattern}' files (interval: {interval}s)")
    print("Press Ctrl+C to stop.")

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        watcher.stop()
        print("\nStopped watching.")

    return 0


def cmd_connect(args: argparse.Namespace, config: RuntimeConfig) -> int:
    """Mount a new protocol adapter via AdapterManager."""
    from runtime.adapters.manager import AdapterManager

    url: str = args.url
    manager = AdapterManager()

    try:
        adapter = manager.create_adapter(url)
    except ValueError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1

    try:
        info = adapter.introspect()
    except Exception as exc:
        print(f"Adapter created but introspect failed: {exc}", file=sys.stderr)
        return 1

    tools = info.get("tools", [])
    version = info.get("version", "unknown")
    print(_green(f"Connected to {url}"))
    print(f"  Adapter version: {version}")
    print(f"  Tools available: {len(tools)}")
    for tool in tools:
        name = tool.get("name", "?")
        desc = tool.get("description", "")
        print(f"    - {name}: {desc}")

    return 0


# ── CLI Parser ────────────────────────────────────────────────────────


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser for the brynq-runtime CLI."""
    parser = argparse.ArgumentParser(
        prog="brynq-runtime",
        description="Brynq Runtime -- Launch and control the local AI runtime.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "examples:\n"
            "  brynq-runtime start              Start runtime + open browser\n"
            "  brynq-runtime login claude        OAuth login for Claude\n"
            "  brynq-runtime models              List available models\n"
            "  brynq-runtime chat                Interactive terminal chat\n"
            '  brynq-runtime chain "summarize"   Run a multi-model chain\n'
            "  brynq-runtime status              Show runtime status\n"
            "  brynq-runtime stop                Stop the runtime server\n"
        ),
    )

    sub = parser.add_subparsers(dest="command", metavar="command")

    # start
    p_start = sub.add_parser("start", help="Start the runtime server and open the app")
    p_start.add_argument(
        "--no-browser", action="store_true",
        help="Do not open the browser automatically",
    )

    # stop
    sub.add_parser("stop", help="Stop the runtime server")

    # status
    sub.add_parser("status", help="Show runtime status")

    # models
    sub.add_parser("models", help="List available models (local + cloud)")

    # login
    p_login = sub.add_parser("login", help="OAuth login for a cloud provider")
    p_login.add_argument(
        "provider",
        help="Provider name: claude, gemini, openai (or: anthropic, google)",
    )

    # chat
    p_chat = sub.add_parser("chat", help="Interactive terminal chat")
    p_chat.add_argument("--model", "-m", default=None, help="Model to use (auto-detected if omitted)")
    p_chat.add_argument("--once", action="store_true", help="Non-interactive: run one prompt and exit")
    p_chat.add_argument("--verbose", "-v", action="store_true", help="Show strategy selection reasoning")
    p_chat.add_argument("--clipboard", action="store_true", help="Read prompt from clipboard when no prompt given")
    p_chat.add_argument("prompt", nargs="*", default=[], help="Prompt text (for --once mode)")

    # chain
    p_chain = sub.add_parser("chain", help="Run a multi-model chain")
    p_chain.add_argument("prompt", help="Chain prompt describing what to do")
    p_chain.add_argument("--email", default=None, help="Email address to send results to after chain execution")

    # batch
    p_batch = sub.add_parser("batch", help="Process a batch file of prompts")
    p_batch.add_argument("filepath", help="Path to batch file (.txt, .csv, .jsonl)")
    p_batch.add_argument("--model", "-m", default=None, help="Model to use")
    p_batch.add_argument("--strategy", "-s", default=None, choices=["sequential", "fan-out", "debate", "refine", "single"], help="Chain strategy")
    p_batch.add_argument("--output", "-o", default=None, help="Output file path for results")
    p_batch.add_argument("--format", "-f", default="json", choices=["json", "csv", "jsonl"], help="Output format (default: json)")

    # pipe
    p_pipe = sub.add_parser("pipe", help="Headless pipe mode: reads stdin, writes stdout")
    p_pipe.add_argument("--model", "-m", help="Force a specific model")
    p_pipe.add_argument("--strategy", "-s", choices=["sequential", "fan-out", "debate", "refine", "single"], help="Chain strategy")
    p_pipe.add_argument("--json", action="store_true", help="Output structured JSON")
    p_pipe.add_argument("--format", choices=["text", "json", "csv", "tsv"], default=None, help="Output format: text, json, csv, tsv")
    p_pipe.add_argument("--delimiter", default="\n", help="Delimiter to split stdin into chunks (default: newline)")
    p_pipe.add_argument("--timeout", type=int, default=120, help="HTTP request timeout in seconds (default: 120)")
    p_pipe.add_argument("--batch-size", type=int, default=1, help="Number of lines to collect before sending as a single prompt (default: 1)")

    # watch
    p_watch = sub.add_parser("watch", help="Watch a directory for new files and process them")
    p_watch.add_argument("directory", help="Directory to watch for new files")
    p_watch.add_argument("--pattern", default="*", help="Glob pattern to match files (default: '*')")
    p_watch.add_argument("--chain", default="default", help="Chain template name to use")
    p_watch.add_argument("--interval", type=int, default=5, help="Polling interval in seconds (default: 5)")

    # learning commands
    sub.add_parser("stats", help="Show learning subsystem usage statistics")
    sub.add_parser("insights", help="Show AI workflow insights and patterns")
    sub.add_parser("learn", help="Auto-detect preferences based on recent usage")
    p_prefs = sub.add_parser("preferences", help="Manage Brynq learning preferences")
    p_prefs.add_argument("--set", help="Set a preference (key=value)")
    sub.add_parser("reset-learning", help="Clear all local learning data")
    sub.add_parser("trends", help="Show 30-day trends")
    p_costs = sub.add_parser("costs", help="Show cost tracker report")
    p_costs.add_argument("--budget", type=float, help="Set a daily budget limit in USD")
    p_ab = sub.add_parser("ab", help="Manage A/B tests")
    p_ab.add_argument("--create", help="Create a test (name)")
    p_ab.add_argument("--conclude", help="Conclude a test (name)")
    p_ab.add_argument("--delete", help="Delete a test (name)")

    # v6 community commands
    sub.add_parser("network", help="Show network insights (requires telemetry opt-in)")
    sub.add_parser("leaderboard", help="Show model leaderboard")
    p_rate = sub.add_parser("rate", help="Rate an agent")
    p_rate.add_argument("agent_id", help="Agent ID")
    p_rate.add_argument("--rating", type=int, required=True, help="Rating (1-5)")
    p_rate.add_argument("--review", default="", help="Optional review text")
    p_reviews = sub.add_parser("reviews", help="Show reviews for an agent")
    p_reviews.add_argument("agent_id", help="Agent ID")

    # connect
    p_connect = sub.add_parser("connect", help="Mount a new protocol adapter by URL")
    p_connect.add_argument("url", help="Adapter URL (e.g. mcp://localhost:9000, rest://api.example.com)")

    # setup
    sub.add_parser("setup", help="Create a desktop shortcut for Brynq Chat")

    # completions
    p_comp = sub.add_parser("completions", help="Print or install shell completion scripts")
    p_comp.add_argument("shell", choices=["bash", "zsh", "powershell"], help="Target shell")
    p_comp.add_argument("--install", action="store_true", help="Install the completion script into your shell config")

    # monitor
    p_mon = sub.add_parser("monitor", help="Live htop-style process monitor")
    p_mon.add_argument("--interval", "-i", type=float, default=2.0, help="Refresh interval (default: 2s)")

    # dashboard
    p_dash = sub.add_parser("dashboard", help="Open operator dashboard in browser")
    p_dash.add_argument("--port", type=int, default=9999, help="Dashboard port (default: 9999)")
    p_dash.add_argument("--no-browser", action="store_true")

    # tui
    sub.add_parser("tui", help="Rich terminal UI chat (requires textual)")

    # spawn
    p_spawn = sub.add_parser("spawn", help="Spawn AI agents")
    spawn_sub = p_spawn.add_subparsers(dest="spawn_command")
    p_spawn_run = spawn_sub.add_parser("run", help="Spawn agents for a task")
    p_spawn_run.add_argument("--task", "-t", required=True, help="Task description")
    p_spawn_run.add_argument("--count", "-n", type=int, default=1, help="Number of agents")
    p_spawn_run.add_argument("--model", default="llama3", help="Model to use")
    p_spawn_run.add_argument("--backend", default="ollama", help="Backend (ollama/claude/gemini)")
    spawn_sub.add_parser("list", help="List running agents")
    p_spawn_kill = spawn_sub.add_parser("kill", help="Kill an agent")
    p_spawn_kill.add_argument("name", help="Agent name to kill")

    # workflow
    p_wf = sub.add_parser("workflow", help="Manage DAG workflows")
    wf_sub = p_wf.add_subparsers(dest="workflow_command")
    p_wf_create = wf_sub.add_parser("create", help="Create workflow from JSON file")
    p_wf_create.add_argument("--file", "-f", required=True, help="Path to workflow JSON")
    p_wf_run = wf_sub.add_parser("run", help="Start a workflow run")
    p_wf_run.add_argument("id", help="Workflow ID")
    p_wf_run.add_argument("--input", "-i", default="", help="Input prompt")
    p_wf_status = wf_sub.add_parser("status", help="Show run status")
    p_wf_status.add_argument("run_id", help="Run ID")
    wf_sub.add_parser("list", help="List workflows")

    return parser


def _fix_windows_encoding() -> None:
    """Fix Windows console encoding: cp1252 cannot handle unicode output."""
    if sys.platform != "win32":
        return
    for stream_name in ("stdout", "stderr"):
        stream = getattr(sys, stream_name, None)
        if stream is None:
            continue
        # Only wrap if it has a raw buffer and is not already UTF-8
        buf = getattr(stream, "buffer", None)
        if buf is None:
            continue
        if getattr(stream, "encoding", "").lower().replace("-", "") == "utf8":
            continue
        try:
            wrapped = io.TextIOWrapper(buf, encoding="utf-8", errors="replace")
            setattr(sys, stream_name, wrapped)
        except Exception:
            pass


def main(argv: list[str] | None = None) -> int:
    """Entry point for the brynq-runtime CLI."""
    _fix_windows_encoding()

    parser = build_parser()
    args = parser.parse_args(argv)

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

    config = load_config()

    commands: dict[str, Any] = {
        "start": cmd_start,
        "stop": cmd_stop,
        "status": cmd_status,
        "models": cmd_models,
        "login": cmd_login,
        "chat": cmd_chat,
        "chain": cmd_chain,
        "batch": cmd_batch,
        "pipe": cmd_pipe,
        "watch": cmd_watch,
        "stats": cmd_stats,
        "insights": cmd_insights,
        "learn": cmd_learn,
        "preferences": cmd_preferences,
        "reset-learning": cmd_reset_learning,
        "trends": cmd_trends,
        "costs": cmd_costs,
        "ab": cmd_ab,
        "setup": cmd_setup,
        "completions": cmd_completions,
        "monitor": cmd_monitor,
        "dashboard": cmd_dashboard,
        "tui": cmd_tui,
        "spawn": cmd_spawn,
        "workflow": cmd_workflow,
        "connect": cmd_connect,
    }

    handler = commands.get(args.command)
    if handler is None:
        parser.print_help()
        return 1

    return handler(args, config)


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