"""Unit tests for DockerExecutor."""

import asyncio
import os
from pathlib import Path
from unittest.mock import MagicMock, patch, AsyncMock

import pytest


# Patch the logger before importing the executor
with patch("app.utils.logger.get_logger", return_value=MagicMock()):
    from app.executors.docker_executor import DockerExecutor


class TestDockerExecutor:
    """Tests for DockerExecutor."""

    @pytest.fixture
    def executor(self):
        """Create executor with mocked logger."""
        with patch("app.executors.base.get_logger", return_value=MagicMock()):
            return DockerExecutor()

    @pytest.fixture
    def temp_stacks_root(self, tmp_path):
        """Create a temporary stacks root directory."""
        stacks_dir = tmp_path / "opt" / "letsbe" / "stacks"
        stacks_dir.mkdir(parents=True)
        return stacks_dir

    @pytest.fixture
    def mock_settings(self, temp_stacks_root):
        """Mock settings with temporary paths."""
        settings = MagicMock()
        settings.allowed_stacks_root = str(temp_stacks_root)
        return settings

    @pytest.fixture
    def mock_get_settings(self, mock_settings):
        """Patch get_settings to return mock settings."""
        with patch("app.executors.docker_executor.get_settings", return_value=mock_settings):
            yield mock_settings

    @pytest.fixture
    def sample_compose_content(self):
        """Sample docker-compose.yml content."""
        return """version: '3.8'
services:
  app:
    image: nginx:latest
    ports:
      - "80:80"
"""

    @pytest.fixture
    def stack_with_docker_compose_yml(self, temp_stacks_root, sample_compose_content):
        """Create a stack with docker-compose.yml."""
        stack_dir = temp_stacks_root / "myapp"
        stack_dir.mkdir()
        compose_file = stack_dir / "docker-compose.yml"
        compose_file.write_text(sample_compose_content)
        return stack_dir

    @pytest.fixture
    def stack_with_compose_yml(self, temp_stacks_root, sample_compose_content):
        """Create a stack with compose.yml (no docker-compose.yml)."""
        stack_dir = temp_stacks_root / "otherapp"
        stack_dir.mkdir()
        compose_file = stack_dir / "compose.yml"
        compose_file.write_text(sample_compose_content)
        return stack_dir

    @pytest.fixture
    def stack_without_compose(self, temp_stacks_root):
        """Create a stack without any compose file."""
        stack_dir = temp_stacks_root / "emptyapp"
        stack_dir.mkdir()
        return stack_dir

    # =========================================================================
    # SUCCESS CASES
    # =========================================================================

    @pytest.mark.asyncio
    async def test_success_with_docker_compose_yml(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test successful reload with docker-compose.yml."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (0, "Container started", "")

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
            })

        assert result.success is True
        assert result.data["compose_dir"] == str(stack_with_docker_compose_yml)
        assert result.data["compose_file"] == str(stack_with_docker_compose_yml / "docker-compose.yml")
        assert result.data["pull_ran"] is False
        assert "up" in result.data["logs"]

        # Verify only 'up' command was called
        mock_run.assert_called_once()
        call_args = mock_run.call_args
        assert call_args[0][2] == ["up", "-d", "--remove-orphans"]

    @pytest.mark.asyncio
    async def test_success_with_compose_yml_fallback(
        self, executor, mock_get_settings, stack_with_compose_yml
    ):
        """Test successful reload with compose.yml fallback."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (0, "Container started", "")

            result = await executor.execute({
                "compose_dir": str(stack_with_compose_yml),
            })

        assert result.success is True
        assert result.data["compose_file"] == str(stack_with_compose_yml / "compose.yml")
        assert result.data["pull_ran"] is False

    @pytest.mark.asyncio
    async def test_docker_compose_yml_preferred_over_compose_yml(
        self, executor, mock_get_settings, temp_stacks_root, sample_compose_content
    ):
        """Test that docker-compose.yml is preferred over compose.yml."""
        stack_dir = temp_stacks_root / "bothfiles"
        stack_dir.mkdir()
        (stack_dir / "docker-compose.yml").write_text(sample_compose_content)
        (stack_dir / "compose.yml").write_text(sample_compose_content)

        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (0, "", "")

            result = await executor.execute({
                "compose_dir": str(stack_dir),
            })

        assert result.success is True
        assert result.data["compose_file"] == str(stack_dir / "docker-compose.yml")

    # =========================================================================
    # PULL PARAMETER TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_pull_false_only_up_called(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test that pull=false only runs 'up' command."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (0, "", "")

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
                "pull": False,
            })

        assert result.success is True
        assert result.data["pull_ran"] is False
        assert "pull" not in result.data["logs"]
        assert "up" in result.data["logs"]

        # Only one call (up)
        assert mock_run.call_count == 1
        call_args = mock_run.call_args
        assert call_args[0][2] == ["up", "-d", "--remove-orphans"]

    @pytest.mark.asyncio
    async def test_pull_true_both_commands_called(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test that pull=true runs both 'pull' and 'up' commands."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (0, "output", "")

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
                "pull": True,
            })

        assert result.success is True
        assert result.data["pull_ran"] is True
        assert "pull" in result.data["logs"]
        assert "up" in result.data["logs"]

        # Two calls: pull then up
        assert mock_run.call_count == 2
        calls = mock_run.call_args_list
        assert calls[0][0][2] == ["pull"]
        assert calls[1][0][2] == ["up", "-d", "--remove-orphans"]

    @pytest.mark.asyncio
    async def test_pull_fails_stops_execution(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test that pull failure stops execution before 'up'."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (1, "", "Error pulling images")

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
                "pull": True,
            })

        assert result.success is False
        assert result.data["pull_ran"] is True
        assert "pull" in result.data["logs"]
        assert "up" not in result.data["logs"]
        assert "pull failed" in result.error.lower()

        # Only one call (pull)
        assert mock_run.call_count == 1

    # =========================================================================
    # FAILURE CASES
    # =========================================================================

    @pytest.mark.asyncio
    async def test_missing_compose_file(
        self, executor, mock_get_settings, stack_without_compose
    ):
        """Test failure when no compose file is found."""
        result = await executor.execute({
            "compose_dir": str(stack_without_compose),
        })

        assert result.success is False
        assert "No compose file found" in result.error
        assert "docker-compose.yml" in result.error
        assert "compose.yml" in result.error

    @pytest.mark.asyncio
    async def test_up_command_fails(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test failure when 'up' command fails."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (1, "", "Error: container crashed")

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
            })

        assert result.success is False
        assert "Docker up failed" in result.error
        assert "up" in result.data["logs"]

    @pytest.mark.asyncio
    async def test_missing_compose_dir_parameter(self, executor, mock_get_settings):
        """Test failure when compose_dir is missing from payload."""
        with pytest.raises(ValueError, match="Missing required fields: compose_dir"):
            await executor.execute({})

    # =========================================================================
    # PATH SECURITY TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_reject_path_outside_allowed_root(
        self, executor, mock_get_settings, tmp_path
    ):
        """Test rejection of compose_dir outside allowed stacks root."""
        outside_dir = tmp_path / "outside"
        outside_dir.mkdir()
        (outside_dir / "docker-compose.yml").write_text("version: '3'\n")

        result = await executor.execute({
            "compose_dir": str(outside_dir),
        })

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

    @pytest.mark.asyncio
    async def test_reject_path_traversal_attack(
        self, executor, mock_get_settings, temp_stacks_root
    ):
        """Test rejection of path traversal attempts."""
        malicious_path = str(temp_stacks_root / ".." / ".." / "etc")

        result = await executor.execute({
            "compose_dir": malicious_path,
        })

        assert result.success is False
        assert "traversal" in result.error.lower() or "validation" in result.error.lower()

    @pytest.mark.asyncio
    async def test_reject_nonexistent_directory(
        self, executor, mock_get_settings, temp_stacks_root
    ):
        """Test rejection of nonexistent directory."""
        result = await executor.execute({
            "compose_dir": str(temp_stacks_root / "doesnotexist"),
        })

        assert result.success is False
        assert "validation failed" in result.error.lower() or "does not exist" in result.error.lower()

    @pytest.mark.asyncio
    async def test_reject_file_instead_of_directory(
        self, executor, mock_get_settings, temp_stacks_root
    ):
        """Test rejection when compose_dir points to a file instead of directory."""
        file_path = temp_stacks_root / "notadir.yml"
        file_path.write_text("version: '3'\n")

        result = await executor.execute({
            "compose_dir": str(file_path),
        })

        assert result.success is False
        assert "not a directory" in result.error.lower() or "validation" in result.error.lower()

    # =========================================================================
    # TIMEOUT AND ERROR HANDLING TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_timeout_handling(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test timeout handling."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.side_effect = asyncio.TimeoutError()

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
                "timeout": 10,
            })

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

    @pytest.mark.asyncio
    async def test_unexpected_exception_handling(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test handling of unexpected exceptions."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.side_effect = RuntimeError("Unexpected error")

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
            })

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

    # =========================================================================
    # OUTPUT STRUCTURE TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_result_structure_on_success(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test that result has correct structure on success."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (0, "stdout content", "stderr content")

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
                "pull": True,
            })

        assert result.success is True
        assert "compose_dir" in result.data
        assert "compose_file" in result.data
        assert "pull_ran" in result.data
        assert "logs" in result.data
        assert isinstance(result.data["logs"], dict)
        assert "pull" in result.data["logs"]
        assert "up" in result.data["logs"]
        assert result.duration_ms is not None
        assert result.error is None

    @pytest.mark.asyncio
    async def test_logs_combine_stdout_stderr(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test that logs contain both stdout and stderr."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (0, "stdout line", "stderr line")

            result = await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
            })

        assert "stdout line" in result.data["logs"]["up"]
        assert "stderr line" in result.data["logs"]["up"]

    # =========================================================================
    # INTERNAL METHOD TESTS
    # =========================================================================

    def test_find_compose_file_docker_compose_yml(
        self, executor, stack_with_docker_compose_yml
    ):
        """Test _find_compose_file finds docker-compose.yml."""
        result = executor._find_compose_file(stack_with_docker_compose_yml)
        assert result == stack_with_docker_compose_yml / "docker-compose.yml"

    def test_find_compose_file_compose_yml(
        self, executor, stack_with_compose_yml
    ):
        """Test _find_compose_file finds compose.yml."""
        result = executor._find_compose_file(stack_with_compose_yml)
        assert result == stack_with_compose_yml / "compose.yml"

    def test_find_compose_file_not_found(
        self, executor, stack_without_compose
    ):
        """Test _find_compose_file returns None when not found."""
        result = executor._find_compose_file(stack_without_compose)
        assert result is None

    def test_combine_output_both_present(self, executor):
        """Test _combine_output with both stdout and stderr."""
        result = executor._combine_output("stdout", "stderr")
        assert result == "stdout\nstderr"

    def test_combine_output_stdout_only(self, executor):
        """Test _combine_output with only stdout."""
        result = executor._combine_output("stdout", "")
        assert result == "stdout"

    def test_combine_output_stderr_only(self, executor):
        """Test _combine_output with only stderr."""
        result = executor._combine_output("", "stderr")
        assert result == "stderr"

    def test_combine_output_both_empty(self, executor):
        """Test _combine_output with empty strings."""
        result = executor._combine_output("", "")
        assert result == ""

    # =========================================================================
    # TASK TYPE TEST
    # =========================================================================

    def test_task_type(self, executor):
        """Test task_type property."""
        assert executor.task_type == "DOCKER_RELOAD"

    # =========================================================================
    # CUSTOM TIMEOUT TEST
    # =========================================================================

    @pytest.mark.asyncio
    async def test_custom_timeout_passed_to_command(
        self, executor, mock_get_settings, stack_with_docker_compose_yml
    ):
        """Test that custom timeout is passed to subprocess."""
        with patch.object(executor, "_run_compose_command") as mock_run:
            mock_run.return_value = (0, "", "")

            await executor.execute({
                "compose_dir": str(stack_with_docker_compose_yml),
                "timeout": 120,
            })

        call_args = mock_run.call_args
        assert call_args[0][3] == 120  # timeout argument
