#!/usr/bin/env python3
"""
Headless OpenCode Integration for Vicoa via ACP

This module provides a headless version of OpenCode that integrates with the Vicoa SDK,
allowing human users to interact with OpenCode through the web dashboard while OpenCode runs
autonomously using the Agent Client Protocol (ACP).

Similar to claude_code.py but uses ACP protocol instead of Claude Agent SDK.
"""

import argparse
import logging
import os
import sys
import threading
import uuid
from typing import Optional, Dict, Any

from integrations.headless.acp_base import ACPWrapperBase, ACPWrapperConfig


logger = logging.getLogger(__name__)


class OpenCodeACPConfig(ACPWrapperConfig):
    """Configuration for OpenCode ACP integration."""

    def __init__(
        self,
        api_key: str,
        base_url: str,
        agent_instance_id: str,
        project_path: str,
        agent_mode: str = "build",
        opencode_api_key: Optional[str] = None,
        model: Optional[str] = None,
        name: str = "OpenCode",
        is_resuming: bool = False,
        opencode_command: str = "opencode",
    ):
        """Initialize OpenCode ACP config.

        Args:
            api_key: Vicoa API key
            base_url: Vicoa backend URL
            agent_instance_id: Agent instance ID
            project_path: Project directory path
            agent_mode: OpenCode mode ("build" or "plan")
            opencode_api_key: Anthropic API key for OpenCode
            model: Model override (e.g., "anthropic/claude-sonnet-4")
            name: Agent display name
            is_resuming: Whether resuming existing session
            opencode_command: Path to opencode binary
        """
        self.api_key = api_key
        self.base_url = base_url
        self.agent_instance_id = agent_instance_id
        self.project_path = project_path
        self.agent_type = "OpenCode"
        self.agent_command = opencode_command

        # OpenCode-specific settings
        self.agent_mode = agent_mode
        self.opencode_api_key = opencode_api_key
        self.model = model

        self.name = name
        self.is_resuming = is_resuming

    def get_acp_command(self) -> list[str]:
        """Return command to start OpenCode in ACP mode."""
        return [self.agent_command, "acp"]

    def get_acp_env(self) -> dict[str, str]:
        """Return environment variables for OpenCode ACP process."""
        env = {}

        if self.opencode_api_key:
            env["ANTHROPIC_API_KEY"] = self.opencode_api_key
            env["OPENCODE_API_KEY"] = self.opencode_api_key

        if self.model:
            env["OPENCODE_MODEL"] = self.model

        # Set production mode to reduce debug output
        env["NODE_ENV"] = "production"

        return env

    @classmethod
    def from_args(
        cls,
        api_key: Optional[str] = None,
        base_url: Optional[str] = None,
        project_path: Optional[str] = None,
        agent_instance_id: Optional[str] = None,
        agent_mode: str = "build",
        name: str = "OpenCode",
        is_resuming: bool = False,
        opencode_api_key: Optional[str] = None,
        model: Optional[str] = None,
        opencode_command: str = "opencode",
    ) -> "OpenCodeACPConfig":
        """Create config from command-line arguments."""
        # Resolve API key
        final_api_key = api_key or os.environ.get("VICOA_API_KEY")
        if not final_api_key:
            raise ValueError(
                "Vicoa API key required: provide --api-key or set VICOA_API_KEY"
            )

        # Resolve base URL
        final_base_url = (
            base_url
            or os.environ.get("VICOA_API_URL")
            or os.environ.get("VICOA_BASE_URL")
            or "https://api.vicoa.ai"
        )

        # Resolve project path
        final_project_path = project_path or os.getcwd()

        # Resolve agent instance ID
        if not agent_instance_id:
            agent_instance_id = str(uuid.uuid4())

        # Resolve OpenCode API key
        final_opencode_api_key = (
            opencode_api_key
            or os.environ.get("OPENCODE_API_KEY")
            or os.environ.get("ANTHROPIC_API_KEY")
        )

        # Resolve model
        final_model = model or os.environ.get("OPENCODE_MODEL")

        return cls(
            api_key=final_api_key,
            base_url=final_base_url,
            agent_instance_id=agent_instance_id,
            project_path=final_project_path,
            agent_mode=agent_mode,
            name=name,
            is_resuming=is_resuming,
            opencode_api_key=final_opencode_api_key,
            model=final_model,
            opencode_command=opencode_command,
        )


class OpenCodeACPWrapper(ACPWrapperBase):
    """Headless OpenCode wrapper using ACP protocol.

    This provides similar functionality to claude_code.py but for OpenCode:
    - Runs OpenCode in ACP mode (headless, no terminal)
    - Integrates with Vicoa backend for monitoring
    - Handles bidirectional messaging (user ↔ agent)
    - Forwards permission requests
    - Tracks session state
    """

    config: OpenCodeACPConfig  # Type hint for config
    _permission_wait_poll_interval_seconds: float = 1.0
    _permission_wait_timeout_minutes: int = 15
    _prompt_cancel_grace_period_seconds: float = 15.0
    _hard_interrupt_fallback_delay_seconds: float = 2.0
    _permission_cancelled_option_id: str = "__vicoa_cancelled__"

    def __init__(self, config: OpenCodeACPConfig):
        super().__init__(config)
        self._prompt_state_lock = threading.Lock()
        self._prompt_in_flight = False
        self._queued_prompts: list[str] = []
        # Interrupt can cancel local wait on long-running session/prompt requests.
        self._prompt_cancel_event = threading.Event()
        # When interrupt happens, drop remaining streamed output from the
        # interrupted turn until a new user prompt starts.
        self._drop_stream_output_until_next_prompt = False
        # Mark active cancellation so permission requests can return ACP
        # outcome=cancelled instead of selecting a reject option.
        self._interrupt_active = False
        # Guard SIGINT fallback: avoid killing ACP while a permission request
        # callback is in-flight.
        self._permission_request_active = False

    def create_session(self) -> None:
        """Create OpenCode coding session via ACP."""
        acp = self.acp
        if not acp:
            from integrations.headless.acp_client import ACPError

            raise ACPError("ACP client not started")

        self.log(f"[ACP] Creating session with mode: {self.config.agent_mode}")

        try:
            from integrations.headless.acp_client import ACPError

            params: Dict[str, Any] = {
                "mode": self.config.agent_mode,
                "cwd": self.config.project_path,
                # OpenCode ACP currently validates this as a required array.
                "mcpServers": [],
            }

            # Add model if specified
            if self.config.model:
                # Parse model string (e.g., "anthropic/claude-sonnet-4")
                if "/" in self.config.model:
                    provider_id, model_id = self.config.model.split("/", 1)
                    params["model"] = {"providerId": provider_id, "modelId": model_id}

            response = acp.send_request("session/new", params)
            response.raise_for_error()

            if response.result:
                self.session_id = response.result.get("sessionId")
                self.log(f"[ACP] Session created: {self.session_id}")
            else:
                raise ACPError("session/new response missing result")

        except Exception as e:
            self.log(f"[ERROR] Failed to create session: {e}")
            raise

    def send_prompt(self, message: str) -> None:
        """Send user message to OpenCode via ACP."""
        if not self.acp or not self.session_id:
            self.log("[WARNING] Cannot send message: ACP not ready")
            return

        if self._handle_control_command(message):
            return

        with self._prompt_state_lock:
            if self._prompt_in_flight:
                self._queued_prompts.append(message)
                self.log("[ACP] Prompt queued while another prompt is running")
                return
            self._prompt_in_flight = True
            self._prepare_for_new_prompt()

        worker = threading.Thread(
            target=self._run_prompt_request, args=(message,), daemon=True
        )
        worker.start()

    def _run_prompt_request(self, message: str) -> None:
        """Execute prompt request in background so polling can continue."""
        self.log(f"[ACP] Sending prompt to OpenCode: {message[:100]}...")

        try:
            acp = self.acp
            if not acp or not self.session_id:
                raise RuntimeError("ACP client not ready")
            payload: Dict[str, Any] = {
                "sessionId": self.session_id,
                "prompt": [{"type": "text", "text": message}],
            }
            # Shell/tool requests can take longer than normal chat completions.
            response = acp.send_request(
                "session/prompt",
                payload,
                timeout=120.0,
                cancel_event=self._prompt_cancel_event,
                cancel_grace_period=self._prompt_cancel_grace_period_seconds,
            )
            response.raise_for_error()
            # Mark that subsequent assistant output should end in awaiting-input state.
            self._awaiting_after_next_agent_output = True
            # OpenCode may not emit session/idle; flush buffered reply chunks
            # when prompt call completes successfully.
            self._flush_assistant_chunk_buffer()
            self._set_awaiting_input_state()
            self.log("[ACP] Prompt sent successfully")
        except Exception as e:
            interrupted = (
                self._prompt_cancel_event.is_set() or "interrupted locally" in str(e)
            )
            # If request/response correlation fails but we already received
            # streamed chunks, still flush them to avoid UI silence.
            if not interrupted:
                self._awaiting_after_next_agent_output = True
                self._flush_assistant_chunk_buffer()
            self._set_awaiting_input_state()
            if interrupted:
                self.log("[ACP] Prompt interrupted")
            else:
                self.log(f"[ERROR] Failed to send prompt: {e}")
        finally:
            next_prompt: str | None = None
            with self._prompt_state_lock:
                self._prompt_in_flight = False
                if self._queued_prompts:
                    next_prompt = self._queued_prompts.pop(0)
                    self._prompt_in_flight = True
                    self._prepare_for_new_prompt()
            if next_prompt:
                worker = threading.Thread(
                    target=self._run_prompt_request, args=(next_prompt,), daemon=True
                )
                worker.start()

    def _prepare_for_new_prompt(self) -> None:
        """Reset transient interrupt/output state before each prompt."""
        self._prompt_cancel_event.clear()
        self._drop_stream_output_until_next_prompt = False
        self._interrupt_active = False

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

        Args:
            method: Notification method name
            params: Notification parameters
        """
        if method in {"permission/request", "session/request_permission"}:
            self._handle_permission_request(params)
        else:
            self.log(f"[ACP] Unhandled notification: {method}")

    def handle_request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
        """Handle OpenCode ACP requests that require explicit responses."""
        if method not in {"permission/request", "session/request_permission"}:
            self.handle_notification(method, params)
            return {}

        option_id = self._handle_permission_request(params)
        if option_id == self._permission_cancelled_option_id:
            return {"outcome": {"outcome": "cancelled"}}
        return {
            "outcome": {
                "outcome": "selected",
                "optionId": option_id,
            }
        }

    def _handle_permission_request(self, params: Dict[str, Any]) -> str:
        """Handle permission request from OpenCode.

        Args:
            params: Permission request parameters
        """
        tool_call = params.get("toolCall", {})
        options = params.get("options", [])

        tool_kind = tool_call.get("kind", "unknown")
        locations = tool_call.get("locations", [])

        message = f"Permission required: {tool_kind}\n"
        if locations:
            message += f"Locations: {', '.join(locations)}\n"

        option_lines: list[str] = []
        normalized_options: list[dict[str, str]] = []
        for idx, opt in enumerate(options):
            option_id = str(opt.get("optionId", "")).strip()
            option_name = str(opt.get("name", option_id or f"Option {idx + 1}")).strip()
            option_kind = str(opt.get("kind", "")).strip()
            if option_id:
                normalized_options.append(
                    {"option_id": option_id, "name": option_name, "kind": option_kind}
                )
            option_lines.append(f"{idx + 1}. {option_name}")
        if option_lines:
            message += f"\n[OPTIONS]\n{chr(10).join(option_lines)}\n[/OPTIONS]"

        # If an interrupt is already active, acknowledge the permission request
        # as cancelled immediately (ACP-compliant behavior).
        if self._interrupt_active:
            return self._permission_cancelled_option_id

        self._permission_request_active = True
        try:
            selected_option = self._wait_for_permission_decision(
                message, normalized_options
            )
            self.log(f"[ACP] Permission selected: {selected_option}")
            return selected_option
        finally:
            self._permission_request_active = False

    def _wait_for_permission_decision(
        self, prompt_message: str, options: list[dict[str, str]]
    ) -> str:
        """Request permission choice from UI and parse user response."""
        fallback_option = self._select_default_permission_option(
            [{"optionId": opt["option_id"], "kind": opt.get("kind")} for opt in options]
        )
        if not self.vicoa_client:
            return fallback_option

        self._set_agent_status("AWAITING_INPUT")
        self._suspend_vicoa_polling = True

        try:
            if self._interrupt_active:
                return self._permission_cancelled_option_id

            response = self.vicoa_client.send_message(
                content=prompt_message,
                agent_type=self.config.agent_type,
                agent_instance_id=self.config.agent_instance_id,
                requires_user_input=True,
                timeout_minutes=self._permission_wait_timeout_minutes,
                poll_interval=self._permission_wait_poll_interval_seconds,
            )

            permission_message_id = getattr(response, "message_id", None)
            if permission_message_id:
                self.last_message_id = permission_message_id

            queued_messages = list(getattr(response, "queued_user_messages", []) or [])
            for raw_message in queued_messages:
                message = str(raw_message or "").strip()
                if not message:
                    continue

                control = self._parse_control_command(message)
                if control:
                    setting = control.get("setting")
                    if setting == "interrupt":
                        self._handle_interrupt_control()
                        return self._permission_cancelled_option_id
                    self._apply_control_command(control)
                    continue

                selected = self._parse_permission_reply(message, options)
                if selected:
                    return selected

            self._send_feedback_message(
                f"No valid permission response received. Defaulting to '{fallback_option}'."
            )
            return fallback_option
        except Exception as e:
            self.log(
                f"[WARNING] Permission request failed, defaulting to {fallback_option}: {e}"
            )
            return fallback_option
        finally:
            self._suspend_vicoa_polling = False

    def _parse_permission_reply(
        self, message: str, options: list[dict[str, str]]
    ) -> str | None:
        """Parse user response into ACP option ID."""
        normalized = message.strip().lower()
        if not normalized:
            return None

        numeric = normalized
        if numeric.isdigit():
            idx = int(numeric) - 1
            if 0 <= idx < len(options):
                return options[idx]["option_id"]

        for opt in options:
            option_id = opt["option_id"]
            option_name = opt["name"]
            option_kind = opt.get("kind", "")
            if normalized in {
                option_id.lower(),
                option_name.lower(),
                option_kind.lower(),
            }:
                return option_id

        keyword_map = {
            "once": {"allow", "allow once", "once", "yes", "approve", "y", "ok", "1"},
            "always": {"always", "allow always", "forever", "2"},
            "reject": {"reject", "deny", "no", "n", "cancel", "3"},
        }
        for opt in options:
            option_id = opt["option_id"]
            option_kind = opt.get("kind", "").lower()
            if option_kind in keyword_map and normalized in keyword_map[option_kind]:
                return option_id

        return None

    def _select_default_permission_option(self, options: list[Dict[str, Any]]) -> str:
        """Pick default permission option, preferring one-time allow."""
        if not options:
            return "once"

        normalized: list[tuple[str, str]] = []
        for opt in options:
            option_id = str(opt.get("optionId") or "").strip()
            kind = str(opt.get("kind") or "").strip()
            if option_id:
                normalized.append((option_id, kind))

        preferred_order = [
            "once",
            "allow_once",
            "always",
            "allow_always",
            "reject",
            "reject_once",
        ]
        for target in preferred_order:
            for option_id, kind in normalized:
                if option_id == target or kind == target:
                    return option_id

        # Fallback to the first option ID provided by ACP.
        return normalized[0][0] if normalized else "once"

    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

        if setting == "agent_type":
            self._handle_agent_type_control(value)
            return

        if setting in {"permission_mode", "thinking"}:
            self._send_feedback_message(
                f"{setting} control is not supported in OpenCode ACP mode."
            )
            return

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

    def _handle_interrupt_control(self) -> None:
        """Interrupt active OpenCode task."""
        acp = self.acp
        if not acp or not self.session_id:
            self._send_feedback_message("Failed to interrupt: session not ready.")
            return

        self._prompt_cancel_event.set()
        self._interrupt_active = True
        self._awaiting_after_next_agent_output = False
        self._drop_stream_output_until_next_prompt = True
        self._assistant_chunk_buffer = ""

        try:
            # ACP spec defines cancellation via session/cancel notification.
            acp.send_notification("session/cancel", {"sessionId": self.session_id})
        except Exception as e:
            self.log(f"[WARNING] Failed to send session/cancel notification: {e}")
        finally:
            # Fallback for ACP implementations that ignore cancel notifications:
            # only send a hard SIGINT if the prompt is still in-flight after a
            # short delay. Immediate SIGINT can terminate OpenCode entirely.
            self._schedule_hard_interrupt_fallback()

            # Local interrupt fallback keeps UX responsive even if agent-side
            # cancel is unsupported in current ACP implementation.
            self._send_feedback_message("Interrupted.")
            self._set_awaiting_input_state()

    def _schedule_hard_interrupt_fallback(self) -> None:
        """Send SIGINT only if cancellation does not settle promptly."""
        acp = self.acp
        if not acp:
            return

        def _fallback() -> None:
            try:
                threading.Event().wait(self._hard_interrupt_fallback_delay_seconds)
                if not self.running:
                    return
                if self._permission_request_active:
                    return
                with self._prompt_state_lock:
                    still_in_flight = self._prompt_in_flight
                if not still_in_flight:
                    return
                interrupted = acp.interrupt_process()
                if interrupted:
                    self.log(
                        "[ACP] Used delayed SIGINT fallback for active prompt interrupt"
                    )
            except Exception as e:
                self.log(f"[WARNING] Delayed SIGINT fallback failed: {e}")

        threading.Thread(target=_fallback, daemon=True).start()

    def _should_buffer_assistant_chunk(self, text: str) -> bool:
        return not self._drop_stream_output_until_next_prompt

    def _handle_agent_type_control(self, value: str | None) -> None:
        """Switch OpenCode mode (build/plan)."""
        acp = self.acp
        if not acp or not self.session_id:
            self._send_feedback_message(
                "Failed to change agent mode: session not ready."
            )
            return

        requested_mode = (value or "").strip().lower()
        if requested_mode not in {"build", "plan"}:
            self._send_feedback_message(
                f"Invalid OpenCode mode '{value}'. Valid options: build, plan."
            )
            return

        if requested_mode == self.config.agent_mode:
            self._send_feedback_message(f"Agent mode is already {requested_mode}.")
            return

        method_candidates = ["session/set_mode", "session/setMode"]
        for method in method_candidates:
            try:
                response = acp.send_request(
                    method,
                    {"sessionId": self.session_id, "modeId": requested_mode},
                    timeout=10.0,
                )
                response.raise_for_error()
                self.config.agent_mode = requested_mode
                self._send_feedback_message(f"Agent changed to {requested_mode}.")
                return
            except Exception as e:
                self.log(f"[WARNING] Failed {method} for mode {requested_mode}: {e}")
                continue

        self._send_feedback_message(f"Failed to change agent mode to {requested_mode}.")


def main() -> int:
    """Main entry point for headless OpenCode via ACP."""
    parser = argparse.ArgumentParser(
        description="Headless OpenCode Integration via ACP"
    )
    parser.add_argument("--api-key", help="Vicoa API key")
    parser.add_argument("--base-url", help="Vicoa base URL")
    parser.add_argument("--project-path", help="Project directory")
    parser.add_argument(
        "--agent-mode",
        default="build",
        choices=["build", "plan"],
        help="OpenCode agent mode",
    )
    parser.add_argument("--name", default="OpenCode", help="Agent display name")
    parser.add_argument("--agent-instance-id", help="Agent instance ID")
    parser.add_argument("--resume", help="Resume session ID")
    parser.add_argument("--opencode-api-key", help="Anthropic API key for OpenCode")
    parser.add_argument(
        "--model", help="Model override (e.g., 'anthropic/claude-sonnet-4')"
    )
    parser.add_argument(
        "--opencode-command", default="opencode", help="Path to opencode binary"
    )
    args = parser.parse_args()

    # Setup logging
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    )

    # Use resume as agent_instance_id if provided
    agent_instance_id = args.resume or args.agent_instance_id
    is_resuming = bool(args.resume)

    try:
        config = OpenCodeACPConfig.from_args(
            api_key=args.api_key,
            base_url=args.base_url,
            project_path=args.project_path,
            agent_instance_id=agent_instance_id,
            agent_mode=args.agent_mode,
            name=args.name,
            is_resuming=is_resuming,
            opencode_api_key=args.opencode_api_key,
            model=args.model,
            opencode_command=args.opencode_command,
        )

        wrapper = OpenCodeACPWrapper(config)
        return wrapper.run()

    except Exception as e:
        logger.error(f"Failed to start OpenCode wrapper: {e}")
        import traceback

        traceback.print_exc()
        return 1


if __name__ == "__main__":
    sys.exit(main())
