"""Unit tests for CompositeExecutor."""

import pytest
from unittest.mock import MagicMock, patch, AsyncMock

from app.executors.base import ExecutionResult


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


class TestCompositeExecutor:
    """Tests for CompositeExecutor."""

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

    def _create_mock_executor(self, success: bool, data: dict, error: str | None = None):
        """Create a mock executor that returns specified result."""
        mock_executor = MagicMock()
        mock_executor.execute = AsyncMock(return_value=ExecutionResult(
            success=success,
            data=data,
            error=error,
        ))
        return mock_executor

    # =========================================================================
    # HAPPY PATH TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_two_steps_both_succeed(self, executor):
        """Test successful execution of two steps."""
        mock_env_executor = self._create_mock_executor(
            success=True,
            data={"updated_keys": ["API_KEY"], "removed_keys": [], "path": "/opt/letsbe/env/app.env"},
        )
        mock_docker_executor = self._create_mock_executor(
            success=True,
            data={"compose_dir": "/opt/letsbe/stacks/myapp", "pull_ran": True, "logs": {}},
        )

        def mock_get_executor(task_type: str):
            if task_type == "ENV_UPDATE":
                return mock_env_executor
            elif task_type == "DOCKER_RELOAD":
                return mock_docker_executor
            raise ValueError(f"Unknown task type: {task_type}")

        with patch("app.executors.get_executor", side_effect=mock_get_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ENV_UPDATE", "payload": {"path": "/opt/letsbe/env/app.env", "updates": {"API_KEY": "secret"}}},
                    {"type": "DOCKER_RELOAD", "payload": {"compose_dir": "/opt/letsbe/stacks/myapp", "pull": True}},
                ]
            })

        assert result.success is True
        assert result.error is None
        assert len(result.data["steps"]) == 2

        # Verify first step
        assert result.data["steps"][0]["index"] == 0
        assert result.data["steps"][0]["type"] == "ENV_UPDATE"
        assert result.data["steps"][0]["status"] == "completed"
        assert result.data["steps"][0]["result"]["updated_keys"] == ["API_KEY"]

        # Verify second step
        assert result.data["steps"][1]["index"] == 1
        assert result.data["steps"][1]["type"] == "DOCKER_RELOAD"
        assert result.data["steps"][1]["status"] == "completed"
        assert result.data["steps"][1]["result"]["compose_dir"] == "/opt/letsbe/stacks/myapp"

    @pytest.mark.asyncio
    async def test_single_step_succeeds(self, executor):
        """Test successful execution of single step."""
        mock_executor = self._create_mock_executor(
            success=True,
            data={"written": True, "path": "/opt/letsbe/env/test.env", "size": 100},
        )

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "FILE_WRITE", "payload": {"path": "/opt/letsbe/env/test.env", "content": "KEY=value"}},
                ]
            })

        assert result.success is True
        assert len(result.data["steps"]) == 1
        assert result.data["steps"][0]["status"] == "completed"

    @pytest.mark.asyncio
    async def test_three_steps_all_succeed(self, executor):
        """Test successful execution of three steps."""
        mock_executor = self._create_mock_executor(success=True, data={"success": True})

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "FILE_WRITE", "payload": {}},
                    {"type": "ENV_UPDATE", "payload": {}},
                    {"type": "DOCKER_RELOAD", "payload": {}},
                ]
            })

        assert result.success is True
        assert len(result.data["steps"]) == 3
        assert all(s["status"] == "completed" for s in result.data["steps"])

    # =========================================================================
    # FAILURE HANDLING TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_first_step_fails_stops_execution(self, executor):
        """Test that first step failure stops execution."""
        mock_executor = self._create_mock_executor(
            success=False,
            data={"partial": "data"},
            error="Validation failed: invalid key format",
        )

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ENV_UPDATE", "payload": {}},
                    {"type": "DOCKER_RELOAD", "payload": {}},  # Should NOT be called
                ]
            })

        assert result.success is False
        assert "Step 0 (ENV_UPDATE) failed" in result.error
        assert "invalid key format" in result.error
        assert len(result.data["steps"]) == 1  # Only first step
        assert result.data["steps"][0]["status"] == "failed"
        assert result.data["steps"][0]["error"] == "Validation failed: invalid key format"

    @pytest.mark.asyncio
    async def test_second_step_fails_preserves_first_result(self, executor):
        """Test that second step failure preserves first step result."""
        mock_env_executor = self._create_mock_executor(
            success=True,
            data={"updated_keys": ["KEY1"]},
        )
        mock_docker_executor = self._create_mock_executor(
            success=False,
            data={},
            error="No compose file found",
        )

        call_count = [0]

        def mock_get_executor(task_type: str):
            call_count[0] += 1
            if task_type == "ENV_UPDATE":
                return mock_env_executor
            elif task_type == "DOCKER_RELOAD":
                return mock_docker_executor
            raise ValueError(f"Unknown task type: {task_type}")

        with patch("app.executors.get_executor", side_effect=mock_get_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ENV_UPDATE", "payload": {}},
                    {"type": "DOCKER_RELOAD", "payload": {}},
                ]
            })

        assert result.success is False
        assert "Step 1 (DOCKER_RELOAD) failed" in result.error
        assert len(result.data["steps"]) == 2

        # First step completed
        assert result.data["steps"][0]["index"] == 0
        assert result.data["steps"][0]["status"] == "completed"
        assert result.data["steps"][0]["result"]["updated_keys"] == ["KEY1"]

        # Second step failed
        assert result.data["steps"][1]["index"] == 1
        assert result.data["steps"][1]["status"] == "failed"
        assert result.data["steps"][1]["error"] == "No compose file found"

    @pytest.mark.asyncio
    async def test_executor_raises_exception(self, executor):
        """Test handling of executor that raises exception."""
        mock_executor = MagicMock()
        mock_executor.execute = AsyncMock(side_effect=RuntimeError("Unexpected database error"))

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ENV_UPDATE", "payload": {}},
                ]
            })

        assert result.success is False
        assert "Step 0 (ENV_UPDATE) failed" in result.error
        assert "Unexpected database error" in result.error
        assert len(result.data["steps"]) == 1
        assert result.data["steps"][0]["status"] == "failed"
        assert "Unexpected database error" in result.data["steps"][0]["error"]

    # =========================================================================
    # VALIDATION TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_empty_steps_validation_error(self, executor):
        """Test that empty steps list fails validation."""
        result = await executor.execute({"steps": []})

        assert result.success is False
        assert "cannot be empty" in result.error
        assert result.data["steps"] == []

    @pytest.mark.asyncio
    async def test_missing_steps_field(self, executor):
        """Test that missing steps field raises ValueError."""
        with pytest.raises(ValueError, match="Missing required fields: steps"):
            await executor.execute({})

    @pytest.mark.asyncio
    async def test_steps_not_a_list(self, executor):
        """Test that non-list steps fails validation."""
        result = await executor.execute({"steps": "not a list"})

        assert result.success is False
        assert "must be a list" in result.error

    @pytest.mark.asyncio
    async def test_step_missing_type_field(self, executor):
        """Test that step without type field fails."""
        result = await executor.execute({
            "steps": [
                {"payload": {"key": "value"}}
            ]
        })

        assert result.success is False
        assert "Step 0 missing 'type' field" in result.error

    @pytest.mark.asyncio
    async def test_step_not_a_dict(self, executor):
        """Test that non-dict step fails validation."""
        result = await executor.execute({
            "steps": ["not a dict"]
        })

        assert result.success is False
        assert "Step 0 is not a valid step definition" in result.error

    @pytest.mark.asyncio
    async def test_unknown_step_type_fails(self, executor):
        """Test that unknown step type fails with clear error."""
        with patch("app.executors.get_executor") as mock_get:
            mock_get.side_effect = ValueError("Unknown task type: INVALID_TYPE. Available: ['ECHO', 'SHELL']")

            result = await executor.execute({
                "steps": [
                    {"type": "INVALID_TYPE", "payload": {}}
                ]
            })

        assert result.success is False
        assert "Unknown task type" in result.error
        assert "INVALID_TYPE" in result.error

    # =========================================================================
    # RESULT STRUCTURE TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_result_has_correct_structure(self, executor):
        """Test that result has all required fields."""
        mock_executor = self._create_mock_executor(
            success=True,
            data={"key": "value"},
        )

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ECHO", "payload": {"message": "test"}},
                ]
            })

        assert result.success is True
        assert "steps" in result.data
        assert isinstance(result.data["steps"], list)

        step = result.data["steps"][0]
        assert "index" in step
        assert "type" in step
        assert "status" in step
        assert "result" in step
        assert step["index"] == 0
        assert step["type"] == "ECHO"
        assert step["status"] == "completed"

    @pytest.mark.asyncio
    async def test_error_field_present_on_failure(self, executor):
        """Test that error field is present in step result on failure."""
        mock_executor = self._create_mock_executor(
            success=False,
            data={},
            error="Something went wrong",
        )

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "SHELL", "payload": {}},
                ]
            })

        assert result.success is False
        assert "error" in result.data["steps"][0]
        assert result.data["steps"][0]["error"] == "Something went wrong"

    @pytest.mark.asyncio
    async def test_error_field_absent_on_success(self, executor):
        """Test that error field is not present in step result on success."""
        mock_executor = self._create_mock_executor(
            success=True,
            data={"result": "ok"},
            error=None,
        )

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ECHO", "payload": {}},
                ]
            })

        assert result.success is True
        assert "error" not in result.data["steps"][0]

    @pytest.mark.asyncio
    async def test_propagates_underlying_executor_results(self, executor):
        """Test that underlying executor data is propagated correctly."""
        specific_data = {
            "updated_keys": ["DB_HOST", "DB_PORT"],
            "removed_keys": ["OLD_KEY"],
            "path": "/opt/letsbe/env/database.env",
            "custom_field": "custom_value",
        }
        mock_executor = self._create_mock_executor(
            success=True,
            data=specific_data,
        )

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ENV_UPDATE", "payload": {}},
                ]
            })

        assert result.success is True
        assert result.data["steps"][0]["result"] == specific_data

    @pytest.mark.asyncio
    async def test_duration_ms_populated(self, executor):
        """Test that duration_ms is populated."""
        mock_executor = self._create_mock_executor(success=True, data={})

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ECHO", "payload": {}},
                ]
            })

        assert result.duration_ms is not None
        assert result.duration_ms >= 0

    # =========================================================================
    # PAYLOAD HANDLING TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_step_payload_defaults_to_empty_dict(self, executor):
        """Test that missing payload in step defaults to empty dict."""
        mock_executor = MagicMock()
        mock_executor.execute = AsyncMock(return_value=ExecutionResult(success=True, data={}))

        with patch("app.executors.get_executor", return_value=mock_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "ECHO"}  # No payload field
                ]
            })

        assert result.success is True
        # Verify execute was called with empty dict
        mock_executor.execute.assert_called_once_with({})

    @pytest.mark.asyncio
    async def test_step_payload_passed_correctly(self, executor):
        """Test that step payload is passed to executor correctly."""
        mock_executor = MagicMock()
        mock_executor.execute = AsyncMock(return_value=ExecutionResult(success=True, data={}))

        expected_payload = {"path": "/opt/letsbe/env/app.env", "updates": {"KEY": "value"}}

        with patch("app.executors.get_executor", return_value=mock_executor):
            await executor.execute({
                "steps": [
                    {"type": "ENV_UPDATE", "payload": expected_payload}
                ]
            })

        mock_executor.execute.assert_called_once_with(expected_payload)

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

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

    # =========================================================================
    # EXECUTION ORDER TESTS
    # =========================================================================

    @pytest.mark.asyncio
    async def test_steps_executed_in_order(self, executor):
        """Test that steps are executed in sequential order."""
        execution_order = []

        def create_tracking_executor(name: str):
            mock = MagicMock()
            async def track_execute(payload):
                execution_order.append(name)
                return ExecutionResult(success=True, data={"name": name})
            mock.execute = track_execute
            return mock

        def mock_get_executor(task_type: str):
            return create_tracking_executor(task_type)

        with patch("app.executors.get_executor", side_effect=mock_get_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "STEP_A", "payload": {}},
                    {"type": "STEP_B", "payload": {}},
                    {"type": "STEP_C", "payload": {}},
                ]
            })

        assert result.success is True
        assert execution_order == ["STEP_A", "STEP_B", "STEP_C"]

    @pytest.mark.asyncio
    async def test_failure_stops_subsequent_steps(self, executor):
        """Test that failure at step N prevents steps N+1 and beyond from running."""
        execution_order = []

        def create_tracking_executor(name: str, should_fail: bool = False):
            mock = MagicMock()
            async def track_execute(payload):
                execution_order.append(name)
                return ExecutionResult(
                    success=not should_fail,
                    data={},
                    error="Failed" if should_fail else None,
                )
            mock.execute = track_execute
            return mock

        def mock_get_executor(task_type: str):
            if task_type == "STEP_B":
                return create_tracking_executor(task_type, should_fail=True)
            return create_tracking_executor(task_type)

        with patch("app.executors.get_executor", side_effect=mock_get_executor):
            result = await executor.execute({
                "steps": [
                    {"type": "STEP_A", "payload": {}},
                    {"type": "STEP_B", "payload": {}},  # This fails
                    {"type": "STEP_C", "payload": {}},  # Should NOT run
                ]
            })

        assert result.success is False
        assert execution_order == ["STEP_A", "STEP_B"]  # STEP_C not executed
