"""Dry-run tests — verify the full pipeline without any LLM calls.

These tests exercise every code path through the orchestrator to ensure
no infinite loops, no budget bypasses, and complete artifact production.
"""

import json
from pathlib import Path

from dpe.engine.orchestrator import Orchestrator
from dpe.models.config import BudgetLimits, DPEConfig, ThrottleBehavior


class TestDryRunComplete:
    """Full pipeline dry-run with default config (cross-critique off, 2 critique rounds)."""

    def test_full_run_completes(self, tmp_path: Path) -> None:
        cfg = DPEConfig(simulation_mode=True, output_dir=str(tmp_path))
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Build a distributed task queue")
        assert manifest.stop_reason == "completed"

    def test_all_mandatory_stages_executed(self, tmp_path: Path) -> None:
        cfg = DPEConfig(simulation_mode=True, output_dir=str(tmp_path))
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        executed = {s.stage for s in manifest.stages if not s.skipped}
        mandatory = {
            "INPUT_NORMALIZE", "IDEATION_A", "IDEATION_B",
            "SYNTHESIS", "PRD_V1", "PRD_CRITIQUE_R1", "PRD_REVISION_R1",
            "FINALIZE",
        }
        assert mandatory.issubset(executed)

    def test_manifest_has_all_required_fields(self, tmp_path: Path) -> None:
        cfg = DPEConfig(simulation_mode=True, output_dir=str(tmp_path))
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        data = manifest.model_dump(mode="json")
        required_keys = {
            "run_id", "timestamp", "config_hash", "models_used",
            "budget_limits", "budget_actuals", "throttle_events",
            "stages", "artifacts", "stop_reason", "warnings",
        }
        assert required_keys.issubset(set(data.keys()))

    def test_manifest_json_roundtrips(self, tmp_path: Path) -> None:
        cfg = DPEConfig(simulation_mode=True, output_dir=str(tmp_path))
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        manifest_path = orch._registry.run_dir / "run_manifest.json"
        loaded = json.loads(manifest_path.read_text(encoding="utf-8"))
        assert loaded["run_id"] == manifest.run_id
        assert loaded["config_hash"] == cfg.config_hash()


class TestDryRunWithCrossCritique:
    """Dry-run with cross-critique enabled — needs higher budget."""

    def test_cross_critique_with_sufficient_budget(self, tmp_path: Path) -> None:
        cfg = DPEConfig(
            simulation_mode=True,
            output_dir=str(tmp_path),
            enable_cross_critique=True,
            budget=BudgetLimits(max_total_calls=15),
        )
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        assert manifest.stop_reason == "completed"
        executed = {s.stage for s in manifest.stages if not s.skipped}
        assert "CROSS_CRITIQUE_A" in executed
        assert "CROSS_CRITIQUE_B" in executed


class TestDryRunBudgetEdgeCases:
    """Test budget enforcement at exact boundaries."""

    def test_exact_budget_for_default_config(self, tmp_path: Path) -> None:
        """Default config should complete with exactly 10 calls."""
        cfg = DPEConfig(
            simulation_mode=True,
            output_dir=str(tmp_path),
            budget=BudgetLimits(max_total_calls=10),
        )
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        assert manifest.stop_reason == "completed"
        assert manifest.budget_actuals.total_calls_used == 10

    def test_one_under_budget_fails(self, tmp_path: Path) -> None:
        """9 calls should not be enough for default config."""
        cfg = DPEConfig(
            simulation_mode=True,
            output_dir=str(tmp_path),
            budget=BudgetLimits(max_total_calls=9),
        )
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        assert manifest.stop_reason == "budget_exceeded"

    def test_degrade_to_local_on_premium_breach(self, tmp_path: Path) -> None:
        """Degradation should allow completion even when premium budget is tight."""
        cfg = DPEConfig(
            simulation_mode=True,
            output_dir=str(tmp_path),
            budget=BudgetLimits(
                max_total_calls=15,
                max_premium_calls=2,
                throttle_behavior=ThrottleBehavior.DEGRADE_TO_LOCAL,
            ),
        )
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        # Should degrade but still complete
        assert manifest.stop_reason == "completed" or "Degraded" in str(manifest.warnings)


class TestDryRunCritiqueRoundsZero:
    """Verify max_critique_rounds=0 skips all critique and revision stages."""

    def test_zero_critique_rounds_skips_r1(self, tmp_path: Path) -> None:
        cfg = DPEConfig(
            simulation_mode=True,
            output_dir=str(tmp_path),
            budget=BudgetLimits(max_critique_rounds=0, max_total_calls=15),
        )
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        assert manifest.stop_reason == "completed"
        executed = {s.stage for s in manifest.stages if not s.skipped}
        skipped = {s.stage for s in manifest.stages if s.skipped}
        # R1 critique and revision should be skipped
        assert "PRD_CRITIQUE_R1" in skipped
        assert "PRD_REVISION_R1" in skipped
        # R2 should also be skipped (depends on R1)
        assert "PRD_CRITIQUE_R2" in skipped
        assert "PRD_REVISION_R2" in skipped
        # Core stages still execute
        assert "INPUT_NORMALIZE" in executed
        assert "IDEATION_A" in executed
        assert "PRD_V1" in executed
        assert "FINALIZE" in executed

    def test_zero_critique_rounds_uses_fewer_calls(self, tmp_path: Path) -> None:
        cfg = DPEConfig(
            simulation_mode=True,
            output_dir=str(tmp_path),
            budget=BudgetLimits(max_critique_rounds=0, max_total_calls=15),
        )
        orch = Orchestrator(config=cfg)
        manifest = orch.run("Test idea")
        # Without critique rounds: 6 stages (normalize, A, B, synthesis, PRD_V1, finalize)
        assert manifest.budget_actuals.total_calls_used == 6


class TestDryRunDeterminism:
    """Verify deterministic behavior across runs."""

    def test_same_config_same_hash(self) -> None:
        cfg1 = DPEConfig(simulation_mode=True)
        cfg2 = DPEConfig(simulation_mode=True)
        assert cfg1.config_hash() == cfg2.config_hash()

    def test_different_config_different_hash(self) -> None:
        cfg1 = DPEConfig(simulation_mode=True)
        cfg2 = DPEConfig(simulation_mode=True, enable_cross_critique=True)
        assert cfg1.config_hash() != cfg2.config_hash()

    def test_stage_count_deterministic(self, tmp_path: Path) -> None:
        cfg = DPEConfig(simulation_mode=True, output_dir=str(tmp_path))
        orch1 = Orchestrator(config=cfg)
        m1 = orch1.run("Same idea")
        cfg2 = DPEConfig(simulation_mode=True, output_dir=str(tmp_path))
        orch2 = Orchestrator(config=cfg2)
        m2 = orch2.run("Same idea")
        assert len(m1.stages) == len(m2.stages)
        assert m1.budget_actuals.total_calls_used == m2.budget_actuals.total_calls_used
