from __future__ import annotations

import asyncio
import json
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import uuid
import base64
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
from runtime.intent import classify_intent
from runtime.rooms.profile import local_profile
from runtime.rooms.project_store import ProjectStore
from runtime.rooms.room_manager import room_manager
from runtime.rooms.chat_store import RoomChatStore
from runtime.rooms import project_sync

_CODE_MODELS = {"claude", "gemini"}

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 []

def _classify(text: str) -> tuple[str, str]:
    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"

_MAX_FILE_SIZE = 500 * 1024

_FILE_PATH_RE = re.compile(
    r"(?:^|(?<=\s))(?!https?://)(?!/[a-z]+(?:\s|$))((?:[A-Za-z]:)?[^\s\"'<>|*?]+)(?=\s|$)", re.MULTILINE
)

def _scan_file_paths(text: str) -> list[Path]:
    found: list[Path] = []
    seen: set[str] = set()
    for m in _FILE_PATH_RE.finditer(text):
        token = m.group(1)
        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:
    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]:
    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]

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

def _call_claude(prompt: str, conversation: list[dict[str, str]]) -> str:
    context_parts = []
    for msg in conversation[-6:]:
        role = "User" if msg["role"] == "user" else "Assistant"
        context_parts.append(f"{role}: {msg['content'][:300]}")
    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

    api_key = os.environ.get("ANTHROPIC_API_KEY", "")
    if api_key:
        try:
            body = json.dumps({
                "model": "claude-sonnet-4-6-20250514", "max_tokens": 4096,
                "messages": [{"role": "user", "content": full_prompt}],
            }).encode("utf-8")
            req = urllib.request.Request(
                "https://api.anthropic.com/v1/messages", data=body,
                headers={"Content-Type": "application/json", "x-api-key": api_key, "anthropic-version": "2023-06-01"},
            )
            with urllib.request.urlopen(req, timeout=600) as resp:
                result = json.loads(resp.read().decode("utf-8"))
                return result.get("content", [{}])[0].get("text", "")
        except Exception:
            pass

    for cmd_args in [
        ["claude", "-p", full_prompt, "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash"],
        ["claude", "-p", full_prompt],
    ]:
        try:
            r = subprocess.run(
                cmd_args, capture_output=True, text=True, timeout=None,
                encoding="utf-8", errors="replace", shell=(sys.platform == "win32")
            )
            if r.returncode == 0 and r.stdout.strip(): return r.stdout.strip()
            if r.returncode != 0 and "--allowedTools" in cmd_args: continue
            if r.returncode != 0: return f"[error: {r.stderr.strip()[:200]}]"
        except FileNotFoundError: return "[Claude CLI not found. Install it or set ANTHROPIC_API_KEY]"
        except subprocess.TimeoutExpired: return "[timed out - try a shorter prompt]"
        except Exception:
            if "--allowedTools" in cmd_args: continue
            return "[error calling Claude]"
    return "[Claude did not respond]"

def _call_gemini(prompt: str) -> str:
    full_prompt = f"[System: {_BRYNQ_SYSTEM}]\n\n{prompt}"
    gemini_cmd = "gemini.cmd" if sys.platform == "win32" else "gemini"
    for cmd_args in [
        [gemini_cmd, "-p", full_prompt, "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash"],
        [gemini_cmd, "-p", full_prompt],
    ]:
        try:
            r = subprocess.run(
                cmd_args, capture_output=True, text=True, timeout=None,
                encoding="utf-8", errors="replace",
            )
            if r.returncode == 0 and r.stdout.strip(): return r.stdout.strip()
            if r.returncode != 0 and "--allowedTools" in cmd_args: continue
            if r.returncode != 0: return f"[error: {r.stderr.strip()[:200]}]"
        except FileNotFoundError: return "[Gemini CLI not found. Install: npm install -g @google/gemini-cli]"
        except subprocess.TimeoutExpired: return "[timed out - try a shorter prompt]"
        except Exception:
            if "--allowedTools" in cmd_args: continue
            return "[error calling Gemini]"
    return "[Gemini did not respond]"

def _call_ollama(prompt: str, model: str, ollama_url: str, timeout: float) -> str:
    try:
        body = {
            "model": model, "stream": False,
            "messages": [{"role": "system", "content": _BRYNQ_SYSTEM}, {"role": "user", "content": prompt}],
        }
        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]"

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

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

class ChatMessage(Static):
    def __init__(self, sender: str, content: str, msg_type: str = "ai", timestamp: str = "", css_class: str = "msg-ai"):
        super().__init__(classes=css_class)
        self._sender = sender
        self._content = content
        self._timestamp = timestamp

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

class RoomListItem(Static):
    def __init__(self, name: str, member_count: int, is_active: bool = False):
        classes = "room-item room-active" if is_active else "room-item"
        super().__init__(classes=classes)
        self._name = name
        self._count = member_count
        self._is_active = is_active

    def compose(self) -> ComposeResult:
        dot = "*" if self._is_active else " "
        yield Label(f"{dot} {self._name} ({self._count})", classes="room-label")

class FileListItem(Static):
    def __init__(self, relative_path: str, size_bytes: int):
        super().__init__(classes="file-item")
        self._path = relative_path
        self._size = size_bytes

    def compose(self) -> ComposeResult:
        yield Label(f"  {self._path}", classes="file-label")

class MemberListItem(Static):
    def __init__(self, username: str, is_online: bool = True):
        classes = "member-online" if is_online else "member-offline"
        super().__init__(classes=classes)
        dot = "*" if is_online else "o"
        self._label = f"{dot} {username}"

    def compose(self) -> ComposeResult:
        yield Label(self._label)

class ModelStatusItem(Static):
    def __init__(self, model_name: str, display_name: str, status: str = "idle"):
        css = f"model-{status}"
        super().__init__(classes=f"model-item {css}")
        icons = {"idle": "--", "active": ">>", "done": "OK", "error": "!!"}
        self._label = f"[{icons.get(status, '--')}] {display_name}"

    def compose(self) -> ComposeResult:
        yield Label(self._label)

class BrynqChat(App):
    TITLE = "BRYNQ"
    CSS = """
Screen { layout: vertical; background: #0f0f1a; }
#body { layout: horizontal; height: 1fr; }
#left-panel { width: 26; background: #12122a; border-right: solid #2a2a4a; }
.section-header { background: #1a1a3a; color: #00d4aa; text-style: bold; text-align: left; width: 100%; height: 1; padding: 0 1; }
.room-item { padding: 0 1; height: 1; color: #8888aa; }
.room-active { color: #e0e0e0; background: #1a2a4a; text-style: bold; }
.file-item { padding: 0 1; height: 1; color: #6688aa; }
.file-label { color: #6688aa; }
#room-list { height: auto; max-height: 50%; }
#project-files { height: 1fr; }
#center-panel { width: 1fr; background: #0f0f1a; }
#center-header { background: #1a3a6e; color: #e0e0e0; text-style: bold; text-align: left; width: 100%; height: 1; padding: 0 1; }
#chat-feed { height: 1fr; overflow-y: auto; padding: 0 1; }
.msg-self { margin: 1 0 0 4; background: #1a2a4a; padding: 1 2; }
.msg-peer { margin: 1 0 0 0; background: #1a1a2e; padding: 1 2; border-left: thick #5599ff; }
.msg-ai { margin: 1 4 0 0; background: #1a1a2e; padding: 1 2; border-left: thick #cc77ff; }
.msg-system { margin: 1 2; background: #0a0a18; padding: 0 2; color: #556677; text-align: center; }
.msg-sender { text-style: bold; margin-bottom: 0; color: #e0e0e0; }
.msg-body { color: #d0d0e0; margin-top: 0; }
#right-panel { width: 22; background: #12122a; border-left: solid #2a2a4a; }
.member-online { padding: 0 1; color: #00d4aa; }
.member-offline { padding: 0 1; color: #555577; }
.model-item { padding: 0 1; }
.model-idle { color: #666688; }
.model-active { color: #ffcc00; }
.model-done { color: #00d4aa; }
.model-error { color: #ff4444; }
#member-list { height: auto; max-height: 50%; }
#model-list { height: 1fr; }
#input-bar { dock: bottom; height: auto; max-height: 4; background: #1a1a2e; border-top: solid #2a2a4a; padding: 0 1; }
#chat-input { width: 100%; background: #0f0f1a; color: #e0e0e0; border: tall #2a2a4a; }
#chat-input:focus { border: tall #00d4aa; }
#input-hint { color: #444466; 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] = {}
        self._last_timer: dict[str, float] = {}
        self._ws_listener_active: bool = False

    def compose(self) -> ComposeResult:
        yield Header()
        with Horizontal(id="body"):
            with Vertical(id="left-panel"):
                yield Label("Rooms", classes="section-header")
                yield VerticalScroll(id="room-list")
                yield Label("Projects", classes="section-header")
                yield VerticalScroll(id="project-files")
            with Vertical(id="center-panel"):
                yield Label("Chat", id="center-header")
                yield VerticalScroll(id="chat-feed")
            with Vertical(id="right-panel"):
                yield Label("Members", classes="section-header")
                yield VerticalScroll(id="member-list")
                yield Label("Models", classes="section-header")
                yield VerticalScroll(id="model-list")
        with Vertical(id="input-bar"):
            yield Input(placeholder="Type a message or /command...", id="chat-input")
            yield Label("/help | /room | /project | /models | /chain", id="input-hint")
        yield Footer()

    def on_mount(self) -> None:
        self._detect_models()
        self._refresh_model_list()
        self._refresh_room_list()
        self._refresh_member_list()
        self._refresh_project_files()
        self._add_system_message("Welcome to Brynq. Type /help for commands.")
        self.query_one("#chat-input", Input).focus()

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

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

    def _detect_models(self) -> None:
        self.active_models.clear()
        if shutil.which("claude") or shutil.which("claude.cmd"):
            self.active_models.append("claude")
        if shutil.which("gemini") or shutil.which("gemini.cmd"):
            self.active_models.append("gemini")
        if os.environ.get("OPENAI_API_KEY"):
            self.active_models.append("chatgpt")
        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}")
        for m in self.active_models:
            self._model_states[m] = "idle"


    def _refresh_room_list(self) -> None:
        container = self.query_one("#room-list", VerticalScroll)
        container.remove_children()
        if room_manager.active_room:
            name = room_manager.active_room.get("name", "Unnamed")
            members = room_manager.active_room.get("members", [])
            count = len(members) if members else 1
            widget = RoomListItem(name, count, is_active=True)
            container.mount(widget)
        else:
            container.mount(Label("  No rooms. Type /room create", classes="room-item"))

    def _refresh_project_files(self) -> None:
        container = self.query_one("#project-files", VerticalScroll)
        container.remove_children()
        store = self._get_project_store()
        if not store:
            container.mount(Label("  No project", classes="file-item"))
            return
        files = store.list_files()
        if not files:
            container.mount(Label("  Empty project", classes="file-item"))
            return
        for f in files:
            container.mount(FileListItem(f["relative_path"], f["size_bytes"]))

    def _refresh_member_list(self) -> None:
        container = self.query_one("#member-list", VerticalScroll)
        container.remove_children()
        if not room_manager.active_room:
            container.mount(Label("  Solo mode", classes="member-offline"))
            return
        prof = local_profile.get()
        container.mount(MemberListItem(prof["username"], is_online=True))
        members = room_manager.active_room.get("members", [])
        for m in members:
            if m.get("user_id") != prof.get("user_id"):
                container.mount(MemberListItem(
                    m.get("username", "Unknown"),
                    is_online=m.get("online", True)
                ))

    def _refresh_model_list(self) -> None:
        container = self.query_one("#model-list", VerticalScroll)
        container.remove_children()
        for m in self.active_models:
            state = self._model_states.get(m, "idle")
            display = _model_display(m)
            container.mount(ModelStatusItem(m, display, state))

    def _set_model_state(self, model: str, state: str) -> None:
        self._model_states[model] = state
        self.call_from_thread(self._refresh_model_list)
        
    def _reset_model_states(self) -> None:
        for m in self._model_states:
            self._model_states[m] = "idle"
        self.call_from_thread(self._refresh_model_list)
        
    def _get_project_store(self) -> ProjectStore | None:
        if room_manager.is_host and room_manager.host_service:
            return getattr(room_manager.host_service, 'project_store', None)
        elif room_manager.client_service:
            return getattr(room_manager.client_service, 'project_store', None)
        return None

    def _in_room(self) -> bool:
        return room_manager.active_room is not None

    def _update_center_header(self) -> None:
        header = self.query_one("#center-header", Label)
        if room_manager.active_room:
            name = room_manager.active_room.get("name", "Room")
            header.update(f"# {name}")
        else:
            header.update("Chat")

    def _add_chat_message(self, sender: str, content: str, msg_type: str = "ai", timestamp: str = "") -> None:
        css_map = {"self": "msg-self", "peer": "msg-peer", "ai": "msg-ai", "system": "msg-system"}
        css_class = css_map.get(msg_type, "msg-ai")
        feed = self.query_one("#chat-feed", VerticalScroll)
        msg = ChatMessage(sender, content, msg_type=msg_type, timestamp=timestamp, css_class=css_class)
        feed.mount(msg)
        msg.scroll_visible()

    def _add_system_message(self, text: str) -> None:
        self._add_chat_message("[System]", text, msg_type="system")

    def _post_chat_message(self, sender: str, content: str, msg_type: str = "ai", timestamp: str = "") -> None:
        self.call_from_thread(self._add_chat_message, sender, content, msg_type, timestamp)

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

    def on_input_submitted(self, event: Input.Submitted) -> None:
        text = event.value.strip()
        if not text:
            return
        event.input.value = ""
        command, original = classify_intent(text)
        if command is not None:
            self._handle_command(command)
        elif self._in_room():
            prof = local_profile.get()
            model, cleaned = self._check_ai_mention(text)
            if model:
                self._add_chat_message(prof["username"], text, msg_type="self")
                room_manager.send_message(text)
                self.run_worker(lambda: self._run_ai_in_room(model, cleaned), thread=True)
            else:
                self._add_chat_message(prof["username"], text, msg_type="self")
                room_manager.send_message(text)
        else:
            if not self.active_models:
                self._add_system_message("No models available. Install one first. Type /help for setup info.")
                return
            self._route_to_ai(text)

    def _check_ai_mention(self, text: str) -> tuple[str | None, str]:
        for model in self.active_models:
            short = model.split(":")[-1]
            if text.lower().startswith(f"@{short} "):
                return model, text[len(short) + 2:]
        return None, text
        
    def _run_ai_in_room(self, model: str, cleaned: str) -> None:
        res, t = self._invoke_model(model, cleaned)
        room_manager.send_message_raw({
            "type": "chat",
            "sender_name": f"[{_model_display(model)}]",
            "sender_id": local_profile.get().get("user_id", ""),
            "content": res,
            "timestamp": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
        })
        self._reset_model_states()

    def _handle_command(self, command: str) -> None:
        parts = command.split(maxsplit=2)
        cmd = parts[0].lower()
        arg1 = parts[1] if len(parts) > 1 else ""
        arg2 = parts[2] if len(parts) > 2 else ""

        if cmd in ("/quit", "/exit", "/q"):
            self.exit()
        elif cmd == "/room":
            self._handle_room_command(arg1, arg2)
        elif cmd == "/project":
            self._handle_project_command(arg1, " ".join(parts[2:]).strip() if len(parts) > 2 else arg2)
        elif cmd == "/models":
            lines = [f"Active models ({len(self.active_models)}):"]
            for m in self.active_models:
                lines.append(f"  {_model_display(m)}")
            self._add_system_message("\n".join(lines))
        elif cmd == "/add":
            if arg1 and arg1 not in self.active_models:
                self.active_models.append(arg1)
                self._model_states[arg1] = "idle"
                self._refresh_model_list()
                self._add_system_message(f"Added model: {arg1}")
        elif cmd == "/remove":
            if arg1 in self.active_models:
                self.active_models.remove(arg1)
                self._model_states.pop(arg1, None)
                self._refresh_model_list()
                self._add_system_message(f"Removed model: {arg1}")
        elif cmd == "/status":
            lines = ["Model status:"]
            for m in self.active_models:
                lines.append(f"  {_model_display(m)}: {self._model_states.get(m, 'idle')}")
            lines.append(f"Ollama running: {_ollama_is_running(self.config.ollama_url)}")
            self._add_system_message("\n".join(lines))
        elif cmd == "/chain":
            prompt = arg1 + " " + arg2
            if cmd == "/chain" and arg1 in ["debate", "refine", "fan_out"]:
                self.run_worker(lambda: self._execute_strategy(arg2, "general", arg1, self.active_models[:3]), thread=True)
            else:
                self.run_worker(lambda: self._execute_strategy(prompt, "general", "single", self.active_models[:1]), thread=True)
        elif cmd == "/help":
            self._add_system_message("Commands: /room, /project, /models, /add, /remove, /status, /chain, /clear, /quit, /search, /timer, /usage")
        elif cmd == "/clear":
            self.action_clear_chat()
        elif cmd == "/export":
            if not self.conversation:
                self._add_system_message("Nothing to export.")
            else:
                stamp = time.strftime("%Y%m%d_%H%M%S")
                Path("exports").mkdir(exist_ok=True)
                out_path = Path("exports") / 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 = [f"Cloud URL: {self.config.cloud_url}", f"Ollama URL: {self.config.ollama_url}", f"Active models: {len(self.active_models)}"]
            self._add_system_message("\n".join(lines))
        elif cmd == "/search":
            self._add_system_message(f"Search for {arg1} {arg2} not fully implemented yet.")
        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":
            total = sum(len(m["content"]) // 4 for m in self.conversation)
            self._add_system_message(f"Estimated token usage: {total:,}")
        else:
            self._add_system_message(f"Unknown command: {cmd}")

    def _handle_room_command(self, action: str, arg: str) -> None:
        if not action:
            self._add_system_message("Usage: /room create <name> | join <code> | status | leave | close")
            return
        try:
            if action == "create":
                room_name = arg or "Untitled Room"
                room = asyncio.run(room_manager.create_room(room_name))
                code = room.get("room_code", "N/A")
                self._add_system_message(f"Room created: \"{room_name}\" -- Code: {code}")
                self._add_system_message(f"Share this code so others can join: {code}")
                self._on_room_joined()
            elif action == "join":
                if not arg:
                    self._add_system_message("Usage: /room join <BRYNQ-XXXX>")
                    return
                room = asyncio.run(room_manager.join_room(arg))
                name = room.get("name", "Unknown")
                self._add_system_message(f"Joined room: \"{name}\"")
                self._on_room_joined()
            elif action == "leave":
                asyncio.run(room_manager.leave_room())
                self._add_system_message("Left the room.")
                self._on_room_left()
            elif action == "close":
                asyncio.run(room_manager.close_room())
                self._add_system_message("Room closed.")
                self._on_room_left()
            elif action == "status":
                if not room_manager.active_room:
                    self._add_system_message("Not in a room.")
                else:
                    r = room_manager.active_room
                    self._add_system_message(
                        f"Room: \"{r.get('name')}\" ({r.get('room_code')})\n"
                        f"Host: {'You' if room_manager.is_host else 'Remote'}\n"
                        f"Members: {len(r.get('members', []))}"
                    )
            else:
                self._add_system_message(f"Unknown room action: {action}")
        except Exception as e:
            self._add_system_message(f"Room error: {e}")

    def _handle_project_command(self, action: str, arg: str) -> None:
        if not action:
            self._add_system_message("Usage: /project create <name> | files | save <path> | open <path> | delete <path> | history | info | list")
            return
        prof = local_profile.get()
        try:
            if action == "create":
                if not room_manager.active_room:
                    self._add_system_message("You must be in a room first. Use /room create <name>.")
                    return
                project_name = arg or "Untitled Project"
                project_id = f"proj_{uuid.uuid4().hex[:12]}"
                store = ProjectStore(project_id)
                meta = {
                    "project_id": project_id, "name": project_name, "room_id": room_manager.active_room.get("room_id", ""),
                    "created_by": prof["user_id"], "created_at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), "description": "",
                }
                store.save_metadata(meta)
                if room_manager.is_host and room_manager.host_service:
                    room_manager.host_service.project_store = store
                    room_manager.host_service.active_project_id = project_id
                elif room_manager.client_service:
                    room_manager.client_service.project_store = store
                    room_manager.client_service.active_project_id = project_id
                announce = {"type": "project_created", "project_id": project_id, "name": project_name, "created_by": prof["user_id"], "created_by_name": prof["username"], "timestamp": meta["created_at"]}
                room_manager.send_message_raw(announce)
                self._add_system_message(f"Project created: \"{project_name}\" ({project_id})")
                self._refresh_project_files()
            elif action == "files":
                store = self._get_project_store()
                if not store:
                    self._add_system_message("No active project. Use /project create first.")
                    return
                files = store.list_files()
                if not files:
                    self._add_system_message("No files yet. Use /project save <path> to add files.")
                else:
                    lines = [f"Project files ({len(files)}):"]
                    for f in files:
                        size_kb = f["size_bytes"] / 1024
                        lines.append(f"  {f['relative_path']}  ({size_kb:.1f} KB)")
                    self._add_system_message("\n".join(lines))
            elif action == "save":
                if not arg:
                    self._add_system_message("Usage: /project save <local-file-path>")
                    return
                store = self._get_project_store()
                if not store:
                    self._add_system_message("No active project.")
                    return
                source = Path(arg).expanduser().resolve()
                if not source.exists() or not source.is_file():
                    self._add_system_message(f"File not found: {arg}")
                    return
                if source.stat().st_size > 5 * 1024 * 1024:
                    self._add_system_message("File exceeds 5MB limit.")
                    return
                content = source.read_bytes()
                rel_path = source.name
                result = store.add_file(rel_path, content, user_id=prof["user_id"], user_name=prof["username"])
                push_msg = {
                    "type": "project_file_push", "project_id": store.project_id, "relative_path": rel_path,
                    "content_b64": base64.b64encode(content).decode("ascii"), "content_hash": result["content_hash"],
                    "size_bytes": result["size_bytes"], "action": result["action"], "user_id": prof["user_id"],
                    "user_name": prof["username"], "timestamp": result["timestamp"],
                }
                room_manager.send_message_raw(push_msg)
                self._add_system_message(f"Saved {rel_path} ({result['size_bytes']} bytes)")
                self._refresh_project_files()
            elif action == "open":
                if not arg:
                    self._add_system_message("Usage: /project open <relative-path>")
                    return
                store = self._get_project_store()
                if not store:
                    self._add_system_message("No active project.")
                    return
                content = store.read_file(arg)
                if content is None:
                    self._add_system_message(f"File not found: {arg}")
                else:
                    try:
                        text = content.decode("utf-8")
                        self._add_chat_message(f"[File: {arg}]", text, msg_type="system")
                    except UnicodeDecodeError:
                        self._add_system_message(f"Binary file ({len(content)} bytes). Cannot display.")
            elif action == "delete":
                if not arg:
                    self._add_system_message("Usage: /project delete <relative-path>")
                    return
                store = self._get_project_store()
                if not store:
                    self._add_system_message("No active project.")
                    return
                deleted = store.delete_file(arg, user_id=prof["user_id"], user_name=prof["username"])
                if deleted:
                    del_msg = {"type": "project_file_deleted", "project_id": store.project_id, "relative_path": arg, "user_id": prof["user_id"], "user_name": prof["username"], "timestamp": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}
                    room_manager.send_message_raw(del_msg)
                    self._add_system_message(f"Deleted: {arg}")
                    self._refresh_project_files()
                else:
                    self._add_system_message(f"File not found: {arg}")
            elif action == "history":
                store = self._get_project_store()
                if not store:
                    self._add_system_message("No active project.")
                    return
                limit = int(arg) if arg.isdigit() else 20
                changes = store.get_history(limit=limit)
                if not changes:
                    self._add_system_message("No changes yet.")
                else:
                    lines = [f"Last {len(changes)} change(s):"]
                    for c in changes:
                        ts = c["timestamp"][:19]
                        lines.append(f"  {ts}  {c['action']:6s}  {c['relative_path']}  by {c['user_name']}")
                    self._add_system_message("\n".join(lines))
            elif action == "info":
                store = self._get_project_store()
                if not store:
                    self._add_system_message("No active project.")
                    return
                meta = store.load_metadata()
                files = store.list_files()
                total_bytes = sum(f["size_bytes"] for f in files)
                self._add_system_message(f"Project: {meta.get('name', 'Unknown')}\n  ID: {meta.get('project_id', 'N/A')}\n  Files: {len(files)}\n  Total size: {total_bytes / 1024:.1f} KB")
            elif action == "list":
                projects = ProjectStore.list_projects()
                if not projects:
                    self._add_system_message("No local projects found.")
                else:
                    lines = [f"{len(projects)} project(s):"]
                    for p in projects:
                        lines.append(f"  {p.get('name', 'Untitled')}  ({p.get('project_id', 'N/A')})")
                    self._add_system_message("\n".join(lines))
            else:
                self._add_system_message(f"Unknown project action: {action}")
        except Exception as e:
            self._add_system_message(f"Project error: {e}")


    def _on_room_joined(self) -> None:
        self._refresh_room_list()
        self._refresh_member_list()
        self._update_center_header()
        self._start_ws_listener()
        if room_manager.is_host and room_manager.host_service:
            history = room_manager.host_service.chat_store.get_history()
            for msg in history:
                self._render_ws_chat_message(msg)
            original_broadcast = room_manager.host_service.broadcast
            async def _patched_broadcast(message):
                await original_broadcast(message)
                self.call_from_thread(self._handle_ws_message, message)
            room_manager.host_service.broadcast = _patched_broadcast

    def _on_room_left(self) -> None:
        self._ws_listener_active = False
        self._refresh_room_list()
        self._refresh_member_list()
        self._refresh_project_files()
        self._update_center_header()

    def _start_ws_listener(self):
        self._ws_listener_active = True
        self.run_worker(self._ws_listener_loop, thread=True, exclusive=False)

    def _ws_listener_loop(self):
        loop = asyncio.new_event_loop()
        async def _listen():
            while self._ws_listener_active:
                client = room_manager.client_service
                if client and client.ws and getattr(client, '_connected', False):
                    try:
                        msg = await asyncio.wait_for(client.ws.recv(), timeout=0.5)
                        data = json.loads(msg)
                        self.call_from_thread(self._handle_ws_message, data)
                    except asyncio.TimeoutError:
                        pass
                    except Exception:
                        await asyncio.sleep(1)
                else:
                    await asyncio.sleep(0.5)
        loop.run_until_complete(_listen())

    def _handle_ws_message(self, data: dict) -> None:
        msg_type = data.get("type")
        if msg_type == "chat":
            self._render_ws_chat_message(data)
        elif msg_type == "system":
            content = data.get("content", "")
            self._add_system_message(content)
            if "connected" in content or "disconnected" in content:
                self._refresh_member_list()
        elif msg_type == "history":
            messages = data.get("messages", [])
            for msg in messages:
                self._render_ws_chat_message(msg)
        elif msg_type == "project_sync":
            self._refresh_project_files()
        elif msg_type == "project_file_push":
            rel_path = data.get("relative_path", "")
            user_name = data.get("user_name", "")
            action = data.get("action", "sync")
            self._add_system_message(f"{rel_path} {action}d by {user_name}")
            self._refresh_project_files()
        elif msg_type == "project_file_deleted":
            rel_path = data.get("relative_path", "")
            user_name = data.get("user_name", "")
            self._add_system_message(f"{rel_path} deleted by {user_name}")
            self._refresh_project_files()
        elif msg_type == "project_created":
            name = data.get("name", "Unknown")
            creator = data.get("created_by_name", "Someone")
            self._add_system_message(f"Project \"{name}\" created by {creator}")
            self._refresh_project_files()
        elif msg_type == "task_event":
            content = data.get("content", data.get("description", "Task update"))
            self._add_system_message(content)

    def _render_ws_chat_message(self, data: dict) -> None:
        prof = local_profile.get()
        sender_name = data.get("sender_name", "Unknown")
        sender_id = data.get("sender_id", "")
        content = data.get("content", "")
        timestamp = data.get("timestamp", "")
        if sender_id == prof.get("user_id"):
            return
        ai_names = {"Claude", "Gemini", "ChatGPT", "Llama", "Mistral", "AI"}
        if sender_name in ai_names or sender_name.startswith("["):
            self._add_chat_message(f"[{sender_name}]", content, msg_type="ai", timestamp=timestamp[:5] if timestamp else "")
        else:
            self._add_chat_message(sender_name, content, msg_type="peer", timestamp=timestamp[:5] if timestamp else "")

    def _route_to_ai(self, text: str) -> None:
        task_type, strategy = _classify(text)
        models = _pick_models(task_type, strategy, self.active_models)
        self._add_chat_message("You", text, msg_type="self")
        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.conversation.append({"role": "user", "content": text})
        
        if task_type == "action":
            result = _try_direct_action(text)
            if result:
                self._add_chat_message("[Brynq]", result, msg_type="system")
                self.conversation.append({"role": "assistant", "content": result})
                return
                
        self.run_worker(lambda: self._execute_strategy(prompt, task_type, strategy, models), thread=True, exclusive=True)

    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)

    def _invoke_model(self, model: str, prompt: str) -> tuple[str, float]:
        from runtime.executor.pipeline.model_router import call_model
        self._set_model_state(model, "active")
        start = time.time()
        # Convert conversation format if needed
        history = [(m.get("role", "user"), "Unknown", m.get("content", "")) for m in self.conversation]
        result = call_model(model, prompt, chat_history=history, config=self.config)
        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_system_message(f"Strategy: Single via {_model_display(model)}")
        result, elapsed = self._invoke_model(model, user_input)
        self._post_chat_message(f"[{_model_display(model)}]", result, msg_type="ai")
        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]

        self._post_system_message(f"Strategy: Debate ({_model_display(model_a)} vs {_model_display(model_b)})")
        q1 = queue.Queue()
        q2 = queue.Queue()

        def t1(): q1.put(self._invoke_model(model_a, f"Argue FOR the following prompt. Be concise.\n{user_input}"))
        def t2(): q2.put(self._invoke_model(model_b, f"Argue AGAINST the following prompt. Be concise.\n{user_input}"))

        th1, th2 = threading.Thread(target=t1), threading.Thread(target=t2)
        th1.start(); th2.start()
        th1.join(); th2.join()

        res_a, t_a = q1.get()
        res_b, t_b = q2.get()
        self._post_chat_message(f"[{_model_display(model_a)} (PRO)]", res_a, msg_type="ai")
        self._post_chat_message(f"[{_model_display(model_b)} (CON)]", res_b, msg_type="ai")
        self._reset_model_states()

        self._post_system_message(f"Judging via {_model_display(judge)}...")
        judge_prompt = f"Original prompt: {user_input}\n\nArgument FOR:\n{res_a}\n\nArgument AGAINST:\n{res_b}\n\nProvide a final synthesized answer."
        res_j, t_j = self._invoke_model(judge, judge_prompt)
        self._post_chat_message(f"[{_model_display(judge)} (JUDGE)]", res_j, msg_type="ai")
        self.conversation.append({"role": "assistant", "content": res_j[:1000]})
        self._reset_model_states()

    def _run_refine(self, user_input: str, models: list[str]) -> None:
        drafter = models[0]
        critique = models[1] if len(models) > 1 else models[0]
        refiner = models[2] if len(models) > 2 else models[0]

        self._post_system_message(f"Strategy: Refine (Draft: {_model_display(drafter)})")
        draft, _ = self._invoke_model(drafter, user_input)
        self._post_chat_message(f"[{_model_display(drafter)} (DRAFT)]", draft, msg_type="ai")
        self._reset_model_states()

        self._post_system_message(f"Refine (Critique: {_model_display(critique)})")
        crit_prompt = f"Original goal: {user_input}\n\nDraft:\n{draft}\n\nCritique this draft. What is missing or wrong? Be concise."
        crit, _ = self._invoke_model(critique, crit_prompt)
        self._post_chat_message(f"[{_model_display(critique)} (CRITIQUE)]", crit, msg_type="ai")
        self._reset_model_states()

        self._post_system_message(f"Refine (Final: {_model_display(refiner)})")
        final_prompt = f"Original goal: {user_input}\n\nDraft:\n{draft}\n\nCritique:\n{crit}\n\nProvide the final improved version."
        final_res, _ = self._invoke_model(refiner, final_prompt)
        self._post_chat_message(f"[{_model_display(refiner)} (FINAL)]", final_res, msg_type="ai")
        self.conversation.append({"role": "assistant", "content": final_res[:1000]})
        self._reset_model_states()

    def _run_fan_out(self, user_input: str, models: list[str]) -> None:
        self._post_system_message(f"Strategy: Fan-Out ({len(models)} models)")
        qs = [queue.Queue() for _ in models]

        def call_m(m: str, q: queue.Queue):
            q.put(self._invoke_model(m, user_input))

        threads = [threading.Thread(target=call_m, args=(m, q)) for m, q in zip(models, qs)]
        for th in threads: th.start()
        for th in threads: th.join()

        for m, q in zip(models, qs):
            res, t = q.get()
            self._post_chat_message(f"[{_model_display(m)}]", res, msg_type="ai")
        self._reset_model_states()

def run_tui(config: RuntimeConfig) -> None:
    app = BrynqChat(config)
    app.run()

if __name__ == "__main__":
    _test_config = RuntimeConfig(ollama_url="http://localhost:11434", cloud_url="http://localhost:8000")
    run_tui(_test_config)

