"""Tests for the workflow execution engine (WorkflowExecutionMixin).

Phase 4A: Integration tests for the main orchestration loop covering:
- 4A.2: BRIEF → SUPERVISION phase sequence for new workflows (skip GATHERING)
- 4A.3: Cancellation between phases triggers correct rollback
- 4A.4: Error in one phase doesn't skip cleanup/state saving
- 4A.5: Legacy resume from GATHERING enters gathering, then advances to SUPERVISION
"""

from __future__ import annotations

import asyncio
import threading
from typing import Any
from unittest.mock import MagicMock

import pytest
from tests.core.research.workflows.deep_research.conftest import make_test_state

from foundry_mcp.core.research.models.deep_research import (
    DeepResearchPhase,
    DeepResearchState,
)
from foundry_mcp.core.research.workflows.base import WorkflowResult
from foundry_mcp.core.research.workflows.deep_research.workflow_execution import (
    WorkflowExecutionMixin,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _ok_result(**overrides: Any) -> WorkflowResult:
    """Return a successful WorkflowResult with optional overrides."""
    defaults: dict[str, Any] = {"success": True, "content": "ok"}
    defaults.update(overrides)
    return WorkflowResult(**defaults)


def _fail_result(error: str = "phase failed") -> WorkflowResult:
    return WorkflowResult(success=False, content="", error=error)


class _BackgroundTask:
    """Minimal stand-in for BackgroundTask with cancellation support."""

    def __init__(self, *, is_cancelled: bool = False) -> None:
        self.is_cancelled = is_cancelled

    def touch(self) -> None:
        """Record liveness (no-op in tests)."""


class StubWorkflow(WorkflowExecutionMixin):
    """Concrete class for testing WorkflowExecutionMixin in isolation.

    All phase executors are async callables that can be overridden per test.
    """

    def __init__(self) -> None:
        self.config = MagicMock()
        self.config.get_phase_timeout.return_value = 60.0
        self.memory = MagicMock()
        self.hooks = MagicMock()
        self.orchestrator = MagicMock()
        self._tasks: dict[str, Any] = {}
        self._tasks_lock = threading.Lock()
        self._search_providers: dict[str, Any] = {}
        self._audit_events: list[tuple[str, dict]] = []

        # Default phase executors — all succeed
        self._clarification_result = _ok_result()
        self._brief_result = _ok_result()
        self._gathering_result = _ok_result()
        self._supervision_result = _ok_result()
        self._synthesis_result = _ok_result()

    # Mixin-required methods
    def _write_audit_event(self, state: Any, event: str, **kwargs: Any) -> None:
        self._audit_events.append((event, kwargs))

    def _flush_state(self, state: Any) -> None:
        pass

    def _record_workflow_error(self, exc: Any, state: Any, context: str) -> None:
        pass

    def _safe_orchestrator_transition(self, state: Any, phase: Any) -> None:
        # In production, this calls orchestrator methods + state.advance_phase().
        # For test isolation, we only advance the phase (the orchestrator
        # evaluate/reflect/record calls are mocked out).
        state.advance_phase()

    # Phase executor stubs
    async def _execute_clarification_async(self, **kwargs: Any) -> WorkflowResult:
        return self._clarification_result

    async def _execute_brief_async(self, **kwargs: Any) -> WorkflowResult:
        return self._brief_result

    async def _execute_gathering_async(self, **kwargs: Any) -> WorkflowResult:
        return self._gathering_result

    async def _execute_supervision_async(self, **kwargs: Any) -> WorkflowResult:
        return self._supervision_result

    async def _execute_synthesis_async(self, **kwargs: Any) -> WorkflowResult:
        return self._synthesis_result


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestPhaseSequence:
    """4A.2: Verify the phase transition sequence for new workflows."""

    @pytest.mark.asyncio
    async def test_brief_to_supervision_to_synthesis_sequence(self):
        """New workflow starting at BRIEF goes BRIEF → SUPERVISION → SYNTHESIS."""
        stub = StubWorkflow()
        phases_executed: list[str] = []

        async def track_brief(**kw: Any) -> WorkflowResult:
            phases_executed.append("brief")
            return _ok_result()

        async def track_supervision(**kw: Any) -> WorkflowResult:
            phases_executed.append("supervision")
            return _ok_result()

        async def track_synthesis(**kw: Any) -> WorkflowResult:
            phases_executed.append("synthesis")
            return _ok_result()

        stub._execute_brief_async = track_brief
        stub._execute_supervision_async = track_supervision
        stub._execute_synthesis_async = track_synthesis

        state = make_test_state(phase=DeepResearchPhase.BRIEF)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is True
        assert phases_executed == ["brief", "supervision", "synthesis"]
        assert state.completed_at is not None

    @pytest.mark.asyncio
    async def test_gathering_phase_is_skipped_for_new_workflows(self):
        """A workflow starting at BRIEF never enters GATHERING."""
        stub = StubWorkflow()
        gathering_called = False

        async def track_gathering(**kw: Any) -> WorkflowResult:
            nonlocal gathering_called
            gathering_called = True
            return _ok_result()

        stub._execute_gathering_async = track_gathering

        state = make_test_state(phase=DeepResearchPhase.BRIEF)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is True
        assert gathering_called is False

    @pytest.mark.asyncio
    async def test_full_sequence_from_clarification(self):
        """Full sequence CLARIFICATION → BRIEF → SUPERVISION → SYNTHESIS."""
        stub = StubWorkflow()
        phases_executed: list[str] = []

        async def track(name: str) -> WorkflowResult:
            phases_executed.append(name)
            return _ok_result()

        stub._execute_clarification_async = lambda **kw: track("clarification")
        stub._execute_brief_async = lambda **kw: track("brief")
        stub._execute_supervision_async = lambda **kw: track("supervision")
        stub._execute_synthesis_async = lambda **kw: track("synthesis")

        state = make_test_state(phase=DeepResearchPhase.CLARIFICATION)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is True
        assert phases_executed == ["clarification", "brief", "supervision", "synthesis"]


class TestLegacyGatheringResume:
    """4A.5: Legacy resume from GATHERING enters gathering, then advances to SUPERVISION."""

    @pytest.mark.asyncio
    async def test_legacy_gathering_resumes_then_advances_to_synthesis(self):
        """State saved at GATHERING runs gathering once, then advances to SYNTHESIS.

        Note: The orchestrator transition after GATHERING calls advance_phase()
        (GATHERING → SUPERVISION), then the explicit advance_phase() at line 243
        advances again (SUPERVISION → SYNTHESIS).  This means SUPERVISION is
        skipped in the legacy resume path — GATHERING already did the research
        work that SUPERVISION would do.
        """
        stub = StubWorkflow()
        phases_executed: list[str] = []

        async def track(name: str) -> WorkflowResult:
            phases_executed.append(name)
            return _ok_result()

        stub._execute_gathering_async = lambda **kw: track("gathering")
        stub._execute_supervision_async = lambda **kw: track("supervision")
        stub._execute_synthesis_async = lambda **kw: track("synthesis")

        state = make_test_state(phase=DeepResearchPhase.GATHERING)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is True
        # GATHERING is run, then the double advance_phase() skips SUPERVISION
        # and goes directly to SYNTHESIS
        assert phases_executed == ["gathering", "synthesis"]
        # Verify the legacy_phase_resume audit event was written
        audit_events = [e[0] for e in stub._audit_events]
        assert "legacy_phase_resume" in audit_events

    @pytest.mark.asyncio
    async def test_legacy_gathering_sets_iteration_in_progress(self):
        """Legacy gathering resume marks iteration_in_progress before executing."""
        stub = StubWorkflow()
        captured_flag: list[bool] = []

        async def capture_flag(**kw: Any) -> WorkflowResult:
            captured_flag.append(kw.get("state", kw).metadata.get("iteration_in_progress", False))
            return _ok_result()

        # We need to capture the flag during gathering execution
        original_run = stub._execute_gathering_async

        async def capturing_gathering(**kw: Any) -> WorkflowResult:
            # At this point, iteration_in_progress should already be set
            state_arg = kw.get("state")
            if state_arg:
                captured_flag.append(state_arg.metadata.get("iteration_in_progress", False))
            return _ok_result()

        stub._execute_gathering_async = capturing_gathering

        state = make_test_state(phase=DeepResearchPhase.GATHERING)

        await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert state.metadata.get("iteration_in_progress") is not None


class TestCancellationBetweenPhases:
    """4A.3: Cancellation between phases triggers correct rollback."""

    @pytest.mark.asyncio
    async def test_cancellation_during_supervision_rolls_back(self):
        """Cancellation during SUPERVISION with prior checkpoint triggers rollback
        and CancelledError propagates to honour Python's cancellation contract."""
        stub = StubWorkflow()

        async def raise_cancelled(**kw: Any) -> WorkflowResult:
            raise asyncio.CancelledError("cancelled")

        stub._execute_supervision_async = raise_cancelled

        state = make_test_state(phase=DeepResearchPhase.SUPERVISION, iteration=2)
        state.metadata["iteration_in_progress"] = True
        state.metadata["last_completed_iteration"] = 1

        with pytest.raises(asyncio.CancelledError):
            await stub._execute_workflow_async(
                state=state,
                provider_id=None,
                timeout_per_operation=60.0,
                max_concurrent=3,
            )

        assert "rollback_counts" in state.metadata
        assert state.metadata.get("discarded_iteration") == 2
        assert state.iteration == 1
        # After the finally block, cancellation_state transitions to "cancelled"
        assert state.metadata.get("cancellation_state") == "cancelled"
        # Verify state was saved
        assert stub.memory.save_deep_research.called

    @pytest.mark.asyncio
    async def test_cancellation_after_brief_before_supervision(self):
        """Cancellation triggered between BRIEF and SUPERVISION via _check_cancellation.
        CancelledError propagates to caller after state cleanup."""
        stub = StubWorkflow()

        # Register a background task that's cancelled
        state = make_test_state(phase=DeepResearchPhase.BRIEF)
        bg_task = _BackgroundTask(is_cancelled=False)
        stub._tasks[state.id] = bg_task

        # Cancel after brief completes
        async def brief_then_cancel(**kw: Any) -> WorkflowResult:
            bg_task.is_cancelled = True
            return _ok_result()

        stub._execute_brief_async = brief_then_cancel

        with pytest.raises(asyncio.CancelledError):
            await stub._execute_workflow_async(
                state=state,
                provider_id=None,
                timeout_per_operation=60.0,
                max_concurrent=3,
            )

        assert state.metadata.get("cancelled") is True

    @pytest.mark.asyncio
    async def test_cancellation_first_iteration_marks_for_discard(self):
        """First iteration cancellation sets rollback_counts without safe checkpoint.
        CancelledError propagates after cleanup."""
        stub = StubWorkflow()

        async def raise_cancelled(**kw: Any) -> WorkflowResult:
            raise asyncio.CancelledError("cancelled")

        stub._execute_supervision_async = raise_cancelled

        state = make_test_state(phase=DeepResearchPhase.SUPERVISION, iteration=1)
        state.metadata["iteration_in_progress"] = True
        # No last_completed_iteration — first iteration

        with pytest.raises(asyncio.CancelledError):
            await stub._execute_workflow_async(
                state=state,
                provider_id=None,
                timeout_per_operation=60.0,
                max_concurrent=3,
            )

        assert "rollback_counts" in state.metadata
        assert state.metadata.get("discarded_iteration") == 1


class TestPhaseErrorHandling:
    """4A.4: Error in one phase doesn't skip cleanup/state saving."""

    @pytest.mark.asyncio
    async def test_brief_error_saves_state_and_returns_failure(self):
        """When BRIEF fails, state is saved and failure result is returned."""
        stub = StubWorkflow()
        stub._brief_result = _fail_result("brief exploded")

        state = make_test_state(phase=DeepResearchPhase.BRIEF)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is False
        # State was flushed after error
        assert stub._flush_state != stub.__class__._flush_state  # Not a no-op assertion
        # mark_failed was called
        assert state.metadata.get("failed") is True

    @pytest.mark.asyncio
    async def test_supervision_error_doesnt_run_synthesis(self):
        """When SUPERVISION fails, SYNTHESIS is not attempted."""
        stub = StubWorkflow()
        stub._supervision_result = _fail_result("supervision blew up")
        synthesis_called = False

        async def track_synthesis(**kw: Any) -> WorkflowResult:
            nonlocal synthesis_called
            synthesis_called = True
            return _ok_result()

        stub._execute_synthesis_async = track_synthesis

        state = make_test_state(phase=DeepResearchPhase.SUPERVISION)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is False
        assert synthesis_called is False

    @pytest.mark.asyncio
    async def test_exception_in_phase_saves_state_and_returns_failure(self):
        """Unhandled exception during phase execution saves state and returns failure."""
        stub = StubWorkflow()

        async def explode(**kw: Any) -> WorkflowResult:
            raise RuntimeError("something terrible happened")

        stub._execute_brief_async = explode

        state = make_test_state(phase=DeepResearchPhase.BRIEF)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is False
        assert "something terrible happened" in (result.error or "")
        # State was persisted
        assert stub.memory.save_deep_research.called
        # Audit event was written
        audit_events = [e[0] for e in stub._audit_events]
        assert "workflow_error" in audit_events

    @pytest.mark.asyncio
    async def test_orchestrator_error_during_synthesis_is_handled(self):
        """Exception in orchestrator post-synthesis raises but state is saved."""
        stub = StubWorkflow()
        stub.orchestrator.evaluate_phase_completion.side_effect = RuntimeError("orchestrator boom")

        state = make_test_state(phase=DeepResearchPhase.SUPERVISION)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        # The orchestrator error propagates to the exception handler
        assert result.success is False
        assert stub.memory.save_deep_research.called
        audit_events = [e[0] for e in stub._audit_events]
        assert "orchestrator_error" in audit_events


class TestCompletionMetadata:
    """Test that successful completion records proper metadata."""

    @pytest.mark.asyncio
    async def test_successful_completion_marks_state(self):
        """Successful run marks state completed with iteration metadata."""
        stub = StubWorkflow()

        state = make_test_state(phase=DeepResearchPhase.BRIEF)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id="test-provider",
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is True
        assert state.completed_at is not None
        assert state.metadata.get("iteration_in_progress") is False
        assert state.metadata.get("last_completed_iteration") == state.iteration
        # Workflow complete audit event
        audit_events = [e[0] for e in stub._audit_events]
        assert "workflow_complete" in audit_events


class TestLegacyPlanningResume:
    """Phase 1c: Legacy resume from PLANNING advances to SUPERVISION without crash."""

    @pytest.mark.asyncio
    async def test_planning_phase_advances_to_supervision(self):
        """Saved state at PLANNING skips to SUPERVISION without AttributeError."""
        stub = StubWorkflow()
        phases_executed: list[str] = []

        async def track(name: str) -> WorkflowResult:
            phases_executed.append(name)
            return _ok_result()

        stub._execute_supervision_async = lambda **kw: track("supervision")
        stub._execute_synthesis_async = lambda **kw: track("synthesis")

        state = make_test_state(phase=DeepResearchPhase.PLANNING)

        result = await stub._execute_workflow_async(
            state=state,
            provider_id=None,
            timeout_per_operation=60.0,
            max_concurrent=3,
        )

        assert result.success is True
        # PLANNING is skipped, goes to SUPERVISION then SYNTHESIS
        assert phases_executed == ["supervision", "synthesis"]
        # Verify the legacy_phase_resume audit event was written
        audit_events = [e[0] for e in stub._audit_events]
        assert "legacy_phase_resume" in audit_events

    @pytest.mark.asyncio
    async def test_planning_phase_enum_deserializes(self):
        """DeepResearchPhase.PLANNING exists for legacy state deserialization."""
        assert DeepResearchPhase.PLANNING.value == "planning"
        # Verify it can be constructed from string
        assert DeepResearchPhase("planning") == DeepResearchPhase.PLANNING


class TestCancelledErrorPropagation:
    """Phase 1b: CancelledError re-raises after state cleanup."""

    @pytest.mark.asyncio
    async def test_cancelled_error_propagates_to_caller(self):
        """asyncio.Task.cancel() on a running workflow results in CancelledError
        being raised to the caller, with state properly cleaned up."""
        stub = StubWorkflow()

        async def slow_supervision(**kw: Any) -> WorkflowResult:
            await asyncio.sleep(10)  # Will be cancelled
            return _ok_result()

        stub._execute_supervision_async = slow_supervision
        state = make_test_state(phase=DeepResearchPhase.SUPERVISION)
        state.metadata["iteration_in_progress"] = True

        task = asyncio.create_task(
            stub._execute_workflow_async(
                state=state,
                provider_id=None,
                timeout_per_operation=60.0,
                max_concurrent=3,
            )
        )

        # Let the task start, then cancel it
        await asyncio.sleep(0.01)
        task.cancel()

        with pytest.raises(asyncio.CancelledError):
            await task

        # State cleanup still happened
        assert state.metadata.get("cancelled") is True
        assert stub.memory.save_deep_research.called


class TestValidateReportOutputPath:
    """0.1: Path traversal protection for report_output_path."""

    def test_rejects_dotdot_segments(self, tmp_path):
        """Paths containing '..' are rejected before resolution."""
        from foundry_mcp.core.research.workflows.deep_research.workflow_execution import (
            _validate_report_output_path,
        )

        malicious = str(tmp_path / ".." / ".." / "etc" / "cron.d" / "backdoor")
        with pytest.raises(ValueError, match="Path traversal detected"):
            _validate_report_output_path(malicious)

    def test_accepts_valid_path(self, tmp_path):
        """A plain path within an existing directory passes validation."""
        from foundry_mcp.core.research.workflows.deep_research.workflow_execution import (
            _validate_report_output_path,
        )

        valid = str(tmp_path / "report.md")
        result = _validate_report_output_path(valid)
        assert result.parent == tmp_path

    def test_rejects_nonexistent_parent(self):
        """Path whose parent directory doesn't exist is rejected."""
        from foundry_mcp.core.research.workflows.deep_research.workflow_execution import (
            _validate_report_output_path,
        )

        with pytest.raises(ValueError, match="Parent directory does not exist"):
            _validate_report_output_path("/nonexistent_dir_abc123/report.md")
