from __future__ import annotations

import asyncio
from collections.abc import Callable, Coroutine
from types import MethodType
from typing import Any, cast

from acp.schema import TerminalOutputResponse, WaitForTerminalExitResponse
import pytest

from revibe.acp.tools.builtins.bash import AcpBashState, Bash
from revibe.core.tools.base import ToolError
from revibe.core.tools.builtins.bash import BashArgs, BashResult, BashToolConfig


class MockTerminalHandle:
    def __init__(
        self,
        terminal_id: str = "test_terminal_123",
        exit_code: int | None = 0,
        output: str = "test output",
        wait_delay: float = 0.01,
    ) -> None:
        self.id = terminal_id
        self._exit_code = exit_code
        self._output = output
        self._wait_delay = wait_delay
        self._killed = False

    async def wait_for_exit(self) -> WaitForTerminalExitResponse:
        await asyncio.sleep(self._wait_delay)
        return WaitForTerminalExitResponse(exitCode=self._exit_code)

    async def current_output(self) -> TerminalOutputResponse:
        return TerminalOutputResponse(output=self._output, truncated=False)

    async def kill(self) -> None:
        self._killed = True

    async def release(self) -> None:
        pass


class MockConnection:
    def __init__(self, terminal_handle: MockTerminalHandle | None = None) -> None:
        self._terminal_handle = terminal_handle or MockTerminalHandle()
        self._create_terminal_called = False
        self._session_update_called = False
        self._create_terminal_error: Exception | None = None
        self._last_create_request = None

    async def createTerminal(self, request) -> MockTerminalHandle:
        self._create_terminal_called = True
        self._last_create_request = request
        if self._create_terminal_error:
            raise self._create_terminal_error
        return self._terminal_handle

    async def sessionUpdate(self, notification) -> None:
        self._session_update_called = True


@pytest.fixture
def mock_connection() -> MockConnection:
    return MockConnection()


@pytest.fixture
def acp_bash_tool(mock_connection: MockConnection) -> Bash:
    config = BashToolConfig()
    # Use model_construct to bypass Pydantic validation for testing
    state = AcpBashState.model_construct(
        connection=mock_connection,  # type: ignore[arg-type]
        session_id="test_session_123",
        tool_call_id="test_tool_call_456",
    )
    return Bash(config=config, state=state)


class TestAcpBashBasic:
    def test_get_name(self) -> None:
        assert Bash.get_name() == "bash"

    def test_get_summary_simple_command(self) -> None:
        args = BashArgs(command="ls")
        display = Bash.get_summary(args)
        assert display == "ls"

    def test_get_summary_with_timeout(self) -> None:
        args = BashArgs(command="ls", timeout=10)
        display = Bash.get_summary(args)
        assert display == "ls (timeout 10s)"

    def test_parse_command_simple(self) -> None:
        tool = Bash(config=BashToolConfig(), state=AcpBashState())
        env, command, args = tool._parse_command("ls")
        assert env == []
        assert command == "ls"
        assert args == []

    def test_parse_command_with_args(self) -> None:
        tool = Bash(config=BashToolConfig(), state=AcpBashState())
        env, command, args = tool._parse_command("ls -la src")
        assert env == []
        assert command == "ls"
        assert args == ["-la", "src"]

    def test_parse_command_with_env(self) -> None:
        tool = Bash(config=BashToolConfig(), state=AcpBashState())
        env, command, args = tool._parse_command("NODE_ENV=test DEBUG=1 npm test")
        assert len(env) == 2
        assert env[0].name == "NODE_ENV"
        assert env[0].value == "test"
        assert env[1].name == "DEBUG"
        assert env[1].value == "1"
        assert command == "npm"
        assert args == ["test"]

    def test_parse_command_with_env_value_contains_equals(self) -> None:
        tool = Bash(config=BashToolConfig(), state=AcpBashState())
        env, command, args = tool._parse_command(
            "PATH=/usr/bin:/usr/local/bin echo hello"
        )
        assert len(env) == 1
        assert env[0].name == "PATH"
        assert env[0].value == "/usr/bin:/usr/local/bin"
        assert command == "echo"
        assert args == ["hello"]


class TestAcpBashExecution:
    @pytest.mark.asyncio
    async def test_run_success(
        self, acp_bash_tool: Bash, mock_connection: MockConnection
    ) -> None:
        from pathlib import Path

        args = BashArgs(command="echo hello")
        result = await acp_bash_tool.run(args)

        assert isinstance(result, BashResult)
        assert result.stdout == "test output"
        assert result.stderr == ""
        assert result.returncode == 0
        assert mock_connection._create_terminal_called

        # Verify CreateTerminalRequest was created correctly
        request = mock_connection._last_create_request
        assert request is not None
        assert request.sessionId == "test_session_123"
        assert request.command == "echo"
        assert request.args == ["hello"]
        assert request.cwd == str(Path.cwd())  # effective_workdir defaults to cwd

    @pytest.mark.asyncio
    async def test_run_creates_terminal_with_env_vars(
        self, mock_connection: MockConnection
    ) -> None:
        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="NODE_ENV=test npm run build")
        await tool.run(args)

        request = mock_connection._last_create_request
        assert request is not None
        assert len(request.env) == 1
        assert request.env[0].name == "NODE_ENV"
        assert request.env[0].value == "test"
        assert request.command == "npm"
        assert request.args == ["run", "build"]

    @pytest.mark.asyncio
    async def test_run_with_nonzero_exit_code(
        self, mock_connection: MockConnection
    ) -> None:
        custom_handle = MockTerminalHandle(
            terminal_id="custom_terminal", exit_code=1, output="error: command failed"
        )
        mock_connection._terminal_handle = custom_handle

        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="test_command")
        with pytest.raises(ToolError) as exc_info:
            await tool.run(args)

        assert (
            str(exc_info.value)
            == "Command failed: 'test_command'\nReturn code: 1\nStdout: error: command failed"
        )

    @pytest.mark.asyncio
    async def test_run_create_terminal_failure(
        self, mock_connection: MockConnection
    ) -> None:
        mock_connection._create_terminal_error = RuntimeError("Connection failed")

        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="test")
        with pytest.raises(ToolError) as exc_info:
            await tool.run(args)

        assert (
            str(exc_info.value)
            == "Failed to create terminal: RuntimeError('Connection failed')"
        )

    @pytest.mark.asyncio
    async def test_run_without_connection(self) -> None:
        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=None, session_id="test_session", tool_call_id="test_call"
            ),
        )

        args = BashArgs(command="test")
        with pytest.raises(ToolError) as exc_info:
            await tool.run(args)

        assert (
            str(exc_info.value)
            == "Connection not available in tool state. This tool can only be used within an ACP session."
        )

    @pytest.mark.asyncio
    async def test_run_without_session_id(self) -> None:
        mock_connection = MockConnection()
        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id=None,
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="test")
        with pytest.raises(ToolError) as exc_info:
            await tool.run(args)

        assert (
            str(exc_info.value)
            == "Session ID not available in tool state. This tool can only be used within an ACP session."
        )

    @pytest.mark.asyncio
    async def test_run_with_none_exit_code(
        self, mock_connection: MockConnection
    ) -> None:
        custom_handle = MockTerminalHandle(
            terminal_id="none_exit_terminal", exit_code=None, output="output"
        )
        mock_connection._terminal_handle = custom_handle

        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="test_command")
        result = await tool.run(args)

        assert result.returncode == 0
        assert result.stdout == "output"


class TestAcpBashTimeout:
    @pytest.mark.asyncio
    async def test_run_with_timeout_raises_error_and_kills(
        self, mock_connection: MockConnection
    ) -> None:
        custom_handle = MockTerminalHandle(
            terminal_id="timeout_terminal",
            output="partial output",
            wait_delay=20,  # Longer than the 1 second timeout
        )
        mock_connection._terminal_handle = custom_handle

        # Use a config with different default timeout to verify args timeout overrides it
        tool = Bash(
            config=BashToolConfig(default_timeout=30),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="slow_command", timeout=1)
        with pytest.raises(ToolError) as exc_info:
            await tool.run(args)

        assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"
        assert custom_handle._killed

    @pytest.mark.asyncio
    async def test_run_timeout_handles_kill_failure(
        self, mock_connection: MockConnection
    ) -> None:
        custom_handle = MockTerminalHandle(
            terminal_id="kill_failure_terminal",
            wait_delay=20,  # Longer than the 1 second timeout
        )
        mock_connection._terminal_handle = custom_handle

        async def failing_kill(self) -> None:
            raise RuntimeError("Kill failed")

        custom_handle.kill = cast(  # type: ignore[assignment]
            Callable[[], Coroutine[Any, Any, None]],
            MethodType(failing_kill, custom_handle),
        )

        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="slow_command", timeout=1)
        # Should still raise timeout error even if kill fails
        with pytest.raises(ToolError) as exc_info:
            await tool.run(args)

        assert str(exc_info.value) == "Command timed out after 1s: 'slow_command'"


class TestAcpBashEmbedding:
    @pytest.mark.asyncio
    async def test_run_with_embedding(self, mock_connection: MockConnection) -> None:
        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="test")
        await tool.run(args)

        assert mock_connection._session_update_called

    @pytest.mark.asyncio
    async def test_run_embedding_without_tool_call_id(
        self, mock_connection: MockConnection
    ) -> None:
        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id=None,
            ),
        )

        args = BashArgs(command="test")
        await tool.run(args)

        # Embedding should be skipped when tool_call_id is None
        assert not mock_connection._session_update_called

    @pytest.mark.asyncio
    async def test_run_embedding_handles_exception(
        self, mock_connection: MockConnection
    ) -> None:
        # Make sessionUpdate raise an exception
        async def failing_session_update(self, notification) -> None:
            raise RuntimeError("Session update failed")

        mock_connection.sessionUpdate = cast(  # type: ignore[assignment]
            Callable[..., Coroutine[Any, Any, None]],
            MethodType(failing_session_update, mock_connection),
        )

        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="test")
        # Should not raise, embedding failure is silently ignored
        result = await tool.run(args)

        assert result is not None
        assert result.stdout == "test output"


class TestAcpBashConfig:
    @pytest.mark.asyncio
    async def test_run_uses_config_default_timeout(
        self, mock_connection: MockConnection
    ) -> None:
        custom_handle = MockTerminalHandle(
            terminal_id="config_timeout_terminal",
            wait_delay=0.01,  # Shorter than config timeout
        )
        mock_connection._terminal_handle = custom_handle

        tool = Bash(
            config=BashToolConfig(default_timeout=30),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="fast", timeout=None)
        result = await tool.run(args)

        # Should succeed with config timeout
        assert result.returncode == 0


class TestAcpBashCleanup:
    @pytest.mark.asyncio
    async def test_run_releases_terminal_on_success(
        self, mock_connection: MockConnection
    ) -> None:
        custom_handle = MockTerminalHandle(terminal_id="cleanup_terminal")
        mock_connection._terminal_handle = custom_handle

        release_called = False

        async def mock_release(self) -> None:
            nonlocal release_called
            release_called = True

        custom_handle.release = MethodType(mock_release, custom_handle)  # type: ignore[assignment]

        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="test")
        await tool.run(args)

        assert release_called

    @pytest.mark.asyncio
    async def test_run_releases_terminal_on_timeout(
        self, mock_connection: MockConnection
    ) -> None:
        # The handle will wait 2 seconds, but timeout is 1 second,
        # so asyncio.wait_for() will raise TimeoutError
        custom_handle = MockTerminalHandle(
            terminal_id="timeout_cleanup_terminal",
            wait_delay=2.0,  # Longer than the 1 second timeout
        )
        mock_connection._terminal_handle = custom_handle

        release_called = False

        async def mock_release(self) -> None:
            nonlocal release_called
            release_called = True

        custom_handle.release = MethodType(mock_release, custom_handle)  # type: ignore[assignment]

        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="slow", timeout=1)
        # Timeout raises an error, but terminal should still be released
        try:
            await tool.run(args)
        except ToolError:
            pass

        assert release_called

    @pytest.mark.asyncio
    async def test_run_handles_release_failure(
        self, mock_connection: MockConnection
    ) -> None:
        custom_handle = MockTerminalHandle(terminal_id="release_failure_terminal")

        async def failing_release(self) -> None:
            raise RuntimeError("Release failed")

        custom_handle.release = cast(  # type: ignore[assignment]
            Callable[[], Coroutine[Any, Any, None]],
            MethodType(failing_release, custom_handle),
        )
        mock_connection._terminal_handle = custom_handle

        tool = Bash(
            config=BashToolConfig(),
            state=AcpBashState.model_construct(
                connection=mock_connection,  # type: ignore[arg-type]
                session_id="test_session",
                tool_call_id="test_call",
            ),
        )

        args = BashArgs(command="test")
        # Should not raise, release failure is silently ignored
        result = await tool.run(args)

        assert result is not None
        assert result.stdout == "test output"
