"""Base class for ACP (Agent Client Protocol) integrations.

This provides a generic framework for integrating any ACP-compatible agent
with Vicoa using the JSON-RPC protocol over stdin/stdout.

Use this for agents that:
- Support ACP protocol (https://agentclientprotocol.com/)
- Don't have native plugin systems
- Need external wrapper for Vicoa integration

For agents with plugin systems (like OpenCode), prefer native plugins instead.
"""

import logging
import difflib
import json
import os
import re
import sys
import time
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, Optional

from integrations.headless.acp_client import ACPClient, ACPError
from vicoa.sdk.client import VicoaClient


logger = logging.getLogger(__name__)
FILE_DUMP_BLOCK_PATTERN = re.compile(
    r"<path>.*?</path>\s*<type>\s*file\s*</type>\s*<content>.*?</content>",
    re.IGNORECASE | re.DOTALL,
)
CONTROL_JSON_PATTERN = re.compile(r'\{[^}]*"type"\s*:\s*"control"[^}]*\}')


class ACPWrapperConfig(ABC):
    """Base configuration for ACP wrappers.

    Subclasses should define agent-specific settings.
    """

    # Vicoa integration (required)
    api_key: str
    base_url: str
    agent_instance_id: str

    # Agent settings (required)
    project_path: str
    agent_type: str  # e.g., "opencode", "cursor", "windsurf"
    agent_command: str  # e.g., "opencode", "cursor"

    # Optional
    name: str
    is_resuming: bool

    @abstractmethod
    def get_acp_command(self) -> list[str]:
        """Return the command to start ACP mode.

        Example: ["opencode", "acp"]
        """
        pass

    @abstractmethod
    def get_acp_env(self) -> dict[str, str]:
        """Return environment variables for ACP process.

        Example: {"ANTHROPIC_API_KEY": "sk-ant-..."}
        """
        pass


class ACPWrapperBase(ABC):
    """Base class for ACP agent wrappers.

    This provides common functionality for integrating ACP-compatible agents
    with Vicoa, including:
    - ACP client lifecycle management
    - Vicoa backend communication
    - Event forwarding
    - Message queue handling
    - Session management

    Subclasses should implement agent-specific behavior:
    - create_session(): How to create a coding session
    - handle_notification(): Agent-specific event handling
    - send_prompt(): How to send user messages
    """

    def __init__(self, config: ACPWrapperConfig):
        """Initialize ACP wrapper.

        Args:
            config: Wrapper configuration
        """
        self.config = config
        self.running = True

        # Setup logging
        self.debug_log_file: Optional[Any] = None
        self._init_logging()

        # Initialize Vicoa client
        self.vicoa_client: Optional[VicoaClient] = None
        self._init_vicoa_client()

        # ACP client (will be started in run())
        self.acp: Optional[ACPClient] = None

        # Session state
        self.session_id: Optional[str] = None
        self.last_message_id: Optional[str] = None
        self._awaiting_input_requested_for_message_id: Optional[str] = None
        self._awaiting_after_next_agent_output: bool = False
        self._suspend_vicoa_polling: bool = False

        # Message queue for user input from Vicoa
        self.message_queue: list[str] = []
        self._last_poll_skip_reason: Optional[str] = None

        # Buffer streamed ACP chunks to avoid fragmented UI messages.
        self._assistant_chunk_buffer: str = ""
        self._assistant_chunk_first_update_at: float = 0.0
        self._assistant_chunk_last_update_at: float = 0.0
        self._assistant_chunk_flush_after_seconds: float = 1.0
        self._assistant_chunk_force_flush_after_seconds: float = 4.0
        # By default, hide tool intermediate output and only show final assistant responses.
        self._show_tool_updates: bool = str(
            os.environ.get("VICOA_ACP_SHOW_TOOL_UPDATES", "")
        ).strip().lower() in {"1", "true", "yes", "on"}
        self._last_tool_change_signature: Optional[str] = None
        self._tool_output_max_lines: int = int(
            os.environ.get("VICOA_ACP_TOOL_OUTPUT_MAX_LINES", "80")
        )
        self._tool_output_max_chars: int = int(
            os.environ.get("VICOA_ACP_TOOL_OUTPUT_MAX_CHARS", "4000")
        )
        self._tool_output_preview_lines: int = int(
            os.environ.get("VICOA_ACP_TOOL_OUTPUT_PREVIEW_LINES", "24")
        )
        self._tool_output_preview_chars: int = int(
            os.environ.get("VICOA_ACP_TOOL_OUTPUT_PREVIEW_CHARS", "1400")
        )

    def run(self) -> int:
        """Main entry point - run the wrapper.

        Returns:
            Exit code (0 for success, 1 for error)
        """
        try:
            self._setup()
            self._run_event_loop()
            return 0
        except KeyboardInterrupt:
            self.log("[INFO] Interrupted by user")
            return 0
        except Exception as e:
            self.log(f"[ERROR] Fatal error: {e}")
            import traceback

            self.log(traceback.format_exc())
            return 1
        finally:
            self._cleanup()

    def _setup(self) -> None:
        """Setup before main loop."""
        self.log(f"[INFO] Setting up {self.config.agent_type} wrapper...")

        # Register with Vicoa if starting new session
        if not self.config.is_resuming and self.vicoa_client:
            try:
                from vicoa.utils import get_project_path

                registration = self.vicoa_client.register_agent_instance(
                    agent_type=self.config.agent_type,
                    transport="local",
                    agent_instance_id=self.config.agent_instance_id,
                    name=self.config.name,
                    project=get_project_path(),
                    home_dir=str(Path.home()),
                )

                self.config.agent_instance_id = registration.agent_instance_id
                self.log(
                    f"[INFO] Registered agent instance: {self.config.agent_instance_id}"
                )

                # Send initial session message
                response = self.vicoa_client.send_message(
                    content=f"{self.config.agent_type} session started, waiting for your input...",
                    agent_type=self.config.agent_type,
                    agent_instance_id=self.config.agent_instance_id,
                    requires_user_input=False,
                )

                if response and hasattr(response, "message_id"):
                    self.last_message_id = response.message_id

            except Exception as e:
                self.log(f"[ERROR] Failed to register with Vicoa: {e}")

        # Start ACP client
        self._start_acp_client()

        # Initialize ACP session
        self._initialize_acp_session()

        # Create agent session (agent-specific)
        self.create_session()

    def _start_acp_client(self) -> None:
        """Start ACP client and connect to agent."""
        command = self.config.get_acp_command()
        env = self.config.get_acp_env()

        self.log(f"[ACP] Starting agent: {' '.join(command)}")
        self.log(f"[ACP] Project path: {self.config.project_path}")

        self.acp = ACPClient(
            command=command,
            cwd=self.config.project_path,
            env=env,
            on_notification=self._handle_acp_notification,
            on_request=self._handle_acp_request,
            on_error=self._handle_acp_error,
            log_func=self.log,
        )

        self.acp.start()
        self.log("[ACP] Agent process started")

    def _initialize_acp_session(self) -> None:
        """Initialize ACP protocol session."""
        acp = self.acp
        if not acp:
            raise ACPError("ACP client not started")

        self.log("[ACP] Initializing protocol session")

        init_payloads = [
            {
                # Newer OpenCode ACP expects numeric protocol version.
                "protocolVersion": 1,
                "capabilities": {"supports": ["streaming", "tools", "permissions"]},
                "clientInfo": {"name": "vicoa", "version": "1.0.0"},
            },
            {
                # Backward-compatible fallback for agents that accept date-style versions.
                "protocolVersion": "2024-11-01",
                "capabilities": {"supports": ["streaming", "tools", "permissions"]},
                "clientInfo": {"name": "vicoa", "version": "1.0.0"},
            },
        ]

        last_error: ACPError | None = None
        for payload in init_payloads:
            try:
                response = acp.send_request("initialize", payload)
                response.raise_for_error()
                self.log("[ACP] Protocol initialized successfully")
                return
            except ACPError as e:
                last_error = e
                self.log(
                    f"[WARNING] ACP initialize attempt failed (protocolVersion={payload['protocolVersion']}): {e}"
                )

        self.log(f"[ERROR] Failed to initialize ACP: {last_error}")
        raise last_error if last_error else ACPError("Failed to initialize ACP")

    @abstractmethod
    def create_session(self) -> None:
        """Create agent coding session.

        Agent-specific implementation. Should:
        1. Call appropriate ACP methods to create session
        2. Store session_id
        3. Handle any agent-specific setup
        """
        pass

    def _run_event_loop(self) -> None:
        """Main event loop - poll for user messages and process."""
        self.log("[INFO] Starting event loop")

        while self.running:
            try:
                # Check for pending messages from Vicoa
                if (
                    self.vicoa_client
                    and self.last_message_id
                    and not self._suspend_vicoa_polling
                ):
                    self._last_poll_skip_reason = None
                    self._poll_vicoa_messages()
                else:
                    reason: str | None = None
                    if not self.vicoa_client:
                        reason = "no_vicoa_client"
                    elif not self.last_message_id:
                        reason = "no_last_message_id"
                    elif self._suspend_vicoa_polling:
                        reason = "polling_suspended"

                    if reason and reason != self._last_poll_skip_reason:
                        self.log(f"[Vicoa] Skipping pending-message poll: {reason}")
                        self._last_poll_skip_reason = reason

                # Process any queued messages
                if self.message_queue:
                    message = self.message_queue.pop(0)
                    self._set_agent_status("ACTIVE")
                    self.send_prompt(message)

                # Flush streamed chunks when stream has gone quiet.
                if (
                    self._assistant_chunk_buffer
                    and self._assistant_chunk_last_update_at > 0
                    and (
                        time.time() - self._assistant_chunk_last_update_at
                        >= self._assistant_chunk_flush_after_seconds
                    )
                    and self._should_flush_assistant_chunk_buffer()
                ):
                    self._flush_assistant_chunk_buffer()

                # Small sleep to avoid busy loop
                time.sleep(0.1)

            except Exception as e:
                self.log(f"[ERROR] Error in event loop: {e}")
                import traceback

                self.log(traceback.format_exc())

    def _poll_vicoa_messages(self) -> None:
        """Poll Vicoa backend for pending user messages."""
        if not self.vicoa_client or not self.config.agent_instance_id:
            return

        try:
            pending = self.vicoa_client.get_pending_messages(
                agent_instance_id=self.config.agent_instance_id,
                # Use backend-managed cursor to avoid local cursor drift between
                # agent/user message IDs and stale read-pointer mismatches.
                last_read_message_id=None,
            )
            pending_status = str(getattr(pending, "status", "unknown")).lower()
            if pending_status not in {"ok", "no_messages"}:
                self.log(f"[Vicoa] Pending-messages poll status: {pending_status}")

            if pending_status == "stale":
                self.log(
                    "[Vicoa] last_read_message_id is stale; retrying poll with reset cursor"
                )
                pending = self.vicoa_client.get_pending_messages(
                    agent_instance_id=self.config.agent_instance_id,
                    last_read_message_id=None,
                )
                pending_status = str(getattr(pending, "status", "unknown")).lower()
                self.log(
                    f"[Vicoa] Pending-messages retry status after cursor reset: {pending_status}"
                )

            messages = self._coerce_messages_list(pending)
            if messages:
                self.log(f"[Vicoa] Received {len(messages)} pending messages")
                for msg in messages:
                    if isinstance(msg, dict):
                        content = msg.get("content", "")
                        sender_type = str(msg.get("sender_type", "")).lower()
                    else:
                        content = getattr(msg, "content", "")
                        sender_type = str(getattr(msg, "sender_type", "")).lower()
                    if sender_type and sender_type not in {"user", "human"}:
                        continue
                    if content:
                        self._ingest_user_message(content)

        except Exception as e:
            self.log(f"[WARNING] Failed to poll Vicoa messages: {e}")

    def _coerce_messages_list(self, pending: Any) -> list[Any]:
        """Normalize SDK/legacy pending message payloads into a concrete list."""
        raw_messages: Any = getattr(pending, "messages", pending)
        if raw_messages is None:
            return []
        if isinstance(raw_messages, list):
            return raw_messages
        try:
            return list(raw_messages)
        except TypeError:
            return []

    def _ingest_user_message(self, content: str) -> None:
        """Route user message to priority handler or normal queue."""
        normalized = str(content or "").strip()
        if not normalized:
            return
        if self.handle_priority_message(normalized):
            return
        self.message_queue.append(normalized)

    def handle_priority_message(self, content: str) -> bool:
        """Handle high-priority user messages immediately.

        Returns True if message was consumed and should not be queued.
        Subclasses can override for control commands like interrupt or mode switch.
        """
        return self._handle_control_command(content)

    def _parse_control_command(self, content: str) -> Dict[str, str] | None:
        """Parse JSON control command from text (can be embedded).

        Supported formats:
        - {"type": "control", "setting": "X", "value": "Y"}
        - {"type": "control", "action": "interrupt"}
        """
        if not content:
            return None

        match = CONTROL_JSON_PATTERN.search(content)
        if not match:
            return None

        try:
            parsed = json.loads(match.group(0))
        except json.JSONDecodeError:
            return None

        if not isinstance(parsed, dict) or parsed.get("type") != "control":
            return None

        setting = parsed.get("setting") or parsed.get("action")
        if not setting:
            return None

        result: Dict[str, str] = {"setting": str(setting)}
        value = parsed.get("value")
        if value is not None:
            result["value"] = str(value)
        return result

    def _handle_control_command(self, content: str) -> bool:
        """Handle JSON control message and return whether it was consumed."""
        control = self._parse_control_command(content)
        if not control:
            return False

        self._apply_control_command(control)
        self._set_awaiting_input_state()
        return True

    def _apply_control_command(self, control: Dict[str, str]) -> None:
        """Apply parsed control command."""
        setting = control.get("setting", "")
        value = control.get("value")
        self.log(
            f"[Vicoa] Control command received: {setting}"
            + (f"={value}" if value is not None else "")
        )

        if setting == "interrupt":
            self._handle_interrupt_control()
            return

        self._send_feedback_message(f"Unknown control setting '{setting}'.")

    def _handle_interrupt_control(self) -> None:
        """Best-effort generic interrupt for ACP sessions."""
        acp = self.acp
        if not acp or not self.session_id:
            self._send_feedback_message("Failed to interrupt: session not ready.")
            return

        try:
            acp.send_notification("session/cancel", {"sessionId": self.session_id})
        except Exception as e:
            self.log(f"[WARNING] Failed to send session/cancel notification: {e}")

        try:
            interrupted = acp.interrupt_process()
            if interrupted:
                self.log("[ACP] Used SIGINT fallback for interrupt")
        except Exception as e:
            self.log(f"[WARNING] SIGINT fallback failed: {e}")

        self._send_feedback_message("Interrupted.")

    def _send_feedback_message(self, content: str) -> None:
        """Send a non-blocking status message to UI."""
        if not self.vicoa_client:
            return
        try:
            response = self.vicoa_client.send_message(
                content=content,
                agent_type=self.config.agent_type,
                agent_instance_id=self.config.agent_instance_id,
                requires_user_input=False,
            )
            if response and hasattr(response, "message_id"):
                self.last_message_id = response.message_id
        except Exception as e:
            self.log(f"[WARNING] Failed to send feedback message: {e}")

    @abstractmethod
    def send_prompt(self, message: str) -> None:
        """Send user message to agent.

        Agent-specific implementation. Should:
        1. Format message for agent
        2. Send via ACP
        3. Handle response

        Args:
            message: User message text
        """
        pass

    def _handle_acp_notification(self, method: str, params: Dict[str, Any]) -> None:
        """Handle ACP notifications from agent.

        Args:
            method: Notification method name
            params: Notification parameters
        """
        if method not in {"session/update", "session/message"}:
            self.log(f"[ACP Notification] {method}")

        # Common notifications
        if method == "session/message":
            self._handle_session_message(params)
        elif method == "session/update":
            self._handle_session_update(params)
        elif method == "session/error":
            self._handle_session_error(params)
        elif method == "session/idle":
            self._handle_session_idle(params)
        else:
            # Delegate to agent-specific handler
            self.handle_notification(method, params)

    def _handle_acp_request(
        self, method: str, params: Dict[str, Any]
    ) -> Dict[str, Any]:
        """Handle ACP requests from agent that require JSON-RPC responses."""
        self.log(f"[ACP Request] {method}")
        return self.handle_request(method, params)

    @abstractmethod
    def handle_notification(self, method: str, params: Dict[str, Any]) -> None:
        """Handle agent-specific ACP notifications.

        Called for notifications not handled by base class.

        Args:
            method: Notification method name
            params: Notification parameters
        """
        pass

    def handle_request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
        """Handle agent-originated ACP request; default to notification behavior."""
        self.handle_notification(method, params)
        return {}

    def _handle_session_message(self, params: Dict[str, Any]) -> None:
        """Handle session message from agent (agent output)."""
        message = params.get("message", {})

        text_parts = self._extract_text_fragments_from_content_payload(
            message.get("content", [])
        )
        # Preserve contiguous text fragments; some ACP providers split a single
        # token/word across adjacent parts (e.g. "1" + "px"). Joining with a
        # newline introduces artificial line breaks in user-visible responses.
        full_text = "".join(part for part in text_parts if part)

        if full_text:
            self._append_assistant_chunk_block(full_text)

    def _handle_session_update(self, params: Dict[str, Any]) -> None:
        """Handle ACP session/update notifications.

        OpenCode primarily streams assistant output through this notification.
        """
        update = params.get("update") or {}
        update_type = update.get("sessionUpdate")

        if update_type == "agent_thought_chunk":
            # Hide internal model reasoning from user-facing UI.
            return

        if update_type == "agent_message_chunk":
            content = update.get("content")
            for text in self._extract_text_fragments_from_content_payload(content):
                if text:
                    self._append_assistant_chunk(text)
            return

        # Shell and tool executions are often surfaced as completed tool updates.
        if update_type == "tool_call_update":
            if not self._show_tool_updates:
                if update.get("status") not in {"completed", "failed"}:
                    return

                rendered_parts: list[str] = []
                change_preview = self._extract_tool_change_preview(update)
                if change_preview:
                    file_target = self._extract_tool_target_file(update)
                    rendered_parts.append(
                        f"Updated `{file_target}`.\n\n{change_preview}"
                        if file_target
                        else change_preview
                    )

                tool_text = self._extract_tool_update_text(update)
                compact_tool_text = self._compact_tool_output(tool_text)
                if compact_tool_text:
                    if (
                        not change_preview
                        or compact_tool_text.strip() != change_preview.strip()
                    ):
                        rendered_parts.append(compact_tool_text)

                if not rendered_parts:
                    return

                rendered = "\n\n".join(part for part in rendered_parts if part.strip())
                signature = rendered.strip()
                if signature and signature != self._last_tool_change_signature:
                    self._append_assistant_chunk_block(rendered)
                    self._last_tool_change_signature = signature
                return

            if update.get("status") not in {"completed", "failed"}:
                return

            extracted_chunks: list[str] = []
            extracted_chunks.extend(
                self._extract_text_fragments_from_content_payload(update.get("content"))
            )

            if not extracted_chunks:
                raw_output = update.get("rawOutput")
                if isinstance(raw_output, dict):
                    fallback_text = self._coerce_tool_output_to_text(
                        raw_output.get("output")
                    ) or self._coerce_tool_output_to_text(raw_output.get("error"))
                    if fallback_text:
                        extracted_chunks.append(fallback_text)

            change_preview = self._extract_tool_change_preview(update)
            if change_preview and not any(
                change_preview.strip() in chunk for chunk in extracted_chunks
            ):
                extracted_chunks.append(change_preview)

            for chunk in extracted_chunks:
                if chunk:
                    self._append_assistant_chunk_block(chunk)

            return

    def _extract_tool_update_text(self, update: Dict[str, Any]) -> str:
        """Extract raw text payloads from tool_call_update structures."""
        if not isinstance(update, dict):
            return ""

        extracted_chunks = self._extract_text_fragments_from_content_payload(
            update.get("content")
        )

        if not extracted_chunks:
            raw_output = update.get("rawOutput")
            if isinstance(raw_output, dict):
                fallback_text = self._coerce_tool_output_to_text(
                    raw_output.get("output")
                ) or self._coerce_tool_output_to_text(raw_output.get("error"))
                if fallback_text:
                    extracted_chunks.append(fallback_text)

        return "\n\n".join(chunk for chunk in extracted_chunks if chunk)

    def _extract_text_fragments_from_content_payload(self, payload: Any) -> list[str]:
        """Normalize ACP content payloads into textual fragments."""
        fragments: list[str] = []
        if payload is None:
            return fragments

        if isinstance(payload, str):
            text = payload.strip("\n")
            return [text] if text else []

        if isinstance(payload, dict):
            # Filter structured diff payloads (they are too noisy for chat output).
            if str(payload.get("type", "")).strip().lower() == "diff":
                return []

            # Standard ACP content object.
            text = self._extract_text_from_acp_content(payload)
            if text:
                fragments.append(text)

            # Common wrappers: {"type":"content","content":{...}}
            nested_content = payload.get("content")
            if nested_content is not None:
                fragments.extend(
                    self._extract_text_fragments_from_content_payload(nested_content)
                )

            # Common list wrappers.
            for key in ("contents", "items", "parts"):
                if key in payload and isinstance(payload.get(key), list):
                    fragments.extend(
                        self._extract_text_fragments_from_content_payload(
                            payload.get(key)
                        )
                    )

            if not fragments:
                fallback = self._coerce_tool_output_to_text(payload).strip()
                if fallback:
                    fragments.append(fallback)
            return fragments

        if isinstance(payload, list):
            for item in payload:
                fragments.extend(
                    self._extract_text_fragments_from_content_payload(item)
                )
            return fragments

        fallback = self._coerce_tool_output_to_text(payload).strip()
        return [fallback] if fallback else []

    def _extract_text_from_acp_content(self, content: Dict[str, Any]) -> str:
        """Extract text payload from ACP content blocks."""
        if not isinstance(content, dict):
            return ""

        content_type = content.get("type")
        if content_type == "text":
            text = content.get("text")
            return text if isinstance(text, str) else ""

        # Some ACP content wrappers carry textual resource payloads.
        if content_type == "resource":
            resource = content.get("resource") or {}
            text = self._coerce_tool_output_to_text(resource.get("text"))
            return text if isinstance(text, str) else ""

        return ""

    def _coerce_tool_output_to_text(self, value: Any) -> str:
        """Best-effort conversion of ACP tool outputs into displayable text."""
        if value is None:
            return ""
        if isinstance(value, str):
            return value.strip("\n")
        if isinstance(value, (int, float, bool)):
            return str(value)
        if isinstance(value, list):
            parts = [self._coerce_tool_output_to_text(item) for item in value]
            return "\n".join(part for part in parts if part)
        if isinstance(value, dict):
            if str(value.get("type", "")).strip().lower() == "diff":
                return ""
            preferred_keys = (
                "stdout",
                "stderr",
                "diff",
                "patch",
                "unifiedDiff",
                "text",
                "output",
                "message",
                "error",
            )
            parts: list[str] = []
            for key in preferred_keys:
                if key in value:
                    text = self._coerce_tool_output_to_text(value.get(key))
                    if text:
                        parts.append(text)
            if parts:
                return "\n".join(parts)
            try:
                import json

                return json.dumps(value, ensure_ascii=False)
            except Exception:
                return str(value)
        return str(value)

    def _forward_agent_text(self, text: str) -> None:
        """Send assistant text output to Vicoa."""
        if not text or not self.vicoa_client:
            return
        text = self._sanitize_agent_text(text)
        if not text:
            return

        try:
            response = self.vicoa_client.send_message(
                content=text,
                agent_type=self.config.agent_type,
                agent_instance_id=self.config.agent_instance_id,
                requires_user_input=False,
            )

            if response and hasattr(response, "message_id"):
                new_message_id = response.message_id
                if new_message_id != self.last_message_id:
                    self._awaiting_input_requested_for_message_id = None
                self.last_message_id = new_message_id

            if self._awaiting_after_next_agent_output:
                self._set_awaiting_input_state()

        except Exception as e:
            self.log(f"[ERROR] Failed to send message to Vicoa: {e}")

    def _set_agent_status(self, status: str) -> None:
        """Best-effort status update for current agent instance."""
        if not self.vicoa_client or not self.config.agent_instance_id:
            return

        try:
            self.vicoa_client.update_agent_instance_status(
                self.config.agent_instance_id, status
            )
            self.log(f"[Vicoa] Agent status set to {status}")
        except Exception as e:
            self.log(f"[WARNING] Failed to update agent status to {status}: {e}")

    def _set_awaiting_input_state(self) -> None:
        """Mark session as awaiting input and trigger user-input request."""
        self._awaiting_after_next_agent_output = False
        self._set_agent_status("AWAITING_INPUT")

        if self.vicoa_client and self.last_message_id:
            if self._awaiting_input_requested_for_message_id == self.last_message_id:
                return
            try:
                queued_messages = self.vicoa_client.mark_message_requires_input(
                    self.last_message_id
                )
                self._awaiting_input_requested_for_message_id = self.last_message_id
                for content in queued_messages:
                    if content:
                        self._ingest_user_message(content)
            except Exception as e:
                self.log(f"[ERROR] Failed to request user input: {e}")

    def _flush_assistant_chunk_buffer(self) -> None:
        """Flush buffered assistant chunks as one user-visible message."""
        if not self._assistant_chunk_buffer:
            return

        buffered_text = self._sanitize_agent_text(self._assistant_chunk_buffer)
        self._assistant_chunk_buffer = ""
        self._assistant_chunk_first_update_at = 0.0
        self._assistant_chunk_last_update_at = 0.0
        if buffered_text:
            self._forward_agent_text(buffered_text)

    def _append_assistant_chunk(self, text: str) -> None:
        """Append assistant stream text preserving source chunk boundaries."""
        if not text:
            return
        if not self._should_buffer_assistant_chunk(text):
            return
        if not self._assistant_chunk_buffer:
            self._assistant_chunk_first_update_at = time.time()
        self._assistant_chunk_buffer += text
        self._assistant_chunk_last_update_at = time.time()

    def _append_assistant_chunk_block(self, text: str) -> None:
        """Append a logical message block with spacing when needed."""
        if not text:
            return
        if not self._should_buffer_assistant_chunk(text):
            return
        if not self._assistant_chunk_buffer:
            self._assistant_chunk_first_update_at = time.time()
        if self._assistant_chunk_buffer and not self._assistant_chunk_buffer.endswith(
            "\n\n"
        ):
            if self._assistant_chunk_buffer.endswith("\n"):
                self._assistant_chunk_buffer += "\n"
            else:
                self._assistant_chunk_buffer += "\n\n"
        self._assistant_chunk_buffer += text
        self._assistant_chunk_last_update_at = time.time()

    def _should_flush_assistant_chunk_buffer(self) -> bool:
        """Avoid flushing while a markdown fenced code block is still open."""
        if not self._assistant_chunk_buffer:
            return False

        # If fence count is balanced, flush immediately.
        fence_count = self._assistant_chunk_buffer.count("```")
        if fence_count % 2 == 0:
            return True

        # Otherwise wait for more chunks, but force-flush after a safety window.
        if self._assistant_chunk_first_update_at <= 0:
            return False
        return (
            time.time() - self._assistant_chunk_first_update_at
            >= self._assistant_chunk_force_flush_after_seconds
        )

    def _should_buffer_assistant_chunk(self, text: str) -> bool:
        """Hook for wrappers to suppress streamed output for specific states."""
        return True

    def _sanitize_agent_text(self, text: str) -> str:
        """Remove verbose raw file dumps from assistant-visible output."""
        if not text:
            return ""
        sanitized = FILE_DUMP_BLOCK_PATTERN.sub("", text)
        sanitized = re.sub(r"\n{3,}", "\n\n", sanitized).strip()
        return sanitized

    def _compact_tool_output(self, text: str) -> str:
        """Keep concise tool output and truncate oversized outputs."""
        sanitized = self._sanitize_agent_text(text)
        if not sanitized:
            return ""

        lines = sanitized.splitlines()
        if (
            len(lines) <= self._tool_output_max_lines
            and len(sanitized) <= self._tool_output_max_chars
        ):
            return sanitized

        preview_lines = lines[: self._tool_output_preview_lines]
        preview = "\n".join(preview_lines)
        if len(preview) > self._tool_output_preview_chars:
            preview = preview[: self._tool_output_preview_chars].rstrip()

        remaining_lines = max(0, len(lines) - len(preview_lines))
        if remaining_lines > 0:
            preview += f"\n\n(Output truncated: {remaining_lines} more lines)"
        else:
            preview += "\n\n(Output truncated)"
        return preview

    def _extract_tool_change_preview(self, update: Dict[str, Any]) -> str:
        """Best-effort extraction of edit patch/diff previews from tool updates."""
        if not isinstance(update, dict):
            return ""

        diff_texts: list[str] = []
        seen: set[str] = set()
        for node in self._iter_nested_dicts(update):
            for key in ("diff", "patch", "unifiedDiff"):
                value = node.get(key)
                if isinstance(value, str):
                    normalized = value.strip()
                    if normalized and normalized not in seen:
                        seen.add(normalized)
                        diff_texts.append(normalized)
                elif isinstance(value, list):
                    text_value = self._coerce_tool_output_to_text(value).strip()
                    if text_value and text_value not in seen:
                        seen.add(text_value)
                        diff_texts.append(text_value)

        if diff_texts:
            rendered: list[str] = []
            for value in diff_texts[:2]:
                if "```" in value:
                    rendered.append(value)
                else:
                    rendered.append(f"```diff\n{value}\n```")
            return "\n\n".join(rendered)

        edit_blocks: list[str] = []
        seen_blocks: set[str] = set()
        for node in self._iter_nested_dicts(update):
            old_value = self._first_non_none(
                node, ("old_string", "oldText", "old", "before")
            )
            new_value = self._first_non_none(
                node, ("new_string", "newText", "new", "after")
            )
            if old_value is None and new_value is None:
                continue

            old_text = self._coerce_tool_output_to_text(old_value)
            new_text = self._coerce_tool_output_to_text(new_value)
            if not old_text and not new_text:
                continue

            diff_block = self._build_diff_block(old_text, new_text)
            if diff_block and diff_block not in seen_blocks:
                seen_blocks.add(diff_block)
                edit_blocks.append(diff_block)

        return "\n\n".join(edit_blocks[:2])

    def _extract_tool_target_file(self, update: Dict[str, Any]) -> str:
        """Best-effort extraction of edited file path from tool payload."""
        if not isinstance(update, dict):
            return ""

        for node in self._iter_nested_dicts(update):
            for key in ("file_path", "filePath", "filepath", "path", "targetPath"):
                value = node.get(key)
                if isinstance(value, str):
                    cleaned = value.strip()
                    if cleaned and len(cleaned) < 2048 and "/" in cleaned:
                        return cleaned
        return ""

    def _iter_nested_dicts(self, value: Any) -> list[Dict[str, Any]]:
        """Collect nested dict nodes from mixed ACP payload structures."""
        nodes: list[Dict[str, Any]] = []
        stack: list[Any] = [value]
        while stack:
            current = stack.pop()
            if isinstance(current, dict):
                nodes.append(current)
                for item in current.values():
                    if isinstance(item, (dict, list)):
                        stack.append(item)
            elif isinstance(current, list):
                for item in current:
                    if isinstance(item, (dict, list)):
                        stack.append(item)
        return nodes

    def _first_non_none(self, source: Dict[str, Any], keys: tuple[str, ...]) -> Any:
        """Return first non-None value for known key aliases."""
        for key in keys:
            if key in source and source.get(key) is not None:
                return source.get(key)
        return None

    def _build_diff_block(self, old_text: str, new_text: str) -> str:
        """Render a compact unified diff block for edit previews."""
        old_lines = old_text.splitlines()
        new_lines = new_text.splitlines()
        if not old_lines and not new_lines:
            return ""

        diff_lines = list(
            difflib.unified_diff(
                old_lines,
                new_lines,
                fromfile="before",
                tofile="after",
                lineterm="",
                n=2,
            )
        )
        if not diff_lines:
            return ""
        return "```diff\n" + "\n".join(diff_lines[:240]) + "\n```"

    def _handle_session_error(self, params: Dict[str, Any]) -> None:
        """Handle session error from agent."""
        error = params.get("error", {})
        message = error.get("message", "Unknown error")
        self.log(f"[ERROR] Agent session error: {message}")

        if self.vicoa_client:
            try:
                self.vicoa_client.send_message(
                    content=f"Error: {message}",
                    agent_type=self.config.agent_type,
                    agent_instance_id=self.config.agent_instance_id,
                    requires_user_input=False,
                )
            except Exception as e:
                self.log(f"[ERROR] Failed to send error to Vicoa: {e}")

    def _handle_session_idle(self, params: Dict[str, Any]) -> None:
        """Handle session idle notification from agent."""
        self.log("[ACP] Agent session is idle")

        self._flush_assistant_chunk_buffer()
        self._set_awaiting_input_state()

    def _handle_acp_error(self, line: str) -> None:
        """Handle stderr output from agent.

        Args:
            line: stderr line
        """
        # Default: just log, don't forward to Vicoa
        pass

    def _cleanup(self) -> None:
        """Cleanup before exit."""
        self.running = False

        # Stop ACP client
        if self.acp:
            self.log("[ACP] Stopping ACP client")
            self.acp.stop()

        # End Vicoa session
        if self.vicoa_client and self.config.agent_instance_id:
            try:
                self.vicoa_client.end_session(self.config.agent_instance_id)
            except Exception as e:
                self.log(f"[ERROR] Failed to end Vicoa session: {e}")

        # Close Vicoa client
        if self.vicoa_client:
            try:
                self.vicoa_client.close()
            except Exception as e:
                self.log(f"[ERROR] Failed to close Vicoa client: {e}")

        # Close log file
        if self.debug_log_file:
            try:
                self.log(f"=== {self.config.agent_type} Wrapper Log Ended ===")
                self.debug_log_file.flush()
                self.debug_log_file.close()
            except Exception:
                pass

    def _init_logging(self) -> None:
        """Initialize debug logging."""
        try:
            log_dir = Path.home() / ".vicoa" / f"{self.config.agent_type}_wrapper"
            log_dir.mkdir(exist_ok=True, parents=True)

            log_file_path = log_dir / f"{self.config.agent_instance_id}.log"
            self.debug_log_file = open(log_file_path, "w")

            timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
            milliseconds = int((time.time() % 1) * 1000)
            self.log(
                f"=== {self.config.agent_type} Wrapper - {timestamp}.{milliseconds:03d} ==="
            )
        except Exception as e:
            print(f"Failed to create debug log file: {e}", file=sys.stderr)

    def _init_vicoa_client(self) -> None:
        """Initialize Vicoa SDK client."""
        if not self.config.api_key:
            raise ValueError("Vicoa API key is required")

        self.vicoa_client = VicoaClient(
            api_key=self.config.api_key,
            base_url=self.config.base_url,
            max_retries=1440,
            backoff_factor=1.0,
            backoff_max=60.0,
            log_func=self.log,
        )

    def log(self, message: str) -> None:
        """Write to debug log file."""
        if self.debug_log_file:
            try:
                timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
                milliseconds = int((time.time() % 1) * 1000)
                self.debug_log_file.write(
                    f"[{timestamp}.{milliseconds:03d}] {message}\n"
                )
                self.debug_log_file.flush()
            except Exception:
                pass
