#!/usr/bin/env python3
"""
Persistence Thread Parameter Optimization

Uses Bayesian Optimization to automatically find the optimal configuration
parameters that minimize commit latency. This is much more efficient than
grid search since it uses a probabilistic model to guide parameter exploration.

Key advantages:
- Efficiently explores high-dimensional parameter spaces
- Uses previous simulation results to guide future parameter choices
- Handles expensive objective function evaluations (our simulation)
- Provides uncertainty estimates for parameter importance
"""

import numpy as np
from typing import Dict, List, Tuple, Optional
import time
from persistence_simulation import PersistenceSimulation, print_results

# Try to import scikit-optimize for Bayesian optimization
try:
    from skopt import gp_minimize, forest_minimize
    from skopt.space import Real, Integer
    from skopt.utils import use_named_args
    from skopt.plots import plot_convergence, plot_objective
    import matplotlib.pyplot as plt

    OPTIMIZE_AVAILABLE = True
except ImportError:
    print("scikit-optimize not available. Install with: pip install scikit-optimize")
    print("Falling back to grid search...")
    OPTIMIZE_AVAILABLE = False


class PersistenceOptimizer:
    """
    Automated parameter optimization for the persistence thread using Bayesian optimization.

    This class finds the optimal configuration parameters to minimize commit latency
    by intelligently exploring the parameter space using Gaussian Process models.
    """

    def __init__(
        self,
        optimization_budget: int = 50,
        simulation_duration: float = 20.0,
        arrival_rate: float = 1000.0,
        objective_metric: str = "p95_latency",
        random_seed: int = 42,
    ):

        self.optimization_budget = optimization_budget
        self.simulation_duration = simulation_duration
        self.arrival_rate = arrival_rate
        self.objective_metric = objective_metric
        self.random_seed = random_seed

        # Track optimization history
        self.optimization_history = []
        self.best_params = None
        self.best_score = float("inf")

        # Define parameter search space
        self.parameter_space = self._define_search_space()
        self.parameter_names = [dim.name for dim in self.parameter_space]

    def _define_search_space(self) -> List:
        """
        Define the parameter search space for optimization.

        Focus on the 3 core parameters that matter for persistence thread performance
        with 100% reliable S3. Retry parameters removed since S3 never fails.
        """
        return [
            # Core batching parameters
            Real(
                1.0, 50.0, name="batch_timeout_ms", prior="log-uniform"
            ),  # Log scale since small changes matter
            Integer(
                64 * 1024,
                4 * 1024 * 1024,
                name="batch_size_threshold",  # 64KB - 4MB
                prior="log-uniform",
            ),
            # Flow control parameters - likely the most impactful
            Integer(1, 50, name="max_in_flight_requests"),
        ]

    def _run_simulation_with_params(self, params: Dict[str, float]) -> Dict:
        """Run simulation with given parameters and return results"""
        try:
            sim = PersistenceSimulation(
                batch_timeout_ms=params["batch_timeout_ms"],
                batch_size_threshold=int(params["batch_size_threshold"]),
                max_in_flight_requests=int(params["max_in_flight_requests"]),
                # Retry parameters fixed since S3 is 100% reliable
                max_retry_attempts=0,  # No retries needed
                retry_base_delay_ms=100.0,  # Irrelevant but needs a value
                # S3 parameters kept fixed - 100% reliable for optimization focus
                s3_latency_shape=2.0,  # Fixed Gamma shape
                s3_latency_scale=15.0,  # Fixed Gamma scale (30ms RTT + ~30ms variable = ~60ms mean)
                s3_failure_rate=0.0,  # 100% reliable S3
                arrival_rate_per_sec=self.arrival_rate,
                simulation_duration_sec=self.simulation_duration,
            )

            return sim.run_simulation()

        except Exception as e:
            print(f"Simulation failed with params {params}: {e}")
            # Return a high penalty score for failed simulations
            return {
                "commit_metrics": {
                    "latency_ms": {"mean": 10000, "p95": 10000, "p99": 10000}
                },
                "error": str(e),
            }

    def _extract_objective_value(self, results: Dict) -> float:
        """Extract the objective value to minimize from simulation results"""
        try:
            commit_metrics = results["commit_metrics"]["latency_ms"]

            if self.objective_metric == "mean_latency":
                return commit_metrics["mean"]
            elif self.objective_metric == "p95_latency":
                return commit_metrics["p95"]
            elif self.objective_metric == "p99_latency":
                return commit_metrics["p99"]
            elif self.objective_metric == "weighted_latency":
                # Weighted combination emphasizing tail latencies
                return (
                    0.3 * commit_metrics["mean"]
                    + 0.5 * commit_metrics["p95"]
                    + 0.2 * commit_metrics["p99"]
                )
            else:
                return commit_metrics["p95"]  # Default to P95

        except KeyError as e:
            print(f"Failed to extract objective from results: {e}")
            return 10000  # High penalty for invalid results

    def optimize_with_bayesian(self) -> Tuple[Dict, float]:
        """
        Use Bayesian Optimization to find optimal parameters.

        This uses Gaussian Process models to build a probabilistic model
        of the objective function and intelligently choose where to sample next.
        """
        if not OPTIMIZE_AVAILABLE:
            return self.optimize_with_grid_search()

        print(
            f"Starting Bayesian Optimization with {self.optimization_budget} evaluations"
        )
        print(f"Objective: Minimize {self.objective_metric}")
        print(f"Parameter space: {len(self.parameter_space)} dimensions")
        print()

        @use_named_args(self.parameter_space)
        def objective(**params):
            """Objective function for Bayesian optimization"""
            print(f"Evaluating: {params}")

            start_time = time.time()
            results = self._run_simulation_with_params(params)
            eval_time = time.time() - start_time

            objective_value = self._extract_objective_value(results)

            # Track optimization history
            history_entry = {
                "params": params.copy(),
                "objective_value": objective_value,
                "results": results,
                "eval_time": eval_time,
                "iteration": len(self.optimization_history) + 1,
            }
            self.optimization_history.append(history_entry)

            # Update best if improved
            if objective_value < self.best_score:
                self.best_score = objective_value
                self.best_params = params.copy()
                print(
                    f"✓ NEW BEST: {objective_value:.2f}ms (evaluation {history_entry['iteration']})"
                )
            else:
                print(f"  Score: {objective_value:.2f}ms")

            print(f"  Time: {eval_time:.1f}s")
            print()

            return objective_value

        # Run Bayesian optimization
        result = gp_minimize(
            func=objective,
            dimensions=self.parameter_space,
            n_calls=self.optimization_budget,
            n_initial_points=10,  # Random exploration first
            acq_func="EI",  # Expected Improvement acquisition
            random_state=self.random_seed,
        )

        # Extract best parameters
        best_params_list = result.x
        best_params_dict = dict(zip(self.parameter_names, best_params_list))
        best_objective = result.fun

        return best_params_dict, best_objective

    def optimize_with_grid_search(self) -> Tuple[Dict, float]:
        """Fallback grid search optimization if scikit-optimize not available"""
        print(
            "Using grid search optimization (install scikit-optimize for better results)"
        )
        print()

        # Define a smaller grid for key parameters
        grid_configs = [
            # Vary max_in_flight and batch_timeout
            {"max_in_flight_requests": 5, "batch_timeout_ms": 5.0},
            {"max_in_flight_requests": 10, "batch_timeout_ms": 5.0},
            {"max_in_flight_requests": 20, "batch_timeout_ms": 5.0},
            {"max_in_flight_requests": 10, "batch_timeout_ms": 2.0},
            {"max_in_flight_requests": 10, "batch_timeout_ms": 10.0},
            {"max_in_flight_requests": 15, "batch_timeout_ms": 3.0},
            {"max_in_flight_requests": 25, "batch_timeout_ms": 7.0},
        ]

        best_params = None
        best_score = float("inf")

        for i, config in enumerate(grid_configs):
            print(f"Evaluating config {i+1}/{len(grid_configs)}: {config}")

            # Use default values for unspecified parameters
            full_params = {
                "batch_timeout_ms": 5.0,
                "batch_size_threshold": 1024 * 1024,
                "max_in_flight_requests": 5,
            }
            full_params.update(config)

            results = self._run_simulation_with_params(full_params)
            objective_value = self._extract_objective_value(results)

            if objective_value < best_score:
                best_score = objective_value
                best_params = full_params.copy()
                print(f"✓ NEW BEST: {objective_value:.2f}ms")
            else:
                print(f"  Score: {objective_value:.2f}ms")
            print()

        return best_params, best_score

    def analyze_parameter_importance(self):
        """Analyze which parameters have the most impact on performance"""
        if not self.optimization_history:
            print("No optimization history available")
            return

        print("Parameter Importance Analysis")
        print("=" * 50)

        # Extract parameter values and objectives
        param_data = {}
        objectives = []

        for entry in self.optimization_history:
            objectives.append(entry["objective_value"])
            for param_name, param_value in entry["params"].items():
                if param_name not in param_data:
                    param_data[param_name] = []
                param_data[param_name].append(param_value)

        objectives = np.array(objectives)

        # Simple correlation analysis
        print("Parameter correlations with objective (lower is better):")
        correlations = []

        for param_name, values in param_data.items():
            correlation = np.corrcoef(values, objectives)[0, 1]
            correlations.append((param_name, correlation))
            print(f"  {param_name:<25}: {correlation:+.3f}")

        print("\nMost impactful parameters (by absolute correlation):")
        correlations.sort(key=lambda x: abs(x[1]), reverse=True)
        for param_name, correlation in correlations[:5]:
            impact = "reduces latency" if correlation < 0 else "increases latency"
            print(f"  {param_name:<25}: {impact} (r={correlation:+.3f})")

    def plot_optimization_progress(self, save_path: Optional[str] = None):
        """Plot optimization convergence"""
        if not OPTIMIZE_AVAILABLE or not self.optimization_history:
            return

        iterations = [entry["iteration"] for entry in self.optimization_history]
        objectives = [entry["objective_value"] for entry in self.optimization_history]

        # Calculate running minimum (best so far)
        running_min = []
        current_min = float("inf")
        for obj in objectives:
            current_min = min(current_min, obj)
            running_min.append(current_min)

        plt.figure(figsize=(12, 8))

        # Plot 1: Objective value over iterations
        plt.subplot(2, 2, 1)
        plt.scatter(iterations, objectives, alpha=0.6, s=30)
        plt.plot(iterations, running_min, "r-", linewidth=2, label="Best so far")
        plt.xlabel("Iteration")
        plt.ylabel(f"{self.objective_metric} (ms)")
        plt.title("Optimization Progress")
        plt.legend()
        plt.grid(True, alpha=0.3)

        # Plot 2: Parameter evolution for key parameters
        plt.subplot(2, 2, 2)
        key_params = ["max_in_flight_requests", "batch_timeout_ms"]
        for param in key_params:
            if param in self.optimization_history[0]["params"]:
                values = [entry["params"][param] for entry in self.optimization_history]
                plt.scatter(iterations, values, alpha=0.6, label=param, s=30)
        plt.xlabel("Iteration")
        plt.ylabel("Parameter Value")
        plt.title("Key Parameter Evolution")
        plt.legend()
        plt.grid(True, alpha=0.3)

        # Plot 3: Objective distribution
        plt.subplot(2, 2, 3)
        plt.hist(objectives, bins=20, alpha=0.7, edgecolor="black")
        plt.axvline(
            self.best_score,
            color="red",
            linestyle="--",
            label=f"Best: {self.best_score:.1f}ms",
        )
        plt.xlabel(f"{self.objective_metric} (ms)")
        plt.ylabel("Count")
        plt.title("Objective Value Distribution")
        plt.legend()
        plt.grid(True, alpha=0.3)

        # Plot 4: Convergence rate
        plt.subplot(2, 2, 4)
        improvements = []
        for i, entry in enumerate(self.optimization_history):
            if i == 0:
                improvements.append(0)
            else:
                prev_best = running_min[i - 1]
                curr_best = running_min[i]
                improvement = prev_best - curr_best
                improvements.append(improvement)

        plt.plot(iterations, improvements, "g-", marker="o", markersize=3)
        plt.xlabel("Iteration")
        plt.ylabel("Improvement (ms)")
        plt.title("Per-Iteration Improvement")
        plt.grid(True, alpha=0.3)

        plt.tight_layout()

        if save_path:
            plt.savefig(save_path, dpi=300, bbox_inches="tight")
            print(f"Optimization plots saved to {save_path}")
        else:
            plt.show()

    def run_optimization(self) -> Dict:
        """Run the full optimization process and return results"""
        start_time = time.time()

        # Run optimization
        if OPTIMIZE_AVAILABLE:
            best_params, best_score = self.optimize_with_bayesian()
        else:
            best_params, best_score = self.optimize_with_grid_search()

        total_time = time.time() - start_time

        # Run final simulation with best parameters for detailed results
        print("Running final simulation with optimal parameters...")
        final_results = self._run_simulation_with_params(best_params)

        # Prepare optimization summary
        optimization_summary = {
            "best_parameters": best_params,
            "best_objective_value": best_score,
            "optimization_time": total_time,
            "evaluations_performed": len(self.optimization_history),
            "final_simulation_results": final_results,
            "optimization_history": self.optimization_history,
        }

        return optimization_summary

    def print_optimization_summary(self, summary: Dict):
        """Print a comprehensive summary of optimization results"""
        print("=" * 80)
        print("BAYESIAN OPTIMIZATION RESULTS")
        print("=" * 80)

        print(f"Optimization completed in {summary['optimization_time']:.1f} seconds")
        print(f"Performed {summary['evaluations_performed']} parameter evaluations")
        print(f"Best {self.objective_metric}: {summary['best_objective_value']:.2f}ms")
        print()

        print("OPTIMAL PARAMETERS:")
        print("-" * 40)
        for param, value in summary["best_parameters"].items():
            if isinstance(value, float):
                if param.endswith("_rate"):
                    print(f"  {param:<25}: {value:.4f}")
                else:
                    print(f"  {param:<25}: {value:.2f}")
            else:
                print(f"  {param:<25}: {value}")

        print("\nDETAILED PERFORMANCE WITH OPTIMAL PARAMETERS:")
        print("-" * 50)
        final_results = summary["final_simulation_results"]
        print_results(final_results)

        print("\nPARAMETER IMPACT ANALYSIS:")
        print("-" * 30)
        self.analyze_parameter_importance()


def main():
    """Main optimization workflow"""
    print("Persistence Thread Parameter Optimization")
    print("Using Bayesian Optimization for intelligent parameter search")
    print()

    # Create optimizer with different objective functions to test
    objectives_to_test = ["p95_latency", "weighted_latency"]

    for objective in objectives_to_test:
        print(f"\n{'='*80}")
        print(f"OPTIMIZING FOR: {objective.upper()}")
        print(f"{'='*80}")

        optimizer = PersistenceOptimizer(
            optimization_budget=30,  # Reasonable for demo
            simulation_duration=15.0,  # Shorter sims for faster optimization
            arrival_rate=1000.0,
            objective_metric=objective,
            random_seed=42,
        )

        # Run optimization
        summary = optimizer.run_optimization()
        optimizer.print_optimization_summary(summary)

        # Generate plots
        try:
            optimizer.plot_optimization_progress(f"optimization_{objective}.png")
        except Exception as e:
            print(f"Could not generate plots: {e}")

        print(f"\nOptimization for {objective} completed!")
        print("=" * 80)


if __name__ == "__main__":
    main()
