"""
High-value edge case tests for benchmarks/optimization/optuna_optimizer.py

Tests cover:
- Edge cases in parameter optimization (_objective with different param types)
- Trial generation and convergence scenarios
- Constraint violation / error handling paths (all try/except blocks)
- Boundary conditions in speed score calculation
- Invalid input handling (zero weights, empty param spaces)
- State management edge cases (_optimization_callback, trials_history)
- Visualization error paths and corner cases
- _save_results with missing/partial study data
- Convenience function argument forwarding
"""

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


MODULE = "local_deep_research.benchmarks.optimization.optuna_optimizer"


def _make_optimizer(**kwargs):
    """Helper to create an OptunaOptimizer with mocked evaluator."""
    from local_deep_research.benchmarks.optimization.optuna_optimizer import (
        OptunaOptimizer,
    )

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


class TestObjectiveParameterTypes:
    """Tests for _objective handling of different parameter types (int, float, categorical)."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_objective_handles_float_param_type(self, mock_evaluator):
        """Test that _objective correctly suggests float parameters."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()

        mock_trial = Mock()
        mock_trial.number = 0
        mock_trial.suggest_float.return_value = 0.5
        mock_trial.set_user_attr = Mock()

        param_space = {
            "learning_rate": {
                "type": "float",
                "low": 0.01,
                "high": 1.0,
                "step": None,
                "log": True,
            }
        }

        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.return_value = {"score": 0.6}
            score = optimizer._objective(mock_trial, param_space=param_space)

        mock_trial.suggest_float.assert_called_once_with(
            "learning_rate", 0.01, 1.0, step=None, log=True
        )
        assert score == 0.6

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_objective_handles_unknown_param_type_silently(
        self, mock_evaluator
    ):
        """Test that _objective skips params with unrecognized type strings."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()

        mock_trial = Mock()
        mock_trial.number = 0
        mock_trial.set_user_attr = Mock()

        # A param type that doesn't match any branch
        param_space = {
            "unknown_param": {
                "type": "boolean",
                "default": True,
            }
        }

        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.return_value = {"score": 0.5}
            score = optimizer._objective(mock_trial, param_space=param_space)

        # The unknown param type should be silently skipped
        assert score == 0.5
        mock_trial.suggest_int.assert_not_called()
        mock_trial.suggest_float.assert_not_called()
        mock_trial.suggest_categorical.assert_not_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_objective_with_empty_param_space(self, mock_evaluator):
        """Test _objective with an empty parameter space dict."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()

        mock_trial = Mock()
        mock_trial.number = 0
        mock_trial.set_user_attr = Mock()

        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.return_value = {"score": 0.3}
            score = optimizer._objective(mock_trial, param_space={})

        assert score == 0.3
        # _run_experiment should be called with empty params dict
        mock_run.assert_called_once_with({})


class TestObjectiveErrorAndCallback:
    """Tests for _objective error handling and progress callback invocation."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_objective_error_triggers_progress_callback_with_error_status(
        self, mock_evaluator
    ):
        """Test that when _run_experiment raises, the progress callback receives error status."""
        mock_evaluator.return_value = Mock()
        callback = Mock()
        optimizer = _make_optimizer(progress_callback=callback)

        mock_trial = Mock()
        mock_trial.number = 5
        mock_trial.suggest_int.return_value = 2
        mock_trial.suggest_categorical.return_value = "iterdrag"
        mock_trial.set_user_attr = Mock()

        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.side_effect = RuntimeError("GPU OOM")
            param_space = optimizer._get_default_param_space()
            score = optimizer._objective(mock_trial, param_space=param_space)

        assert score == float("-inf")
        # Check that the error callback was invoked
        error_calls = [
            c
            for c in callback.call_args_list
            if c[0][2].get("status") == "error"
        ]
        assert len(error_calls) == 1
        assert "GPU OOM" in error_calls[0][0][2]["error"]

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_objective_success_appends_to_trials_history(self, mock_evaluator):
        """Test that a successful trial is appended to trials_history with correct fields."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()

        mock_trial = Mock()
        mock_trial.number = 7
        mock_trial.suggest_int.return_value = 3
        mock_trial.suggest_categorical.return_value = "standard"
        mock_trial.set_user_attr = Mock()

        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.return_value = {"score": 0.92, "quality_score": 0.95}
            param_space = optimizer._get_default_param_space()
            optimizer._objective(mock_trial, param_space=param_space)

        assert len(optimizer.trials_history) == 1
        entry = optimizer.trials_history[0]
        assert entry["trial_number"] == 7
        assert entry["score"] == 0.92
        assert "params" in entry
        assert "duration" in entry
        assert "timestamp" in entry

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_objective_error_does_not_append_to_trials_history(
        self, mock_evaluator
    ):
        """Test that a failed trial is NOT appended to trials_history."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()

        mock_trial = Mock()
        mock_trial.number = 0
        mock_trial.suggest_int.return_value = 1
        mock_trial.suggest_categorical.return_value = "rapid"
        mock_trial.set_user_attr = Mock()

        with patch.object(optimizer, "_run_experiment") as mock_run:
            mock_run.side_effect = ValueError("bad config")
            param_space = optimizer._get_default_param_space()
            optimizer._objective(mock_trial, param_space=param_space)

        assert len(optimizer.trials_history) == 0


class TestRunExperimentEdgeCases:
    """Tests for _run_experiment edge cases and error paths."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_run_experiment_error_returns_failure_dict(
        self, mock_profiler_cls, mock_evaluator
    ):
        """Test that _run_experiment catches exceptions and returns a failure dict."""
        mock_eval_instance = Mock()
        mock_eval_instance.evaluate.side_effect = ConnectionError(
            "network down"
        )
        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": 1})

        assert result["success"] is False
        assert result["score"] == 0.0
        assert "network down" in result["error"]
        # Profiler stop should still be called on error
        mock_profiler.stop.assert_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_speed_score_boundary_very_fast(
        self, mock_profiler_cls, mock_evaluator
    ):
        """Test speed_score is clamped to 1.0 for very fast durations (<60s)."""
        mock_eval_instance = Mock()
        mock_eval_instance.evaluate.return_value = {
            "quality_score": 0.9,
            "benchmark_results": {},
        }
        mock_evaluator.return_value = mock_eval_instance

        mock_profiler = Mock()
        mock_profiler.get_summary.return_value = {"total_duration": 10.0}
        mock_profiler_cls.return_value = mock_profiler

        optimizer = _make_optimizer(
            metric_weights={"quality": 0.5, "speed": 0.5}
        )
        result = optimizer._run_experiment({"iterations": 1})

        # speed_score = max(0, min(1, 1 - (10-60)/180)) = min(1, 1 + 50/180) = 1.0
        assert result["speed_score"] == 1.0

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_speed_score_boundary_very_slow(
        self, mock_profiler_cls, mock_evaluator
    ):
        """Test speed_score is clamped to 0.0 for very slow durations (>240s)."""
        mock_eval_instance = Mock()
        mock_eval_instance.evaluate.return_value = {
            "quality_score": 0.5,
            "benchmark_results": {},
        }
        mock_evaluator.return_value = mock_eval_instance

        mock_profiler = Mock()
        mock_profiler.get_summary.return_value = {"total_duration": 500.0}
        mock_profiler_cls.return_value = mock_profiler

        optimizer = _make_optimizer()
        result = optimizer._run_experiment({"iterations": 5})

        # speed_score = max(0, min(1, 1 - (500-60)/180)) = max(0, 1 - 2.44) = 0.0
        assert result["speed_score"] == 0.0

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_run_experiment_default_params_when_missing(
        self, mock_profiler_cls, mock_evaluator
    ):
        """Test _run_experiment uses defaults when params dict is sparse."""
        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.return_value = {"total_duration": 100.0}
        mock_profiler_cls.return_value = mock_profiler

        optimizer = _make_optimizer()
        result = optimizer._run_experiment({})  # empty params

        assert result["success"] is True
        # Should have used defaults and not crashed
        assert "score" in result


class TestSpeedScoreCalculation:
    """Tests for the speed score formula boundary conditions."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_speed_score_at_exact_60_seconds(
        self, mock_profiler_cls, mock_evaluator
    ):
        """Test speed_score at exactly 60 seconds (should be 1.0)."""
        mock_eval_instance = Mock()
        mock_eval_instance.evaluate.return_value = {
            "quality_score": 0.5,
            "benchmark_results": {},
        }
        mock_evaluator.return_value = mock_eval_instance

        mock_profiler = Mock()
        mock_profiler.get_summary.return_value = {"total_duration": 60.0}
        mock_profiler_cls.return_value = mock_profiler

        optimizer = _make_optimizer()
        result = optimizer._run_experiment({"iterations": 1})

        # speed_score = max(0, min(1, 1 - (60-60)/180)) = 1.0
        assert result["speed_score"] == pytest.approx(1.0)

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.SpeedProfiler")
    def test_speed_score_at_exact_240_seconds(
        self, mock_profiler_cls, mock_evaluator
    ):
        """Test speed_score at exactly 240 seconds (should be 0.0)."""
        mock_eval_instance = Mock()
        mock_eval_instance.evaluate.return_value = {
            "quality_score": 0.5,
            "benchmark_results": {},
        }
        mock_evaluator.return_value = mock_eval_instance

        mock_profiler = Mock()
        mock_profiler.get_summary.return_value = {"total_duration": 240.0}
        mock_profiler_cls.return_value = mock_profiler

        optimizer = _make_optimizer()
        result = optimizer._run_experiment({"iterations": 1})

        # speed_score = max(0, min(1, 1 - (240-60)/180)) = max(0, 0) = 0.0
        assert result["speed_score"] == pytest.approx(0.0)


class TestWeightNormalizationEdgeCases:
    """Tests for metric weight normalization edge cases."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_zero_total_weight_preserved(self, mock_evaluator):
        """Test that zero total weight does not cause division by zero."""
        mock_evaluator.return_value = Mock()
        # All weights are zero - normalization guard: total_weight > 0 is False
        optimizer = _make_optimizer(metric_weights={"quality": 0, "speed": 0})

        # Weights should remain as-is (all zeros) since total is 0
        assert optimizer.metric_weights["quality"] == 0
        assert optimizer.metric_weights["speed"] == 0

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_single_metric_weight_normalizes_to_one(self, mock_evaluator):
        """Test that a single metric weight normalizes to 1.0."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(metric_weights={"quality": 5.0})

        assert optimizer.metric_weights["quality"] == pytest.approx(1.0)

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_many_metrics_normalize_correctly(self, mock_evaluator):
        """Test normalization with many metrics."""
        mock_evaluator.return_value = Mock()
        weights = {"quality": 3.0, "speed": 2.0, "resource": 1.0, "cost": 4.0}
        optimizer = _make_optimizer(metric_weights=weights)

        total = sum(optimizer.metric_weights.values())
        assert total == pytest.approx(1.0)
        assert optimizer.metric_weights["quality"] == pytest.approx(0.3)
        assert optimizer.metric_weights["cost"] == pytest.approx(0.4)


class TestOptimizationCallbackEdgeCases:
    """Tests for _optimization_callback periodic save behavior."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_callback_saves_at_trial_10(self, mock_evaluator):
        """Test that _optimization_callback triggers save at trial 10."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()

        mock_study = Mock()
        mock_trial = Mock()
        mock_trial.number = 10

        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_callback_does_not_save_at_trial_0(self, mock_evaluator):
        """Test that _optimization_callback does NOT trigger save at trial 0."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()

        mock_study = Mock()
        mock_trial = Mock()
        mock_trial.number = 0

        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_not_called()
        mock_viz.assert_not_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_callback_does_not_save_at_non_multiple_of_10(self, mock_evaluator):
        """Test that _optimization_callback does NOT save at trial 7."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()

        mock_study = Mock()
        mock_trial = Mock()
        mock_trial.number = 7

        with patch.object(optimizer, "_save_results") as mock_save:
            optimizer._optimization_callback(mock_study, mock_trial)

        mock_save.assert_not_called()


class TestSaveResultsEdgeCases:
    """Tests for _save_results with edge case data."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.joblib")
    @patch(
        "local_deep_research.security.file_write_verifier.write_json_verified"
    )
    def test_save_results_with_numpy_values_in_nested_dicts(
        self, mock_write_json, mock_joblib, mock_evaluator, tmp_path
    ):
        """Test _save_results converts numpy types in nested trial dicts."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))

        mock_study = Mock()
        mock_study.best_params = {"iterations": 2}
        mock_study.best_value = np.float64(0.85)
        mock_study.best_trial = Mock()
        mock_study.best_trial.user_attrs = {}
        mock_study.trials = [Mock()]
        optimizer.study = mock_study
        optimizer.best_params = {"iterations": 2}

        # Include numpy values in trials_history
        optimizer.trials_history = [
            {
                "trial_number": 0,
                "params": {"iterations": np.int64(2)},
                "score": np.float64(0.85),
                "result": {"quality_score": np.float32(0.9)},
            }
        ]

        optimizer._save_results()

        # Verify write_json_verified was called (history + best params)
        assert mock_write_json.call_count == 2

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.joblib")
    @patch(
        "local_deep_research.security.file_write_verifier.write_json_verified"
    )
    def test_save_results_with_no_study_skips_best_params(
        self, mock_write_json, mock_joblib, mock_evaluator, tmp_path
    ):
        """Test _save_results when study is None skips best_params and study save."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        optimizer.study = None
        optimizer.best_params = None
        optimizer.trials_history = []

        optimizer._save_results()

        # Only history file should be written, not best_params
        assert mock_write_json.call_count == 1
        mock_joblib.dump.assert_not_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.joblib")
    @patch(
        "local_deep_research.security.file_write_verifier.write_json_verified"
    )
    def test_save_results_study_without_best_params(
        self, mock_write_json, mock_joblib, mock_evaluator, tmp_path
    ):
        """Test _save_results when study exists but best_params is empty/falsy."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))

        mock_study = Mock()
        mock_study.best_params = {}  # empty dict is falsy
        mock_study.best_value = 0.0
        mock_study.trials = []
        optimizer.study = mock_study
        optimizer.best_params = {}
        optimizer.trials_history = []

        optimizer._save_results()

        # history is written + study.pkl is saved, but best_params JSON is skipped
        # because self.study.best_params is {} which is falsy
        assert mock_write_json.call_count == 1
        mock_joblib.dump.assert_called_once()


class TestVisualizationEdgeCases:
    """Tests for visualization methods handling edge cases."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.PLOTTING_AVAILABLE", False)
    def test_create_visualizations_returns_early_without_matplotlib(
        self, mock_evaluator
    ):
        """Test _create_visualizations returns early when matplotlib is unavailable."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer()
        optimizer.study = Mock()
        optimizer.study.trials = [Mock(), Mock()]

        # Should not raise and should return early
        optimizer._create_visualizations()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.PLOTTING_AVAILABLE", True)
    def test_create_visualizations_returns_early_with_less_than_2_trials(
        self, mock_evaluator, tmp_path
    ):
        """Test _create_visualizations returns early with fewer than 2 trials."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        optimizer.study = Mock()
        optimizer.study.trials = [Mock()]  # Only 1 trial
        optimizer.trials_history = []

        # Should not raise - returns early due to insufficient trials
        with patch.object(
            optimizer, "_create_optuna_visualizations"
        ) as mock_optuna_viz:
            optimizer._create_visualizations()
            mock_optuna_viz.assert_not_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_create_custom_visualizations_returns_early_with_no_history(
        self, mock_evaluator, tmp_path
    ):
        """Test _create_custom_visualizations returns early with empty trials_history."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        optimizer.trials_history = []

        # Should not raise or try to create any plots
        with patch(f"{MODULE}.plt") as mock_plt:
            optimizer._create_custom_visualizations(str(tmp_path))
            mock_plt.figure.assert_not_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    def test_quality_vs_speed_plot_no_successful_trials(
        self, mock_evaluator, tmp_path
    ):
        """Test _create_quality_vs_speed_plot with only failed trials."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        optimizer.trials_history = [
            {"result": {"success": False}, "params": {}, "score": 0.0},
        ]

        with patch(f"{MODULE}.plt") as mock_plt:
            optimizer._create_quality_vs_speed_plot(str(tmp_path), "20260304")
            # Should return early because no successful trials
            mock_plt.figure.assert_not_called()

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.PLOTTING_AVAILABLE", True)
    def test_quick_visualizations_handles_plot_error(
        self, mock_evaluator, tmp_path
    ):
        """Test _create_quick_visualizations gracefully handles plot errors."""
        mock_evaluator.return_value = Mock()
        optimizer = _make_optimizer(output_dir=str(tmp_path))
        optimizer.study = Mock()
        optimizer.study.trials = [Mock(), Mock()]

        with patch(f"{MODULE}.plot_optimization_history") as mock_plot:
            mock_plot.side_effect = RuntimeError("plot failed")
            # Should not raise
            optimizer._create_quick_visualizations()


class TestOptimizeMethodEdgeCases:
    """Tests for optimize() method edge cases."""

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.optuna")
    def test_optimize_calls_progress_callback_on_start(
        self, mock_optuna, mock_evaluator
    ):
        """Test optimize() calls progress_callback with 'starting' status."""
        mock_evaluator.return_value = Mock()
        callback = Mock()

        mock_study = Mock()
        mock_study.best_params = {"iterations": 1}
        mock_study.best_value = 0.5
        mock_study.best_trial = Mock()
        mock_study.best_trial.user_attrs = {}
        mock_study.trials = []
        mock_optuna.create_study.return_value = mock_study

        optimizer = _make_optimizer(n_trials=1, progress_callback=callback)

        with (
            patch.object(optimizer, "_save_results"),
            patch.object(optimizer, "_create_visualizations"),
        ):
            optimizer.optimize()

        # First callback call should have status "starting"
        first_call = callback.call_args_list[0]
        assert first_call[0][0] == 0  # trial_num = 0
        assert first_call[0][2]["status"] == "starting"

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.optuna")
    def test_optimize_calls_progress_callback_on_completion(
        self, mock_optuna, mock_evaluator
    ):
        """Test optimize() calls progress_callback with 'completed' status at the end."""
        mock_evaluator.return_value = Mock()
        callback = Mock()

        mock_study = Mock()
        mock_study.best_params = {"iterations": 2}
        mock_study.best_value = 0.9
        mock_study.best_trial = Mock()
        mock_study.best_trial.user_attrs = {}
        mock_study.trials = [Mock()]
        mock_optuna.create_study.return_value = mock_study

        optimizer = _make_optimizer(n_trials=1, progress_callback=callback)

        with (
            patch.object(optimizer, "_save_results"),
            patch.object(optimizer, "_create_visualizations"),
        ):
            optimizer.optimize()

        # Last callback call should have status "completed"
        last_call = callback.call_args_list[-1]
        assert last_call[0][2]["status"] == "completed"
        assert last_call[0][2]["best_value"] == 0.9

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.optuna")
    def test_optimize_handles_keyboard_interrupt(
        self, mock_optuna, mock_evaluator
    ):
        """Test optimize() handles KeyboardInterrupt, saves results, and returns best."""
        mock_evaluator.return_value = Mock()

        mock_study = Mock()
        mock_study.best_params = {"iterations": 1}
        mock_study.best_value = 0.3
        mock_study.best_trial = Mock()
        mock_study.best_trial.user_attrs = {}
        mock_study.trials = [Mock()]
        mock_study.optimize.side_effect = KeyboardInterrupt()
        mock_optuna.create_study.return_value = mock_study

        optimizer = _make_optimizer(n_trials=10)

        with (
            patch.object(optimizer, "_save_results") as mock_save,
            patch.object(optimizer, "_create_visualizations") as mock_viz,
        ):
            best_params, best_value = optimizer.optimize()

        # Should still save and visualize
        mock_save.assert_called_once()
        mock_viz.assert_called_once()
        assert best_params == {"iterations": 1}
        assert best_value == 0.3

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.optuna")
    def test_optimize_keyboard_interrupt_with_callback(
        self, mock_optuna, mock_evaluator
    ):
        """Test optimize() invokes callback with 'interrupted' on KeyboardInterrupt."""
        mock_evaluator.return_value = Mock()
        callback = Mock()

        mock_study = Mock()
        mock_study.best_params = {"iterations": 1}
        mock_study.best_value = 0.2
        mock_study.best_trial = Mock()
        mock_study.best_trial.user_attrs = {}
        mock_study.trials = [Mock(), Mock()]
        mock_study.optimize.side_effect = KeyboardInterrupt()
        mock_optuna.create_study.return_value = mock_study

        optimizer = _make_optimizer(n_trials=10, progress_callback=callback)

        with (
            patch.object(optimizer, "_save_results"),
            patch.object(optimizer, "_create_visualizations"),
        ):
            optimizer.optimize()

        # Find the interrupted callback
        interrupted_calls = [
            c
            for c in callback.call_args_list
            if c[0][2].get("status") == "interrupted"
        ]
        assert len(interrupted_calls) == 1
        assert interrupted_calls[0][0][2]["trials_completed"] == 2

    @patch(f"{MODULE}.CompositeBenchmarkEvaluator")
    @patch(f"{MODULE}.optuna")
    def test_optimize_uses_custom_param_space(
        self, mock_optuna, mock_evaluator
    ):
        """Test optimize() passes custom param_space through to _objective."""
        mock_evaluator.return_value = Mock()

        mock_study = Mock()
        mock_study.best_params = {"custom_param": 5}
        mock_study.best_value = 0.7
        mock_study.best_trial = Mock()
        mock_study.best_trial.user_attrs = {}
        mock_study.trials = []
        mock_optuna.create_study.return_value = mock_study

        optimizer = _make_optimizer(n_trials=1)

        custom_space = {
            "custom_param": {"type": "int", "low": 1, "high": 10, "step": 1}
        }

        with (
            patch.object(optimizer, "_save_results"),
            patch.object(optimizer, "_create_visualizations"),
        ):
            best_params, best_value = optimizer.optimize(
                param_space=custom_space
            )

        # Verify study.optimize was called (the partial wrapping the custom space)
        mock_study.optimize.assert_called_once()
        assert best_params == {"custom_param": 5}


class TestConvenienceFunctionForwarding:
    """Tests for convenience functions forwarding arguments correctly."""

    @patch(f"{MODULE}.OptunaOptimizer")
    def test_optimize_parameters_forwards_all_kwargs(self, mock_cls):
        """Test optimize_parameters forwards all keyword arguments."""
        from local_deep_research.benchmarks.optimization.optuna_optimizer import (
            optimize_parameters,
        )

        mock_instance = Mock()
        mock_instance.optimize.return_value = ({"a": 1}, 0.5)
        mock_cls.return_value = mock_instance

        callback = Mock()
        optimize_parameters(
            query="my query",
            output_dir="/tmp/out",
            model_name="gpt-4",
            provider="openai",
            search_tool="google",
            temperature=0.3,
            n_trials=15,
            timeout=600,
            n_jobs=2,
            study_name="my_study",
            optimization_metrics=["quality"],
            metric_weights={"quality": 1.0},
            progress_callback=callback,
            benchmark_weights={"simpleqa": 0.5, "browsecomp": 0.5},
        )

        call_kwargs = mock_cls.call_args[1]
        assert call_kwargs["base_query"] == "my query"
        assert call_kwargs["output_dir"] == "/tmp/out"
        assert call_kwargs["model_name"] == "gpt-4"
        assert call_kwargs["provider"] == "openai"
        assert call_kwargs["temperature"] == 0.3
        assert call_kwargs["n_trials"] == 15
        assert call_kwargs["timeout"] == 600
        assert call_kwargs["n_jobs"] == 2
        assert call_kwargs["study_name"] == "my_study"
        assert call_kwargs["progress_callback"] is callback
        assert call_kwargs["benchmark_weights"] == {
            "simpleqa": 0.5,
            "browsecomp": 0.5,
        }

    @patch(f"{MODULE}.OptunaOptimizer")
    def test_optimize_for_speed_passes_reduced_param_space(self, mock_cls):
        """Test optimize_for_speed provides a reduced param space (max iterations=3)."""
        from local_deep_research.benchmarks.optimization.optuna_optimizer import (
            optimize_for_speed,
        )

        mock_instance = Mock()
        mock_instance.optimize.return_value = ({}, 0.0)
        mock_cls.return_value = mock_instance

        optimize_for_speed(query="test", n_trials=5)

        # Check the param_space passed to optimize()
        optimize_call = mock_instance.optimize.call_args
        param_space = optimize_call[1].get("param_space") or optimize_call[0][0]
        assert param_space["iterations"]["high"] == 3
        assert "focused-iteration" in param_space["search_strategy"]["choices"]
