import asyncio
import fnmatch
import json
import os
import re
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any, Protocol

from brainfile import read_board_config

from otto.acp.registry import is_acp_model
from otto.agent import run
from otto.auth import AuthStorage
from otto.autonomy import PulseRunner
from otto.config import (
    OTTO_HOME,
    BotAuthConfig,
    BotConfig,
    Config,
    UserConfig,
    resolve_user,
    save_config,
)
from otto.delegation import BOARD_CONFIG
from otto.gateway import Gateway
from otto.log import get_logger
from otto.memory import Memory
from otto.model_catalog import (
    list_oauth_models,
    list_oauth_providers,
    validate_oauth_model_for_provider,
)
from otto.prompts import build_system_prompt
from otto.providers import format_acp_chat_rejection
from otto.sessions import SessionStore
from otto.skills import BUILTIN_SKILLS_DIR, discover
from otto.subagents import SubagentProfile, load_subagent_profiles
from otto.thinking import (
    get_thinking_capabilities,
    normalize_thinking_level,
    reasoning_effort_for_model,
)
from otto.tools import create_session_tools

_log = get_logger(__name__)

# Reverse of auth/registry._PREFIX_MAP: litellm_provider -> otto_prefix
_OTTO_PREFIX_MAP: dict[str, str] = {
    "anthropic": "claude-code",
    "vertex_ai": "gemini-cli",
    "google": "gemini-cli",
}

_DEFAULT_MODEL_PATTERNS: list[str] = [
    "claude-sonnet-*",
    "claude-haiku-*",
    "gpt-5*",
    "gemini-2.5*",
    "gemini-3*",
]

_MAX_PICKER_MODELS = 20
_FLATTEN_MODEL_BUTTON_THRESHOLD = 4

_PROVIDER_DISPLAY_NAMES: dict[str, str] = {
    "claude-code": "Anthropic",
    "anthropic": "Anthropic",
    "openai": "OpenAI",
    "codex": "OpenAI",
    "gemini-cli": "Google",
    "vertex_ai": "Google",
    "google": "Google",
    "copilot": "Copilot",
    "github_copilot": "Copilot",
}

_OAUTH_PROVIDER_DISPLAY_NAMES: dict[str, str] = {
    "anthropic": "Anthropic",
    "openai": "OpenAI",
    "google": "Google",
    "copilot": "Copilot",
}

_OAUTH_PROVIDER_ORDER: tuple[str, ...] = ("anthropic", "openai", "google", "copilot")

# Maps otto model prefix → (auth_storage provider key, env var to check).
_PROVIDER_AUTH_CHECK: dict[str, tuple[str, str | None]] = {
    "claude-code": ("anthropic", "ANTHROPIC_API_KEY"),
    "openai": ("openai", "OPENAI_API_KEY"),
    "gemini-cli": ("google", "GOOGLE_API_KEY"),
}


def _active_provider_prefixes(auth_storage: AuthStorage | None) -> set[str] | None:
    """Return the set of otto model prefixes with active credentials.

    Checks both OAuth credentials in auth_storage and environment variables.
    Returns None if detection is unavailable (no auth_storage and no env vars),
    meaning all providers should be shown.
    """
    active: set[str] = set()
    for prefix, (auth_key, env_var) in _PROVIDER_AUTH_CHECK.items():
        if auth_storage and auth_storage.get_credentials(auth_key):
            active.add(prefix)
        elif env_var and os.environ.get(env_var):
            active.add(prefix)
    # Also include prefixless models (e.g. bare "gpt-4o") if openai is active.
    if "openai" in active:
        active.add("")
    return active if active else None


def _to_otto_model_id(litellm_key: str) -> str:
    """Convert a litellm model key to an Otto-prefixed model identifier."""
    if "/" in litellm_key:
        provider, model = litellm_key.split("/", 1)
    else:
        provider, model = "", litellm_key
    otto_prefix = _OTTO_PREFIX_MAP.get(provider)
    if otto_prefix:
        return f"{otto_prefix}/{model}"
    return litellm_key


def _make_display_name(model_key: str) -> str:
    """Build a human-readable display name from a litellm model key."""
    # Strip provider prefix
    if "/" in model_key:
        _, name = model_key.split("/", 1)
    else:
        name = model_key
    # Remove date suffixes like -20250514
    name = re.sub(r"-\d{8}$", "", name)
    # Replace hyphens with spaces, titlecase
    return name.replace("-", " ").replace("_", " ").title()


def _dedup_base_name(key: str) -> str:
    """Return the base name without dated suffix for dedup grouping."""
    if "/" in key:
        _, name = key.split("/", 1)
    else:
        name = key
    return re.sub(r"-\d{8}$", "", name)


def _build_model_presets(model_list: list[str]) -> list[tuple[str, str]]:
    """Build model presets dynamically from litellm's model registry.

    Returns a list of (display_name, otto_model_id) tuples.
    """
    try:
        import litellm
    except ImportError:
        return []

    patterns = model_list if model_list else _DEFAULT_MODEL_PATTERNS
    all_keys = list(litellm.model_cost.keys())

    # Filter keys matching any pattern
    matched: list[str] = []
    for key in all_keys:
        # Match against the model name part (without provider prefix)
        if "/" in key:
            _, model_part = key.split("/", 1)
        else:
            model_part = key
        for pattern in patterns:
            if fnmatch.fnmatch(model_part, pattern) or fnmatch.fnmatch(key, pattern):
                matched.append(key)
                break

    # Deduplicate: for models with dated versions, keep only the latest
    base_groups: dict[str, list[str]] = {}
    for key in matched:
        base = _dedup_base_name(key)
        base_groups.setdefault(base, []).append(key)

    deduped: list[str] = []
    for group in base_groups.values():
        # Sort to get the latest date suffix last; pick it
        group.sort()
        deduped.append(group[-1])

    # Build display name + otto ID pairs
    results: list[tuple[str, str]] = []
    for key in deduped:
        display = _make_display_name(key)
        otto_id = _to_otto_model_id(key)
        results.append((display, otto_id))

    # Sort by provider then display name
    def _sort_key(item: tuple[str, str]) -> tuple[str, str]:
        otto_id = item[1]
        if "/" in otto_id:
            provider, _ = otto_id.split("/", 1)
        else:
            provider = ""
        return (provider, item[0])

    results.sort(key=_sort_key)

    return results[:_MAX_PICKER_MODELS]


def _group_presets_by_provider(
    presets: list[tuple[str, str]],
    active_prefixes: set[str] | None = None,
) -> dict[str, list[tuple[str, str]]]:
    """Group model presets by provider prefix extracted from otto_model_id.

    When *active_prefixes* is provided, only models whose prefix is in the set
    are included.  When ``None``, all models are shown.

    Returns a dict mapping display provider name to list of (label, model_id) tuples.
    """
    groups: dict[str, list[tuple[str, str]]] = {}
    for label, otto_id in presets:
        if "/" in otto_id:
            prefix = otto_id.split("/", 1)[0]
        else:
            prefix = ""
        if active_prefixes is not None and prefix not in active_prefixes:
            continue
        display_name = _PROVIDER_DISPLAY_NAMES.get(prefix)
        if display_name is None:
            display_name = prefix.title() if prefix else "Other"
        groups.setdefault(display_name, []).append((label, otto_id))
    return groups


def _provider_key_from_otto_id(otto_id: str) -> str:
    """Extract the raw provider prefix from an otto model id."""
    if "/" in otto_id:
        return otto_id.split("/", 1)[0]
    return ""


def _resolve_provider_filter(
    arg: str, grouped: dict[str, list[tuple[str, str]]]
) -> tuple[str, list[tuple[str, str]]] | None:
    """Match a user argument to a provider (case-insensitive).

    Checks against both display names and raw provider keys.
    Returns (display_name, models) or None if no match.
    """
    lower = arg.lower()
    # Check display names first
    for display_name, models in grouped.items():
        if display_name.lower() == lower:
            return display_name, models
    # Check raw provider keys
    for key, display_name in _PROVIDER_DISPLAY_NAMES.items():
        if key.lower() == lower and display_name in grouped:
            return display_name, grouped[display_name]
    return None


def _oauth_provider_from_model_id(model_id: str) -> str | None:
    prefix = _provider_key_from_otto_id(_canonical_model_id(model_id))
    mapping = {
        "anthropic": "anthropic",
        "claude-code": "anthropic",
        "openai": "openai",
        "codex": "openai",
        "google": "google",
        "gemini-cli": "google",
        "vertex_ai": "google",
        "copilot": "copilot",
        "github_copilot": "copilot",
    }
    return mapping.get(prefix)


def _canonical_model_id(model_id: str) -> str:
    value = str(model_id or "").strip()
    if value.lower().startswith("copilot/"):
        return f"github_copilot/{value.split('/', 1)[1]}"
    return value


def _is_model_allowed(model_id: str, patterns: list[str]) -> bool:
    if not patterns:
        return True
    if "/" in model_id:
        _, model_part = model_id.split("/", 1)
    else:
        model_part = model_id
    for pattern in patterns:
        if fnmatch.fnmatch(model_id, pattern) or fnmatch.fnmatch(model_part, pattern):
            return True
    return False


def _active_oauth_providers(auth_storage: AuthStorage | None) -> set[str] | None:
    env_fallbacks: dict[str, tuple[str, ...]] = {
        "anthropic": ("ANTHROPIC_API_KEY",),
        "openai": ("OPENAI_API_KEY",),
        "google": ("GOOGLE_API_KEY",),
        "copilot": ("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_COPILOT_TOKEN"),
    }
    active: set[str] = set()
    for provider in list_oauth_providers():
        if auth_storage and auth_storage.get_credentials(provider):
            active.add(provider)
            continue
        if provider == "copilot":
            try:
                from otto.auth.copilot import has_litellm_credentials

                if has_litellm_credentials():
                    active.add(provider)
                    continue
            except Exception:
                pass
        for env_var in env_fallbacks.get(provider, ()):
            if os.environ.get(env_var):
                active.add(provider)
                break
    return active if active else None


def _preferred_prefix_for_provider(provider: str) -> str:
    return {
        "anthropic": "claude-code",
        "openai": "codex",
        "google": "gemini-cli",
        "copilot": "copilot",
    }.get(provider, "")


def _provider_model_presets(config: Config, provider: str) -> list[tuple[str, str]]:
    patterns = config.agent.model_list
    raw_models = list_oauth_models(provider)
    if not raw_models:
        return []

    filtered = [model_id for model_id in raw_models if _is_model_allowed(model_id, patterns)]
    if not filtered:
        return []

    grouped: dict[str, list[str]] = {}
    order: list[str] = []
    for model_id in filtered:
        key = model_id.split("/", 1)[1] if "/" in model_id else model_id
        if key not in grouped:
            grouped[key] = []
            order.append(key)
        grouped[key].append(model_id)

    preferred_prefix = _preferred_prefix_for_provider(provider)
    results: list[tuple[str, str]] = []
    for key in order:
        candidates = grouped[key]
        chosen = next(
            (
                model_id
                for model_id in candidates
                if _provider_key_from_otto_id(model_id) == preferred_prefix
            ),
            candidates[0],
        )
        results.append((_make_display_name(chosen), chosen))
    return results


def _available_provider_models(
    config: Config,
    auth_storage: AuthStorage | None,
) -> dict[str, list[tuple[str, str]]]:
    active = _active_oauth_providers(auth_storage)

    provider_order = [
        provider for provider in _OAUTH_PROVIDER_ORDER if provider in list_oauth_providers()
    ]
    provider_order.extend(
        provider for provider in list_oauth_providers() if provider not in provider_order
    )

    available: dict[str, list[tuple[str, str]]] = {}
    for provider in provider_order:
        if active is not None and provider not in active:
            continue
        presets = _provider_model_presets(config, provider)
        if presets:
            available[provider] = presets
    return available


def build_provider_picker(
    config: Config,
    auth_storage: AuthStorage | None = None,
) -> tuple[str, list[list[tuple[str, str]]]]:
    """Return model picker view.

    Default: show configured OAuth providers, then drill into provider model list.
    If the final filtered list is small, flatten to direct model buttons for speed.
    """
    current = config.agent.model
    available = _available_provider_models(config, auth_storage)

    if not available:
        text = f"<b>Model</b>\nCurrent: <code>{current}</code>\n\nNo configured providers found."
        return text, [[("Custom Model", "model:custom"), ("Close", "model:cancel")]]

    flattened: list[tuple[str, str]] = [item for models in available.values() for item in models]
    if len(flattened) <= _FLATTEN_MODEL_BUTTON_THRESHOLD:
        text = f"<b>Model</b>\nCurrent: <code>{current}</code>\n\nChoose a model:"
        buttons = [
            [(label, f"model:{model_id}") for label, model_id in flattened[i : i + 2]]
            for i in range(0, len(flattened), 2)
        ]
        buttons.append([("Custom Model", "model:custom"), ("Close", "model:cancel")])
        return text, buttons

    text = f"<b>Model</b>\nCurrent: <code>{current}</code>\n\nChoose a provider:"
    buttons = [
        [
            (
                _OAUTH_PROVIDER_DISPLAY_NAMES.get(provider, provider.title()),
                f"model:provider:{provider}",
            )
        ]
        for provider in available.keys()
    ]
    buttons.append([("Custom Model", "model:custom"), ("Close", "model:cancel")])
    return text, buttons


def get_provider_model_buttons(
    config: Config,
    provider_key: str,
    auth_storage: AuthStorage | None = None,
) -> tuple[str, list[list[tuple[str, str]]]]:
    """Return (text, buttons) for a provider's model list."""
    available = _available_provider_models(config, auth_storage)
    filtered = available.get(provider_key, [])
    display_name = _OAUTH_PROVIDER_DISPLAY_NAMES.get(provider_key, provider_key.title() or "Other")
    if not filtered:
        text = f"No models found for {display_name}."
        buttons: list[list[tuple[str, str]]] = [
            [("« Back", "model:back"), ("Close", "model:cancel")]
        ]
        return text, buttons

    text = f"<b>{display_name}</b>\nChoose a model:"
    buttons = [
        [(label, f"model:{model_id}") for label, model_id in filtered[i : i + 2]]
        for i in range(0, len(filtered), 2)
    ]
    buttons.append([("« Back", "model:back"), ("Custom Model", "model:custom")])
    buttons.append([("Close", "model:cancel")])
    return text, buttons


class Renderer(Protocol):
    output_format: str

    def on_text(self, chunk: str) -> None:
        """Receive a streaming text chunk."""
        ...

    def on_tool_start(self, name: str, args: dict) -> None:
        """Handle tool execution start."""
        ...

    def on_tool_end(self, name: str, result: str) -> None:
        """Handle tool execution completion."""
        ...

    async def send_text(self, text: str) -> None:
        """Send a complete text message."""
        ...

    async def send_with_buttons(self, text: str, buttons: list[list[tuple[str, str]]]) -> None:
        """Send text with optional inline buttons. buttons: list of rows, each row is list of (label, callback_data) tuples."""
        ...

    async def show_error(self, error: str) -> None:
        """Display an error message."""
        ...

    async def send_file(self, path: str, caption: str | None = None) -> None:
        """Send a file to the user."""
        ...

    async def flush(self) -> None:
        """Flush any buffered content."""
        ...


type CommandHandler = Callable[["Chat", str, str, str | None, "Renderer"], Awaitable[None]]


HELP_CATEGORIES: dict[str, dict[str, Any]] = {
    "chat": {
        "label": "Chat",
        "summary": "New, stop, resume, clear",
        "title": "Chat Commands",
        "commands": [
            ("/new", "Start fresh session"),
            ("/clear", "Clear session history"),
            ("/resume", "Resume an archived session"),
            ("/stop", "Cancel current response"),
        ],
    },
    "ai": {
        "label": "AI",
        "summary": "Model, thinking, status, tools, agents",
        "title": "AI Commands",
        "commands": [
            ("/model", "Show or change current model"),
            ("/thinking", "Show or change reasoning depth"),
            ("/subagents", "List configured delegation subagents"),
            ("/status", "System status"),
            ("/agents", "Show delegated agent jobs"),
        ],
    },
    "info": {
        "label": "Info",
        "summary": "History, memory",
        "title": "Info Commands",
        "commands": [
            ("/history", "Show session info"),
            ("/help", "Show this help menu"),
            ("/status", "Show model and memory status"),
        ],
    },
}

HELP_QUICK_COMMANDS = ["status", "new", "model"]


def _normalize_channels(selection: object | None) -> set[str]:
    """Normalize user channel choice metadata into a set of channel names."""
    if selection is None:
        return {"cli"}

    if isinstance(selection, Config):
        bots = getattr(selection, "bots", None)
        if bots:
            return _normalize_channels(getattr(bots[0], "channels", ()))
        return {"cli"}

    if isinstance(selection, dict):
        for key in ("channels", "selected_channels", "channel_selection", "channel_choice"):
            if key in selection:
                return _normalize_channels(selection[key])
        return {"cli"}

    if isinstance(selection, str):
        parts = [part.strip() for part in selection.replace(",", " ").split() if part.strip()]
        return {part.lower() for part in parts} or {"cli"}

    if isinstance(selection, (list, tuple, set)):
        channels: set[str] = set()
        for item in selection:
            if isinstance(item, str):
                channels.add(item.lower())
                continue
            if isinstance(item, dict):
                channel_type = item.get("type")
                if isinstance(channel_type, str):
                    channels.add(channel_type.lower())
                continue
            channel_type = getattr(item, "type", None)
            if isinstance(channel_type, str):
                channels.add(channel_type.lower())
        return channels or {"cli"}

    return {"cli"}


def _telegram_handle_from_bot_name(
    bot_name: str | None, *, telegram_handle: str | None = None
) -> str:
    """Derive a best-effort Telegram bot handle label."""
    if telegram_handle:
        return telegram_handle if telegram_handle.startswith("@") else f"@{telegram_handle}"

    if not bot_name:
        return "@telegram-bot"

    cleaned = bot_name.strip()
    if not cleaned:
        return "@telegram-bot"
    return cleaned if cleaned.startswith("@") else f"@{cleaned}"


def build_setup_completion_message(
    selection: object | None,
    *,
    bot_name: str | None = None,
    telegram_handle: str | None = None,
    output_format: str = "markdown",
) -> str:
    """Build a post-setup handoff message based on channel selection."""
    selected = _normalize_channels(selection)
    has_cli = "cli" in selected
    has_telegram = "telegram" in selected

    if has_cli and not has_telegram:
        if output_format == "telegram_html":
            return "".join(
                [
                    "<b>Setup complete.</b>\n\n",
                    "You're all set for local-first usage.\n",
                    "Start chatting now in this CLI window.\n",
                ]
            )
        return (
            "Setup complete.\n\n"
            "You're all set for local-only mode.\n"
            "Continue right here in this CLI session."
        )

    handle = _telegram_handle_from_bot_name(bot_name, telegram_handle=telegram_handle)
    if has_cli and has_telegram:
        if output_format == "telegram_html":
            return "".join(
                [
                    "<b>Setup complete.</b>\n\n",
                    "How do you want to continue?\n",
                    "• Keep chatting here in this CLI session, or\n",
                    f"• Open {handle} in Telegram and send <code>/start</code>.\n",
                    "Your chats are synced across both channels.",
                ]
            )
        return (
            "Setup complete.\n\n"
            "How do you want to continue?\n"
            "1) Keep chatting here in this CLI session, or\n"
            f"2) Open Telegram ({handle}), send /start, and continue there.\n"
            "Your chats are synced across both channels."
        )

    if output_format == "telegram_html":
        return "".join(
            [
                "<b>Setup complete.</b>\n\n",
                "Telegram channel configured.\n",
                f"Open {handle} and send <code>/start</code> to begin.\n",
            ]
        )
    return f"Setup complete. Telegram channel configured. Open {handle} and send /start to begin."


def get_help_data() -> dict[str, Any]:
    """Return structured help data used by command + callback handlers."""
    categories: dict[str, dict[str, Any]] = {}
    for key, value in HELP_CATEGORIES.items():
        categories[key] = {
            "label": value["label"],
            "summary": value["summary"],
            "title": value["title"],
            "commands": list(value["commands"]),
        }
    return {
        "title": "Otto Commands",
        "categories": categories,
        "quick_launch": list(HELP_QUICK_COMMANDS),
    }


def build_help_main_view() -> tuple[str, list[list[tuple[str, str]]]]:
    help_data = get_help_data()
    categories: dict[str, dict[str, Any]] = help_data["categories"]
    category_order = ["chat", "ai", "info"]
    lines = [
        "<b>Otto Commands</b> — Command Center",
        "",
    ]
    for key in category_order:
        category = categories[key]
        lines.append(f"**{category['label']}** — {category['summary']}")
    lines.extend(["", "Tap a category below."])
    buttons = [
        [(categories[key]["label"], f"help:{key}") for key in category_order],
        [(f"/{command}", f"help:run:{command}") for command in help_data["quick_launch"]],
    ]
    return "\n".join(lines), buttons


def build_help_category_view(category_key: str) -> tuple[str, list[list[tuple[str, str]]]] | None:
    help_data = get_help_data()
    categories: dict[str, dict[str, Any]] = help_data["categories"]
    category = categories.get(category_key)
    if category is None:
        return None
    lines = [f"**{category['title']}**", ""]
    for command, description in category["commands"]:
        lines.append(f"{command} — {description}")
    buttons = [[("« Back", "help:back")]]
    return "\n".join(lines), buttons


def build_help_plaintext(*, cli: bool = False) -> str:
    help_data = get_help_data()
    categories: dict[str, dict[str, Any]] = help_data["categories"]
    lines = ["**Commands**", ""]
    for category_key in ["chat", "ai", "info"]:
        category = categories[category_key]
        for command, description in category["commands"]:
            lines.append(f"  {command}  {description}")
    if cli:
        lines.append("")
        lines.append("  /exit  Exit the CLI")
    return "\n".join(lines)


async def _cmd_help(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    _ = chat, chat_id, args, bot_id
    fmt = getattr(renderer, "output_format", "telegram_html")
    if fmt == "markdown":
        await renderer.send_text(build_help_plaintext(cli=True))
    else:
        text, buttons = build_help_main_view()
        await renderer.send_with_buttons(text, buttons)


def _get_uptime() -> str:
    """Get system uptime as a human-readable string."""
    try:
        with open("/proc/uptime") as f:
            uptime_seconds = float(f.readline().split()[0])
        days, remainder = divmod(int(uptime_seconds), 86400)
        hours, remainder = divmod(remainder, 3600)
        minutes, _ = divmod(remainder, 60)
        if days > 0:
            return f"{days}d {hours}h"
        elif hours > 0:
            return f"{hours}h {minutes}m"
        else:
            return f"{minutes}m"
    except Exception:
        return "unknown"


async def _get_active_jobs_count_async() -> int:
    """Get count of active orchestrator jobs asynchronously."""
    try:
        from otto.orchestrator import get_orchestrator

        jobs = await get_orchestrator().list_jobs()
        active = [j for j in jobs if j.get("status") in {"in_progress", "ready"}]
        return len(active)
    except Exception:
        return 0


def build_status_card(
    model: str,
    msg_count: int,
    tool_count: int,
    jobs_count: int = 0,
    thinking: str = "off",
) -> tuple[str, list[list[tuple[str, str]]]]:
    """Build status card with live stats and quick actions."""
    uptime = _get_uptime()

    lines = [
        "<b>System Status</b>",
        "",
        "<b>Runtime</b>",
        f"Uptime: {uptime}",
        f"Tools available: {tool_count}",
        "",
        "<b>Session</b>",
        f"Messages: {msg_count}",
        f"Model: {model}",
        f"Thinking: {thinking}",
        "",
        "<b>Jobs</b>",
        f"Active: {jobs_count}",
    ]

    buttons = [
        [
            ("Update", "status:refresh"),
            ("New Session", "status:new"),
            ("Change Model", "status:model"),
        ],
        [("Thinking", "status:thinking")],
    ]

    return "\n".join(lines), buttons


async def _cmd_status(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    _ = args, bot_id
    model = chat._active_model(chat_id, bot_id=bot_id)
    thinking = chat._thinking_overrides.get(chat_id, chat._thinking_default)
    session = chat._session_store.load(chat_id)
    tools_list = chat._gateway.tools()
    msg_count = len(session.messages)
    tool_count = len(tools_list)

    fmt = getattr(renderer, "output_format", "telegram_html")
    if fmt == "markdown":
        lines = [
            "**Status**",
            "",
            f"**Model:**  {model}",
            f"**Thinking:**  {thinking}",
            f"**Session:**  {msg_count} messages",
            f"**Tools:**  {tool_count} registered",
            "**Memory:**  active",
        ]
        await renderer.send_text("\n".join(lines))
    else:
        jobs_count = await _get_active_jobs_count_async()
        text, buttons = build_status_card(model, msg_count, tool_count, jobs_count, thinking)
        await renderer.send_with_buttons(text, buttons)


async def _cmd_model(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    model_name = args.strip()
    model_name = _canonical_model_id(model_name)
    current = chat._active_model(chat_id, bot_id=bot_id)
    presets = _build_model_presets(chat._config.agent.model_list)
    if not presets:
        # Fallback when litellm is unavailable
        presets = [
            ("Claude Sonnet", "claude-code/claude-sonnet-4-5-20250514"),
            ("Claude Haiku", "claude-code/claude-haiku-4-5-20251001"),
            ("GPT-4o", "gpt-4o"),
            ("GPT-4o Mini", "gpt-4o-mini"),
        ]
    fmt = getattr(renderer, "output_format", "telegram_html")
    # For CLI output, show all model providers so selection list is deterministic.
    # Telegram uses button-based selection and keeps credential-aware filtering in
    # build_provider_picker.
    active = None if fmt == "markdown" else _active_provider_prefixes(chat._auth_storage)
    grouped = _group_presets_by_provider(presets, active_prefixes=active)

    if model_name:
        # Check if arg is a provider filter (CLI only)
        if fmt == "markdown":
            match = _resolve_provider_filter(model_name, grouped)
            if match is not None:
                display_name, models = match
                lines = [f"**Current model:** {current}", "", f"**{display_name}**"]
                for label, model_id in models:
                    lines.append(f"  {label}  /model {model_id}")
                await renderer.send_text("\n".join(lines))
                return
        if is_acp_model(model_name):
            await renderer.show_error(format_acp_chat_rejection(model_name))
            return
        # Not a provider filter — set model directly
        selected_provider = _oauth_provider_from_model_id(model_name)
        validation = validate_oauth_model_for_provider(model_name, provider=selected_provider)
        if not validation.is_ok:
            if validation.error:
                await renderer.show_error(validation.error)
            else:
                await renderer.show_error("Model rejected by validation checks.")
            return

        chat._model_overrides[chat_id] = model_name
        _persist_model(chat._config, model_name, bot_id=bot_id or chat._bot_name)
        on_change = getattr(renderer, "on_model_change", None)
        if callable(on_change):
            on_change(model_name)

        message = f"Model updated: {model_name}"
        if validation.warning:
            message = f"{message}\nWarning: {validation.warning}"
        await renderer.send_text(message)
        return

    fmt = getattr(renderer, "output_format", "telegram_html")
    if fmt == "markdown":
        lines = [f"**Current model:** {current}", ""]
        for display_name, models in grouped.items():
            lines.append(f"**{display_name}**")
            for label, model_id in models:
                lines.append(f"  {label}  /model {model_id}")
            lines.append("")
        await renderer.send_text("\n".join(lines).rstrip())
    else:
        text, buttons = build_provider_picker(chat._config, auth_storage=chat._auth_storage)
        text = text.replace(f"<code>{chat._config.agent.model}</code>", f"<code>{current}</code>")
        await renderer.send_with_buttons(text, buttons)


async def _cmd_thinking(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    model = chat._active_model(chat_id, bot_id=bot_id)
    caps = get_thinking_capabilities(model)

    raw = args.strip()
    if raw:
        requested = normalize_thinking_level(raw)
        if requested is None:
            await renderer.show_error("Invalid thinking level. Use: off, low, medium, high, xhigh.")
            return

        if requested == "on" and "on" not in caps.levels and "medium" in caps.levels:
            requested = "medium"

        if requested not in caps.levels:
            await renderer.show_error(
                f"Thinking level '{requested}' is not supported for {model}. "
                f"Supported: {', '.join(caps.levels)}"
            )
            return

        if requested == "off":
            chat._thinking_overrides.pop(chat_id, None)
        else:
            chat._thinking_overrides[chat_id] = requested

        chat._thinking_default = requested
        target_bot = bot_id or chat._bot_name
        _persist_thinking_default(chat._config, requested, bot_id=target_bot)

        await renderer.send_text(f"Thinking mode updated: {requested}")
        return

    current = chat._thinking_overrides.get(chat_id, chat._thinking_default)
    if current not in caps.levels:
        current = "off"

    lines = [
        "**Thinking Mode**",
        "",
        f"**Model:**  {model}",
        f"**Current:**  {current}",
        f"**Default:**  {chat._thinking_default}",
        f"**Supported:**  {', '.join(caps.levels)}",
    ]

    fmt = getattr(renderer, "output_format", "telegram_html")
    if fmt == "markdown":
        lines.extend(["", "Set with: /thinking <level>"])
        await renderer.send_text("\n".join(lines))
        return

    buttons: list[list[tuple[str, str]]] = []
    row: list[tuple[str, str]] = []
    for level in caps.levels:
        label = f"✓ {level}" if level == current else level
        row.append((label, f"thinking:{level}"))
        if len(row) == 3:
            buttons.append(row)
            row = []
    if row:
        buttons.append(row)
    buttons.append([("Close", "thinking:cancel")])

    await renderer.send_with_buttons("\n".join(lines), buttons)


def _persist_thinking_default(config: Config, thinking_default: str, *, bot_id: str | None) -> None:
    """Persist per-bot default thinking mode to config.toml."""
    if config.bots:
        target_name = bot_id or (config.bots[0].name if config.bots else None)
        if target_name:
            new_bots = []
            for bot_cfg in config.bots:
                if bot_cfg.name == target_name:
                    new_bot = object.__new__(type(bot_cfg))
                    object.__setattr__(new_bot, "name", bot_cfg.name)
                    object.__setattr__(new_bot, "model", bot_cfg.model)
                    object.__setattr__(new_bot, "auth", bot_cfg.auth)
                    object.__setattr__(new_bot, "workspace", bot_cfg.workspace)
                    object.__setattr__(new_bot, "skills", bot_cfg.skills)
                    object.__setattr__(new_bot, "channels", bot_cfg.channels)
                    object.__setattr__(new_bot, "thinking_default", thinking_default)
                    new_bots.append(new_bot)
                else:
                    new_bots.append(bot_cfg)
            object.__setattr__(config, "bots", new_bots)

            raw = getattr(config, "_raw_values", None)
            if isinstance(raw, dict):
                raw_bots = raw.get("bots")
                if isinstance(raw_bots, list):
                    for raw_bot in raw_bots:
                        if not isinstance(raw_bot, dict):
                            continue
                        if raw_bot.get("name") == target_name:
                            raw_bot["thinking_default"] = thinking_default
                            break
    try:
        save_config(config)
    except Exception:
        _log.warning("failed to persist thinking_default change", exc_info=True)


def _persist_model(config: Config, model: str, *, bot_id: str | None = None) -> None:
    """Persist model selection to config.toml."""
    if bot_id and config.bots:
        new_bots = []
        for bot_cfg in config.bots:
            if bot_cfg.name == bot_id:
                new_bot = object.__new__(type(bot_cfg))
                object.__setattr__(new_bot, "name", bot_cfg.name)
                object.__setattr__(new_bot, "model", model)
                object.__setattr__(new_bot, "auth", bot_cfg.auth)
                object.__setattr__(new_bot, "workspace", bot_cfg.workspace)
                object.__setattr__(new_bot, "skills", bot_cfg.skills)
                object.__setattr__(new_bot, "channels", bot_cfg.channels)
                object.__setattr__(new_bot, "thinking_default", bot_cfg.thinking_default)
                new_bots.append(new_bot)
            else:
                new_bots.append(bot_cfg)
        object.__setattr__(config, "bots", new_bots)

        raw = getattr(config, "_raw_values", None)
        if isinstance(raw, dict):
            raw_bots = raw.get("bots")
            if isinstance(raw_bots, list):
                for raw_bot in raw_bots:
                    if not isinstance(raw_bot, dict):
                        continue
                    if raw_bot.get("name") == bot_id:
                        raw_bot["model"] = model
                        break

    elif bot_id:
        # Find the matching legacy telegram bot and update its model
        if config.telegram:
            new_bots = []
            for bot_cfg in config.telegram.bots:
                if bot_cfg.alias == bot_id:
                    new_bot = object.__new__(type(bot_cfg))
                    object.__setattr__(new_bot, "alias", bot_cfg.alias)
                    object.__setattr__(new_bot, "token", bot_cfg.token)
                    object.__setattr__(new_bot, "model", model)
                    new_bots.append(new_bot)
                else:
                    new_bots.append(bot_cfg)
            tc = config.telegram
            new_tc = type(tc)(
                token=tc.token,
                owner_id=tc.owner_id,
                allowed_users=tc.allowed_users,
                bootstrap=tc.bootstrap,
                bots=new_bots,
            )
            object.__setattr__(config, "telegram", new_tc)
    else:
        new_agent = type(config.agent)(
            model=model,
            model_list=config.agent.model_list,
            history_tool_results_keep=config.agent.history_tool_results_keep,
            compaction_enabled=config.agent.compaction_enabled,
            compaction_threshold=config.agent.compaction_threshold,
            compaction_keep_recent_messages=config.agent.compaction_keep_recent_messages,
            delegation_timeout=config.agent.delegation_timeout,
        )
        object.__setattr__(config, "agent", new_agent)

        raw = getattr(config, "_raw_values", None)
        if isinstance(raw, dict):
            raw_agent = raw.get("agent")
            if isinstance(raw_agent, dict):
                raw_agent["model"] = model

    try:
        save_config(config)
    except Exception:
        _log.warning("failed to persist model change", exc_info=True)


def build_new_confirmation(msg_count: int) -> tuple[str, list[list[tuple[str, str]]]]:
    """Build confirmation dialog for /new command."""
    text = f"Start a new session? Current session has {msg_count} messages."
    buttons = [
        [("Start New", "new:confirm"), ("Keep Current", "new:cancel")],
    ]
    return text, buttons


def build_new_success() -> tuple[str, list[list[tuple[str, str]]]]:
    """Build success card after session archive."""
    text = "<b>New session started</b>\nPrevious session archived."
    buttons = [
        [("Open History", "history:nav:0")],
    ]
    return text, buttons


def execute_new_session(chat: "Chat", chat_id: str) -> None:
    """Execute the new session action (archive + clear)."""
    chat._session_store.rotate(chat_id)
    chat._memory.delete_prefix(f"chat:{chat_id}:")
    from otto.lsp.symbols import clear_context as _clear_repo_ctx

    _clear_repo_ctx(chat_id)


async def _cmd_new(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    """Start new session with confirmation dialog."""
    _ = args, bot_id

    def _notify_session_reset() -> None:
        on_reset = getattr(renderer, "on_session_reset", None)
        if callable(on_reset):
            on_reset()

    fmt = getattr(renderer, "output_format", "telegram_html")

    if fmt == "markdown":
        # CLI: immediate action without confirmation
        execute_new_session(chat, chat_id)
        _notify_session_reset()
        await renderer.send_text("New session started.")
        return

    # Telegram: show confirmation dialog
    session = chat._session_store.load(chat_id)
    msg_count = len(session.messages) if hasattr(session.messages, "__len__") else 0

    if msg_count == 0:
        # No messages to archive, just start fresh
        _notify_session_reset()
        await renderer.send_text("New session ready.")
        return

    text, buttons = build_new_confirmation(msg_count)
    await renderer.send_with_buttons(text, buttons)


async def _cmd_clear(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    """Internal alias to _cmd_new for backward compatibility."""
    await _cmd_new(chat, chat_id, args, bot_id, renderer)


async def _cmd_resume(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    _ = bot_id
    ts = args.strip()
    if ts:
        ok = chat._session_store.restore(chat_id, ts)
        if ok:
            # Let the renderer replay history if it supports it
            on_restored = getattr(renderer, "on_session_restored", None)
            if callable(on_restored):
                on_restored(chat_id, chat._session_store)
            else:
                await renderer.send_text(f"Resumed session from {ts}.")
        else:
            await renderer.send_text(f"No archived session found for '{ts}'.")
        return

    archives = chat._session_store.list_archived(chat_id)
    if not archives:
        await renderer.send_text("No archived sessions.")
        return

    lines = ["**Archived sessions:**", ""]
    for i, arc in enumerate(archives, 1):
        preview = arc["preview"] or "(empty)"
        lines.append(f"{i}. `{arc['ts']}` — {arc['msg_count']} msgs — {preview}")
    lines.append("")
    lines.append("Use `/resume <timestamp>` to restore.")
    await renderer.send_text("\n".join(lines))


async def _cmd_history(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    _ = args, bot_id
    session = chat._session_store.load(chat_id)
    model = chat._active_model(chat_id, bot_id=bot_id)
    archives = chat._session_store.list_archived(chat_id)

    fmt = getattr(renderer, "output_format", "telegram_html")

    # Current session summary
    msg_count = len(session.messages)
    current_line = f"{msg_count} message{'s' if msg_count != 1 else ''} · {model}"

    if fmt != "telegram_html":
        # CLI: plain text
        lines = ["**Current Session**", current_line]
        if archives:
            lines.extend(["", "**Archived Sessions**"])
            for arc in archives[:5]:
                ts_display = f"{arc['ts'][:4]}-{arc['ts'][4:6]}-{arc['ts'][6:8]} {arc['ts'][9:11]}:{arc['ts'][11:13]}"
                lines.append(f"  {ts_display} · {arc['msg_count']} msgs")
        await renderer.send_text("\n".join(lines))
        return

    # Telegram: scrollable single-row buttons
    text_lines = ["**Current Session**", current_line]

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

    if archives:
        text_lines.extend(["", "**Recent Sessions**"])
        # Show 3 sessions, one per row
        for arc in archives[:3]:
            ts = arc["ts"]
            month = _ts_month(ts)
            day = ts[6:8]
            hour = ts[9:11]
            minute = ts[11:13]
            label = f"{month} {day} {hour}:{minute} · {arc['msg_count']} msgs"
            buttons.append([(label, f"history:resume:{ts}")])

        # Navigation row if more than 3 sessions
        if len(archives) > 3:
            buttons.append([("▲ Older", "history:nav:3")])

    await renderer.send_with_buttons("\n".join(text_lines), buttons)


def build_history_page(
    chat: "Chat", chat_id: str, offset: int
) -> tuple[str, list[list[tuple[str, str]]]]:
    """Build paginated history view data for navigation callbacks.

    Returns (text, buttons) tuple for in-place editing.
    """
    session = chat._session_store.load(chat_id)
    model = chat._active_model(chat_id)
    archives = chat._session_store.list_archived(chat_id)

    msg_count = len(session.messages)
    current_line = f"{msg_count} message{'s' if msg_count != 1 else ''} · {model}"

    text_lines = ["<b>Current Session</b>", current_line]
    buttons: list[list[tuple[str, str]]] = []

    if archives:
        text_lines.extend(["", "<b>Recent Sessions</b>"])
        # Clamp offset to valid range
        max_offset = max(0, len(archives) - 3)
        offset = max(0, min(offset, max_offset))

        for arc in archives[offset : offset + 3]:
            ts = arc["ts"]
            month = _ts_month(ts)
            day = ts[6:8]
            hour = ts[9:11]
            minute = ts[11:13]
            label = f"{month} {day} {hour}:{minute} · {arc['msg_count']} msgs"
            buttons.append([(label, f"history:resume:{ts}")])

        # Navigation row
        nav_buttons = []
        if offset + 3 < len(archives):
            nav_buttons.append(("▲ Older", f"history:nav:{offset + 3}"))
        if offset > 0:
            nav_buttons.append(("▼ Newer", f"history:nav:{max(0, offset - 3)}"))
        if nav_buttons:
            buttons.append(nav_buttons)

    return "\n".join(text_lines), buttons


def _ts_month(ts: str) -> str:
    """Convert YYYYMMDD to short month name."""
    months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
    try:
        m = int(ts[4:6])
        return months[m - 1] if 1 <= m <= 12 else "???"
    except (ValueError, IndexError):
        return "???"


def _is_path_within(path: Path, root: Path) -> bool:
    try:
        path.relative_to(root)
        return True
    except ValueError:
        return False


def _classify_skill_scope(
    skills_dir: Path,
    *,
    bot_name_hint: str | None = None,
) -> tuple[str | None, str | None]:
    """Return (scope, bot_name) for a skills directory.

    scope is one of: bot-local, global, builtin, None.
    """
    resolved = skills_dir.resolve(strict=False)
    global_root = (OTTO_HOME / "skills").resolve(strict=False)
    bots_root = (OTTO_HOME / "bots").resolve(strict=False)
    builtin_root = BUILTIN_SKILLS_DIR.resolve(strict=False)

    if _is_path_within(resolved, builtin_root):
        return "builtin", None
    if _is_path_within(resolved, global_root):
        return "global", None
    if _is_path_within(resolved, bots_root):
        rel = resolved.relative_to(bots_root)
        bot_name = rel.parts[0] if rel.parts else None
        return "bot-local", bot_name

    # Any remaining resolved skills dir is treated as bot-local workspace skills.
    return "bot-local", bot_name_hint


def _format_scoped_skills_list(
    skills_dirs: list[Path],
    *,
    filter: str = "",
    bot_name_hint: str | None = None,
) -> str:
    keyword = filter.strip().lower()
    bot_local: list[str] = []
    global_skills: list[str] = []

    seen_entries: set[tuple[str, str, str | None]] = set()
    for skills_dir in skills_dirs:
        scope, dir_bot_name = _classify_skill_scope(skills_dir, bot_name_hint=bot_name_hint)
        if scope not in {"bot-local", "global"}:
            continue

        for skill in discover(skills_dir):
            haystack = f"{skill.name} {skill.description} {skill.path}".lower()
            if keyword and keyword not in haystack:
                continue

            dedup_key = (scope, skill.name, dir_bot_name)
            if dedup_key in seen_entries:
                continue
            seen_entries.add(dedup_key)

            if scope == "bot-local":
                if dir_bot_name:
                    bot_local.append(
                        f"- `{skill.name}` — {skill.description} (bot: {dir_bot_name})"
                    )
                else:
                    bot_local.append(f"- `{skill.name}` — {skill.description}")
            else:
                global_skills.append(f"- `{skill.name}` — {skill.description}")

    if not bot_local and not global_skills:
        if keyword:
            return f"No bot-local or global skills matching '{filter.strip()}'."
        return "No bot-local or global skills installed."

    lines = ["**Installed Skills**", ""]
    lines.append("**Bot-local skills**")
    if bot_local:
        lines.extend(bot_local)
    else:
        lines.append("- (none)")

    lines.append("")
    lines.append("**Global skills**")
    if global_skills:
        lines.extend(global_skills)
    else:
        lines.append("- (none)")

    return "\n".join(lines)


def build_skills_main_view(
    skills_dirs: list[Path], bot_name_hint: str | None = None, page: int = 0, page_size: int = 5
) -> tuple[str, list[list[tuple[str, str]]]]:
    """Build skills list with card layout and pagination."""
    all_skills: list[tuple[str, str, str, Path]] = []

    for skills_dir in skills_dirs:
        scope, _ = _classify_skill_scope(skills_dir, bot_name_hint=bot_name_hint)
        if scope not in {"bot-local", "global"}:
            continue

        for skill in discover(skills_dir):
            scope_label = "local" if scope == "bot-local" else "global"
            all_skills.append((skill.name, skill.description, scope_label, skill.path))

    # Deduplicate by name
    seen: set[str] = set()
    unique_skills: list[tuple[str, str, str, Path]] = []
    for name, desc, scope, path in all_skills:
        if name not in seen:
            seen.add(name)
            unique_skills.append((name, desc, scope, path))

    total = len(unique_skills)
    start = page * page_size
    end = min(start + page_size, total)
    page_skills = unique_skills[start:end]

    if not unique_skills:
        return "No skills installed.", []

    lines = [
        f"<b>Skills</b>\n{total} installed",
        "",
    ]

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

    for name, desc, scope, path in page_skills:
        _ = desc, path
        lines.append(f"<b>{name}</b> · {scope}")
        # 1 per row: drill into skill detail
        buttons.append([(f"{name}", f"skills:detail:{name}:{page}")])

    if total > page_size:
        nav_row: list[tuple[str, str]] = []
        if page > 0:
            nav_row.append(("◀", f"skills:page:{page - 1}"))
        nav_row.append((f"{start + 1}-{end} of {total}", "skills:noop"))
        if end < total:
            nav_row.append(("▶", f"skills:page:{page + 1}"))
        buttons.append(nav_row)

    return "\n".join(lines), buttons


def build_skill_detail_view(
    name: str,
    skills_dirs: list[Path],
    bot_name_hint: str | None = None,
    *,
    back_page: int | None = None,
) -> tuple[str, list[list[tuple[str, str]]]] | None:
    """Build skill detail card with description and actions."""
    for skills_dir in skills_dirs:
        for skill in discover(skills_dir):
            if skill.name == name:
                scope, _ = _classify_skill_scope(skills_dir, bot_name_hint=bot_name_hint)
                scope_label = "local" if scope == "bot-local" else "global"
                lines = [
                    f"<b>{skill.name}</b> · {scope_label}",
                    "",
                    skill.description or "<i>No description</i>",
                ]
                back_action = "skills:back"
                if back_page is not None:
                    back_action = f"skills:page:{back_page}"

                buttons = [
                    [("Load this skill", f"skills:load:{name}")],
                    [("« Back", back_action)],
                ]
                return "\n".join(lines), buttons
    return None


async def _cmd_skills(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    """List bot-local and global installed skills."""
    from otto.tools import _resolve_skill_dirs

    _ = chat_id, bot_id
    resolved_dirs = _resolve_skill_dirs(chat._skills_dirs)

    fmt = getattr(renderer, "output_format", "telegram_html")
    if fmt == "markdown":
        await renderer.send_text(
            _format_scoped_skills_list(
                resolved_dirs,
                filter=args.strip(),
                bot_name_hint=chat._bot_name,
            )
        )
    else:
        text, buttons = build_skills_main_view(resolved_dirs, bot_name_hint=chat._bot_name)
        await renderer.send_with_buttons(text, buttons)


def _format_subagents_plain(profiles: list[SubagentProfile]) -> str:
    if not profiles:
        return "No subagents configured. Add markdown files under `~/.otto/agents/*.md`."

    lines = ["**Subagents**", ""]
    for profile in profiles:
        lines.append(f"- **{profile.name}** — {profile.description}")
        lines.append(f"  model: `{profile.model}`")
        if profile.fallback:
            lines.append(f"  fallback: `{profile.fallback}`")
    return "\n".join(lines)


def build_subagents_main_view(
    profiles: list[SubagentProfile],
    *,
    page: int = 0,
    page_size: int = 6,
) -> tuple[str, list[list[tuple[str, str]]]]:
    if not profiles:
        text = "<b>Subagents</b>\n0 configured\n\nAdd markdown files under <code>~/.otto/agents/*.md</code>."
        return text, [[("Close", "subagents:cancel")]]

    total = len(profiles)
    page = max(0, page)
    start = page * page_size
    end = min(start + page_size, total)
    page_profiles = profiles[start:end]

    lines = [f"<b>Subagents</b>\n{total} configured", ""]
    buttons: list[list[tuple[str, str]]] = []

    for profile in page_profiles:
        lines.append(f"<b>{profile.name}</b> · <code>{profile.model}</code>")
        lines.append(profile.description)
        lines.append("")
        buttons.append([(profile.name, f"subagents:detail:{profile.name}:{page}")])

    if total > page_size:
        nav: list[tuple[str, str]] = []
        if page > 0:
            nav.append(("◀", f"subagents:page:{page - 1}"))
        nav.append((f"{start + 1}-{end} of {total}", "subagents:noop"))
        if end < total:
            nav.append(("▶", f"subagents:page:{page + 1}"))
        buttons.append(nav)

    buttons.append([("Close", "subagents:cancel")])
    return "\n".join(lines).rstrip(), buttons


def build_subagent_detail_view(
    name: str,
    profiles: list[SubagentProfile],
    *,
    back_page: int = 0,
) -> tuple[str, list[list[tuple[str, str]]]] | None:
    for profile in profiles:
        if profile.name != name:
            continue

        lines = [
            f"<b>{profile.name}</b>",
            "",
            profile.description,
            "",
            f"<b>Primary model</b>: <code>{profile.model}</code>",
        ]
        if profile.fallback:
            lines.append(f"<b>Fallback</b>: <code>{profile.fallback}</code>")
        prompt_text = getattr(profile, "prompt", None)
        if isinstance(prompt_text, str) and prompt_text.strip():
            prompt_preview = prompt_text.strip()
            if len(prompt_preview) > 500:
                prompt_preview = prompt_preview[:497] + "..."
            lines.extend(["", "<b>Persona instructions</b>", prompt_preview])

        buttons = [
            [("« Back", f"subagents:page:{max(0, back_page)}")],
            [("Close", "subagents:cancel")],
        ]
        return "\n".join(lines), buttons

    return None


async def _cmd_subagents(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    _ = chat, chat_id, args, bot_id
    profiles = load_subagent_profiles()
    fmt = getattr(renderer, "output_format", "telegram_html")
    if fmt == "markdown":
        await renderer.send_text(_format_subagents_plain(profiles))
        return

    text, buttons = build_subagents_main_view(profiles)
    await renderer.send_with_buttons(text, buttons)


def _agent_duration_label(seconds: float | None) -> str | None:
    if seconds is None:
        return None
    if seconds < 60:
        return f"{seconds:.0f}s"
    if seconds < 3600:
        return f"{seconds / 60:.1f}m"
    return f"{seconds / 3600:.1f}h"


def build_agents_main_view(
    jobs: list[dict], view: str = "all", page: int = 0, page_size: int = 3
) -> tuple[str, list[list[tuple[str, str]]]]:
    """Build agents view with filter tabs and job list."""
    statuses: dict[str, set[str]] = {
        "all": {"ready", "in_progress", "delivered", "done", "failed", "blocked", "cancelled"},
        "active": {"in_progress", "ready"},
        "done": {"done", "delivered"},
        "failed": {"failed", "cancelled"},
    }

    filtered = [j for j in jobs if j.get("status") in statuses.get(view, statuses["all"])]
    filtered.sort(key=lambda j: str(j.get("createdAt", "")), reverse=True)

    total = len(filtered)
    start = page * page_size
    end = min(start + page_size, total)
    page_jobs = filtered[start:end]

    view_labels = {
        "all": "all jobs",
        "active": "running + pending",
        "done": "past jobs",
        "failed": "past jobs",
    }

    lines = [
        f"<b>Agents ({view_labels.get(view, 'all jobs')})</b>",
        "",
    ]

    if not filtered:
        lines.append("<i>No jobs in this view</i>")
    else:
        for job in page_jobs:
            status = str(job.get("status", "unknown"))
            job_id = job.get("id", "???")
            task = str(job.get("description", job.get("title", "")))
            task = " ".join(task.split())
            preview = task[:40] + ("..." if len(task) > 40 else "")
            lines.append(f"<code>{job_id}</code> · {status.upper()}")
            lines.append(f"   {preview}")

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

    # Filter tabs
    filter_row: list[tuple[str, str]] = []
    for key in ["all", "active", "done", "failed"]:
        label = view_labels[key]
        if key == view:
            label = f"[{label}]"
        action = "past" if key in {"done", "failed"} else key
        filter_row.append((label, f"agents:{action}"))
    buttons.append(filter_row)

    # Job action buttons
    for job in page_jobs:
        job_id = job.get("id", "")
        if job_id:
            buttons.append([(f"{job_id}", f"agents:job:{job_id}")])

    # Pagination
    if total > page_size:
        nav_row: list[tuple[str, str]] = []
        if page > 0:
            nav_row.append(("◀", f"agents:page:{view}:{page - 1}"))
        nav_row.append((f"{start + 1}-{end} of {total}", "agents:noop"))
        if end < total:
            nav_row.append(("▶", f"agents:page:{view}:{page + 1}"))
        buttons.append(nav_row)

    return "\n".join(lines), buttons


def build_agent_job_detail(job: dict) -> tuple[str, list[list[tuple[str, str]]]]:
    """Build job detail view with actions."""
    job_id = job.get("id", "???")
    status = str(job.get("status", "unknown"))
    task = str(job.get("description", job.get("title", "")))

    lines = [
        f"<b>{job_id}</b>",
        f"Status: {status.upper()}",
        "",
        task[:200] + ("..." if len(task) > 200 else ""),
    ]

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

    action_row: list[tuple[str, str]] = []
    if status in {"in_progress", "ready"}:
        action_row.append(("Cancel", f"agents:cancel:{job_id}"))
    if status in {"failed", "cancelled"}:
        action_row.append(("Retry", f"agents:retry:{job_id}"))
    if action_row:
        buttons.append(action_row)

    buttons.append([("« Back", "agents:back")])

    return "\n".join(lines), buttons


async def _cmd_agents(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    _ = chat, chat_id, bot_id
    mode = args.strip().lower()
    if mode in {"all", "*", "full"}:
        view = "all"
    elif mode in {"past", "done", "history"}:
        view = "past"
    else:
        view = "active"

    from otto.orchestrator import get_orchestrator

    jobs = await get_orchestrator().list_jobs()

    fmt = getattr(renderer, "output_format", "telegram_html")
    if fmt == "telegram_html":
        # Map old view names to new ones
        view_map = {"past": "done", "active": "active", "all": "all"}
        mapped_view = view_map.get(view, "all")
        text, buttons = build_agents_main_view(jobs, mapped_view)
        await renderer.send_with_buttons(text, buttons)
    else:
        # CLI text output
        statuses: dict[str, set[str]] = {
            "active": {"in_progress", "ready"},
            "past": {"done", "failed", "cancelled"},
            "all": {"ready", "in_progress", "delivered", "done", "failed", "blocked", "cancelled"},
        }
        filtered = [j for j in jobs if j.get("status") in statuses[view]]
        filtered.sort(key=lambda j: str(j.get("createdAt", "")), reverse=True)

        if not filtered:
            msg = {
                "active": "No running or pending agent jobs.",
                "past": "No completed/failed/cancelled agent jobs yet.",
                "all": "No delegated agent jobs yet.",
            }[view]
            await renderer.send_text(msg)
            return

        title = {
            "active": "**Agents (running + pending)**",
            "past": "**Agents (past jobs)**",
            "all": "**Agents (all jobs)**",
        }[view]
        lines = [title, ""]
        for job in filtered:
            status = str(job.get("status", "unknown"))
            task = str(job.get("description", job.get("title", "")))
            task = " ".join(task.split())
            preview = task[:72] + ("..." if len(task) > 72 else "")
            lines.append(f"`{job['id']}` — {status.upper()}: {preview}")

            if view in {"past", "all"}:
                metrics = job.get("contract", {}).get("metrics", {})
                attempts = job.get("x-otto", {}).get("attempts")
                if attempts is not None:
                    lines.append(f"attempts: {attempts}")
                duration = metrics.get("duration")
                if isinstance(duration, (int, float)):
                    if duration >= 60:
                        lines.append(f"duration: {duration / 60:.1f}m")
                    else:
                        lines.append(f"duration: {duration:.0f}s")

        lines.extend(["", "Use `/agents all` or `/agents past` for other views."])
        await renderer.send_text("\n".join(lines))


async def _cmd_stop(
    chat: "Chat", chat_id: str, args: str, bot_id: str | None, renderer: Renderer
) -> None:
    _ = args
    if chat.stop(chat_id):
        await renderer.send_text("Stopping...")
    else:
        await renderer.send_text("Nothing running.")


def _model_supports_vision(model: str) -> bool:
    """Check if a model supports vision/image inputs via litellm model info."""
    try:
        import litellm

        litellm_model = model.split("/", 1)[-1] if "/" in model else model
        info = litellm.get_model_info(model=litellm_model)
        return bool(info.get("supports_vision", False))
    except Exception:
        return False


def _build_content_blocks(
    text: str,
    attachments: list[dict],
    model: str,
) -> list[dict]:
    """Build OpenAI-style content blocks from text and attachments.

    Returns a list of content block dicts suitable for the user message
    sent to the LLM. Image attachments are converted to image_url blocks
    with data URIs; if the model lacks vision support, a text note is
    substituted instead.
    """
    blocks: list[dict] = [{"type": "text", "text": text or ""}]
    has_vision = _model_supports_vision(model)
    for att in attachments:
        if att["type"] == "image":
            if has_vision:
                blocks.append(
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:{att['mime']};base64,{att['data']}"},
                    }
                )
            else:
                blocks.append(
                    {
                        "type": "text",
                        "text": (
                            "(User sent an image, but the current model does not support vision.)"
                        ),
                    }
                )
        elif att["type"] == "text":
            label = att.get("filename", "attachment")
            blocks.append({"type": "text", "text": f"[{label}]\n{att['content']}"})
    return blocks


def _attachment_summary(text: str, attachments: list[dict]) -> str:
    """Build a text-only summary for session storage (no base64 data)."""
    parts = [text] if text else []
    for att in attachments:
        if att["type"] == "image":
            parts.append("[User sent an image]")
        elif att["type"] == "text":
            label = att.get("filename", "attachment")
            parts.append(f"[{label}] {att['content']}")
    return "\n".join(parts) if parts else ""


def _count_tokens(messages: list[dict], model: str = "claude-sonnet-4-5-20250514") -> int:
    """Count tokens in messages using litellm, falling back to len-based estimate."""
    try:
        import litellm

        litellm_model = model.split("/", 1)[-1] if "/" in model else model
        return litellm.token_counter(model=litellm_model, messages=messages)
    except Exception:
        total = 0
        for msg in messages:
            content = msg.get("content", "")
            if isinstance(content, str):
                total += len(content)
            elif isinstance(content, list):
                for part in content:
                    if isinstance(part, dict):
                        total += len(str(part.get("text", "")))
            # Account for tool_calls
            if "tool_calls" in msg:
                for tc in msg["tool_calls"]:
                    fn = tc.get("function", {})
                    total += len(str(fn.get("name", ""))) + len(str(fn.get("arguments", "")))
        return len(messages) * 500


def _cap_session_history(
    messages: list[dict], max_tokens: int = 30_000, min_keep: int = 6
) -> list[dict]:
    """Trim session history from the front to fit within a token budget.

    Preserves at least min_keep recent messages. Never splits an assistant
    message with tool_calls from its subsequent tool result messages.
    """
    if not messages:
        return messages

    token_count = _count_tokens(messages)
    if token_count <= max_tokens:
        return messages

    if len(messages) <= min_keep:
        return messages

    # Build "turn groups" — sequences of messages that must stay together.
    # A turn group is one of:
    #   - A user message (standalone)
    #   - An assistant message WITHOUT tool_calls (standalone)
    #   - An assistant message WITH tool_calls + all following tool messages
    groups: list[list[int]] = []
    i = 0
    while i < len(messages):
        msg = messages[i]
        if msg.get("role") == "assistant" and "tool_calls" in msg:
            # Collect this assistant + all following tool messages
            group = [i]
            j = i + 1
            while j < len(messages) and messages[j].get("role") == "tool":
                group.append(j)
                j += 1
            groups.append(group)
            i = j
        else:
            groups.append([i])
            i += 1

    # Find the minimum number of groups from the end that covers min_keep messages
    kept_msg_count = 0
    min_groups_from_end = 0
    for g in reversed(groups):
        if kept_msg_count >= min_keep:
            break
        kept_msg_count += len(g)
        min_groups_from_end += 1

    # Try trimming groups from the front until under budget
    trim_from = 0
    max_trim = len(groups) - min_groups_from_end
    for trim_from in range(1, max_trim + 1):
        kept_indices = []
        for g in groups[trim_from:]:
            kept_indices.extend(g)
        kept = [messages[idx] for idx in kept_indices]
        if _count_tokens(kept) <= max_tokens:
            break
    else:
        # Even trimming to min_keep didn't fit — just keep min_keep messages
        trim_from = max_trim

    kept_indices = []
    for g in groups[trim_from:]:
        kept_indices.extend(g)
    result = [messages[idx] for idx in kept_indices]

    if trim_from > 0:
        result.insert(
            0,
            {
                "role": "user",
                "content": "[Earlier conversation history was trimmed to save context]",
            },
        )

    return result


def _collapse_tool_rounds(messages: list[dict], original_count: int) -> list[dict]:
    """Remove intermediate tool call/result messages added during agent.run().

    Everything from original_count onward was appended by the agent loop
    (assistant+tool_calls and tool result messages). Strip those out — the
    final assistant text response gets appended separately after this call.
    """
    if original_count >= len(messages):
        return messages

    # Keep everything before the agent run started
    return list(messages[:original_count])


COMMANDS: dict[str, CommandHandler] = {
    "help": _cmd_help,
    "status": _cmd_status,
    "model": _cmd_model,
    "thinking": _cmd_thinking,
    "new": _cmd_new,
    "clear": _cmd_clear,
    "resume": _cmd_resume,
    "history": _cmd_history,
    "skills": _cmd_skills,
    "subagents": _cmd_subagents,
    "agents": _cmd_agents,
    "stop": _cmd_stop,
    "setup": None,  # Lazy import — resolved at dispatch time
}


class Chat:
    def __init__(
        self,
        config: Config,
        gateway: Gateway,
        session_store: SessionStore,
        memory: Memory,
        pulse_runner: PulseRunner | None = None,
        *,
        bot_name: str | None = None,
        workspace_mode: str | None = None,
        workspace_root: str | None = None,
        workspace_sandbox: str | None = None,
        workspace_sandbox_network: bool | None = None,
        skills_dirs: list[str | Path] | None = None,
    ):
        self._config = config
        self._gateway = gateway
        self._session_store = session_store
        self._memory = memory
        self._pulse_runner = pulse_runner
        self._bot_name = bot_name
        self._workspace_mode = workspace_mode
        self._workspace_root = workspace_root
        self._workspace_sandbox = workspace_sandbox
        self._workspace_sandbox_network = workspace_sandbox_network
        self._skills_dirs = [Path(str(path)).expanduser() for path in (skills_dirs or [])]
        self._model_overrides: dict[str, str] = {}
        self._thinking_overrides: dict[str, str] = {}
        self._thinking_default = self._resolve_thinking_default()
        self._auth_storage = AuthStorage()
        self._active_tasks: dict[str, asyncio.Task] = {}

    @staticmethod
    def _context_key(chat_id: str, *, bot_id: str | None = None) -> str:
        """Return the context key for a chat.

        With per-bot Chat instances, scoping is handled by having separate
        Chat/Memory/SessionStore per bot. The bot_id parameter is accepted
        for backward compatibility with telegram.py but ignored.
        """
        return chat_id

    def _resolve_thinking_default(self) -> str:
        bots = list(getattr(self._config, "bots", []) or [])
        if not bots:
            return "off"

        target = None
        if self._bot_name:
            for bot in bots:
                if bot.name == self._bot_name:
                    target = bot
                    break
        if target is None:
            target = bots[0]

        normalized = normalize_thinking_level(getattr(target, "thinking_default", "off"))
        return normalized if normalized in {"off", "low", "medium", "high", "xhigh"} else "off"

    def _active_bot_config(self, bot_id: str | None = None) -> BotConfig | None:
        bots = list(getattr(self._config, "bots", []) or [])
        if not bots:
            return None
        target = bot_id or self._bot_name
        if target:
            for bot in bots:
                if bot.name == target:
                    return bot
        return bots[0]

    def _default_model(self, bot_id: str | None = None) -> str:
        bot_cfg = self._active_bot_config(bot_id)
        if bot_cfg and bot_cfg.model:
            return _canonical_model_id(bot_cfg.model)
        return _canonical_model_id(self._config.agent.model)

    def _active_model(self, chat_id: str, *, bot_id: str | None = None) -> str:
        return _canonical_model_id(self._model_overrides.get(chat_id, self._default_model(bot_id)))

    def resolve_config_user(
        self, *, telegram_id: int | None = None, discord_id: int | None = None
    ) -> UserConfig | None:
        return resolve_user(self._config.users, telegram_id=telegram_id, discord_id=discord_id)

    def is_authorized_user(
        self,
        *,
        telegram_id: int | None = None,
        discord_id: int | None = None,
        bot_id: str | None = None,
    ) -> bool:
        bot_cfg = self._active_bot_config(bot_id)
        if bot_cfg is None:
            return False
        user = self.resolve_config_user(telegram_id=telegram_id, discord_id=discord_id)
        if user is None:
            return False
        if user.name == bot_cfg.auth.owner:
            return True
        return user.name in bot_cfg.auth.allowed_users

    def try_bootstrap_user(
        self,
        *,
        telegram_id: int | None = None,
        discord_id: int | None = None,
        username_hint: str | None = None,
        bot_id: str | None = None,
    ) -> tuple[bool, str | None]:
        bot_cfg = self._active_bot_config(bot_id)
        if bot_cfg is None:
            return False, None
        if bot_cfg.auth.bootstrap != "first_user":
            return False, None
        if bot_cfg.auth.owner and bot_cfg.auth.owner.strip():
            return False, None

        existing = self.resolve_config_user(telegram_id=telegram_id, discord_id=discord_id)
        if existing is not None:
            user_name = existing.name
            updated_users = list(self._config.users)
        else:
            selected_id = telegram_id if telegram_id is not None else discord_id
            if selected_id is None:
                return False, None
            base_name = (username_hint or "").strip() or f"user_{selected_id}"
            taken = {user.name for user in self._config.users}
            user_name = base_name
            suffix = 2
            while user_name in taken:
                user_name = f"{base_name}_{suffix}"
                suffix += 1
            new_user = UserConfig(name=user_name, telegram_id=telegram_id, discord_id=discord_id)
            updated_users = [*self._config.users, new_user]

        old_auth = bot_cfg.auth
        new_allowed = list(old_auth.allowed_users)
        if user_name not in new_allowed:
            new_allowed.insert(0, user_name)
        new_auth = BotAuthConfig(owner=user_name, allowed_users=new_allowed, bootstrap="disabled")

        updated_bot = BotConfig(
            name=bot_cfg.name,
            model=bot_cfg.model,
            auth=new_auth,
            workspace=bot_cfg.workspace,
            skills=bot_cfg.skills,
            channels=bot_cfg.channels,
            thinking_default=bot_cfg.thinking_default,
        )

        updated_bots = [updated_bot if b.name == bot_cfg.name else b for b in self._config.bots]

        object.__setattr__(self._config, "users", updated_users)
        object.__setattr__(self._config, "bots", updated_bots)

        raw = getattr(self._config, "_raw_values", None)
        if isinstance(raw, dict):
            raw_users = raw.get("users")
            if isinstance(raw_users, list):
                for raw_user in raw_users:
                    if not isinstance(raw_user, dict):
                        continue
                    if raw_user.get("name") != user_name:
                        continue
                    if telegram_id is not None:
                        raw_user["telegram_id"] = telegram_id
                    if discord_id is not None:
                        raw_user["discord_id"] = discord_id
                    break
                else:
                    entry: dict[str, Any] = {"name": user_name}
                    if telegram_id is not None:
                        entry["telegram_id"] = telegram_id
                    if discord_id is not None:
                        entry["discord_id"] = discord_id
                    raw_users.append(entry)

            raw_bots = raw.get("bots")
            if isinstance(raw_bots, list):
                for raw_bot in raw_bots:
                    if not isinstance(raw_bot, dict):
                        continue
                    if raw_bot.get("name") != bot_cfg.name:
                        continue
                    raw_auth = raw_bot.get("auth")
                    if not isinstance(raw_auth, dict):
                        raw_auth = {}
                        raw_bot["auth"] = raw_auth
                    raw_auth["owner"] = user_name
                    raw_auth["allowed_users"] = list(new_allowed)
                    raw_auth["bootstrap"] = "disabled"
                    break

        try:
            save_config(self._config)
        except Exception:
            _log.warning("failed to persist bootstrap config change", exc_info=True)

        return True, user_name

    def stop(self, chat_id: str, bot_id: str | None = None) -> bool:
        """Cancel any active agent run for this chat. Returns True if a task was cancelled."""
        task = self._active_tasks.get(chat_id)
        if task and not task.done():
            task.cancel()
            return True
        return False

    async def close(self) -> None:
        """Release chat-scoped resources."""
        return

    async def handle_message(
        self,
        chat_id: str,
        text: str,
        renderer: Renderer,
        bot_id: str | None = None,
        *,
        background: bool = False,
        attachments: list[dict] | None = None,
    ) -> None:
        """Main entry point. Routes slash commands or runs the agent.

        Args:
            bot_id: Accepted for backward compatibility but ignored. Scoping is
                    handled by per-bot Chat instances.
            background: True when invoked by the scheduler. Enables the send_message
                        tool so the agent can proactively notify the user. During normal
                        chat the streaming response already reaches the user via flush(),
                        so send_message is omitted to prevent duplicate delivery loops.
        """
        if text.startswith("/"):
            command_text = text[1:].strip()
            if not command_text:
                await renderer.show_error("Unknown command: /")
                return
            parts = command_text.split(None, 1)
            command = parts[0]
            args = parts[1] if len(parts) > 1 else ""
            await self.handle_command(
                chat_id=chat_id,
                command=command,
                args=args,
                renderer=renderer,
                bot_id=bot_id,
            )
            return

        task = asyncio.current_task()
        if task:
            self._active_tasks[chat_id] = task

        try:
            # Save user message to full session history (text-only, no base64)
            session = self._session_store.load(chat_id)
            if attachments:
                session_text = _attachment_summary(text, attachments)
            else:
                session_text = text
            session.messages.append({"role": "user", "content": session_text})
            self._session_store.save(session)

            model = self._active_model(chat_id, bot_id=bot_id)
            if is_acp_model(model):
                await renderer.show_error(format_acp_chat_rejection(model))
                return

            # Build LLM context with pruning + compaction
            tool_results_keep = self._config.agent.history_tool_results_keep
            llm_messages = self._session_store.load_for_llm(
                chat_id, tool_results_keep=tool_results_keep
            )
            llm_messages = _cap_session_history(llm_messages)

            # Replace last user message with multimodal content blocks if needed
            if attachments and llm_messages:
                content_blocks = _build_content_blocks(text, attachments, model)
                # Find the last user message and replace its content
                for i in range(len(llm_messages) - 1, -1, -1):
                    if llm_messages[i].get("role") == "user":
                        llm_messages[i] = dict(llm_messages[i])
                        llm_messages[i]["content"] = content_blocks
                        break

            memory_context: list[dict] = []
            try:
                memory_context = self._memory.search(text, limit=5)
            except Exception:
                memory_context = []

            identity = None
            try:
                if BOARD_CONFIG.exists():
                    board_cfg = read_board_config(str(BOARD_CONFIG))
                    if board_cfg.agent and board_cfg.agent.identity:
                        identity = board_cfg.agent.identity
            except Exception:
                identity = None
            fmt = getattr(renderer, "output_format", "telegram_html")

            # Retrieve cached repo context for system prompt injection.
            from otto.lsp.symbols import format_for_prompt, get_context

            repo_ctx = get_context(chat_id)
            repo_ctx_str = format_for_prompt(repo_ctx) if repo_ctx else None

            on_model_change = getattr(renderer, "on_model_change", None)
            if callable(on_model_change):
                on_model_change(model)
            thinking_level = self._thinking_overrides.get(chat_id, self._thinking_default)
            reasoning_effort = reasoning_effort_for_model(model, thinking_level)
            gateway_tools = self._gateway.tools()

            async def _notify(text: str) -> None:
                await renderer.send_text(text)

            async def _send_file(path: str, caption: str | None = None) -> None:
                await renderer.send_file(path, caption)

            # Build base tools first so delegation can pass them to sub-agents.
            base_tools = gateway_tools
            session_tools = create_session_tools(
                chat_id=chat_id,
                bot_id=bot_id or self._bot_name,
                pulse_runner=self._pulse_runner,
                notify=_notify if background else None,
                delegate_notify=_notify,
                send_file=_send_file,
                workspace_mode=self._workspace_mode,
                workspace_root=self._workspace_root,
                workspace_sandbox=self._workspace_sandbox,
                workspace_sandbox_network=self._workspace_sandbox_network,
                base_tools=base_tools,
                session_model=model,
                auth_storage=self._auth_storage,
                output_format=fmt,
            )
            all_tools = gateway_tools + session_tools
            active_tool_names = list(dict.fromkeys(tool.name for tool in all_tools))

            system_prompt = build_system_prompt(
                memory_context=memory_context,
                identity=identity,
                output_format=fmt,
                repo_context=repo_ctx_str,
                active_tools=active_tool_names,
                skills_dirs=self._skills_dirs or None,
            )

            result = await run(
                model=model,
                system=system_prompt,
                messages=llm_messages,
                tools=all_tools,
                auth_storage=self._auth_storage,
                compaction_enabled=self._config.agent.compaction_enabled,
                compaction_threshold=self._config.agent.compaction_threshold,
                compaction_keep_recent_messages=self._config.agent.compaction_keep_recent_messages,
                reasoning_effort=reasoning_effort,
                on_text=renderer.on_text,
                on_tool_start=renderer.on_tool_start,
                on_tool_end=renderer.on_tool_end,
            )
            await renderer.flush()

            # Save assistant response to full session (reload to get latest)
            session = self._session_store.load(chat_id)
            for turn_idx, turn in enumerate(result.turns):
                turn_tool_calls = turn.tool_calls or []
                if not turn_tool_calls:
                    continue

                assistant_tool_calls: list[dict[str, Any]] = []
                tool_result_messages: list[dict[str, str]] = []
                for call_idx, call in enumerate(turn_tool_calls):
                    tool_call_id = str(call.get("id") or f"tc_{turn_idx}_{call_idx}")
                    call_name = str(call.get("name", ""))
                    call_args = call.get("args")
                    if not isinstance(call_args, dict):
                        call_args = {}

                    assistant_tool_calls.append(
                        {
                            "id": tool_call_id,
                            "type": "function",
                            "function": {
                                "name": call_name,
                                "arguments": json.dumps(call_args),
                            },
                        }
                    )

                    call_result_raw = call.get("result")
                    call_result = "" if call_result_raw is None else str(call_result_raw)
                    if len(call_result) > 500:
                        call_result = f"{call_result[:497]}..."
                    tool_result_messages.append(
                        {
                            "role": "tool",
                            "tool_call_id": tool_call_id,
                            "name": call_name,
                            "content": call_result,
                        }
                    )

                assistant_message: dict[str, Any] = {
                    "role": "assistant",
                    "tool_calls": assistant_tool_calls,
                }
                if turn.text:
                    assistant_message["content"] = turn.text
                session.messages.append(assistant_message)
                session.messages.extend(tool_result_messages)

            session.messages.append({"role": "assistant", "content": result.text})
            self._session_store.save(session)

        finally:
            if task and self._active_tasks.get(chat_id) is task:
                self._active_tasks.pop(chat_id, None)

    async def handle_command(
        self,
        chat_id: str,
        command: str,
        args: str,
        renderer: Renderer,
        bot_id: str | None = None,
    ) -> None:
        """Dispatch slash commands."""
        name = command.strip().lstrip("/").lower()

        # /setup — direct users to CLI TUI (setup wizard cannot run in-chat)
        if name == "setup":
            await renderer.send_text(
                "To reconfigure Otto, run <code>otto setup</code> from your terminal."
            )
            return

        handler = COMMANDS.get(name)
        if handler:
            await handler(self, chat_id, args, bot_id, renderer)
        else:
            await renderer.show_error(f"Unknown command: /{name}")
