"""
Tests for the Chain Planner — the core intelligence on our servers.

Tests cover:
    - Intent classification (analyze, write, research, compound intents)
    - Model scoring and ranking (task affinity, constraints, learned adjustments)
    - Strategy selection (single, sequential, fan-out, debate, refine)
    - Prompt optimization per model family (Claude XML, Gemini markdown, local direct)
    - Full planning pipeline (request -> ExecutionPlan)
    - Quality evaluation (scoring, history tracking, suggestions)
    - Tier enforcement (free vs pro step limits)
"""

from __future__ import annotations

import os
import tempfile
import pytest

from cloud.planner.chain_planner import (
    ChainPlanner,
    PlanRequest,
    PlanningError,
    classify_intent,
    _classify_step_type,
)
from cloud.planner.model_scorer import (
    ModelScorer,
    ModelScore,
    TASK_TYPES,
    _parse_model_id,
    _make_model_id,
)
from cloud.planner.prompt_engine import (
    PromptEngine,
    PromptPackage,
    get_model_family,
)
from cloud.planner.strategies import (
    SingleModelStrategy,
    SequentialChainStrategy,
    FanOutStrategy,
    DebateStrategy,
    RefineStrategy,
    StepSpec,
    select_strategy,
    STRATEGIES,
)
from cloud.planner.quality_eval import (
    QualityEvaluator,
    StepReport,
)


# ===========================================================================
# Intent Classification
# ===========================================================================


class TestIntentClassification:
    """Test that user requests are correctly classified into task types."""

    def test_analyze_intent(self):
        tasks = classify_intent("Analyze this quarterly report")
        assert "analysis" in tasks

    def test_research_intent(self):
        tasks = classify_intent("Research the latest trends in AI")
        assert "research" in tasks

    def test_write_intent(self):
        tasks = classify_intent("Write a blog post about climate change")
        assert "creative" in tasks

    def test_code_intent(self):
        tasks = classify_intent("Write a Python function to sort a list")
        assert "code" in tasks

    def test_summarize_intent(self):
        tasks = classify_intent("Summarize this long document")
        assert "summary" in tasks

    def test_fact_check_intent(self):
        tasks = classify_intent("Fact-check these claims about vaccines")
        assert "fact_check" in tasks

    def test_compare_intent_produces_judge(self):
        tasks = classify_intent("Compare the pros and cons of remote work")
        assert "judge" in tasks or "analysis" in tasks

    def test_compound_analyze_and_summarize(self):
        tasks = classify_intent("Analyze this data and summarize the findings")
        assert "analysis" in tasks
        assert "summary" in tasks

    def test_compound_research_and_fact_check(self):
        tasks = classify_intent("Research this topic and fact-check the key claims")
        assert "research" in tasks
        assert "fact_check" in tasks

    def test_unknown_intent_returns_general(self):
        tasks = classify_intent("Hello, how are you?")
        assert tasks == ["general"]

    def test_empty_input_returns_general(self):
        tasks = classify_intent("")
        assert tasks == ["general"]

    def test_multiple_intents_capped_at_three(self):
        tasks = classify_intent(
            "Analyze, research, write, summarize, and fact-check everything"
        )
        assert len(tasks) <= 3

    def test_explain_maps_to_analysis(self):
        tasks = classify_intent("Explain how quantum computing works")
        assert "analysis" in tasks

    def test_review_maps_to_critique(self):
        tasks = classify_intent("Review this essay and give feedback")
        assert "critique" in tasks


# ===========================================================================
# Model Scoring
# ===========================================================================


class TestModelScorer:
    """Test model ranking across different task types."""

    @pytest.fixture
    def scorer(self):
        return ModelScorer()

    @pytest.fixture
    def all_models(self):
        return [
            "claude:claude-sonnet-4-20250514",
            "gemini:gemini-2.5-pro",
            "ollama:llama3",
            "ollama:mistral",
        ]

    def test_rank_returns_sorted_list(self, scorer, all_models):
        ranked = scorer.rank("analysis", all_models)
        assert len(ranked) == len(all_models)
        # Should be sorted by composite score descending
        for i in range(len(ranked) - 1):
            assert ranked[i].composite >= ranked[i + 1].composite

    def test_rank_for_analysis_prefers_claude(self, scorer, all_models):
        ranked = scorer.rank("analysis", all_models)
        # Claude should score high for analysis (strong quality + affinity)
        assert ranked[0].backend == "claude"

    def test_rank_for_summary_prefers_fast_model(self, scorer, all_models):
        ranked = scorer.rank("summary", all_models)
        # For summary, speed and cost matter more — local models should rank well
        top = ranked[0]
        # Either a local model or a fast cloud model should be at top
        assert top.composite > 0

    def test_rank_for_research_prefers_gemini(self, scorer, all_models):
        ranked = scorer.rank("research", all_models)
        # Gemini is strong at research
        top_backends = [r.backend for r in ranked[:2]]
        assert "gemini" in top_backends or "claude" in top_backends

    def test_rank_unknown_task_falls_back_to_general(self, scorer, all_models):
        ranked = scorer.rank("nonexistent_task", all_models)
        assert len(ranked) > 0
        # Should use "general" weights

    def test_rank_empty_models_returns_empty(self, scorer):
        ranked = scorer.rank("analysis", [])
        assert ranked == []

    def test_budget_constraint_filters_expensive(self, scorer):
        models = [
            "claude:claude-opus-4-20250514",  # expensive
            "ollama:llama3",                   # free
        ]
        ranked = scorer.rank("analysis", models, budget_constraint=0.001)
        # Opus costs 0.075/1k, should be filtered out. Llama is free.
        model_ids = [r.model_id for r in ranked]
        assert "claude:claude-opus-4-20250514" not in model_ids
        assert "ollama:llama3" in model_ids

    def test_latency_constraint_filters_slow(self, scorer):
        models = [
            "claude:claude-opus-4-20250514",  # ~8000ms
            "ollama:phi3",                     # ~400ms
        ]
        ranked = scorer.rank("analysis", models, latency_constraint_ms=1000)
        model_ids = [r.model_id for r in ranked]
        assert "claude:claude-opus-4-20250514" not in model_ids
        assert "ollama:phi3" in model_ids

    def test_best_for_returns_single_model(self, scorer, all_models):
        best = scorer.best_for("code", all_models)
        assert best is not None
        assert isinstance(best, ModelScore)

    def test_best_for_empty_returns_none(self, scorer):
        best = scorer.best_for("code", [])
        assert best is None

    def test_model_score_has_reason(self, scorer, all_models):
        ranked = scorer.rank("analysis", all_models)
        for score in ranked:
            assert isinstance(score.reason, str)

    def test_parse_model_id_with_backend(self):
        backend, model = _parse_model_id("claude:claude-sonnet-4")
        assert backend == "claude"
        assert model == "claude-sonnet-4"

    def test_parse_model_id_bare_claude(self):
        backend, model = _parse_model_id("claude-sonnet-4")
        assert backend == "claude"

    def test_parse_model_id_bare_ollama(self):
        backend, model = _parse_model_id("llama3")
        assert backend == "ollama"

    def test_parse_model_id_bare_gemini(self):
        backend, model = _parse_model_id("gemini-2.5-pro")
        assert backend == "gemini"

    def test_learned_adjustments_update(self, scorer):
        model_id = "claude:claude-sonnet-4"
        # Initial rank
        ranked_before = scorer.rank("analysis", [model_id])
        score_before = ranked_before[0].composite if ranked_before else 0

        # Report several successful fast executions
        for _ in range(5):
            scorer.update_from_report(
                model_id=model_id,
                task_type="analysis",
                success=True,
                duration_ms=500,
                token_count=100,
            )

        # Score should have increased (positive adjustment)
        ranked_after = scorer.rank("analysis", [model_id])
        score_after = ranked_after[0].composite if ranked_after else 0
        assert score_after >= score_before

    def test_learned_adjustments_penalize_failures(self, scorer):
        model_id = "claude:claude-sonnet-4"
        ranked_before = scorer.rank("analysis", [model_id])
        score_before = ranked_before[0].composite

        # Report failures
        for _ in range(5):
            scorer.update_from_report(
                model_id=model_id,
                task_type="analysis",
                success=False,
                duration_ms=5000,
                token_count=0,
            )

        ranked_after = scorer.rank("analysis", [model_id])
        score_after = ranked_after[0].composite
        assert score_after <= score_before


# ===========================================================================
# Strategy Selection
# ===========================================================================


class TestStrategySelection:
    """Test that the right strategy is selected for different scenarios."""

    def test_single_task_single_model(self):
        strategy = select_strategy(["analysis"], num_available_models=1)
        assert strategy.name == "single"

    def test_multiple_tasks_sequential(self):
        strategy = select_strategy(
            ["analysis", "summary"], num_available_models=3,
        )
        assert strategy.name == "sequential"

    def test_debate_keywords(self):
        strategy = select_strategy(
            ["analysis"], num_available_models=3,
            user_request="debate the pros and cons of AI regulation",
        )
        assert strategy.name == "debate"

    def test_fanout_keywords(self):
        strategy = select_strategy(
            ["research"], num_available_models=3,
            user_request="get multiple perspectives on this policy",
        )
        assert strategy.name == "fan_out"

    def test_refine_keywords(self):
        strategy = select_strategy(
            ["creative"], num_available_models=3,
            user_request="write a draft and then refine it",
        )
        assert strategy.name == "refine"

    def test_single_model_forces_single_or_sequential(self):
        strategy = select_strategy(
            ["analysis"], num_available_models=1,
            user_request="debate this topic",
        )
        # With only 1 model, can't do debate
        assert strategy.name in ("single", "sequential")

    def test_all_strategies_registered(self):
        assert "single" in STRATEGIES
        assert "sequential" in STRATEGIES
        assert "fan_out" in STRATEGIES
        assert "debate" in STRATEGIES
        assert "refine" in STRATEGIES


# ===========================================================================
# Strategy Step Building
# ===========================================================================


class TestStrategySteps:
    """Test that each strategy produces correct step specifications."""

    @pytest.fixture
    def ranked_models(self):
        """Simulated ranked models for testing."""
        return {
            "analysis": [
                {"model_id": "claude:claude-sonnet-4", "backend": "claude", "model": "claude-sonnet-4", "composite": 0.9},
                {"model_id": "gemini:gemini-2.5-pro", "backend": "gemini", "model": "gemini-2.5-pro", "composite": 0.85},
                {"model_id": "ollama:llama3", "backend": "ollama", "model": "llama3", "composite": 0.6},
            ],
            "summary": [
                {"model_id": "ollama:llama3", "backend": "ollama", "model": "llama3", "composite": 0.8},
                {"model_id": "claude:claude-sonnet-4", "backend": "claude", "model": "claude-sonnet-4", "composite": 0.7},
            ],
            "judge": [
                {"model_id": "claude:claude-sonnet-4", "backend": "claude", "model": "claude-sonnet-4", "composite": 0.95},
            ],
            "critique": [
                {"model_id": "claude:claude-sonnet-4", "backend": "claude", "model": "claude-sonnet-4", "composite": 0.9},
            ],
            "creative": [
                {"model_id": "claude:claude-sonnet-4", "backend": "claude", "model": "claude-sonnet-4", "composite": 0.88},
                {"model_id": "ollama:llama3", "backend": "ollama", "model": "llama3", "composite": 0.55},
            ],
            "draft": [
                {"model_id": "ollama:llama3", "backend": "ollama", "model": "llama3", "composite": 0.75},
            ],
            "general": [
                {"model_id": "ollama:llama3", "backend": "ollama", "model": "llama3", "composite": 0.65},
            ],
        }

    def test_single_produces_one_step(self, ranked_models):
        strategy = SingleModelStrategy()
        steps = strategy.build_steps("Analyze this", ["analysis"], ranked_models)
        assert len(steps) == 1
        assert steps[0].task_type == "analysis"
        assert steps[0].model_hint == "claude:claude-sonnet-4"

    def test_sequential_produces_ordered_steps(self, ranked_models):
        strategy = SequentialChainStrategy()
        steps = strategy.build_steps(
            "Analyze and summarize",
            ["analysis", "summary"],
            ranked_models,
        )
        assert len(steps) == 2
        assert steps[0].task_type == "analysis"
        assert steps[1].task_type == "summary"
        # Second step should reference first step's output
        assert "step_1" in steps[1].context_refs

    def test_sequential_context_refs_grow(self, ranked_models):
        strategy = SequentialChainStrategy()
        steps = strategy.build_steps(
            "Three step chain",
            ["analysis", "summary", "creative"],
            ranked_models,
        )
        assert len(steps) == 3
        # Step 3 should reference steps 1 and 2
        assert "step_1" in steps[2].context_refs
        assert "step_2" in steps[2].context_refs

    def test_fanout_produces_parallel_plus_merge(self, ranked_models):
        strategy = FanOutStrategy()
        steps = strategy.build_steps("Analyze from multiple angles", ["analysis"], ranked_models)
        # Should have 3 parallel + 1 merge = 4 steps
        assert len(steps) >= 2  # At minimum 2 (one parallel + one merge)
        # Check parallel group
        parallel_steps = [s for s in steps if s.parallel_group is not None]
        assert len(parallel_steps) >= 2
        # Last step should be synthesizer
        assert steps[-1].role == "synthesizer"

    def test_debate_produces_pro_con_judge(self, ranked_models):
        strategy = DebateStrategy()
        steps = strategy.build_steps("Debate AI regulation", ["analysis"], ranked_models)
        assert len(steps) == 3
        roles = [s.role for s in steps]
        assert "debater_pro" in roles
        assert "debater_con" in roles
        assert "judge" in roles

    def test_debate_uses_different_models_if_available(self, ranked_models):
        strategy = DebateStrategy()
        steps = strategy.build_steps("Debate this", ["analysis"], ranked_models)
        debaters = [s for s in steps if s.role.startswith("debater")]
        # If different backends are available, debaters should use different ones
        if len(debaters) == 2 and debaters[0].model_hint and debaters[1].model_hint:
            # At least one debater should differ from the other
            # (may or may not depending on available models)
            assert debaters[0].model_hint is not None
            assert debaters[1].model_hint is not None

    def test_refine_produces_draft_critique_revise(self, ranked_models):
        strategy = RefineStrategy()
        steps = strategy.build_steps("Write and improve", ["creative"], ranked_models)
        assert len(steps) == 3
        roles = [s.role for s in steps]
        assert "drafter" in roles
        assert "critic" in roles
        assert "reviser" in roles

    def test_refine_reviser_references_draft_and_critique(self, ranked_models):
        strategy = RefineStrategy()
        steps = strategy.build_steps("Write and improve", ["creative"], ranked_models)
        reviser = [s for s in steps if s.role == "reviser"][0]
        assert "step_1" in reviser.context_refs
        assert "step_2" in reviser.context_refs


# ===========================================================================
# Prompt Optimization
# ===========================================================================


class TestPromptEngine:
    """Test prompt optimization per model family."""

    @pytest.fixture
    def engine(self):
        return PromptEngine()

    def test_optimize_for_claude_includes_system_prompt(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Analyze this data",
            model_id="claude:claude-sonnet-4",
            step_type="analysis",
        )
        assert pkg.system_prompt != ""
        assert "analyst" in pkg.system_prompt.lower()

    def test_optimize_for_gemini_has_markdown_system(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Research this topic",
            model_id="gemini:gemini-2.5-pro",
            step_type="research",
        )
        assert "# Role:" in pkg.system_prompt or "Research" in pkg.system_prompt

    def test_optimize_for_local_has_concise_system(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Summarize this",
            model_id="ollama:llama3",
            step_type="summary",
        )
        assert pkg.system_prompt != ""
        # Local prompts should be shorter
        assert len(pkg.system_prompt) < 200

    def test_context_injection_claude_uses_xml(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Review the analysis",
            model_id="claude:claude-sonnet-4",
            step_type="critique",
            context="The analysis found three key trends...",
        )
        assert "<context" in pkg.user_prompt
        assert "</context>" in pkg.user_prompt

    def test_context_injection_gemini_uses_markdown(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Fact-check this",
            model_id="gemini:gemini-2.5-pro",
            step_type="fact_check",
            context="Claims: 1. The earth is round...",
        )
        assert "##" in pkg.user_prompt or "---" in pkg.user_prompt

    def test_context_injection_local_uses_delimiters(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Summarize this",
            model_id="ollama:llama3",
            step_type="summary",
            context="A long analysis...",
        )
        assert "===" in pkg.user_prompt

    def test_analysis_uses_low_temperature(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Analyze",
            model_id="claude:claude-sonnet-4",
            step_type="analysis",
        )
        assert pkg.temperature <= 0.4

    def test_creative_uses_high_temperature(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Write a story",
            model_id="claude:claude-sonnet-4",
            step_type="creative",
        )
        assert pkg.temperature >= 0.6

    def test_code_uses_low_temperature(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Write a function",
            model_id="claude:claude-sonnet-4",
            step_type="code",
        )
        assert pkg.temperature <= 0.3

    def test_local_model_caps_max_tokens(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Long analysis",
            model_id="ollama:llama3",
            step_type="analysis",
        )
        assert pkg.max_tokens <= 2048  # Local cap

    def test_cloud_model_allows_higher_max_tokens(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Long analysis",
            model_id="claude:claude-sonnet-4",
            step_type="analysis",
        )
        assert pkg.max_tokens >= 2048  # Cloud can go higher

    def test_get_model_family_claude(self):
        assert get_model_family("claude:claude-sonnet-4") == "claude"
        assert get_model_family("claude-sonnet-4") == "claude"

    def test_get_model_family_gemini(self):
        assert get_model_family("gemini:gemini-2.5-pro") == "gemini"
        assert get_model_family("gemini-2.5-pro") == "gemini"

    def test_get_model_family_ollama(self):
        assert get_model_family("ollama:llama3") == "ollama"
        assert get_model_family("llama3") == "ollama"

    def test_get_model_family_gpt(self):
        assert get_model_family("gpt-4o") == "openai"

    def test_no_context_means_clean_prompt(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Hello world",
            model_id="claude:claude-sonnet-4",
            step_type="general",
        )
        assert pkg.user_prompt == "Hello world"
        assert pkg.context_prefix == ""

    def test_unknown_task_type_falls_back_to_general(self, engine):
        pkg = engine.optimize_prompt(
            raw_prompt="Do something",
            model_id="claude:claude-sonnet-4",
            step_type="nonexistent_task",
        )
        assert pkg.system_prompt != ""  # Should get "general" system prompt
        assert pkg.temperature > 0  # Should get general params


# ===========================================================================
# Quality Evaluation
# ===========================================================================


class TestQualityEvaluator:
    """Test quality scoring and feedback loops."""

    @pytest.fixture
    def evaluator(self):
        # Use a temp directory so tests don't pollute the repo
        with tempfile.TemporaryDirectory() as tmpdir:
            yield QualityEvaluator(data_dir=tmpdir)

    def test_record_and_retrieve_model_stats(self, evaluator):
        evaluator.record_execution("plan-1", {
            "total_elapsed_ms": 2000,
            "steps_completed": 2,
            "steps_failed": 0,
            "steps": [
                {
                    "model_name": "claude-sonnet-4",
                    "task_type": "analysis",
                    "elapsed_ms": 1200,
                    "tokens_used": 500,
                    "success": True,
                },
                {
                    "model_name": "ollama:llama3",
                    "task_type": "summary",
                    "elapsed_ms": 300,
                    "tokens_used": 150,
                    "success": True,
                },
            ],
        })

        stats = evaluator.get_model_stats("claude-sonnet-4")
        assert stats.usage_count == 1
        assert stats.success_rate == 1.0
        assert stats.avg_latency_ms > 0

    def test_model_stats_tracks_failures(self, evaluator):
        evaluator.record_execution("plan-2", {
            "total_elapsed_ms": 5000,
            "steps_completed": 1,
            "steps_failed": 1,
            "steps": [
                {
                    "model_name": "gemini-pro",
                    "task_type": "research",
                    "elapsed_ms": 5000,
                    "tokens_used": 0,
                    "success": False,
                    "error_type": "timeout",
                },
                {
                    "model_name": "gemini-pro",
                    "task_type": "research",
                    "elapsed_ms": 1200,
                    "tokens_used": 800,
                    "success": True,
                },
            ],
        })

        stats = evaluator.get_model_stats("gemini-pro")
        assert stats.usage_count == 2
        assert stats.success_rate == 0.5
        assert "timeout" in stats.error_breakdown

    def test_strategy_stats(self, evaluator):
        # Record a 2-step execution
        evaluator.record_execution("plan-3", {
            "total_elapsed_ms": 3000,
            "steps_completed": 2,
            "steps_failed": 0,
            "steps": [
                {"model_name": "m1", "elapsed_ms": 1500, "tokens_used": 200, "success": True},
                {"model_name": "m2", "elapsed_ms": 1500, "tokens_used": 200, "success": True},
            ],
        })

        stats = evaluator.get_strategy_stats("2-step")
        assert stats.total_executions == 1
        assert stats.success_rate == 1.0

    def test_quality_summary_empty(self, evaluator):
        summary = evaluator.get_quality_summary(hours=24)
        assert summary["total_plans"] == 0

    def test_suggest_improvements_slow_model(self, evaluator):
        # Record several slow executions
        for i in range(5):
            evaluator.record_execution(f"plan-slow-{i}", {
                "total_elapsed_ms": 10000,
                "steps_completed": 1,
                "steps_failed": 0,
                "steps": [
                    {
                        "model_name": "slow-model",
                        "task_type": "analysis",
                        "elapsed_ms": 10000,
                        "tokens_used": 5000,
                        "success": True,
                    },
                ],
            })

        suggestions = evaluator.suggest_improvements("plan-slow-0")
        # Should suggest switching to a faster model
        assert len(suggestions) > 0
        categories = [s.category for s in suggestions]
        assert "model_switch" in categories


# ===========================================================================
# Full Planning Pipeline
# ===========================================================================


class TestFullPlanningPipeline:
    """Integration tests for the complete plan() pipeline."""

    @pytest.fixture
    def planner(self):
        return ChainPlanner()

    @pytest.fixture
    def models(self):
        return [
            "claude:claude-sonnet-4-20250514",
            "gemini:gemini-2.5-pro",
            "ollama:llama3",
        ]

    @pytest.mark.asyncio
    async def test_simple_analysis_produces_plan(self, planner, models):
        request = PlanRequest(
            user_input="Analyze this quarterly report",
            available_models=models,
            user_id="test-user",
        )
        plan = await planner.plan(request)

        assert plan.plan_id != ""
        assert plan.user_id == "test-user"
        assert len(plan.steps) >= 1
        assert plan.original_input == "Analyze this quarterly report"
        assert plan.version == 1

    @pytest.mark.asyncio
    async def test_compound_request_produces_multi_step(self, planner, models):
        request = PlanRequest(
            user_input="Analyze this report and summarize the key findings",
            available_models=models,
            user_id="test-user",
            user_tier="pro",
        )
        plan = await planner.plan(request)

        assert len(plan.steps) >= 2
        # Steps should have different models or at least different prompts
        step_types = set()
        for step in plan.steps:
            step_types.add(step.config.get("system_prompt", "")[:20])

    @pytest.mark.asyncio
    async def test_empty_request_raises_error(self, planner, models):
        request = PlanRequest(
            user_input="",
            available_models=models,
        )
        with pytest.raises(PlanningError, match="Empty request"):
            await planner.plan(request)

    @pytest.mark.asyncio
    async def test_no_models_raises_error(self, planner):
        request = PlanRequest(
            user_input="Analyze this",
            available_models=[],
        )
        with pytest.raises(PlanningError, match="No models available"):
            await planner.plan(request)

    @pytest.mark.asyncio
    async def test_plan_steps_have_valid_types(self, planner, models):
        request = PlanRequest(
            user_input="Research AI trends",
            available_models=models,
        )
        plan = await planner.plan(request)

        for step in plan.steps:
            assert step.type in ("local", "cloud", "local_agent", "remote_agent")
            assert step.backend in ("claude", "gemini", "ollama", "lmstudio", "openai")
            assert step.prompt != ""
            assert step.step_id.startswith("step_")

    @pytest.mark.asyncio
    async def test_plan_steps_have_prompts_and_config(self, planner, models):
        request = PlanRequest(
            user_input="Write a Python function to calculate fibonacci",
            available_models=models,
        )
        plan = await planner.plan(request)

        for step in plan.steps:
            assert step.prompt != ""
            assert "temperature" in step.config
            assert "max_tokens" in step.config

    @pytest.mark.asyncio
    async def test_free_tier_limits_steps(self, planner, models):
        request = PlanRequest(
            user_input="Analyze, fact-check, and summarize this complex report",
            available_models=models,
            user_tier="free",
        )
        plan = await planner.plan(request)
        # Free tier: max 2 steps
        assert len(plan.steps) <= 2

    @pytest.mark.asyncio
    async def test_pro_tier_allows_more_steps(self, planner, models):
        request = PlanRequest(
            user_input="Analyze, fact-check, and summarize this complex report",
            available_models=models,
            user_tier="pro",
        )
        plan = await planner.plan(request)
        # Pro tier: max 5 steps, so a 3-step chain should work
        assert len(plan.steps) >= 1  # At least 1 step

    @pytest.mark.asyncio
    async def test_preferred_strategy_override(self, planner, models):
        request = PlanRequest(
            user_input="Analyze this data",
            available_models=models,
            user_preferences={"strategy": "refine"},
        )
        plan = await planner.plan(request)
        # Refine strategy produces 3 steps (draft, critique, revise)
        # But free tier caps at 2, so expect at most 2
        assert len(plan.steps) >= 1

    @pytest.mark.asyncio
    async def test_plan_has_correct_auth_types(self, planner, models):
        request = PlanRequest(
            user_input="Summarize this",
            available_models=models,
        )
        plan = await planner.plan(request)

        for step in plan.steps:
            if step.backend in ("claude", "gemini"):
                assert step.auth_type == "byok"
            elif step.backend in ("ollama", "lmstudio"):
                assert step.auth_type == "none"

    @pytest.mark.asyncio
    async def test_single_local_model_works(self, planner):
        request = PlanRequest(
            user_input="Explain quantum computing",
            available_models=["ollama:llama3"],
        )
        plan = await planner.plan(request)
        assert len(plan.steps) >= 1
        assert plan.steps[0].type == "local"

    @pytest.mark.asyncio
    async def test_plan_expires_after_creation(self, planner, models):
        request = PlanRequest(
            user_input="Analyze this",
            available_models=models,
        )
        plan = await planner.plan(request)
        assert plan.expires_at > plan.created_at


# ===========================================================================
# Step Type Classification
# ===========================================================================


class TestStepTypeClassification:
    def test_ollama_is_local(self):
        step_type, auth = _classify_step_type("ollama")
        assert step_type == "local"
        assert auth == "none"

    def test_lmstudio_is_local(self):
        step_type, auth = _classify_step_type("lmstudio")
        assert step_type == "local"
        assert auth == "none"

    def test_claude_is_cloud_byok(self):
        step_type, auth = _classify_step_type("claude")
        assert step_type == "cloud"
        assert auth == "byok"

    def test_gemini_is_cloud_byok(self):
        step_type, auth = _classify_step_type("gemini")
        assert step_type == "cloud"
        assert auth == "byok"

    def test_unknown_defaults_to_cloud(self):
        step_type, auth = _classify_step_type("some_new_backend")
        assert step_type == "cloud"
        assert auth == "byok"
