# client.py - EOG-HPO Client SDK
"""
EOG-HPO Client SDK
Provides intelligent hyperparameter recommendations
"""

import requests
import webbrowser
import time 
import re
import os
import json
import getpass
import logging
from pathlib import Path
from typing import Dict, List, Optional, Callable
from dataclasses import dataclass

# Optional dependencies for visualization
try:
    import pandas as pd
    import numpy as np
    from IPython.display import display, HTML, clear_output
    import matplotlib.pyplot as plt
    _VISUALIZATION_AVAILABLE = True
except ImportError:
    _VISUALIZATION_AVAILABLE = False

# Configure logger
logger = logging.getLogger(__name__)

@dataclass
class Config:
    """Hyperparameter configuration"""
    params: Dict[str, float]
    config_id: str
    iteration: int

 
class EOGHPOClient:
    """
    Client for EOG-HPO recommendation service.
    Trains locally, gets recommendations from cloud.
    """
    
    # Production API endpoint - all users connect here
    DEFAULT_BASE_URL = "https://api.eaheog.com"
    
    def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
        """
        Initialize EOG-HPO client
        
        Args:
            api_key: Optional API key (will prompt for login if not provided)
            base_url: Optional custom API endpoint (for testing only, defaults to production)
        """
        # Use provided URL, environment variable, or production default
        # Environment variable is mainly for internal testing
        self.base_url = (
            base_url or 
            os.environ.get("EOGHPO_BASE_URL") or 
            self.DEFAULT_BASE_URL
        )
        
        self.base_url = self.base_url.rstrip('/')
        self.session_id = None
        self.history = []
        self.current_iteration = 0
        self.search_space = {}
        self.categorical_maps = {}
        self.maximize = True
        
        # Setup persistent session with retries
        self.session = requests.Session()
        adapter = requests.adapters.HTTPAdapter(
            max_retries=3,
            pool_connections=10,
            pool_maxsize=10
        )
        self.session.mount('https://', adapter)
        self.session.mount('http://', adapter)
        
        # Set timeout for all requests
        self.timeout = 60
        
        # 1. Try provided key
        self.api_key = api_key
        
        # 2. Try Environment Variable
        if not self.api_key:
            self.api_key = os.environ.get("EOGHPO_API_KEY")
            
        if self.api_key:
            self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
        logger.debug(f"Initialized EOGHPOClient with base_url={self.base_url}")

    def _get_config_path(self) -> Path:
        """Returns path to local credentials file."""
        config_dir = Path.home() / ".eaheog"
        config_dir.mkdir(exist_ok=True)
        return config_dir / "credentials.json"

    def _load_stored_api_key(self) -> Optional[str]:
        try:
            path = self._get_config_path()
            if path.exists():
                with open(path, 'r') as f:
                    return json.load(f).get('api_key')
        except Exception:
            pass
        return None

    def _save_api_key(self, api_key: str):
        try:
            path = self._get_config_path()
            with open(path, 'w') as f:
                json.dump({'api_key': api_key}, f)
            # Secure file permissions (Unix-like systems)
            try:
                os.chmod(path, 0o600)
            except:
                pass  # Windows doesn't support chmod
        except Exception as e:
            logger.warning(f"Failed to save credentials: {e}")
            print(f"⚠️ Could not save credentials locally: {e}")

    def _get_session_state_path(self) -> Path:
        """Returns path to local active session file."""
        return self._get_config_path().parent / "active_session.json"

    def _save_active_session(self, session_id: str, search_space: Dict):
        try:
            path = self._get_session_state_path()
            with open(path, 'w') as f:
                json.dump({
                    'session_id': session_id,
                    'param_keys': list(search_space.keys()),
                    'timestamp': time.time()
                }, f)
        except Exception as e:
            logger.warning(f"Failed to save session state: {e}")

    def _load_active_session(self) -> Optional[Dict]:
        try:
            path = self._get_session_state_path()
            if path.exists():
                with open(path, 'r') as f:
                    return json.load(f)
        except Exception:
            pass
        return None

    def _clear_active_session(self):
        try:
            path = self._get_session_state_path()
            if path.exists():
                os.remove(path)
        except Exception:
            pass
    
    def _sanitize_for_json(self, obj):
        """
        Recursively converts non-JSON-serializable types (like numpy/pandas) 
        into standard Python types.
        """
        # Handle numpy scalars/arrays and pandas Series
        if hasattr(obj, 'tolist'):
            return obj.tolist()
            
        # Handle numpy scalars that might not have tolist (fallback)
        if hasattr(obj, 'item'):
            try:
                return obj.item()
            except (ValueError, TypeError):
                pass

        if isinstance(obj, dict):
            return {k: self._sanitize_for_json(v) for k, v in obj.items()}
            
        if isinstance(obj, (list, tuple)):
            return [self._sanitize_for_json(x) for x in obj]
            
        return obj

    def _make_request(self, method: str, endpoint: str, **kwargs):
        """Unified request handler with proper error handling"""
        url = f"{self.base_url}{endpoint}"
        
        # Add timeout if not specified
        if 'timeout' not in kwargs:
            kwargs['timeout'] = self.timeout
            
        logger.debug(f"Request: {method} {url}")
         
        response = None
        try:
            response = self.session.request(method, url, **kwargs)
            logger.debug(f"Response: {response.status_code} {response.reason} - {response.elapsed.total_seconds():.3f}s")
            response.raise_for_status()
            return response
        except requests.exceptions.ConnectionError as e:
            logger.error(f"Connection error connecting to {self.base_url}: {e}")
            raise ConnectionError(
                f"Cannot connect to EOG-HPO service at {self.base_url}. "
                f"Please check your internet connection."
            ) from e
        except requests.exceptions.Timeout as e:
            logger.error(f"Request timed out: {e}")
            raise TimeoutError(
                f"Request timed out after {self.timeout}s. "
                f"The service may be experiencing high load. Please try again."
            ) from e
        except requests.exceptions.HTTPError as e:
            # Parse error message from response if available
            try:
                if response is not None:
                    error_data = response.json()
                    logger.error(f"HTTP Error details: {error_data}")
                    error_msg = (
                        error_data.get('message') or 
                        error_data.get('detail') or 
                        error_data.get('error') or 
                        str(e)
                    )
                else:
                    error_msg = str(e)
            except:
                # Fallback to text if JSON fails
                error_msg = response.text if response is not None else str(e)
            logger.error(f"API Error: {error_msg}")
            raise Exception(f"API Error: {error_msg}") from e
        
    def logout(self):
        """Logs out by clearing local credentials."""
        try:
            path = self._get_config_path()
            if path.exists():
                os.remove(path)
            self.api_key = None
            if "Authorization" in self.session.headers:
                del self.session.headers["Authorization"]
            logger.info("User logged out")
            print("✅ Logged out successfully.")
        except Exception as e:
            logger.error(f"Error logging out: {e}")
            print(f"❌ Error logging out: {e}")

    def signup(self):
        """Interactive sign-up helper."""
        print("=== EOG-HPO Sign Up ===")
        while True:
            email = input("Email: ").strip()
            if re.match(r"[^@]+@[^@]+\.[^@]+", email):
                break
            print("❌ Invalid email format. Please try again.")
            
        while True:
            password = getpass.getpass("Password: ")
            if len(password) >= 8:
                break
            print("❌ Password must be at least 8 characters.")

        try:
            self._make_request(
                "POST",
                "/auth/signup",
                json={"email": email, "password": password}
            )
            logger.info(f"Signup successful for {email}")
            print("✅ Sign up successful! Logging in...")
        except Exception as e:
            logger.error(f"Signup failed: {e}")
            print(f"❌ Sign up failed: {e}")
            return

        try:
            # Automatically log in with the provided credentials
            login_response = self._make_request(
                "POST",
                "/auth/login",
                json={"email": email, "password": password}
            )
            data = login_response.json()
            self.api_key = data.get("api_key") or data.get("token")
            if not self.api_key:
                raise ValueError("No API key returned from login")
            self._save_api_key(self.api_key)
            self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
            logger.info("Login successful")
            print("✅ Login successful!")
        except Exception as e:
            logger.warning(f"Auto-login failed: {e}")
            print(f"⚠️ Auto-login failed: {e}. Please log in manually.")

    def login(self):
        """Interactive authentication manager."""
        print("\n=== EOG-HPO Authentication ===")
        
        # Check for stored credentials first
        stored_key = self._load_stored_api_key()
        
        if stored_key and not self.api_key:
            print(f"🔑 Found saved credentials (Key ends in ...{stored_key[-4:]})")
            print("1. Use saved credentials")
            print("2. Log out and sign in as different user")
            print("3. Create new account")
            
            choice = input("Select option (1-3) [1]: ").strip() or '1'
            
            if choice == '1':
                self.api_key = stored_key
                self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
                logger.info("Logged in with saved credentials")
                print("✅ Logged in using saved credentials.")
                return
            elif choice == '2':
                self.logout()
            elif choice == '3':
                self.logout()
                self.signup()
        
        if not self.api_key:
            print("\n1. Log In")
            print("2. Sign Up")
            print("3. Enter API Key manually")
            choice = input("Select option (1-3) [1]: ").strip() or '1'
            
            if choice == '3':
                self.api_key = getpass.getpass("API Key: ").strip()
                if self.api_key:
                    self._save_api_key(self.api_key)
                    self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
                    print("✅ API Key saved.")
                return
            elif choice == '2':
                self.signup()
                if self.api_key:
                    return
                print("\nPlease log in with your new account.")

        print("\n--- Log In ---")
        email = input("Email: ")
        password = getpass.getpass("Password: ")
        try:
            response = self._make_request(
                "POST",
                "/auth/login",
                json={"email": email, "password": password}
            )
            data = response.json()
            self.api_key = data.get("api_key") or data.get("token")
            if not self.api_key:
                raise ValueError("No API key returned from login")
            self._save_api_key(self.api_key)
            self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
            logger.info("Login successful")
            print("✅ Login successful!")
        except Exception as e:
            logger.error(f"Login failed: {e}")
            print(f"❌ Login failed: {e}")
            print("ℹ️  Forgot your password? Reset it here: https://www.eaheog.com/dashboard/reset.html")

    def estimate_cost(self, n_iterations: int, search_space: Dict[str, tuple], n_runs: int = 1) -> Dict:
        """
        Estimate cost before starting optimization
        
        Args:
            n_iterations: Number of configurations to try
            search_space: The search space dictionary (needed for dimension pricing)
            n_runs: Number of independent runs
            
        Returns:
            Dict with cost estimate and payment link
        """
        if not self.api_key:
            print("ℹ️  Authentication required for cost estimation.")
            self.login()
            if not self.api_key:
                print("⚠️  Proceeding without authentication (Public pricing only).")

        logger.info(f"Estimating cost for {n_iterations} iterations")
        response = self._make_request(
            "POST",
            "/estimate",
            json={
                "n_iterations": n_iterations,
                "search_space": search_space,
                "n_runs": n_runs
            }
        )
        
        result = response.json()
        print(f"💰 Estimated Cost: ${result.get('estimated_cost', 0.0):.2f}")
        print(f"📊 Recommendations: {result.get('total_recommendations', 0)}")
        print(f"⏱️  Expected Duration: ~{result.get('estimated_minutes', 0)} minutes")
        print(f"ℹ️  Pricing Calculator: https://www.eaheog.com/dashboard/pricing.html")
        
        return result

    def open_pricing_calculator(self):
        """Opens the online pricing calculator to help estimate costs."""
        url = "https://www.eaheog.com/dashboard/pricing.html"
        print(f"🔗 Opening pricing calculator: {url}")
        webbrowser.open(url)
    
    def _validate_search_space(self, search_space: Dict[str, any]):
        """Validates the search space format locally."""
        if not search_space:
            raise ValueError("The 'search_space' dictionary cannot be empty.")
        for name, bounds in search_space.items():
            # Handle Categorical (List)
            if isinstance(bounds, list):
                if len(bounds) < 2:
                    raise ValueError(f"Categorical parameter '{name}' must have at least 2 choices")
                continue

            # Handle Numerical Range (Tuple)
            if not isinstance(bounds, tuple) or len(bounds) != 2:
                raise ValueError(f"Parameter '{name}' bounds must be a tuple (min, max) or a list [choices]")
            if not all(isinstance(x, (int, float)) for x in bounds):
                raise ValueError(f"Parameter '{name}' bounds must be numbers")
            if bounds[0] >= bounds[1]:
                raise ValueError(f"Parameter '{name}': min ({bounds[0]}) must be < max ({bounds[1]})")

    def start_optimization(
        self,
        search_space: Dict[str, tuple],
        n_iterations: int = 100,
        objective_name: str = "score",
        maximize: bool = True,
        promo_code: Optional[str] = None
    ) -> str:
        """
        Start a new optimization session
        
        Args:
            search_space: Dict mapping param names to (min, max) tuples
            n_iterations: Number of configurations to try
            objective_name: Name of the metric to optimize
            maximize: Whether to maximize (True) or minimize (False) the objective
            promo_code: Optional promotional code for discounts
            
        Returns:
            session_id: Unique identifier for this optimization session
        """
        if not self.api_key:
            print("🔐 Authentication required.")
            self.login()
            if not self.api_key:
                raise ValueError("Authentication required to start optimization")
        
        self._validate_search_space(search_space)
        self.search_space = search_space
        self.maximize = maximize
        
        # Prepare API search space (map categoricals to indices)
        self.categorical_maps = {}
        api_search_space = {}
        param_types = {}
        
        for name, bounds in search_space.items():
            if isinstance(bounds, list):
                self.categorical_maps[name] = bounds
                api_search_space[name] = (0, len(bounds) - 1)
                # Categorical parameters are handled as integers (indices) by the optimizer
                param_types[name] = 'int'
            else:
                api_search_space[name] = bounds

        # Sanitize for JSON serialization (handle numpy types)
        api_search_space = self._sanitize_for_json(api_search_space)

        payload = {
            "search_space": api_search_space,
            "param_types": param_types,
            "categories_map": self.categorical_maps,
            "n_iterations": n_iterations,
            "objective_name": objective_name,
            "maximize": maximize,
            "promo_code": promo_code
        }

        response = self._make_request("POST", "/initiate", json=payload)
        
        data = response.json()
        
        # Check if payment is required and prompt for promo code
        payment_url = data.get("payment_url")
        if payment_url and payment_url != "FREE_PROMO_BYPASS":
            cost = data.get("cost", 0.0)
            print(f"\n💳 Payment Required: ${cost:.2f}")
            
            user_promo = input("Have a promo code? Enter it here (or press Enter to pay): ").strip()
            if user_promo:
                payload["promo_code"] = user_promo
                print("Verifying promo code...")
                response = self._make_request("POST", "/initiate", json=payload)
                data = response.json()
                payment_url = data.get("payment_url")
                
                if payment_url and payment_url != "FREE_PROMO_BYPASS":
                    new_cost = data.get("cost", 0.0)
                    print(f"💳 Adjusted Cost: ${new_cost:.2f}")
                else:
                    print("✅ Promo code applied! Payment waived.")

        self.session_id = data.get("session_id") or data.get("job_id")
        if not self.session_id:
            raise KeyError(f"Server response missing session_id. Keys: {list(data.keys())}")
            
        self._save_active_session(self.session_id, search_space)
        logger.info(f"Session created: {self.session_id}")
        
        # Handle payment if required
        if payment_url and payment_url != "FREE_PROMO_BYPASS":
            print(f"🔗 Complete payment at: {payment_url}")
            webbrowser.open(payment_url)
            
            print("\n⏳ Waiting for payment confirmation...")
            while True:
                time.sleep(5)
                try:
                    status = self.get_session_status()
                    if status.get("status") == "ACTIVE" or status.get("payment_status") == "PAID":
                        logger.info("Payment confirmed")
                        print("✅ Payment confirmed!")
                        break
                except Exception:
                    pass
        
        print(f"✅ Optimization session started: {self.session_id}")
        return self.session_id
    
    def get_next_config(self) -> Optional[Config]:
        """
        Get the next configuration to evaluate
        
        Returns:
            Config object with parameters to try, or None if optimization is complete
        """
        if not self.session_id:
            raise ValueError("No active session. Call start_optimization() first")
        
        logger.debug(f"Requesting next config for session {self.session_id}")
        response = self._make_request(
            "POST",
            f"/job/{self.session_id}/next",
        )
        
        try:
            data = response.json()
        except requests.exceptions.JSONDecodeError:
            # Handle invalid JSON (e.g. plain text error from server)
            raise Exception(f"Server returned invalid JSON: {response.text}")
        
        if data.get("status") == "complete":
            logger.info("Optimization job complete")
            print("✅ Optimization complete!")
            return None
        
        # Handle cases where job is not ready (e.g. WAITING_FOR_PAYMENT)
        if "config" not in data:
            if "message" in data:
                raise Exception(f"Job not ready: {data['message']}")
            raise KeyError(f"Server response missing 'config': {data}")

        params = data["config"]
        
        # Auto-convert to integers if search space bounds are integers
        if self.search_space:
            for key, value in params.items():
                # 1. Handle Categorical Mapping
                if key in self.categorical_maps:
                    choices = self.categorical_maps[key]
                    idx = int(round(value))
                    idx = max(0, min(idx, len(choices) - 1))  # Clamp to valid range
                    params[key] = choices[idx]
        
        return Config(
            params=params,
            config_id=data.get("config_id", ""),
            iteration=data.get("iteration", 0)
        )
    
    def report_result(self, config: Config, score: float):
        """
        Report the evaluation result for a configuration
        
        Args:
            config: The Config object that was evaluated
            score: The resulting score/metric value
        """
        # Sanitize inputs to ensure JSON serializability (e.g. numpy floats)
        safe_score = self._sanitize_for_json(score)
        safe_params = self._sanitize_for_json(config.params)

        logger.debug(f"Reporting result: config={config.config_id}, score={safe_score}")
        response = self._make_request(
            "POST",
            f"/job/{self.session_id}/report",
            json={
                "job_id": self.session_id,
                "config_id": config.config_id,
                "score": safe_score,
                "params": safe_params
            }
        )
        
        # Store in local history
        self.history.append({
            'iteration': config.iteration,
            'config': safe_params,
            'score': safe_score
        })
        
        print(f"✅ Result reported: {safe_score:.6f}")
    
    def _autosave_results(self):
        """Saves current history to a local file to prevent data loss."""
        if not self.session_id or not self.history:
            return
        try:
            filename = f"eoghpo_session_{self.session_id}.json"
            with open(filename, 'w') as f:
                json.dump(self.history, f, indent=2)
        except Exception as e:
            logger.warning(f"Failed to autosave results: {e}")

    def optimize(
        self,
        objective_function: Callable[[Dict[str, float]], float],
        search_space: Dict[str, tuple],
        n_iterations: int = 100,
        maximize: bool = True,
        promo_code: Optional[str] = None,
        show_progress: bool = True
    ) -> Dict:
        """
        Run complete optimization loop
        
        Args:
            objective_function: Function that takes config dict and returns score
            search_space: Dict mapping param names to (min, max) tuples
            n_iterations: Number of configurations to try
            maximize: Whether to maximize (True) or minimize (False)
            promo_code: Optional promotional code
            show_progress: Whether to show live progress visualization
            
        Returns:
            Dict with best configuration and score
        """
        if not self.api_key:
            print("🔐 Authentication required.")
            self.login()
            if not self.api_key:
                raise ValueError("Authentication required")
        
        self.search_space = search_space
        self.categorical_maps = {k: v for k, v in search_space.items() if isinstance(v, list)}
        self.maximize = maximize
        
        # Check for interrupted session on disk
        if not self.session_id:
            saved_session = self._load_active_session()
            if saved_session:
                # Verify search space matches to ensure we resume the correct job
                saved_keys = set(saved_session.get('param_keys', []))
                current_keys = set(search_space.keys())
                
                if saved_keys == current_keys:
                    print(f"\n⚠️  Found interrupted session: {saved_session['session_id']}")
                    choice = input("   Resume this session? (Y/n) [Y]: ").strip().lower()
                    if choice in ('', 'y', 'yes'):
                        self.session_id = saved_session['session_id']
                        # Try to load local history
                        try:
                            hist_file = f"eoghpo_session_{self.session_id}.json"
                            if os.path.exists(hist_file):
                                with open(hist_file, 'r') as f:
                                    self.history = json.load(f)
                                print(f"   Loaded {len(self.history)} previous results from local history.")
                        except Exception as e:
                            logger.warning(f"Could not load local history: {e}")
                    else:
                        self._clear_active_session()
                else:
                    # Different search space, clear old session
                    self._clear_active_session()

        # Resume existing session or start new one
        if self.session_id:
            logger.info(f"Resuming session {self.session_id}")
            print(f"📍 Resuming session: {self.session_id}")
            self.get_session_status()
        else:
            self.start_optimization(
                search_space=search_space,
                n_iterations=n_iterations,
                promo_code=promo_code,
                maximize=maximize
            )
        
        print(f"\n🚀 Starting optimization for {n_iterations} iterations...\n")
        
        # Optimization loop
        try:
            for i in range(n_iterations):
                # Get next config
                config = self.get_next_config()
                if config is None:
                    self._clear_active_session()
                    break
                
                print(f"📊 Iteration {config.iteration}/{n_iterations}")
                print(f"   Config: {config.params}")
                
                # Train on client machine
                print("  🔄 Training locally...")
                score = objective_function(config.params)
                
                # Report result
                self.report_result(config, score)
                
                # Autosave results
                self._autosave_results()
                
                # Update progress visualization
                if show_progress and (i % 5 == 0 or i == n_iterations - 1):
                    self.show_progress()
                
                # Safety throttle
                time.sleep(0.5)
        except KeyboardInterrupt:
            print("\n⚠️ Optimization interrupted by user. Saving progress...")
            print("   To resume, simply run this script again.")
        
        # Final results
        self.print_summary()
        if not self.history:
            return {}
            
        if self.maximize:
            best = max(self.history, key=lambda x: x['score'])
        else:
            best = min(self.history, key=lambda x: x['score'])
        
        return best
    
    def show_progress(self, in_notebook: Optional[bool] = None):
        """Display live progress visualization"""
        if not _VISUALIZATION_AVAILABLE:
            print("⚠️ Visualization libraries not installed. Install with: pip install eaheog[viz]")
            return

        if not self.history:
            return
        
        # Auto-detect environment if not specified
        if in_notebook is None:
            try:
                get_ipython
                in_notebook = True
            except NameError:
                in_notebook = False
        
        df = pd.DataFrame(self.history)
        
        if in_notebook:
            clear_output(wait=True)
            
            fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 4))
            
            # Score over time
            ax1.plot(df['iteration'], df['score'], 'o-', alpha=0.6)
            if self.maximize:
                ax1.plot(df['iteration'], df['score'].cummax(), 'r-', linewidth=2, label='Best')
            else:
                ax1.plot(df['iteration'], df['score'].cummin(), 'r-', linewidth=2, label='Best')
            ax1.set_xlabel('Iteration')
            ax1.set_ylabel('Score')
            ax1.set_title('Optimization Progress')
            ax1.legend()
            ax1.grid(True, alpha=0.3)
            
            # Parameter importance
            if len(self.history) > 10:
                param_vars = {}
                for param in self.history[0]['config'].keys():
                    values = [h['config'][param] for h in self.history]
                    param_vars[param] = np.var(values)
                
                ax2.barh(list(param_vars.keys()), list(param_vars.values()))
                ax2.set_xlabel('Variance (Exploration)')
                ax2.set_title('Parameter Exploration')
            
            plt.tight_layout()
            plt.show()
            
            best_val = df['score'].max() if self.maximize else df['score'].min()
            
            display(HTML(f"""
            <div style='background: #f0f0f0; padding: 15px; border-radius: 8px;'>
                <h3>📊 Current Stats</h3>
                <table style='width: 100%;'>
                    <tr><td><b>Iterations:</b></td><td>{len(self.history)}/{self.current_iteration}</td></tr>
                    <tr><td><b>Best Score:</b></td><td>{best_val:.6f}</td></tr>
                    <tr><td><b>Mean Score:</b></td><td>{df['score'].mean():.6f}</td></tr>
                    <tr><td><b>Std Dev:</b></td><td>{df['score'].std():.6f}</td></tr>
                </table>
            </div>
            """))
        else:
            best_val = df['score'].max() if self.maximize else df['score'].min()
            print(f"\n{'='*60}")
            print(f"Iteration: {len(self.history)}/{self.current_iteration}")
            print(f"Best Score: {best_val:.6f}")
            print(f"Mean Score: {df['score'].mean():.6f}")
            print(f"{'='*60}\n")
    
    def print_summary(self):
        """Prints a detailed summary of the optimization run."""
        if not self.history:
            print("No optimization history available.")
            return

        if not _VISUALIZATION_AVAILABLE:
            print(f"Session: {self.session_id}, Count: {len(self.history)}")
            return

        df = pd.DataFrame(self.history)
        
        if self.maximize:
            best_run = df.loc[df['score'].idxmax()]
        else:
            best_run = df.loc[df['score'].idxmin()]
        
        print("\n" + "="*60)
        print("📊 OPTIMIZATION RUN SUMMARY")
        print("="*60)
        print(f"Session ID:       {self.session_id}")
        print(f"Total Iterations: {len(df)}")
        print(f"Best Score:       {best_run['score']:.6f}")
        print(f"Average Score:    {df['score'].mean():.6f}")
        print("-" * 60)
        print("🏆 Best Configuration:")
        for param, value in best_run['config'].items():
            print(f"   • {param}: {value}")
        print("-" * 60)
        self.get_web_dashboard_url()
        print("="*60 + "\n")

    def get_web_dashboard_url(self) -> str:
        """Get URL to view progress in web browser"""
        if not self.session_id:
            raise ValueError("No active session")
        
        url = "https://www.eaheog.com/dashboard/overview.html"
        print(f"🔗 View progress at: {url}")
        webbrowser.open(url)
        return url
    
    def get_session_status(self) -> Dict:
        """Get current session status"""
        response = self._make_request(
            "GET",
            f"/job/{self.session_id}/status",
        )
        return response.json()
    
    def export_results(self, filename: str = "eoghpo_results.csv"):
        """Export optimization history to CSV"""
        if not _VISUALIZATION_AVAILABLE:
            print("⚠️ pandas not installed. Install with: pip install eaheog[viz]")
            return
            
        df = pd.DataFrame(self.history)
        
        # Flatten config dict into columns
        config_df = pd.json_normalize([h['config'] for h in self.history])
        result_df = pd.concat([df[['iteration', 'score']], config_df], axis=1)
        
        result_df.to_csv(filename, index=False)
        print(f"✅ Results exported to {filename}")