"""Tests for the desktop sidecar protocol and dispatch logic."""

from __future__ import annotations

import asyncio
import json
from typing import Any
from unittest.mock import MagicMock, patch

import pytest
from icarus.session.events import (
    AutoApproveEnabled,
    StatusUpdate,
    TextComplete,
    TextDelta,
    TokenUsage,
    ToolCallStart,
    ToolResult,
    ToolsApproved,
    ToolsRejected,
)
from icarus.sidecar.protocol import (
    parse_command,
    serialize_event,
    serialize_message,
)

# ---------------------------------------------------------------------------
# Protocol: serialize_event
# ---------------------------------------------------------------------------

class TestSerializeEvent:
    """Verify JSON round-trip for every SessionEvent type."""

    @pytest.mark.parametrize(
        ("event", "expected_type", "expected_fields"),
        [
            (StatusUpdate("Thinking"), "status_update", {"status": "Thinking"}),
            (StatusUpdate(None), "status_update", {"status": None}),
            (TextDelta("hi", "m1"), "text_delta", {"text": "hi", "message_id": "m1"}),
            (
                TextComplete("hello world", "m1"),
                "text_complete",
                {"full_text": "hello world", "message_id": "m1"},
            ),
            (
                ToolCallStart("tc1", "read_file", {"path": "/tmp/f.py"}),
                "tool_call_start",
                {"tool_id": "tc1", "tool_name": "read_file", "args": {"path": "/tmp/f.py"}},
            ),
            (
                ToolResult("tc1", "read_file", "success", "contents"),
                "tool_result",
                {"tool_id": "tc1", "status": "success", "output": "contents"},
            ),
            (
                ToolResult("tc2", "execute", "error", "failed", diff="---", diff_path="x.py"),
                "tool_result",
                {"status": "error", "diff": "---", "diff_path": "x.py"},
            ),
            (ToolsApproved(), "tools_approved", {}),
            (ToolsRejected(), "tools_rejected", {"message": ToolsRejected().message}),
            (AutoApproveEnabled(), "auto_approve_enabled", {}),
            (TokenUsage(1540), "token_usage", {"total_tokens": 1540}),
        ],
        ids=[
            "status-thinking",
            "status-none",
            "text-delta",
            "text-complete",
            "tool-call-start",
            "tool-result-success",
            "tool-result-error-with-diff",
            "tools-approved",
            "tools-rejected",
            "auto-approve",
            "token-usage",
        ],
    )
    def test_serialize_event(
        self,
        event: Any,
        expected_type: str,
        expected_fields: dict[str, Any],
    ) -> None:
        line = serialize_event(event)
        parsed = json.loads(line)

        assert parsed["type"] == expected_type
        for key, value in expected_fields.items():
            assert parsed[key] == value

    def test_thread_id_included_when_set(self) -> None:
        event = TextDelta("hi", "m1")
        event.thread_id = "thread-abc"
        line = serialize_event(event)
        parsed = json.loads(line)
        assert parsed["thread_id"] == "thread-abc"

    def test_thread_id_null_when_unset(self) -> None:
        event = TextDelta("hi", "m1")
        line = serialize_event(event)
        parsed = json.loads(line)
        assert parsed["thread_id"] is None

    def test_output_is_compact_single_line(self) -> None:
        line = serialize_event(TextDelta("hello", "m1"))
        assert "\n" not in line
        # Compact separators: no spaces after , or :
        assert ", " not in line
        assert ": " not in line


# ---------------------------------------------------------------------------
# Protocol: serialize_message
# ---------------------------------------------------------------------------

class TestSerializeMessage:
    def test_human_message(self) -> None:
        msg = MagicMock()
        msg.type = "human"
        msg.content = "what is 2+2?"
        msg.id = "h1"
        msg.tool_calls = None

        result = serialize_message(msg)
        assert result["role"] == "human"
        assert result["content"] == "what is 2+2?"
        assert result["id"] == "h1"
        assert "tool_calls" not in result

    def test_ai_message_with_tool_calls(self) -> None:
        msg = MagicMock()
        msg.type = "ai"
        msg.content = "Let me check."
        msg.id = "a1"
        msg.tool_calls = [
            {"id": "tc1", "name": "read_file", "args": {"path": "/tmp/f.py"}},
        ]

        result = serialize_message(msg)
        assert result["role"] == "ai"
        assert len(result["tool_calls"]) == 1
        assert result["tool_calls"][0]["name"] == "read_file"

    def test_tool_message(self) -> None:
        msg = MagicMock()
        msg.type = "tool"
        msg.content = "file contents here"
        msg.id = "t1"
        msg.tool_calls = None
        msg.tool_call_id = "tc1"
        msg.name = "read_file"
        msg.status = "success"

        result = serialize_message(msg)
        assert result["role"] == "tool"
        assert result["tool_call_id"] == "tc1"
        assert result["name"] == "read_file"
        assert result["status"] == "success"

    def test_multimodal_content_extracts_text(self) -> None:
        msg = MagicMock()
        msg.type = "human"
        msg.content = [
            {"type": "text", "text": "describe this image"},
            {"type": "image_url", "url": "data:image/png;base64,abc"},
        ]
        msg.id = "h2"
        msg.tool_calls = None

        result = serialize_message(msg)
        assert result["content"] == "describe this image\n[image]"


# ---------------------------------------------------------------------------
# Protocol: parse_command
# ---------------------------------------------------------------------------

class TestParseCommand:
    def test_valid_command(self) -> None:
        cmd = parse_command('{"cmd":"send","thread_id":"abc","content":"hi"}')
        assert cmd["cmd"] == "send"
        assert cmd["thread_id"] == "abc"

    def test_invalid_json_raises(self) -> None:
        with pytest.raises(json.JSONDecodeError):
            parse_command("not json")


# ---------------------------------------------------------------------------
# Sidecar: dispatch routing
# ---------------------------------------------------------------------------

class TestSidecarDispatch:
    """Verify commands reach the right handler without booting a real agent."""

    @pytest.fixture
    def sidecar(self) -> Any:
        from icarus.sidecar.main import Sidecar

        s = Sidecar()
        # Stub the agent so handlers that need it don't crash
        s._agent = MagicMock()
        return s

    @pytest.mark.asyncio
    async def test_unknown_command_returns_error(self, sidecar: Any) -> None:
        with patch("icarus.sidecar.main.write_response") as mock_write:
            await sidecar._dispatch({"cmd": "bogus"})
            mock_write.assert_called_once()
            data = mock_write.call_args[0][0]
            assert data["type"] == "error"
            assert "bogus" in data["message"]

    @pytest.mark.asyncio
    async def test_send_missing_fields_returns_error(self, sidecar: Any) -> None:
        with patch("icarus.sidecar.main.write_response") as mock_write:
            await sidecar._dispatch({"cmd": "send", "thread_id": "abc"})
            data = mock_write.call_args[0][0]
            assert data["type"] == "error"

    @pytest.mark.asyncio
    async def test_approve_without_pending_hitl_logs_warning(
        self, sidecar: Any
    ) -> None:
        # No pending future — should log warning, not crash
        await sidecar._dispatch({"cmd": "approve", "thread_id": "t1"})

    @pytest.mark.asyncio
    async def test_cancel_without_streaming_returns_cancelled(
        self, sidecar: Any
    ) -> None:
        with patch("icarus.sidecar.main.write_response") as mock_write:
            await sidecar._dispatch({"cmd": "cancel", "thread_id": "t1"})
            data = mock_write.call_args[0][0]
            assert data["type"] == "cancelled"
            assert data["thread_id"] == "t1"

    @pytest.mark.asyncio
    async def test_list_models_dispatches(self, sidecar: Any) -> None:
        mock_info = MagicMock()
        mock_info.models = [{"provider": "openai", "model": "gpt-4o"}]
        mock_info.current = "openai:gpt-4o"
        mock_info.default = "openai:gpt-4o"

        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch("icarus.session.model.list_models", return_value=mock_info),
        ):
            await sidecar._dispatch({"cmd": "list_models"})
            data = mock_write.call_args[0][0]
            assert data["type"] == "models_list"
            assert len(data["models"]) == 1
            assert data["current"] == "openai:gpt-4o"

    @pytest.mark.asyncio
    async def test_set_model_missing_field_returns_error(self, sidecar: Any) -> None:
        with patch("icarus.sidecar.main.write_response") as mock_write:
            await sidecar._dispatch({"cmd": "set_model"})
            data = mock_write.call_args[0][0]
            assert data["type"] == "error"
            assert "requires model" in data["message"]

    @pytest.mark.asyncio
    async def test_set_model_already_active(self, sidecar: Any) -> None:
        """Lazy switch: if the requested model matches the current (or pending),
        set_model responds with already_active without calling switch_model."""
        sidecar._engine._ctx = MagicMock()
        sidecar._engine._mcp_tools = []
        sidecar._engine._checkpointer = MagicMock()
        sidecar._pending_model = None

        from icarus.config.app_config import settings
        with (
            patch("icarus.sidecar.main.write_response") as mock_write,
            patch.object(settings, "model_provider", "openai"),
            patch.object(settings, "model_name", "gpt-4o"),
        ):
            await sidecar._dispatch({"cmd": "set_model", "model": "openai:gpt-4o"})
            data = mock_write.call_args[0][0]
            assert data["type"] == "model_set"
            assert data["already_active"] is True



# ---------------------------------------------------------------------------
# Sidecar: HITL bridge
# ---------------------------------------------------------------------------

class TestHITLBridge:
    """Verify the asyncio.Future-based HITL bridge between stdin and stream.

    The HITL bridge now lives in the engine. These tests verify the
    sidecar delegates correctly by testing through the engine's API.
    """

    @pytest.mark.asyncio
    async def test_approve_resolves_future(self) -> None:
        from icarus.session.thread_state import ThreadState
        from icarus.sidecar.main import Sidecar

        s = Sidecar()
        engine = s._engine
        # Pre-set thread to STREAMING so HITL transition is valid
        engine._state_manager.set_state("t1", ThreadState.STREAMING)

        # Create a resolver just like the engine does during streaming
        emitted: list[dict] = []
        resolver = engine._make_hitl_resolver("t1", emitted.append)

        # Start the resolver (it will block on the Future)
        async def simulate_hitl() -> dict[str, Any]:
            return await resolver(
                [{"name": "execute", "args": {"command": "ls"}}],
                "icarus",
            )

        task = asyncio.create_task(simulate_hitl())
        await asyncio.sleep(0)  # yield so the task runs to the await

        # Now resolve from "stdin" via the engine
        assert "t1" in engine._hitl_futures
        engine.approve("t1")

        result = await task
        assert result == {"type": "approve"}
        assert "t1" not in engine._hitl_futures  # cleaned up

    @pytest.mark.asyncio
    async def test_reject_resolves_future(self) -> None:
        from icarus.session.thread_state import ThreadState
        from icarus.sidecar.main import Sidecar

        s = Sidecar()
        engine = s._engine
        engine._state_manager.set_state("t1", ThreadState.STREAMING)

        emitted: list[dict] = []
        resolver = engine._make_hitl_resolver("t1", emitted.append)

        async def simulate_hitl() -> dict[str, Any]:
            return await resolver(
                [{"name": "write_file", "args": {"path": "/tmp/x"}}],
                "icarus",
            )

        task = asyncio.create_task(simulate_hitl())
        await asyncio.sleep(0)

        engine.reject("t1")

        result = await task
        assert result == {"type": "reject"}

    @pytest.mark.asyncio
    async def test_auto_approve_all_resolves_future(self) -> None:
        from icarus.session.thread_state import ThreadState
        from icarus.sidecar.main import Sidecar

        s = Sidecar()
        engine = s._engine
        engine._state_manager.set_state("t1", ThreadState.STREAMING)

        emitted: list[dict] = []
        resolver = engine._make_hitl_resolver("t1", emitted.append)

        async def simulate_hitl() -> dict[str, Any]:
            return await resolver([], "icarus")

        task = asyncio.create_task(simulate_hitl())
        await asyncio.sleep(0)

        engine.approve("t1", auto_approve_all=True)

        result = await task
        assert result == {"type": "auto_approve_all"}

    @pytest.mark.asyncio
    async def test_hitl_callback_writes_request_to_stdout(self) -> None:
        from icarus.session.thread_state import ThreadState
        from icarus.sidecar.main import Sidecar

        s = Sidecar()
        engine = s._engine
        engine._state_manager.set_state("t1", ThreadState.STREAMING)

        # Capture emitted events
        emitted: list[dict] = []
        resolver = engine._make_hitl_resolver("t1", emitted.append)

        task = asyncio.create_task(
            resolver(
                [{"name": "execute", "args": {"command": "rm -rf /"}}],
                "default",
            )
        )
        await asyncio.sleep(0)

        # Verify the HITL request was emitted
        # The resolver emits the hitl_request event
        assert len(emitted) == 1
        data = emitted[0]
        assert data["type"] == "hitl_request"
        assert len(data["action_requests"]) == 1
        assert data["action_requests"][0]["name"] == "execute"
        assert data["assistant_id"] == "default"
        assert data["thread_id"] == "t1"

        # Clean up
        engine.reject("t1")
        await task
