"""Terminal renderer for agent_ws_v3 streaming messages."""

from __future__ import annotations

import json
import random
import re
import sys
import threading
import time
from dataclasses import dataclass
from typing import Any, Optional

from rich.console import Console, Group
from rich.live import Live
from rich.markdown import Markdown
from rich.panel import Panel
from rich.pretty import Pretty
from rich.status import Status
from rich.table import Table
from rich.text import Text

RENDER_PROFILE_TERMINAL = "terminal"
RENDER_PROFILE_IM = "im"


@dataclass
class AskPromptRequest:
    """Represents an ask_message that requires user input."""

    message_id: str
    questions: list[dict[str, Any]]


ASK_MESSAGE_TYPES = {"ask_message", "ask_user_input", "askuserinput"}
COLLECTION_PLAN_MESSAGE_TYPES = {"collection_plan", "collect_plan", "collect-plan"}


def _normalize_message_type(raw_type: Any) -> str:
    msg_type = str(raw_type or "_unknown")
    if msg_type in ASK_MESSAGE_TYPES:
        return "ask_message"
    if msg_type in COLLECTION_PLAN_MESSAGE_TYPES:
        return "collection_plan"
    return msg_type


class StreamRenderer:
    """Render websocket messages with type-aware formatting."""

    def __init__(
        self,
        console: Optional[Console] = None,
        *,
        collapse_thinking: bool = False,
        plain_output: bool = False,
        render_profile: str = RENDER_PROFILE_TERMINAL,
        live_status_enabled: bool = True,
        defer_collection_plan: bool = False,
        sticky_collection_plan_live: bool = False,
    ) -> None:
        self.console = console or Console()
        self._collapse_thinking = collapse_thinking
        self._plain_output = plain_output
        self._render_profile = render_profile
        self._live_status_enabled = live_status_enabled
        self._defer_collection_plan = defer_collection_plan
        self._sticky_collection_plan_live = sticky_collection_plan_live
        self._stream_buffers: dict[str, str] = {}
        self._stream_labels: dict[str, str] = {}
        self._stream_started: set[str] = set()
        self._open_stream_ids: set[str] = set()
        self._active_stream_id: Optional[str] = None
        self._live_thinking_stream_id: Optional[str] = None
        self._live_thinking_line_len = 0
        self._thinking_block_open = False
        self._thinking_line_buf = ""
        self._completed_thinking: dict[str, dict[str, Any]] = {}
        self._thinking_status: Optional[Status] = None
        self._thinking_lock = threading.Lock()
        self._thinking_stop_event: Optional[threading.Event] = None
        self._thinking_animation_thread: Optional[threading.Thread] = None
        self._thinking_plain_text = "thinking..."
        self._thinking_sweep_index = 0
        self._thinking_sweep_width = 4
        self._agent_thinking_active = False
        self._static_thinking_notice_visible = False
        self._pending_mcp_tools: dict[str, dict] = {}  # message_id -> {tool_name, sweep_index}
        self._deferred_im_agent_message: Optional[str] = None
        self._deferred_im_recommended_questions: list[str] = []
        self._deferred_collection_plan: Optional[dict[str, Any]] = None
        self._collection_plan_live: Optional[Live] = None
        self._rendered_ask_answers: set[tuple[str, str]] = set()
        self._pending_historical_asks: dict[str, dict[str, Any]] = {}
        self._loading_messages = [
            "Still working on your request...",
            "Gathering context and checking details...",
            "Crunching through the next steps...",
            "Running tools and validating results...",
            "Polishing the response for you...",
        ]
        self._loading_message = random.choice(self._loading_messages)
        self._loading_next_switch_at = time.monotonic() + 1.8

    def set_live_status_enabled(self, enabled: bool) -> None:
        self._live_status_enabled = enabled
        if not enabled:
            self._stop_thinking_status()

    def reset_turn_state(self) -> None:
        self._deferred_im_agent_message = None
        self._deferred_im_recommended_questions = []
        self._deferred_collection_plan = None
        self._pending_historical_asks = {}
        self._stop_collection_plan_live()

    def begin_historical_replay(self) -> None:
        self._pending_historical_asks = {}
        self._rendered_ask_answers = set()

    def flush_pending_historical_asks(self) -> None:
        if not self._pending_historical_asks:
            return
        pending = list(self._pending_historical_asks.values())
        self._pending_historical_asks = {}
        for item in pending:
            self._render_historical_ask_state(item)

    def _glyph(self, rich_text: str, plain_text: str) -> str:
        return plain_text if self._plain_output else rich_text

    def _is_im_profile(self) -> bool:
        return self._render_profile == RENDER_PROFILE_IM

    def _safe_text(self, text: str) -> str:
        if not self._plain_output:
            return text

        encoding = (
            getattr(self.console.file, "encoding", None)
            or getattr(sys.stdout, "encoding", None)
            or "utf-8"
        )
        try:
            return text.encode(encoding, errors="ignore").decode(encoding, errors="ignore")
        except Exception:
            return text

    def _console_supports_unicode(self) -> bool:
        encoding = (
            getattr(self.console.file, "encoding", None)
            or getattr(sys.stdout, "encoding", None)
            or ""
        ).lower()
        return "utf" in encoding or "utf-" in encoding

    def _should_render_session_notice(self, message: str, *, warning: bool) -> bool:
        if not self._plain_output:
            return True
        if not self._is_im_profile():
            return True
        if warning:
            return True
        return message.startswith("thread_id=")

    def flush_deferred_output(self) -> None:
        if not (
            (self._plain_output and self._is_im_profile())
            or self._defer_collection_plan
            or self._sticky_collection_plan_live
        ):
            return

        if self._deferred_im_agent_message:
            self.console.print(self._safe_text(self._deferred_im_agent_message))
        if self._deferred_im_recommended_questions:
            self.console.print(self._safe_text("Recommended questions"))
            for index, question in enumerate(self._deferred_im_recommended_questions, 1):
                self.console.print(self._safe_text(f"  {index}. {question}"))
        if self._deferred_collection_plan:
            self._render_collection_plan_snapshot(self._deferred_collection_plan)

        self._deferred_im_agent_message = None
        self._deferred_im_recommended_questions = []
        self._deferred_collection_plan = None

    def render_banner(
        self,
        *,
        endpoint: str,
        user_id: str,
        thread_id: str,
        language: Optional[str] = None,
        hide_session_details: bool = False,
    ) -> None:
        if self._plain_output:
            return

        thread_label = thread_id or "(awaiting metadata)"
        status = Table.grid(padding=(0, 1))
        status.add_column(style="bold cyan", no_wrap=True)
        status.add_column()
        status.add_row("language", language or "-")
        status.add_row("thread", thread_label if not hide_session_details else "(hidden)")
        if not hide_session_details:
            status.add_row("user", user_id)
            status.add_row("endpoint", endpoint)
        status.add_row("commands", "/help  /new  /resume <thread_id>  /thread")

        logo = Text()
        logo.append("LUCKEE\n", style="bold cyan")
        logo.append("Amazon Ads CLI", style="dim")

        self.console.print(
            Panel(
                Group(logo, status),
                title="Luckee",
                border_style="cyan",
                expand=False,
            )
        )

    def render_invocation_error(
        self,
        *,
        summary: str,
        hint: str,
        detail: Optional[str] = None,
        debug: bool = False,
    ) -> None:
        if self._plain_output:
            self.console.print(self._safe_text(summary))
            self.console.print(self._safe_text(f"Hint: {hint}"))
            if debug and detail:
                self.console.print(self._safe_text(f"Detail: {detail}"))
            return

        self.console.print(f"[bold red]{summary}[/bold red]")
        self.console.print(f"[dim]{hint}[/dim]")
        if debug and detail:
            self.console.print(f"[dim]detail: {detail}[/dim]")

    def render_session_notice(self, message: str, *, warning: bool = False) -> None:
        if not self._should_render_session_notice(message, warning=warning):
            return
        label = "Session"
        if self._plain_output:
            self.console.print(self._safe_text(f"{label}: {message}"))
            return

        style = "yellow" if warning else "cyan"
        self.console.print(f"[{style}]{label}:[/{style}] {message}")

    def _strip_session_block(self, text: str) -> str:
        """Remove session banner-like blocks echoed in model output."""
        if not text:
            return text

        # Matches common variants that include the session heading and fields.
        block_pattern = re.compile(
            r"(?:^|\n)"
            r"(?:[^\n]*Session[^\n]*\n)?"
            r"(?:[^\n]*\n){0,3}"
            r"[^\n]*Agent Loop CLI[^\n]*\n"
            r"[^\n]*endpoint:[^\n]*\n"
            r"[^\n]*user_id:[^\n]*\n"
            r"[^\n]*thread_id:[^\n]*"
            r"(?:\n[^\n]*){0,3}",
            re.IGNORECASE,
        )
        cleaned = block_pattern.sub("\n", text)
        cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
        return cleaned

    def render_help(self) -> None:
        command_rows = [
            ("/help", "Show this help"),
            ("/thread", "Show current thread id"),
            ("/new", "Reconnect with a fresh session"),
            ("/resume <thread_id>", "Reconnect and resume a specific thread"),
            ("/attach <path>", "Queue local file for next query upload"),
            ("/attachments", "List queued local attachments"),
            ("/detach <index|all>", "Remove queued attachments"),
            ("/clear", "Clear terminal output"),
            ("/interrupt", "Send interrupt message"),
            ("/upgrade", "Upgrade CLI from configured wheel server"),
            ("/thinking", "List cached thinking blocks"),
            ("/thinking <message_id|prefix>", "Show one thinking block"),
            ("/exit or /quit", "Terminate and exit"),
        ]

        if getattr(self.console, "no_color", False) or not self._console_supports_unicode():
            self.console.print("Commands")
            for command, description in command_rows:
                self.console.print(f"  {command:<28} {description}")
            return

        table = Table(title="Commands", show_header=True, header_style="bold magenta")
        table.add_column("Command", style="cyan")
        table.add_column("Description")
        for command, description in command_rows:
            table.add_row(command, description)
        self.console.print(table)

    def render_thinking_history(self, message_id: Optional[str] = None) -> None:
        if not self._completed_thinking:
            self.console.print("[dim]no thinking blocks cached[/dim]")
            return

        if not message_id:
            self.console.print("[bold magenta]Thinking blocks[/bold magenta]")
            for key, item in self._completed_thinking.items():
                thinking_time = item.get("thinking_time")
                duration = (
                    f"{thinking_time:.1f}s"
                    if isinstance(thinking_time, (int, float))
                    else "unknown"
                )
                preview = str(item.get("text") or "").strip().replace("\n", " ")
                if len(preview) > 70:
                    preview = f"{preview[:67]}..."
                self.console.print(f"  {key} ({duration}) {preview}")
            return

        selected_id = message_id
        if selected_id not in self._completed_thinking:
            matches = [k for k in self._completed_thinking if k.startswith(selected_id)]
            if len(matches) == 1:
                selected_id = matches[0]
            elif len(matches) > 1:
                self.console.print(
                    f"[yellow]multiple matches for prefix '{message_id}'[/yellow]"
                )
                for key in matches:
                    self.console.print(f"  {key}")
                return
            else:
                self.console.print(f"[red]thinking block not found:[/red] {message_id}")
                return

        item = self._completed_thinking[selected_id]
        thinking_time = item.get("thinking_time")
        if isinstance(thinking_time, (int, float)):
            title = f"Thinking ({thinking_time:.1f}s) {selected_id}"
        else:
            title = f"Thinking {selected_id}"
        self.console.print(
            Panel(
                self._format_thinking_panel_body(str(item.get("text") or "")),
                title=title,
                border_style="magenta",
                width=self._thinking_panel_width(),
                expand=False,
            )
        )

    def render_message(
        self, msg: dict[str, Any], *, historical: bool = False
    ) -> Optional[AskPromptRequest]:
        msg_type = _normalize_message_type(msg.get("type", "_unknown"))
        if msg_type == "complete":
            self.flush_pending_historical_asks()
            self._stop_thinking_status()
            credits = msg.get("credits")
            credits_suffix = f" credits={credits}" if credits is not None else ""
            if not self._plain_output:
                self.console.print(
                    f"[bold green]{self._glyph('✓', 'OK')} complete[/bold green]{credits_suffix}"
                )
            self._stop_collection_plan_live()
            self.flush_deferred_output()
            return None

        metadata_like_message = msg_type in {"metadata", "config", "topic_message"}

        if (
            not historical
            and self._should_start_live_status(msg_type, msg)
            and not self._open_stream_ids
        ):
            self._ensure_thinking_status()
        if msg_type not in {"agent_thinking", "metadata"}:
            self._agent_thinking_active = False

        if metadata_like_message:
            return None

        if (
            self._thinking_block_open
            and msg_type != "thinking_commentary"
            and msg_type != "agent_thinking"
        ):
            self._close_thinking_block(self._format_duration(None))

        if historical and self._should_flush_historical_asks_before(msg_type, msg):
            self.flush_pending_historical_asks()

        if msg_type == "user_message":
            content = (
                msg.get("message")
                or msg.get("user_message")
                or msg.get("user_query")
                or ""
            )
            if isinstance(content, str) and content.strip():
                rendered = self._strip_session_block(content)
                if self._plain_output:
                    self.console.print(self._safe_text(f"You: {rendered}"))
                else:
                    self.console.print(f"[bold white]You:[/bold white] {self._safe_text(rendered)}")
            return None

        if msg_type in {
            "thinking_commentary",
            "commentary",
            "child_step_commentary",
            "inter_child_text",
        }:
            if self._plain_output and (
                self._is_im_profile() or msg_type != "thinking_commentary"
            ):
                return None
            self._render_streaming_commentary(msg)
            return None

        if msg_type == "agent_message":
            content = msg.get("agent_message") or msg.get("message") or ""
            if isinstance(content, str):
                content = self._strip_session_block(content)
                if content.strip():
                    if self._plain_output and self._is_im_profile():
                        self._deferred_im_agent_message = content
                    elif self._plain_output:
                        self.console.print(self._safe_text(content))
                    else:
                        self.console.print(Markdown(content))
            else:
                self.console.print(Pretty(content))
            return None

        if msg_type in {"tool_execution", "tool_progress", "tool_completed"}:
            if self._plain_output:
                return None
            self._render_tool_event(msg, historical=historical)
            return None

        if msg_type == "mcp_message":
            self._handle_mcp_message(msg, historical=historical)
            return None

        if msg_type == "skill_message":
            self._handle_skill_message(msg, historical=historical)
            return None

        if msg_type == "subagent_message":
            self._handle_subagent_message(msg, historical=historical)
            return None

        if msg_type == "agent_thinking":
            if self._plain_output:
                return None
            if historical:
                return None
            step_index = msg.get("step_index")
            thinking_text = str(msg.get("message") or "thinking...")
            step_prefix = f"step {step_index} " if step_index is not None else ""
            self._thinking_plain_text = f"{step_prefix}{thinking_text}"
            self._thinking_sweep_index = 0
            if not self._live_status_enabled and not self._static_thinking_notice_visible:
                self.console.print(
                    self._safe_text(
                        f"{self._glyph('✨', 'THINK')} {self._thinking_plain_text}"
                    )
                )
                self._static_thinking_notice_visible = True
            self._agent_thinking_active = True
            if self._thinking_status is not None:
                self._thinking_status.update(self._format_thinking_status_text())
            return None

        if msg_type in {"step_discovered", "step_started", "step_progress", "step_completed"}:
            if self._plain_output:
                return None
            step_index = msg.get("step_index", "?")
            title = msg.get("title") or msg.get("description") or ""
            self.console.print(f"[cyan]step {step_index}[/cyan] {msg_type}: {title}")
            return None

        if msg_type in {"interrupted", "terminated"}:
            self._stop_thinking_status()
            self._stop_collection_plan_live()
            message = str(msg.get("message", "") or "")
            if getattr(self.console, "no_color", False) or not self._console_supports_unicode():
                self.console.print(self._safe_text(f"{msg_type}: {message}"))
            else:
                self.console.print(f"[yellow]{msg_type}:[/yellow] {message}")
            return None

        if msg_type == "recommended_questions":
            questions = msg.get("questions") or []
            if questions:
                if self._plain_output and self._is_im_profile():
                    self._deferred_im_recommended_questions = [str(q) for q in questions]
                elif self._plain_output:
                    self.console.print(self._safe_text("Recommended questions"))
                    for i, q in enumerate(questions, 1):
                        self.console.print(self._safe_text(f"  {i}. {q}"))
                else:
                    self.console.print("[bold cyan]Recommended questions[/bold cyan]")
                    for i, q in enumerate(questions, 1):
                        self.console.print(f"  {i}. {q}")
            return None

        if msg_type == "collection_plan":
            if self._plain_output and self._is_im_profile():
                self._deferred_collection_plan = self._normalize_collection_plan_snapshot(
                    msg
                )
                return None
            if self._sticky_collection_plan_live and not self._plain_output:
                snapshot = self._normalize_collection_plan_snapshot(msg)
                self._deferred_collection_plan = snapshot
                self._update_collection_plan_live(snapshot)
                return None
            if self._defer_collection_plan:
                self._deferred_collection_plan = self._normalize_collection_plan_snapshot(
                    msg
                )
                return None
            self._render_collection_plan(msg)
            return None

        if msg_type == "file_monitor":
            if self._plain_output and self._is_im_profile():
                return None
            self._render_file_monitor(msg)
            return None

        if msg_type == "error_update":
            self._stop_thinking_status()
            self._stop_collection_plan_live()
            err = msg.get("error_message") or msg.get("ui_message") or "Unknown error"
            code = msg.get("error_code")
            if getattr(self.console, "no_color", False) or not self._console_supports_unicode():
                self.console.print(self._safe_text(f"Error\ncode={code}\n{err}"))
            else:
                self.console.print(
                    Panel(
                        f"[bold red]Error[/bold red]\ncode={code}\n{err}",
                        border_style="red",
                    )
                )
            return None

        if msg_type == "resume_complete":
            self.flush_pending_historical_asks()
            if self._plain_output and self._is_im_profile():
                return None
            self.console.print(
                f"[green]resume complete[/green] lines={msg.get('line_count', '?')}"
            )
            return None

        if msg_type == "ask_message":
            status = str(msg.get("status") or "")
            answers = msg.get("answers") or {}
            questions = list(msg.get("questions") or [])
            if historical:
                self._record_historical_ask_state(
                    message_id=str(msg.get("message_id", "")),
                    questions=questions,
                    answers=answers if isinstance(answers, dict) else {},
                    status=status,
                    answer=msg.get("answer"),
                    error=msg.get("error"),
                )
                return None
            if isinstance(answers, dict) and answers:
                self._render_ask_answers(
                    message_id=str(msg.get("message_id", "")),
                    answers=answers,
                )
                return None
            if status == "called" or (historical and not status and questions):
                if historical:
                    self._render_historical_ask_prompt(
                        message_id=str(msg.get("message_id", "")),
                        questions=questions,
                    )
                    return None
                # Ensure the prompt is visible and not overwritten by live status updates.
                self._stop_thinking_status()
                self._render_ask_prompt(msg)
                return AskPromptRequest(
                    message_id=str(msg.get("message_id", "")),
                    questions=questions,
                )
            if historical and not status:
                return None
            self._render_ask_message_status(msg)
            return None

        # Fallback for unknown/new message types.
        compact = json.dumps(msg, ensure_ascii=False)
        self.console.print(
            Panel(compact, title=f"message:{msg_type}", border_style="white")
        )
        return None

    def _should_flush_historical_asks_before(
        self, msg_type: str, msg: dict[str, Any]
    ) -> bool:
        if msg_type in {
            "ask_message",
            "metadata",
            "config",
            "topic_message",
            "thinking_commentary",
            "commentary",
            "child_step_commentary",
            "inter_child_text",
            "agent_thinking",
        }:
            return False
        if msg_type == "user_message":
            content = (
                msg.get("message")
                or msg.get("user_message")
                or msg.get("user_query")
                or ""
            )
            return bool(str(content).strip())
        if msg_type == "agent_message":
            content = msg.get("agent_message") or msg.get("message") or ""
            if not isinstance(content, str):
                return True
            return bool(self._strip_session_block(content).strip())
        return True

    def _record_historical_ask_state(
        self,
        *,
        message_id: str,
        questions: list[dict[str, Any]],
        answers: dict[str, Any],
        status: str,
        answer: Any,
        error: Any,
    ) -> None:
        if not message_id:
            message_id = f"_historical_ask_{len(self._pending_historical_asks) + 1}"
        item = self._pending_historical_asks.setdefault(
            message_id,
            {
                "message_id": message_id,
                "questions": [],
                "answers": {},
                "status": "",
                "answer": "",
                "error": None,
            },
        )
        if questions:
            item["questions"] = questions
        if answers:
            item_answers = item.setdefault("answers", {})
            item_answers.update(
                {
                    str(question): str(selected)
                    for question, selected in answers.items()
                    if question is not None and selected is not None
                }
            )
        normalized_status = status.strip().lower()
        if normalized_status:
            item["status"] = normalized_status
        if isinstance(answer, str) and answer.strip():
            item["answer"] = answer
        if error:
            item["error"] = error

    def _render_historical_ask_state(self, item: dict[str, Any]) -> None:
        message_id = str(item.get("message_id") or "")
        answers = item.get("answers") or {}
        status = str(item.get("status") or "")
        questions = list(item.get("questions") or [])
        if isinstance(answers, dict) and answers:
            self._render_ask_answers(message_id=message_id, answers=answers)
            if status in {"failed", "error"}:
                self._render_ask_message_status(
                    {
                        "type": "ask_message",
                        "status": status,
                        "message_id": message_id,
                        "error": item.get("error"),
                    }
                )
            return
        if status in {"failed", "error"}:
            self._render_ask_message_status(
                {
                    "type": "ask_message",
                    "status": status,
                    "message_id": message_id,
                    "error": item.get("error"),
                }
            )
            return
        if status == "answered":
            self._render_ask_message_status(
                {
                    "type": "ask_message",
                    "status": status,
                    "message_id": message_id,
                    "answer": item.get("answer"),
                }
            )
            return
        if questions:
            self._render_historical_ask_prompt(
                message_id=message_id,
                questions=questions,
            )
            return

    def _render_file_monitor(self, msg: dict[str, Any]) -> None:
        file_path = str(msg.get("file_path") or "(unknown)")
        file_type = str(msg.get("file_type") or "file")
        metadata = msg.get("metadata")
        source = ""
        reason = ""
        if isinstance(metadata, dict):
            source = str(metadata.get("source") or "")
            reason = str(metadata.get("reason") or "")

        lines = [f"[blue]file monitor[/blue] ({file_type}) [cyan]{file_path}[/cyan]"]
        if source:
            lines.append(f"[dim]source:[/dim] {source}")
        if reason:
            lines.append(f"[dim]reason:[/dim] {reason}")

        content = msg.get("content")
        if isinstance(content, str) and content.strip():
            lines.append("")
            lines.append(content.rstrip())

        self.console.print("\n".join(lines))

    def _should_start_live_status(self, msg_type: str, msg: dict[str, Any]) -> bool:
        if msg_type in {
            "thinking_commentary",
            "commentary",
            "child_step_commentary",
            "inter_child_text",
            "agent_thinking",
            "step_discovered",
            "step_started",
            "step_progress",
            "tool_execution",
            "tool_progress",
        }:
            return True
        if msg_type in {"mcp_message", "skill_message", "subagent_message"}:
            status = str(msg.get("status") or "")
            return status in {"called", "running"}
        return False

    def _render_streaming_commentary(self, msg: dict[str, Any]) -> None:
        msg_type = msg.get("type", "commentary")
        phase = msg.get("phase")
        message_id = str(msg.get("message_id") or "")
        label = msg_type.replace("_", " ")
        if message_id:
            self._stream_labels[message_id] = label

        if phase == "start":
            if message_id:
                self._stream_buffers[message_id] = ""
                self._stream_started.discard(message_id)
                self._open_stream_ids.add(message_id)
                self._active_stream_id = message_id
                if msg_type == "thinking_commentary":
                    self._live_thinking_stream_id = message_id
                    self._live_thinking_line_len = 0
            if not self._plain_output and msg_type != "thinking_commentary":
                self._stop_thinking_status()
            return

        if phase == "delta":
            delta = str(msg.get("delta") or "")
            if (
                self._thinking_status is not None
                and not self._plain_output
                and msg_type != "thinking_commentary"
            ):
                self._stop_thinking_status()
            if not message_id:
                if delta and not self._plain_output:
                    self.console.print(delta, end="")
                return

            if (
                not self._plain_output
                and self._active_stream_id
                and self._active_stream_id != message_id
            ):
                if self._thinking_block_open:
                    self._close_thinking_block(self._format_duration(None))
                else:
                    self._finish_live_thinking_line()
                self.console.print("")
            self._active_stream_id = message_id
            self._stream_buffers[message_id] = self._stream_buffers.get(message_id, "") + delta
            if message_id not in self._stream_started:
                if not self._plain_output:
                    if msg_type == "thinking_commentary":
                        self._open_thinking_block()
                    else:
                        self.console.print(f"[dim]{label}...[/dim]")
                self._stream_started.add(message_id)
            if delta and not self._plain_output:
                if msg_type == "thinking_commentary":
                    self._write_thinking_delta(delta)
                else:
                    self.console.print(delta, end="")
            return

        if phase == "complete":
            final_text = msg.get("final_text")
            thinking_time = msg.get("thinking_time")
            if msg_type == "thinking_commentary":
                text = self._stream_buffers.get(message_id, "")
                if isinstance(final_text, str) and final_text.strip():
                    text = final_text
                if message_id:
                    self._completed_thinking[message_id] = {
                        "text": text,
                        "thinking_time": thinking_time,
                    }
                duration = self._format_duration(thinking_time)
                if self._thinking_block_open:
                    self._close_thinking_block(duration)
                elif text.strip():
                    self._render_thinking_commentary_line(text, thinking_time)
            else:
                text = (
                    final_text
                    if isinstance(final_text, str) and final_text.strip()
                    else self._stream_buffers.get(message_id, "")
                )
                text = self._strip_session_block(text)
                self.console.print("")  # newline after delta stream
                if text.strip():
                    self.console.print(Panel(Markdown(text), title=label, border_style="cyan"))
            self._stream_buffers.pop(message_id, None)
            self._stream_labels.pop(message_id, None)
            self._stream_started.discard(message_id)
            self._open_stream_ids.discard(message_id)
            if self._active_stream_id == message_id:
                self._active_stream_id = None
            if self._live_thinking_stream_id == message_id:
                self._live_thinking_stream_id = None
            return

        # Unknown phase or non-streaming payload
        fallback_text = str(msg.get("message") or msg.get("text") or "")
        fallback_text = self._strip_session_block(fallback_text)
        if fallback_text:
            self.console.print(f"[dim]{label}:[/dim] {self._safe_text(fallback_text)}")

    def _thinking_panel_width(self) -> int:
        # Keep thinking blocks in a stable, readable area.
        return max(56, min(self.console.width - 2, 86))

    def _format_thinking_panel_body(self, text: str) -> str:
        # Render as single-row viewport:
        # - thinking
        #   <one line clipped to fixed width>
        inner_width = max(24, self._thinking_panel_width() - 8)
        single_line = " ".join(text.split())
        if len(single_line) > inner_width:
            # Keep a fixed-width viewport on the newest content.
            if self._plain_output:
                single_line = f"...{single_line[-(inner_width - 3):]}"
            else:
                single_line = f"…{single_line[-(inner_width - 1):]}"
        return f"- thinking\n   {single_line}"

    def _format_duration(self, seconds: Any) -> str:
        if not isinstance(seconds, (int, float)):
            return "unknown"
        if seconds < 60:
            return f"{seconds:.1f}s"
        mins = int(seconds // 60)
        rem = int(seconds % 60)
        return f"{mins}m {rem:02d}s"

    def _single_row_thinking_text(self, text: str) -> str:
        viewport = max(28, min(self.console.width - 22, 92))
        single_line = " ".join(text.split())
        if len(single_line) > viewport:
            if self._plain_output:
                single_line = f"...{single_line[-(viewport - 3):]}"
            else:
                single_line = f"…{single_line[-(viewport - 1):]}"
        return single_line

    def _render_thinking_commentary_line(self, text: str, thinking_time: Any) -> None:
        if self._plain_output and self._is_im_profile():
            return
        duration = self._format_duration(thinking_time)
        self.console.print(
            f"[magenta]{self._glyph('🧠', 'THINK')} Thinking for {duration}[/magenta]"
        )
        self.console.print(
            f"[dim]   {self._safe_text(self._single_row_thinking_text(text))}[/dim]"
        )

    def _open_thinking_block(self) -> None:
        if self._thinking_block_open:
            return
        self._thinking_line_buf = ""
        self._thinking_block_open = True

    def _write_thinking_delta(self, delta: str) -> None:
        if not delta:
            return
        self._thinking_line_buf += delta
        if self._plain_output:
            return
        with self._thinking_lock:
            if self._thinking_status is not None:
                self._thinking_status.update(self._format_thinking_status_text())

    def _close_thinking_block(self, duration: str) -> None:
        if not self._thinking_block_open:
            return
        self._thinking_block_open = False
        self._thinking_line_buf = ""

    def _finish_live_thinking_line(self) -> None:
        if self._thinking_block_open:
            return
        if self._live_thinking_line_len > 0:
            self.console.file.write("\n")
            self.console.file.flush()
            self._live_thinking_line_len = 0

    def _render_ask_message_status(self, msg: dict[str, Any]) -> None:
        status = str(msg.get("status") or "unknown")
        message_id = str(msg.get("message_id") or "")
        answer = msg.get("answer")
        error = msg.get("error")

        if self._plain_output and self._is_im_profile() and status == "answered":
            return

        if status == "answered":
            title = "[green]✓ answer submitted[/green]"
            suffix = f" id={message_id}" if message_id else ""
            self.console.print(f"{title}{suffix}")
            if isinstance(answer, str) and answer.strip():
                self.console.print(f"[dim]   {self._single_row_thinking_text(answer)}[/dim]")
            return

        if status == "failed":
            detail = str(error or "ask_message failed")
            self.console.print(f"[red]✗ ask_message failed[/red] id={message_id}")
            self.console.print(f"[dim]   {self._single_row_thinking_text(detail)}[/dim]")
            return

        # Keep non-called statuses readable without dumping raw JSON blobs.
        suffix = f" id={message_id}" if message_id else ""
        self.console.print(f"[dim]ask_message status={status}[/dim]{suffix}")

    def _render_ask_prompt(self, msg: dict[str, Any]) -> None:
        if not self._plain_output:
            return

        message_id = str(msg.get("message_id") or "")
        questions = list(msg.get("questions") or [])

        self.console.print(self._safe_text("Need user input."))
        if message_id:
            self.console.print(self._safe_text(f"message_id={message_id}"))

        for index, question in enumerate(questions, 1):
            question_text = str(question.get("question") or f"Question {index}")
            self.console.print(self._safe_text(f"Q{index}: {question_text}"))
            options = question.get("options") or []
            labels = [str(opt.get("label")) for opt in options if opt.get("label")]
            if labels:
                self.console.print(
                    self._safe_text("Options: " + " | ".join(labels))
                )

    def _render_historical_ask_prompt(
        self,
        *,
        message_id: str,
        questions: list[dict[str, Any]],
    ) -> None:
        if self._plain_output:
            self.console.print(self._safe_text(f"Agent asked for input id={message_id}"))
            for question in questions:
                question_text = str(question.get("question") or "").strip()
                if question_text:
                    self.console.print(self._safe_text(question_text))
                labels = [
                    str(opt.get("label"))
                    for opt in (question.get("options") or [])
                    if opt.get("label")
                ]
                for index, label in enumerate(labels, 1):
                    self.console.print(self._safe_text(f"  {index}. {label}"))
            return

        lines: list[str] = []
        if message_id:
            lines.append(f"[dim]id={message_id}[/dim]")
        for question in questions:
            header = str(question.get("header") or "").strip()
            question_text = str(question.get("question") or "").strip()
            if question_text:
                if lines:
                    lines.append("")
                title = f"{header}: {question_text}" if header else question_text
                lines.append(f"[bold]{title}[/bold]")
            labels = [
                str(opt.get("label"))
                for opt in (question.get("options") or [])
                if opt.get("label")
            ]
            for index, label in enumerate(labels, 1):
                lines.append(f"  {index}. {label}")
        self.console.print(
            Panel("\n".join(lines), title="Agent asked for input", border_style="cyan")
        )

    def _render_ask_answers(self, *, message_id: str, answers: dict[str, Any]) -> None:
        normalized_answers = {
            str(question): str(answer)
            for question, answer in answers.items()
            if question is not None and answer is not None
        }
        if not normalized_answers:
            return

        fingerprint = json.dumps(
            {"message_id": message_id, "answers": normalized_answers},
            ensure_ascii=False,
            sort_keys=True,
        )
        dedupe_key = (message_id, fingerprint)
        if dedupe_key in self._rendered_ask_answers:
            return
        self._rendered_ask_answers.add(dedupe_key)

        if self._plain_output:
            title = (
                f"Selected answers id={message_id}"
                if message_id
                else "Selected answers"
            )
            self.console.print(self._safe_text(title))
            for question, answer in normalized_answers.items():
                self.console.print(self._safe_text(f"- {question}: {answer}"))
            return

        lines: list[str] = []
        if message_id:
            lines.append(f"[dim]id={message_id}[/dim]")
        for question, answer in normalized_answers.items():
            lines.append(f"[bold]{question}[/bold]")
            lines.append(f"  {answer}")
        self.console.print(
            Panel("\n".join(lines), title="Selected answers", border_style="green")
        )

    def _render_collection_plan(self, msg: dict[str, Any]) -> None:
        self._render_collection_plan_snapshot(
            self._normalize_collection_plan_snapshot(msg)
        )

    def _build_collection_plan_renderable(self, snapshot: dict[str, Any]) -> Panel:
        reasoning = str(snapshot.get("reasoning") or "").strip()
        steps = snapshot.get("steps") or []
        total_steps = snapshot.get("total_steps") or len(steps)
        current_step_index = snapshot.get("current_step_index") or "-"
        current_step_number = self._coerce_step_index(current_step_index)

        lines = [f"[bold cyan]Collection plan[/bold cyan] ({total_steps} step(s))"]
        if reasoning:
            lines.append(f"[dim]{reasoning}[/dim]")
        for step in steps:
            step_index = step.get("step_index", "?")
            description = str(step.get("description") or "").strip() or "(no description)"
            status = str(step.get("status") or "").strip()
            display_status = self._resolve_collection_plan_status(
                step_index=step_index,
                raw_status=status,
                current_step_number=current_step_number,
            )
            lines.append(
                f"{self._collection_plan_marker(display_status)} {step_index}. {description}"
            )

        return Panel("\n".join(lines), border_style="cyan")

    def _update_collection_plan_live(self, snapshot: dict[str, Any]) -> None:
        renderable = self._build_collection_plan_renderable(snapshot)
        if self._collection_plan_live is None:
            self._collection_plan_live = Live(
                renderable,
                console=self.console,
                auto_refresh=False,
                transient=True,
            )
            self._collection_plan_live.start()
            self._collection_plan_live.refresh()
            return

        self._collection_plan_live.update(renderable, refresh=True)

    def _stop_collection_plan_live(self) -> None:
        if self._collection_plan_live is None:
            return
        try:
            self._collection_plan_live.stop()
        finally:
            self._collection_plan_live = None

    def _normalize_collection_plan_snapshot(self, msg: dict[str, Any]) -> dict[str, Any]:
        plan = msg.get("plan")
        plan_dict = plan if isinstance(plan, dict) else {}
        reasoning = str(msg.get("reasoning") or plan_dict.get("reasoning") or "").strip()
        raw_steps = plan_dict.get("steps") or []
        steps: list[dict[str, Any]] = []
        for step in raw_steps:
            if not isinstance(step, dict):
                continue
            steps.append(
                {
                    "step_index": step.get("step_index", "?"),
                    "description": str(step.get("description") or "").strip(),
                    "status": str(step.get("status") or "").strip(),
                }
            )
        total_steps = msg.get("total_steps") or len(steps)
        current_step_index = (
            msg.get("current_step_index")
            or plan_dict.get("current_step_index")
            or "-"
        )
        return {
            "reasoning": reasoning,
            "steps": steps,
            "total_steps": total_steps,
            "current_step_index": current_step_index,
        }

    def _render_collection_plan_snapshot(self, snapshot: dict[str, Any]) -> None:
        reasoning = str(snapshot.get("reasoning") or "").strip()
        steps = snapshot.get("steps") or []
        total_steps = snapshot.get("total_steps") or len(steps)
        current_step_index = snapshot.get("current_step_index") or "-"

        if self._plain_output:
            self.console.print(
                self._safe_text(
                    f"Collection plan: {total_steps} step(s), current={current_step_index}"
                )
            )
            if reasoning:
                self.console.print(self._safe_text(f"Reason: {reasoning}"))
            for step in steps:
                step_index = step.get("step_index", "?")
                description = str(step.get("description") or "").strip()
                status = str(step.get("status") or "pending").strip()
                summary = description or "(no description)"
                self.console.print(
                    self._safe_text(f"{step_index}. {summary} ({status})")
                )
            return

        self.console.print(self._build_collection_plan_renderable(snapshot))

    def _coerce_step_index(self, value: Any) -> Optional[int]:
        try:
            return int(value)
        except (TypeError, ValueError):
            return None

    def _resolve_collection_plan_status(
        self,
        *,
        step_index: Any,
        raw_status: str,
        current_step_number: Optional[int],
    ) -> str:
        normalized = raw_status.strip().lower()
        if normalized in {"completed", "complete", "done", "success"}:
            return "completed"
        if normalized in {"in_progress", "running", "current", "active", "started"}:
            return "current"
        if normalized in {"pending", "todo", "queued", "planned", "waiting"}:
            return "pending"

        numeric_step = self._coerce_step_index(step_index)
        if current_step_number is not None and numeric_step is not None:
            if numeric_step < current_step_number:
                return "completed"
            if numeric_step == current_step_number:
                return "current"
        return "pending"

    def _collection_plan_marker(self, status: str) -> str:
        supports_unicode = self._console_supports_unicode()
        if status == "completed":
            return "[green]✓[/green]" if supports_unicode else "[green][x][/green]"
        if status == "current":
            return "[bold cyan]◉[/bold cyan]" if supports_unicode else "[bold cyan][>][/bold cyan]"
        return "[dim]○[/dim]" if supports_unicode else "[dim][ ][/dim]"

    def _handle_mcp_message(
        self, msg: dict[str, Any], *, historical: bool = False
    ) -> None:
        message_id = str(msg.get("message_id") or "")
        tool_name = str(msg.get("tool_name") or "unknown")
        status = msg.get("status")

        if self._plain_output and status not in ("failed", "error"):
            return

        if status == "called":
            if historical:
                return
            with self._thinking_lock:
                self._pending_mcp_tools[message_id] = {
                    "tool_name": tool_name,
                    "sweep_index": 0,
                }
            return

        if status in ("completed", "failed", "error"):
            with self._thinking_lock:
                self._pending_mcp_tools.pop(message_id, None)

            error = msg.get("error")
            result = msg.get("result")
            content = ""
            if error:
                content = str(error)
            elif result is not None:
                if isinstance(result, dict):
                    output = result.get("output", "")
                    content = str(output) if output else json.dumps(result, ensure_ascii=False)
                else:
                    content = str(result)
            self._render_mcp_completed_line(tool_name, status, content)

    def _handle_skill_message(
        self, msg: dict[str, Any], *, historical: bool = False
    ) -> None:
        message_id = str(msg.get("message_id") or "")
        skill_name = str(msg.get("skill") or "unknown")
        status = msg.get("status")

        if self._plain_output and status not in ("failed", "error"):
            return

        if status == "called":
            if historical:
                return
            with self._thinking_lock:
                self._pending_mcp_tools[message_id] = {
                    "tool_name": f"Skill({skill_name})",
                    "sweep_index": 0,
                }
            return

        if status in ("completed", "failed", "error"):
            with self._thinking_lock:
                self._pending_mcp_tools.pop(message_id, None)
            metadata = msg.get("metadata") or {}
            content = str(metadata.get("args") or "")
            error = msg.get("error")
            if error:
                content = str(error)
            self._render_mcp_completed_line(f"Skill({skill_name})", status, content)

    def _handle_subagent_message(
        self, msg: dict[str, Any], *, historical: bool = False
    ) -> None:
        message_id = str(msg.get("message_id") or "")
        subagent_type = str(msg.get("subagent_type") or "unknown")
        status = msg.get("status")

        if self._plain_output and status not in ("failed", "error"):
            return

        if status in ("called", "running"):
            if historical:
                return
            with self._thinking_lock:
                self._pending_mcp_tools[message_id] = {
                    "tool_name": f"Task({subagent_type})",
                    "sweep_index": 0,
                }
            return

        if status in ("completed", "failed", "error"):
            with self._thinking_lock:
                self._pending_mcp_tools.pop(message_id, None)

            error = msg.get("error")
            result = msg.get("result")
            description = str(msg.get("description") or "")
            content = ""
            if error:
                content = str(error)
            elif result is not None:
                content = str(result)
            elif description:
                content = description
            self._render_mcp_completed_line(f"Task({subagent_type})", status, content)

    def _render_mcp_completed_line(self, tool_name: str, status: str, content: str) -> None:
        if status in ("failed", "error"):
            self.console.print(
                f"[red]{self._glyph('✗', 'ERR')} {tool_name}[/red]"
            )
        else:
            self.console.print(
                f"[blue]{self._glyph('🔧', 'TOOL')} {tool_name}[/blue]"
            )
        if content.strip():
            self.console.print(
                f"[dim]   {self._safe_text(self._single_row_thinking_text(content))}[/dim]"
            )

    def _render_tool_event(
        self, msg: dict[str, Any], *, historical: bool = False
    ) -> None:
        msg_type = str(msg.get("type"))
        tool_name = str(msg.get("tool_name") or msg.get("tool") or msg.get("name") or "unknown_tool")
        step_index = msg.get("step_index")
        child_index = msg.get("child_index")
        tool_key = f"tool_{step_index}_{child_index}" if step_index is not None and child_index is not None else f"tool_{tool_name}"

        if msg_type == "tool_execution":
            if historical:
                return
            with self._thinking_lock:
                self._pending_mcp_tools[tool_key] = {
                    "tool_name": tool_name,
                    "sweep_index": 0,
                }
            return

        if msg_type == "tool_progress":
            return

        if msg_type == "tool_completed":
            with self._thinking_lock:
                self._pending_mcp_tools.pop(tool_key, None)
            result = msg.get("result")
            content = ""
            if isinstance(result, dict):
                output = result.get("output") or result.get("message") or result.get("summary") or ""
                content = str(output) if output else json.dumps(result, ensure_ascii=False)
            elif result is not None:
                content = str(result)
            self._render_mcp_completed_line(tool_name, "completed", content)

    def _format_thinking_status_text(self) -> Text:
        output = Text()
        if self._agent_thinking_active:
            message = self._thinking_plain_text or "thinking..."
            line = Text(
                f"{self._glyph('✨', 'THINK')} {message}",
                style="bold magenta",
            )
            line_length = len(line.plain)
            if line_length > 2:
                sweep_start = min(2 + self._thinking_sweep_index, line_length)
                sweep_end = min(sweep_start + self._thinking_sweep_width, line_length)
                if sweep_start < sweep_end:
                    line.stylize("bold white", sweep_start, sweep_end)
            output.append(line)
            output.append("\n")
        if self._thinking_block_open and self._thinking_line_buf:
            viewport = max(20, self._thinking_panel_width() - 4)
            single_line = " ".join(self._thinking_line_buf.split())
            if len(single_line) > viewport:
                single_line = f"…{single_line[-(viewport - 1):]}"
            output.append(Text(
                f"{self._glyph('🧠', 'THINK')} {single_line}",
                style="dim",
            ))
            output.append("\n")
        for tool_info in list(self._pending_mcp_tools.values()):
            tool_name = tool_info["tool_name"]
            sweep_idx = tool_info["sweep_index"]
            display_text = f"{self._glyph('🔧', 'TOOL')} {tool_name}"
            line = Text(display_text, style="bold blue")
            line_length = len(line.plain)
            if line_length > 2:
                sweep_start = min(2 + sweep_idx, line_length)
                sweep_end = min(sweep_start + self._thinking_sweep_width, line_length)
                if sweep_start < sweep_end:
                    line.stylize("bold white", sweep_start, sweep_end)
            output.append(line)
            output.append("\n")
        output.append(
            Text(
                self._glyph("────────────────────────────", "----------------------------"),
                style="dim",
            )
        )
        output.append("\n")
        output.append(
            Text(
                f"{self._glyph('⏳', 'WAIT')} {self._loading_message}",
                style="dim cyan",
            )
        )
        return output

    def _start_thinking_animation_loop(self) -> None:
        stop_event = threading.Event()
        self._thinking_stop_event = stop_event

        def _animate_loop() -> None:
            while not stop_event.wait(0.12):
                with self._thinking_lock:
                    if self._thinking_status is None:
                        break
                    now = time.monotonic()
                    if now >= self._loading_next_switch_at:
                        self._loading_message = random.choice(self._loading_messages)
                        self._loading_next_switch_at = now + 1.8

                    if self._agent_thinking_active:
                        text_length = len(self._thinking_plain_text or "thinking...")
                        if text_length > 0:
                            self._thinking_sweep_index = (self._thinking_sweep_index + 1) % text_length
                    for tool_info in self._pending_mcp_tools.values():
                        text_length = max(1, len(tool_info["tool_name"]) + 4)  # "🔧 " prefix
                        tool_info["sweep_index"] = (tool_info["sweep_index"] + 1) % text_length
                    status_text = self._format_thinking_status_text()
                    self._thinking_status.update(status_text)

        self._thinking_animation_thread = threading.Thread(
            target=_animate_loop,
            name="agent-thinking-sweep",
            daemon=True,
        )
        self._thinking_animation_thread.start()

    def _stop_thinking_status(self) -> None:
        if self._thinking_stop_event is not None:
            self._thinking_stop_event.set()
            self._thinking_stop_event = None
        if self._thinking_status is not None:
            self._thinking_status.stop()
            self._thinking_status = None
        self._agent_thinking_active = False
        self._static_thinking_notice_visible = False
        self._thinking_animation_thread = None

    def _ensure_thinking_status(self) -> None:
        if self._plain_output:
            return
        if not self._live_status_enabled:
            return
        if getattr(self.console, "no_color", False) or not self._console_supports_unicode():
            return
        if self._thinking_status is not None:
            return
        self._loading_next_switch_at = time.monotonic() + 1.8
        status = self.console.status(self._format_thinking_status_text(), spinner="dots")
        status.start()
        self._thinking_status = status
        self._start_thinking_animation_loop()

