"""Tests for the parallel exploration pipeline mode."""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from triad.orchestrator import (
    ParallelOrchestrator,
    _detect_incomplete_sections,
    _extract_scores,
    _get_tiebreaker_key,
    run_pipeline,
)
from triad.schemas.arbiter import ArbiterReview, Verdict
from triad.schemas.messages import (
    AgentMessage,
    MessageType,
    PipelineStage,
    TokenUsage,
)
from triad.schemas.pipeline import (
    ModelConfig,
    PipelineConfig,
    RoleFitness,
    TaskSpec,
)

# Patch targets
_PROVIDER = "triad.orchestrator.LiteLLMProvider"
_CONSENSUS_PROVIDER = "triad.consensus.protocol.LiteLLMProvider"
_ARBITER_REVIEW = "triad.orchestrator.ArbiterEngine.review"


# ── Factories ──────────────────────────────────────────────────────

def _make_model_config(model: str = "model-a-v1", **overrides) -> ModelConfig:
    defaults = {
        "provider": "test",
        "model": model,
        "display_name": "Test Model",
        "api_key_env": "TEST_KEY",
        "context_window": 128000,
        "cost_input": 3.0,
        "cost_output": 15.0,
        "fitness": RoleFitness(
            architect=0.9, implementer=0.8,
            refactorer=0.7, verifier=0.85,
        ),
    }
    defaults.update(overrides)
    return ModelConfig(**defaults)


def _make_three_model_registry() -> dict[str, ModelConfig]:
    return {
        "model-a": _make_model_config(
            model="model-a-v1",
            fitness=RoleFitness(
                architect=0.9, implementer=0.8,
                refactorer=0.7, verifier=0.85,
            ),
        ),
        "model-b": _make_model_config(
            model="model-b-v1",
            fitness=RoleFitness(
                architect=0.7, implementer=0.9,
                refactorer=0.8, verifier=0.75,
            ),
        ),
        "model-c": _make_model_config(
            model="model-c-v1",
            fitness=RoleFitness(
                architect=0.6, implementer=0.7,
                refactorer=0.9, verifier=0.90,
            ),
        ),
    }


def _make_task(**overrides) -> TaskSpec:
    defaults = {"task": "Build a REST API"}
    defaults.update(overrides)
    return TaskSpec(**defaults)


def _make_agent_message(
    content: str = "output", cost: float = 0.01,
) -> AgentMessage:
    return AgentMessage(
        from_agent=PipelineStage.ARCHITECT,
        to_agent=PipelineStage.IMPLEMENT,
        msg_type=MessageType.IMPLEMENTATION,
        content=f"{content}\n\nCONFIDENCE: 0.85",
        confidence=0.0,
        token_usage=TokenUsage(
            prompt_tokens=100, completion_tokens=50, cost=cost,
        ),
        model="model-a-v1",
    )


def _make_review_content(arch: int, impl: int, quality: int) -> str:
    return (
        f"ARCHITECTURE: {arch}\nGood structure.\n\n"
        f"IMPLEMENTATION: {impl}\nSolid code.\n\n"
        f"QUALITY: {quality}\nClean.\n\n"
        f"CONFIDENCE: 0.85"
    )


def _make_approve_review(**overrides) -> ArbiterReview:
    defaults = {
        "stage_reviewed": PipelineStage.VERIFY,
        "reviewed_model": "model-a-v1",
        "arbiter_model": "model-b-v1",
        "verdict": Verdict.APPROVE,
        "confidence": 0.9,
        "reasoning": "VERDICT: APPROVE",
        "token_cost": 0.005,
    }
    defaults.update(overrides)
    return ArbiterReview(**defaults)


def _mock_provider(responses: list[AgentMessage]):
    mock_cls = MagicMock()
    mock_inst = MagicMock()
    mock_cls.return_value = mock_inst
    mock_inst.complete = AsyncMock(side_effect=responses)
    return mock_cls, mock_inst


# ── Score Extraction ──────────────────────────────────────────────

class TestExtractScores:
    def test_extracts_all_three(self):
        content = _make_review_content(8, 7, 9)
        scores = _extract_scores(content)
        assert scores == {"architecture": 8, "implementation": 7, "quality": 9}

    def test_clamps_above_ten(self):
        scores = _extract_scores("ARCHITECTURE: 15\nIMPLEMENTATION: 5\nQUALITY: 5")
        assert scores["architecture"] == 10

    def test_clamps_below_one(self):
        scores = _extract_scores("ARCHITECTURE: 0\nIMPLEMENTATION: 5\nQUALITY: 5")
        assert scores["architecture"] == 1

    def test_defaults_on_missing(self):
        scores = _extract_scores("No scores here")
        assert scores == {"architecture": 5, "implementation": 5, "quality": 5}


# ── Tiebreaker ────────────────────────────────────────────────────

class TestGetTiebreaker:
    def test_selects_highest_verifier_fitness(self):
        registry = _make_three_model_registry()
        # model-c has verifier=0.90 (highest)
        assert _get_tiebreaker_key(registry) == "model-c"


# ── Parallel Fan-Out ──────────────────────────────────────────────

class TestParallelFanOut:
    async def test_calls_all_models(self):
        """Fan-out should call all models in the registry."""
        registry = _make_three_model_registry()
        # 3 fan-out + 6 reviews (3*2) + 1 synthesis = 10 calls
        responses = [_make_agent_message(f"output-{i}") for i in range(10)]
        mock_cls, mock_inst = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        assert result.parallel_result is not None
        assert len(result.parallel_result.individual_outputs) == 3
        assert result.success is True

    async def test_single_model_fallback(self):
        """One model in registry → single-model fallback, not crash."""
        registry = {"model-a": _make_model_config()}
        responses = [_make_agent_message("only output")]
        mock_cls, _ = _mock_provider(responses)

        with patch(_PROVIDER, mock_cls):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        assert result.success is True
        pr = result.parallel_result
        assert pr is not None
        assert pr.winner == "model-a"
        assert pr.synthesized_output == "only output\n\nCONFIDENCE: 0.85"
        assert pr.scores == {}
        assert pr.votes == {"model-a": "model-a"}

    async def test_two_of_three_fail_single_model_fallback(self):
        """If 2 of 3 models fail in fan-out, fallback to the sole survivor."""
        registry = _make_three_model_registry()
        # First call succeeds, next two fail
        responses = [
            _make_agent_message("surviving output"),
            RuntimeError("model-b failed"),
            RuntimeError("model-c failed"),
        ]
        mock_cls = MagicMock()
        mock_inst = MagicMock()
        mock_cls.return_value = mock_inst
        mock_inst.complete = AsyncMock(side_effect=responses)

        with patch(_PROVIDER, mock_cls):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        assert result.success is True
        pr = result.parallel_result
        assert pr is not None
        assert len(pr.individual_outputs) == 1
        assert pr.scores == {}

    async def test_all_models_fail_raises(self):
        """If all models fail in fan-out, raise RuntimeError."""
        registry = _make_three_model_registry()
        mock_cls = MagicMock()
        mock_inst = MagicMock()
        mock_cls.return_value = mock_inst
        mock_inst.complete = AsyncMock(
            side_effect=RuntimeError("all down"),
        )

        with (
            patch(_PROVIDER, mock_cls),
            pytest.raises(RuntimeError, match="all models failed"),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            await orch.run()


# ── Cross-Review + Voting ─────────────────────────────────────────

class TestParallelVoting:
    async def test_scores_and_votes_populated(self):
        registry = _make_three_model_registry()
        # 3 fan-out + 6 reviews + 1 synthesis = 10
        fan_out = [
            _make_agent_message("solution A"),
            _make_agent_message("solution B"),
            _make_agent_message("solution C"),
        ]
        # Reviews: a→b, a→c, b→a, b→c, c→a, c→b
        # model-a scores model-b highest
        reviews = [
            _make_agent_message(_make_review_content(8, 9, 8)),  # a→b
            _make_agent_message(_make_review_content(6, 5, 6)),  # a→c
            _make_agent_message(_make_review_content(7, 7, 7)),  # b→a
            _make_agent_message(_make_review_content(5, 6, 5)),  # b→c
            _make_agent_message(_make_review_content(8, 8, 7)),  # c→a
            _make_agent_message(_make_review_content(6, 7, 6)),  # c→b
        ]
        synthesis = [_make_agent_message("synthesized output")]

        mock_cls, _ = _mock_provider(fan_out + reviews + synthesis)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        pr = result.parallel_result
        assert pr is not None
        assert len(pr.scores) == 3
        assert len(pr.votes) == 3
        assert pr.winner != ""
        assert pr.synthesized_output != ""

    async def test_majority_vote_wins(self):
        """If 2 of 3 vote for the same model, that model wins."""
        registry = _make_three_model_registry()
        fan_out = [
            _make_agent_message("solution A"),
            _make_agent_message("solution B"),
            _make_agent_message("solution C"),
        ]
        # a votes for b (b scores higher), b votes for a, c votes for a
        # → a wins with 2 votes
        reviews = [
            _make_agent_message(_make_review_content(9, 9, 9)),  # a→b
            _make_agent_message(_make_review_content(5, 5, 5)),  # a→c
            _make_agent_message(_make_review_content(9, 9, 9)),  # b→a
            _make_agent_message(_make_review_content(5, 5, 5)),  # b→c
            _make_agent_message(_make_review_content(9, 9, 9)),  # c→a
            _make_agent_message(_make_review_content(5, 5, 5)),  # c→b
        ]
        synthesis = [_make_agent_message("final")]

        mock_cls, _ = _mock_provider(fan_out + reviews + synthesis)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        pr = result.parallel_result
        assert pr is not None
        # b and a get voted for; a→b, b→a, c→a → a wins with 2 votes
        assert pr.winner == "model-a"

    async def test_tie_goes_to_tiebreaker(self):
        """If votes are tied, consensus tiebreaker resolves."""
        registry = {
            "model-a": _make_model_config(
                model="a-v1",
                fitness=RoleFitness(verifier=0.7),
            ),
            "model-b": _make_model_config(
                model="b-v1",
                fitness=RoleFitness(verifier=0.95),
            ),
        }
        fan_out = [
            _make_agent_message("solution A"),
            _make_agent_message("solution B"),
        ]
        # Each votes for the other → tie
        reviews = [
            _make_agent_message(_make_review_content(9, 9, 9)),  # a→b
            _make_agent_message(_make_review_content(9, 9, 9)),  # b→a
        ]
        synthesis = [_make_agent_message("final")]

        mock_cls, _ = _mock_provider(fan_out + reviews + synthesis)

        # Tiebreaker response via consensus protocol
        tiebreak_msg = _make_agent_message(
            "WINNER: model-b\n\nREASONING: Better approach.",
        )
        consensus_mock_cls = MagicMock()
        consensus_mock_inst = MagicMock()
        consensus_mock_cls.return_value = consensus_mock_inst
        consensus_mock_inst.complete = AsyncMock(return_value=tiebreak_msg)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_CONSENSUS_PROVIDER, consensus_mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        # model-b selected by consensus tiebreaker
        assert result.parallel_result.winner == "model-b"


# ── Synthesis ─────────────────────────────────────────────────────

class TestParallelSynthesis:
    async def test_synthesized_output_in_result(self):
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        assert result.parallel_result.synthesized_output != ""


# ── Arbiter Review ────────────────────────────────────────────────

class TestParallelArbiter:
    async def test_arbiter_reviews_synthesized_output(self):
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)
        mock_arbiter = AsyncMock(return_value=_make_approve_review())

        with patch(_PROVIDER, mock_cls), patch(_ARBITER_REVIEW, mock_arbiter):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="bookend",
                ),
                registry=registry,
            )
            result = await orch.run()

        mock_arbiter.assert_called_once()
        assert len(result.arbiter_reviews) == 1
        assert result.success is True

    async def test_arbiter_off_skips_review(self):
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)
        mock_arbiter = AsyncMock()

        with patch(_PROVIDER, mock_cls), patch(_ARBITER_REVIEW, mock_arbiter):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        mock_arbiter.assert_not_called()
        assert result.arbiter_reviews == []

    async def test_arbiter_halt_stops_pipeline(self):
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)
        halt_review = _make_approve_review(
            verdict=Verdict.HALT,
            reasoning="Critical issues in synthesis",
        )
        mock_arbiter = AsyncMock(return_value=halt_review)

        with patch(_PROVIDER, mock_cls), patch(_ARBITER_REVIEW, mock_arbiter):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="bookend",
                ),
                registry=registry,
            )
            result = await orch.run()

        assert result.halted is True
        assert result.success is False


# ── Synthesis Retry on REJECT ─────────────────────────────────────


class TestParallelSynthesisRetry:
    """Tests for retry-on-REJECT in parallel synthesis."""

    async def test_reject_triggers_retry_then_approve(self):
        """REJECT verdict retries synthesis; APPROVE on retry succeeds."""
        registry = _make_three_model_registry()
        # 3 fan-out + 6 reviews + 1 synthesis + 1 retry synthesis = 11
        responses = [_make_agent_message(f"out-{i}") for i in range(11)]
        mock_cls, _ = _mock_provider(responses)

        reject_review = _make_approve_review(
            verdict=Verdict.REJECT,
            reasoning="Missing error handling",
        )
        approve_review = _make_approve_review(verdict=Verdict.APPROVE)
        # First review → REJECT, second (retry) → APPROVE
        mock_arbiter = AsyncMock(side_effect=[reject_review, approve_review])

        with patch(_PROVIDER, mock_cls), patch(_ARBITER_REVIEW, mock_arbiter):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="bookend",
                ),
                registry=registry,
            )
            result = await orch.run()

        assert result.success is True
        assert len(result.arbiter_reviews) == 2
        assert result.arbiter_reviews[0].verdict == Verdict.REJECT
        assert result.arbiter_reviews[1].verdict == Verdict.APPROVE

    async def test_reject_exhausts_retries(self):
        """If synthesis is still REJECTED after max retries, run completes."""
        registry = _make_three_model_registry()
        # 3 fan-out + 6 reviews + 1 synthesis + 2 retry syntheses = 12
        responses = [_make_agent_message(f"out-{i}") for i in range(12)]
        mock_cls, _ = _mock_provider(responses)

        reject_review = _make_approve_review(
            verdict=Verdict.REJECT,
            reasoning="Still bad",
        )
        # All 3 reviews (initial + 2 retries) → REJECT
        mock_arbiter = AsyncMock(return_value=reject_review)

        with patch(_PROVIDER, mock_cls), patch(_ARBITER_REVIEW, mock_arbiter):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="bookend",
                ),
                registry=registry,
            )
            result = await orch.run()

        # Still succeeds (not halted) but has REJECT verdicts
        assert result.success is True
        # 1 initial + 2 retries = 3 reviews
        assert len(result.arbiter_reviews) == 3
        assert all(r.verdict == Verdict.REJECT for r in result.arbiter_reviews)

    async def test_flag_does_not_trigger_retry(self):
        """FLAG verdict does NOT trigger retry (only REJECT does)."""
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)

        flag_review = _make_approve_review(verdict=Verdict.FLAG)
        mock_arbiter = AsyncMock(return_value=flag_review)

        with patch(_PROVIDER, mock_cls), patch(_ARBITER_REVIEW, mock_arbiter):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="bookend",
                ),
                registry=registry,
            )
            result = await orch.run()

        # Only 1 review — no retries triggered
        mock_arbiter.assert_called_once()
        assert len(result.arbiter_reviews) == 1
        assert result.success is True

    async def test_synthesis_retry_uses_retry_template(self):
        """Retry should call parallel_synthesis_retry, not parallel_synthesize."""
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(11)]
        mock_cls, _ = _mock_provider(responses)

        reject_review = _make_approve_review(
            verdict=Verdict.REJECT, reasoning="Issues found",
        )
        approve_review = _make_approve_review(verdict=Verdict.APPROVE)
        mock_arbiter = AsyncMock(side_effect=[reject_review, approve_review])

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, mock_arbiter),
            patch("triad.orchestrator.render_prompt") as mock_render,
        ):
            mock_render.return_value = "system prompt"
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="bookend",
                ),
                registry=registry,
            )
            await orch.run()

        # Collect all template names used in render_prompt calls
        template_names = [call.args[0] for call in mock_render.call_args_list]
        assert "parallel_synthesis_retry" in template_names
        # parallel_synthesize should be called for the initial synthesis
        assert "parallel_synthesize" in template_names

    async def test_synthesis_retry_passes_previous_synthesis(self):
        """Retry template vars should include previous_synthesis."""
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(11)]
        mock_cls, _ = _mock_provider(responses)

        reject_review = _make_approve_review(
            verdict=Verdict.REJECT, reasoning="Issues found",
        )
        approve_review = _make_approve_review(verdict=Verdict.APPROVE)
        mock_arbiter = AsyncMock(side_effect=[reject_review, approve_review])

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, mock_arbiter),
            patch("triad.orchestrator.render_prompt") as mock_render,
        ):
            mock_render.return_value = "system prompt"
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="bookend",
                ),
                registry=registry,
            )
            await orch.run()

        # Find the retry call and check its kwargs
        retry_calls = [
            call for call in mock_render.call_args_list
            if call.args[0] == "parallel_synthesis_retry"
        ]
        assert len(retry_calls) >= 1
        retry_kwargs = retry_calls[0].kwargs
        assert "previous_synthesis" in retry_kwargs
        assert "arbiter_feedback" in retry_kwargs


# ── Cost Tracking ─────────────────────────────────────────────────

class TestParallelCosts:
    async def test_costs_aggregated(self):
        registry = _make_three_model_registry()
        responses = [
            _make_agent_message(f"out-{i}", cost=0.10) for i in range(10)
        ]
        mock_cls, _ = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        # 10 calls * $0.10 = $1.00
        assert abs(result.total_cost - 1.0) < 1e-10


# ── Mode Dispatch ─────────────────────────────────────────────────

class TestModeDispatch:
    async def test_sequential_mode_uses_pipeline_orchestrator(self):
        """run_pipeline with sequential mode uses PipelineOrchestrator."""
        with patch(
            "triad.orchestrator.PipelineOrchestrator"
        ) as mock_orch_cls:
            mock_inst = MagicMock()
            mock_inst.run = AsyncMock(return_value=MagicMock())
            mock_orch_cls.return_value = mock_inst

            await run_pipeline(
                _make_task(),
                PipelineConfig(
                    pipeline_mode="sequential", arbiter_mode="off",
                    persist_sessions=False,
                ),
                _make_three_model_registry(),
            )
            mock_orch_cls.assert_called_once()

    async def test_parallel_mode_uses_parallel_orchestrator(self):
        with patch(
            "triad.orchestrator.ParallelOrchestrator"
        ) as mock_orch_cls:
            mock_inst = MagicMock()
            mock_inst.run = AsyncMock(return_value=MagicMock())
            mock_orch_cls.return_value = mock_inst

            await run_pipeline(
                _make_task(),
                PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                    persist_sessions=False,
                ),
                _make_three_model_registry(),
            )
            mock_orch_cls.assert_called_once()

    async def test_debate_mode_uses_debate_orchestrator(self):
        with patch(
            "triad.orchestrator.DebateOrchestrator"
        ) as mock_orch_cls:
            mock_inst = MagicMock()
            mock_inst.run = AsyncMock(return_value=MagicMock())
            mock_orch_cls.return_value = mock_inst

            await run_pipeline(
                _make_task(),
                PipelineConfig(
                    pipeline_mode="debate", arbiter_mode="off",
                    persist_sessions=False,
                ),
                _make_three_model_registry(),
            )
            mock_orch_cls.assert_called_once()

    async def test_review_mode_uses_review_orchestrator(self):
        with patch(
            "triad.orchestrator.ReviewOrchestrator"
        ) as mock_orch_cls:
            mock_inst = MagicMock()
            mock_inst.run = AsyncMock(return_value=MagicMock())
            mock_orch_cls.return_value = mock_inst

            await run_pipeline(
                _make_task(),
                PipelineConfig(
                    pipeline_mode="review", arbiter_mode="off",
                    persist_sessions=False,
                ),
                _make_three_model_registry(),
            )
            mock_orch_cls.assert_called_once()

    async def test_improve_mode_uses_improve_orchestrator(self):
        with patch(
            "triad.orchestrator.ImproveOrchestrator"
        ) as mock_orch_cls:
            mock_inst = MagicMock()
            mock_inst.run = AsyncMock(return_value=MagicMock())
            mock_orch_cls.return_value = mock_inst

            await run_pipeline(
                _make_task(),
                PipelineConfig(
                    pipeline_mode="improve", arbiter_mode="off",
                    persist_sessions=False,
                ),
                _make_three_model_registry(),
            )
            mock_orch_cls.assert_called_once()


# ── _select_top_models ───────────────────────────────────────────


class TestSelectTopModels:
    """Tests for the _select_top_models() filtering function."""

    def test_returns_full_registry_when_small(self):
        """If registry has <= N models, return it unchanged."""
        from triad.orchestrator import _select_top_models

        registry = _make_three_model_registry()
        result = _select_top_models(registry, n=3)
        assert result == registry

    def test_returns_full_registry_when_fewer_than_n(self):
        """If registry has fewer than N models, return it unchanged."""
        from triad.orchestrator import _select_top_models

        registry = {
            "model-a": _make_model_config(model="a-v1"),
            "model-b": _make_model_config(model="b-v1"),
        }
        result = _select_top_models(registry, n=3)
        assert result == registry

    def test_selects_top_n_by_average_fitness(self):
        """When registry has > N models, select top N by avg fitness."""
        from triad.orchestrator import _select_top_models

        registry = {
            "high": _make_model_config(
                model="high-v1",
                fitness=RoleFitness(
                    architect=0.95, implementer=0.90,
                    refactorer=0.85, verifier=0.90,
                ),
            ),
            "mid": _make_model_config(
                model="mid-v1",
                fitness=RoleFitness(
                    architect=0.80, implementer=0.80,
                    refactorer=0.80, verifier=0.80,
                ),
            ),
            "low": _make_model_config(
                model="low-v1",
                fitness=RoleFitness(
                    architect=0.50, implementer=0.50,
                    refactorer=0.50, verifier=0.50,
                ),
            ),
            "lowest": _make_model_config(
                model="lowest-v1",
                fitness=RoleFitness(
                    architect=0.30, implementer=0.30,
                    refactorer=0.30, verifier=0.30,
                ),
            ),
        }
        result = _select_top_models(registry, n=2)
        assert len(result) == 2
        assert "high" in result
        assert "mid" in result
        assert "low" not in result
        assert "lowest" not in result

    def test_default_n_is_three(self):
        """Default n=3 selects top 3 models."""
        from triad.orchestrator import _select_top_models

        registry = {
            f"model-{i}": _make_model_config(
                model=f"model-{i}-v1",
                fitness=RoleFitness(
                    architect=0.1 * (i + 1), implementer=0.1 * (i + 1),
                    refactorer=0.1 * (i + 1), verifier=0.1 * (i + 1),
                ),
            )
            for i in range(5)
        }
        result = _select_top_models(registry)
        assert len(result) == 3
        # Top 3 should be models 4, 3, 2 (highest average fitness)
        assert "model-4" in result
        assert "model-3" in result
        assert "model-2" in result

    def test_single_model_returned_unchanged(self):
        """A single-model registry is returned as-is."""
        from triad.orchestrator import _select_top_models

        registry = {"only": _make_model_config(model="only-v1")}
        result = _select_top_models(registry, n=3)
        assert result == registry

    def test_provider_diversity_one_per_provider(self):
        """With 4 providers, 3-model fan-out picks best from 3 different providers."""
        from triad.orchestrator import _select_top_models

        registry = {
            # Anthropic — two models, highest fitness overall
            "claude-opus": _make_model_config(
                model="claude-opus-v1", provider="anthropic",
                fitness=RoleFitness(
                    architect=0.95, implementer=0.90,
                    refactorer=0.90, verifier=0.90,
                ),
            ),
            "claude-sonnet": _make_model_config(
                model="claude-sonnet-v1", provider="anthropic",
                fitness=RoleFitness(
                    architect=0.85, implementer=0.85,
                    refactorer=0.85, verifier=0.85,
                ),
            ),
            # OpenAI
            "o3": _make_model_config(
                model="o3-v1", provider="openai",
                fitness=RoleFitness(
                    architect=0.88, implementer=0.82,
                    refactorer=0.85, verifier=0.88,
                ),
            ),
            # Google
            "gemini-pro": _make_model_config(
                model="gemini-pro-v1", provider="google",
                fitness=RoleFitness(
                    architect=0.80, implementer=0.80,
                    refactorer=0.80, verifier=0.80,
                ),
            ),
        }
        result = _select_top_models(registry, n=3)
        assert len(result) == 3
        # Should pick best from 3 different providers
        providers = {registry[k].provider for k in result}
        assert len(providers) == 3
        # claude-opus (anthropic), o3 (openai), gemini-pro (google)
        assert "claude-opus" in result
        assert "o3" in result
        assert "gemini-pro" in result
        # claude-sonnet should NOT be picked despite being 3rd highest fitness
        assert "claude-sonnet" not in result

    def test_provider_diversity_falls_back_when_fewer_providers(self):
        """When fewer providers than N, second model from same provider fills the gap."""
        from triad.orchestrator import _select_top_models

        registry = {
            "claude-opus": _make_model_config(
                model="claude-opus-v1", provider="anthropic",
                fitness=RoleFitness(
                    architect=0.95, implementer=0.90,
                    refactorer=0.90, verifier=0.90,
                ),
            ),
            "claude-sonnet": _make_model_config(
                model="claude-sonnet-v1", provider="anthropic",
                fitness=RoleFitness(
                    architect=0.85, implementer=0.85,
                    refactorer=0.85, verifier=0.85,
                ),
            ),
            "o3": _make_model_config(
                model="o3-v1", provider="openai",
                fitness=RoleFitness(
                    architect=0.88, implementer=0.82,
                    refactorer=0.85, verifier=0.88,
                ),
            ),
            "gpt-4o-mini": _make_model_config(
                model="gpt-4o-mini-v1", provider="openai",
                fitness=RoleFitness(
                    architect=0.60, implementer=0.60,
                    refactorer=0.60, verifier=0.60,
                ),
            ),
        }
        # Only 2 providers, but n=3 — must reuse a provider
        result = _select_top_models(registry, n=3)
        assert len(result) == 3
        # Pass 1: claude-opus (anthropic) + o3 (openai)
        # Pass 2: claude-sonnet (next-best remaining)
        assert "claude-opus" in result
        assert "o3" in result
        assert "claude-sonnet" in result
        assert "gpt-4o-mini" not in result

    def test_provider_diversity_same_provider_all_models(self):
        """When all models share a provider, falls back to pure fitness ranking."""
        from triad.orchestrator import _select_top_models

        registry = {
            "high": _make_model_config(
                model="high-v1", provider="anthropic",
                fitness=RoleFitness(
                    architect=0.95, implementer=0.95,
                    refactorer=0.95, verifier=0.95,
                ),
            ),
            "mid": _make_model_config(
                model="mid-v1", provider="anthropic",
                fitness=RoleFitness(
                    architect=0.80, implementer=0.80,
                    refactorer=0.80, verifier=0.80,
                ),
            ),
            "low": _make_model_config(
                model="low-v1", provider="anthropic",
                fitness=RoleFitness(
                    architect=0.50, implementer=0.50,
                    refactorer=0.50, verifier=0.50,
                ),
            ),
            "lowest": _make_model_config(
                model="lowest-v1", provider="anthropic",
                fitness=RoleFitness(
                    architect=0.30, implementer=0.30,
                    refactorer=0.30, verifier=0.30,
                ),
            ),
        }
        result = _select_top_models(registry, n=2)
        assert len(result) == 2
        # Same behavior as before — top 2 by fitness
        assert "high" in result
        assert "mid" in result


# ── Parallel Event Emissions ─────────────────────────────────────


class TestParallelEventEmissions:
    """Tests that the parallel orchestrator emits correct events."""

    async def test_emits_pipeline_started_and_completed(self):
        """Parallel mode should emit pipeline_started and pipeline_completed."""
        from triad.dashboard.events import PipelineEventEmitter

        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)
        emitter = PipelineEventEmitter()

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
                event_emitter=emitter,
            )
            await orch.run()

        event_types = [e.type for e in emitter.history]
        assert "pipeline_started" in event_types
        assert "pipeline_completed" in event_types

    async def test_emits_stage_started_and_completed(self):
        """Parallel mode should emit stage_started/completed for each phase."""
        from triad.dashboard.events import PipelineEventEmitter

        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)
        emitter = PipelineEventEmitter()

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
                event_emitter=emitter,
            )
            await orch.run()

        event_types = [e.type for e in emitter.history]
        assert event_types.count("stage_started") >= 3
        assert event_types.count("stage_completed") >= 3

    async def test_emits_consensus_vote(self):
        """Parallel mode should emit a consensus_vote event."""
        from triad.dashboard.events import PipelineEventEmitter

        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)
        emitter = PipelineEventEmitter()

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
                event_emitter=emitter,
            )
            await orch.run()

        event_types = [e.type for e in emitter.history]
        assert "consensus_vote" in event_types

    async def test_pipeline_started_event_has_mode_parallel(self):
        """pipeline_started event should have mode='parallel'."""
        from triad.dashboard.events import PipelineEventEmitter

        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)
        emitter = PipelineEventEmitter()

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
                event_emitter=emitter,
            )
            await orch.run()

        started_events = [
            e for e in emitter.history if e.type == "pipeline_started"
        ]
        assert len(started_events) == 1
        assert started_events[0].data["mode"] == "parallel"

    async def test_no_events_when_no_emitter(self):
        """When no emitter is provided, no events are emitted (no error)."""
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
                event_emitter=None,
            )
            result = await orch.run()

        # Should complete without error
        assert result.success is True


# ── Parallel Fallback ────────────────────────────────────────────


class TestParallelFallback:
    """Tests for _call_model fallback behavior in ParallelOrchestrator."""

    async def test_fallback_on_primary_failure(self):
        """If the primary model fails, fallback to another model."""
        registry = _make_three_model_registry()
        # First call fails (primary), then all subsequent succeed
        fail_then_succeed = AsyncMock(
            side_effect=[
                RuntimeError("rate limited"),
                _make_agent_message("fallback output"),
                # Remaining calls for the rest of the pipeline
            ] + [_make_agent_message(f"out-{i}") for i in range(20)],
        )

        mock_cls = MagicMock()
        mock_inst = MagicMock()
        mock_cls.return_value = mock_inst
        mock_inst.complete = fail_then_succeed

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            # The _call_model should retry with a fallback on failure
            msg = await orch._call_model(
                "model-a",
                registry["model-a"],
                "architect",
                120,
            )

        assert msg.content is not None

    async def test_all_models_fail_raises(self):
        """If all models fail, RuntimeError is raised."""
        registry = {
            "model-a": _make_model_config(model="a-v1"),
        }

        mock_cls = MagicMock()
        mock_inst = MagicMock()
        mock_cls.return_value = mock_inst
        mock_inst.complete = AsyncMock(
            side_effect=RuntimeError("all down"),
        )

        with patch(_PROVIDER, mock_cls):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            with pytest.raises(RuntimeError, match="All models failed"):
                await orch._call_model(
                    "model-a",
                    registry["model-a"],
                    "architect",
                    120,
                )


# ── _detect_incomplete_sections ──────────────────────────────────


class TestDetectIncompleteSections:
    """Tests for the incompleteness scanner."""

    def test_detects_pass_placeholder(self):
        code = "def foo():\n    pass\n"
        issues = _detect_incomplete_sections(code)
        assert any("pass" in i for i in issues)

    def test_detects_ellipsis_placeholder(self):
        code = "def foo():\n    ...\n"
        issues = _detect_incomplete_sections(code)
        assert any("..." in i or "pass" in i for i in issues)

    def test_detects_todo_comments(self):
        code = "def foo():\n    # TODO: implement this\n    return None\n"
        issues = _detect_incomplete_sections(code)
        assert any("TODO" in i for i in issues)

    def test_detects_fixme_comments(self):
        code = "def foo():\n    # FIXME: broken\n    return None\n"
        issues = _detect_incomplete_sections(code)
        assert any("TODO" in i or "FIXME" in i for i in issues)

    def test_detects_missing_file_implementations(self):
        code = (
            "## Project Structure\n"
            "├── src/main.py\n"
            "├── src/utils.py\n"
            "└── src/models.py\n\n"
            "# file: src/main.py\n"
            "```python\nprint('hello')\n```\n"
        )
        issues = _detect_incomplete_sections(code)
        assert any("utils.py" in i or "models.py" in i for i in issues)

    def test_no_issues_for_complete_code(self):
        code = (
            "# file: src/main.py\n"
            "```python\n"
            "def greet(name: str) -> str:\n"
            "    return f'Hello, {name}!'\n"
            "```\n"
        )
        issues = _detect_incomplete_sections(code)
        assert issues == []

    def test_detects_lazy_implementation_language(self):
        code = "# Implementation left as exercise\ndef foo(): return 1\n"
        issues = _detect_incomplete_sections(code)
        assert any("Lazy" in i for i in issues)

    def test_detects_placeholder_keyword(self):
        code = "# This is a placeholder for the real implementation\ndef foo(): return 1\n"
        issues = _detect_incomplete_sections(code)
        assert any("Lazy" in i or "placeholder" in i.lower() for i in issues)

    def test_pass_inside_string_not_detected(self):
        """'pass' as part of a word like 'password' should not trigger."""
        code = "password = 'secret'\nbypass = True\n"
        issues = _detect_incomplete_sections(code)
        # Should not detect placeholder 'pass' in 'password' or 'bypass'
        assert not any("`pass`" in i for i in issues)


# ── Completion Pass Integration ──────────────────────────────────


class TestParallelCompletionPass:
    """Tests for the completion pass after synthesis."""

    async def test_completion_fires_when_incomplete(self):
        """Completion pass should fire when synthesis output has TODO markers."""
        registry = _make_three_model_registry()
        # 3 fan-out + 6 reviews + 1 synthesis (with TODO) + 1 completion = 11
        incomplete_synthesis = _make_agent_message(
            "# file: main.py\ndef handle():\n    # TODO: implement\n    pass"
        )
        complete_output = _make_agent_message(
            "# file: main.py\ndef handle():\n    return {'status': 'ok'}"
        )
        fan_out = [_make_agent_message(f"solution-{i}") for i in range(3)]
        reviews = [_make_agent_message(_make_review_content(8, 8, 8)) for _ in range(6)]
        responses = fan_out + reviews + [incomplete_synthesis, complete_output]

        mock_cls, _ = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
            patch("triad.orchestrator.render_prompt") as mock_render,
        ):
            mock_render.return_value = "system prompt"
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        template_names = [call.args[0] for call in mock_render.call_args_list]
        assert "parallel_completion" in template_names
        assert result.success is True

    async def test_completion_skipped_when_output_complete(self):
        """Completion pass should not fire when synthesis is already complete."""
        registry = _make_three_model_registry()
        # 3 fan-out + 6 reviews + 1 synthesis (complete) = 10
        complete_synthesis = _make_agent_message(
            "# file: main.py\n"
            "def handle():\n"
            "    return {'status': 'ok'}\n"
        )
        fan_out = [_make_agent_message(f"solution-{i}") for i in range(3)]
        reviews = [_make_agent_message(_make_review_content(8, 8, 8)) for _ in range(6)]
        responses = fan_out + reviews + [complete_synthesis]

        mock_cls, _ = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
            patch("triad.orchestrator.render_prompt") as mock_render,
        ):
            mock_render.return_value = "system prompt"
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        template_names = [call.args[0] for call in mock_render.call_args_list]
        assert "parallel_completion" not in template_names
        assert result.success is True

    async def test_completion_pass_output_replaces_synthesis(self):
        """The completion pass output should become the final synthesized_output."""
        registry = _make_three_model_registry()
        incomplete_synthesis = _make_agent_message(
            "# file: main.py\ndef handle():\n    # TODO: implement\n    pass"
        )
        completed_code = _make_agent_message(
            "# file: main.py\ndef handle():\n    return {'status': 'ok'}"
        )
        fan_out = [_make_agent_message(f"solution-{i}") for i in range(3)]
        reviews = [_make_agent_message(_make_review_content(8, 8, 8)) for _ in range(6)]
        responses = fan_out + reviews + [incomplete_synthesis, completed_code]

        mock_cls, _ = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=None)),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        # The final output should be from the completion pass, not the original synthesis
        assert "return {'status': 'ok'}" in result.parallel_result.synthesized_output

    async def test_completion_pass_cost_included_in_total(self):
        """Completion pass token cost should be included in the total."""
        registry = _make_three_model_registry()
        incomplete_synthesis = _make_agent_message(
            "# file: main.py\ndef handle():\n    # TODO: implement\n    pass",
            cost=0.10,
        )
        completed = _make_agent_message(
            "# file: main.py\ndef handle():\n    return 1",
            cost=0.15,
        )
        fan_out = [_make_agent_message(f"s-{i}", cost=0.10) for i in range(3)]
        reviews = [_make_agent_message(_make_review_content(8, 8, 8), cost=0.01) for _ in range(6)]
        responses = fan_out + reviews + [incomplete_synthesis, completed]

        mock_cls, _ = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=None)),
        ):
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            result = await orch.run()

        # 3*0.10 + 6*0.01 + 0.10 + 0.15 = 0.30 + 0.06 + 0.10 + 0.15 = 0.61
        assert result.total_cost > 0.55

    async def test_fan_out_uses_parallel_generate_template(self):
        """Fan-out should use 'parallel_generate' template, not 'architect'."""
        registry = _make_three_model_registry()
        responses = [_make_agent_message(f"out-{i}") for i in range(10)]
        mock_cls, _ = _mock_provider(responses)

        with (
            patch(_PROVIDER, mock_cls),
            patch(_ARBITER_REVIEW, AsyncMock(return_value=_make_approve_review())),
            patch("triad.orchestrator.render_prompt") as mock_render,
        ):
            mock_render.return_value = "system prompt"
            orch = ParallelOrchestrator(
                task=_make_task(),
                config=PipelineConfig(
                    pipeline_mode="parallel", arbiter_mode="off",
                ),
                registry=registry,
            )
            await orch.run()

        template_names = [call.args[0] for call in mock_render.call_args_list]
        # Fan-out calls should use parallel_generate, not architect
        assert "parallel_generate" in template_names
        assert "architect" not in template_names
