"""
V7 - GRU Model for Cryptocurrency Price Prediction (Adapted from V6)

This module implements a GRU-based neural network model for predicting
cryptocurrency prices directly (regression), calculating uncertainty via
Monte Carlo dropout, and deriving predicted returns.
"""
import numpy as np
import tensorflow as tf
import os
import joblib
import logging
import time
import sys
from sklearn.preprocessing import MinMaxScaler, StandardScaler # Keep both options
from sklearn.metrics import mean_absolute_error, mean_squared_error, accuracy_score, classification_report, confusion_matrix
from tensorflow.keras.models import Sequential, load_model, Model # Keep Model if needed
from tensorflow.keras.layers import GRU, Dropout, Dense, Input # Keep Input if needed
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau
from tensorflow.keras.optimizers import Adam
import matplotlib.pyplot as plt

# Configure logging (ensure it doesn't conflict with other loggers)
gru_logger = logging.getLogger(__name__)
# Avoid adding handlers if they already exist from a root config
if not gru_logger.hasHandlers():
    gru_logger.setLevel(logging.INFO)
    # Check if root logger has handlers to avoid duplicate console output
    root_logger = logging.getLogger()
    if not root_logger.hasHandlers() or all(isinstance(h, logging.FileHandler) for h in root_logger.handlers):
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setLevel(logging.INFO)
        formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
        console_handler.setFormatter(formatter)
        gru_logger.addHandler(console_handler)
        gru_logger.propagate = False # Prevent propagation to root if we added a handler
    else:
        gru_logger.propagate = True # Propagate to root if it has handlers

# Test log message
gru_logger.info("GRU predictor V7 (V6 Adaptation) logger initialized.")

# --- Helper: Predict in Batches ---
def predict_in_batches(model, X, batch_size=1024):
    """Process predictions in batches to avoid OOM errors"""
    n_samples = X.shape[0]
    n_batches = (n_samples + batch_size - 1) // batch_size
    predictions = []
    for i in range(n_batches):
        start_idx = i * batch_size
        end_idx = min((i + 1) * batch_size, n_samples)
        # Use training=False for standard prediction
        batch_predictions = model(X[start_idx:end_idx], training=False).numpy()
        predictions.append(batch_predictions)
        if n_batches > 10 and (i+1) % max(1, n_batches//10) == 0:
            gru_logger.info(f"Prediction batch progress: {i+1}/{n_batches}")
    return np.vstack(predictions)


class CryptoGRUModel:
    """
    GRU-based model for cryptocurrency price prediction, adapted from V6.
    Predicts price directly and calculates MC uncertainty.
    """

    def __init__(self, model_dir=None):
        """
        Initialize the GRU regression model.

        Args:
            model_dir (str, optional): Directory to load pre-trained model and scalers.
        """
        gru_logger.info("Initializing V7 CryptoGRUModel (V6 Adaptation)...")
        self.model: tf.keras.Model = None
        self.feature_scaler: StandardScaler | MinMaxScaler = None # Allow either
        self.y_scaler: MinMaxScaler = None # V6 used MinMaxScaler for target
        self.model_dir = model_dir # Store model dir for saving/loading convenience
        self.is_trained = False
        self.is_loaded = False

        if model_dir:
            self.load(model_dir)

    def _build_model(self, input_data):
        """
        Build the GRU model architecture dynamically from input data dimensions,
        exactly matching the V6 implementation.

        Args:
            input_data (np.array): Training data, used to determine input shape.

        Returns:
            tf.keras.Model: Compiled GRU model matching V6.
        """
        gru_logger.info("Building V6 GRU model architecture...")
        # Determine input shape from data
        input_shape = (input_data.shape[1], input_data.shape[2])
        
        # Create Sequential model to match V6 implementation
        model = Sequential(name="V6_GRU_Regression")

        # Add explicit Input layer as recommended
        model.add(Input(shape=input_shape, name="input_layer"))

        # Add single GRU layer (100 units) - remove input_shape argument
        model.add(GRU(100, name="gru_100"))

        # Add Dropout layer (V6 used 0.2)
        model.add(Dropout(0.2, name="dropout_0.2"))

        # Output layer with linear activation for price prediction
        model.add(Dense(1, activation='linear', name="output_price")) # output_dim = 1

        # Compile the model using MSE loss as in V6
        model.compile(
            optimizer=Adam(learning_rate=0.001), # V6 used 0.001 LR
            loss='mse', # V6 used MSE
            metrics=['mae', 'mape', tf.keras.metrics.RootMeanSquaredError(name='rmse')] # V6 metrics
        )

        gru_logger.info(f"V6 Model built: 1 GRU layer (100 units), Dropout (0.2), Linear Output.")
        gru_logger.info(f"Input shape: {(input_data.shape[1], input_data.shape[2])}")
        model.summary(print_fn=gru_logger.info)

        return model

    def train(self, X_train, y_train_scaled, X_val, y_val_scaled,
              feature_scaler, y_scaler, # Pass fitted scalers
              batch_size=32, epochs=20, patience=10,
              model_save_dir='models/gru_predictor_trained'):
        """
        Train the GRU regression model using V6 parameters.
        Assumes sequences (X) and scaled targets (y) are provided.
        Fitted scalers must also be provided for saving.

        Args:
            X_train (np.array): Training sequences.
            y_train_scaled (np.array): Scaled training target prices (shape: [samples, 1]).
            X_val (np.array): Validation sequences.
            y_val_scaled (np.array): Scaled validation target prices (shape: [samples, 1]).
            feature_scaler: Fitted feature scaler (StandardScaler or MinMaxScaler).
            y_scaler: Fitted target scaler (MinMaxScaler).
            batch_size (int): Batch size for training (default: 32 as per V6).
            epochs (int): Maximum number of epochs (default: 20 as per V6).
            patience (int): Patience for early stopping (default: 10).
            model_save_dir (str): Directory to save the best model and scalers.

        Returns:
            dict: Training history.
        """
        gru_logger.info("--- Starting GRU Model Training (V6 Adaptation) ---")

        # Store scalers
        if feature_scaler is None or y_scaler is None:
            gru_logger.error("Fitted scalers must be provided for training.")
            return None
        self.feature_scaler = feature_scaler
        self.y_scaler = y_scaler

        # Build model dynamically based on input data shape if not already loaded/built
        if self.model is None:
            gru_logger.info(f"Building model dynamically from input data with shape {X_train.shape}")
            self.model = self._build_model(X_train)
        
        # Set up callbacks (monitoring val_rmse as in V6)
        os.makedirs(model_save_dir, exist_ok=True)
        best_model_path = os.path.join(model_save_dir, "best_model_reg.keras")
        
        gru_logger.info("Monitoring val_rmse for EarlyStopping and ModelCheckpoint.")
        callbacks = [
            EarlyStopping(
                monitor='val_rmse', patience=patience, restore_best_weights=True,
                verbose=1, mode='min'
            ),
            ModelCheckpoint(
                filepath=best_model_path, monitor='val_rmse', save_best_only=True,
                verbose=1, mode='min'
            ),
            ReduceLROnPlateau(
                monitor='val_rmse', factor=0.5, patience=patience // 2, # Reduce LR faster
                min_lr=1e-6, verbose=1, mode='min'
            )
        ]

        # Record start time
        start_time = time.time()
        gru_logger.info(f"Starting training: epochs={epochs}, batch_size={batch_size}, patience={patience}")

        # --- Log first input sequence and target price ---
        if len(X_train) > 0 and len(y_train_scaled) > 0:
            gru_logger.info(f"First training sequence (X_train[0]) shape: {X_train[0].shape}")
            log_steps = min(5, X_train.shape[1])
            gru_logger.info(f"First {log_steps} steps of first training sequence (scaled):\n{X_train[0][:log_steps]}")
            gru_logger.info(f"Target scaled price for first sequence (y_train_scaled[0]): {y_train_scaled[0]:.6f}")
        else:
            gru_logger.warning("X_train or y_train_scaled is empty, cannot log first sequence.")

        # Train the model
        history = self.model.fit(
            X_train, y_train_scaled, 
            validation_data=(X_val, y_val_scaled),
            batch_size=batch_size,
            epochs=epochs,
            callbacks=callbacks,
            verbose=1,
        )

        # Calculate training duration
        training_duration = time.time() - start_time
        hours, remainder = divmod(training_duration, 3600)
        minutes, seconds = divmod(remainder, 60)
        gru_logger.info(f"Training completed in {int(hours)}h {int(minutes)}m {int(seconds)}s")

        # Load the best model saved by ModelCheckpoint
        if os.path.exists(best_model_path):
            gru_logger.info(f"Loading best regression model from {best_model_path}")
            # No custom objects needed for standard MSE loss and metrics
            self.model = load_model(best_model_path)
            self.is_trained = True
            self.is_loaded = False # Trained in this session
            # Save scalers alongside the best model
            self.save(model_save_dir) # Save model and scalers
        else:
            gru_logger.warning(f"Best model file not found at {best_model_path}. Using the final state.")
            self.is_trained = True
            self.is_loaded = False
            # Save the final state model and scalers
            self.save(model_save_dir)

        return history.history

    def predict_scaled_price(self, X):
        """
        Make scaled price predictions with the trained regression model.

        Args:
            X (np.array): Input sequences.

        Returns:
            np.array: Predicted scaled prices (shape: [samples, 1]).
        """
        if self.model is None:
            gru_logger.error("Model not loaded or trained. Cannot predict.")
            raise ValueError("Model is not available for prediction.")

        gru_logger.info(f"Predicting scaled prices on data with shape {X.shape}")
        return self.model.predict(X)
        # Consider using predict_in_batches for large X


    def evaluate(self, X_test, y_test_scaled, y_start_price_test, n_mc_samples=30):
        """
        Evaluate the regression model on test data, calculate uncertainty,
        and derive predicted returns and confidence. Mirrors V6 evaluation logic.

        Args:
            X_test (np.array): Test sequences.
            y_test_scaled (np.array): Scaled true target prices (shape: [samples, 1]).
            y_start_price_test (np.array): Unscaled price at the start of each test target window (shape: [samples] or [samples, 1]).
            n_mc_samples (int): Number of Monte Carlo samples for uncertainty estimation.

        Returns:
            dict: Dictionary containing evaluation results:
                  'pred_percent_change': Predicted % change based on unscaled price predictions.
                  'raw_confidence_score': Confidence score (1 - normalized MC std dev).
                  'predicted_unscaled_prices': Unscaled price predictions.
                  'mae': Mean Absolute Error (unscaled).
                  'rmse': Root Mean Squared Error (unscaled).
                  'mape': Mean Absolute Percentage Error (unscaled).
                  'mc_unscaled_std_dev': Unscaled standard deviation from MC dropout.
                  'misc_metrics': Dict with scaled metrics if needed.
        """
        gru_logger.info("--- Starting Model Evaluation (V6 Adaptation) ---")
        if self.model is None or self.y_scaler is None:
            gru_logger.error("Model or y_scaler not available for evaluation.")
            return None
        if X_test is None or y_test_scaled is None or y_start_price_test is None:
             gru_logger.error("Missing data for evaluation (X_test, y_test_scaled, or y_start_price_test).")
             return None

        # --- 1. Standard Prediction ---
        gru_logger.info(f"Predicting on Test set (Standard) with shape {X_test.shape}")
        y_pred_test_standard_scaled = predict_in_batches(self.model, X_test) # Use batch predictor

        # --- 2. Monte Carlo Dropout Prediction ---
        gru_logger.info(f"Running Monte Carlo dropout inference ({n_mc_samples} samples)...")
        mc_preds_test_list = []

        # Define the prediction step with training=True inside the loop
        @tf.function
        def mc_predict_step_test(batch):
            return self.model(batch, training=True) # Enable dropout

        for i in range(n_mc_samples):
            mc_preds = []
            # Batch processing within MC loop
            for j in range(0, len(X_test), 1024):
                batch = X_test[j:j+1024]
                batch_preds = mc_predict_step_test(tf.constant(batch, dtype=tf.float32)).numpy()
                mc_preds.append(batch_preds)
            mc_preds_test_list.append(np.vstack(mc_preds))
            if n_mc_samples > 1 and (i+1) % max(1, n_mc_samples//5) == 0:
                 gru_logger.info(f"  MC progress: {i+1}/{n_mc_samples}")

        mc_preds_stack = np.stack(mc_preds_test_list)
        y_pred_test_mc_std_scaled = np.std(mc_preds_stack, axis=0)
        gru_logger.info(f"MC dropout completed. Scaled Std Dev: Min={np.min(y_pred_test_mc_std_scaled):.6f}, Max={np.max(y_pred_test_mc_std_scaled):.6f}, Mean={np.mean(y_pred_test_mc_std_scaled):.6f}")

        # --- 3. Inverse Transform ---
        gru_logger.info("Inverse transforming predictions and true values...")
        try:
            y_pred_test_standard_unscaled = self.y_scaler.inverse_transform(y_pred_test_standard_scaled).flatten()
            # Reshape y_test_scaled to 2D before inverse_transform
            if y_test_scaled.ndim == 1:
                 y_test_scaled_2d = y_test_scaled.reshape(-1, 1)
            else:
                 y_test_scaled_2d = y_test_scaled # Assume it's already 2D if not 1D
            y_test_true_unscaled = self.y_scaler.inverse_transform(y_test_scaled_2d).flatten()
        except Exception as e:
             gru_logger.error(f"Error during inverse transform: {e}", exc_info=True)
             return None

        # --- 4. Unscale MC Standard Deviation ---
        gru_logger.info("Unscaling MC standard deviation...")
        y_pred_test_mc_std_unscaled_flat = np.zeros_like(y_pred_test_mc_std_scaled.flatten()) # Default
        try:
            # Use scaler's data range if available (more robust for MinMaxScaler)
            if hasattr(self.y_scaler, 'data_min_') and hasattr(self.y_scaler, 'data_max_'):
                 data_range = self.y_scaler.data_max_[0] - self.y_scaler.data_min_[0]
                 if data_range > 1e-9:
                     y_pred_test_mc_std_unscaled_flat = y_pred_test_mc_std_scaled.flatten() * data_range
                     gru_logger.info(f"Unscaled MC std dev (Test): Mean={np.mean(y_pred_test_mc_std_unscaled_flat):.6f}")
                 else:
                     gru_logger.warning("Scaler data range is near zero. Cannot reliably unscale MC std dev.")
            else:
                 gru_logger.warning("y_scaler missing data_min_/data_max_. Cannot unscale MC std dev accurately.")
        except Exception as e:
            gru_logger.error(f"Error unscaling MC std dev: {e}", exc_info=True)
            # Keep the default of zeros or potentially use scaled std dev as fallback?

        # --- 5. Calculate Raw Confidence Score ---
        gru_logger.info("Calculating raw confidence score (1 - normalized std dev)...")
        test_raw_confidence_score = np.ones_like(y_pred_test_mc_std_unscaled_flat) * 0.5 # Default if std dev is constant
        epsilon = 1e-9
        max_std_dev_test = np.max(y_pred_test_mc_std_unscaled_flat) + epsilon
        min_std_dev_test = np.min(y_pred_test_mc_std_unscaled_flat)

        if max_std_dev_test > min_std_dev_test:
            # Normalize the UNscaled std dev between 0 and 1
            normalized_std_dev = (y_pred_test_mc_std_unscaled_flat - min_std_dev_test) / (max_std_dev_test - min_std_dev_test)
            # Confidence is inverse of normalized uncertainty
            test_raw_confidence_score = 1.0 - normalized_std_dev
        else:
            gru_logger.warning("MC standard deviation is constant or near-constant. Setting raw confidence to 0.5.")

        test_raw_confidence_score = np.clip(test_raw_confidence_score, 0.0, 1.0) # Ensure bounds
        gru_logger.info(f"Raw Confidence score: Min={np.min(test_raw_confidence_score):.4f}, Max={np.max(test_raw_confidence_score):.4f}, Mean={np.mean(test_raw_confidence_score):.4f}")

        # --- 6. Calculate Predicted Percentage Change ---
        gru_logger.info("Calculating predicted percentage change...")
        # Ensure y_start_price_test is flattened and aligned
        y_start_price_flat = y_start_price_test.flatten()
        if len(y_pred_test_standard_unscaled) != len(y_start_price_flat):
             gru_logger.error(f"Length mismatch: Pred Price ({len(y_pred_test_standard_unscaled)}) vs Start Price ({len(y_start_price_flat)})")
             # Attempt to align if possible (e.g., maybe y_start_price_test has extra initial values)
             if len(y_start_price_flat) > len(y_pred_test_standard_unscaled):
                 diff = len(y_start_price_flat) - len(y_pred_test_standard_unscaled)
                 gru_logger.warning(f"Attempting alignment by trimming {diff} elements from start_price array.")
                 y_start_price_flat = y_start_price_flat[diff:] # Trim from the beginning? Or end? Needs context. Assume end.
                 # y_start_price_flat = y_start_price_flat[:-diff] # Trim from end

             if len(y_pred_test_standard_unscaled) != len(y_start_price_flat):
                 gru_logger.error("Alignment failed. Cannot calculate predicted change.")
                 pred_percent_change = np.zeros_like(y_pred_test_standard_unscaled) # Fallback
             else:
                 gru_logger.info("Alignment successful.")
                 pred_percent_change = np.where(
                     np.abs(y_start_price_flat) > epsilon,
                     (y_pred_test_standard_unscaled / y_start_price_flat) - 1,
                     0 # Assign 0 change if start price is near zero
                 )
        else:
             pred_percent_change = np.where(
                 np.abs(y_start_price_flat) > epsilon,
                 (y_pred_test_standard_unscaled / y_start_price_flat) - 1,
                 0 # Assign 0 change if start price is near zero
             )
        gru_logger.info(f"Predicted Percent Change: Min={np.min(pred_percent_change):.4f}, Max={np.max(pred_percent_change):.4f}, Mean={np.mean(pred_percent_change):.4f}")

        # --- 7. Calculate Regression Metrics ---
        gru_logger.info("Calculating regression metrics (on unscaled data)...")
        eval_mae = mean_absolute_error(y_test_true_unscaled, y_pred_test_standard_unscaled)
        eval_mse = mean_squared_error(y_test_true_unscaled, y_pred_test_standard_unscaled)
        eval_rmse = np.sqrt(eval_mse)
        mask = y_test_true_unscaled != 0
        eval_mape = np.mean(np.abs((y_test_true_unscaled[mask] - y_pred_test_standard_unscaled[mask]) / y_test_true_unscaled[mask])) * 100 if np.any(mask) else 0.0

        gru_logger.info(f"Test MAE (Unscaled): {eval_mae:.4f}")
        gru_logger.info(f"Test RMSE (Unscaled): {eval_rmse:.4f}")
        gru_logger.info(f"Test MAPE (Unscaled): {eval_mape:.4f}%")

        # --- 8. Return Results ---
        results = {
            'pred_percent_change': pred_percent_change,
            'raw_confidence_score': test_raw_confidence_score,
            'predicted_unscaled_prices': y_pred_test_standard_unscaled,
            'true_unscaled_prices': y_test_true_unscaled, # Include true values for plotting/analysis
            'mae': eval_mae,
            'rmse': eval_rmse,
            'mape': eval_mape,
            'mc_unscaled_std_dev': y_pred_test_mc_std_unscaled_flat,
            # Add derived direction accuracy for comparison?
            # 'derived_direction_accuracy': accuracy_score(np.sign(y_test_true_unscaled - y_start_price_flat), np.sign(y_pred_test_standard_unscaled - y_start_price_flat))
        }
        gru_logger.info("--- Evaluation Completed ---")
        return results


    def save(self, model_dir):
        """
        Save the trained regression model and scalers.

        Args:
            model_dir (str): Directory to save artifacts.
        """
        if not (self.is_trained or self.is_loaded):
            gru_logger.error("Cannot save, model not trained/loaded.")
            return
        if self.model is None or self.feature_scaler is None or self.y_scaler is None:
            gru_logger.error("Cannot save, model or scalers missing.")
            return

        os.makedirs(model_dir, exist_ok=True)
        model_path = os.path.join(model_dir, "best_model_reg.keras") # V6 name
        feature_scaler_path = os.path.join(model_dir, "feature_scaler.joblib")
        y_scaler_path = os.path.join(model_dir, "y_scaler.joblib")

        try:
            self.model.save(model_path)
            gru_logger.info(f"Keras model saved to {model_path}")
            joblib.dump(self.feature_scaler, feature_scaler_path)
            gru_logger.info(f"Feature scaler saved to {feature_scaler_path}")
            joblib.dump(self.y_scaler, y_scaler_path)
            gru_logger.info(f"Target scaler (MinMaxScaler) saved to {y_scaler_path}")
        except Exception as e:
            gru_logger.error(f"Error saving model/scalers to {model_dir}: {e}", exc_info=True)

    def load(self, model_dir):
        """
        Load a previously trained regression model and its scalers.

        Args:
            model_dir (str): Directory containing model (.keras) and scalers (.joblib).
        """
        gru_logger.info(f"Attempting to load V6-style GRU regression model and scalers from: {model_dir}")
        self.model_dir = model_dir
        model_path = os.path.join(model_dir, 'best_model_reg.keras') # V7.20 Fix: Load correct filename
        scaler_feature_path = os.path.join(model_dir, 'feature_scaler.joblib')
        scaler_y_path = os.path.join(model_dir, 'y_scaler.joblib')

        # Check if all required files exist
        files_exist = all(os.path.exists(p) for p in [model_path, scaler_feature_path, scaler_y_path])

        if not files_exist:
            gru_logger.warning(f"Cannot load model. Required files missing in {model_dir}.")
            gru_logger.warning(f" Missing: {[p for p in [model_path, scaler_feature_path, scaler_y_path] if not os.path.exists(p)]}")
            self.is_loaded = False
            return False

        try:
            # Load Keras model
            gru_logger.info(f"Loading GRU model from: {model_path}")
            # Load without compiling if optimizer state is not needed or causes issues
            self.model = load_model(model_path, compile=False)

            # Load scalers
            gru_logger.info(f"Loading feature scaler from: {scaler_feature_path}")
            self.feature_scaler = joblib.load(scaler_feature_path)
            gru_logger.info(f"Loading target scaler from: {scaler_y_path}")
            self.y_scaler = joblib.load(scaler_y_path)

            self.is_loaded = True
            self.is_trained = False # Loaded, not trained in this session
            gru_logger.info(f"CryptoGRUModel loaded successfully from {model_dir}.")

            # V7.9 Explicitly build after loading to be safe
            if self.model and hasattr(self.model, 'input_shape'):
                 gru_logger.info("Triggering model build after loading...")
                 dummy_input = tf.zeros((1,) + self.model.input_shape[1:], dtype=tf.float32)
                 _ = self.model(dummy_input)
                 gru_logger.info("Model built after loading.")
            else:
                 gru_logger.warning("Could not get input shape to explicitly build model after loading.")

            return True
        except Exception as e:
            gru_logger.error(f"Error loading model/scalers from {model_dir}: {e}", exc_info=True)
            self.model = None; self.feature_scaler = None; self.y_scaler = None
            self.is_trained = False; self.is_loaded = False
            return False

    def plot_training_history(self, history, save_path=None):
        """
        Plot training history (Loss, MAE, RMSE). Adapted from V6.

        Args:
            history (dict): Training history from model.fit().
            save_path (str, optional): Path to save the plot.
        """
        if not history:
            gru_logger.warning("No history data provided to plot.")
            return

        plt.figure(figsize=(18, 5))

        # Plot training & validation loss (MSE)
        plt.subplot(1, 3, 1)
        plt.plot(history.get('loss', []), label='Train Loss (MSE)')
        plt.plot(history.get('val_loss', []), label='Val Loss (MSE)')
        plt.title('Model Loss (MSE)')
        plt.ylabel('Loss')
        plt.xlabel('Epoch')
        plt.legend(loc='upper right')
        plt.grid(True)

        # Plot training & validation MAE
        plt.subplot(1, 3, 2)
        plt.plot(history.get('mae', []), label='Train MAE')
        plt.plot(history.get('val_mae', []), label='Val MAE')
        plt.title('Model Mean Absolute Error')
        plt.ylabel('MAE')
        plt.xlabel('Epoch')
        plt.legend(loc='upper right')
        plt.grid(True)

        # Plot training & validation RMSE
        plt.subplot(1, 3, 3)
        plt.plot(history.get('rmse', []), label='Train RMSE')
        plt.plot(history.get('val_rmse', []), label='Val RMSE')
        plt.title('Model Root Mean Squared Error')
        plt.ylabel('RMSE')
        plt.xlabel('Epoch')
        plt.legend(loc='upper right')
        plt.grid(True)

        plt.tight_layout()

        if save_path:
            try:
                plt.savefig(save_path)
                gru_logger.info(f"Training history plot saved to {save_path}")
            except Exception as e:
                gru_logger.error(f"Error saving training history plot: {e}")
        else:
            plt.show() # Display plot if not saving

        plt.close()

    def plot_evaluation_results(self, eval_results, save_path=None):
        """
        Plot evaluation results for the REGRESSION model using data from evaluate().
        Adapted from V6.

        Args:
            eval_results (dict): Dictionary returned by the evaluate() method.
            save_path (str, optional): Path to save the plots.
        """
        if not eval_results:
            gru_logger.warning("No evaluation results provided to plot.")
            return

        y_true = eval_results.get('true_unscaled_prices')
        y_pred = eval_results.get('predicted_unscaled_prices')
        mae = eval_results.get('mae', -1)
        mape = eval_results.get('mape', -1)
        confidence = eval_results.get('raw_confidence_score')
        uncertainty_std = eval_results.get('mc_unscaled_std_dev')

        if y_true is None or y_pred is None:
            gru_logger.error("Missing true or predicted prices in evaluation results for plotting.")
            return

        plt.figure(figsize=(15, 12)) # Adjusted size

        # Plot 1: True vs. Predicted Prices with Uncertainty Bands
        plt.subplot(3, 1, 1) # Changed to 3 rows
        plt.plot(y_true, label='True Prices', alpha=0.7)
        plt.plot(y_pred, label=f'Predicted Prices (MAE: {mae:.2f})', alpha=0.7)
        if uncertainty_std is not None:
             plt.fill_between(
                 range(len(y_pred)),
                 y_pred - uncertainty_std,
                 y_pred + uncertainty_std,
                 color='orange', alpha=0.2, label='Uncertainty (MC Std Dev)'
             )
        plt.title(f"True vs. Predicted Prices (MAPE: {mape:.2f}%)")
        plt.ylabel('Price')
        plt.legend()
        plt.grid(True)

        # Plot 2: Prediction Errors (Residuals)
        plt.subplot(3, 1, 2)
        errors = y_true - y_pred
        plt.hist(errors, bins=50, alpha=0.7)
        plt.title(f"Prediction Errors (Residuals) - MAE: {mae:.4f}")
        plt.xlabel('Error (True - Predicted)')
        plt.ylabel('Frequency')
        plt.grid(True)

        # Plot 3: Confidence Scores
        plt.subplot(3, 1, 3)
        if confidence is not None:
            plt.plot(confidence, label='Raw Confidence Score', color='green')
            plt.title(f"Confidence Score (Mean: {np.mean(confidence):.3f})")
            plt.ylabel("Confidence (0-1)")
            plt.ylim(0, 1.05)
            plt.legend()
        else:
            plt.title("Confidence Score (Not Available)")
        plt.xlabel('Time Step (Test Set)')
        plt.grid(True)


        plt.tight_layout()

        if save_path:
            try:
                plt.savefig(save_path)
                gru_logger.info(f"Regression evaluation plots saved to {save_path}")
            except Exception as e:
                gru_logger.error(f"Error saving evaluation plot: {e}")
        else:
            plt.show()

        plt.close()

# Example usage placeholder (won't run directly here)
if __name__ == "__main__":
    gru_logger.info("CryptoGRUModel (V6 Adaptation) module loaded.")
    # Example:
    # model = CryptoGRUModel()
    # # Need to load data, preprocess, scale, create sequences first...
    # # history = model.train(X_train, y_train_scaled, X_val, y_val_scaled, feature_scaler, y_scaler)
    # # eval_results = model.evaluate(X_test, y_test_scaled, y_start_price_test)
    # # model.plot_training_history(history, save_path='training_hist.png')
    # # model.plot_evaluation_results(eval_results, save_path='evaluation.png')
