"""
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 ─────────────────────────────────────────────────────────

VERSION = "0.1.0"
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"].split(":")[0] 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")


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


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.
    """
    import re as _re
    import shutil
    import subprocess
    import threading

    # ── 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}")

    # ── 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}, ...]

    # ── 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(text: str) -> tuple[str, str]:
        """Classify user input → (task_type, strategy).

        Returns:
            task_type: action, code, analysis, creative, research, compare, general
            strategy: single, debate, refine, fan_out
        """
        t = text.lower()

        # Action tasks — try direct execution first
        if any(k in t for k in (
            "create ", "delete ", "make ", "open ", "run ", "install ",
            "move ", "copy ", "rename ", "download ", "mkdir", "folder",
            "file ", "save ", "write to ", "execute",
        )):
            return "action", "single"

        # Code tasks — write/debug/review code (Claude + Gemini only)
        if any(k in t for k in (
            "code", "function", "class ", "debug", "fix ", "refactor",
            "implement", "script", "program", "api ", "endpoint",
            "bug ", "error ", "test ", "deploy",
        )):
            return "code", "single"

        # Compare/debate tasks
        if any(k in t for k in (
            "compare", "vs ", "versus", "better", "pros and cons",
            "debate", "argue", "which ", "should i use",
            "difference between", "advantages",
        )):
            return "compare", "debate"

        # Creative tasks — draft → critique → revise
        if any(k in t for k in (
            "write ", "draft ", "compose", "design ", "story",
            "email ", "blog ", "essay ", "article ", "landing page",
            "headline", "slogan", "pitch",
        )):
            return "creative", "refine"

        # Research/analysis — multiple perspectives
        if any(k in t for k in (
            "analyze", "research", "explain", "how does",
            "what is", "why ", "investigate", "explore",
            "summarize", "overview",
        )):
            return "research", "fan_out"

        return "general", "single"

    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 in ("action", "code"):
            # Only code-capable models (Claude preferred)
            if "claude" in active_models:
                return ["claude"]
            return code_capable[:1] if code_capable else all_m[:1]

        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]

    # ── 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]}")
        if context_parts:
            full_prompt = "Previous conversation:\n" + "\n".join(context_parts) + f"\n\nUser: {prompt}"
        else:
            full_prompt = prompt
        try:
            r = subprocess.run(
                ["claude", "--print"], input=full_prompt,
                capture_output=True, text=True, timeout=600,
                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]"
        except Exception as e:
            return f"[error: {e}]"

    def _call_gemini(prompt: str) -> str:
        try:
            r = subprocess.run(
                ["gemini", "-p", prompt], input=prompt,
                capture_output=True, text=True, timeout=600,
                encoding="utf-8", errors="replace", shell=True,
            )
            return r.stdout.strip() if r.returncode == 0 else f"[error: {r.stderr.strip()[:200]}]"
        except subprocess.TimeoutExpired:
            return "[timed out]"
        except Exception as e:
            return f"[error: {e}]"

    def _call_ollama(prompt: str, model: str) -> str:
        try:
            body = {
                "model": model,
                "messages": [{"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 Exception as e:
            return f"[error: {e}]"

    def _call_model(model: str, prompt: str) -> str:
        if model == "claude":
            return _call_claude(prompt)
        elif model == "gemini":
            return _call_gemini(prompt)
        elif model.startswith("ollama:"):
            return _call_ollama(prompt, model.split(":", 1)[1])
        return "[unknown model]"

    # ── Strategy executors ─────────────────────────────────────────
    def _run_single(user_input: str, models: list[str]) -> None:
        model = models[0]
        _print_status(f"Routing to {_model_label(model)}")
        start = time.time()
        result = _call_model(model, user_input)
        elapsed = time.time() - start
        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]

        # Pro
        _print_status(f"Debate: {_model_label(model_a)} argues FOR")
        start = time.time()
        pro = _call_model(model_a, f"Present a STRONG CASE IN FAVOR. Best arguments and reasoning.\n\nTopic: {user_input}")
        chat_history.append(("assistant", f"{_GREEN_C}PRO{_RESET} {_model_label(model_a)}" if _COLOR else f"PRO {_model_label(model_a)}", pro, f"{time.time()-start:.1f}s"))
        _redraw()

        # Con
        _print_status(f"Debate: {_model_label(model_b)} argues AGAINST")
        start = time.time()
        con = _call_model(model_b, f"Present a STRONG CASE AGAINST. Weaknesses and counterarguments.\n\nTopic: {user_input}")
        chat_history.append(("assistant", f"{_RED_C}CON{_RESET} {_model_label(model_b)}" if _COLOR else f"CON {_model_label(model_b)}", con, f"{time.time()-start:.1f}s"))
        _redraw()

        # Judge
        _print_status(f"Judging: {_model_label(judge)}")
        start = time.time()
        verdict = _call_model(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."
        )
        chat_history.append(("assistant", f"VERDICT {_model_label(judge)}", verdict, f"{time.time()-start:.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]

        # Draft
        _print_status(f"Drafting: {_model_label(drafter)}")
        start = time.time()
        draft = _call_model(drafter, f"Produce a solid first draft.\n\n{user_input}")
        chat_history.append(("assistant", f"DRAFT {_model_label(drafter)}", draft, f"{time.time()-start:.1f}s"))
        _redraw()

        # Critique
        _print_status(f"Critiquing: {_model_label(critic)}")
        start = time.time()
        critique = _call_model(critic,
            f"Review critically. Strengths, weaknesses, specific improvements.\n\nRequest: {user_input}\n\nDraft:\n{draft}"
        )
        chat_history.append(("assistant", f"CRITIQUE {_model_label(critic)}", critique, f"{time.time()-start:.1f}s"))
        _redraw()

        # Revise
        _print_status(f"Revising: {_model_label(reviser)}")
        start = time.time()
        revision = _call_model(reviser,
            f"Revise incorporating the critique. Produce an improved version.\n\nRequest: {user_input}\n\nDraft:\n{draft}\n\nCritique:\n{critique}"
        )
        chat_history.append(("assistant", f"FINAL {_model_label(reviser)}", revision, f"{time.time()-start:.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()

        _print_status(f"Fan-out to {len(models)} models...")

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

        for m in models:
            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]
            _print_status(f"Synthesizing: {_model_label(synth_model)}")
            parts = "\n\n".join(f"=== {m} ===\n{results_map.get(m,'')}" for m in models)
            start = time.time()
            synthesis = _call_model(synth_model,
                f"Multiple perspectives on: {user_input}\n\n{parts}\n\n"
                f"Synthesize into one coherent response. Best insights from each."
            )
            chat_history.append(("assistant", f"SYNTHESIS {_model_label(synth_model)}", synthesis, f"{time.time()-start:.1f}s"))

    # ── 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}")
        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:
        print()
        if _COLOR:
            print(f"{_BG_PANEL}{_DIM}{'─' * cols}{_RESET}")
            sys.stdout.write(f"{_BG_PANEL} ")
        else:
            print("─" * cols)
            sys.stdout.write(" ")
        try:
            user_input = input(f"{_WHITE_BOLD}brynq>{_RESET}{_BG_PANEL} " if _COLOR else "brynq> ")
        except (EOFError, KeyboardInterrupt):
            return ""
        if _COLOR:
            print(f"{_BG_PANEL}{_DIM}{'─' * cols}{_RESET}")
        else:
            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}")

    chat_history: list[tuple[str, str, str, str]] = []

    def _redraw() -> None:
        _draw_header()
        for _, sender, content, meta in chat_history:
            _print_message(sender, content, meta)

    _draw_header()

    # ── 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 cmd_arg and cmd_arg not in active_models:
                    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 == "/clear":
                chat_history.clear()
                _redraw()
                continue
            else:
                _redraw()
                print(f"\n  {_dim('Commands: /add, /remove, /models, /clear, /quit')}")
                continue

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

        # 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

        # Route to models
        models_for_task = _pick_models(task_type, strategy)
        strategy_label = f"[{task_type} → {strategy}]"
        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 strategy == "debate":
            _run_debate(user_input, models_for_task)
        elif strategy == "refine":
            _run_refine(user_input, models_for_task)
        elif strategy == "fan_out":
            _run_fan_out(user_input, models_for_task)
        else:
            _run_single(user_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]})

        _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)")

    return 0 if success 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


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

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

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

    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,
        "setup": cmd_setup,
    }

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

    return handler(args, config)


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