"""Question/answer coordination for the OpenCode agent.

This encapsulates:
- Detecting question toolcalls and presenting UI to the user
- Waiting for an answer (event-based)
- Submitting answers back to OpenCode
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
from typing import Any, Dict, List, Optional

from modules.agents.base import AgentRequest

from ..question_ui import build_answer_receipt_text
from .server import OpenCodeServerManager
from .types import PendingQuestionPayload

logger = logging.getLogger(__name__)


class OpenCodeQuestionHandler:
    def __init__(self, controller, im_client, settings_manager, get_server=None):
        self._controller = controller
        self._im_client = im_client
        self._settings_manager = settings_manager
        self._get_server = get_server  # Callback to get server instance for abort

        self._pending_questions: Dict[str, PendingQuestionPayload] = {}
        self._question_answer_events: Dict[str, asyncio.Event] = {}
        # Track reactions added after question answer for cleanup
        # Maps base_session_id -> (context, message_id, emoji)
        self._answer_reactions: Dict[str, tuple] = {}

    def _platform_supports_question_tool(self, request: AgentRequest) -> bool:
        platform = request.context.platform or (request.context.platform_specific or {}).get("platform")
        if not platform:
            platform = getattr(self._controller.config, "platform", "")
        return platform != "wechat"

    def _question_tool_disabled_text(self) -> str:
        translator = getattr(self._controller, "_t", None)
        if callable(translator):
            return translator("error.opencodeQuestionUnsupportedWechat")
        return "OpenCode interactive questions are temporarily disabled on WeChat. Please send a follow-up message instead."

    def get_pending(self, base_session_id: str) -> Optional[PendingQuestionPayload]:
        return self._pending_questions.get(base_session_id)

    def set_pending(self, base_session_id: str, payload: PendingQuestionPayload) -> None:
        self._pending_questions[base_session_id] = payload

    def pop_pending(self, base_session_id: str) -> Optional[PendingQuestionPayload]:
        return self._pending_questions.pop(base_session_id, None)

    async def clear(self, base_session_id: str, context=None) -> None:
        """Clear all state for a session, including removing question UI and answer reaction.

        Args:
            base_session_id: The session identifier
            context: Optional MessageContext for removing the inline keyboard.
                     When provided, stale question buttons will be cleaned up.
        """
        pending = self._pending_questions.pop(base_session_id, None)
        self._question_answer_events.pop(base_session_id, None)

        # Remove the inline keyboard / buttons so stale buttons can't be clicked
        if pending and context:
            msg_id = pending.get("prompt_message_id")
            if msg_id and hasattr(self._im_client, "remove_inline_keyboard"):
                try:
                    await self._im_client.remove_inline_keyboard(
                        context,
                        msg_id,
                        text=pending.get("prompt_text") or "",
                        parse_mode="markdown",
                    )
                except Exception as err:
                    logger.debug(f"Failed to remove question UI for {base_session_id}: {err}")

        await self._remove_answer_reaction(base_session_id)

    async def _remove_answer_reaction(self, base_session_id: str) -> None:
        """Remove the 👀 reaction added after question answer."""
        reaction_info = self._answer_reactions.pop(base_session_id, None)
        if reaction_info and hasattr(self._im_client, "remove_reaction"):
            context, message_id, emoji = reaction_info
            try:
                await self._im_client.remove_reaction(context, message_id, emoji)
            except Exception as err:
                logger.debug(f"Failed to remove answer reaction: {err}")

    def _get_or_create_question_event(self, base_session_id: str) -> asyncio.Event:
        """Get or create an event for question answer coordination.

        Clears the event if it already exists (important for nested questions).
        """

        evt = self._question_answer_events.get(base_session_id)
        if evt is None:
            evt = asyncio.Event()
            self._question_answer_events[base_session_id] = evt
        else:
            evt.clear()
        return evt

    def _clear_question_event(self, base_session_id: str, evt: asyncio.Event) -> None:
        """Clear question event, but only if it's still the same object (avoid races)."""

        current = self._question_answer_events.get(base_session_id)
        if current is evt:
            self._question_answer_events.pop(base_session_id, None)

    async def wait_for_question_answer(
        self,
        request: AgentRequest,
        session_id: str,
        pending_payload: PendingQuestionPayload,
    ) -> bool:
        """Wait for user to answer the question.

        Waits indefinitely — the user can answer whenever they come back.
        The asyncio task is suspended (no CPU cost) until the event fires.

        Returns:
            True if answer was submitted successfully, False if cancelled
        """

        evt = self._get_or_create_question_event(request.base_session_id)

        try:
            await evt.wait()
            logger.info("Answer received for %s, resuming poll loop", request.base_session_id)
            return True
        except asyncio.CancelledError:
            logger.info("Question wait cancelled for %s", request.base_session_id)
            raise
        finally:
            self._clear_question_event(request.base_session_id, evt)

    async def _send_answer_receipt(self, request: AgentRequest, answers_payload: List[List[str]]) -> None:
        receipt_text = build_answer_receipt_text(self._controller, answers_payload)
        if not receipt_text:
            return

        try:
            await self._im_client.send_message(request.context, receipt_text)
        except Exception as err:
            logger.debug(f"Failed to send answer receipt: {err}")

    async def open_question_modal(self, request: AgentRequest, pending: PendingQuestionPayload) -> None:
        trigger_id = None
        interaction = None
        if request.context.platform_specific:
            trigger_id = request.context.platform_specific.get("trigger_id")
            interaction = request.context.platform_specific.get("interaction")
        if not trigger_id and not interaction:
            await self._im_client.send_message(
                request.context,
                "Modal UI is not available. Please reply with a custom message.",
            )
            return

        if not hasattr(self._im_client, "open_opencode_question_modal") and not hasattr(
            self._im_client, "open_question_modal"
        ):
            await self._im_client.send_message(
                request.context,
                "Modal UI is not available. Please reply with a custom message.",
            )
            return

        try:
            if hasattr(self._im_client, "open_opencode_question_modal"):
                await self._im_client.run_on_client_loop(
                    self._im_client.open_opencode_question_modal(
                        trigger_id=interaction or trigger_id,
                        context=request.context,
                        pending=pending,
                    )
                )
            else:
                await self._im_client.run_on_client_loop(
                    self._im_client.open_question_modal(
                        trigger_id=interaction or trigger_id,
                        context=request.context,
                        pending=pending,
                        callback_prefix="opencode_question",
                    )
                )
        except Exception as err:
            logger.error(f"Failed to open OpenCode question modal: {err}", exc_info=True)
            await self._im_client.send_message(
                request.context,
                f"Failed to open modal: {err}. Please reply with a custom message.",
            )

    async def process_question_answer(
        self,
        request: AgentRequest,
        pending: PendingQuestionPayload,
        server: OpenCodeServerManager,
    ) -> None:
        session_id = pending.get("session_id")
        directory = pending.get("directory")
        question_id = pending.get("question_id")
        option_labels = pending.get("option_labels")
        option_labels = option_labels if isinstance(option_labels, list) else []
        question_count = pending.get("question_count")
        pending_thread_id = pending.get("thread_id")
        question_message_id = pending.get("prompt_message_id")
        trigger_message_id = pending.get("trigger_message_id")  # Original request msg_id

        if pending_thread_id and not request.context.thread_id:
            request.context.thread_id = pending_thread_id

        try:
            question_count_int = int(question_count) if question_count is not None else 1
        except Exception:
            question_count_int = 1
        question_count_int = max(1, question_count_int)

        if not session_id or not directory:
            await self._controller.emit_agent_message(
                request.context,
                "notify",
                "OpenCode question context is missing; please reply with a custom message.",
            )
            return

        answer_text = None
        if request.message.startswith("opencode_question:choose:"):
            try:
                choice_idx = int(request.message.rsplit(":", 1)[-1]) - 1
                if 0 <= choice_idx < len(option_labels):
                    answer_text = str(option_labels[choice_idx]).strip()
            except Exception:
                pass

        is_modal_payload = False
        answers_payload: Optional[List[List[str]]] = None
        if request.message.startswith("opencode_question:modal:"):
            is_modal_payload = True
            try:
                payload = json.loads(request.message.split(":", 2)[-1])
                answers = payload.get("answers") if isinstance(payload, dict) else None
                if isinstance(answers, list) and answers:
                    normalized: List[List[str]] = []
                    for answer in answers:
                        if isinstance(answer, list):
                            normalized.append([str(x) for x in answer if x])
                        elif answer:
                            normalized.append([str(answer)])
                        else:
                            normalized.append([])
                    answers_payload = normalized
                    if normalized:
                        answer_text = " ".join(normalized[0])
            except Exception:
                logger.debug("Failed to parse modal answers payload")

        if answer_text is None and request.message.startswith("opencode_question:"):
            raw_payload = request.message.split(":", 2)[-1]
            answer_text = raw_payload.strip() if raw_payload else ""

        if not answer_text:
            answer_text = (request.message or "").strip()

        if not answer_text:
            await self._controller.emit_agent_message(
                request.context,
                "notify",
                "Please reply with an answer.",
            )
            return

        # Consume pending state now; we'll put it back on failure.
        self._pending_questions.pop(request.base_session_id, None)

        if not question_id:
            call_id = pending.get("call_id")
            message_id = pending.get("message_id")
            try:
                questions = await server.list_questions(directory)
                if not questions:
                    questions = await server.list_questions()
                for item in questions:
                    tool = item.get("tool") or {}
                    item_session_id = item.get("sessionID") or item.get("sessionId") or item.get("session_id")
                    if item_session_id != session_id:
                        continue
                    if call_id and tool.get("callID") != call_id:
                        continue
                    if message_id and tool.get("messageID") != message_id:
                        continue
                    question_id = item.get("id")
                    questions_obj = item.get("questions")
                    if isinstance(questions_obj, list):
                        question_count_int = max(1, len(questions_obj))
                    break
            except Exception as err:
                logger.warning(f"Failed to resolve OpenCode question id: {err}")

        if not question_id:
            self._pending_questions[request.base_session_id] = pending
            await self._controller.emit_agent_message(
                request.context,
                "notify",
                "OpenCode is waiting for input, but the question id could not be resolved. Please retry.",
            )
            return

        if is_modal_payload and answers_payload is not None:
            padded = answers_payload[:question_count_int]
            if len(padded) < question_count_int:
                padded.extend([[] for _ in range(question_count_int - len(padded))])
            answers_payload = padded
        else:
            answers_payload = [[answer_text] for _ in range(question_count_int)]

        if question_message_id and hasattr(self._im_client, "remove_inline_keyboard"):
            fallback_text = pending.get("prompt_text") if isinstance(pending, dict) else None
            try:
                await self._im_client.remove_inline_keyboard(
                    request.context,
                    question_message_id,
                    text=fallback_text,
                    parse_mode="markdown",
                )
            except Exception as err:
                logger.debug(f"Failed to remove question buttons: {err}")

        try:
            ok = await server.reply_question(question_id, directory, answers_payload)
        except Exception as err:
            logger.warning(f"Failed to reply OpenCode question: {err}")
            self._pending_questions[request.base_session_id] = pending
            # Do NOT set event here - let user retry, poll loop keeps waiting
            await self._controller.emit_agent_message(
                request.context,
                "notify",
                f"Failed to submit answer to OpenCode: {err}. Please retry.",
            )
            return

        if not ok:
            self._pending_questions[request.base_session_id] = pending
            # Do NOT set event here - let user retry, poll loop keeps waiting
            await self._controller.emit_agent_message(
                request.context,
                "notify",
                "OpenCode did not accept the answer. Please retry.",
            )
            return

        await self._send_answer_receipt(request, answers_payload)

        # Add 👀 reaction to question message to indicate answer received
        # Track it for cleanup when the request completes
        if question_message_id and hasattr(self._im_client, "add_reaction"):
            try:
                emoji = "eyes"
                await self._im_client.add_reaction(request.context, question_message_id, emoji)
                # Save for cleanup when request completes
                self._answer_reactions[request.base_session_id] = (request.context, question_message_id, emoji)
            except Exception as err:
                logger.debug(f"Failed to add reaction to question message: {err}")

        # Clear consolidated message ID so subsequent log messages appear after user's reply
        # instead of editing the old consolidated message from before the question
        await self._controller.clear_consolidated_message_id(request.context, trigger_message_id)

        evt = self._question_answer_events.get(request.base_session_id)
        if evt:
            evt.set()
            logger.info("Answer submitted for %s, signaling main poll loop to resume", session_id)
        else:
            logger.warning("No wait event found for %s; cannot resume poll", request.base_session_id)
            await self._controller.emit_agent_message(
                request.context,
                "notify",
                "Answer submitted, but I no longer have an active poll loop for this session. "
                "Please send a new message to continue.",
            )

    async def handle_question_toolcall(
        self,
        request: AgentRequest,
        server: OpenCodeServerManager,
        opencode_session_id: str,
        message_id: str,
        tool_part: Dict[str, Any],
        tool_input: Dict[str, Any],
        call_key: str,
        seen_tool_calls: set[str],
    ) -> bool:
        """Render question UI and wait for answer.

        Returns:
            True if answer received (caller should restart poll), False if timed out.
        """

        logger.info(
            "Detected question toolcall for %s message=%s callID=%s",
            opencode_session_id,
            message_id,
            tool_part.get("callID"),
        )

        if not self._platform_supports_question_tool(request):
            logger.warning(
                "Blocking OpenCode question toolcall on unsupported platform %s for session %s",
                getattr(self._controller.config, "platform", ""),
                opencode_session_id,
            )
            seen_tool_calls.add(call_key)
            try:
                await server.abort_session(opencode_session_id, request.working_path)
            except Exception as err:
                logger.warning("Failed to abort OpenCode session after blocked question toolcall: %s", err)
            await self._controller.emit_agent_message(
                request.context,
                "notify",
                self._question_tool_disabled_text(),
            )
            return False

        qlist = tool_input.get("questions") if isinstance(tool_input, dict) else None
        qlist = qlist if isinstance(qlist, list) else []

        question_fetch_deadline = time.monotonic() + 60.0
        question_fetch_delays = [1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0]
        max_question_attempts = 10

        def _question_delay(attempt_index: int) -> float:
            if attempt_index >= len(question_fetch_delays):
                return 0.0
            remaining = question_fetch_deadline - time.monotonic()
            if remaining <= 0:
                return 0.0
            return min(question_fetch_delays[attempt_index], remaining)

        question_id = None
        questions_listing: List[Dict[str, Any]] = []
        list_attempts = 0
        last_list_err: Optional[Exception] = None
        for attempt in range(max_question_attempts):
            list_attempts = attempt + 1
            try:
                questions_listing = await server.list_questions(request.working_path)
                if not questions_listing:
                    questions_listing = await server.list_questions()
                last_list_err = None
            except Exception as err:
                last_list_err = err
                questions_listing = []
            if questions_listing:
                break
            if attempt < max_question_attempts - 1:
                delay = _question_delay(attempt)
                if delay <= 0:
                    break
                await asyncio.sleep(delay)

        if last_list_err and not questions_listing:
            logger.warning(f"Failed to fetch questions listing for prompt fallback: {last_list_err}")

        logger.info(
            "Question list fetch for %s: dir=%s attempts=%s items=%s",
            opencode_session_id,
            request.working_path,
            list_attempts,
            len(questions_listing),
        )

        if questions_listing:
            try:
                item_sessions = [
                    (item.get("sessionID") or item.get("sessionId") or item.get("session_id"))
                    for item in questions_listing
                ]
                logger.info("Question list sessions for %s: %s", opencode_session_id, item_sessions)
            except Exception:
                pass

        if questions_listing and not qlist:
            try:
                first_questions = questions_listing[0].get("questions")
                if not isinstance(first_questions, list):
                    first_questions = []
                listing_preview = {
                    "id": questions_listing[0].get("id"),
                    "sessionID": questions_listing[0].get("sessionID"),
                    "tool": questions_listing[0].get("tool"),
                    "questions_len": len(first_questions),
                }
                logger.info("Question list preview for %s: %s", opencode_session_id, listing_preview)
            except Exception:
                pass

        matched_item = None
        if questions_listing:
            session_items: List[Dict[str, Any]] = []
            for item in questions_listing:
                item_session_id = item.get("sessionID") or item.get("sessionId") or item.get("session_id")
                if item_session_id == opencode_session_id:
                    session_items.append(item)

            for item in session_items:
                tool_meta = item.get("tool") or {}
                if tool_part.get("callID") and tool_meta.get("callID") == tool_part.get("callID"):
                    matched_item = item
                    break
                if message_id and tool_meta.get("messageID") == message_id:
                    matched_item = item
                    break

            if matched_item is None and session_items:
                # Only fallback to the first item if we cannot verify staleness.
                # If both the current tool call and the listed item carry
                # callIDs and they differ, the listed item is stale (e.g. a
                # leftover from a pre-timeout question) — skip it.
                our_call_id = tool_part.get("callID")
                first_item = session_items[0]
                first_call_id = (first_item.get("tool") or {}).get("callID")

                if our_call_id and first_call_id and our_call_id != first_call_id:
                    logger.warning(
                        "Question match for session %s: callID mismatch, "
                        "skipping stale fallback (expected=%s, found=%s, question_id=%s)",
                        opencode_session_id,
                        our_call_id,
                        first_call_id,
                        first_item.get("id"),
                    )
                else:
                    matched_item = first_item
                    logger.warning(
                        "Question match for session %s: no callID/messageID match, "
                        "falling back to first item in same session (id=%s, callID=%s)",
                        opencode_session_id,
                        matched_item.get("id"),
                        (matched_item.get("tool") or {}).get("callID"),
                    )

            # Cross-session fallback by callID — only if exactly one item
            # matches the callID across all sessions (callID is unique per
            # tool invocation, so a single match is reliable).
            if matched_item is None and tool_part.get("callID"):
                call_matches = [
                    item
                    for item in questions_listing
                    if (item.get("tool") or {}).get("callID") == tool_part.get("callID")
                ]
                if len(call_matches) == 1:
                    matched_item = call_matches[0]
                    item_sid = (
                        matched_item.get("sessionID") or matched_item.get("sessionId") or matched_item.get("session_id")
                    )
                    logger.warning(
                        "Question match: cross-session callID fallback "
                        "(current=%s, matched=%s, question_id=%s, callID=%s)",
                        opencode_session_id,
                        item_sid,
                        matched_item.get("id"),
                        tool_part.get("callID"),
                    )
                elif len(call_matches) > 1:
                    logger.warning(
                        "Question match ambiguous by callID; skip fallback (session=%s, callID=%s, matches=%s)",
                        opencode_session_id,
                        tool_part.get("callID"),
                        len(call_matches),
                    )

            # REMOVED: cross-session messageID fallback — messageID is NOT
            # unique across sessions and can bind question_id to the wrong
            # session, causing the current session to hang forever.

            # REMOVED: blind len==1 fallback — grabbed questions from
            # unrelated sessions.

            if matched_item is None:
                logger.warning(
                    "Question match failed for session=%s (callID=%s, messageID=%s, listed=%s, session_items=%s)",
                    opencode_session_id,
                    tool_part.get("callID"),
                    message_id,
                    len(questions_listing),
                    len(session_items),
                )

        if matched_item:
            question_id = matched_item.get("id")
            if not qlist:
                q_obj = matched_item.get("questions")
                if isinstance(q_obj, list):
                    qlist = q_obj

        if not qlist and message_id:
            msg_attempts = 0
            last_msg_err: Optional[Exception] = None
            full_message: Optional[Dict[str, Any]] = None
            for attempt in range(max_question_attempts):
                msg_attempts = attempt + 1
                try:
                    full_message = await server.get_message(
                        session_id=opencode_session_id,
                        message_id=message_id,
                        directory=request.working_path,
                    )
                    last_msg_err = None
                except Exception as err:
                    last_msg_err = err
                    full_message = None

                if full_message:
                    for msg_part in full_message.get("parts", []) or []:
                        if msg_part.get("type") != "tool":
                            continue
                        if msg_part.get("tool") != "question":
                            continue
                        msg_call_id = msg_part.get("callID") or msg_part.get("id")
                        if call_key and msg_call_id and msg_call_id != call_key:
                            continue
                        msg_state = msg_part.get("state") or {}
                        msg_input = msg_state.get("input") or {}
                        msg_questions = msg_input.get("questions") if isinstance(msg_input, dict) else None
                        if isinstance(msg_questions, list):
                            qlist = msg_questions
                            break
                if qlist:
                    break
                if attempt < max_question_attempts - 1:
                    delay = _question_delay(attempt)
                    if delay <= 0:
                        break
                    await asyncio.sleep(delay)

            if last_msg_err and not qlist:
                logger.warning(f"Failed to fetch full question input from message {message_id}: {last_msg_err}")
            if full_message is not None:
                parts = full_message.get("parts", []) or []
                tool_parts = [p for p in parts if p.get("type") == "tool"]
                logger.info(
                    "Question message fetch for %s: attempts=%s parts=%s tool_parts=%s",
                    opencode_session_id,
                    msg_attempts,
                    len(parts),
                    len(tool_parts),
                )

        option_labels: list[str] = []
        lines: list[str] = []
        for q_idx, q in enumerate(qlist or []):
            if not isinstance(q, dict):
                continue
            title = (q.get("header") or f"Question {q_idx + 1}").strip()
            prompt = (q.get("question") or "").strip()
            options_raw = q.get("options")
            options: List[Dict[str, Any]] = options_raw if isinstance(options_raw, list) else []

            lines.append(f"**{title}**")
            if prompt:
                lines.append(prompt)

            for idx, opt in enumerate(options, start=1):
                if not isinstance(opt, dict):
                    continue
                label = (opt.get("label") or f"Option {idx}").strip()
                desc = (opt.get("description") or "").strip()
                if q_idx == 0:
                    option_labels.append(label)
                if desc:
                    lines.append(f"{idx}. *{label}* - {desc}")
                else:
                    lines.append(f"{idx}. *{label}*")

            if q_idx < len(qlist) - 1:
                lines.append("")

        first_q = qlist[0] if qlist and isinstance(qlist[0], dict) else {}
        text = "\n".join(lines)
        logger.info(
            "Question prompt built for %s: len=%s preview=%r",
            opencode_session_id,
            len(text),
            text[:200],
        )

        logger.info(
            "Question prompt data for %s: qlist=%s options=%s question_id=%s call_id=%s",
            opencode_session_id,
            len(qlist),
            len(option_labels),
            question_id,
            tool_part.get("callID"),
        )

        if not option_labels:
            logger.warning(
                "Question toolcall had no options in tool_input; session=%s question_id=%s",
                opencode_session_id,
                question_id,
            )

        question_count = len(qlist) if qlist else 1
        multiple = bool(first_q.get("multiple"))

        pending_payload: PendingQuestionPayload = {
            "session_id": opencode_session_id,
            "directory": request.working_path,
            "question_id": question_id,
            "call_id": tool_part.get("callID"),
            "message_id": message_id,
            "prompt_message_id": None,
            "prompt_text": text,
            "option_labels": option_labels,
            "question_count": question_count,
            "multiple": multiple,
            "questions": qlist,
            "thread_id": request.context.thread_id,
            "trigger_message_id": request.context.message_id,  # For consolidated key
        }
        self._pending_questions[request.base_session_id] = pending_payload

        # Multi-select or multi-question: show full text + modal button
        if multiple or question_count != 1 or len(option_labels) > 10:
            modal_keyboard = None
            if hasattr(self._im_client, "send_message_with_buttons"):
                from modules.im import InlineButton, InlineKeyboard

                modal_keyboard = InlineKeyboard(
                    buttons=[[InlineButton(text="Choose…", callback_data="opencode_question:open_modal")]]
                )

            if modal_keyboard:
                try:
                    logger.info(
                        "Sending modal open button for %s (multiple=%s questions=%s)",
                        opencode_session_id,
                        multiple,
                        question_count,
                    )
                    question_message_id = await self._im_client.send_message_with_buttons(
                        request.context,
                        text,
                        modal_keyboard,
                        parse_mode="markdown",
                    )
                    if question_message_id:
                        pending_payload["prompt_message_id"] = question_message_id

                    seen_tool_calls.add(call_key)
                    if await self.wait_for_question_answer(request, opencode_session_id, pending_payload):
                        return True
                    return False
                except Exception as err:
                    logger.warning(
                        f"Failed to send modal button, falling back to text: {err}",
                        exc_info=True,
                    )

            await self._im_client.send_message(request.context, text, parse_mode="markdown")
            seen_tool_calls.add(call_key)
            if await self.wait_for_question_answer(request, opencode_session_id, pending_payload):
                return True
            return False

        # single question + single select + <=10 options -> buttons
        if (
            question_count == 1
            and isinstance(first_q, dict)
            and not multiple
            and len(option_labels) <= 10
            and hasattr(self._im_client, "send_message_with_buttons")
        ):
            from modules.im import InlineButton, InlineKeyboard

            buttons: list[list[InlineButton]] = []
            row: list[InlineButton] = []
            for idx, label in enumerate(option_labels, start=1):
                callback = f"opencode_question:choose:{idx}"
                row.append(InlineButton(text=label, callback_data=callback))
                if len(row) == 5:
                    buttons.append(row)
                    row = []
            if row:
                buttons.append(row)

            keyboard = InlineKeyboard(buttons=buttons)
            try:
                logger.info(
                    "Sending single-select buttons for %s (options=%s)",
                    opencode_session_id,
                    len(option_labels),
                )
                question_message_id = await self._im_client.send_message_with_buttons(
                    request.context,
                    text,
                    keyboard,
                    parse_mode="markdown",
                )
                if question_message_id:
                    pending_payload["prompt_message_id"] = question_message_id
                seen_tool_calls.add(call_key)
                if await self.wait_for_question_answer(request, opencode_session_id, pending_payload):
                    return True
                return False
            except Exception as err:
                logger.warning(
                    f"Failed to send Slack buttons, falling back to text: {err}",
                    exc_info=True,
                )

        # fallback: text-only
        try:
            await self._im_client.send_message(
                request.context,
                text,
                parse_mode="markdown",
            )
        except Exception as err:
            logger.error(f"Failed to send question prompt to Slack: {err}", exc_info=True)

        seen_tool_calls.add(call_key)
        if await self.wait_for_question_answer(request, opencode_session_id, pending_payload):
            return True
        return False
