"""
Brynq TUI Chat — Textual-powered orchestrated multi-model chat.

A full terminal UI that replaces the ANSI chat in ``runtime/cli.py``.
Runs model calls in Textual workers so the interface never blocks.

Launch::

    from runtime.tui_chat import BrynqChat
    from runtime.config import load_config
    BrynqChat(load_config()).run()
"""

from __future__ import annotations

import json
import re
import shutil
import subprocess
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Footer, Header, Input, Label, Static
from textual.worker import Worker, WorkerState

from runtime.config import RuntimeConfig

# ── Ollama helpers (same as cli.py) ──────────────────────────────────


def _ollama_is_running(ollama_url: str) -> bool:
    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]:
    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 []


# ── Task classification (mirrors cli.py) ─────────────────────────────

_CODE_MODELS = {"claude", "gemini"}


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

    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"

    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"

    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"

    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"

    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"


# ── File auto-reading ────────────────────────────────────────────────

_MAX_FILE_SIZE = 500 * 1024  # 500 KB

# Match tokens that look like file paths (contain / or \ or have an extension).
# Excludes URLs, slash commands, and bare words without path separators or extensions.
_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}"


def _pick_models(task_type: str, strategy: str, active_models: list[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"):
        if "claude" in active_models:
            return ["claude"]
        return code_capable[:1] if code_capable else all_m[:1]

    if strategy == "debate":
        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":
        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":
        return all_m[:3]

    return code_capable[:1] if code_capable else all_m[:1]


# ── Model callers (same as cli.py) ───────────────────────────────────


def _call_claude(prompt: str, conversation: list[dict[str, str]]) -> str:
    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, ollama_url: str, timeout: float) -> str:
    try:
        body = {
            "model": model,
            "messages": [{"role": "user", "content": prompt}],
            "stream": False,
        }
        data = json.dumps(body).encode("utf-8")
        req = urllib.request.Request(
            f"{ollama_url}/api/chat",
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=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, conversation: list[dict[str, str]],
    ollama_url: str, ollama_timeout: float,
) -> str:
    if model == "claude":
        return _call_claude(prompt, conversation)
    elif model == "gemini":
        return _call_gemini(prompt)
    elif model.startswith("ollama:"):
        return _call_ollama(prompt, model.split(":", 1)[1], ollama_url, ollama_timeout)
    return "[unknown model]"


# ── Model display name ───────────────────────────────────────────────


def _model_display(model: str) -> str:
    if model == "claude":
        return "Claude (Opus 4.6)"
    elif model == "gemini":
        return "Gemini 3.1"
    elif model.startswith("ollama:"):
        return f"Ollama/{model.split(':', 1)[1]}"
    return model


# ── Direct action executor ───────────────────────────────────────────

from pathlib import Path as _Path


def _try_direct_action(text: str) -> str | None:
    t = text.lower().strip()

    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" in t else _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}"

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

    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


# ── Textual Widgets ──────────────────────────────────────────────────


class ChatMessage(Static):
    """A single message bubble in the feed."""

    def __init__(self, sender: str, content: str, meta: str = "", css_class: str = "msg-assistant") -> None:
        super().__init__(classes=css_class)
        self._sender = sender
        self._content = content
        self._meta = meta

    def compose(self) -> ComposeResult:
        meta_part = f"  [{self._meta}]" if self._meta else ""
        yield Label(f"{self._sender}{meta_part}", classes="msg-sender")
        yield Static(self._content, classes="msg-body")


class OrchestrationLog(Static):
    """One entry in the orchestration decision panel."""


class ModelStatusEntry(Static):
    """One entry in the model-status panel."""


# ── Main App ─────────────────────────────────────────────────────────


class BrynqChat(App):
    """Textual TUI chat with multi-model orchestration."""

    TITLE = "BRYNQ CHAT"

    CSS = """
    Screen {
        layout: vertical;
        background: #0f0f1a;
    }

    /* ── Header bar ────────────────────────────────────────────── */
    #header-bar {
        dock: top;
        height: 3;
        background: #1a3a6e;
        color: #e0e0e0;
        padding: 0 2;
        content-align: center middle;
    }
    #header-title {
        text-style: bold;
        color: #00d4aa;
        width: auto;
    }
    #header-models {
        color: #a0a0c0;
        margin-left: 2;
        width: auto;
    }

    /* ── Three-column layout ───────────────────────────────────── */
    #body {
        layout: horizontal;
        height: 1fr;
    }

    /* Left panel — orchestration decisions */
    #left-panel {
        width: 28;
        background: #12122a;
        border-right: solid #2a2a4a;
        padding: 0;
    }
    #left-title {
        background: #00d4aa;
        color: #0f0f1a;
        text-style: bold;
        text-align: center;
        width: 100%;
        height: 1;
        padding: 0 1;
    }
    #orch-log {
        padding: 0 1;
        height: 1fr;
        overflow-y: auto;
    }
    .orch-entry {
        margin-bottom: 1;
        color: #8888aa;
    }
    .orch-entry .orch-label {
        color: #00d4aa;
        text-style: bold;
    }

    /* Center panel — chat feed */
    #center-panel {
        width: 1fr;
        background: #0f0f1a;
        padding: 0;
    }
    #center-title {
        background: #1a3a6e;
        color: #e0e0e0;
        text-style: bold;
        text-align: center;
        width: 100%;
        height: 1;
        padding: 0 1;
    }
    #chat-feed {
        height: 1fr;
        overflow-y: auto;
        padding: 0 1;
    }

    /* Message styles */
    .msg-user {
        margin: 1 0 0 4;
        background: #1a2a4a;
        padding: 1 2;
    }
    .msg-assistant {
        margin: 1 4 0 0;
        background: #1a1a2e;
        padding: 1 2;
        border-left: thick #00d4aa;
    }
    .msg-system {
        margin: 1 2;
        background: #2a1a1a;
        padding: 1 2;
        color: #888888;
        text-align: center;
    }
    .msg-sender {
        text-style: bold;
        margin-bottom: 0;
    }
    .msg-sender-user {
        color: #5599ff;
    }
    .msg-sender-claude {
        color: #cc77ff;
    }
    .msg-sender-gemini {
        color: #5599ff;
    }
    .msg-sender-ollama {
        color: #ffcc00;
    }
    .msg-sender-system {
        color: #00d4aa;
    }
    .msg-body {
        color: #d0d0e0;
        margin-top: 0;
    }

    /* Right panel — model status */
    #right-panel {
        width: 24;
        background: #12122a;
        border-left: solid #2a2a4a;
        padding: 0;
    }
    #right-title {
        background: #00d4aa;
        color: #0f0f1a;
        text-style: bold;
        text-align: center;
        width: 100%;
        height: 1;
        padding: 0 1;
    }
    #model-status {
        padding: 0 1;
        height: 1fr;
    }
    .model-entry {
        margin-bottom: 1;
    }
    .model-idle {
        color: #666688;
    }
    .model-running {
        color: #ffcc00;
    }
    .model-done {
        color: #00d4aa;
    }
    .model-waiting {
        color: #555577;
    }

    /* ── Bottom panel — input ──────────────────────────────────── */
    #input-panel {
        dock: bottom;
        height: auto;
        max-height: 5;
        background: #1a1a2e;
        border-top: solid #2a2a4a;
        padding: 1 2;
    }
    #chat-input {
        width: 100%;
        background: #0f0f1a;
        color: #e0e0e0;
        border: tall #2a2a4a;
    }
    #chat-input:focus {
        border: tall #00d4aa;
    }
    #input-hint {
        color: #555577;
        text-align: right;
        height: 1;
    }
    """

    BINDINGS = [
        Binding("ctrl+c", "quit", "Quit", show=True),
        Binding("ctrl+l", "clear_chat", "Clear", show=True),
        Binding("escape", "focus_input", "Focus Input", show=False),
    ]

    def __init__(self, config: RuntimeConfig) -> None:
        super().__init__()
        self.config = config
        self.active_models: list[str] = []
        self.conversation: list[dict[str, str]] = []
        self._model_states: dict[str, str] = {}  # model -> idle/running/done/waiting
        self._last_timer: dict[str, float] = {}  # model -> last response seconds

    # ── Layout ────────────────────────────────────────────────────

    def compose(self) -> ComposeResult:
        # Header
        with Horizontal(id="header-bar"):
            yield Label("BRYNQ CHAT", id="header-title")
            yield Label("detecting models...", id="header-models")

        # Three-column body
        with Horizontal(id="body"):
            # Left panel — orchestration log
            with Vertical(id="left-panel"):
                yield Label("ORCHESTRATION", id="left-title")
                yield VerticalScroll(id="orch-log")

            # Center — chat feed
            with Vertical(id="center-panel"):
                yield Label("MESSAGES", id="center-title")
                yield VerticalScroll(id="chat-feed")

            # Right panel — model status
            with Vertical(id="right-panel"):
                yield Label("MODEL STATUS", id="right-title")
                yield Vertical(id="model-status")

        # Pinned bottom input
        with Vertical(id="input-panel"):
            yield Input(placeholder="Type a message... (/quit to exit, /clear to reset)", id="chat-input")
            yield Label("Enter to send | /quit /clear /models /status /export /config /timer /usage /help", id="input-hint")

        yield Footer()

    # ── Lifecycle ─────────────────────────────────────────────────

    def on_mount(self) -> None:
        self._detect_models()
        self._refresh_model_status()
        self.query_one("#chat-input", Input).focus()

    def _detect_models(self) -> None:
        """Detect available models (Claude CLI, Gemini CLI, Ollama)."""
        self.active_models.clear()

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

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

        # Init states
        for m in self.active_models:
            self._model_states[m] = "idle"

        # Update header
        if self.active_models:
            tags = "  ".join(_model_display(m) for m in self.active_models)
            self.query_one("#header-models", Label).update(tags)
        else:
            self.query_one("#header-models", Label).update("No models detected")
            self._add_system_message(
                "No AI models detected. Install at least one:\n"
                "  Free:  ollama.com/download  then  ollama pull llama3\n"
                "  Cloud: npm i -g @anthropic-ai/claude-code\n"
                "  Cloud: npm i -g @google/gemini-cli"
            )

        self._refresh_model_status()

    # ── Model status panel ────────────────────────────────────────

    def _refresh_model_status(self) -> None:
        container = self.query_one("#model-status", Vertical)
        container.remove_children()
        for m in self.active_models:
            state = self._model_states.get(m, "idle")
            display = _model_display(m)
            if state == "running":
                icon, css = ">>", "model-running"
            elif state == "done":
                icon, css = "OK", "model-done"
            elif state == "waiting":
                icon, css = "..", "model-waiting"
            else:
                icon, css = "--", "model-idle"
            entry = Static(f"[{icon}] {display}", classes=f"model-entry {css}")
            container.mount(entry)

    def _set_model_state(self, model: str, state: str) -> None:
        self._model_states[model] = state
        self.call_from_thread(self._refresh_model_status)

    # ── Orchestration log panel ───────────────────────────────────

    def _log_decision(self, text: str) -> None:
        """Append a line to the orchestration log (left panel)."""
        container = self.query_one("#orch-log", VerticalScroll)
        entry = Static(text, classes="orch-entry")
        container.mount(entry)
        entry.scroll_visible()

    # ── Chat feed ─────────────────────────────────────────────────

    def _add_user_message(self, text: str, meta: str = "") -> None:
        feed = self.query_one("#chat-feed", VerticalScroll)
        msg = ChatMessage("You", text, meta=meta, css_class="msg-user")
        feed.mount(msg)
        msg.scroll_visible()

    def _add_assistant_message(self, sender: str, content: str, meta: str = "", model: str = "") -> None:
        feed = self.query_one("#chat-feed", VerticalScroll)
        msg = ChatMessage(sender, content, meta=meta, css_class="msg-assistant")
        feed.mount(msg)
        msg.scroll_visible()

    def _add_system_message(self, text: str) -> None:
        feed = self.query_one("#chat-feed", VerticalScroll)
        msg = ChatMessage("Brynq", text, css_class="msg-system")
        feed.mount(msg)
        msg.scroll_visible()

    # Thread-safe versions for workers
    def _post_user_message(self, text: str, meta: str = "") -> None:
        self.call_from_thread(self._add_user_message, text, meta)

    def _post_assistant_message(self, sender: str, content: str, meta: str = "", model: str = "") -> None:
        self.call_from_thread(self._add_assistant_message, sender, content, meta, model)

    def _post_system_message(self, text: str) -> None:
        self.call_from_thread(self._add_system_message, text)

    def _post_decision(self, text: str) -> None:
        self.call_from_thread(self._log_decision, text)

    # ── Input handling ────────────────────────────────────────────

    def on_input_submitted(self, event: Input.Submitted) -> None:
        text = event.value.strip()
        if not text:
            return
        event.input.value = ""

        # Slash commands
        if text.startswith("/"):
            self._handle_command(text)
            return

        if not self.active_models:
            self._add_system_message("No models available. Install one first.")
            return

        # Classify, pick models, orchestrate
        task_type, strategy = _classify(text)
        models = _pick_models(task_type, strategy, self.active_models)

        # Show user message
        self._add_user_message(text, meta=f"{task_type} -> {strategy}")

        # Log the orchestration decision
        self._log_decision(f"Task: {task_type}")
        self._log_decision(f"Strategy: {strategy}")
        self._log_decision(f"Models: {', '.join(_model_display(m) for m in models)}")
        self._log_decision("---")

        # Auto-read files referenced in the message
        attached_files = _scan_file_paths(text)
        prompt = _prepend_file_contents(text, attached_files)
        if attached_files:
            names = ", ".join(f.name for f in attached_files)
            self._add_system_message(f"Auto-attached {len(attached_files)} file(s): {names}")
            self._log_decision(f"Files: {names}")

        # Add to conversation memory
        self.conversation.append({"role": "user", "content": text})

        # Try direct action first
        if task_type == "action":
            result = _try_direct_action(text)
            if result:
                self._add_assistant_message("Brynq", result, meta="instant")
                self.conversation.append({"role": "assistant", "content": result})
                return

        # Dispatch strategy in a worker thread
        self.run_worker(
            lambda: self._execute_strategy(prompt, task_type, strategy, models),
            thread=True,
            exclusive=True,
        )

    def _handle_command(self, text: str) -> None:
        parts = text.split(maxsplit=1)
        cmd = parts[0].lower()

        if cmd in ("/quit", "/exit", "/q"):
            self.exit()
        elif cmd == "/clear":
            self.action_clear_chat()
        elif cmd == "/models":
            lines = []
            for m in self.active_models:
                code_tag = " [code]" if m in _CODE_MODELS else ""
                lines.append(f"  {_model_display(m)}{code_tag}")
            self._add_system_message("Active models:\n" + "\n".join(lines))
        elif cmd == "/add":
            arg = parts[1].strip() if len(parts) > 1 else ""
            if arg and arg not in self.active_models:
                self.active_models.append(arg)
                self._model_states[arg] = "idle"
                self._refresh_model_status()
                tags = "  ".join(_model_display(m) for m in self.active_models)
                self.query_one("#header-models", Label).update(tags)
                self._add_system_message(f"Added model: {arg}")
        elif cmd == "/remove":
            arg = parts[1].strip() if len(parts) > 1 else ""
            if arg in self.active_models and len(self.active_models) > 1:
                self.active_models.remove(arg)
                self._model_states.pop(arg, None)
                self._refresh_model_status()
                tags = "  ".join(_model_display(m) for m in self.active_models)
                self.query_one("#header-models", Label).update(tags)
                self._add_system_message(f"Removed model: {arg}")
        elif cmd == "/export":
            if not self.conversation:
                self._add_system_message("Nothing to export — chat history is empty.")
            else:
                from datetime import datetime as _dt
                from pathlib import Path
                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"
                out_path.write_text(
                    json.dumps(self.conversation, indent=2, ensure_ascii=False),
                    encoding="utf-8",
                )
                self._add_system_message(f"Exported {len(self.conversation)} messages to {out_path}")
        elif cmd == "/config":
            lines = [
                "Runtime Configuration:",
                f"  Cloud URL:     {self.config.cloud_url}",
                f"  Ollama URL:    {self.config.ollama_url}",
                f"  Runtime port:  {self.config.local_port}",
                f"  Active models: {len(self.active_models)}",
            ]
            self._add_system_message("\n".join(lines))
        elif cmd == "/timer":
            if not self._last_timer:
                self._add_system_message("No model calls yet.")
            else:
                lines = [f"  {_model_display(m)}: {t:.1f}s" for m, t in self._last_timer.items()]
                self._add_system_message("Last response times:\n" + "\n".join(lines))
        elif cmd == "/usage":
            if not self.conversation:
                self._add_system_message("No messages yet — token usage is 0.")
            else:
                total = 0
                per_role: dict[str, int] = {}
                for msg in self.conversation:
                    tokens = len(msg["content"]) // 4
                    total += tokens
                    role = msg["role"]
                    per_role[role] = per_role.get(role, 0) + tokens
                lines = [f"Estimated token usage: {total:,}"]
                for role, count in sorted(per_role.items()):
                    lines.append(f"  {role}: {count:,}")
                self._add_system_message("\n".join(lines))
        elif cmd == "/history":
            if not self.conversation:
                self._add_system_message("No messages yet.")
            else:
                recent = self.conversation[-10:]
                lines = [f"Last {len(recent)} message(s):"]
                for msg in recent:
                    sender = "You" if msg["role"] == "user" else "Assistant"
                    preview = msg["content"][:120].replace("\n", " ")
                    if len(msg["content"]) > 120:
                        preview += "..."
                    lines.append(f"  [{sender}] {preview}")
                self._add_system_message("\n".join(lines))
        elif cmd == "/help":
            lines = [
                "Available commands:",
                "  /help             Show this help message",
                "  /quit (/exit, /q) Exit the chat",
                "  /clear            Clear chat history and orchestration log",
                "  /models           List active models",
                "  /status           Show model and service connection status",
                "  /config           Show runtime configuration",
                "  /add <model>      Add a model (e.g. /add ollama:mistral)",
                "  /remove <model>   Remove a model",
                "  /export           Export chat history to JSON file",
                "  /timer            Show last response times per model",
                "  /usage            Estimate token usage for the session",
                "  /history          Show the last 10 messages",
            ]
            self._add_system_message("\n".join(lines))
        elif cmd == "/status":
            lines = ["Connected Models:"]
            for m in self.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(self.config.ollama_url)
                else:
                    ok = True
                dot = "\u25cf" if ok else "\u25cb"
                state = "online" if ok else "offline"
                code_tag = " [code]" if m in _CODE_MODELS else ""
                lines.append(f"  {dot} {_model_display(m)}{code_tag}  {state}")
            lines.append("")
            lines.append("Services:")
            ollama_up = _ollama_is_running(self.config.ollama_url)
            ollama_state = "running" if ollama_up else "not running"
            lines.append(f"  {'●' if ollama_up else '○'} Ollama  {ollama_state}  ({self.config.ollama_url})")
            self._add_system_message("\n".join(lines))
        else:
            self._add_system_message("Unknown command. Type /help for a list of available commands.")

    def action_clear_chat(self) -> None:
        feed = self.query_one("#chat-feed", VerticalScroll)
        feed.remove_children()
        orch = self.query_one("#orch-log", VerticalScroll)
        orch.remove_children()
        self.conversation.clear()
        self._last_timer.clear()
        for m in self._model_states:
            self._model_states[m] = "idle"
        self._refresh_model_status()
        self._add_system_message("Chat cleared.")

    def action_focus_input(self) -> None:
        self.query_one("#chat-input", Input).focus()

    # ── Strategy execution (runs in worker thread) ────────────────

    def _execute_strategy(self, user_input: str, task_type: str, strategy: str, models: list[str]) -> None:
        if strategy == "debate":
            self._run_debate(user_input, models)
        elif strategy == "refine":
            self._run_refine(user_input, models)
        elif strategy == "fan_out":
            self._run_fan_out(user_input, models)
        else:
            self._run_single(user_input, models)

        # Save last response to conversation memory
        # (The individual runners handle posting messages)

    def _invoke_model(self, model: str, prompt: str) -> tuple[str, float]:
        """Call a model and return (response, elapsed_seconds). Updates status panel."""
        self._set_model_state(model, "running")
        start = time.time()
        result = _call_model(model, prompt, self.conversation, self.config.ollama_url, self.config.ollama_timeout)
        elapsed = time.time() - start
        self._set_model_state(model, "done")
        self._last_timer[model] = elapsed
        return result, elapsed

    def _run_single(self, user_input: str, models: list[str]) -> None:
        model = models[0]
        self._post_decision(f"Single: {_model_display(model)}")
        result, elapsed = self._invoke_model(model, user_input)
        self._post_assistant_message(_model_display(model), result, meta=f"{elapsed:.1f}s", model=model)
        self.conversation.append({"role": "assistant", "content": result[:1000]})
        self._reset_model_states()

    def _run_debate(self, 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]

        # Set waiting states
        for m in [model_a, model_b, judge]:
            self._set_model_state(m, "waiting")

        # PRO
        self._post_decision(f"PRO: {_model_display(model_a)}")
        pro, t1 = self._invoke_model(model_a, f"Present a STRONG CASE IN FAVOR. Best arguments and reasoning.\n\nTopic: {user_input}")
        self._post_assistant_message(f"PRO  {_model_display(model_a)}", pro, meta=f"{t1:.1f}s", model=model_a)

        # CON
        self._post_decision(f"CON: {_model_display(model_b)}")
        con, t2 = self._invoke_model(model_b, f"Present a STRONG CASE AGAINST. Weaknesses and counterarguments.\n\nTopic: {user_input}")
        self._post_assistant_message(f"CON  {_model_display(model_b)}", con, meta=f"{t2:.1f}s", model=model_b)

        # VERDICT
        self._post_decision(f"JUDGE: {_model_display(judge)}")
        verdict, t3 = self._invoke_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.",
        )
        self._post_assistant_message(f"VERDICT  {_model_display(judge)}", verdict, meta=f"{t3:.1f}s", model=judge)
        self.conversation.append({"role": "assistant", "content": verdict[:1000]})
        self._reset_model_states()

    def _run_refine(self, 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]

        for m in [drafter, critic, reviser]:
            self._set_model_state(m, "waiting")

        # Draft
        self._post_decision(f"DRAFT: {_model_display(drafter)}")
        draft, t1 = self._invoke_model(drafter, f"Produce a solid first draft.\n\n{user_input}")
        self._post_assistant_message(f"DRAFT  {_model_display(drafter)}", draft, meta=f"{t1:.1f}s", model=drafter)

        # Critique
        self._post_decision(f"CRITIQUE: {_model_display(critic)}")
        critique, t2 = self._invoke_model(
            critic,
            f"Review critically. Strengths, weaknesses, specific improvements.\n\nRequest: {user_input}\n\nDraft:\n{draft}",
        )
        self._post_assistant_message(f"CRITIQUE  {_model_display(critic)}", critique, meta=f"{t2:.1f}s", model=critic)

        # Revise
        self._post_decision(f"FINAL: {_model_display(reviser)}")
        revision, t3 = self._invoke_model(
            reviser,
            f"Revise incorporating the critique. Produce an improved version.\n\nRequest: {user_input}\n\nDraft:\n{draft}\n\nCritique:\n{critique}",
        )
        self._post_assistant_message(f"FINAL  {_model_display(reviser)}", revision, meta=f"{t3:.1f}s", model=reviser)
        self.conversation.append({"role": "assistant", "content": revision[:1000]})
        self._reset_model_states()

    def _run_fan_out(self, user_input: str, models: list[str]) -> None:
        for m in models:
            self._set_model_state(m, "waiting")

        results_map: dict[str, str] = {}
        timings_map: dict[str, float] = {}
        lock = threading.Lock()

        self._post_decision(f"FAN-OUT: {len(models)} models")

        def _threaded(m: str) -> None:
            self._set_model_state(m, "running")
            s = time.time()
            out = _call_model(m, user_input, self.conversation, self.config.ollama_url, self.config.ollama_timeout)
            elapsed = time.time() - s
            with lock:
                results_map[m] = out
                timings_map[m] = elapsed
            self._set_model_state(m, "done")

        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:
            self._last_timer[m] = timings_map.get(m, 0)
            self._post_assistant_message(
                _model_display(m), results_map.get(m, ""), meta=f"{timings_map.get(m, 0):.1f}s", model=m,
            )

        # Synthesize if 2+ models responded
        if len(models) >= 2:
            synth_model = "claude" if "claude" in self.active_models else models[0]
            self._post_decision(f"SYNTHESIS: {_model_display(synth_model)}")
            parts = "\n\n".join(f"=== {m} ===\n{results_map.get(m, '')}" for m in models)
            synthesis, ts = self._invoke_model(
                synth_model,
                f"Multiple perspectives on: {user_input}\n\n{parts}\n\n"
                f"Synthesize into one coherent response. Best insights from each.",
            )
            self._post_assistant_message(f"SYNTHESIS  {_model_display(synth_model)}", synthesis, meta=f"{ts:.1f}s", model=synth_model)
            self.conversation.append({"role": "assistant", "content": synthesis[:1000]})
        elif results_map:
            first = next(iter(results_map.values()))
            self.conversation.append({"role": "assistant", "content": first[:1000]})

        self._reset_model_states()

    def _reset_model_states(self) -> None:
        """Set all models back to idle after a strategy completes."""
        for m in self._model_states:
            self._model_states[m] = "idle"
        self.call_from_thread(self._refresh_model_status)


# ── Entry point ──────────────────────────────────────────────────────


def main() -> None:
    from runtime.config import load_config
    app = BrynqChat(load_config())
    app.run()


if __name__ == "__main__":
    main()
