import asyncio
import base64
import contextlib
import io
import secrets
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from otto.chat import (
    Chat,
    Renderer,
    build_agent_job_detail,
    build_agents_main_view,
    build_help_category_view,
    build_help_main_view,
    build_history_page,
    build_new_success,
    build_provider_picker,
    build_subagent_detail_view,
    build_subagents_main_view,
    execute_new_session,
    get_provider_model_buttons,
)
from otto.config import BotConfig, ChannelConfig, Config
from otto.log import get_logger
from otto.subagents import load_subagent_profiles

log = get_logger("otto.discord")

_DISCORD_MAX_MESSAGE_LEN = 2000
_CALLBACK_TOKEN_TTL_SECONDS = 30 * 60
_STATUS_WORKING_THRESHOLD_SECONDS = 8.0
_STATUS_WORKING_REFRESH_SECONDS = 5.0
_STATUS_TICK_SECONDS = 1.0
_STATUS_MIN_EDIT_INTERVAL_SECONDS = 1.0
_MESSAGE_DEBOUNCE_SECONDS = 2.0
_GENERIC_USER_ERROR = "There was an error with this request. Check otto logs for more information."
_BUSY_CHAT_MESSAGE = "Still working on your previous message. Send /stop to cancel it."
_UNAUTHORIZED_MESSAGE = "You are not authorized to use this bot."


def _monotonic() -> float:
    """Monotonic clock used for internal bot timestamps.

    Tests may patch this without affecting asyncio's event loop clock.
    """

    return time.monotonic()


@dataclass(slots=True)
class _CallbackToken:
    payload: str
    expires_at: float


@dataclass(slots=True)
class _SteeringRunState:
    pending_inputs: list[str] = field(default_factory=list)
    steering_cancelled: bool = False
    stop_requested: bool = False
    tools_completed: bool = False
    cached_tool_context: str | None = None
    renderer: "DiscordRenderer | None" = None


@dataclass(slots=True)
class _StatusState:
    message: Any | None = None
    message_id: int | None = None
    status_text: str | None = None
    state: str = "idle"
    run_active: bool = False
    run_started_at: float | None = None
    last_working_update_at: float = 0.0
    last_edit_at: float = 0.0
    suppressed: bool = False


@dataclass(slots=True)
class _PendingMessageBatch:
    chat_id: str
    trigger_message: Any
    parts: list[str] = field(default_factory=list)


@dataclass(slots=True)
class _UserFacingError:
    category: str
    user_message: str


def _find_split_index(text: str, max_len: int) -> int:
    if max_len <= 0:
        return 1
    window = text[:max_len]
    if not window:
        return 1
    for separator in ("\n\n", "\n"):
        split_index = window.rfind(separator)
        if split_index > 0:
            return split_index + len(separator)
    for separator in (" ", "\t"):
        split_index = window.rfind(separator)
        if split_index > 0:
            return split_index + 1
    return max_len


def _split_text_for_discord(text: str, max_len: int = _DISCORD_MAX_MESSAGE_LEN) -> list[str]:
    if not text:
        return []
    chunk_len = max(1, int(max_len))
    if len(text) <= chunk_len:
        return [text]

    chunks: list[str] = []
    remaining = text
    while remaining:
        if len(remaining) <= chunk_len:
            chunks.append(remaining)
            break
        split_index = _find_split_index(remaining, chunk_len)
        chunks.append(remaining[:split_index])
        remaining = remaining[split_index:]
    return chunks


class DiscordRenderer:
    output_format = "markdown"

    def __init__(
        self,
        target: Any,
        *,
        max_len: int = _DISCORD_MAX_MESSAGE_LEN,
        callback_encoder: Callable[[str], Awaitable[str]] | None = None,
    ):
        self._target = target
        self._max_len = max(1, int(max_len))
        self._buffer: list[str] = []
        self._active_tool_calls = 0
        self._had_tool_calls = False
        self._tool_names_used: list[str] = []
        self._callback_encoder = callback_encoder
        self._local_callback_tokens: dict[str, str] = {}

    def on_text(self, chunk: str) -> None:
        self._buffer.append(chunk)

    def on_tool_start(self, name: str, args: dict) -> None:
        _ = args
        self._tool_names_used.append(name)
        self._active_tool_calls += 1
        self._had_tool_calls = True
        self._buffer.clear()

    def on_tool_end(self, name: str, result: str) -> None:
        _ = name, result
        if self._active_tool_calls > 0:
            self._active_tool_calls -= 1

    def has_active_tool_call(self) -> bool:
        return self._active_tool_calls > 0

    def had_tool_calls(self) -> bool:
        return self._had_tool_calls

    def all_tools_idle(self) -> bool:
        return self._had_tool_calls and self._active_tool_calls == 0

    def tool_call_count(self) -> int:
        return len(self._tool_names_used)

    def resolve_callback_data(self, custom_id: str) -> str | None:
        if custom_id.startswith("cb:"):
            return self._local_callback_tokens.get(custom_id.split(":", 1)[1])
        return custom_id

    async def send_text(self, text: str) -> None:
        normalized = self._normalize_text(text)
        for chunk in _split_text_for_discord(normalized, self._max_len):
            await self._send_content(chunk, components=None)

    async def send_with_buttons(self, text: str, buttons: list[list[tuple[str, str]]]) -> None:
        normalized = self._normalize_text(text)
        rows = await self._encode_buttons(buttons)
        chunks = _split_text_for_discord(normalized, self._max_len)
        if not chunks:
            chunks = [""]

        for chunk in chunks[:-1]:
            await self._send_content(chunk, components=None)
        await self._send_content(chunks[-1], components=rows)

    async def show_error(self, error: str) -> None:
        await self.send_text(f"Error: {error}")

    async def send_file(self, path: str, caption: str | None = None) -> None:
        file_path = Path(path)
        try:
            data = file_path.read_bytes()
        except Exception as exc:
            await self.send_text(f"Failed to send file: {exc}")
            return

        await self._send_file(data=data, filename=file_path.name, content=caption)

    async def flush(self) -> None:
        text = "".join(self._buffer)
        self._buffer.clear()
        self._tool_names_used.clear()
        if text:
            await self.send_text(text)

    @staticmethod
    def _normalize_text(text: str) -> str:
        normalized = str(text or "")
        replacements = {
            "<b>": "**",
            "</b>": "**",
            "<strong>": "**",
            "</strong>": "**",
            "<i>": "*",
            "</i>": "*",
            "<em>": "*",
            "</em>": "*",
            "<code>": "`",
            "</code>": "`",
            "<br>": "\n",
            "<br/>": "\n",
            "<br />": "\n",
        }
        for source, target in replacements.items():
            normalized = normalized.replace(source, target)
        return normalized

    async def _encode_buttons(
        self, buttons: list[list[tuple[str, str]]]
    ) -> list[list[dict[str, str]]]:
        rows: list[list[dict[str, str]]] = []
        for row in buttons:
            encoded_row: list[dict[str, str]] = []
            for label, payload in row:
                custom_id = await self._encode_callback_data(payload)
                encoded_row.append({"label": label, "custom_id": custom_id})
            rows.append(encoded_row)
        return rows

    async def _encode_callback_data(self, payload: str) -> str:
        if len(payload) <= 100:
            return payload
        if self._callback_encoder is not None:
            return await self._callback_encoder(payload)
        token = secrets.token_urlsafe(8)
        self._local_callback_tokens[token] = payload
        return f"cb:{token}"

    @staticmethod
    def _as_discord_view(components: list[list[dict[str, str]]]) -> Any | None:
        try:
            from discord import ui
        except ImportError:
            return None
        if not isinstance(components, list):
            return None
        view = ui.View(timeout=0)
        for row in components:
            if not isinstance(row, list):
                continue
            for item in row:
                if not isinstance(item, dict):
                    continue
                view.add_item(
                    ui.Button(
                        label=item.get("label", ""),
                        custom_id=item.get("custom_id"),
                    )
                )
        return view

    @classmethod
    def _format_send_kwargs(cls, content: str, components: Any | None) -> dict[str, Any]:
        kwargs: dict[str, Any] = {"content": content}
        if components is None:
            return kwargs
        view = cls._as_discord_view(components)
        if view is not None:
            kwargs["view"] = view
        else:
            kwargs["components"] = components
        return kwargs

    @staticmethod
    def _as_discord_file(data: bytes, filename: str) -> Any | None:
        try:
            import discord
        except ImportError:
            return None
        return discord.File(io.BytesIO(data), filename=filename)

    async def _send_content(self, content: str, *, components: Any | None) -> None:
        target = self._target
        kwargs = self._format_send_kwargs(content, components)
        if hasattr(target, "send_message"):
            await target.send_message(**kwargs)
            return
        if hasattr(target, "send"):
            await target.send(**kwargs)
            return
        if hasattr(target, "reply"):
            await target.reply(**kwargs)
            return

        response = getattr(target, "response", None)
        if response is not None and hasattr(response, "send_message"):
            await response.send_message(**kwargs)
            return

        followup = getattr(target, "followup", None)
        if followup is not None and hasattr(followup, "send"):
            await followup.send(**kwargs)

    async def _send_file(self, *, data: bytes, filename: str, content: str | None = None) -> None:
        target = self._target
        discord_file = self._as_discord_file(data, filename)

        if hasattr(target, "send_file"):
            if discord_file is not None:
                await target.send_file(file=discord_file, content=content)
            else:
                await target.send_file(data=data, filename=filename, content=content)
            return

        file_kwarg: dict[str, Any] = (
            {"file": discord_file}
            if discord_file is not None
            else {"file": {"data": data, "filename": filename}}
        )

        response = getattr(target, "response", None)
        if response is not None and hasattr(response, "send_message"):
            await response.send_message(content=content or "", **file_kwarg)
            return

        followup = getattr(target, "followup", None)
        if followup is not None and hasattr(followup, "send"):
            await followup.send(content=content or "", **file_kwarg)
            return

        await self.send_text("Failed to send file: No Discord file transport available")


class DiscordInteractionRenderer(DiscordRenderer):
    def __init__(
        self,
        target: Any,
        *,
        max_len: int = _DISCORD_MAX_MESSAGE_LEN,
        callback_encoder: Callable[[str], Awaitable[str]] | None = None,
    ):
        super().__init__(target, max_len=max_len, callback_encoder=callback_encoder)
        self._used_initial_response = False

    async def _send_content(self, content: str, *, components: Any | None) -> None:
        target = self._target
        kwargs = self._format_send_kwargs(content, components)

        if not self._used_initial_response and hasattr(target, "edit_original_response"):
            await target.edit_original_response(**kwargs)
            self._used_initial_response = True
            return

        response = getattr(target, "response", None)
        if response is not None and hasattr(response, "edit_message"):
            await response.edit_message(**kwargs)
            self._used_initial_response = True
            return

        followup = getattr(target, "followup", None)
        if followup is not None and hasattr(followup, "send"):
            await followup.send(**kwargs)
            self._used_initial_response = True
            return

        await super()._send_content(content, components=components)
        self._used_initial_response = True


class DiscordBot:
    def __init__(
        self,
        chat: Chat,
        config: Config,
        bot_config: BotConfig | None = None,
        channel_config: ChannelConfig | None = None,
        *,
        alias: str | None = None,
    ):
        self._chat = chat
        self._config = config
        self._bot_config = bot_config
        self._channel_config = channel_config
        self._alias = bot_config.name if bot_config is not None else alias
        self._callback_tokens: dict[str, _CallbackToken] = {}
        self._callback_token_lock = asyncio.Lock()
        self._status_by_chat: dict[str, _StatusState] = {}
        self._status_lock_by_chat: dict[str, asyncio.Lock] = {}
        self._running_tasks: dict[str, asyncio.Task[Any]] = {}
        self._active_chat_slots: set[str] = set()
        self._slot_lock = asyncio.Lock()
        self._steering_state: dict[str, _SteeringRunState] = {}
        self._steering_lock = asyncio.Lock()
        self._pending_message_batches: dict[str, _PendingMessageBatch] = {}
        self._debounce_tasks: dict[str, asyncio.Task[None]] = {}
        self._debounce_lock = asyncio.Lock()
        self._client: Any | None = None
        self._gateway_task: asyncio.Task[Any] | None = None
        self._stopping: bool = False

    @property
    def alias(self) -> str:
        return self._alias or "default"

    def _apply_bot_default_model(self, chat_id: str) -> None:
        bot_model: str | None = None
        if self._bot_config is not None:
            bot_model = self._bot_config.model
        if not bot_model:
            return
        context_key = self._chat._context_key(chat_id, bot_id=self.alias)
        if context_key not in self._chat._model_overrides:
            self._chat._model_overrides[context_key] = bot_model

    async def start(self) -> None:
        import discord
        from discord import app_commands

        token = None
        if self._channel_config is not None:
            token = getattr(self._channel_config, "token", None)
        if not token:
            log.warning("no discord token configured, skipping start")
            return

        intents = discord.Intents.default()
        intents.message_content = True
        self._client = discord.Client(intents=intents)
        tree = app_commands.CommandTree(self._client)

        no_arg_commands = [
            "help",
            "status",
            "new",
            "clear",
            "history",
            "tools",
            "skills",
            "subagents",
            "stop",
            "setup",
            "hud",
        ]
        for cmd_name in no_arg_commands:

            @tree.command(name=cmd_name, description=cmd_name)
            async def _no_arg_handler(interaction: discord.Interaction, _n: str = cmd_name) -> None:
                _ = _n
                await self._on_command(interaction)

        def _make_opt_handler(name: str, param: str) -> None:
            @tree.command(name=name, description=name)
            @app_commands.describe(value=f"Optional {param} value")
            async def _handler(interaction: discord.Interaction, value: str | None = None) -> None:
                _ = value
                await self._on_command(interaction)

        _make_opt_handler("model", "model")
        _make_opt_handler("thinking", "thinking")
        _make_opt_handler("resume", "resume")
        _make_opt_handler("agents", "agents")

        guild_id = None
        if self._channel_config is not None:
            guild_id = getattr(self._channel_config, "guild_id", None)

        @self._client.event
        async def on_ready() -> None:
            log.info("discord connected", user=str(self._client.user))
            if guild_id is not None:
                guild_obj = discord.Object(id=int(guild_id))
                tree.copy_global_to(guild=guild_obj)
                await tree.sync(guild=guild_obj)
            else:
                await tree.sync()
            log.info("discord commands synced")

        @self._client.event
        async def on_message(message: discord.Message) -> None:
            if message.author == self._client.user:
                return
            await self._on_message(message)

        @self._client.event
        async def on_interaction(interaction: discord.Interaction) -> None:
            if interaction.type == discord.InteractionType.application_command:
                return
            await self._on_interaction(interaction)

        self._stopping = False
        self._gateway_task = asyncio.create_task(self._client.start(token))
        log.info("discord gateway task started")

    async def stop(self) -> None:
        self._stopping = True

        for task in list(self._debounce_tasks.values()):
            task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await task
        self._debounce_tasks.clear()
        self._pending_message_batches.clear()

        if self._client is not None:
            await self._client.close()

        if self._gateway_task is not None:
            self._gateway_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._gateway_task
            self._gateway_task = None

        self._client = None
        log.info("discord bot stopped")

    async def _on_message(self, message: Any) -> None:
        author = getattr(message, "author", None)
        if author is not None and getattr(author, "bot", False):
            return

        text = str(getattr(message, "content", "") or "").strip()
        attachments = await self._extract_message_attachments(message)
        if not text and not attachments:
            return

        if not await self._authorize_or_bootstrap(author, message):
            return

        chat_id = self._chat_key_for_event(message)
        if attachments:
            await self._dispatch_message_batch(
                chat_id, message, [text] if text else [], attachments=attachments
            )
            return

        if text:
            await self._queue_debounced_text_message(message=message, chat_id=chat_id, text=text)

    async def _queue_debounced_text_message(self, *, message: Any, chat_id: str, text: str) -> None:
        async with self._debounce_lock:
            batch = self._pending_message_batches.get(chat_id)
            if batch is None:
                batch = _PendingMessageBatch(chat_id=chat_id, trigger_message=message)
                self._pending_message_batches[chat_id] = batch
            batch.parts.append(text)
            batch.trigger_message = message

            prior_task = self._debounce_tasks.get(chat_id)
            if prior_task is not None:
                prior_task.cancel()

            task = asyncio.create_task(self._debounce_and_dispatch(chat_id))
            self._debounce_tasks[chat_id] = task
            task.add_done_callback(lambda done, key=chat_id: self._cleanup_debounce_task(key, done))

    def _cleanup_debounce_task(self, chat_id: str, done: asyncio.Task[None]) -> None:
        if self._debounce_tasks.get(chat_id) is done:
            self._debounce_tasks.pop(chat_id, None)

    async def _debounce_and_dispatch(self, chat_id: str) -> None:
        try:
            await asyncio.sleep(_MESSAGE_DEBOUNCE_SECONDS)
        except asyncio.CancelledError:
            return

        async with self._debounce_lock:
            batch = self._pending_message_batches.pop(chat_id, None)
            if self._debounce_tasks.get(chat_id) is asyncio.current_task():
                self._debounce_tasks.pop(chat_id, None)

        if batch is None:
            return

        await self._dispatch_message_batch(chat_id, batch.trigger_message, batch.parts)

    async def _dispatch_message_batch(
        self,
        chat_id: str,
        message: Any,
        parts: list[str],
        *,
        attachments: list[dict[str, str]] | None = None,
    ) -> None:
        merged_text = self._merge_coalesced_parts(parts)
        if not merged_text and not attachments:
            return

        self._apply_bot_default_model(chat_id)

        if not await self._claim_chat_slot(chat_id):
            if merged_text and await self._try_steer_active_run(chat_id, merged_text):
                return
            await self._send_ephemeral_text(message, _BUSY_CHAT_MESSAGE)
            return

        status_target = getattr(message, "channel", None) or message
        renderer = DiscordRenderer(message, callback_encoder=self.register_callback_payload)

        async def _handler(run_renderer: DiscordRenderer) -> None:
            await self._chat.handle_message(
                chat_id=chat_id,
                text=merged_text,
                renderer=run_renderer,
                bot_id=self.alias,
                attachments=attachments,
            )

        try:
            await self._run_turn_with_status(chat_id, renderer, status_target, _handler)
        finally:
            await self._release_chat_slot(chat_id)
            async with self._steering_lock:
                self._steering_state.pop(chat_id, None)

    async def _extract_message_attachments(self, message: Any) -> list[dict[str, str]]:
        raw_attachments = getattr(message, "attachments", None)
        if not isinstance(raw_attachments, list):
            return []

        extracted: list[dict[str, str]] = []
        for attachment in raw_attachments:
            payload = await self._extract_attachment_payload(attachment)
            if payload is not None:
                extracted.append(payload)
        return extracted

    async def _extract_attachment_payload(self, attachment: Any) -> dict[str, str] | None:
        data: bytes | None = None
        if hasattr(attachment, "read"):
            with contextlib.suppress(Exception):
                read_result = await attachment.read()
                if isinstance(read_result, bytes):
                    data = read_result

        if data is None:
            return None

        filename = str(getattr(attachment, "filename", "attachment") or "attachment")
        content_type = str(getattr(attachment, "content_type", "") or "").lower()

        if content_type.startswith("image/"):
            encoded = base64.b64encode(data).decode("ascii")
            return {"type": "image", "mime": content_type or "image/png", "data": encoded}

        text_extensions = {
            ".txt",
            ".py",
            ".json",
            ".md",
            ".csv",
            ".log",
            ".xml",
            ".yaml",
            ".toml",
            ".sh",
            ".js",
            ".ts",
            ".html",
            ".css",
            ".sql",
            ".rs",
            ".go",
            ".c",
            ".h",
            ".cpp",
            ".java",
            ".rb",
            ".php",
            ".r",
            ".swift",
            ".kt",
        }
        suffix = Path(filename).suffix.lower()
        if content_type.startswith("text/") or suffix in text_extensions:
            extracted = data.decode("utf-8", errors="replace")
        else:
            extracted = f"User sent file: {filename} ({len(data)} bytes)"
        return {"type": "text", "filename": filename, "content": extracted}

    async def _on_command(self, interaction: Any) -> None:
        command = self._interaction_command_name(interaction)
        if not command:
            return
        if not await self._authorize_or_bootstrap(getattr(interaction, "user", None), interaction):
            return

        args = self._interaction_command_args(interaction)
        chat_id = self._chat_key_for_event(interaction)
        self._apply_bot_default_model(chat_id)
        await self._defer_interaction(interaction, thinking=True)
        renderer = DiscordInteractionRenderer(
            interaction,
            callback_encoder=self.register_callback_payload,
        )

        if command == "stop":
            stopped = await self._stop_active_run(chat_id)
            await renderer.send_text("Stopped." if stopped else "Nothing running.")
            return

        if not await self._claim_chat_slot(chat_id):
            await self._send_ephemeral_text(interaction, _BUSY_CHAT_MESSAGE)
            return

        status_target = getattr(interaction, "followup", None) or interaction

        async def _handler(run_renderer: DiscordInteractionRenderer) -> None:
            await self._chat.handle_command(
                chat_id=chat_id,
                command=command,
                args=args,
                renderer=run_renderer,
                bot_id=self.alias,
            )

        try:
            await self._run_turn_with_status(chat_id, renderer, status_target, _handler)
        finally:
            await self._release_chat_slot(chat_id)
            async with self._steering_lock:
                self._steering_state.pop(chat_id, None)

    async def _on_interaction(self, interaction: Any) -> None:
        if not await self._authorize_or_bootstrap(getattr(interaction, "user", None), interaction):
            return

        await self._defer_interaction(interaction, update=True)
        custom_id = await self._resolve_custom_id(interaction)
        if not custom_id or ":" not in custom_id:
            return

        prefix, action = custom_id.split(":", 1)
        chat_id = self._chat_key_for_event(interaction)
        renderer: Renderer = DiscordInteractionRenderer(
            interaction,
            callback_encoder=self.register_callback_payload,
        )

        if prefix in {"control", "stop"} and action in {"stop", "cancel"}:
            stopped = await self._stop_active_run(chat_id)
            await renderer.send_text("Stopped." if stopped else "Nothing running.")
            return

        if not await self._claim_chat_slot(chat_id):
            await self._send_ephemeral_text(interaction, _BUSY_CHAT_MESSAGE)
            return

        try:
            if prefix == "status":
                await self._handle_status_callback(chat_id, action, renderer)
            elif prefix == "model":
                await self._handle_model_callback(chat_id, action, renderer)
            elif prefix == "help":
                await self._handle_help_callback(chat_id, action, renderer)
            elif prefix == "agents":
                await self._handle_agents_callback(chat_id, action, renderer)
            elif prefix == "new":
                await self._handle_new_callback(chat_id, action, renderer)
            elif prefix == "history":
                await self._handle_history_callback(chat_id, action, renderer)
            elif prefix == "thinking":
                await self._handle_thinking_callback(chat_id, action, renderer)
            elif prefix == "subagents":
                await self._handle_subagents_callback(action, renderer)
        finally:
            await self._release_chat_slot(chat_id)

    async def _claim_chat_slot(self, chat_id: str) -> bool:
        async with self._slot_lock:
            if chat_id in self._active_chat_slots:
                return False
            self._active_chat_slots.add(chat_id)
            return True

    async def _release_chat_slot(self, chat_id: str) -> None:
        async with self._slot_lock:
            self._active_chat_slots.discard(chat_id)

    async def _stop_active_run(self, chat_id: str) -> bool:
        async with self._steering_lock:
            state = self._steering_state.get(chat_id)
            if state is None:
                state = _SteeringRunState()
                self._steering_state[chat_id] = state
            state.stop_requested = True
            state.pending_inputs.clear()
            state.steering_cancelled = False

        task = self._running_tasks.get(chat_id)
        if task is None or task.done():
            return False
        task.cancel()
        return True

    async def _try_steer_active_run(self, chat_id: str, text: str) -> bool:
        merged = self._merge_coalesced_parts([text])
        if not merged:
            return True

        task = self._running_tasks.get(chat_id)
        if task is None or task.done():
            return False

        async with self._steering_lock:
            state = self._steering_state.get(chat_id)
            if state is None:
                state = _SteeringRunState()
                self._steering_state[chat_id] = state
            state.pending_inputs.append(merged)

            renderer = state.renderer
            if renderer is not None and renderer.has_active_tool_call():
                return True
            if renderer is not None and renderer.all_tools_idle() and not state.tools_completed:
                state.tools_completed = True
                state.cached_tool_context = self._tool_context_summary(renderer)
                state.steering_cancelled = True
                task.cancel()
                return True
            if renderer is not None and state.tools_completed:
                state.steering_cancelled = True
                task.cancel()
                return True

            state.steering_cancelled = True
            task.cancel()
            return True

    @staticmethod
    def _merge_coalesced_parts(parts: list[str]) -> str:
        merged_parts = [part.strip() for part in parts if isinstance(part, str) and part.strip()]
        return "\n\n".join(merged_parts)

    async def _send_ephemeral_text(self, target: Any, text: str) -> None:
        if hasattr(target, "send"):
            with contextlib.suppress(Exception):
                await target.send(content=text)
                return
        if hasattr(target, "send_message"):
            with contextlib.suppress(Exception):
                await target.send_message(content=text, components=None)
                return
        response = getattr(target, "response", None)
        if response is not None and hasattr(response, "send_message"):
            with contextlib.suppress(Exception):
                await response.send_message(content=text, ephemeral=True)
                return
        followup = getattr(target, "followup", None)
        if followup is not None and hasattr(followup, "send"):
            with contextlib.suppress(Exception):
                await followup.send(content=text, ephemeral=True)

    def _is_authorized(self, user_id: int) -> bool:
        if self._bot_config is not None:
            auth_fn = getattr(self._chat, "is_authorized_user", None)
            if callable(auth_fn):
                try:
                    resolved = auth_fn(discord_id=user_id, bot_id=self.alias)
                    if isinstance(resolved, bool):
                        return resolved
                except Exception as exc:
                    log.debug("auth callback failed", error=str(exc))

            user = self._chat.resolve_config_user(discord_id=user_id)
            if user is None:
                return False
            if user.name == self._bot_config.auth.owner:
                return True
            return user.name in self._bot_config.auth.allowed_users
        return True

    async def _authorize_or_bootstrap(self, user: Any, target: Any) -> bool:
        user_id = self._discord_user_id(user)
        if user_id is None:
            return self._bot_config is None
        if self._is_authorized(user_id):
            return True
        if self._try_bootstrap(user):
            await self._send_ephemeral_text(
                target, "Welcome! You are now the owner of this Otto instance."
            )
            return True
        await self._send_ephemeral_text(target, _UNAUTHORIZED_MESSAGE)
        return False

    def _try_bootstrap(self, user: Any) -> bool:
        if self._bot_config is None:
            return False
        user_id = self._discord_user_id(user)
        if user_id is None:
            return False

        username = getattr(user, "name", None) or getattr(user, "global_name", None)
        bootstrap_fn = getattr(self._chat, "try_bootstrap_user", None)
        if callable(bootstrap_fn):
            claimed, owner_name = bootstrap_fn(
                discord_id=user_id,
                username_hint=username,
                bot_id=self.alias,
            )
            if not claimed:
                return False
            refreshed = next((b for b in self._config.bots if b.name == self.alias), None)
            if refreshed is not None:
                self._bot_config = refreshed
            _ = owner_name
            return True
        return False

    @staticmethod
    def _discord_user_id(user: Any) -> int | None:
        if user is None:
            return None
        value = getattr(user, "id", None)
        if isinstance(value, int):
            return value
        if isinstance(value, str):
            text = value.strip()
            if text.isdigit():
                return int(text)
        return None

    async def _run_turn_with_status(
        self,
        chat_id: str,
        renderer: DiscordRenderer,
        status_target: Any,
        handler: Callable[[DiscordRenderer], Awaitable[None]],
    ) -> None:
        run_task = asyncio.current_task()
        if run_task is not None:
            self._running_tasks[chat_id] = run_task

        async with self._steering_lock:
            steering = self._steering_state.get(chat_id)
            if steering is None:
                steering = _SteeringRunState()
                self._steering_state[chat_id] = steering
            steering.renderer = renderer
            steering.stop_requested = False
            steering.steering_cancelled = False
            steering.tools_completed = False
            steering.cached_tool_context = None

        lock = self._status_lock_by_chat.setdefault(chat_id, asyncio.Lock())
        try:
            async with lock:
                status = self._status_by_chat.setdefault(chat_id, _StatusState())
                current_handler: Callable[[DiscordRenderer], Awaitable[None]] = handler
                current_renderer: DiscordRenderer = renderer

                while True:
                    await self._set_status_text(
                        status,
                        status_target,
                        self._status_thinking_text(),
                        state_name="thinking",
                    )
                    status.run_active = True
                    status.run_started_at = _monotonic()
                    status.last_working_update_at = 0.0

                    async with self._steering_lock:
                        steering = self._steering_state.get(chat_id)
                        if steering is None:
                            steering = _SteeringRunState()
                            self._steering_state[chat_id] = steering
                        steering.renderer = current_renderer
                        steering.steering_cancelled = False

                    stop = asyncio.Event()
                    tick_task = asyncio.create_task(
                        self._status_tick_loop(
                            chat_id=chat_id,
                            state=status,
                            status_target=status_target,
                            renderer=current_renderer,
                            stop=stop,
                        )
                    )
                    await self._trigger_typing_once(status_target)
                    typing_task = asyncio.create_task(self._keep_typing(status_target, stop))

                    run_error: BaseException | None = None
                    cancelled = False
                    try:
                        await current_handler(current_renderer)
                    except asyncio.CancelledError:
                        cancelled = True
                    except Exception as exc:
                        run_error = exc
                        classified = self._classify_exception(exc)
                        log.warning(
                            "discord run failed",
                            chat_id=chat_id,
                            category=classified.category,
                            error=str(exc),
                        )
                        await current_renderer.show_error(classified.user_message)
                    finally:
                        stop.set()
                        tick_task.cancel()
                        typing_task.cancel()
                        with contextlib.suppress(asyncio.CancelledError):
                            await tick_task
                        with contextlib.suppress(asyncio.CancelledError):
                            await typing_task

                    status.run_active = False
                    status.run_started_at = None
                    status.last_working_update_at = 0.0

                    if run_error is not None:
                        await self._set_status_text(
                            status,
                            status_target,
                            self._status_error_text(run_error),
                            state_name="error",
                            force=True,
                        )
                        return

                    pending_inputs: list[str] = []
                    stop_requested = False
                    scenario_b = False
                    cached_tool_context: str | None = None
                    async with self._steering_lock:
                        steering = self._steering_state.get(chat_id)
                        if steering is not None:
                            pending_inputs = list(steering.pending_inputs)
                            stop_requested = steering.stop_requested
                            scenario_b = steering.tools_completed
                            cached_tool_context = steering.cached_tool_context
                            steering.pending_inputs.clear()
                            steering.steering_cancelled = False
                            steering.tools_completed = False
                            steering.cached_tool_context = None

                    if run_error is None and cancelled and stop_requested:
                        await self._set_status_text(
                            status,
                            status_target,
                            self._status_idle_text(),
                            state_name="idle",
                            force=True,
                        )
                        return

                    merged_pending = self._merge_coalesced_parts(pending_inputs)
                    if cancelled and merged_pending:
                        if scenario_b:

                            async def _scenario_b_handler(
                                replay_renderer: DiscordRenderer,
                                merged_text: str = merged_pending,
                                tool_ctx: str | None = cached_tool_context,
                            ) -> None:
                                if tool_ctx:
                                    steered_text = (
                                        f"[Context from previous tool run: {tool_ctx}]\n\n"
                                        f"While working on this, you added: {merged_text}"
                                    )
                                else:
                                    steered_text = (
                                        f"While working on this, you added: {merged_text}"
                                    )
                                await self._chat.handle_message(
                                    chat_id=chat_id,
                                    text=steered_text,
                                    renderer=replay_renderer,
                                    bot_id=self.alias,
                                )

                            current_handler = _scenario_b_handler
                        else:

                            async def _scenario_a_handler(
                                replay_renderer: DiscordRenderer,
                                merged_text: str = merged_pending,
                            ) -> None:
                                steered_text = f"While working on this, you added: {merged_text}"
                                await self._chat.handle_message(
                                    chat_id=chat_id,
                                    text=steered_text,
                                    renderer=replay_renderer,
                                    bot_id=self.alias,
                                )

                            current_handler = _scenario_a_handler

                        current_renderer = DiscordRenderer(
                            current_renderer._target,
                            callback_encoder=self.register_callback_payload,
                        )
                        continue

                    await self._set_status_text(
                        status,
                        status_target,
                        self._status_idle_text(),
                        state_name="idle",
                        force=True,
                    )
                    return
        finally:
            self._running_tasks.pop(chat_id, None)

    async def _status_tick_loop(
        self,
        *,
        chat_id: str,
        state: _StatusState,
        status_target: Any,
        renderer: DiscordRenderer,
        stop: asyncio.Event,
    ) -> None:
        while not stop.is_set():
            try:
                await asyncio.wait_for(stop.wait(), timeout=_STATUS_TICK_SECONDS)
            except TimeoutError:
                pass

            task = self._running_tasks.get(chat_id)
            if task is not None and not task.done():
                async with self._steering_lock:
                    steering = self._steering_state.get(chat_id)
                    if (
                        steering is not None
                        and steering.pending_inputs
                        and not steering.stop_requested
                    ):
                        if renderer.has_active_tool_call():
                            pass
                        elif renderer.all_tools_idle() and not steering.tools_completed:
                            steering.tools_completed = True
                            steering.cached_tool_context = self._tool_context_summary(renderer)
                            steering.steering_cancelled = True
                            task.cancel()
                        elif steering.tools_completed:
                            steering.steering_cancelled = True
                            task.cancel()
                        else:
                            steering.steering_cancelled = True
                            task.cancel()

            if not state.run_active or state.run_started_at is None:
                continue

            elapsed = _monotonic() - state.run_started_at
            if elapsed < _STATUS_WORKING_THRESHOLD_SECONDS:
                continue

            now = _monotonic()
            if (
                state.last_working_update_at
                and (now - state.last_working_update_at) < _STATUS_WORKING_REFRESH_SECONDS
            ):
                continue

            updated = await self._set_status_text(
                state,
                status_target,
                self._status_working_text(elapsed=elapsed, tool_count=renderer.tool_call_count()),
                state_name="working",
            )
            if updated:
                state.last_working_update_at = now

    @staticmethod
    def _tool_context_summary(renderer: DiscordRenderer) -> str | None:
        tool_names = list(getattr(renderer, "_tool_names_used", []))
        if not tool_names:
            return None
        grouped: dict[str, int] = {}
        for name in tool_names:
            grouped[name] = grouped.get(name, 0) + 1
        parts = [f"{name} x{count}" if count > 1 else name for name, count in grouped.items()]
        return ", ".join(parts)

    @staticmethod
    def _status_thinking_text() -> str:
        return "Thinking…"

    @staticmethod
    def _status_idle_text() -> str:
        return "Idle"

    @staticmethod
    def _status_working_text(*, elapsed: float, tool_count: int) -> str:
        seconds = max(0, int(elapsed))
        suffix = "tool" if tool_count == 1 else "tools"
        if tool_count > 0:
            return f"Working · {seconds}s · {tool_count} {suffix}"
        return f"Working · {seconds}s"

    @staticmethod
    def _status_error_text(error: BaseException) -> str:
        detail = str(error).strip()
        if not detail:
            return "Error"
        if len(detail) > 80:
            detail = detail[:77] + "..."
        return f"Error · {detail}"

    @staticmethod
    def _resolve_typing_trigger(target: Any) -> Any | None:
        trigger = getattr(target, "trigger_typing", None)
        if trigger is not None:
            return trigger
        channel = getattr(target, "channel", None)
        if channel is not None:
            return getattr(channel, "trigger_typing", None)
        return None

    @classmethod
    async def _trigger_typing_once(cls, target: Any) -> None:
        trigger = cls._resolve_typing_trigger(target)
        if trigger is not None:
            with contextlib.suppress(Exception):
                await trigger()

    @classmethod
    async def _keep_typing(cls, target: Any, stop: asyncio.Event) -> None:
        trigger = cls._resolve_typing_trigger(target)
        if trigger is None:
            return
        while not stop.is_set():
            try:
                await asyncio.wait_for(stop.wait(), timeout=8.0)
            except TimeoutError:
                pass
            except (asyncio.CancelledError, Exception):
                break
            else:
                break
            try:
                await trigger()
            except (asyncio.CancelledError, Exception):
                break

    @staticmethod
    def _iter_exception_chain(exc: BaseException) -> list[BaseException]:
        chain: list[BaseException] = []
        seen: set[int] = set()
        current: BaseException | None = exc
        while current is not None:
            marker = id(current)
            if marker in seen:
                break
            seen.add(marker)
            chain.append(current)
            current = current.__cause__ or current.__context__
        return chain

    @classmethod
    def _classify_exception(cls, exc: BaseException) -> _UserFacingError:
        from otto.errors import (
            OttoAuthError,
            OttoConfigError,
            OttoExecutionError,
            OttoTransientError,
        )

        chain = cls._iter_exception_chain(exc)
        class_names = [type(item).__name__.lower() for item in chain]
        messages = [str(item).lower() for item in chain]
        tokens = " | ".join([*class_names, *messages])

        rate_limit_markers = [
            "ratelimiterror",
            "rate limit",
            "too many requests",
            "quota exceeded",
            " 429",
        ]
        if any(marker in tokens for marker in rate_limit_markers):
            return _UserFacingError(
                category="rate_limit",
                user_message="Rate limited by the model provider. Please retry in a moment.",
            )

        auth_markers = [
            "authenticationerror",
            "permissiondeniederror",
            "unauthorized",
            "invalid api key",
            "expired token",
            "forbidden",
            "permission denied",
            " 401",
            " 403",
            "auth",
        ]
        if any(marker in tokens for marker in auth_markers):
            return _UserFacingError(
                category="auth",
                user_message="Authentication with the model provider failed. Reauthenticate and try again.",
            )

        model_markers = [
            "notfounderror",
            "model not found",
            "unknown model",
            "unsupported model",
            "unavailable model",
            "model unavailable",
        ]
        if any(marker in tokens for marker in model_markers):
            return _UserFacingError(
                category="model_unavailable",
                user_message="The selected model is unavailable. Choose a different model and try again.",
            )

        context_markers = [
            "contextwindowexceedederror",
            "maximum context length",
            "context window",
            "token limit",
            "request too large",
        ]
        if any(marker in tokens for marker in context_markers):
            return _UserFacingError(
                category="context_window",
                user_message=(
                    "This request is too large for the model context window. "
                    "Start a new session or shorten the request."
                ),
            )

        bad_request_markers = [
            "badrequesterror",
            "invalid request",
            "invalid tool",
            "malformed",
            "invalid schema",
            "unprocessable",
            " 400",
            " 422",
        ]
        if any(marker in tokens for marker in bad_request_markers):
            return _UserFacingError(
                category="bad_request",
                user_message=(
                    "The request could not be processed by the model provider. "
                    "Please retry or adjust your request."
                ),
            )

        transient_markers = [
            "apiconnectionerror",
            "serviceunavailableerror",
            "internalservererror",
            "timeout",
            "timed out",
            "temporarily unavailable",
            "connection reset",
            "upstream",
            " 500",
            " 502",
            " 503",
            " 504",
        ]
        if any(marker in tokens for marker in transient_markers):
            return _UserFacingError(
                category="provider_transient",
                user_message="The model provider is temporarily unavailable. Please retry shortly.",
            )

        policy_markers = [
            "contentpolicyviolationerror",
            "content policy",
            "safety",
            "policy violation",
            "prompt blocked",
        ]
        if any(marker in tokens for marker in policy_markers):
            return _UserFacingError(
                category="content_policy",
                user_message="This request was blocked by provider safety policies. Please revise and retry.",
            )

        if isinstance(exc, OttoAuthError):
            return _UserFacingError(
                category="auth",
                user_message="Authentication with the model provider failed. Reauthenticate and try again.",
            )
        if isinstance(exc, OttoConfigError):
            return _UserFacingError(
                category="config",
                user_message="Otto configuration is invalid. Check your settings and retry.",
            )
        if isinstance(exc, OttoTransientError):
            return _UserFacingError(
                category="provider_transient",
                user_message="The model provider is temporarily unavailable. Please retry shortly.",
            )
        if isinstance(exc, OttoExecutionError):
            return _UserFacingError(category="execution", user_message=_GENERIC_USER_ERROR)

        return _UserFacingError(category="unknown", user_message=_GENERIC_USER_ERROR)

    async def _set_status_text(
        self,
        state: _StatusState,
        status_target: Any,
        text: str,
        *,
        state_name: str,
        force: bool = False,
    ) -> bool:
        if state.status_text == text:
            return False

        now = _monotonic()
        if (
            not force
            and state.last_edit_at
            and (now - state.last_edit_at) < _STATUS_MIN_EDIT_INTERVAL_SECONDS
        ):
            return False

        try:
            if state.message is None:
                if state.suppressed and not force:
                    return False
                message = await self._send_status_message(status_target, text)
                if message is None:
                    state.suppressed = True
                    return False
                state.message = message
                state.message_id = self._extract_message_id(message)
                state.suppressed = False
            else:
                ok = await self._edit_status_message(status_target, state, text)
                if not ok:
                    state.suppressed = True
                    state.message = None
                    state.message_id = None
                    return False
        except Exception as exc:
            log.debug("HUD status update failed", error=str(exc))
            state.suppressed = True
            state.message = None
            state.message_id = None
            return False

        state.status_text = text
        state.state = state_name
        state.last_edit_at = now
        return True

    async def _send_status_message(self, target: Any, text: str) -> Any | None:
        if hasattr(target, "send_message"):
            return await target.send_message(content=text, components=None)
        if hasattr(target, "send"):
            try:
                return await target.send(content=text, components=None, wait=True)
            except TypeError:
                return await target.send(content=text, components=None)
        if hasattr(target, "reply"):
            return await target.reply(content=text)
        if hasattr(target, "edit_original_response"):
            await target.edit_original_response(content=text, components=None)
            return target
        return None

    async def _edit_status_message(self, target: Any, state: _StatusState, text: str) -> bool:
        message = state.message
        if message is not None and hasattr(message, "edit"):
            await message.edit(content=text)
            return True

        if state.message_id is not None and hasattr(target, "edit_message"):
            await target.edit_message(message_id=state.message_id, content=text, components=None)
            return True

        if hasattr(target, "edit_original_response"):
            await target.edit_original_response(content=text, components=None)
            return True

        return False

    @staticmethod
    def _extract_message_id(message: Any) -> int | None:
        value = getattr(message, "id", None)
        if isinstance(value, int):
            return value
        value = getattr(message, "message_id", None)
        if isinstance(value, int):
            return value
        return None

    async def _defer_interaction(
        self,
        interaction: Any,
        *,
        thinking: bool = False,
        update: bool = False,
    ) -> None:
        response = getattr(interaction, "response", None)
        if response is None or not hasattr(response, "defer"):
            return

        if hasattr(response, "is_done") and callable(response.is_done):
            with contextlib.suppress(Exception):
                if response.is_done():
                    return

        with contextlib.suppress(Exception):
            if update:
                await response.defer()
                return
            await response.defer(thinking=thinking)

    async def register_callback_payload(self, payload: str) -> str:
        if len(payload) <= 100:
            return payload
        token = secrets.token_urlsafe(8)
        async with self._callback_token_lock:
            self._prune_callback_tokens_locked()
            self._callback_tokens[token] = _CallbackToken(
                payload=payload,
                expires_at=time.time() + _CALLBACK_TOKEN_TTL_SECONDS,
            )
        return f"cb:{token}"

    async def _resolve_custom_id(self, interaction: Any) -> str:
        data = getattr(interaction, "data", None)
        custom_id = ""
        if isinstance(data, dict):
            custom_id = str(data.get("custom_id", "") or "")
        elif hasattr(interaction, "custom_id"):
            custom_id = str(getattr(interaction, "custom_id", "") or "")
        if not custom_id.startswith("cb:"):
            return custom_id

        token = custom_id.split(":", 1)[1]
        async with self._callback_token_lock:
            self._prune_callback_tokens_locked()
            entry = self._callback_tokens.get(token)
            if entry is None:
                return ""
            return entry.payload

    def _prune_callback_tokens_locked(self) -> None:
        now = time.time()
        expired = [
            token for token, entry in self._callback_tokens.items() if entry.expires_at <= now
        ]
        for token in expired:
            self._callback_tokens.pop(token, None)

    def _chat_key_for_event(self, event: Any) -> str:
        guild_id = getattr(event, "guild_id", None)
        if guild_id is None:
            guild = getattr(event, "guild", None)
            if guild is not None:
                guild_id = getattr(guild, "id", None)
        channel_id = getattr(event, "channel_id", None)
        if channel_id is None:
            channel = getattr(event, "channel", None)
            channel_id = getattr(channel, "id", None)
        if channel_id is None:
            channel_id = 0

        thread_id = None
        thread = getattr(event, "thread", None)
        if thread is not None:
            thread_id = getattr(thread, "id", None)
        if thread_id is None:
            channel = getattr(event, "channel", None)
            if getattr(channel, "parent_id", None) is not None:
                thread_id = getattr(channel, "id", None)

        guild_part = str(guild_id) if guild_id is not None else "dm"
        key = f"discord:{guild_part}:{channel_id}"
        if thread_id is not None and thread_id != channel_id:
            key = f"{key}:thread:{thread_id}"
        return key

    def _interaction_command_name(self, interaction: Any) -> str:
        name = getattr(interaction, "command_name", None)
        if isinstance(name, str) and name:
            return name
        data = getattr(interaction, "data", None)
        if isinstance(data, dict):
            data_name = data.get("name")
            if isinstance(data_name, str):
                return data_name
        command = getattr(interaction, "command", None)
        command_name = getattr(command, "name", None)
        if isinstance(command_name, str):
            return command_name
        return ""

    def _interaction_command_args(self, interaction: Any) -> str:
        namespace = getattr(interaction, "namespace", None)
        if namespace is not None:
            values = [
                str(value)
                for value in vars(namespace).values()
                if value is not None and str(value).strip()
            ]
            if values:
                return " ".join(values)

        data = getattr(interaction, "data", None)
        if isinstance(data, dict):
            options = data.get("options")
            if isinstance(options, list):
                values: list[str] = []
                for option in options:
                    if not isinstance(option, dict):
                        continue
                    value = option.get("value")
                    if value is None:
                        continue
                    text = str(value).strip()
                    if text:
                        values.append(text)
                if values:
                    return " ".join(values)
        return ""

    async def _handle_status_callback(self, chat_id: str, action: str, renderer: Renderer) -> None:
        if action == "model":
            context_key = self._chat._context_key(chat_id, bot_id=self.alias)
            model = self._chat._model_overrides.get(context_key, self._config.agent.model)
            await renderer.send_text(f"Current model: {model}")
            return
        if action == "thinking":
            await self._chat.handle_command(
                chat_id=chat_id,
                command="thinking",
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )
            return
        if action == "new":
            await self._chat.handle_command(
                chat_id=chat_id,
                command="new",
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )
            return
        if action == "refresh":
            await self._chat.handle_command(
                chat_id=chat_id,
                command="status",
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )

    async def _handle_new_callback(self, chat_id: str, action: str, renderer: Renderer) -> None:
        if action == "cancel":
            await renderer.send_text("Kept current session.")
            return
        if action != "confirm":
            await renderer.send_text("Unknown action.")
            return
        execute_new_session(self._chat, chat_id)
        text, buttons = build_new_success()
        await renderer.send_with_buttons(text, buttons)

    async def _handle_history_callback(self, chat_id: str, action: str, renderer: Renderer) -> None:
        if action.startswith("nav:"):
            raw_offset = action.split(":", 1)[1]
            try:
                offset = int(raw_offset)
            except ValueError:
                await renderer.send_text("Invalid history page.")
                return
            text, buttons = build_history_page(self._chat, chat_id, offset)
            await renderer.send_with_buttons(text, buttons)
            return

        if action.startswith("resume:"):
            ts = action.split(":", 1)[1]
            ok = self._chat._session_store.restore(chat_id, ts)
            if not ok:
                await renderer.send_text(f"No archived session found for `{ts}`.")
                return
            text, buttons = build_history_page(self._chat, chat_id, 0)
            await renderer.send_with_buttons(f"Resumed session `{ts}`.\n\n{text}", buttons)
            return

        await renderer.send_text("Unknown history action.")

    async def _handle_model_callback(self, chat_id: str, action: str, renderer: Renderer) -> None:
        if action == "cancel":
            await renderer.send_text("Model unchanged.")
            return
        if action == "custom":
            await renderer.send_text(
                "Send the model string as a message.\nExample: `codex/gpt-5.3-codex`"
            )
            return
        if action == "back":
            text, buttons = build_provider_picker(
                self._config, auth_storage=self._chat._auth_storage
            )
            context_key = self._chat._context_key(chat_id, bot_id=self.alias)
            current = self._chat._model_overrides.get(context_key, self._config.agent.model)
            text = text.replace(str(self._config.agent.model), str(current))
            await renderer.send_with_buttons(text, buttons)
            return
        if action.startswith("provider:"):
            provider_key = action.split(":", 1)[1]
            text, buttons = get_provider_model_buttons(
                self._config,
                provider_key,
                auth_storage=self._chat._auth_storage,
            )
            await renderer.send_with_buttons(text, buttons)
            return

        await self._chat.handle_command(
            chat_id=chat_id,
            command="model",
            args=action,
            renderer=renderer,
            bot_id=self.alias,
        )

    async def _handle_thinking_callback(
        self, chat_id: str, action: str, renderer: Renderer
    ) -> None:
        if action == "cancel":
            await renderer.send_text("Thinking unchanged.")
            return

        await self._chat.handle_command(
            chat_id=chat_id,
            command="thinking",
            args=action,
            renderer=renderer,
            bot_id=self.alias,
        )

    async def _handle_subagents_callback(self, action: str, renderer: Renderer) -> None:
        profiles = load_subagent_profiles()

        if action == "cancel" or action == "noop":
            if action == "cancel":
                await renderer.send_text("Closed.")
            return

        if action.startswith("page:"):
            raw_page = action.split(":", 1)[1]
            try:
                page = max(0, int(raw_page))
            except ValueError:
                page = 0
            text, buttons = build_subagents_main_view(profiles, page=page)
            await renderer.send_with_buttons(text, buttons)
            return

        if action.startswith("detail:"):
            parts = action.split(":", 2)
            if len(parts) != 3:
                await renderer.send_text("Unknown action.")
                return
            _, name, raw_page = parts
            try:
                back_page = max(0, int(raw_page))
            except ValueError:
                back_page = 0
            view = build_subagent_detail_view(name, profiles, back_page=back_page)
            if view is None:
                await renderer.send_text("Subagent not found.")
                return
            text, buttons = view
            await renderer.send_with_buttons(text, buttons)
            return

        await renderer.send_text("Unknown action.")

    async def _handle_help_callback(self, chat_id: str, action: str, renderer: Renderer) -> None:
        if action == "back":
            text, buttons = build_help_main_view()
            await renderer.send_with_buttons(text, buttons)
            return

        if action.startswith("run:"):
            command = action.split(":", 1)[1].strip().lstrip("/")
            if not command:
                await renderer.send_text("Unknown command.")
                return
            await self._chat.handle_command(
                chat_id=chat_id,
                command=command,
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )
            return

        category_view = build_help_category_view(action)
        if category_view is None:
            await renderer.send_text("Unknown help category.")
            return

        text, buttons = category_view
        await renderer.send_with_buttons(text, buttons)

    async def _handle_agents_callback(self, chat_id: str, action: str, renderer: Renderer) -> None:
        if action in {"active", "all", "past"}:
            args = "" if action == "active" else action
            await self._chat.handle_command(
                chat_id=chat_id,
                command="agents",
                args=args,
                renderer=renderer,
                bot_id=self.alias,
            )
            return

        if action.startswith("detail:") or action.startswith("job:"):
            job_id = action.split(":", 1)[1].strip()
            if not job_id:
                await renderer.send_text("Unknown action.")
                return
            from otto.orchestrator import get_orchestrator

            job = await get_orchestrator().get_job(job_id)
            if not job:
                await renderer.send_text("Job not found.")
                return
            text, buttons = build_agent_job_detail(job)
            await renderer.send_with_buttons(text, buttons)
            return

        if action == "back":
            from otto.orchestrator import get_orchestrator

            jobs = await get_orchestrator().list_jobs()
            text, buttons = build_agents_main_view(jobs, "active")
            await renderer.send_with_buttons(text, buttons)
            return

        if action.startswith("page:"):
            parts = action.split(":", 2)
            if len(parts) != 3:
                await renderer.send_text("Unknown action.")
                return
            _, raw_view, raw_page = parts
            try:
                page = max(0, int(raw_page))
            except ValueError:
                page = 0
            view = raw_view if raw_view in {"all", "active", "done", "failed"} else "active"
            from otto.orchestrator import get_orchestrator

            jobs = await get_orchestrator().list_jobs()
            text, buttons = build_agents_main_view(jobs, view, page=page)
            await renderer.send_with_buttons(text, buttons)
            return

        if action.startswith("cancel:"):
            job_id = action.split(":", 1)[1].strip()
            from otto.orchestrator import get_orchestrator

            ok = await get_orchestrator().cancel(job_id)
            await renderer.send_text(
                f"Cancelled `{job_id}`." if ok else f"Could not cancel `{job_id}`."
            )
            await self._chat.handle_command(
                chat_id=chat_id,
                command="agents",
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )
            return

        if action.startswith("retry:"):
            job_id = action.split(":", 1)[1].strip()
            await self._chat.handle_command(
                chat_id=chat_id,
                command="retry_job",
                args=job_id,
                renderer=renderer,
                bot_id=self.alias,
            )
            return

        if action.startswith("redirect:"):
            payload = action.split(":", 1)[1].strip()
            await self._chat.handle_command(
                chat_id=chat_id,
                command="redirect_job",
                args=payload,
                renderer=renderer,
                bot_id=self.alias,
            )
            return

        await renderer.send_text("Unknown action.")


__all__ = [
    "DiscordBot",
    "DiscordInteractionRenderer",
    "DiscordRenderer",
    "_split_text_for_discord",
]
