"""Tests for openjarvis.optimize.optimizer module."""

from __future__ import annotations

from unittest.mock import MagicMock

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib  # type: ignore[no-redef]

from openjarvis.evals.core.types import RunSummary
from openjarvis.learning.optimize.optimizer import OptimizationEngine
from openjarvis.learning.optimize.store import OptimizationStore
from openjarvis.learning.optimize.types import (
    OptimizationRun,
    SearchDimension,
    SearchSpace,
    TrialConfig,
    TrialFeedback,
    TrialResult,
)


def _sample_search_space() -> SearchSpace:
    return SearchSpace(
        dimensions=[
            SearchDimension(
                name="agent.type",
                dim_type="categorical",
                values=["simple", "orchestrator"],
                primitive="agent",
            ),
            SearchDimension(
                name="intelligence.temperature",
                dim_type="continuous",
                low=0.0,
                high=1.0,
                primitive="intelligence",
            ),
        ],
        fixed={"engine": "ollama"},
    )


def _sample_summary(accuracy: float = 0.8) -> RunSummary:
    return RunSummary(
        benchmark="test",
        category="reasoning",
        backend="jarvis-direct",
        model="test-model",
        total_samples=50,
        scored_samples=50,
        correct=int(accuracy * 50),
        accuracy=accuracy,
        errors=0,
        mean_latency_seconds=1.0,
        total_cost_usd=0.01,
    )


def _sample_trial_result(
    trial_id: str = "t1",
    accuracy: float = 0.8,
    summary: RunSummary | None = None,
) -> TrialResult:
    config = TrialConfig(
        trial_id=trial_id,
        params={"agent.type": "orchestrator"},
        reasoning="test reasoning",
    )
    if summary is None:
        summary = _sample_summary(accuracy)
    return TrialResult(
        trial_id=trial_id,
        config=config,
        accuracy=accuracy,
        mean_latency_seconds=1.0,
        total_cost_usd=0.01,
        samples_evaluated=50,
        summary=summary,
    )


# ---------------------------------------------------------------------------
# __init__
# ---------------------------------------------------------------------------


class TestOptimizationEngineInit:
    """Tests for OptimizationEngine.__init__."""

    def test_stores_all_params(self) -> None:
        space = _sample_search_space()
        optimizer = MagicMock()
        runner = MagicMock()
        store = MagicMock()

        engine = OptimizationEngine(
            search_space=space,
            llm_optimizer=optimizer,
            trial_runner=runner,
            store=store,
            max_trials=10,
            early_stop_patience=3,
        )

        assert engine.search_space is space
        assert engine.llm_optimizer is optimizer
        assert engine.trial_runner is runner
        assert engine.store is store
        assert engine.max_trials == 10
        assert engine.early_stop_patience == 3

    def test_default_params(self) -> None:
        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=MagicMock(),
            trial_runner=MagicMock(),
        )
        assert engine.store is None
        assert engine.max_trials == 20
        assert engine.early_stop_patience == 5


# ---------------------------------------------------------------------------
# run() with mocked dependencies
# ---------------------------------------------------------------------------


class TestOptimizationEngineRun:
    """Tests for OptimizationEngine.run()."""

    def test_basic_run(self) -> None:
        space = _sample_search_space()
        optimizer = MagicMock()
        runner = MagicMock()

        initial_config = TrialConfig(
            trial_id="init",
            params={"agent.type": "orchestrator"},
        )
        second_config = TrialConfig(
            trial_id="next",
            params={"agent.type": "simple"},
        )
        optimizer.propose_initial.return_value = initial_config
        optimizer.propose_next.return_value = second_config
        optimizer.analyze_trial.return_value = TrialFeedback(
            summary_text="analysis text",
        )
        optimizer.optimizer_model = "test-model"

        runner.run_trial.return_value = _sample_trial_result(
            "init",
            accuracy=0.8,
        )
        runner.benchmark = "supergpqa"

        engine = OptimizationEngine(
            search_space=space,
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=2,
        )

        # Make second trial return different accuracy
        runner.run_trial.side_effect = [
            _sample_trial_result("init", accuracy=0.8),
            _sample_trial_result("next", accuracy=0.85),
        ]

        result = engine.run()

        assert isinstance(result, OptimizationRun)
        assert result.status == "completed"
        assert len(result.trials) == 2
        assert result.best_trial is not None
        assert result.best_trial.accuracy == 0.85
        assert result.optimizer_model == "test-model"
        assert result.benchmark == "supergpqa"

        optimizer.propose_initial.assert_called_once()
        assert runner.run_trial.call_count == 2
        assert optimizer.analyze_trial.call_count == 2

    def test_single_trial(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        config = TrialConfig(trial_id="only", params={})
        optimizer.propose_initial.return_value = config
        optimizer.analyze_trial.return_value = TrialFeedback(summary_text="good")
        optimizer.optimizer_model = "m"
        runner.run_trial.return_value = _sample_trial_result("only", 0.9)
        runner.benchmark = "test"

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=1,
        )
        result = engine.run()

        assert len(result.trials) == 1
        assert result.best_trial.accuracy == 0.9
        # propose_next should NOT be called when max_trials=1
        optimizer.propose_next.assert_not_called()

    def test_analysis_text_set_on_result(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t1",
            params={},
        )
        optimizer.analyze_trial.return_value = TrialFeedback(
            summary_text="detailed analysis",
        )
        optimizer.optimizer_model = "m"
        runner.run_trial.return_value = _sample_trial_result("t1", 0.8)
        runner.benchmark = "b"

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=1,
        )
        result = engine.run()

        assert result.trials[0].analysis == "detailed analysis"

    def test_run_without_summary_skips_analysis(self) -> None:
        """If trial result has no summary, analysis should be empty."""
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t1",
            params={},
        )
        optimizer.optimizer_model = "m"

        # Result with no summary
        result_no_summary = TrialResult(
            trial_id="t1",
            config=TrialConfig(trial_id="t1", params={}),
            accuracy=0.5,
            summary=None,
        )
        runner.run_trial.return_value = result_no_summary
        runner.benchmark = "b"

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=1,
        )
        run = engine.run()

        assert run.trials[0].analysis == ""
        optimizer.analyze_trial.assert_not_called()


# ---------------------------------------------------------------------------
# Early stopping
# ---------------------------------------------------------------------------


class TestEarlyStopping:
    """Tests for early stopping behavior."""

    def test_early_stop_after_patience(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t0",
            params={},
        )
        optimizer.propose_next.side_effect = [
            TrialConfig(trial_id=f"t{i}", params={}) for i in range(1, 20)
        ]
        optimizer.analyze_trial.return_value = TrialFeedback(summary_text="ok")
        optimizer.optimizer_model = "m"
        runner.benchmark = "b"

        # First trial is the best; all subsequent are worse
        results = [_sample_trial_result("t0", accuracy=0.9)]
        for i in range(1, 20):
            results.append(_sample_trial_result(f"t{i}", accuracy=0.5))
        runner.run_trial.side_effect = results

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=20,
            early_stop_patience=3,
        )
        run = engine.run()

        # Should stop after 1 (best) + 3 (patience) = 4 trials
        assert len(run.trials) == 4
        assert run.best_trial.trial_id == "t0"
        assert run.status == "completed"

    def test_no_early_stop_when_improving(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t0",
            params={},
        )
        optimizer.propose_next.side_effect = [
            TrialConfig(trial_id=f"t{i}", params={}) for i in range(1, 5)
        ]
        optimizer.analyze_trial.return_value = TrialFeedback(summary_text="ok")
        optimizer.optimizer_model = "m"
        runner.benchmark = "b"

        # Accuracy keeps improving
        results = [
            _sample_trial_result("t0", accuracy=0.5),
            _sample_trial_result("t1", accuracy=0.6),
            _sample_trial_result("t2", accuracy=0.7),
            _sample_trial_result("t3", accuracy=0.8),
            _sample_trial_result("t4", accuracy=0.9),
        ]
        runner.run_trial.side_effect = results

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=5,
            early_stop_patience=3,
        )
        run = engine.run()

        assert len(run.trials) == 5
        assert run.best_trial.accuracy == 0.9


# ---------------------------------------------------------------------------
# Progress callback
# ---------------------------------------------------------------------------


class TestProgressCallback:
    """Tests for progress_callback."""

    def test_callback_called(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t0",
            params={},
        )
        optimizer.propose_next.side_effect = [
            TrialConfig(trial_id=f"t{i}", params={}) for i in range(1, 3)
        ]
        optimizer.analyze_trial.return_value = TrialFeedback(summary_text="ok")
        optimizer.optimizer_model = "m"
        runner.benchmark = "b"

        results = [
            _sample_trial_result(f"t{i}", accuracy=0.5 + i * 0.1) for i in range(3)
        ]
        runner.run_trial.side_effect = results

        callback = MagicMock()

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=3,
        )
        engine.run(progress_callback=callback)

        assert callback.call_count == 3
        callback.assert_any_call(1, 3)
        callback.assert_any_call(2, 3)
        callback.assert_any_call(3, 3)

    def test_no_callback(self) -> None:
        """run() should work fine without a callback."""
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t0",
            params={},
        )
        optimizer.analyze_trial.return_value = TrialFeedback(summary_text="ok")
        optimizer.optimizer_model = "m"
        runner.run_trial.return_value = _sample_trial_result("t0", 0.8)
        runner.benchmark = "b"

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=1,
        )
        run = engine.run()
        assert run.status == "completed"


# ---------------------------------------------------------------------------
# export_best_recipe
# ---------------------------------------------------------------------------


class TestExportBestRecipe:
    """Tests for export_best_recipe."""

    def test_exports_valid_toml(self, tmp_path) -> None:
        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=MagicMock(),
            trial_runner=MagicMock(),
        )

        best = _sample_trial_result("best", accuracy=0.95)
        best.config.params = {
            "intelligence.model": "qwen3:8b",
            "intelligence.temperature": 0.3,
            "engine.backend": "ollama",
            "agent.type": "native_react",
            "agent.max_turns": 10,
            "tools.tool_set": ["calculator", "think"],
            "learning.routing_policy": "grpo",
        }

        run = OptimizationRun(
            run_id="run-export",
            search_space=_sample_search_space(),
            best_trial=best,
            status="completed",
        )

        path = tmp_path / "best_recipe.toml"
        result_path = engine.export_best_recipe(run, path)

        assert result_path == path
        assert path.exists()
        assert run.best_recipe_path == str(path)

        # Verify it's valid TOML
        with open(path, "rb") as fh:
            data = tomllib.load(fh)

        assert data["recipe"]["name"] == "optimized-best"
        assert "0.9500" in data["recipe"]["description"]
        assert data["intelligence"]["model"] == "qwen3:8b"
        assert data["intelligence"]["temperature"] == 0.3
        assert data["engine"]["key"] == "ollama"
        assert data["agent"]["type"] == "native_react"
        assert data["agent"]["max_turns"] == 10
        assert data["agent"]["tools"] == ["calculator", "think"]
        assert data["learning"]["routing"] == "grpo"

    def test_export_creates_parent_dirs(self, tmp_path) -> None:
        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=MagicMock(),
            trial_runner=MagicMock(),
        )

        best = _sample_trial_result("best", accuracy=0.9)
        run = OptimizationRun(
            run_id="run-dirs",
            search_space=_sample_search_space(),
            best_trial=best,
            status="completed",
        )

        path = tmp_path / "nested" / "deep" / "recipe.toml"
        result_path = engine.export_best_recipe(run, path)
        assert result_path.exists()

    def test_export_no_best_trial_raises(self, tmp_path) -> None:
        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=MagicMock(),
            trial_runner=MagicMock(),
        )

        run = OptimizationRun(
            run_id="run-no-best",
            search_space=_sample_search_space(),
            best_trial=None,
        )

        try:
            engine.export_best_recipe(run, tmp_path / "out.toml")
            assert False, "Expected ValueError"
        except ValueError as e:
            assert "No best trial" in str(e)

    def test_export_minimal_params(self, tmp_path) -> None:
        """Export with minimal params should still produce valid TOML."""
        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=MagicMock(),
            trial_runner=MagicMock(),
        )

        config = TrialConfig(trial_id="min", params={})
        best = TrialResult(
            trial_id="min",
            config=config,
            accuracy=0.5,
        )
        run = OptimizationRun(
            run_id="run-min",
            search_space=_sample_search_space(),
            best_trial=best,
        )

        path = tmp_path / "minimal.toml"
        engine.export_best_recipe(run, path)

        with open(path, "rb") as fh:
            data = tomllib.load(fh)

        assert data["recipe"]["name"] == "optimized-min"


# ---------------------------------------------------------------------------
# run() with store
# ---------------------------------------------------------------------------


class TestRunWithStore:
    """Tests for run() with a real OptimizationStore."""

    def test_saves_trials_and_run(self, tmp_path) -> None:
        store = OptimizationStore(tmp_path / "opt.db")
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t0",
            params={},
        )
        optimizer.propose_next.return_value = TrialConfig(
            trial_id="t1",
            params={},
        )
        optimizer.analyze_trial.return_value = TrialFeedback(summary_text="analysis")
        optimizer.optimizer_model = "m"
        runner.benchmark = "b"

        runner.run_trial.side_effect = [
            _sample_trial_result("t0", accuracy=0.7),
            _sample_trial_result("t1", accuracy=0.8),
        ]

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            store=store,
            max_trials=2,
        )
        run = engine.run()

        # Verify trials were saved
        trials = store.get_trials(run.run_id)
        assert len(trials) == 2

        # Verify run was saved
        loaded_run = store.get_run(run.run_id)
        assert loaded_run is not None
        assert loaded_run.status == "completed"
        assert len(loaded_run.trials) == 2

        store.close()

    def test_no_store_does_not_error(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t0",
            params={},
        )
        optimizer.analyze_trial.return_value = TrialFeedback(summary_text="ok")
        optimizer.optimizer_model = "m"
        runner.run_trial.return_value = _sample_trial_result("t0", 0.8)
        runner.benchmark = "b"

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            store=None,
            max_trials=1,
        )
        run = engine.run()
        assert run.status == "completed"


# ---------------------------------------------------------------------------
# config.py
# ---------------------------------------------------------------------------


class TestLoadOptimizeConfig:
    """Tests for load_optimize_config."""

    def test_loads_toml_file(self, tmp_path) -> None:
        from openjarvis.learning.optimize.config import load_optimize_config

        toml_content = b"""
[optimize]
max_trials = 15
benchmark = "supergpqa"

[[optimize.search]]
name = "agent.type"
type = "categorical"
values = ["simple", "orchestrator"]

[optimize.fixed]
engine = "ollama"
"""
        path = tmp_path / "optimize.toml"
        path.write_bytes(toml_content)

        config = load_optimize_config(path)
        assert config["optimize"]["max_trials"] == 15
        assert config["optimize"]["benchmark"] == "supergpqa"
        assert len(config["optimize"]["search"]) == 1
        assert config["optimize"]["fixed"]["engine"] == "ollama"

    def test_file_not_found(self, tmp_path) -> None:
        from openjarvis.learning.optimize.config import load_optimize_config

        try:
            load_optimize_config(tmp_path / "nonexistent.toml")
            assert False, "Expected FileNotFoundError"
        except FileNotFoundError:
            pass

    def test_loads_string_path(self, tmp_path) -> None:
        from openjarvis.learning.optimize.config import load_optimize_config

        path = tmp_path / "test.toml"
        path.write_bytes(b"[optimize]\nmax_trials = 5\n")

        config = load_optimize_config(str(path))
        assert config["optimize"]["max_trials"] == 5


class TestParetoFrontier:
    """Tests for Pareto frontier in the run loop."""

    def test_pareto_frontier_populated_after_run(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        optimizer.propose_initial.return_value = TrialConfig(
            trial_id="t0",
            params={},
        )
        optimizer.propose_next.return_value = TrialConfig(
            trial_id="t1",
            params={},
        )
        # analyze_trial returns TrialFeedback
        optimizer.analyze_trial.return_value = TrialFeedback(
            summary_text="analysis",
        )
        optimizer.optimizer_model = "m"
        runner.benchmark = "b"

        runner.run_trial.side_effect = [
            _sample_trial_result("t0", accuracy=0.8),
            _sample_trial_result("t1", accuracy=0.9),
        ]

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=2,
        )
        run = engine.run()

        assert len(run.pareto_frontier) > 0
        # The better trial should be on the frontier
        frontier_ids = {t.trial_id for t in run.pareto_frontier}
        assert "t1" in frontier_ids


class TestTargetedAndMerge:
    """Tests for targeted mutation and merge in the run loop."""

    def test_targeted_proposal_used_when_target_primitive_set(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        configs = [TrialConfig(trial_id=f"t{i}", params={}) for i in range(5)]
        optimizer.propose_initial.return_value = configs[0]
        optimizer.propose_next.side_effect = configs[1:]
        optimizer.propose_targeted.return_value = configs[3]
        optimizer.optimizer_model = "m"
        runner.benchmark = "b"

        # Trials 0-2: normal. Trial 2 feedback has target_primitive
        fb_normal = TrialFeedback(summary_text="ok")
        fb_targeted = TrialFeedback(
            summary_text="agent needs tuning",
            target_primitive="agent",
        )

        optimizer.analyze_trial.side_effect = [
            fb_normal,  # trial 0
            fb_normal,  # trial 1
            fb_targeted,  # trial 2 -> will trigger targeted on next
            fb_normal,  # trial 3
        ]

        results = [
            _sample_trial_result(f"t{i}", accuracy=0.5 + i * 0.05) for i in range(4)
        ]
        runner.run_trial.side_effect = results

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=4,
        )
        engine.run()

        # After trial 2 (trial_num=3, > 2), targeted should be used
        assert optimizer.propose_targeted.called

    def test_merge_triggered_periodically(self) -> None:
        optimizer = MagicMock()
        runner = MagicMock()

        configs = [TrialConfig(trial_id=f"t{i}", params={}) for i in range(7)]
        optimizer.propose_initial.return_value = configs[0]
        optimizer.propose_next.side_effect = configs[1:]
        optimizer.propose_merge.return_value = configs[5]
        optimizer.optimizer_model = "m"
        runner.benchmark = "b"

        fb = TrialFeedback(summary_text="ok")
        optimizer.analyze_trial.return_value = fb

        # Create diverse results with genuine tradeoffs so
        # frontier has >= 2 members (high acc/high lat vs low acc/low lat)
        tradeoffs = [
            (0.9, 3.0, 0.05),  # t0: high accuracy, high latency, high cost
            (0.5, 0.5, 0.01),  # t1: low accuracy, low latency, low cost
            (0.7, 1.5, 0.03),  # t2: dominated by t0+t1 combo
            (0.85, 2.5, 0.04),  # t3: close to t0 tradeoff
            (0.6, 0.8, 0.015),  # t4: close to t1 tradeoff
            (0.75, 1.0, 0.02),  # t5: merged result
        ]
        results = []
        for i, (acc, lat, cost) in enumerate(tradeoffs):
            r = _sample_trial_result(f"t{i}", accuracy=acc)
            r.mean_latency_seconds = lat
            r.total_cost_usd = cost
            results.append(r)
        runner.run_trial.side_effect = results

        engine = OptimizationEngine(
            search_space=_sample_search_space(),
            llm_optimizer=optimizer,
            trial_runner=runner,
            max_trials=6,
        )
        engine.run()

        # Merge should be triggered at trial_num=5
        assert optimizer.propose_merge.called
