"""
BMO Interactive CLI Client
Provides a premium terminal experience inspired by high-end AI agents (Claude Code, Aider).
Integrates with the unified BMOEngine backend.
"""

import asyncio
import os
import sys
import threading
import logging

logger = logging.getLogger(__name__)
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.styles import Style
from prompt_toolkit.application import Application
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import Layout, HSplit, VSplit, Window
from prompt_toolkit.layout.controls import FormattedTextControl, BufferControl
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.widgets import Frame


from core.bmo_engine import BMOEngine
from rich.panel import Panel
from rich.rule import Rule
from core.goal_runner import GoalRunner
from core.budget_tracker import BudgetTracker
from core.cli_renderer import (
    print_banner,
    print_assistant_message,
    print_system_status,
    print_sessions_list,
    print_error,
    print_success,
    print_info,
    bmo_spinner
)

from config.settings import OWNER_ID
# Configuration: Default admin user chat ID
CHAT_ID = int(os.getenv("TELEGRAM_BOT_TOKEN_ADMIN_ID", str(OWNER_ID)))
USER_ID = CHAT_ID
USERNAME = "cli_user"

# Capture where the user launched bmo from — this is the project context
USER_CWD = os.getcwd()

# CLI Commands lists for autocomplete
COMMANDS = [
    "/help",
    "/new",
    "/sessions",
    "/model",
    "/clear",
    "/status",
    "/share",
    "/exit",
    "/goal",
    "/budget",
    "/bfp",
    "/web",
    "/interrupt",
    "/stop",
]

completer = WordCompleter(COMMANDS, ignore_case=True)

# Styling for the prompt_toolkit interface
cli_style = Style.from_dict({
    "bottom-toolbar": "bg:#222222 #00ffff bold",
    "bottom-toolbar.text": "#ffffff",
    "prompt": "#00ffff bold",
})

from prompt_toolkit.filters import Condition

class ModelSelectorTUI:
    def __init__(self, raw_providers, favorites, save_fav_callback):
        self.raw_providers = raw_providers
        self.favorites = set(favorites)
        self.save_fav_callback = save_fav_callback
        
        self.provider_list = []
        for p in raw_providers:
            p_models = []
            raw_models = p.get("models", {})
            if isinstance(raw_models, dict):
                for m_id, m_data in raw_models.items():
                    p_models.append({
                        "id": m_id,
                        "name": m_data.get("name", m_id) if isinstance(m_data, dict) else m_id
                    })
            elif isinstance(raw_models, list):
                for m in raw_models:
                    p_models.append({
                        "id": m.get("id") if isinstance(m, dict) else m,
                        "name": m.get("name") or m.get("id") if isinstance(m, dict) else m
                    })
            if p_models:
                self.provider_list.append({
                    "id": p.get("id"),
                    "name": p.get("name") or p.get("id"),
                    "models": p_models
                })

        self.filtered_providers = list(self.provider_list)
        self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
        
        self.selected_prov_idx = 0
        self.selected_model_idx = 0
        self.focus_pane = "search"  # "search", "providers", or "models"
        self.search_text = ""
        self.selected_model_id = None
        self.selected_provider_id = None
        
        # Build UI components
        self.search_buffer = Buffer()
        
        @self.search_buffer.on_text_changed.add_handler
        def _on_text_changed(buf):
            self.search_text = buf.text.lower()
            self.filtered_providers = []
            for p in self.provider_list:
                if self.search_text in p["name"].lower() or self.search_text in p["id"].lower():
                    self.filtered_providers.append(p)
            # Sort: favorites first, then alphabetically by name
            self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
            self.selected_prov_idx = 0
            self.selected_model_idx = 0
            
        self.kb = KeyBindings()
        self._setup_keybindings()
        
    def _setup_keybindings(self):
        in_search = Condition(lambda: self.focus_pane == "search")
        in_providers = Condition(lambda: self.focus_pane == "providers")
        in_models = Condition(lambda: self.focus_pane == "models")

        # Global exit bindings
        @self.kb.add("c-c")
        def _global_exit(event):
            event.app.exit(result=None)

        # Search Pane Keybindings
        @self.kb.add("escape", filter=in_search)
        def _search_exit(event):
            event.app.exit(result=None)

        @self.kb.add("down", filter=in_search)
        @self.kb.add("enter", filter=in_search)
        @self.kb.add("tab", filter=in_search)
        def _search_go_to_providers(event):
            if self.filtered_providers:
                self.focus_pane = "providers"
                event.app.layout.focus(self.providers_window)

        # Providers Pane Keybindings
        @self.kb.add("escape", filter=in_providers)
        @self.kb.add("left", filter=in_providers)
        def _providers_exit(event):
            self.focus_pane = "search"
            event.app.layout.focus(self.search_window)

        @self.kb.add("up", filter=in_providers)
        def _providers_up(event):
            if self.filtered_providers:
                self.selected_prov_idx = (self.selected_prov_idx - 1) % len(self.filtered_providers)
                self.selected_model_idx = 0

        @self.kb.add("down", filter=in_providers)
        def _providers_down(event):
            if self.filtered_providers:
                self.selected_prov_idx = (self.selected_prov_idx + 1) % len(self.filtered_providers)
                self.selected_model_idx = 0

        @self.kb.add("space", filter=in_providers)
        def _providers_toggle_fav(event):
            if self.filtered_providers:
                prov = self.filtered_providers[self.selected_prov_idx]
                prov_id = prov["id"]
                if prov_id in self.favorites:
                    self.favorites.remove(prov_id)
                else:
                    self.favorites.add(prov_id)
                self.save_fav_callback(list(self.favorites))
                
                # Re-sort list and update index to stay focused on same item
                self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
                for idx, p in enumerate(self.filtered_providers):
                    if p["id"] == prov_id:
                        self.selected_prov_idx = idx
                        break

        @self.kb.add("enter", filter=in_providers)
        @self.kb.add("right", filter=in_providers)
        @self.kb.add("tab", filter=in_providers)
        def _providers_select(event):
            if self.filtered_providers:
                prov = self.filtered_providers[self.selected_prov_idx]
                if prov["models"]:
                    self.focus_pane = "models"
                    self.selected_model_idx = 0
                    event.app.layout.focus(self.models_window)

        # Models Pane Keybindings
        @self.kb.add("escape", filter=in_models)
        @self.kb.add("left", filter=in_models)
        @self.kb.add("tab", filter=in_models)
        def _models_back(event):
            self.focus_pane = "providers"
            event.app.layout.focus(self.providers_window)

        @self.kb.add("up", filter=in_models)
        def _models_up(event):
            if self.filtered_providers:
                prov = self.filtered_providers[self.selected_prov_idx]
                if prov["models"]:
                    self.selected_model_idx = (self.selected_model_idx - 1) % len(prov["models"])

        @self.kb.add("down", filter=in_models)
        def _models_down(event):
            if self.filtered_providers:
                prov = self.filtered_providers[self.selected_prov_idx]
                if prov["models"]:
                    self.selected_model_idx = (self.selected_model_idx + 1) % len(prov["models"])

        @self.kb.add("enter", filter=in_models)
        def _models_select(event):
            if self.filtered_providers:
                prov = self.filtered_providers[self.selected_prov_idx]
                if prov["models"]:
                    model = prov["models"][self.selected_model_idx]
                    self.selected_provider_id = prov["id"]
                    self.selected_model_id = model["id"]
                    event.app.exit(result=(self.selected_provider_id, self.selected_model_id))

    def _get_visible_slice(self, items, selected_idx, max_visible=12):
        total = len(items)
        if total <= max_visible:
            return items, 0
        
        half = max_visible // 2
        start = selected_idx - half
        if start < 0:
            start = 0
        elif start + max_visible > total:
            start = total - max_visible
            
        end = start + max_visible
        return items[start:end], start

    def _render_providers(self):
        if not self.filtered_providers:
            return [("", " No matching providers found.\n")]
            
        visible_providers, start_idx = self._get_visible_slice(
            self.filtered_providers, 
            self.selected_prov_idx, 
            max_visible=15
        )
        
        lines = []
        if start_idx > 0:
            lines.append(("class:scroll-indicator", "   ▲ ... more providers above ...\n"))
            
        for idx, prov in enumerate(visible_providers):
            actual_idx = start_idx + idx
            is_selected = (actual_idx == self.selected_prov_idx)
            is_fav = prov["id"] in self.favorites
            
            prefix = " > " if is_selected else "   "
            fav_star = "⭐" if is_fav else "  "
            
            if is_selected:
                if self.focus_pane == "providers":
                    style = "class:selected"
                else:
                    style = "class:selected-inactive"
            else:
                style = ""
                
            lines.append((style, f"{prefix}{fav_star} {prov['name']} ({prov['id']})\n"))
            
        if start_idx + len(visible_providers) < len(self.filtered_providers):
            lines.append(("class:scroll-indicator", "   ▼ ... more providers below ...\n"))
            
        return lines

    def _render_models(self):
        if not self.filtered_providers:
            return [("", " No provider selected.\n")]
            
        prov = self.filtered_providers[self.selected_prov_idx]
        if not prov["models"]:
            return [("", " No models available.\n")]
            
        visible_models, start_idx = self._get_visible_slice(
            prov["models"], 
            self.selected_model_idx, 
            max_visible=15
        )
        
        lines = []
        if start_idx > 0:
            lines.append(("class:scroll-indicator", "   ▲ ... more models above ...\n"))
            
        for idx, model in enumerate(visible_models):
            actual_idx = start_idx + idx
            is_selected = (actual_idx == self.selected_model_idx)
            
            prefix = " > " if is_selected else "   "
            if is_selected:
                if self.focus_pane == "models":
                    style = "class:selected"
                else:
                    style = "class:selected-inactive"
            else:
                style = ""
                
            lines.append((style, f"{prefix}{model['id']} ({model['name']})\n"))
            
        if start_idx + len(visible_models) < len(prov["models"]):
            lines.append(("class:scroll-indicator", "   ▼ ... more models below ...\n"))
            
        return lines

    def _get_search_title(self):
        if self.focus_pane == "search":
            return [("class:frame.label.active", " Search Provider (Start typing...) ")]
        return [("class:frame.label", " Search Provider ")]

    def _get_providers_title(self):
        if self.focus_pane == "providers":
            return [("class:frame.label.active", " Providers List (⭐ Favorites) ")]
        return [("class:frame.label", " Providers List ")]

    def _get_models_title(self):
        if self.focus_pane == "models":
            return [("class:frame.label.active", " Available Models ")]
        return [("class:frame.label", " Available Models ")]

    def _render_status(self):
        if self.focus_pane == "search":
            return [("class:help", " [Down/Enter] Navigate Providers | [Esc] Cancel Selection")]
        elif self.focus_pane == "providers":
            return [("class:help", " [Up/Down] Choose Provider | [Space] Toggle Favorite ⭐ | [Enter/Right] Show Models | [Esc/Left] Back to Search")]
        else:
            return [("class:help", " [Up/Down] Choose Model | [Enter] Confirm Model Selection | [Esc/Left] Back to Providers")]

    def get_app(self):
        style = Style.from_dict({
            "selected": "bg:#00ffff #000000 bold",
            "selected-inactive": "bg:#333333 #ffffff italic",
            "help": "bg:#222222 #00ffff bold",
            "frame.label": "#888888",
            "frame.label.active": "#00ffff bold",
            "scroll-indicator": "#555555 italic",
        })
        
        self.search_window = Window(content=BufferControl(buffer=self.search_buffer), height=1)
        self.providers_window = Window(content=FormattedTextControl(self._render_providers, focusable=True))
        self.models_window = Window(content=FormattedTextControl(self._render_models, focusable=True))
        
        self.layout = Layout(
            HSplit([
                Frame(self.search_window, title=self._get_search_title),
                VSplit([
                    Frame(self.providers_window, title=self._get_providers_title),
                    Frame(self.models_window, title=self._get_models_title),
                ]),
                Window(content=FormattedTextControl(self._render_status), height=1),
            ]),
            focused_element=self.search_window
        )
        
        app = Application(
            layout=self.layout,
            key_bindings=self.kb,
            style=style,
            full_screen=True
        )
        return app

def is_bot_running():
    import socket
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(0.2)
            s.connect(("127.0.0.1", 4097))
            return True
    except Exception:
        return False

def start_bot_background():
    import subprocess
    import sys
    import os
    creationflags = 0
    if sys.platform == "win32":
        creationflags = 0x08000000
    try:
        subprocess.Popen(
            [sys.executable, "main.py"],
            cwd=os.path.dirname(os.path.abspath(__file__)),
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            creationflags=creationflags,
            close_fds=True
        )
        return True
    except Exception:
        return False

async def main():
    # Automatically ensure the BMO Bot is running in the background
    if not is_bot_running():
        print_info("BMO Telegram Bot/MCP Server is not running. Starting it in the background...")
        if start_bot_background():
            # Wait briefly for server to bind to port 4097
            for _ in range(10):
                await asyncio.sleep(0.3)
                if is_bot_running():
                    print_success("BMO Bot / MCP Server is online.")
                    break
            else:
                print_info("BMO Bot started but not responding yet. It will continue loading in the background.")
        else:
            print_error("Failed to start BMO Bot in the background.")

    # Initialize Engine, GoalRunner, and BudgetTracker
    engine = BMOEngine(user_cwd=USER_CWD)
    # CLI has its own async loop — disable the worker subprocess so all queries
    # go through _send_direct. The worker is only needed for Telegram's sync handlers.
    engine.client._worker = None
    goal_runner = GoalRunner(engine)
    budget_tracker = BudgetTracker()

    # ── Start BFP Agent (independent of the worker, always start in CLI) ──
    # BFP is gated inside ensure_worker() which returns early when _worker=None,
    # so we start it explicitly here instead.
    if not engine._bfp_started:
        from config.settings import BFP_RELAY_URL, BFP_TRANSPORT_PORT, BFP_A2A_PORT
        _relay = BFP_RELAY_URL or "http://localhost:9753"
        asyncio.create_task(engine.bfp.start(
            bfp_port=BFP_TRANSPORT_PORT,
            a2a_port=BFP_A2A_PORT,
            relay_url=_relay,
        ))
        engine._bfp_started = True
        print_info(f"BFP Agent starting... (relay: {_relay})")

    # Check OpenCode Connection
    status = await engine.get_status()
    connection_state = [status.get("connected", False)]  # mutable so toolbar closure can reflect updates

    # Ensure session exists
    session = await engine.get_or_create_session(CHAT_ID, USER_ID, USERNAME)
    provider_id, model_id = await engine.get_model_info(CHAT_ID)

    # Print welcome banner
    print_banner(model_id, connection_state[0])
    print_info(f"Connected to SQLite database: {engine.storage.db_path}")
    print_info(f"Active Session: [bold cyan]{session.title or session.session_id[:8]}[/bold cyan]")
    print_info("Type /help to see all interactive commands.\n")

    # Define bottom toolbar function
    def get_toolbar_text():
        conn_indicator = "● Online" if connection_state[0] else "● Offline"
        curr_title = session.title or session.session_id[:8]
        curr_mode = session.metadata.get("active_mode", "execute")
        
        # Budget info
        limit = budget_tracker.get_limit(session.session_id)
        cost = budget_tracker.get_session_cost(session.session_id)
        budget_str = f"${cost:.2f}/${limit:.2f}"
        
        return [
            ("class:bottom-toolbar", f" BMO CLI | Server: {conn_indicator} | Session: {curr_title} | Model: {model_id} | Mode: {curr_mode} | Budget: {budget_str} ")
        ]

    prompt_kb = KeyBindings()

    @prompt_kb.add("c-c", eager=True)
    def _clear_input_ctrl_c(event):
        event.app.current_buffer.reset()

    @prompt_kb.add("escape", eager=True)
    def _clear_input_esc(event):
        event.app.current_buffer.reset()

    # Storage for collapsed pastes: placeholder → full text
    _paste_store: dict[str, str] = {}
    _paste_counter = [0]
    PASTE_COLLAPSE_THRESHOLD = 5  # lines

    from prompt_toolkit.keys import Keys
    @prompt_kb.add(Keys.BracketedPaste, eager=True)
    def _handle_paste(event):
        """Collapse large pastes into a [Pasted text #N +M lines] placeholder."""
        data = event.data
        lines = data.splitlines()
        if len(lines) >= PASTE_COLLAPSE_THRESHOLD:
            _paste_counter[0] += 1
            n = _paste_counter[0]
            placeholder = f"[Pasted text #{n} +{len(lines)} lines]"
            _paste_store[placeholder] = data
            event.app.current_buffer.insert_text(placeholder)
        else:
            event.app.current_buffer.insert_text(data)

    prompt_session = PromptSession(
        message="BMO > ",
        completer=completer,
        bottom_toolbar=get_toolbar_text,
        style=cli_style,
        key_bindings=prompt_kb,
    )
    _PS = PromptSession  # shorthand for sub-prompts — each call creates a fresh instance
    _pending_inject: str = None  # holds mid-run injection text to send on next iteration

    while True:
        try:
            # Sync active session from database (in case Telegram changed it)
            active_session = engine.storage.load_session(CHAT_ID)
            if active_session and active_session.session_id != session.session_id:
                session = active_session
                provider_id, model_id = await engine.get_model_info(CHAT_ID)

            # If an inject was requested during thinking, use it directly
            # instead of waiting for new user input from the prompt.
            if _pending_inject is not None:
                user_input = _pending_inject
                _pending_inject = None
            else:
                user_input = await prompt_session.prompt_async()
                user_input = user_input.strip()

            # Check again after user input to ensure we don't send to a stale session
            active_session = engine.storage.load_session(CHAT_ID)
            if active_session and active_session.session_id != session.session_id:
                session = active_session
                provider_id, model_id = await engine.get_model_info(CHAT_ID)
                print_info(f"\n[System] Session synced with Telegram. Active session is now: {session.title}")

            if not user_input:
                continue

            # Expand paste placeholders back to full text before sending.
            # Handles two cases:
            #   1. user_input IS the placeholder (simple paste + Enter)
            #   2. user_input CONTAINS placeholder(s) embedded in longer text
            if _paste_store:
                if user_input in _paste_store:
                    # Simple case: the whole input is a placeholder
                    user_input = _paste_store.pop(user_input)
                else:
                    # Complex case: scan for embedded placeholders
                    for placeholder, content in list(_paste_store.items()):
                        if placeholder in user_input:
                            user_input = user_input.replace(placeholder, content)
                            _paste_store.pop(placeholder, None)

            # Command routing
            if user_input.startswith("/"):
                cmd_parts = user_input.split(" ", 1)
                cmd = cmd_parts[0].lower()

                if cmd in ("/exit", "/quit", "/q"):
                    print_success("Goodbye!")
                    break

                elif cmd in ("/help", "/h"):
                    print_info("Available Commands:")
                    print_info("  /help or /h      - Display this help message")
                    print_info("  /new or /n       - Start a fresh conversation session")
                    print_info("  /sessions or /s  - List recent conversation sessions and switch")
                    print_info("  /model or /m     - Interactively choose provider and model")
                    print_info("  /clear or /c     - Clear message history of current session")
                    print_info("  /status or /t    - Display server connection status and stats")
                    print_info("  /web             - Start/show webchat URL (Cloudflare tunnel)")
                    print_info("  /goal <task>     - Run autonomous goal loop (parallel subagents)")
                    print_info("  /goal list       - List past goal results")
                    print_info("  /goal results <id> - View full details of a saved goal")
                    print_info("  /budget <limit>  - Set session token cost budget limit in USD")
                    print_info("  /share           - Export current session as .txt or .json file")
                    print_info("  /bfp status      - Show BFP identity, capabilities, relay connection")
                    print_info("  /bfp find <cap>  - Find agents by capability via relay")
                    print_info("  /bfp delegate <did> <task> - Send a task to another agent")
                    print_info("  /bfp talk <did> <msg> - Send a message to another agent")
                    print_info("  /exit or /q      - Exit the CLI interface")

                elif cmd in ("/new", "/n"):
                    print_info("Creating new session...")
                    session = await engine.create_new_session(CHAT_ID, USER_ID, USERNAME)
                    provider_id, model_id = await engine.get_model_info(CHAT_ID)
                    print_success(f"Started new session: {session.title}")

                elif cmd in ("/clear", "/c"):
                    print_info("Clearing current session history...")
                    if await engine.clear_session_history(CHAT_ID):
                        print_success("Session message history cleared successfully.")
                    else:
                        print_error("Failed to clear session history.")

                elif cmd in ("/status", "/t"):
                    status = await engine.get_status()
                    print_system_status(status, model_id)

                elif cmd in ("/budget", "/b"):
                    if len(cmd_parts) < 2:
                        current_limit = budget_tracker.get_limit(session.session_id)
                        print_info(f"Current budget limit: ${current_limit:.2f}")
                        limit_input = (await _PS().prompt_async("Enter new budget limit in USD: ")).strip()
                    else:
                        limit_input = cmd_parts[1].strip()

                    try:
                        new_limit = float(limit_input)
                        budget_tracker.set_limit(session.session_id, new_limit)
                        print_success(f"Session budget set to: ${new_limit:.2f}")
                    except ValueError:
                        print_error("Invalid budget limit value.")

                elif cmd in ("/goal", "/g"):
                    sub = cmd_parts[1].strip() if len(cmd_parts) > 1 else ""

                    # /goal list — show saved goal results
                    if sub == "list":
                        goals = goal_runner.list_saved_goals()
                        if not goals:
                            print_info("No saved goals found.")
                        else:
                            console.print(f"\n  [bold cyan]Saved Goals:[/bold cyan]")
                            for g in goals:
                                ts = g["timestamp"][:19]
                                ok_icon = "[green]OK[/green]" if g["completed_steps"] == g["total_steps"] else "[yellow]partial[/yellow]"
                                console.print(f"    [bold]{g['goal_id']}[/bold]  {ok_icon}  {g['completed_steps']}/{g['total_steps']} steps  [dim]{ts}[/dim]")
                                console.print(f"      [dim]{g['objective'][:120]}[/dim]")
                            console.print()
                        continue

                    # /goal results <id> — view full detail of a saved goal
                    parts = sub.split(maxsplit=1)
                    if parts[0] == "results" and len(parts) > 1:
                        gid = parts[1].strip()
                        data = goal_runner.get_goal_result(gid)
                        if not data:
                            print_error(f"Goal '{gid}' not found.")
                        else:
                            console.print()
                            console.print(Rule(f"[bold cyan]Goal Result: {gid}[/bold cyan]", style="cyan"))
                            console.print(f"  [bold white]{data['objective']}[/bold white]")
                            console.print(f"  [dim]Ran at {data['timestamp'][:19]} — {data['completed_steps']}/{data['total_steps']} steps[/dim]")
                            console.print()
                            for r in data["results"]:
                                status = "BLOCKED" if r["blocked"] else "OK"
                                color = "red" if r["blocked"] else "green"
                                console.print(f"  [{color}]Step {r['step']}/{data['total_steps']} [{status}][/{color}] [white]{r['task']}[/white] [{dim}]{r['elapsed']:.1f}s[/dim]")
                                console.print(f"  [{color}]──[/{color}]")
                                resp_lines = r["response"].strip().split("\n")
                                for line in resp_lines:
                                    console.print(f"    [dim]{line[:200]}[/dim]")
                                console.print()
                        console.print()
                        continue

                    # /goal list or unknown subcommand — show usage
                    if sub and sub not in ("list", "results"):
                        print_info("Usage:")
                        print_info("  /goal <objective>         - Run a new autonomous goal")
                        print_info("  /goal list                - List saved goal results")
                        print_info("  /goal results <id>        - View full results of a saved goal")
                        continue

                    # No subcommand → new goal
                    if not sub:
                        objective = (await _PS().prompt_async("Enter autonomous objective goal: ")).strip()
                    else:
                        objective = sub

                    if not objective:
                        print_error("Objective cannot be empty.")
                        continue

                    await goal_runner.run_goal(CHAT_ID, USER_ID, objective)

                elif cmd in ("/sessions", "/s"):
                    sessions = await engine.list_sessions(CHAT_ID)
                    if not sessions:
                        print_info("No saved sessions found.")
                        continue

                    print_sessions_list(sessions)

                    choice = (await _PS().prompt_async("\nEnter session index number to switch (or press Enter to cancel): ")).strip()
                    if choice.isdigit():
                        idx = int(choice) - 1
                        if 0 <= idx < len(sessions):
                            selected_sid = sessions[idx]["session_id"]
                            new_sess = await engine.switch_session(CHAT_ID, selected_sid)
                            if new_sess:
                                session = new_sess
                                provider_id, model_id = await engine.get_model_info(CHAT_ID)
                                print_success(f"Switched active session to: {session.title}")
                            else:
                                print_error("Failed to switch session.")
                        else:
                            print_error("Invalid session index.")

                elif cmd in ("/model", "/m"):
                    raw_providers = await engine.list_models()
                    if not raw_providers:
                        print_error("Could not fetch available models from server.")
                        continue

                    import time
                    from models.chat_models import UserMemory
                    memory = engine.storage.load_memory(CHAT_ID)
                    if not memory:
                        memory = UserMemory(
                            user_id=USER_ID,
                            chat_id=CHAT_ID,
                            preferences={"favorite_providers": []},
                            knowledge={},
                            created_at=time.time(),
                            updated_at=time.time()
                        )
                    if "favorite_providers" not in memory.preferences:
                        memory.preferences["favorite_providers"] = []

                    def save_fav_callback(fav_list):
                        memory.preferences["favorite_providers"] = fav_list
                        memory.updated_at = time.time()
                        engine.storage.save_memory(memory)

                    tui = ModelSelectorTUI(
                        raw_providers=raw_providers,
                        favorites=memory.preferences["favorite_providers"],
                        save_fav_callback=save_fav_callback
                    )

                    app = tui.get_app()
                    result = await app.run_async()

                    if result:
                        selected_provider_id, selected_model_id = result
                        if await engine.set_model(CHAT_ID, selected_provider_id, selected_model_id):
                            provider_id, model_id = await engine.get_model_info(CHAT_ID)
                            print_success(f"Active model set to: {model_id} ({provider_id})")
                        else:
                            print_error("Failed to switch model.")
                    else:
                        print_info("Cancelled model selection.")

                elif cmd in ("/share",):
                    current_session = engine.storage.get_session_by_id(session.session_id)
                    if not current_session or not current_session.messages:
                        print_error("No messages in current session to export.")
                        continue

                    print_info("Export format:")
                    print_info("  [1] Text file (.txt)")
                    print_info("  [2] JSON file (.json)")
                    fmt_choice = (await _PS().prompt_async("Choose [1/2] (default: 1): ")).strip()
                    fmt = "json" if fmt_choice == "2" else "txt"

                    import json as _json
                    from datetime import datetime as _dt

                    exports_dir = os.path.join(os.path.dirname(__file__), "data", "exports")
                    safe_title = (session.title or session.session_id[:8]).replace(" ", "_").replace("/", "-")
                    timestamp = _dt.now().strftime("%Y%m%d_%H%M%S")
                    filename = f"bmo_session_{safe_title}_{timestamp}.{fmt}"
                    out_path = os.path.join(exports_dir, filename)
                    os.makedirs(exports_dir, exist_ok=True)

                    # Messages are dicts with key "sender" (values: "user" or "assistant")
                    msgs = current_session.messages

                    def _sender(m):
                        if isinstance(m, dict):
                            return m.get("sender") or m.get("role", "")
                        return getattr(m, "sender", None) or getattr(m, "role", "")

                    def _content(m):
                        return m["content"] if isinstance(m, dict) else getattr(m, "content", "")

                    def _timestamp(m):
                        return m.get("timestamp") if isinstance(m, dict) else getattr(m, "timestamp", None)

                    if fmt == "json":
                        export_data = {
                            "session_id": session.session_id,
                            "title": session.title,
                            "exported_at": _dt.now().isoformat(),
                            "messages": [
                                {
                                    "sender": _sender(m),
                                    "content": _content(m),
                                    "timestamp": _timestamp(m),
                                }
                                for m in msgs
                            ]
                        }
                        with open(out_path, "w", encoding="utf-8") as _f:
                            _json.dump(export_data, _f, ensure_ascii=False, indent=2)
                    else:
                        lines = [
                            "BMO Session Export",
                            f"Title   : {session.title}",
                            f"ID      : {session.session_id}",
                            f"Exported: {_dt.now().strftime('%Y-%m-%d %H:%M:%S')}",
                            "=" * 60,
                            "",
                        ]
                        for m in msgs:
                            role_label = "You" if _sender(m) == "user" else "BMO"
                            lines.append(f"[{role_label}]")
                            lines.append(_content(m))
                            lines.append("")
                        with open(out_path, "w", encoding="utf-8") as _f:
                            _f.write("\n".join(lines))

                    print_success(f"Session exported → {out_path}")

                elif cmd == "/bfp":
                    parts = user_input.split(maxsplit=2)
                    subcmd = parts[1] if len(parts) > 1 else ""
                    if subcmd == "status":
                        s = engine.bfp.get_status()
                        print_info("── BFP Status ──")
                        print_info(f"  DID           : {s['did']}")
                        print_info(f"  Running       : {'Yes' if s['running'] else 'No'}")
                        print_info(f"  Relay         : {s['relay_url'] or 'Not connected'}")
                        print_info(f"  Relay Status  : {'Connected' if s['connected_to_relay'] else 'Disconnected'}")
                        print_info(f"  Transport Port: {s['bfp_port'] or 'N/A'}")
                        print_info(f"  Capabilities  : {', '.join(s['capabilities'])}")

                    elif subcmd == "find":
                        if len(parts) < 3:
                            print_error("Usage: /bfp find <capability>")
                            continue
                        capability = parts[2]
                        if not engine.bfp.is_running:
                            print_error("BFP not started")
                            continue
                        try:
                            agents = await engine.bfp.find_agents(capability)
                            if not agents:
                                print_info(f"No agents found with capability: {capability}")
                            else:
                                print_info(f"Agents with '{capability}' capability ({len(agents)}):")
                                for a in agents:
                                    name = a.get("name", a["did"][:30])
                                    did = a["did"]
                                    caps = ", ".join(a.get("capabilities", []))
                                    print_info(f"  {name}")
                                    print_info(f"    DID: {did}")
                                    print_info(f"    Capabilities: {caps}")
                        except Exception as e:
                            print_error(f"Discovery failed: {e}")

                    elif subcmd == "delegate":
                        if len(parts) < 4:
                            print_error("Usage: /bfp delegate <did> <task>")
                            continue
                        target_did = parts[2]
                        task_text = parts[3]
                        if not engine.bfp.is_running:
                            print_error("BFP not started")
                            continue
                        try:
                            print_info(f"Delegating task to {target_did[:40]}...")
                            result = await engine.bfp.delegate(target_did, {"query": task_text})
                            print_success(f"Result: {result.get('result', 'No result')}")
                        except Exception as e:
                            print_error(f"Delegation failed: {e}")

                    elif subcmd == "talk":
                        if len(parts) < 4:
                            print_error("Usage: /bfp talk <did> <message>")
                            continue
                        target_did = parts[2]
                        message = parts[3]
                        if not engine.bfp.is_running:
                            print_error("BFP not started")
                            continue
                        try:
                            print_info(f"Sending message to {target_did[:40]}...")
                            response = await engine.bfp.talk(target_did, message)
                            print_info(f"Response: {response}")
                        except Exception as e:
                            print_error(f"Talk failed: {e}")

                    else:
                        print_info("BFP Commands:")
                        print_info("  /bfp status               - Show BFP identity and connection status")
                        print_info("  /bfp find <capability>    - Find agents by capability via relay")
                        print_info("  /bfp delegate <did> <msg> - Send a task to another agent")
                        print_info("  /bfp talk <did> <msg>     - Chat with another agent")

                elif cmd == "/web":
                    # Check task registry for existing webchat/tunnel
                    import json as _json
                    from config.settings import BMO_HOME
                    _tasks_file = BMO_HOME / "data" / "background_tasks.json"
                    _existing_url = None
                    if _tasks_file.exists():
                        try:
                            _tasks = _json.loads(_tasks_file.read_text("utf-8"))
                            _existing_url = _tasks.get("webchat_tunnel_url")
                        except Exception:
                            pass

                    if _existing_url:
                        print_success(f"💬 Webchat is already running!")
                        print_info(f"   Local:  http://127.0.0.1:3456")
                        print_info(f"   Public: {_existing_url}")
                    else:
                        print_info("⏳ Starting webchat + Cloudflare tunnel...")
                        try:
                            from tools.task_registry import get_active_tasks
                            # Check if webchat is actually already running on port 3456
                            import socket as _sock
                            _s = _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM)
                            _s.settimeout(0.5)
                            _webchat_up = _s.connect_ex(("127.0.0.1", 3456)) == 0
                            _s.close()

                            if _webchat_up:
                                # Start only the tunnel
                                tunnel_result = await engine.client._call_mcp_tool(
                                    "tunnel_webchat", {"port": 3456}
                                ) if hasattr(engine.client, "_call_mcp_tool") else None
                                if tunnel_result:
                                    print_success(f"🌐 Tunnel started: {tunnel_result}")
                                else:
                                    print_info("   Webchat running locally: http://127.0.0.1:3456")
                                    print_info("   To get a public URL, run: bmo web")
                            else:
                                print_info("   Webchat not running. Start it with: bmo web")
                                print_info("   (Run `bmo web` from your terminal)")
                        except Exception as _e:
                            print_error(f"Web command failed: {_e}")
                            print_info("   Alternative: run `bmo web` from your terminal")

                else:
                    print_error(f"Unknown command: {cmd}")
                continue

            # Check budget limit before sending
            if budget_tracker.is_exceeded(session.session_id):
                print_error("⚠️ Session budget limit exceeded. Increase budget via /budget to continue.")
                continue

            # Offline guard — return immediately if server is unreachable
            if not connection_state[0]:
                alive = await engine.client.is_alive()
                connection_state[0] = alive
                if not alive:
                    print_error(
                        "BMO is offline — OpenCode server not reachable.\n"
                        "  Start it with:  opencode serve --port 4800"
                    )
                    continue

            # Regular message — stream response
            import time as _time
            from core.cli_renderer import console

            # ── Dual-channel input system ────────────────────────────────────
            # Channel 1: Ctrl+C / ESC        → hard cancel
            # Channel 2: keystroke + Enter   → mid-run injection (abort + new message)
            _cancel_event = threading.Event()
            _inject_event = threading.Event()
            _inject_buffer = []  # chars typed during thinking
            _spinner_active = [True]  # shared flag for permission prompt suspension

            def _key_watcher():
                """Reads keystrokes during thinking. Supports abort and mid-run injection."""
                try:
                    import msvcrt
                    while not _cancel_event.is_set() and not _inject_event.is_set():
                        if not _spinner_active[0]:
                            import time as _t; _t.sleep(0.1)
                            continue
                        if msvcrt.kbhit():
                            ch = msvcrt.getwch()
                            if ch in ('\x03', '\x1b'):  # Ctrl+C or ESC → hard cancel
                                _cancel_event.set()
                                return
                            elif ch in ('\r', '\n'):  # Enter → inject buffer as new message
                                if _inject_buffer:
                                    _inject_event.set()
                                    return
                            elif ch == '\x08':  # Backspace
                                if _inject_buffer:
                                    _inject_buffer.pop()
                                    sys.stdout.write('\b \b')
                                    sys.stdout.flush()
                            elif ch.isprintable():
                                _inject_buffer.append(ch)
                                sys.stdout.write(ch)
                                sys.stdout.flush()
                        import time as _t; _t.sleep(0.05)
                except Exception:
                    pass

            threading.Thread(target=_key_watcher, daemon=True).start()

            _current_activity = ["thinking..."]

            def on_token(partial: str):
                pass  # no live display — kept for API compatibility

            def on_activity(kind: str, detail: str):
                if kind == "thinking":
                    return # Skip full thinking blocks to avoid clutter
                _current_activity[0] = detail

            async def on_permission(perm_id: str, perm_type: str, patterns: list) -> str:
                """Called when the OpenCode server requests permission. Pauses spinner and prompts user."""
                _spinner_active[0] = False
                sys.stdout.write("\r" + " " * 80 + "\r")
                sys.stdout.flush()
                panel = Panel(
                    f"[bold yellow]⚠️  BMO SECURITY PERMISSION REQUEST[/bold yellow]\n\n"
                    f"[white]Type:[/white] [bold]{perm_type}[/bold]\n"
                    f"[white]Scope:[/white] [code]{', '.join(patterns[:3]) if patterns else '*'}[/code]\n\n"
                    f"[dim]Choose an option (Auto-approves 'allow' after 7 minutes of inactivity):[/dim]\n"
                    f"  [bold cyan]a[/bold cyan] → Allow once\n"
                    f"  [bold cyan]d[/bold cyan] → Deny\n"
                    f"  [bold cyan]y[/bold cyan] → Yes, always (save to vault)",
                    border_style="yellow",
                    width=70,
                )
                console.print(panel)
                
                try:
                    # 7 minutes timeout (420 seconds)
                    choice = await asyncio.wait_for(
                        _PS().prompt_async("Your choice [a/d/y]: "),
                        timeout=420.0
                    )
                    choice = choice.strip().lower()
                except asyncio.TimeoutError:
                    from datetime import datetime as _dt
                    print_info("\n⚠️  Permission request timed out after 7 minutes of inactivity. Auto-approving once to avoid freezing.")
                    # Log the auto-approval to logs/permissions.log
                    try:
                        log_dir = os.path.join(os.path.dirname(__file__), "logs")
                        os.makedirs(log_dir, exist_ok=True)
                        with open(os.path.join(log_dir, "permissions.log"), "a", encoding="utf-8") as lf:
                            lf.write(f"[{_dt.now().isoformat()}] AUTO-APPROVED (Timeout): type={perm_type}, scope={patterns}\n")
                    except Exception:
                        pass
                    choice = "allow"

                if choice in ("a", "allow"):
                    reply = "allow"
                elif choice in ("y", "yes", "always"):
                    reply = "always"
                else:
                    reply = "deny"
                _spinner_active[0] = True
                sys.stdout.write(f"  {_DIM}BMO is thinking...{_RESET}\n")
                sys.stdout.flush()
                return reply

            stream_task = asyncio.ensure_future(
                engine.send_message_streaming(
                    CHAT_ID, USER_ID, user_input,
                    interface="cli", on_token=on_token, on_activity=on_activity,
                    on_permission=on_permission,
                )
            )
            start_t = _time.monotonic()
            response = None
            elapsed_total = 0.0
            _dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
            _tick = 0
            _DIM = "\x1b[2m"
            _CYAN = "\x1b[36m"
            _RESET = "\x1b[0m"
            sys.stdout.write(f"  {_DIM}BMO is thinking... (Ctrl+C/ESC=cancel | type+Enter=inject){_RESET}\n")
            sys.stdout.flush()
            _inj_prompt_shown = False
            try:
                while not stream_task.done():
                    if _cancel_event.is_set():
                        raise asyncio.CancelledError("cancelled via Ctrl+C / ESC")
                    if _inject_event.is_set():
                        raise asyncio.CancelledError("inject")
                    await asyncio.sleep(0.1)
                    if _spinner_active[0]:
                        elapsed_total = _time.monotonic() - start_t
                        _tick += 1
                        if _tick % 10 == 0:
                            injected_preview = ''.join(_inject_buffer)
                            act = _current_activity[0]
                            import re
                            act_clean = re.sub(r'<[^>]+>', '', act)
                            if len(act_clean) > 40:
                                act_clean = act_clean[:37] + "..."
                            if injected_preview:
                                line = f"\r  {_DIM}{_dots[(_tick // 10) % len(_dots)]} {elapsed_total:.0f}s{_RESET} {_CYAN}[{act_clean}]{_RESET}  {_CYAN}↩ {injected_preview}{_RESET}   "
                            else:
                                line = f"\r  {_DIM}{_dots[(_tick // 10) % len(_dots)]} {elapsed_total:.0f}s{_RESET} {_CYAN}[{act_clean}]{_RESET}   "
                            sys.stdout.write(line + " " * max(0, 80 - len(line)))
                            sys.stdout.flush()
                elapsed_total = _time.monotonic() - start_t
                sys.stdout.write("\r" + " " * 70 + "\r")
                sys.stdout.flush()
                response = await stream_task
            except (KeyboardInterrupt, asyncio.CancelledError) as exc:
                stream_task.cancel()
                _cancel_event.set()
                # ── Abort OpenCode server-side stream ──
                _oc_sid = session.metadata.get("opencode_session_id")
                if _oc_sid:
                    try:
                        await engine.client.abort_session(_oc_sid)
                        logger.info("CLI abort_session(%s) sent", _oc_sid)
                    except Exception:
                        pass
                elapsed_total = _time.monotonic() - start_t
                response = None
                sys.stdout.write("\r" + " " * 70 + "\r")
                sys.stdout.flush()

                # ── Mid-run injection: queue the typed text for next iteration ──
                injected_text = ''.join(_inject_buffer).strip()
                if injected_text and str(exc) == "inject":
                    await engine.reset_opencode_session(CHAT_ID)
                    console.print(f"\n  [bold cyan]↩ Injecting:[/bold cyan] [italic]{injected_text}[/italic]\n")
                    _pending_inject = injected_text  # picked up at top of next loop iteration
                    continue  # skip cancel message, go straight to sending
            finally:
                _cancel_event.set()

            connection_state[0] = engine.client.is_connected

            if response is None:
                console.print("\n  [bold yellow]⚡ Request cancelled.[/bold yellow]  [dim](New OpenCode session will be used for next message)[/dim]\n")
                await engine.reset_opencode_session(CHAT_ID)
                continue

            budget_tracker.record_usage(session.session_id, model_id, len(user_input) * 4, len(response) * 4)
            print_assistant_message(response, elapsed_total)

        except (KeyboardInterrupt, asyncio.CancelledError):
            continue
        except EOFError:
            print_success("\nGoodbye!")
            break
        except Exception as e:
            print_error(f"An unexpected error occurred: {e}")

    # Cleanup: ensure worker subprocess and httpx client are closed
    # before the event loop tears down. Prevents unclosed
    # _ProactorBasePipeTransport warnings on /exit.
    try:
        if engine.worker_manager and engine.worker_manager.is_alive:
            await engine.worker_manager.shutdown()
    except Exception:
        pass
    try:
        if hasattr(engine.client._http, 'aclose'):
            await engine.client._http.aclose()
    except Exception:
        pass

def main_run():
    import warnings
    warnings.filterwarnings("ignore", category=ResourceWarning)
    # Suppress Windows asyncio pipe ValueError on exit (closed pipe repr noise)
    warnings.filterwarnings("ignore", message=".*I/O operation on closed pipe.*")

    # Set up basic logging for the CLI TUI to write to ~/.bmo/logs/cli.log
    try:
        from config.settings import LOGS_DIR
        LOGS_DIR.mkdir(parents=True, exist_ok=True)
        logging.basicConfig(
            filename=LOGS_DIR / "cli.log",
            filemode="a",
            format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
            level=logging.INFO
        )
    except Exception:
        pass

    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        sys.exit(0)

if __name__ == "__main__":
    main_run()
