"""Tests for core tools (list_hosts, get_host, ssh_execute, ask_user, etc.)."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from merlya.tools.core import (
    ToolResult,
    ask_user,
    detect_unsafe_password,
    get_host,
    get_variable,
    list_hosts,
    request_confirmation,
    resolve_secrets,
    set_variable,
    ssh_execute,
)

# ==============================================================================
# Tests for resolve_secrets
# ==============================================================================


class TestResolveSecrets:
    """Tests for resolve_secrets function."""

    def test_resolve_single_secret(self) -> None:
        """Test resolving a single secret reference."""
        secrets = MagicMock()
        secrets.get.return_value = "secret_value"

        resolved, safe = resolve_secrets("echo @my-secret", secrets)

        assert resolved == "echo secret_value"
        assert safe == "echo ***"

    def test_resolve_multiple_secrets(self) -> None:
        """Test resolving multiple secret references."""
        secrets = MagicMock()
        secrets.get.side_effect = lambda name: {"api-key": "key123", "db-pass": "pass456"}.get(name)

        resolved, safe = resolve_secrets("curl -H 'Auth: @api-key' --data @db-pass", secrets)

        assert "key123" in resolved
        assert "pass456" in resolved
        assert "***" in safe
        assert "@api-key" not in safe

    def test_resolve_secret_not_found(self) -> None:
        """Test that unresolved secrets remain unchanged."""
        secrets = MagicMock()
        secrets.get.return_value = None

        resolved, _safe = resolve_secrets("echo @unknown-secret", secrets)

        # Secret reference stays as-is when not found
        assert "@unknown-secret" in resolved

    def test_resolve_email_not_matched(self) -> None:
        """Test that email addresses are not treated as secrets."""
        secrets = MagicMock()
        secrets.get.return_value = "SHOULD_NOT_APPEAR"

        resolved, _safe = resolve_secrets("git config user@github.com", secrets)

        # Email should not be treated as secret
        assert "user@github.com" in resolved

    def test_resolve_structured_secret(self) -> None:
        """Test resolving structured secret keys with colons."""
        secrets = MagicMock()
        secrets.get.return_value = "root_password"

        resolved, safe = resolve_secrets("sudo @sudo:hostname:password", secrets)

        assert resolved == "sudo root_password"
        assert safe == "sudo ***"

    def test_resolve_no_secrets(self) -> None:
        """Test command with no secret references."""
        secrets = MagicMock()

        resolved, safe = resolve_secrets("ls -la", secrets)

        assert resolved == "ls -la"
        assert safe == "ls -la"
        secrets.get.assert_not_called()

    def test_resolve_skips_known_hosts(self) -> None:
        """Test that known host names are not treated as secrets."""
        secrets = MagicMock()
        secrets.get.return_value = "SHOULD_NOT_APPEAR"
        known_hosts = {"pine64", "web-01", "db-server"}

        resolved, safe = resolve_secrets("ping @pine64", secrets, known_hosts)

        # pine64 should not be resolved as a secret
        assert resolved == "ping @pine64"
        assert safe == "ping @pine64"
        # secrets.get should not be called for known hosts
        secrets.get.assert_not_called()

    def test_resolve_skips_known_hosts_case_insensitive(self) -> None:
        """Test that known host names are matched case-insensitively.

        In real usage, known_hosts is built with both original and lowercase versions:
        known_host_names = {h.name for h in hosts} | {h.name.lower() for h in hosts}
        """
        secrets = MagicMock()
        secrets.get.return_value = "SHOULD_NOT_APPEAR"
        # Simulates real behavior: set contains both original and lowercase
        known_hosts = {"Pine64", "pine64", "WEB-01", "web-01"}

        resolved, safe = resolve_secrets("ping @pine64", secrets, known_hosts)

        # pine64 should not be resolved
        assert resolved == "ping @pine64"
        assert safe == "ping @pine64"

    def test_resolve_resolves_secrets_not_hosts(self) -> None:
        """Test that secrets are resolved but host names are not."""
        secrets = MagicMock()
        secrets.get.side_effect = lambda name: "secret_value" if name == "api-key" else None
        known_hosts = {"pine64", "web-01"}

        resolved, safe = resolve_secrets("curl -H 'Auth: @api-key' @pine64", secrets, known_hosts)

        # @api-key should be resolved, @pine64 should NOT
        assert resolved == "curl -H 'Auth: secret_value' @pine64"
        assert safe == "curl -H 'Auth: ***' @pine64"


# ==============================================================================
# Tests for resolve_host_references
# ==============================================================================


class TestResolveHostReferences:
    """Tests for resolve_host_references function."""

    @pytest.mark.asyncio
    async def test_resolve_from_inventory(self) -> None:
        """Test resolving @hostname from inventory."""
        from merlya.tools.core import resolve_host_references

        # Create mock hosts
        host = MagicMock()
        host.name = "pine64"
        host.hostname = "192.168.1.100"
        hosts = [host]

        result = await resolve_host_references("ping @pine64", hosts)

        assert result == "ping 192.168.1.100"

    @pytest.mark.asyncio
    async def test_resolve_from_inventory_case_insensitive(self) -> None:
        """Test that host resolution is case-insensitive."""
        from merlya.tools.core import resolve_host_references

        host = MagicMock()
        host.name = "Pine64"
        host.hostname = "192.168.1.100"
        hosts = [host]

        result = await resolve_host_references("ping @pine64", hosts)

        assert result == "ping 192.168.1.100"

    @pytest.mark.asyncio
    async def test_resolve_via_dns(self) -> None:
        """Test resolving @hostname via DNS when not in inventory."""
        from merlya.tools.core import resolve_host_references

        # Empty inventory, but google.com resolves via DNS
        result = await resolve_host_references("ping @google.com", [])

        # Should resolve to google.com (DNS worked)
        assert result == "ping google.com"

    @pytest.mark.asyncio
    async def test_resolve_with_user_prompt(self) -> None:
        """Test asking user when inventory and DNS both fail."""
        from merlya.tools.core import resolve_host_references

        ui = MagicMock()
        ui.prompt = AsyncMock(return_value="10.0.0.50")

        # Unknown host, won't resolve via DNS
        result = await resolve_host_references("ping @unknownhost12345", [], ui)

        assert result == "ping 10.0.0.50"
        ui.prompt.assert_called_once()

    @pytest.mark.asyncio
    async def test_no_resolution_keeps_original(self) -> None:
        """Test that unresolved hosts stay as @hostname."""
        from merlya.tools.core import resolve_host_references

        ui = MagicMock()
        ui.prompt = AsyncMock(return_value="")  # User provides nothing

        result = await resolve_host_references("ping @unknownhost12345", [], ui)

        # Should keep original since nothing resolved
        assert result == "ping @unknownhost12345"

    @pytest.mark.asyncio
    async def test_skip_structured_references(self) -> None:
        """Test that structured refs like @sudo:host:password are skipped."""
        from merlya.tools.core import resolve_host_references

        result = await resolve_host_references("echo @sudo:host:password", [])

        # Should not try to resolve structured refs (they're for secrets)
        assert result == "echo @sudo:host:password"


# ==============================================================================
# Tests for detect_unsafe_password
# ==============================================================================


class TestDetectUnsafePassword:
    """Tests for detect_unsafe_password function."""

    def test_detects_echo_sudo_pattern(self) -> None:
        """Test detection of echo password | sudo -S pattern."""
        result = detect_unsafe_password("echo 'mypassword' | sudo -S apt update")

        assert result is not None
        assert "SECURITY" in result

    def test_detects_password_flag_pattern(self) -> None:
        """Test detection of -p'password' pattern (quoted only to avoid false positives)."""
        result = detect_unsafe_password("mysql -p'MySecret123'")

        assert result is not None
        assert "SECURITY" in result

    def test_detects_password_equals_pattern(self) -> None:
        """Test detection of --password=value pattern."""
        result = detect_unsafe_password("mysql --password=secret123")

        assert result is not None
        assert "SECURITY" in result

    def test_allows_secret_reference_quoted_echo(self) -> None:
        """Test that quoted @secret references are allowed in echo pattern."""
        # The pattern requires space after echo, so direct @ doesn't match
        _result = detect_unsafe_password("echo '@db-password' | sudo -S apt update")

        # Note: Current implementation may still flag this - test actual behavior
        # The protection is about detecting obvious plaintext passwords
        # Secret references should be resolved BEFORE execution anyway

    def test_allows_password_equals_secret(self) -> None:
        """Test that --password=@secret is allowed."""
        result = detect_unsafe_password("mysql --password=@db-secret db_name")

        assert result is None

    def test_allows_safe_commands(self) -> None:
        """Test that safe commands pass."""
        result = detect_unsafe_password("ls -la /var/log")

        assert result is None


# ==============================================================================
# Tests for _is_ip helper
# ==============================================================================


class TestIsIPAddress:
    """Tests for is_ip_address helper function."""

    def test_valid_ipv4(self) -> None:
        """Test valid IPv4 address."""
        from merlya.tools.core.ssh_connection import is_ip_address

        assert is_ip_address("192.168.1.1") is True
        assert is_ip_address("10.0.0.1") is True
        assert is_ip_address("127.0.0.1") is True

    def test_valid_ipv6(self) -> None:
        """Test valid IPv6 address."""
        from merlya.tools.core.ssh_connection import is_ip_address

        assert is_ip_address("::1") is True
        assert is_ip_address("2001:db8::1") is True

    def test_invalid_ip(self) -> None:
        """Test invalid IP addresses."""
        from merlya.tools.core.ssh_connection import is_ip_address

        assert is_ip_address("hostname.example.com") is False
        assert is_ip_address("192.168.1.256") is False
        assert is_ip_address("not-an-ip") is False


# ==============================================================================
# Tests for explain_ssh_error
# ==============================================================================


class TestExplainSSHError:
    """Tests for explain_ssh_error function."""

    def test_timeout_error_errno_60(self) -> None:
        """Test explanation for timeout error (errno 60)."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("Connection timed out errno 60")
        result = explain_ssh_error(error, "host1")

        assert "timeout" in result.symptom.lower()
        assert result.suggestion

    def test_timeout_error_errno_110(self) -> None:
        """Test explanation for timeout error (errno 110)."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("errno 110 connection timed out")
        result = explain_ssh_error(error, "host1")

        assert "timeout" in result.symptom.lower()

    def test_connection_refused(self) -> None:
        """Test explanation for connection refused."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("Connection refused")
        result = explain_ssh_error(error, "host1")

        assert "refused" in result.symptom.lower()

    def test_no_route_to_host(self) -> None:
        """Test explanation for no route to host."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("No route to host")
        result = explain_ssh_error(error, "host1")

        assert "route" in result.symptom.lower()

    def test_dns_resolution_failed(self) -> None:
        """Test explanation for DNS failure."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("Name or service not known")
        result = explain_ssh_error(error, "unknown-host")

        assert "DNS" in result.symptom

    def test_authentication_failed(self) -> None:
        """Test explanation for auth failure."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("Authentication failed")
        result = explain_ssh_error(error, "host1")

        assert "Authentication" in result.symptom

    def test_host_key_verification_failed(self) -> None:
        """Test explanation for host key verification."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("Host key verification failed")
        result = explain_ssh_error(error, "host1")

        assert "key" in result.symptom.lower()

    def test_via_jump_host_in_message(self) -> None:
        """Test that jump host is mentioned when provided."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("Connection timed out")
        result = explain_ssh_error(error, "host1", via="jump-host")

        assert "jump-host" in result.suggestion

    def test_generic_error(self) -> None:
        """Test generic error fallback."""
        from merlya.tools.core.ssh_errors import explain_ssh_error

        error = Exception("Some unknown error")
        result = explain_ssh_error(error, "host1")

        assert result.symptom
        assert result.suggestion


# ==============================================================================
# Tests for list_hosts
# ==============================================================================


class TestListHosts:
    """Tests for list_hosts function."""

    @pytest.mark.asyncio
    async def test_list_all_hosts(self, mock_shared_context: MagicMock) -> None:
        """Test listing all hosts."""
        result = await list_hosts(mock_shared_context)

        assert result.success is True
        assert isinstance(result.data, list)
        assert len(result.data) == 4  # From mock_hosts_list fixture

    @pytest.mark.asyncio
    async def test_list_hosts_by_tag(self, mock_shared_context: MagicMock) -> None:
        """Test listing hosts by tag."""
        mock_shared_context.hosts.get_by_tag = AsyncMock(
            return_value=[
                mock_shared_context.hosts.get_all.return_value[0],  # web-01
                mock_shared_context.hosts.get_all.return_value[1],  # web-02
            ]
        )

        result = await list_hosts(mock_shared_context, tag="web")

        assert result.success is True
        mock_shared_context.hosts.get_by_tag.assert_called_once_with("web")

    @pytest.mark.asyncio
    async def test_list_hosts_by_status(self, mock_shared_context: MagicMock) -> None:
        """Test listing hosts filtered by status."""
        result = await list_hosts(mock_shared_context, status="healthy")

        assert result.success is True
        # All healthy hosts should be returned
        for host in result.data:
            assert host["status"] == "healthy"

    @pytest.mark.asyncio
    async def test_list_hosts_with_limit(self, mock_shared_context: MagicMock) -> None:
        """Test listing hosts with limit."""
        result = await list_hosts(mock_shared_context, limit=2)

        assert result.success is True
        assert len(result.data) == 2

    @pytest.mark.asyncio
    async def test_list_hosts_error(self, mock_shared_context: MagicMock) -> None:
        """Test list_hosts handles errors."""
        mock_shared_context.hosts.get_all = AsyncMock(side_effect=Exception("Database error"))

        result = await list_hosts(mock_shared_context)

        assert result.success is False
        assert "Database error" in result.error


# ==============================================================================
# Tests for get_host
# ==============================================================================


class TestGetHost:
    """Tests for get_host function."""

    @pytest.mark.asyncio
    async def test_get_host_found(self, mock_shared_context: MagicMock) -> None:
        """Test getting a host that exists."""
        result = await get_host(mock_shared_context, "web-01")

        assert result.success is True
        assert result.data["name"] == "web-01"
        assert "hostname" in result.data

    @pytest.mark.asyncio
    async def test_get_host_not_found(self, mock_shared_context: MagicMock) -> None:
        """Test getting a host that doesn't exist."""
        mock_shared_context.hosts.get_by_name = AsyncMock(return_value=None)

        result = await get_host(mock_shared_context, "nonexistent")

        assert result.success is False
        assert "not found" in result.error

    @pytest.mark.asyncio
    async def test_get_host_with_metadata(self, mock_shared_context: MagicMock) -> None:
        """Test getting host with metadata."""
        result = await get_host(mock_shared_context, "web-01", include_metadata=True)

        assert result.success is True
        assert "metadata" in result.data

    @pytest.mark.asyncio
    async def test_get_host_error(self, mock_shared_context: MagicMock) -> None:
        """Test get_host handles errors."""
        mock_shared_context.hosts.get_by_name = AsyncMock(side_effect=Exception("Error"))

        result = await get_host(mock_shared_context, "web-01")

        assert result.success is False


# ==============================================================================
# Tests for ask_user
# ==============================================================================


class TestAskUser:
    """Tests for ask_user function."""

    @pytest.mark.asyncio
    async def test_ask_user_simple_prompt(self, mock_shared_context: MagicMock) -> None:
        """Test simple prompt."""
        result = await ask_user(mock_shared_context, "What is your name?")

        assert result.success is True
        assert result.data == "test_input"

    @pytest.mark.asyncio
    async def test_ask_user_with_choices(self, mock_shared_context: MagicMock) -> None:
        """Test prompt with choices."""
        result = await ask_user(
            mock_shared_context,
            "Pick one:",
            choices=["a", "b", "c"],
        )

        assert result.success is True
        mock_shared_context.ui.prompt_choice.assert_called_once()

    @pytest.mark.asyncio
    async def test_ask_user_secret(self, mock_shared_context: MagicMock) -> None:
        """Test secret prompt."""
        result = await ask_user(
            mock_shared_context,
            "Password:",
            secret=True,
        )

        assert result.success is True
        assert result.data == "secret_value"
        mock_shared_context.ui.prompt_secret.assert_called_once()

    @pytest.mark.asyncio
    async def test_ask_user_with_default(self, mock_shared_context: MagicMock) -> None:
        """Test prompt with default value."""
        result = await ask_user(
            mock_shared_context,
            "Name:",
            default="default_value",
        )

        assert result.success is True
        mock_shared_context.ui.prompt.assert_called_with("Name:", "default_value")

    @pytest.mark.asyncio
    async def test_ask_user_error(self, mock_shared_context: MagicMock) -> None:
        """Test ask_user handles errors."""
        mock_shared_context.ui.prompt = AsyncMock(side_effect=Exception("UI error"))

        result = await ask_user(mock_shared_context, "Question?")

        assert result.success is False


# ==============================================================================
# Tests for request_confirmation
# ==============================================================================


class TestRequestConfirmation:
    """Tests for request_confirmation function."""

    @pytest.mark.asyncio
    async def test_confirmation_accepted(self, mock_shared_context: MagicMock) -> None:
        """Test confirmation when user accepts."""
        mock_shared_context.ui.prompt_confirm = AsyncMock(return_value=True)

        result = await request_confirmation(
            mock_shared_context,
            "Delete all files?",
        )

        assert result.success is True
        assert result.data is True

    @pytest.mark.asyncio
    async def test_confirmation_rejected(self, mock_shared_context: MagicMock) -> None:
        """Test confirmation when user rejects."""
        mock_shared_context.ui.prompt_confirm = AsyncMock(return_value=False)

        result = await request_confirmation(
            mock_shared_context,
            "Delete all files?",
        )

        assert result.success is True
        assert result.data is False

    @pytest.mark.asyncio
    async def test_confirmation_with_details(self, mock_shared_context: MagicMock) -> None:
        """Test confirmation with details."""
        result = await request_confirmation(
            mock_shared_context,
            "Restart service?",
            details="This will cause downtime",
            risk_level="high",
        )

        assert result.success is True
        mock_shared_context.ui.info.assert_called()

    @pytest.mark.asyncio
    async def test_confirmation_error(self, mock_shared_context: MagicMock) -> None:
        """Test confirmation handles errors."""
        mock_shared_context.ui.prompt_confirm = AsyncMock(side_effect=Exception("Error"))

        result = await request_confirmation(mock_shared_context, "Action?")

        assert result.success is False
        assert result.data is False


# ==============================================================================
# Tests for get_variable / set_variable
# ==============================================================================


class TestVariables:
    """Tests for variable operations."""

    @pytest.mark.asyncio
    async def test_get_variable_found(self, mock_shared_context: MagicMock) -> None:
        """Test getting an existing variable."""
        mock_var = MagicMock()
        mock_var.value = "my_value"
        mock_shared_context.variables.get = AsyncMock(return_value=mock_var)

        result = await get_variable(mock_shared_context, "my_var")

        assert result.success is True
        assert result.data == "my_value"

    @pytest.mark.asyncio
    async def test_get_variable_not_found(self, mock_shared_context: MagicMock) -> None:
        """Test getting a non-existent variable."""
        mock_shared_context.variables.get = AsyncMock(return_value=None)

        result = await get_variable(mock_shared_context, "unknown")

        assert result.success is False
        assert "not found" in result.error

    @pytest.mark.asyncio
    async def test_get_variable_error(self, mock_shared_context: MagicMock) -> None:
        """Test get_variable handles errors."""
        mock_shared_context.variables.get = AsyncMock(side_effect=Exception("Error"))

        result = await get_variable(mock_shared_context, "var")

        assert result.success is False

    @pytest.mark.asyncio
    async def test_set_variable(self, mock_shared_context: MagicMock) -> None:
        """Test setting a variable."""
        result = await set_variable(mock_shared_context, "my_var", "my_value")

        assert result.success is True
        mock_shared_context.variables.set.assert_called_once()

    @pytest.mark.asyncio
    async def test_set_variable_as_env(self, mock_shared_context: MagicMock) -> None:
        """Test setting a variable as environment variable."""
        result = await set_variable(
            mock_shared_context,
            "PATH_EXT",
            "/usr/local/bin",
            is_env=True,
        )

        assert result.success is True
        assert result.data["is_env"] is True

    @pytest.mark.asyncio
    async def test_set_variable_error(self, mock_shared_context: MagicMock) -> None:
        """Test set_variable handles errors."""
        mock_shared_context.variables.set = AsyncMock(side_effect=Exception("Error"))

        result = await set_variable(mock_shared_context, "var", "val")

        assert result.success is False


# ==============================================================================
# Tests for ssh_execute
# ==============================================================================


class TestSSHExecute:
    """Tests for ssh_execute function."""

    @pytest.mark.asyncio
    async def test_ssh_execute_success(self, mock_shared_context: MagicMock) -> None:
        """Test successful SSH command execution."""
        from merlya.ssh.pool import SSHResult

        mock_pool = MagicMock()
        mock_pool.execute = AsyncMock(
            return_value=SSHResult(
                stdout="output",
                stderr="",
                exit_code=0,
            )
        )
        mock_pool.has_passphrase_callback = MagicMock(return_value=True)
        mock_pool.has_mfa_callback = MagicMock(return_value=True)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=mock_pool)

        result = await ssh_execute(mock_shared_context, "web-01", "ls -la")

        assert result.success is True
        assert result.data["stdout"] == "output"

    @pytest.mark.asyncio
    async def test_ssh_execute_host_not_in_inventory_tries_direct(
        self, mock_shared_context: MagicMock
    ) -> None:
        """Test SSH execute with host not in inventory attempts direct connection.

        PROACTIVE MODE: Hosts not in inventory should be tried directly,
        not rejected. The connection may fail, but we should attempt it.
        """
        from merlya.ssh.pool import SSHResult

        mock_shared_context.hosts.get_by_name = AsyncMock(return_value=None)

        mock_pool = MagicMock()
        mock_pool.execute = AsyncMock(
            return_value=SSHResult(
                stdout="connected",
                stderr="",
                exit_code=0,
            )
        )
        mock_pool.has_passphrase_callback = MagicMock(return_value=True)
        mock_pool.has_mfa_callback = MagicMock(return_value=True)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=mock_pool)

        result = await ssh_execute(mock_shared_context, "unknown-host", "ls")

        # Proactive mode: should attempt connection, not fail immediately
        assert result.success is True
        assert result.data["stdout"] == "connected"
        # Verify the pool.execute was called with the hostname directly
        mock_pool.execute.assert_called_once()

    @pytest.mark.asyncio
    async def test_ssh_execute_direct_ip(self, mock_shared_context: MagicMock) -> None:
        """Test SSH execute with direct IP address."""
        from merlya.ssh.pool import SSHResult

        mock_shared_context.hosts.get_by_name = AsyncMock(return_value=None)

        mock_pool = MagicMock()
        mock_pool.execute = AsyncMock(
            return_value=SSHResult(
                stdout="ok",
                stderr="",
                exit_code=0,
            )
        )
        mock_pool.has_passphrase_callback = MagicMock(return_value=True)
        mock_pool.has_mfa_callback = MagicMock(return_value=True)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=mock_pool)

        result = await ssh_execute(mock_shared_context, "192.168.1.100", "whoami")

        assert result.success is True

    @pytest.mark.asyncio
    async def test_ssh_execute_with_secret(self, mock_shared_context: MagicMock) -> None:
        """Test SSH execute with secret resolution."""
        from merlya.ssh.pool import SSHResult

        mock_shared_context.secrets.get = MagicMock(return_value="api_key_value")

        mock_pool = MagicMock()
        mock_pool.execute = AsyncMock(
            return_value=SSHResult(
                stdout="authenticated",
                stderr="",
                exit_code=0,
            )
        )
        mock_pool.has_passphrase_callback = MagicMock(return_value=True)
        mock_pool.has_mfa_callback = MagicMock(return_value=True)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=mock_pool)

        # Use a command that won't trigger password detection
        result = await ssh_execute(
            mock_shared_context, "web-01", "curl -H 'Auth: @api-key' https://api.example.com"
        )

        assert result.success is True

    @pytest.mark.asyncio
    async def test_ssh_execute_unsafe_password_blocked(
        self, mock_shared_context: MagicMock
    ) -> None:
        """Test that unsafe password patterns are blocked."""
        result = await ssh_execute(
            mock_shared_context,
            "web-01",
            "echo 'mypassword' | sudo -S apt update",
        )

        assert result.success is False
        assert "SECURITY" in result.error

    @pytest.mark.asyncio
    async def test_ssh_execute_command_failure(self, mock_shared_context: MagicMock) -> None:
        """Test SSH execute when command fails."""
        from merlya.ssh.pool import SSHResult

        mock_pool = MagicMock()
        mock_pool.execute = AsyncMock(
            return_value=SSHResult(
                stdout="",
                stderr="command not found",
                exit_code=127,
            )
        )
        mock_pool.has_passphrase_callback = MagicMock(return_value=True)
        mock_pool.has_mfa_callback = MagicMock(return_value=True)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=mock_pool)

        result = await ssh_execute(mock_shared_context, "web-01", "nonexistent_cmd")

        assert result.success is False
        assert result.data["exit_code"] == 127

    @pytest.mark.asyncio
    async def test_ssh_execute_connection_error(self, mock_shared_context: MagicMock) -> None:
        """Test SSH execute handles connection errors."""
        mock_pool = MagicMock()
        mock_pool.execute = AsyncMock(side_effect=Exception("Connection timed out"))
        mock_pool.has_passphrase_callback = MagicMock(return_value=True)
        mock_pool.has_mfa_callback = MagicMock(return_value=True)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=mock_pool)

        result = await ssh_execute(mock_shared_context, "web-01", "ls")

        assert result.success is False
        assert "symptom" in result.data

    @pytest.mark.asyncio
    async def test_ssh_execute_with_jump_host(self, mock_shared_context: MagicMock) -> None:
        """Test SSH execute via jump host."""
        from merlya.ssh.pool import SSHResult

        mock_pool = MagicMock()
        mock_pool.execute = AsyncMock(
            return_value=SSHResult(
                stdout="connected via jump",
                stderr="",
                exit_code=0,
            )
        )
        mock_pool.has_passphrase_callback = MagicMock(return_value=True)
        mock_pool.has_mfa_callback = MagicMock(return_value=True)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=mock_pool)

        result = await ssh_execute(mock_shared_context, "web-01", "whoami", via="bastion")

        assert result.success is True
        assert result.data["via"] == "bastion"

    @pytest.mark.asyncio
    async def test_ssh_execute_auto_elevates_on_stdout_permission_denied(
        self, mock_shared_context: MagicMock
    ) -> None:
        """Permission errors may appear in stdout (e.g., journalctl); auto-elevation must still trigger."""
        from merlya.ssh.pool import SSHResult
        from merlya.tools.core import ssh as ssh_module

        mock_shared_context.hosts.get_by_name = AsyncMock(return_value=None)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=MagicMock())

        denied = SSHResult(
            stdout="Hint: You are not authorized to view the system journal.",
            stderr="",
            exit_code=1,
        )
        elevated_ok = SSHResult(stdout="ok", stderr="", exit_code=0)

        execute_ssh_command = AsyncMock(return_value=denied)
        execute_with_elevation = AsyncMock(return_value=(elevated_ok, "sudo_with_password"))

        with (
            patch.object(ssh_module, "execute_ssh_command", execute_ssh_command),
            patch.object(ssh_module, "execute_with_elevation", execute_with_elevation),
        ):
            result = await ssh_execute(
                mock_shared_context, "192.168.1.7", "journalctl -xe --no-pager"
            )

        assert result.success is True
        assert result.data["stdout"] == "ok"
        assert result.data["elevation"] == "sudo_with_password"
        execute_with_elevation.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_ssh_execute_auto_elevates_on_privileged_command_silent_failure(
        self, mock_shared_context: MagicMock
    ) -> None:
        """If a privileged command fails without explicit stderr, ssh_execute should still attempt elevation."""
        from merlya.ssh.pool import SSHResult
        from merlya.tools.core import ssh as ssh_module

        mock_shared_context.hosts.get_by_name = AsyncMock(return_value=None)
        mock_shared_context.get_ssh_pool = AsyncMock(return_value=MagicMock())

        class _Perms:
            def requires_elevation(self, _cmd: str) -> bool:
                return True

        mock_shared_context.get_permissions = AsyncMock(return_value=_Perms())

        denied = SSHResult(stdout="", stderr="", exit_code=1)
        elevated_ok = SSHResult(stdout="ok", stderr="", exit_code=0)

        execute_ssh_command = AsyncMock(return_value=denied)
        execute_with_elevation = AsyncMock(return_value=(elevated_ok, "sudo"))

        with (
            patch.object(ssh_module, "execute_ssh_command", execute_ssh_command),
            patch.object(ssh_module, "execute_with_elevation", execute_with_elevation),
        ):
            result = await ssh_execute(
                mock_shared_context, "192.168.1.7", "journalctl -xe --no-pager"
            )

        assert result.success is True
        assert result.data["stdout"] == "ok"
        assert result.data["elevation"] == "sudo"
        execute_with_elevation.assert_awaited_once()


# ==============================================================================
# Tests for ensure_callbacks
# ==============================================================================


class TestEnsureCallbacks:
    """Tests for ensure_callbacks function."""

    def test_sets_passphrase_callback(self, mock_shared_context: MagicMock) -> None:
        """Test that passphrase callback is set."""
        from merlya.tools.core.ssh_connection import ensure_callbacks

        mock_pool = MagicMock()
        mock_pool.has_passphrase_callback.return_value = False
        mock_pool.has_mfa_callback.return_value = True

        ensure_callbacks(mock_shared_context, mock_pool)

        mock_pool.set_passphrase_callback.assert_called_once()

    def test_sets_mfa_callback(self, mock_shared_context: MagicMock) -> None:
        """Test that MFA callback is set."""
        from merlya.tools.core.ssh_connection import ensure_callbacks

        mock_pool = MagicMock()
        mock_pool.has_passphrase_callback.return_value = True
        mock_pool.has_mfa_callback.return_value = False

        ensure_callbacks(mock_shared_context, mock_pool)

        mock_pool.set_mfa_callback.assert_called_once()

    def test_skips_if_callbacks_already_set(self, mock_shared_context: MagicMock) -> None:
        """Test that callbacks are not set if already present."""
        from merlya.tools.core.ssh_connection import ensure_callbacks

        mock_pool = MagicMock()
        mock_pool.has_passphrase_callback.return_value = True
        mock_pool.has_mfa_callback.return_value = True

        ensure_callbacks(mock_shared_context, mock_pool)

        mock_pool.set_passphrase_callback.assert_not_called()
        mock_pool.set_mfa_callback.assert_not_called()


# ==============================================================================
# Tests for ToolResult dataclass
# ==============================================================================


class TestToolResult:
    """Tests for ToolResult dataclass."""

    def test_success_result(self) -> None:
        """Test creating success result."""
        result = ToolResult(success=True, data={"key": "value"})

        assert result.success is True
        assert result.data == {"key": "value"}
        assert result.error is None

    def test_failure_result(self) -> None:
        """Test creating failure result."""
        result = ToolResult(success=False, data=None, error="Something went wrong")

        assert result.success is False
        assert result.error == "Something went wrong"


# ==============================================================================
# Tests for bash_execute (local command execution)
# ==============================================================================


class TestBashExecute:
    """Tests for bash_execute function."""

    @pytest.fixture
    def mock_context(self) -> MagicMock:
        """Create mock SharedContext for bash tests."""
        ctx = MagicMock()
        ctx.secrets = MagicMock()
        ctx.secrets.get.return_value = None  # No secrets by default
        # Mock hosts.get_all() to return empty list (no hosts to confuse with secrets)
        ctx.hosts = MagicMock()
        ctx.hosts.get_all = AsyncMock(return_value=[])
        # Mock UI for host resolution prompts
        ctx.ui = MagicMock()
        ctx.ui.prompt = AsyncMock(return_value="")  # Empty = no user input
        return ctx

    @pytest.mark.asyncio
    async def test_bash_execute_success(self, mock_context: MagicMock) -> None:
        """Test successful local command execution."""
        from merlya.tools.core import bash_execute

        result = await bash_execute(mock_context, "echo 'hello world'", timeout=10)

        assert result.success is True
        assert "hello world" in result.data["stdout"]
        assert result.data["exit_code"] == 0

    @pytest.mark.asyncio
    async def test_bash_execute_command_failure(self, mock_context: MagicMock) -> None:
        """Test command that returns non-zero exit code."""
        from merlya.tools.core import bash_execute

        result = await bash_execute(mock_context, "exit 1", timeout=10)

        assert result.success is False
        assert result.data["exit_code"] == 1

    @pytest.mark.asyncio
    async def test_bash_execute_empty_command(self, mock_context: MagicMock) -> None:
        """Test that empty command is rejected."""
        from merlya.tools.core import bash_execute

        result = await bash_execute(mock_context, "", timeout=10)

        assert result.success is False
        assert "empty" in result.error.lower()

    @pytest.mark.asyncio
    async def test_bash_execute_invalid_timeout(self, mock_context: MagicMock) -> None:
        """Test that invalid timeout is rejected."""
        from merlya.tools.core import bash_execute

        result = await bash_execute(mock_context, "echo test", timeout=0)

        assert result.success is False
        assert "timeout" in result.error.lower()

    @pytest.mark.asyncio
    async def test_bash_execute_dangerous_command_blocked(self, mock_context: MagicMock) -> None:
        """Test that dangerous commands are blocked."""
        from merlya.tools.core import bash_execute

        result = await bash_execute(mock_context, "rm -rf /", timeout=10)

        assert result.success is False
        assert "SECURITY" in result.error

    @pytest.mark.asyncio
    async def test_bash_execute_with_secret(self, mock_context: MagicMock) -> None:
        """Test command with secret resolution."""
        from merlya.tools.core import bash_execute

        mock_context.secrets.get.return_value = "secret_value"

        result = await bash_execute(mock_context, "echo @test-secret", timeout=10)

        assert result.success is True
        # Secret should be resolved in actual command
        assert "secret_value" in result.data["stdout"]

    @pytest.mark.asyncio
    async def test_bash_execute_captures_stderr(self, mock_context: MagicMock) -> None:
        """Test that stderr is captured."""
        from merlya.tools.core import bash_execute

        result = await bash_execute(mock_context, "echo 'error' >&2", timeout=10)

        assert result.success is True
        assert "error" in result.data["stderr"]
