#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Anomaly Detection core class definition

Author: rameshbk
Last modified: Shweta, 14th November, 2025
"""
import os
import glob
import re
import scipy.io as sio
import pandas as pd
import numpy as np

import joblib
import logging
from sklearn.neural_network import MLPRegressor

logger = logging.getLogger(__name__)

import matplotlib.pyplot as plt
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from scipy.stats import skew, kurtosis, norm
from mpl_toolkits.mplot3d import Axes3D
import statsmodels.api as sm
import logging
from IntelliMaint.utils import Utils

# Set random seed for reproducibility


from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, ConstantKernel as C, WhiteKernel, Matern, DotProduct
from sklearn.preprocessing import PolynomialFeatures, RobustScaler, MinMaxScaler, StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.svm import SVR as SklearnSVR
from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error


class GPRDegradationModel:
    """
    Generic Gaussian Process Regression (GPR) based degradation model for
    remaining useful life (RUL) prediction of industrial components.
    
    This model can be used for various types of components where degradation is
    monitored through MQE (Mean Quantization Error) or similar health indicators.
    
    Example usage:
    ```python
    # Initialize the model
    model = GPRDegradationModel(config={
        'window_size': 1.0,  # 1 second per window
        'adaptive_thresholds': True
    })
    
    # Run complete workflow
    results = model.run_workflow(
        health_indicator_scores=log_anomaly_scores,
        anomaly_point=first_anomaly_idx
    )
    
    # Get RUL prediction
    rul = results['rul']
    print(f"Predicted RUL: {rul} samples")
    print(f"Estimated time to failure: {results['remaining_time']['time_string']}")
    ```
    """

    def __init__(self, config=None, verbose=False):
        """
        Initialize the GPR Degradation Model
        
        Args:
            config (dict, optional): Configuration parameters including:
                - prediction_horizon: Number of time steps to predict ahead
                - window_size: Size of time window in seconds for displaying results
                - threshold_settings: Settings for incipient and failure thresholds
        """

        # Default configurations
        self.config = config

        self.verbose = verbose

        ut = Utils(verbose=verbose)
        self.logger = ut.get_logger(self.__class__.__name__)
        self._print_debug = ut.print_debug

        if self.verbose:
            print(f"[DEBUG] GPRDegradationModel initialized with config {self.config}")
        self.logger.info(f"Initialized GPRDegradationModel with config {self.config}")

        # Prediction parameters
        self.prediction_method = "gpr" # or ensemble
        self.prediction_horizon = self.config.get('prediction_horizon', 300)
        self.window_size = self.config.get('window_size',1.0)  # Default 1 second per window
        self.random_state = 42  # For reproducibility
        
        # Threshold settings - can be manually set or adaptively determined
        self.incipient_threshold = self.config.get('incipient_threshold', 0.3)
        self.failure_threshold = self.config.get('failure_threshold', 4.0)
        self.adaptive_thresholds = True 
        
        # Anomaly detection settings
        self.anomaly_offset = None  # Will be set during initialization or from external detection
        
        # Data storage
        self.scale_score = False
        self.raw_scores = None      # Original health indicator scores
        self.scaled_scores = None   # Normalized/scaled health indicator scores
        self.full_mqe_scores = None  # Same as scaled_scores, kept for backward compatibility
        self.degradation_method = "trend_analysis" # or static
        
        # Model storage
        self.gpr_model = None 
        self.poly_model = None       
        self.y_pred = None
        self.y_std = None
        self.kernels = RBF(length_scale=1.0) + Matern(length_scale=1.0, nu=1.5) + WhiteKernel(noise_level=1e-3)

    def _print_debug(self, msg):
        # Use ONLY occasionally and only for values useful to see in verbose mode
        if self.verbose:
            print(f"[DEBUG] {msg}")

    def process_data(self, health_indicator_scores):
        """
        Process and scale health indicator data (e.g., MQE, anomaly scores)
        """
        self.logger.info("Processing and scaling health indicator data")

        # Store the raw scores
        self.raw_scores = np.array(health_indicator_scores)

        # Diagnostic output
        self.logger.debug(f"Input scores - min: {np.min(self.raw_scores)}, max: {np.max(self.raw_scores)}")
        self.logger.debug(f"Input scores - finite values: {np.isfinite(self.raw_scores).sum()} / {len(self.raw_scores)}")
            

        if self.scale_score: 
            self.logger.debug("Data will be scaled")
            
            # Remove any infinity or NaN values
            clean_scores = np.nan_to_num(
                self.raw_scores, 
                nan=np.min(self.raw_scores), 
                posinf=np.max(self.raw_scores), 
                neginf=np.min(self.raw_scores)
            )
            
            # Apply min-max scaling to get values in a 0-10 range for better visualization and modeling
            min_val = np.min(clean_scores)
            max_val = np.max(clean_scores)
            
            if min_val == max_val:
                scaled_scores = np.ones_like(clean_scores)
            else:
                scaled_scores = (clean_scores - min_val) / (max_val - min_val) * 10
            
            # Store the scaled scores
            self.scaled_scores = scaled_scores
            self.full_mqe_scores = scaled_scores  # For backward compatibility
            
            # Diagnostic output
            self.logger.debug(f"Scaled scores stats: min={np.min(scaled_scores)}, max={np.max(scaled_scores)}")
            
        else:
            self.logger.debug("Data will not be scaled")
            scaled_scores = health_indicator_scores
            # Store the scaled scores
            self.scaled_scores = scaled_scores
            self.full_mqe_scores = scaled_scores  # For backward compatibility            

        self.logger.info("Scaled scores obtained")
        return scaled_scores
    
    def set_thresholds(self, incipient=None, failure=None):
        """
        Set degradation thresholds manually or compute them adaptively from data
        
        Args:
            incipient (float, optional): Manual incipient threshold
            failure (float, optional): Manual failure threshold
            
        Returns:
            tuple: (incipient_threshold, failure_threshold)
        """
        self.logger.info("Setting thresholds for incipient/failure detection")
        if incipient is not None:
            self.incipient_threshold = incipient
            self.logger.debug(f"Manually set incipient threshold: {self.incipient_threshold}")
        
        if failure is not None:
            self.failure_threshold = failure
            self.logger.debug(f"Manually set failure threshold: {self.failure_threshold}")
            return self.incipient_threshold, self.failure_threshold

        # If no thresholds provided and adaptive thresholds enabled, compute from data
        if self.adaptive_thresholds and self.scaled_scores is not None:
            self.logger.info("Computing thresholds from data")
            # Calculate percentiles for threshold setting
            percentiles = np.percentile(self.scaled_scores, [25, 50, 75, 90, 95, 99])
            
            # Set incipient threshold based on percentiles if not manually provided
            if incipient is None:
                self.incipient_threshold = percentiles[1]
                self.logger.debug(f"Adaptive incipient threshold: {self.incipient_threshold}")
            
            # Adaptive approach to set the failure threshold if not manually provided
            if failure is None:
                # Method 1: Mean of top values
                top_n = max(5, int(len(self.scaled_scores) * 0.01))  # Use at least 5 points or 1% of data
                sorted_scores = np.sort(self.scaled_scores)
                top_values = sorted_scores[-top_n:]
                mean_top_values = np.mean(top_values)
                
                # Method 2: High percentile
                high_percentile = percentiles[5]  # 99th percentile
                
                # Method 3: End of life focus
                end_window = min(50, len(self.scaled_scores) // 10)  # Last 50 points or 10% of data
                end_data = self.scaled_scores[-end_window:]
                end_max = np.max(end_data)
                end_mean = np.mean(end_data)
                end_75th = np.percentile(end_data, 75)

                self.logger.debug(f"Maximum score: {np.max(self.scaled_scores):.4f}")
                self.logger.debug(f"Mean of top {top_n} values: {mean_top_values:.4f}")
                self.logger.debug(f"99th percentile: {high_percentile:.4f}")
                self.logger.debug(f"End window maximum: {end_max:.4f}")
                self.logger.debug(f"End window mean: {end_mean:.4f}")
                
                # Choose the most appropriate method based on data characteristics
                if end_max > high_percentile * 0.8:
                    self.failure_threshold = max(end_75th, high_percentile * 0.7)
                    self.logger.debug(f"Failure threshold from end-of-life: {self.failure_threshold:.4f}")
                else:
                    self.failure_threshold = max(high_percentile, mean_top_values * 0.9)
                    self.logger.debug(f"Failure threshold from global stats: {self.failure_threshold:.4f}")
                
                # Safety check: ensure enough separation between thresholds
                if self.failure_threshold < self.incipient_threshold * 2:
                    old_threshold = self.failure_threshold
                    self.failure_threshold = self.incipient_threshold * 2
                    self.logger.warning(f"Adjusting failure threshold from {old_threshold:.4f} to {self.failure_threshold:.4f}")
        else:
            # Static Thresholds for cumulative health indicator scores
            self.incipient_threshold = 0.6
            self.failure_threshold = 0.8
            self.logger.info("Incipient and Failure thresholds set statically")

        return self.incipient_threshold, self.failure_threshold
    
    def detect_degradation_onset(self, anomaly_point=None):
        """
        Detect the onset of degradation using trend analysis or external anomaly detection
        
        Args:
            anomaly_point (int, optional): Externally detected anomaly index
            
        Returns:
            int: Detected degradation onset index
        """
        self.logger.info("Detecting degradation onset")
        # If an external anomaly point is provided, use it
        if anomaly_point is not None:
            self.anomaly_offset = anomaly_point
            self.logger.debug(f"Using provided anomaly point as degradation onset: {self.anomaly_offset}")
            return self.anomaly_offset
        
        if self.scaled_scores is not None:

            if self.degradation_method == 'static':
                # === Detect degradation onset ===
                # Define onset as first point where observed wear exceeds 60%
                onset_mask = self.scaled_scores >= self.incipient_threshold
                if np.any(onset_mask):
                    self.anomaly_offset = np.where(onset_mask)[0][0]
                else:
                    self.anomaly_offset = 0 # fallback if never crossed
                self.logger.debug(f"Static warning index: {self.anomaly_offset}")
            
            elif self.degradation_method == 'trend_analysis':
                # Otherwise use trend analysis to detect degradation onset

                # Calculate rolling mean to smooth the data
                window_size = min(31, len(self.scaled_scores) // 10)
                if window_size % 2 == 0:
                    window_size += 1 # Ensure odd window size
                
                if len(self.scaled_scores) > window_size:
                    rolling_mean = np.convolve(
                        self.scaled_scores,
                        np.ones(window_size) / window_size,
                        mode='valid'
                    )
                    
                    # Pad the beginning to maintain array size
                    pad_width = len(self.scaled_scores) - len(rolling_mean)
                    rolling_mean = np.pad(rolling_mean, (pad_width, 0), 'edge')
                    
                    # Calculate slope using finite differences
                    slopes = np.gradient(rolling_mean)
                    
                    # Find regions with consistent positive slope
                    pos_slope_regions = slopes > 0
                    
                    # Look for the first significant region of positive slope
                    kernel_size = min(15, len(pos_slope_regions) // 20)
                    if kernel_size > 0:
                        conv_result = np.convolve(
                            pos_slope_regions.astype(int), 
                            np.ones(kernel_size), 
                            mode='same'
                        )
                        
                        # Find the first point where we have several consecutive positive slopes
                        threshold = kernel_size * 0.8  # 80% of the kernel should be positive
                        significant_regions = conv_result > threshold
                        
                        degradation_candidates = np.where(significant_regions)[0]
                        
                        if len(degradation_candidates) > 0:
                            # Take the first significant point as degradation onset
                            self.anomaly_offset = degradation_candidates[0]
                            self.logger.debug(f"Trend analysis degradation onset: {self.anomaly_offset}")
                        else:
                            # Fallback: use a point 1/4 of the way through the data
                            self.anomaly_offset = len(self.scaled_scores) // 4
                    else:
                        # Not enough data for kernel analysis
                        self.anomaly_offset = len(self.scaled_scores) // 4
                else:
                    # Not enough data for rolling mean
                    self.anomaly_offset = len(self.scaled_scores) // 4
                    self.logger.debug(f"Not enough data for rolling mean. Using {self.anomaly_offset} as degradation onset.")

                # Ensure we have enough data for training
                if self.anomaly_offset > len(self.scaled_scores) - 10:
                    self.anomaly_offset = max(0, len(self.scaled_scores) - 20)
                    self.logger.warning(f"Adjusted degradation onset to {self.anomaly_offset} to ensure enough training data")
        else:
            # No data available, use a default value
            self.anomaly_offset = 0
            self.logger.warning("No data available for degradation onset detection.")
        
        return self.anomaly_offset
    

    def predict_rul_ensemble(self):
        """
        Predict the Remaining Useful Life (RUL) and wear percentage using an ensemble
        of GPR and polynomial regression, trained from degradation onset.

        Returns:
            dict: Dictionary with predicted wear, metrics, important times, and estimated RUL in hours.
        """
        
        self.logger.info("Training ensemble model (GPR + polynomial regression)")
        np.random.seed(self.random_state)  # For reproducibility
        hi = self.scaled_scores
        onset_idx = self.anomaly_offset

        # === Train GPR on cumulative wear directly ===
        X_train_size = onset_idx
        X_train = np.arange(0, X_train_size).reshape(-1, 1)
        y_train = hi[0:X_train_size]
        self.logger.debug(f"Size of the training dataset for gpr is {X_train_size} starting from {0}")
        
        # Test set
        test_idx = onset_idx
        X_pred = np.arange(test_idx, len(hi)).reshape(-1, 1)
        self.logger.debug(f"Size of predicted wear for gpr is {len(X_pred)} starting from {test_idx}")
        
        # === Linear Regression on Polynomial Feature === 
        poly_features = PolynomialFeatures(degree=2)
        X_poly = poly_features.fit_transform(X_train)
        self.poly_model = LinearRegression()
        self.poly_model.fit(X_poly, y_train)
        
        X_pred_poly = poly_features.transform(X_pred)
        y_poly_pred = self.poly_model.predict(np.vstack([X_poly, X_pred_poly]))

        self.gpr_model = GaussianProcessRegressor(
            kernel=self.kernels,
            alpha=1e-4,
            n_restarts_optimizer=15,
            normalize_y=True)
        self.gpr_model.fit(X_train, y_train)
        y_gpr_pred, y_gpr_std = self.gpr_model.predict(np.vstack([X_train, X_pred]), return_std=True)

        alpha = np.linspace(0.5, 1.0, len(X_pred) + len(X_train))
        self.y_pred = (1 - alpha) * y_poly_pred + alpha * y_gpr_pred
        self.y_std = y_gpr_std[X_train_size:]

        mae = mean_absolute_error(hi[onset_idx:], self.y_pred[onset_idx:])
        rmse = np.sqrt(mean_squared_error(hi[onset_idx:], self.y_pred[onset_idx:]))
        r2score = r2_score(hi[onset_idx:], self.y_pred[onset_idx:])
        
        for i, (fileidx, wear) in enumerate(zip(X_pred.flatten(), self.y_pred)):
            self._print_debug(f"File index {fileidx}: Predicted wear = {wear:.2f}")
    
        self.logger.info(f"Ensemble model - MAE: {mae:.4f}, RMSE: {rmse:.4f}, R2: {r2score:.4f}")
        self._print_debug(f"1st 5 GPR+poly preds: {self.y_pred[:5]}")
        
        ##########
        self.logger.info("Predicting RUL via ensemble")
        
        if self.gpr_model is None or self.poly_model is None:
            self.logger.error("Models not trained. Call train_ensemble_model() first.")
            raise ValueError("Models not trained. Call train_ensemble_model() first.")
        
        hi = self.scaled_scores
        predicted_wear = self.y_pred
        actual_critical = hi >= self.failure_threshold
        
        # Define critical as first point where wear crosses failure threshold
        if np.any(actual_critical):
            actual_critical_idx = np.where(actual_critical)[0][0]
        else:
            actual_critical_idx = 0

        predicted_wear = self.y_pred        
        # === Detect degradation ===
        # For predicted wear, obtain warning and critical points
        warning_mask = predicted_wear >= self.incipient_threshold
        if np.any(warning_mask):
            warning_idx = np.where(warning_mask)[0][0]
            # print(f" Warning Point: At hour {warning_time} (wear ≥ 80%) for file index {warning_idx}")
        else:
            self.logger.info("Predicted wear doesn't cross 60% threshold")

        # Find critical point
        failure_mask = predicted_wear >= self.failure_threshold
        if np.any(failure_mask):
            critical_idx = np.where(failure_mask)[0][0] 
            # print(f" Critical Point: At hour {critical_time} (wear ≥ 80%) for file index {critical_idx}")
        else:
            self.logger.info("No critical point found — fallback to failure")

        predicted_failure_idx = np.where(predicted_wear>=1)[0][0]
        
        # From degradation onset to failure
        rul_from_onset_idx = predicted_failure_idx - warning_idx
        
        # From critical point to failure
        rul_from_critical_idx = predicted_failure_idx - critical_idx
        
        # Logging
        self.logger.info(f" Predicted Warning index: at {warning_idx}")
        self.logger.info(f" Actual Critical index: at {actual_critical_idx}")
        self.logger.info(f" Predicted Critical index: at {critical_idx}")
        self.logger.info(f" Predicted Failure index: at {predicted_failure_idx}")
        self.logger.info(f" RUL from critical index: at {rul_from_critical_idx}")
        
        if predicted_failure_idx:
            self.logger.info(f" Predicted Failure Cycle: {predicted_failure_idx}")
            self.logger.info(f" Estimated RUL: {rul_from_onset_idx:.2f} indices") #changed rul_hours to rul_from_critical_hours
    
        self.calculate_remaining_time(rul_from_onset_idx)

        return (
            self.y_pred,               # Predicted values
            self.y_std**2,             # Variance
            rul_from_onset_idx, 
            None# RUL
        )        

    def predict_rul_gpr(self, hi=None):
        """
        Predict Remaining Useful Life using the trained GPR model
        
        Args:
            horizon (int, optional): Prediction horizon
        
        Returns:
            tuple: (predicted_values, variance, rul, future_indices)
        """
        self.logger.info("Training GPR model for RUL prediction")
        
        if self.scaled_scores is None:
            self.logger.error("No data available. Call process_data() first.")
            raise ValueError("No data available. Call process_data() first.")
        
        if self.anomaly_offset is None:
            self.logger.info("Degradation onset not detected; auto-detecting.")
            self.detect_degradation_onset()
        
        # Select data from degradation onset for training
        selected_scores = self.scaled_scores[self.anomaly_offset:]
        self.logger.debug(f"Selected {len(selected_scores)} points after anomaly offset {self.anomaly_offset}")
        self._print_debug(f"GPR training set length: {len(selected_scores)} points")
        
        # Prepare input data for GPR
        X = np.arange(len(selected_scores)).reshape(-1, 1)
        y = selected_scores
        
        # Initialize and fit GPR model
        self.gpr_model = GaussianProcessRegressor(
            kernel=self.kernels,
            n_restarts_optimizer=10,
            alpha=1e-10,
            random_state=self.random_state)
        
        # Fit the model
        self.gpr_model.fit(X, y)
        
        self.logger.info(f"GPR model trained (offset={self.anomaly_offset}, incipient={self.incipient_threshold}, failure={self.failure_threshold})")
        self._print_debug(f"GPR model fitted. X shape: {X.shape}, y shape: {y.shape}")
        

        ######
        self.logger.info("Predicting RUL using GPR model")
        
        horizon = None
        if self.gpr_model is None:
            self.logger.error("Model not trained. Call train_model() first.")
            raise ValueError("Model not trained. Call train_model() first.")
        # Use default or specified horizon
        horizon = horizon or self.prediction_horizon
        
        # Calculate distance to the end of our data to ensure we capture full degradation
        if self.scaled_scores is not None:
            data_length = len(self.scaled_scores)
            # Distance from anomaly onset to end of data
            distance_to_end = data_length - self.anomaly_offset
            # Add some margin (50% more)
            extended_horizon = int(distance_to_end * 1.5)
            # Use the larger of the specified horizon or the extended one
            horizon = max(horizon, extended_horizon)
            self.logger.debug(f"Prediction horizon auto-extended to {horizon}")
        
        # Generate future time steps
        X_pred = np.arange(0, horizon).reshape(-1, 1)
        
        # Predict health indicator values with uncertainty
        try:
            y_pred, y_std = self.gpr_model.predict(X_pred, return_std=True)
        except Exception as e:
            self.logger.error(f"Prediction error: {str(e)}")
            return np.zeros(horizon), np.zeros(horizon), 0, np.arange(horizon)
        
        # Find RUL when prediction crosses failure threshold
        rul_indices = np.where(y_pred >= self.failure_threshold)[0]
        
        # Determine RUL
        if len(rul_indices) > 0:
            rul = rul_indices[0] # First point crossing failure threshold
            self.logger.debug(f"RUL found by threshold crossing at {rul}")
        else:
            # If no crossing, try to find where the prediction starts to decline
            # after reaching a peak - this could indicate a different failure mode
            
            # Find the peak in the prediction
            if len(y_pred) > 30:  # Need enough points to identify a pattern
                # Smooth the prediction to avoid minor fluctuations
                window_size = min(15, len(y_pred) // 20)
                if window_size % 2 == 0:
                    window_size += 1  # Ensure odd window size
                
                # Apply smoothing
                smooth_pred = np.convolve(
                    y_pred, 
                    np.ones(window_size)/window_size, 
                    mode='valid'
                )
                # Pad to maintain size
                pad_width = len(y_pred) - len(smooth_pred)
                smooth_pred = np.pad(smooth_pred, (0, pad_width), 'edge')
                
                # Find the peak
                peak_idx = np.argmax(smooth_pred)
                
                if peak_idx > 0 and peak_idx < len(y_pred) - 20:
                    # Use peak as RUL if it's not at the very beginning or end
                    self.logger.debug(f"No threshold crossing found. Using prediction peak at index {peak_idx} as RUL")
                    rul = peak_idx
                else:
                    # If no meaningful peak, use 3/4 of the horizon
                    rul = horizon * 3 // 4
                    self.logger.debug(f"No threshold crossing or clear peak. Using {rul} as RUL")
            else:
                # Not enough points for analysis, use a default value
                rul = horizon - 1
                self.logger.debug(f"No threshold crossing found. Using {rul} as RUL")
        
        # Calculate time to data end (for comparison)
        if self.scaled_scores is not None:
            data_length = len(self.scaled_scores)
            time_to_data_end = data_length - self.anomaly_offset
            self.logger.debug(f"Time from anomaly onset to end of data: {time_to_data_end} samples")
            
            if rul < time_to_data_end * 0.7:
                self.logger.warning(f"PREDICTED RUL ({rul}) is much shorter than actual data length.")
        
        self.calculate_remaining_time(rul)
        self._print_debug(f"GPR-predicted RUL: {rul}")
        
        return (
            y_pred,               # Predicted values
            y_std**2,             # Variance
            rul,                  # RUL
            X_pred.flatten()      # Future indices
        )

    def calculate_remaining_time(self, rul):
        """
        Calculate remaining time in human-readable format
        
        Args:
            rul (int): Remaining useful life in samples
            
        Returns:
            tuple: (hours, minutes, seconds)
        """
        self.logger.info("Calculating Remaining Time")

        # Calculate remaining time based on window size
        remaining_time_seconds = rul * self.window_size
        
        # Convert to hours, minutes, seconds
        hours = remaining_time_seconds // 3600
        minutes = (remaining_time_seconds % 3600) // 60
        seconds = remaining_time_seconds % 60
        
        time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s" if hours > 0 else f"{int(minutes)}m {int(seconds)}s"
        
        self.logger.debug(f"RUL: {rul} samples (each sample = {self.window_size} second window)")
        self.logger.debug(f"Estimated Remaining Time: {time_str}")
        
        self.remaining_time = {
            "samples": rul,
            "seconds": remaining_time_seconds,
            "hours": hours,
            "minutes": minutes,
            "seconds_remainder": seconds,
            "time_string": time_str
        }
        
        return hours, minutes, seconds
    

    def get_results_dataframe(self, rul=None):
        """
        Create a DataFrame with RUL prediction results
        
        Args:
            rul (int, optional): RUL value (if not provided, will use last predicted RUL)
            
        Returns:
            pd.DataFrame: RUL prediction results
        """
        # If RUL not provided, try to get from stored values
        if rul is None:
            if hasattr(self, 'remaining_time') and 'samples' in self.remaining_time:
                rul = self.remaining_time['samples']
            else:
                self.logger.warning("No RUL value available. Run predict_rul() first.")
                return pd.DataFrame()
        
        # Get time estimates
        if not hasattr(self, 'remaining_time') or self.remaining_time['samples'] != rul:
            self.calculate_remaining_time(rul)
        
        results_df = pd.DataFrame({
            "rul": rul,
            "start_time": [self.anomaly_offset],
            "predicted_failure_time": [self.anomaly_offset + rul],
            "remaining_useful_life_samples": [rul],
            "incipient_threshold": [self.incipient_threshold],
            "failure_threshold": [self.failure_threshold],
            "remaining_time_seconds": [self.remaining_time["seconds"]],
            "remaining_time_hours": [self.remaining_time["hours"]],
            "remaining_time_minutes": [self.remaining_time["minutes"]],
            "window_size_seconds": [self.window_size]
        })

        self.logger.info("Results dataframe created")
        return results_df

    def run_workflow(self, health_indicator_scores, anomaly_point=None, process_data=False):
        """
        Run the complete RUL prediction workflow
        
        Args:
            health_indicator_scores (np.ndarray): Health indicator scores
            anomaly_point (int, optional): Externally detected anomaly index
            
        Returns:
            dict: Complete results including RUL prediction and plots
        """
        self.logger.info("Running the full RUL prediction workflow")

        # Step 1: Process the data
        self.process_data(health_indicator_scores)
        
        # Step 2: Set thresholds (adaptive by default)
        self.set_thresholds()
        
        # Step 3: Detect degradation onset (or use provided anomaly point)
        self.detect_degradation_onset(anomaly_point)
        
        # Step 4 & 5: Train the model and Predict rul
        if self.prediction_method == "gpr":
            # self.train_model()
            predicted_values, variance, rul, future_indices = self.predict_rul_gpr()
        elif self.prediction_method == "ensemble":
            # self.train_ensemble_model()
            predicted_values, variance, rul, future_indices = self.predict_rul_ensemble()        

        
        # # Step 6: Create visualization
        # self.plot_rul_prediction(
        #     time_indices=np.arange(len(health_indicator_scores)),
        #     actual_values=self.scaled_scores,
        #     predicted_values=predicted_values,
        #     variance=variance,
        #     future_indices=future_indices,
        #     rul=rul
        # )
        
        # Step 7: Get results DataFrame
        results_df = self.get_results_dataframe(rul)

        self.logger.info("Workflow complete")
        self._print_debug(f"Workflow RUL: {rul}")
        
        # Return all results in a dictionary
        return {
            "rul": rul,
            "predicted_values": predicted_values,
            "variance": variance, 
            "future_indices": future_indices,
            "results_df": results_df,
            "anomaly_offset": self.anomaly_offset,
            "incipient_threshold": self.incipient_threshold,
            "failure_threshold": self.failure_threshold,
            "remaining_time": self.remaining_time
        }
    
class GPParticleFilter:
    """
    Gaussian Process Particle Filter (GPPF) model for degradation trajectory prediction
    and Remaining Useful Life (RUL) estimation.

    This class combines a Gaussian Process Regression (GPR) model with a particle
    filtering approach to simulate degradation evolution over time. The GPR learns
    the degradation rate (dy/dt) as a function of the current health indicator (HI),
    while the particle filter performs stochastic forward simulations to predict
    the mean degradation path and estimate RUL.

    Typical use cases include:
    - Predicting bearing or tool wear progression based on measured degradation signals.
    - Simulating multiple degradation trajectories to assess uncertainty in RUL.
    - Early warning prediction when HI crosses a failure threshold.

    Example usage:
    ```python
    import numpy as np
    from my_module import GPParticleFilter

    # Sample synthetic degradation data
    y = np.linspace(0.2, 0.9, 100) + np.random.randn(100) * 0.02

    # Initialize and fit the GP Particle Filter
    model = GPParticleFilter(length_scale=0.3, alpha=1e-5, noise_std=0.02)
    model.fit(y)

    # Predict degradation trajectory and RUL
    results = model.predict_rul(y_segment=y, steps=200, N=300)

    # Access RUL estimates for each starting point
    for start_idx, res in results.items():
        print(f"Start index {start_idx} -> Estimated RUL: {res['rul_estimate']} steps")
    ```
    """

    def __init__(self, length_scale: float = 0.3, alpha: float = 1e-5, noise_std: float = 0.02, verbose: bool = False):
        """
        Initialize the Gaussian Process Particle Filter model.

        Args:
            length_scale (float, optional): Length scale parameter for the RBF kernel.
                Controls smoothness of the degradation rate function. Default is 0.3.
            alpha (float, optional): Added noise level in GPR for numerical stability.
                Default is 1e-5.
            noise_std (float, optional): Standard deviation of process noise used during
                particle simulation. Higher values increase trajectory variability.
                Default is 0.02.
            `verbose (bool)`: Enable logging
        """
        # Initialization parameters
        self.length_scale = length_scale
        self.alpha = alpha
        self.noise_std = noise_std
        self.verbose = verbose

        # Logger setup
        ut = Utils(verbose=verbose)
        self.logger = ut.get_logger(self.__class__.__name__)
        self._print_debug = ut.print_debug

        self.logger.info(f"Initialized {self.__class__.__name__} "
                         f"with length_scale={self.length_scale}, alpha={self.alpha}, noise_std={self.noise_std}")
        if self.verbose:
            print(f"[DEBUG] {self.__class__.__name__} initialized with length_scale={self.length_scale}, "
                  f"alpha={self.alpha}, noise_std={self.noise_std}")

        self.gp: Optional[GaussianProcessRegressor] = None

    def fit(self, y: np.ndarray):
        """
        Fit the underlying Gaussian Process to model the degradation rate.

        Args:
            y (np.ndarray): One-dimensional array of degradation signal (e.g., health
                indicator values) over time. The GP learns dy/dt as a function of y.

        Returns:
            None
        """

        if hasattr(y, "values"):
            # Select numeric columns only (ignore strings)
            if hasattr(y, "select_dtypes"):
                y = y.select_dtypes(include=[np.number])
                if y.shape[1] > 1:
                    y = y.mean(axis=1)  # Combine multiple numeric features
                else:
                    y = y.iloc[:, 0]
            y = y.values

        y = np.asarray(y).reshape(-1)
        dy = np.gradient(y)

        X = y.reshape(-1, 1)
        Y = dy.reshape(-1, 1)

        self.gp = GaussianProcessRegressor(kernel=RBF(self.length_scale), alpha=self.alpha)
        self.gp.fit(X, Y)

        self.logger.info("GP model fitted successfully")
        self.logger.debug(f"Training data shape: X={X.shape}, Y={Y.shape}")
        self.logger.debug(f"GP trained with length_scale={self.length_scale}, alpha={self.alpha}")

    def particle_filter(self, y0: float, threshold: float, steps: int = 200, N: int = 200,
                        clip_dy: Tuple[float, float] = (-0.01, 0.05)):
        """
        Perform particle filter simulation using the trained GP model.

        Args:
            y0 (float): Initial health indicator value at the starting point.
            threshold (float): Failure threshold (maximum degradation value to stop simulation).
            steps (int, optional): Maximum number of simulation steps. Default is 200.
            N (int, optional): Number of particles for the Monte Carlo simulation.
                Larger N increases stability but adds computation cost. Default is 200.
            clip_dy (Tuple[float, float], optional): Bounds for predicted degradation rate (dy)
                to prevent unrealistic GP predictions. Default is (-0.01, 0.05).

        Returns:
            Tuple[np.ndarray, np.ndarray]:
                - means: Mean degradation trajectory over time.
                - stds: Standard deviation of the simulated trajectories.
        """
        if self.gp is None:
            raise RuntimeError("GP not fitted before particle filter simulation")

        self.logger.info("Starting particle filter simulation")
        self.logger.debug(f"y0={y0}, threshold={threshold}, steps={steps}, particles={N}")

        particles = y0 + np.random.randn(N, 1) * 0.05
        means, stds = [], []

        for t in range(steps):
            dy_hat, dy_std = self.gp.predict(particles, return_std=True)
            dy_hat = np.clip(dy_hat, clip_dy[0], clip_dy[1]).reshape(-1, 1)
            particles = particles + dy_hat + np.random.randn(N, 1) * self.noise_std

            means.append(np.mean(particles))
            stds.append(np.std(particles))

            if np.mean(particles) >= threshold:
                self.logger.info(f"Threshold reached at step {t}")
                break

        self.logger.info("Particle filter simulation completed")
        return np.array(means), np.array(stds)

    def predict_rul(self, y_segment: np.ndarray, start_rel_idx: Optional[List[int]] = None,
                    steps: int = 300, N: int = 200):
        """
        Predict Remaining Useful Life (RUL) using GP-based particle filtering.

        This method repeatedly applies the particle filter from different points
        along the degradation signal to estimate RUL distributions.

        Args:
            y_segment (np.ndarray): Degradation signal or health indicator time series.
            start_rel_idx (List[int], optional): Indices (relative to y_segment)
                from which to start RUL simulations. If None, three evenly spaced
                start points are automatically selected.
            steps (int, optional): Number of forward simulation steps per start point.
                Default is 300.
            N (int, optional): Number of particles per simulation. Default is 200.

        Returns:
            Dict[int, Dict[str, Any]]:
                A dictionary where each key is a start index and each value contains:
                    - 'mean': Mean simulated trajectory.
                    - 'std': Standard deviation across particles.
                    - 'rul_estimate': Estimated RUL in time steps.
        """
    
        # Handle pandas DataFrame or Series inputs safely
        if hasattr(y_segment, "values"):
            import pandas as pd
            if isinstance(y_segment, pd.DataFrame):
                # Keep only numeric columns
                y_segment = y_segment.select_dtypes(include=[np.number])
                if y_segment.empty:
                    raise ValueError("Input DataFrame has no numeric columns.")
                # Average across columns if multiple numeric features
                if y_segment.shape[1] > 1:
                    y_segment = y_segment.mean(axis=1)
                else:
                    y_segment = y_segment.iloc[:, 0]
            elif isinstance(y_segment, pd.Series):
                y_segment = y_segment.astype(float)
            y_segment = y_segment.values  # Convert to numpy array
                
        y_segment = np.asarray(y_segment).reshape(-1)
        self.fit(y_segment)
        threshold = np.max(y_segment)
        results = {}

        if start_rel_idx is None:
            start_rel_idx = [0, max(0, len(y_segment)//3), max(0, 2*len(y_segment)//3)]

        self.logger.info("Starting RUL prediction")
        self.logger.debug(f"Start indices for RUL estimation: {start_rel_idx}")

        for sp in start_rel_idx:
            y0 = y_segment[sp]
            mu, sigma = self.particle_filter(y0, threshold=threshold, steps=steps, N=N)
            results[int(sp)] = {"mean": mu, "std": sigma, "rul_estimate": len(mu)}
            self.logger.debug(f"RUL estimated from index {sp}: {len(mu)} steps")

        self.logger.info("RUL prediction completed for all start points")
        return results
    
class SVRDegradationModel:
    """
    Support Vector Regression (SVR) model for modeling degradation trends
    and predicting Remaining Useful Life (RUL).

    This model applies a Support Vector Regression approach to learn the mapping
    between time (or another predictor variable) and a health indicator (HI).
    SVR is particularly effective for systems where degradation progresses in a
    nonlinear yet smooth fashion, and where robustness against noise is required.

    Typical use cases include:
    - Modeling bearing or tool wear progression using HI curves.
    - Nonlinear curve fitting for degradation signals.
    - RUL estimation when combined with a failure threshold and extrapolation.

    The model internally scales features using `StandardScaler` to improve SVR
    performance. It supports single-dimensional inputs (e.g., time) or
    multi-dimensional feature vectors if required.

    Example usage:
    ```python
    import numpy as np
    from my_module import SVRDegradationModel

    t = np.arange(0, 200)
    hi = 0.2 + 0.003 * t + 0.02 * np.sin(0.1 * t)
    hi += np.random.randn(200) * 0.01

    model = SVRDegradationModel(kernel='rbf', C=50.0, epsilon=0.01)
    model.fit(t, hi)

    hi_pred = model.predict(t)
    print("First 10 predictions:", hi_pred[:10])
    ```
    """

    def __init__(self, kernel: str = 'rbf', C: float = 100.0, epsilon: float = 0.01, verbose: bool = False):
        """
        Initialize the SVR-based degradation model.

        Args:
            kernel (str, optional): Kernel type used in SVR ('rbf', 'linear', 'poly').
                Default is 'rbf'.
            C (float, optional): Regularization parameter controlling model flexibility.
                Default is 100.0.
            epsilon (float, optional): Epsilon-insensitive tube width.
                Default is 0.01.
            verbose (bool, optional): Enable debug-level console printing.
        """
        self.model = SklearnSVR(kernel=kernel, C=C, epsilon=epsilon)
        self.scaler = StandardScaler()
        self.verbose = verbose

        # === Logger setup (same style as GPParticleFilter) ===
        ut = Utils(verbose=verbose)
        self.logger = ut.get_logger(self.__class__.__name__)
        self._print_debug = ut.print_debug

        self.logger.info(
            f"Initialized {self.__class__.__name__} with kernel={kernel}, C={C}, epsilon={epsilon}"
        )

        if self.verbose:
            print(f"[DEBUG] {self.__class__.__name__} initialized with kernel={kernel}, C={C}, epsilon={epsilon}")

    def fit(self, X, y):
        """
        Fit the SVR model to the given degradation data.

        Args:
            X (np.ndarray): Input feature values (e.g., time indices). Can be 1D or 2D.
            y (np.ndarray): Corresponding health indicator (HI) values.

        Returns:
            None
        """
        self.logger.info("Starting SVR model training")

        X1 = X.reshape(-1, 1) if getattr(X, "ndim", 1) == 1 else X
        X_scaled = self.scaler.fit_transform(X1)

        self.model.fit(X_scaled, y)

    def predict(self, X):
        """
        Predict degradation values using the trained SVR model.

        Args:
            X (np.ndarray): Input feature values for prediction. Can be 1D or 2D.

        Returns:
            np.ndarray: Predicted health indicator (HI) values.
        """
        X1 = X.reshape(-1, 1) if getattr(X, "ndim", 1) == 1 else X
        X_scaled = self.scaler.transform(X1)

        preds = self.model.predict(X_scaled)

        self.logger.info("SVR prediction completed")
        return preds


class MLPRULForecasterWrapper:
    """
    Wrapper for MLP-based Health Index (HI) forecasting and RUL estimation.

    This class creates sliding-window training samples from the HI curve and
    trains a Multi-Layer Perceptron (MLP) to predict the next HI value.
    During prediction, it recursively forecasts future HI values and estimates
    the time until the HI crosses the failure threshold.

    Attributes:
        window_size (int): Number of past HI points used as input.
        forecast_horizon (int): Number of steps to recursively forecast.
        failure_threshold (float): HI value indicating failure.
        model (MLPRegressor): MLP used for forecasting.
        _scale_lo (float): Minimum HI seen during training (for scaling).
        _scale_hi (float): Maximum HI seen during training (for scaling).
    """

    def __init__(self,
                 window_size: int = 20,
                 forecast_horizon: int = 50,
                 failure_threshold: float = 0.7):
        self.window_size = int(window_size)
        self.forecast_horizon = int(forecast_horizon)
        self.failure_threshold = float(failure_threshold)

        self.model = MLPRegressor(
            hidden_layer_sizes=(64, 64),
            max_iter=1000,
            random_state=42
        )

        self._scale_lo = None
        self._scale_hi = None

        logger.info(
            f"Initialized MLPRULForecasterWrapper(window_size={self.window_size}, "
            f"forecast_horizon={self.forecast_horizon}, "
            f"failure_threshold={self.failure_threshold})"
        )

    def fit(self, hi: np.ndarray, degradation_start: int):
        """
        Train the MLP forecaster using sliding-window samples.

        Args:
            hi (np.ndarray): Full HI array.
            degradation_start (int): Index where true degradation begins.

        Returns:
            self
        """
        logger.info("Starting MLP RUL forecaster training...")

        hi = np.asarray(hi).reshape(-1)
        lo, hi_max = float(np.min(hi)), float(np.max(hi))
        denom = hi_max - lo if hi_max - lo > 1e-8 else 1.0

        scaled = (hi - lo) / denom
        logger.debug(f"HI normalized: lo={lo}, hi={hi_max}, denom={denom}")

        # Build dataset
        X, y = [], []
        for t in range(degradation_start, len(scaled) - self.window_size - 1):
            X.append(scaled[t:t + self.window_size])
            y.append(scaled[t + self.window_size])

        if len(X) == 0:
            logger.error("No training windows available. Check inputs!")
            raise ValueError("No training windows created.")

        X, y = np.array(X), np.array(y)

        logger.info(f"Training samples created: X={X.shape}, y={y.shape}")

        # Save scaling factors
        self._scale_lo, self._scale_hi = lo, hi_max

        # Train MLP
        logger.info("Fitting MLP model...")
        self.model.fit(X, y)
        logger.info("MLP training complete.")

        return self

    def predict(self, hi: np.ndarray, prediction_point: int):
        """
        Predict future HI values and estimate RUL.

        Args:
            hi (np.ndarray): Full HI array.
            prediction_point (int): Index where forecasting should begin.

        Returns:
            forecast (np.ndarray): Predicted HI values.
            upper (np.ndarray): +2σ confidence band.
            lower (np.ndarray): -2σ confidence band.
            cross_time (int or None): Absolute index when HI crosses the threshold.
            rul_estimate (int or None): Steps until threshold crossing.
        """
        logger.info("Starting MLP RUL prediction...")

        hi_raw = np.asarray(hi).reshape(-1)

        lo = self._scale_lo if self._scale_lo is not None else float(np.min(hi_raw))
        hi_max = self._scale_hi if self._scale_hi is not None else float(np.max(hi_raw))
        denom = hi_max - lo if (hi_max - lo) > 1e-8 else 1.0

        logger.debug(f"Using scaling: lo={lo}, hi={hi_max}, denom={denom}")

        hi_scaled = (hi_raw - lo) / denom

        # Extract window
        start = int(prediction_point - self.window_size)
        if start < 0:
            logger.error("Prediction point too early for window size.")
            raise ValueError("prediction_point too early for window_size")

        window = hi_scaled[start:prediction_point].tolist()

        if np.std(window) < 1e-6:
            logger.warning("Early HI window is too flat; model cannot forecast.")
            return None, None, None, None, None

        # Generate forecast
        forecast = [hi_scaled[prediction_point]]
        for _ in range(1, self.forecast_horizon):
            pred = float(self.model.predict([window[-self.window_size:]])[0])
            forecast.append(pred)
            window.append(pred)

        forecast = np.clip(forecast, 0.0, 1.0)
        forecast = np.array(forecast)

        # Confidence bands
        residuals = (
            self.model.predict([hi_scaled[start:prediction_point]])
            - [hi_scaled[prediction_point]]
        )

        std = float(np.std(residuals)) if residuals.size > 0 else 0.0
        upper = forecast + 2 * std
        lower = forecast - 2 * std

        # Find crossing point
        cross_idx = np.where(forecast > self.failure_threshold)[0]
        cross_time = None
        rul_estimate = None

        if cross_idx.size > 0:
            cross_idx0 = int(cross_idx[0])
            cross_time = prediction_point + cross_idx0
            rul_estimate = cross_idx0
            logger.info(f"Failure predicted at t={cross_time} (RUL={rul_estimate})")
        else:
            logger.info("No threshold crossing detected within forecast horizon.")

        return forecast, upper, lower, cross_time, rul_estimate

    def save(self, path: str):
        """Save trained model to disk."""
        os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
        joblib.dump(self.model, path)
        logger.info(f"MLP model saved to {path}")

    def load(self, path: str):
        """Load trained model from disk."""
        self.model = joblib.load(path)
        logger.info(f"MLP model loaded from {path}")
