"""Tests for AppFactoryOrchestrator (LangGraph state machine)."""

import asyncio
import json
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app_factory.core.graph import AppFactoryOrchestrator, AppFactoryState


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_pm_agent():
    agent = AsyncMock()
    agent.expand_prompt_to_prd = AsyncMock(return_value="# Generated PRD\n\nObjective: Build an app")
    agent.handle_clarification_request = AsyncMock(return_value="Clarification: use REST API")
    return agent


@pytest.fixture
def mock_task_agent():
    agent = AsyncMock()
    agent.parse_prd = AsyncMock(return_value={"tasks": []})
    agent.get_unblocked_tasks = AsyncMock(return_value=[
        {"id": 1, "title": "Task 1", "status": "pending", "dependencies": []},
        {"id": 2, "title": "Task 2", "status": "pending", "dependencies": []},
    ])
    agent.update_task_status = AsyncMock()
    agent.get_task_details = AsyncMock(return_value={"id": 1, "title": "Task 1"})
    return agent


@pytest.fixture
def mock_dev_manager():
    manager = AsyncMock()
    manager.execute_with_retry = AsyncMock(return_value={
        "status": "success",
        "output": "Task completed",
        "files_changed": ["app.py"],
        "exit_code": 0,
    })
    return manager


@pytest.fixture
def mock_qa_agent():
    agent = AsyncMock()
    agent.max_retries = 3
    agent.review_and_merge = AsyncMock(return_value={
        "status": "merged",
        "commit_sha": "abc123",
        "review_summary": "LGTM",
    })
    return agent


@pytest.fixture
def mock_workspace_manager():
    manager = AsyncMock()
    manager.create_worktree = AsyncMock(return_value="/tmp/worktree/task-1")
    manager.spin_up_clean_room = AsyncMock(return_value=MagicMock(id="container-123"))
    manager.cleanup_workspace = AsyncMock()
    return manager


@pytest.fixture
def mock_observability():
    obs = MagicMock()
    obs.log_state_transition = MagicMock()
    obs.log_error = MagicMock()
    return obs


@pytest.fixture
def orchestrator(mock_pm_agent, mock_task_agent, mock_dev_manager,
                 mock_qa_agent, mock_workspace_manager, mock_observability):
    return AppFactoryOrchestrator(
        pm_agent=mock_pm_agent,
        task_agent=mock_task_agent,
        dev_manager=mock_dev_manager,
        qa_agent=mock_qa_agent,
        workspace_manager=mock_workspace_manager,
        observability=mock_observability,
    )


# ---------------------------------------------------------------------------
# State Schema Tests
# ---------------------------------------------------------------------------


class TestStateSchema:
    def test_state_has_all_required_fields(self):
        required_fields = {
            "user_input", "prd", "tasks", "active_tasks", "completed_tasks",
            "blocked_tasks", "clarification_requests", "global_architecture",
            "iteration_count", "max_iterations", "errors",
        }
        assert required_fields == set(AppFactoryState.__annotations__.keys())

    def test_state_can_be_instantiated(self):
        state: AppFactoryState = {
            "user_input": "build a todo app",
            "prd": "",
            "tasks": [],
            "active_tasks": {},
            "completed_tasks": [],
            "blocked_tasks": {},
            "clarification_requests": [],
            "global_architecture": "",
            "iteration_count": 0,
            "max_iterations": 50,
            "errors": [],
        }
        assert state["user_input"] == "build a todo app"
        assert state["max_iterations"] == 50


# ---------------------------------------------------------------------------
# Graph Construction Tests
# ---------------------------------------------------------------------------


class TestGraphBuild:
    def test_graph_builds_without_errors(self):
        orch = AppFactoryOrchestrator()
        compiled = orch.build_graph()
        assert compiled is not None

    def test_graph_builds_with_all_agents(self, orchestrator):
        compiled = orchestrator.build_graph()
        assert compiled is not None

    def test_graph_builds_with_none_agents(self):
        orch = AppFactoryOrchestrator(
            pm_agent=None, task_agent=None, dev_manager=None,
            qa_agent=None, workspace_manager=None, observability=None,
        )
        compiled = orch.build_graph()
        assert compiled is not None


# ---------------------------------------------------------------------------
# Node Tests
# ---------------------------------------------------------------------------


class TestPMNode:
    @pytest.mark.asyncio
    async def test_pm_node_sets_prd(self, orchestrator, mock_pm_agent):
        state = {"user_input": "build a todo app", "errors": []}
        result = await orchestrator._pm_node(state)
        assert "prd" in result
        assert result["prd"] == "# Generated PRD\n\nObjective: Build an app"
        mock_pm_agent.expand_prompt_to_prd.assert_called_once_with("build a todo app")

    @pytest.mark.asyncio
    async def test_pm_node_handles_no_input(self, orchestrator):
        state = {"user_input": "", "errors": []}
        result = await orchestrator._pm_node(state)
        assert result["prd"] == ""
        assert any("No user input" in e for e in result["errors"])

    @pytest.mark.asyncio
    async def test_pm_node_handles_agent_error(self, orchestrator, mock_pm_agent):
        mock_pm_agent.expand_prompt_to_prd.side_effect = RuntimeError("API down")
        state = {"user_input": "build app", "errors": []}
        result = await orchestrator._pm_node(state)
        assert result["prd"] == ""
        assert any("PM agent error" in e for e in result["errors"])

    @pytest.mark.asyncio
    async def test_pm_node_without_agent(self):
        orch = AppFactoryOrchestrator()
        state = {"user_input": "build a todo app", "errors": []}
        result = await orch._pm_node(state)
        assert "Mock PRD" in result["prd"]


class TestTaskNode:
    @pytest.mark.asyncio
    async def test_task_node_populates_tasks(self, orchestrator, mock_task_agent):
        state = {"prd": "some PRD", "tasks": [], "iteration_count": 0, "max_iterations": 50, "errors": []}
        result = await orchestrator._task_node(state)
        assert "tasks" in result
        assert len(result["tasks"]) == 2
        mock_task_agent.parse_prd.assert_called_once()

    @pytest.mark.asyncio
    async def test_task_node_increments_iteration(self, orchestrator):
        state = {"prd": "PRD", "tasks": [], "iteration_count": 5, "max_iterations": 50, "errors": []}
        result = await orchestrator._task_node(state)
        assert result["iteration_count"] == 6

    @pytest.mark.asyncio
    async def test_task_node_refreshes_on_subsequent_passes(self, orchestrator, mock_task_agent):
        state = {
            "prd": "PRD",
            "tasks": [{"id": 1, "title": "existing", "status": "pending", "dependencies": []}],
            "iteration_count": 1,
            "max_iterations": 50,
            "errors": [],
        }
        result = await orchestrator._task_node(state)
        mock_task_agent.parse_prd.assert_not_called()
        mock_task_agent.get_unblocked_tasks.assert_called()

    @pytest.mark.asyncio
    async def test_task_node_stops_at_max_iterations(self, orchestrator):
        state = {"prd": "PRD", "tasks": [], "iteration_count": 49, "max_iterations": 50, "errors": []}
        result = await orchestrator._task_node(state)
        assert result["iteration_count"] == 50
        assert any("Max iterations" in e for e in result["errors"])


class TestDevDispatchNode:
    @pytest.mark.asyncio
    async def test_dev_dispatch_spawns_concurrent_tasks(self, orchestrator, mock_dev_manager, mock_workspace_manager):
        state = {
            "tasks": [
                {"id": 1, "title": "Task 1", "status": "pending", "dependencies": []},
                {"id": 2, "title": "Task 2", "status": "pending", "dependencies": []},
            ],
            "completed_tasks": [],
            "active_tasks": {},
            "errors": [],
            "clarification_requests": [],
            "global_architecture": "",
        }
        result = await orchestrator._dev_dispatch_node(state)
        assert "1" in result["completed_tasks"]
        assert "2" in result["completed_tasks"]
        assert mock_dev_manager.execute_with_retry.call_count == 2
        assert mock_workspace_manager.create_worktree.call_count == 2

    @pytest.mark.asyncio
    async def test_dev_dispatch_handles_needs_clarification(self, orchestrator, mock_dev_manager):
        mock_dev_manager.execute_with_retry.return_value = {
            "status": "needs_clarification",
            "output": "Cannot figure out API format",
            "files_changed": [],
            "exit_code": -1,
        }
        state = {
            "tasks": [{"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}],
            "completed_tasks": [],
            "active_tasks": {},
            "errors": [],
            "clarification_requests": [],
            "global_architecture": "",
        }
        result = await orchestrator._dev_dispatch_node(state)
        assert len(result["clarification_requests"]) == 1
        assert result["clarification_requests"][0]["task_id"] == "1"

    @pytest.mark.asyncio
    async def test_dev_dispatch_skips_completed(self, orchestrator):
        state = {
            "tasks": [{"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}],
            "completed_tasks": ["1"],
            "active_tasks": {},
            "errors": [],
            "clarification_requests": [],
            "global_architecture": "",
        }
        result = await orchestrator._dev_dispatch_node(state)
        assert result == {}

    @pytest.mark.asyncio
    async def test_dev_dispatch_without_agents(self):
        orch = AppFactoryOrchestrator()
        state = {
            "tasks": [
                {"id": 1, "title": "Task 1", "status": "pending", "dependencies": []},
            ],
            "completed_tasks": [],
            "active_tasks": {},
            "errors": [],
            "clarification_requests": [],
            "global_architecture": "",
        }
        result = await orch._dev_dispatch_node(state)
        assert "1" in result["completed_tasks"]


class TestQANode:
    @pytest.mark.asyncio
    async def test_qa_node_processes_results(self, orchestrator, mock_qa_agent, mock_task_agent):
        state = {
            "tasks": [{"id": "1", "title": "Task 1"}],
            "active_tasks": {"1": {"status": "success", "worktree_path": "/tmp/wt"}},
            "completed_tasks": ["1"],
            "errors": [],
            "clarification_requests": [],
            "blocked_tasks": {},
        }
        result = await orchestrator._qa_node(state)
        mock_qa_agent.review_and_merge.assert_called_once()
        assert result["active_tasks"]["1"]["status"] == "merged"

    @pytest.mark.asyncio
    async def test_qa_node_no_tasks_for_qa(self, orchestrator):
        state = {
            "tasks": [],
            "active_tasks": {},
            "completed_tasks": [],
            "errors": [],
            "clarification_requests": [],
            "blocked_tasks": {},
        }
        result = await orchestrator._qa_node(state)
        assert result == {}

    @pytest.mark.asyncio
    async def test_qa_node_handles_qa_failure(self, orchestrator, mock_qa_agent):
        mock_qa_agent.review_and_merge.return_value = {
            "status": "tests_failed",
            "retry_count": 3,
        }
        state = {
            "tasks": [{"id": "1", "title": "Task 1"}],
            "active_tasks": {"1": {"status": "success", "worktree_path": "/tmp/wt"}},
            "completed_tasks": ["1"],
            "errors": [],
            "clarification_requests": [],
            "blocked_tasks": {},
        }
        result = await orchestrator._qa_node(state)
        assert len(result["clarification_requests"]) == 1


# ---------------------------------------------------------------------------
# Routing Tests
# ---------------------------------------------------------------------------


class TestRoutingAfterTasks:
    def test_routes_to_dev_dispatch_when_unblocked(self, orchestrator):
        state = {
            "tasks": [{"id": 1, "status": "pending", "dependencies": []}],
            "completed_tasks": [],
            "blocked_tasks": {},
            "clarification_requests": [],
            "iteration_count": 1,
            "max_iterations": 50,
        }
        assert orchestrator._should_continue_after_tasks(state) == "dev_dispatch"

    def test_routes_to_end_when_all_done(self, orchestrator):
        state = {
            "tasks": [{"id": 1, "status": "done", "dependencies": []}],
            "completed_tasks": ["1"],
            "blocked_tasks": {},
            "clarification_requests": [],
            "iteration_count": 1,
            "max_iterations": 50,
        }
        assert orchestrator._should_continue_after_tasks(state) == "end"

    def test_routes_to_clarification_when_blocked(self, orchestrator):
        state = {
            "tasks": [{"id": 1, "status": "pending", "dependencies": [2]}],
            "completed_tasks": [],
            "blocked_tasks": {"1": "dependency not met"},
            "clarification_requests": [],
            "iteration_count": 1,
            "max_iterations": 50,
        }
        assert orchestrator._should_continue_after_tasks(state) == "clarification"

    def test_routes_to_end_at_max_iterations(self, orchestrator):
        state = {
            "tasks": [{"id": 1, "status": "pending", "dependencies": []}],
            "completed_tasks": [],
            "blocked_tasks": {},
            "clarification_requests": [],
            "iteration_count": 50,
            "max_iterations": 50,
        }
        assert orchestrator._should_continue_after_tasks(state) == "end"

    def test_routes_to_end_when_no_tasks(self, orchestrator):
        state = {
            "tasks": [],
            "completed_tasks": [],
            "blocked_tasks": {},
            "clarification_requests": [],
            "iteration_count": 1,
            "max_iterations": 50,
        }
        assert orchestrator._should_continue_after_tasks(state) == "end"


class TestRoutingAfterQA:
    def test_routes_to_task_node(self, orchestrator):
        state = {
            "clarification_requests": [],
            "iteration_count": 1,
            "max_iterations": 50,
        }
        assert orchestrator._should_continue_after_qa(state) == "task_node"

    def test_routes_to_clarification(self, orchestrator):
        state = {
            "clarification_requests": [{"question": "What API?"}],
            "iteration_count": 1,
            "max_iterations": 50,
        }
        assert orchestrator._should_continue_after_qa(state) == "clarification"

    def test_routes_to_end_at_max_iterations(self, orchestrator):
        state = {
            "clarification_requests": [],
            "iteration_count": 50,
            "max_iterations": 50,
        }
        assert orchestrator._should_continue_after_qa(state) == "end"


# ---------------------------------------------------------------------------
# Iteration Safety Tests
# ---------------------------------------------------------------------------


class TestIterationSafety:
    @pytest.mark.asyncio
    async def test_iteration_limit_prevents_infinite_loop(self, orchestrator):
        state = {
            "prd": "PRD",
            "tasks": [],
            "iteration_count": 49,
            "max_iterations": 50,
            "errors": [],
        }
        result = await orchestrator._task_node(state)
        assert result["iteration_count"] == 50
        assert any("Max iterations" in e for e in result["errors"])


# ---------------------------------------------------------------------------
# State Persistence Tests
# ---------------------------------------------------------------------------


class TestStatePersistence:
    def test_save_state(self, orchestrator):
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "state.json")
            state = {
                "user_input": "test",
                "prd": "PRD content",
                "tasks": [{"id": 1}],
                "active_tasks": {},
                "completed_tasks": ["1"],
                "blocked_tasks": {},
                "clarification_requests": [],
                "global_architecture": "",
                "iteration_count": 5,
                "max_iterations": 50,
                "errors": [],
            }
            orchestrator.save_state(state, path)
            assert os.path.exists(path)
            with open(path) as f:
                loaded = json.load(f)
            assert loaded["user_input"] == "test"
            assert loaded["iteration_count"] == 5

    def test_load_state(self, orchestrator):
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "state.json")
            data = {"user_input": "hello", "prd": "PRD", "iteration_count": 3}
            with open(path, "w") as f:
                json.dump(data, f)
            loaded = orchestrator.load_state(path)
            assert loaded["user_input"] == "hello"
            assert loaded["iteration_count"] == 3

    def test_save_state_creates_directory(self, orchestrator):
        with tempfile.TemporaryDirectory() as tmpdir:
            path = os.path.join(tmpdir, "nested", "dir", "state.json")
            orchestrator.save_state({"user_input": "test"}, path)
            assert os.path.exists(path)


# ---------------------------------------------------------------------------
# Clarification Node Tests
# ---------------------------------------------------------------------------


class TestClarificationNode:
    @pytest.mark.asyncio
    async def test_clarification_resolves_requests(self, orchestrator, mock_pm_agent):
        state = {
            "clarification_requests": [
                {"requesting_agent": "dev", "task_id": "1", "question": "What format?", "context": ""},
            ],
            "blocked_tasks": {"1": "needs clarification"},
            "errors": [],
        }
        result = await orchestrator._clarification_node(state)
        assert result["clarification_requests"] == []
        assert "1" not in result["blocked_tasks"]
        mock_pm_agent.handle_clarification_request.assert_called_once()

    @pytest.mark.asyncio
    async def test_clarification_handles_empty_requests(self, orchestrator):
        state = {"clarification_requests": [], "blocked_tasks": {}, "errors": []}
        result = await orchestrator._clarification_node(state)
        assert result["clarification_requests"] == []

    @pytest.mark.asyncio
    async def test_clarification_without_agent(self):
        orch = AppFactoryOrchestrator()
        state = {
            "clarification_requests": [{"task_id": "1", "question": "?"}],
            "blocked_tasks": {"1": "blocked"},
            "errors": [],
        }
        result = await orch._clarification_node(state)
        assert result["clarification_requests"] == []


# ---------------------------------------------------------------------------
# End-to-End Run Test
# ---------------------------------------------------------------------------


class TestEndToEnd:
    @pytest.mark.asyncio
    async def test_run_executes_end_to_end(self, mock_pm_agent, mock_task_agent,
                                            mock_dev_manager, mock_qa_agent,
                                            mock_workspace_manager, mock_observability):
        # After first get_unblocked_tasks returns tasks, the second call returns empty
        # to terminate the loop.
        mock_task_agent.get_unblocked_tasks = AsyncMock(
            side_effect=[
                [{"id": 1, "title": "Task 1", "status": "pending", "dependencies": []}],
                [],  # No more tasks - triggers end
            ]
        )

        orch = AppFactoryOrchestrator(
            pm_agent=mock_pm_agent,
            task_agent=mock_task_agent,
            dev_manager=mock_dev_manager,
            qa_agent=mock_qa_agent,
            workspace_manager=mock_workspace_manager,
            observability=mock_observability,
        )

        with tempfile.TemporaryDirectory() as tmpdir:
            state_path = os.path.join(tmpdir, "state.json")
            with patch.object(orch, "save_state") as mock_save:
                result = await orch.run("build a todo app")

        assert result["prd"] != ""
        mock_pm_agent.expand_prompt_to_prd.assert_called_once_with("build a todo app")
        mock_task_agent.parse_prd.assert_called_once()

    @pytest.mark.asyncio
    async def test_run_with_no_agents(self):
        orch = AppFactoryOrchestrator()
        with tempfile.TemporaryDirectory() as tmpdir:
            state_path = os.path.join(tmpdir, "state.json")
            with patch.object(orch, "save_state"):
                result = await orch.run("build something")
        assert "Mock PRD" in result["prd"]
