"""Tests for SSH backend (connection pooling, execution, cancellation)."""

import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from rpipe.ssh_backend import (
    SSHPool,
    AsyncSSHBackend,
    RunHandle,
    SSHConnectionError,
    SSHExecutionError,
)


class TestSSHPoolConnectionReuse:
    """Test that pool reuses connections for the same host."""

    @pytest.mark.asyncio
    async def test_pool_reuses_connection(self):
        """Subsequent calls to get(same_host) return same connection."""
        with patch("rpipe.ssh_backend.asyncssh.connect") as mock_connect:
            mock_conn = MagicMock()
            # is_closing() must return a boolean, not an async result
            mock_conn.is_closing = MagicMock(return_value=False)
            # Make connect an async function that returns mock_conn
            async def async_connect(*args, **kwargs):
                return mock_conn
            mock_connect.side_effect = async_connect

            pool = SSHPool(known_hosts="/dev/null")

            # First call: creates connection
            conn1 = await pool.get("example.com")
            assert conn1 is mock_conn
            assert mock_connect.call_count == 1

            # Second call: reuses same connection
            conn2 = await pool.get("example.com")
            assert conn2 is mock_conn
            assert conn2 is conn1  # Same object
            assert mock_connect.call_count == 1  # No new connection created

    @pytest.mark.asyncio
    async def test_pool_different_hosts_different_connections(self):
        """Different hosts get different connections."""
        with patch("rpipe.ssh_backend.asyncssh.connect") as mock_connect:
            mock_conn1 = AsyncMock()
            mock_conn1.is_closing.return_value = False
            mock_conn2 = AsyncMock()
            mock_conn2.is_closing.return_value = False

            # Create async generator of connections
            conns = [mock_conn1, mock_conn2]
            async def async_connect(*args, **kwargs):
                return conns.pop(0)
            mock_connect.side_effect = async_connect

            pool = SSHPool(known_hosts="/dev/null")

            conn1 = await pool.get("host1.com")
            conn2 = await pool.get("host2.com")

            assert conn1 is mock_conn1
            assert conn2 is mock_conn2
            assert conn1 is not conn2
            assert mock_connect.call_count == 2

    @pytest.mark.asyncio
    async def test_pool_reconnects_on_stale(self):
        """Stale connections are replaced."""
        with patch("rpipe.ssh_backend.asyncssh.connect") as mock_connect:
            mock_conn1 = AsyncMock()
            mock_conn1.is_closing.return_value = False
            mock_conn2 = AsyncMock()
            mock_conn2.is_closing.return_value = False

            conns = [mock_conn1, mock_conn2]
            async def async_connect(*args, **kwargs):
                return conns.pop(0)
            mock_connect.side_effect = async_connect

            pool = SSHPool(known_hosts="/dev/null")

            # First connection
            conn1 = await pool.get("example.com")
            assert mock_connect.call_count == 1

            # Simulate connection becoming stale
            mock_conn1.is_closing.return_value = True

            # Next call should create new connection
            conn2 = await pool.get("example.com")
            assert conn2 is mock_conn2
            assert conn1 is not conn2
            assert mock_connect.call_count == 2


class TestSSHPoolErrors:
    """Test error handling in pool."""

    @pytest.mark.asyncio
    async def test_connection_timeout(self):
        """Timeout on SSH connect raises SSHConnectionError."""
        with patch("rpipe.ssh_backend.asyncssh.connect") as mock_connect:
            mock_connect.side_effect = asyncio.TimeoutError()

            pool = SSHPool(known_hosts="/dev/null", connect_timeout=1.0)

            with pytest.raises(SSHConnectionError, match="timed out"):
                await pool.get("example.com")

    @pytest.mark.asyncio
    async def test_connection_auth_failure(self):
        """Auth failure raises SSHConnectionError."""
        import asyncssh

        with patch("rpipe.ssh_backend.asyncssh.connect") as mock_connect:
            mock_connect.side_effect = asyncssh.PermissionDenied("Auth failed")

            pool = SSHPool(known_hosts="/dev/null")

            with pytest.raises(SSHConnectionError, match="failed"):
                await pool.get("example.com")


class TestAsyncSSHBackendExecution:
    """Test process execution via SSH backend."""

    @pytest.mark.asyncio
    async def test_simple_execution(self):
        """Execute a simple command on remote host."""
        with patch("rpipe.ssh_backend.asyncssh.connect") as mock_connect:
            # Mock connection
            mock_conn = AsyncMock()
            mock_conn.is_closing.return_value = False

            async def async_connect(*args, **kwargs):
                return mock_conn
            mock_connect.side_effect = async_connect

            # Mock process
            mock_proc = AsyncMock()
            mock_proc.exit_status = 0
            mock_conn.create_process.return_value = mock_proc

            pool = SSHPool(known_hosts="/dev/null")
            backend = AsyncSSHBackend(pool)

            # Run command
            handle = await backend.run(
                run_id=1,
                host="example.com",
                cmd="echo hello",
            )

            # Verify command was built correctly
            assert handle.run_id == 1
            assert handle.host == "example.com"
            assert handle.proc is mock_proc

            # Verify correct bash command was executed
            executed_cmd = mock_conn.create_process.call_args[0][0]
            assert "bash -lc" in executed_cmd
            assert "echo hello" in executed_cmd

    @pytest.mark.asyncio
    async def test_execution_with_cwd(self):
        """Execute command with working directory."""
        with patch("rpipe.ssh_backend.asyncssh.connect") as mock_connect:
            mock_conn = AsyncMock()
            mock_conn.is_closing.return_value = False

            async def async_connect(*args, **kwargs):
                return mock_conn
            mock_connect.side_effect = async_connect

            mock_proc = AsyncMock()
            mock_proc.exit_status = 0
            mock_conn.create_process.return_value = mock_proc

            pool = SSHPool(known_hosts="/dev/null")
            backend = AsyncSSHBackend(pool)

            # Run command with cwd
            handle = await backend.run(
                run_id=1,
                host="example.com",
                cmd="pwd",
                cwd="/var/log",
            )

            # Verify cd was added to command
            executed_cmd = mock_conn.create_process.call_args[0][0]
            assert "cd '/var/log'" in executed_cmd
            assert "pwd" in executed_cmd

    @pytest.mark.asyncio
    async def test_execution_with_env(self):
        """Execute command with environment variables."""
        with patch("rpipe.ssh_backend.asyncssh.connect") as mock_connect:
            mock_conn = AsyncMock()
            mock_conn.is_closing.return_value = False

            async def async_connect(*args, **kwargs):
                return mock_conn
            mock_connect.side_effect = async_connect

            mock_proc = AsyncMock()
            mock_proc.exit_status = 0
            mock_conn.create_process.return_value = mock_proc

            pool = SSHPool(known_hosts="/dev/null")
            backend = AsyncSSHBackend(pool)

            # Run command with env
            handle = await backend.run(
                run_id=1,
                host="example.com",
                cmd="echo $FOO",
                env={"FOO": "bar"},
            )

            # Verify export was added to command
            executed_cmd = mock_conn.create_process.call_args[0][0]
            assert "export FOO='bar'" in executed_cmd
            assert "echo $FOO" in executed_cmd


class TestRunHandle:
    """Test RunHandle methods."""

    @pytest.mark.asyncio
    async def test_wait_for_process(self):
        """wait() waits for process to complete."""
        mock_proc = AsyncMock()
        mock_proc.exit_status = 0
        mock_proc.wait.return_value = 0

        handle = RunHandle(run_id=1, host="example.com", proc=mock_proc, started_at=time.time())

        exit_code = await handle.wait()
        assert exit_code == 0
        mock_proc.wait.assert_called_once()

    def test_cancel_soft(self):
        """cancel(hard=False) sends SIGTERM."""
        mock_proc = MagicMock()
        handle = RunHandle(run_id=1, host="example.com", proc=mock_proc, started_at=time.time())

        handle.cancel(hard=False)
        mock_proc.terminate.assert_called_once()
        mock_proc.kill.assert_not_called()

    def test_cancel_hard(self):
        """cancel(hard=True) sends SIGKILL."""
        mock_proc = MagicMock()
        handle = RunHandle(run_id=1, host="example.com", proc=mock_proc, started_at=time.time())

        handle.cancel(hard=True)
        mock_proc.kill.assert_called_once()
        mock_proc.terminate.assert_not_called()

    def test_elapsed(self):
        """elapsed property returns time since start."""
        now = time.time()
        handle = RunHandle(run_id=1, host="example.com", proc=MagicMock(), started_at=now - 5.0)

        elapsed = handle.elapsed
        assert 4.9 < elapsed < 5.1  # Allow some timing variance


class TestShellQuoting:
    """Test shell quoting for safe interpolation."""

    def test_quote_simple_string(self):
        """Simple strings are quoted."""
        quoted = AsyncSSHBackend._sh_quote("hello")
        assert quoted == "'hello'"

    def test_quote_with_spaces(self):
        """Strings with spaces are quoted."""
        quoted = AsyncSSHBackend._sh_quote("hello world")
        assert quoted == "'hello world'"

    def test_quote_with_single_quote(self):
        """Single quotes inside are escaped."""
        quoted = AsyncSSHBackend._sh_quote("it's")
        assert quoted == "'it'\"'\"'s'"

    def test_quote_multiple_single_quotes(self):
        """Multiple single quotes are all escaped."""
        quoted = AsyncSSHBackend._sh_quote("can't won't")
        assert quoted == "'can'\"'\"'t won'\"'\"'t'"

    def test_quote_preserves_other_chars(self):
        """Other special chars are preserved inside quotes."""
        quoted = AsyncSSHBackend._sh_quote("$VAR `cmd` $(cmd)")
        assert quoted == "'$VAR `cmd` $(cmd)'"
