"""
Coverage tests for benchmarks/optimization/optuna_optimizer.py.
"""

import numpy as np
import pytest
from unittest.mock import Mock, patch

MODULE = "local_deep_research.benchmarks.optimization.optuna_optimizer"


def _make_optimizer(**kwargs):
    from local_deep_research.benchmarks.optimization.optuna_optimizer import (
        OptunaOptimizer,
    )

    defaults = {"base_query": "coverage test query"}
    defaults.update(kwargs)
    return OptunaOptimizer(**defaults)


class TestObjectiveFloatParamSuggestion:
    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_float_param_with_log_scale(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()
        mock_trial = Mock()
        mock_trial.number = 0
        mock_trial.suggest_float.return_value = 0.01
        param_space = {
            "lr": {"type": "float", "low": 0.0001, "high": 1.0, "log": True}
        }
        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.return_value = {"score": 0.77}
            score = optimizer._objective(mock_trial, param_space=param_space)
        mock_trial.suggest_float.assert_called_once_with(
            "lr", 0.0001, 1.0, step=None, log=True
        )
        assert score == 0.77

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_float_param_with_step_no_log(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()
        mock_trial = Mock()
        mock_trial.number = 1
        mock_trial.suggest_float.return_value = 0.5
        param_space = {
            "dropout": {"type": "float", "low": 0.0, "high": 1.0, "step": 0.1}
        }
        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.return_value = {"score": 0.65}
            score = optimizer._objective(mock_trial, param_space=param_space)
        mock_trial.suggest_float.assert_called_once_with(
            "dropout", 0.0, 1.0, step=0.1, log=False
        )
        assert score == 0.65


class TestObjectiveProgressCallbacks:
    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_callback_trial_started_then_completed(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        callback = Mock()
        optimizer = _make_optimizer(progress_callback=callback, n_trials=5)
        mock_trial = Mock()
        mock_trial.number = 2
        mock_trial.suggest_int.return_value = 3
        mock_trial.suggest_categorical.return_value = "rapid"
        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.return_value = {"score": 0.88}
            param_space = optimizer._get_default_param_space()
            optimizer._objective(mock_trial, param_space=param_space)
        stages = [c[0][2]["stage"] for c in callback.call_args_list]
        assert "trial_started" in stages
        assert "trial_completed" in stages
        completed_call = [
            c
            for c in callback.call_args_list
            if c[0][2]["stage"] == "trial_completed"
        ][0]
        assert completed_call[0][2]["score"] == 0.88

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_callback_trial_error_on_exception(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        callback = Mock()
        optimizer = _make_optimizer(progress_callback=callback, n_trials=5)
        mock_trial = Mock()
        mock_trial.number = 3
        mock_trial.suggest_int.return_value = 1
        mock_trial.suggest_categorical.return_value = "standard"
        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.side_effect = RuntimeError("timeout")
            param_space = optimizer._get_default_param_space()
            score = optimizer._objective(mock_trial, param_space=param_space)
        assert score == float("-inf")
        error_calls = [
            c
            for c in callback.call_args_list
            if c[0][2].get("stage") == "trial_error"
        ]
        assert len(error_calls) == 1
        assert "timeout" in error_calls[0][0][2]["error"]


class TestOptimizeKeyboardInterrupt:
    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.optuna")
    def test_keyboard_interrupt_saves_and_calls_callback(
        self, mock_optuna, mock_evaluator
    ):
        mock_evaluator.return_value = Mock()
        callback = Mock()
        mock_study = Mock()
        mock_study.best_params = {"iterations": 2}
        mock_study.best_value = 0.45
        mock_study.trials = [Mock(), Mock(), Mock()]
        mock_study.optimize.side_effect = KeyboardInterrupt()
        mock_optuna.create_study.return_value = mock_study
        optimizer = _make_optimizer(n_trials=20, progress_callback=callback)
        with (
            patch.object(optimizer, "_save_results") as mock_save,
            patch.object(optimizer, "_create_visualizations") as mock_viz,
        ):
            best_params, best_value = optimizer.optimize()
        mock_save.assert_called_once()
        mock_viz.assert_called_once()
        assert best_params == {"iterations": 2}
        assert best_value == 0.45
        interrupted = [
            c
            for c in callback.call_args_list
            if c[0][2].get("status") == "interrupted"
        ]
        assert len(interrupted) == 1
        assert interrupted[0][0][2]["trials_completed"] == 3

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.optuna")
    def test_keyboard_interrupt_without_callback(
        self, mock_optuna, mock_evaluator
    ):
        mock_evaluator.return_value = Mock()
        mock_study = Mock()
        mock_study.best_params = {"iterations": 1}
        mock_study.best_value = 0.1
        mock_study.trials = []
        mock_study.optimize.side_effect = KeyboardInterrupt()
        mock_optuna.create_study.return_value = mock_study
        optimizer = _make_optimizer(n_trials=5)
        assert optimizer.progress_callback is None
        with (
            patch.object(optimizer, "_save_results"),
            patch.object(optimizer, "_create_visualizations"),
        ):
            best_params, best_value = optimizer.optimize()
        assert best_params == {"iterations": 1}


class TestOptimizeCompletionCallback:
    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.optuna")
    def test_completion_callback_includes_best_params_and_value(
        self, mock_optuna, mock_evaluator
    ):
        mock_evaluator.return_value = Mock()
        callback = Mock()
        mock_study = Mock()
        mock_study.best_params = {"iterations": 4, "max_results": 80}
        mock_study.best_value = 0.93
        mock_study.trials = [Mock(), Mock()]
        mock_optuna.create_study.return_value = mock_study
        optimizer = _make_optimizer(n_trials=2, progress_callback=callback)
        with (
            patch.object(optimizer, "_save_results"),
            patch.object(optimizer, "_create_visualizations"),
        ):
            optimizer.optimize()
        completed = [
            c
            for c in callback.call_args_list
            if c[0][2].get("status") == "completed"
            and c[0][2].get("stage") == "finished"
        ]
        assert len(completed) == 1
        info = completed[0][0][2]
        assert info["best_params"] == {"iterations": 4, "max_results": 80}
        assert info["best_value"] == 0.93
        assert info["trials_completed"] == 2


class TestOptimizationCallbackStoresTrial:
    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_saves_at_trial_20(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()
        mock_study = Mock()
        mock_trial = Mock()
        mock_trial.number = 20
        with (
            patch.object(optimizer, "_save_results") as mock_save,
            patch.object(optimizer, "_create_quick_visualizations") as mock_viz,
        ):
            optimizer._optimization_callback(mock_study, mock_trial)
        mock_save.assert_called_once()
        mock_viz.assert_called_once()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_no_save_at_trial_5(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()
        mock_study = Mock()
        mock_trial = Mock()
        mock_trial.number = 5
        with patch.object(optimizer, "_save_results") as mock_save:
            optimizer._optimization_callback(mock_study, mock_trial)
        mock_save.assert_not_called()


class TestCreateQuickVisualizations:
    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.PLOTTING_AVAILABLE", True)
    @patch(f"{MODULE}.plot_optimization_history")
    def test_quick_viz_with_sufficient_trials(
        self, mock_plot_history, mock_evaluator, tmp_path
    ):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        mock_study = Mock()
        mock_study.trials = [Mock(), Mock(), Mock()]
        optimizer.study = mock_study
        mock_fig = Mock()
        mock_plot_history.return_value = mock_fig
        optimizer._create_quick_visualizations()
        mock_plot_history.assert_called_once_with(mock_study)
        mock_fig.write_image.assert_called_once()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.PLOTTING_AVAILABLE", True)
    def test_quick_viz_returns_early_fewer_than_2_trials(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()
        mock_study = Mock()
        mock_study.trials = [Mock()]
        optimizer.study = mock_study
        with patch(f"{MODULE}.plot_optimization_history") as mock_plot:
            optimizer._create_quick_visualizations()
            mock_plot.assert_not_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.PLOTTING_AVAILABLE", False)
    def test_quick_viz_returns_early_without_matplotlib(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()
        optimizer.study = Mock()
        optimizer.study.trials = [Mock(), Mock()]
        with patch(f"{MODULE}.plot_optimization_history") as mock_plot:
            optimizer._create_quick_visualizations()
            mock_plot.assert_not_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.PLOTTING_AVAILABLE", True)
    def test_quick_viz_returns_early_when_no_study(self, mock_evaluator):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()
        optimizer.study = None
        with patch(f"{MODULE}.plot_optimization_history") as mock_plot:
            optimizer._create_quick_visualizations()
            mock_plot.assert_not_called()


class TestSaveResultsNumpyConversion:
    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.joblib")
    @patch(
        "local_deep_research.security.file_write_verifier.write_json_verified"
    )
    def test_numpy_int64_top_level_converted(
        self, mock_write_json, mock_joblib, mock_evaluator, tmp_path
    ):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        optimizer.study = None
        optimizer.trials_history = [
            {
                "trial_number": np.int64(0),
                "score": np.float64(0.92),
                "params": {"iterations": np.int64(3)},
            }
        ]
        optimizer._save_results()
        assert mock_write_json.call_count == 1
        written_data = mock_write_json.call_args_list[0][0][1]
        assert isinstance(written_data[0]["trial_number"], float)
        assert isinstance(written_data[0]["score"], float)
        assert isinstance(written_data[0]["params"]["iterations"], float)

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.joblib")
    @patch(
        "local_deep_research.security.file_write_verifier.write_json_verified"
    )
    def test_numpy_float64_in_result_dict_converted(
        self, mock_write_json, mock_joblib, mock_evaluator, tmp_path
    ):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        optimizer.study = None
        optimizer.trials_history = [
            {
                "trial_number": 0,
                "result": {
                    "quality_score": np.float64(0.85),
                    "speed_score": np.float64(0.72),
                },
                "params": {},
                "score": 0.8,
            }
        ]
        optimizer._save_results()
        written_data = mock_write_json.call_args_list[0][0][1]
        result_dict = written_data[0]["result"]
        assert isinstance(result_dict["quality_score"], float)
        assert isinstance(result_dict["speed_score"], float)

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.joblib")
    @patch(
        "local_deep_research.security.file_write_verifier.write_json_verified"
    )
    def test_save_results_with_study_saves_best_params_and_pkl(
        self, mock_write_json, mock_joblib, mock_evaluator, tmp_path
    ):
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        mock_study = Mock()
        mock_study.best_params = {"iterations": 3}
        mock_study.best_value = 0.91
        mock_study.trials = [Mock()]
        optimizer.study = mock_study
        optimizer.trials_history = []
        optimizer._save_results()
        assert mock_write_json.call_count == 2
        mock_joblib.dump.assert_called_once()


class TestRunExperimentErrorPaths:
    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_evaluator_error_returns_failure_stops_profiler(
        self, mock_profiler_cls, mock_evaluator
    ):
        mock_eval_instance = Mock()
        mock_eval_instance.evaluate.side_effect = ValueError("bad config")
        mock_evaluator.return_value = mock_eval_instance
        mock_profiler = Mock()
        mock_profiler_cls.return_value = mock_profiler
        optimizer = _make_optimizer()
        result = optimizer._run_experiment(
            {"iterations": 2, "questions_per_iteration": 1}
        )
        assert result["success"] is False
        assert result["score"] == 0.0
        assert "bad config" in result["error"]
        mock_profiler.start.assert_called_once()
        mock_profiler.stop.assert_called_once()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_profiler_get_summary_error_caught(
        self, mock_profiler_cls, mock_evaluator
    ):
        mock_eval_instance = Mock()
        mock_eval_instance.evaluate.return_value = {
            "quality_score": 0.7,
            "benchmark_results": {},
        }
        mock_evaluator.return_value = mock_eval_instance
        mock_profiler = Mock()
        mock_profiler.get_summary.side_effect = RuntimeError("profiler broken")
        mock_profiler_cls.return_value = mock_profiler
        optimizer = _make_optimizer()
        result = optimizer._run_experiment({"iterations": 1})
        assert result["success"] is False
        assert result["score"] == 0.0
        assert "profiler broken" in result["error"]
        assert mock_profiler.stop.call_count >= 1

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_successful_experiment_returns_all_fields(
        self, mock_profiler_cls, mock_evaluator
    ):
        mock_eval_instance = Mock()
        mock_eval_instance.evaluate.return_value = {
            "quality_score": 0.85,
            "benchmark_results": {"simpleqa": {"accuracy": 0.85}},
        }
        mock_evaluator.return_value = mock_eval_instance
        mock_profiler = Mock()
        mock_profiler.get_summary.return_value = {"total_duration": 120.0}
        mock_profiler_cls.return_value = mock_profiler
        optimizer = _make_optimizer(
            metric_weights={"quality": 0.6, "speed": 0.4}
        )
        result = optimizer._run_experiment(
            {
                "iterations": 2,
                "questions_per_iteration": 3,
                "search_strategy": "iterdrag",
                "max_results": 50,
            }
        )
        assert result["success"] is True
        assert result["quality_score"] == 0.85
        assert result["speed_score"] == pytest.approx(2 / 3, abs=0.01)
        assert result["total_duration"] == 120.0
        assert "score" in result
        assert "benchmark_results" in result
