#!/usr/bin/env python

# Copyright (c) 2023, 2025 Oracle and/or its affiliates.
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/

import logging
import os
import tempfile
import time
import traceback
import warnings
from abc import ABC, abstractmethod
from typing import Tuple

import numpy as np
import pandas as pd
import report_creator as rc

from ads.common.decorator.runtime_dependency import runtime_dependency
from ads.common.object_storage_details import ObjectStorageDetails
from ads.opctl import logger
from ads.opctl.operator.lowcode.common.const import DataColumns
from ads.opctl.operator.lowcode.common.utils import (
    datetime_to_seconds,
    disable_print,
    enable_print,
    human_time_friendly,
    load_pkl,
    merged_category_column_name,
    seconds_to_datetime,
    write_data,
    write_file,
    write_json,
    write_pkl,
)
from ads.opctl.operator.lowcode.forecast.utils import (
    _build_metrics_df,
    _build_metrics_per_horizon,
    _label_encode_dataframe,
    default_signer,
    evaluate_train_metrics,
    get_auto_select_plot,
    get_forecast_plots,
)

from ..const import (
    AUTO_SELECT,
    BACKTEST_REPORT_NAME,
    SUMMARY_METRICS_HORIZON_LIMIT,
    ForecastOutputColumns,
    SpeedAccuracyMode,
    SupportedMetrics,
    SupportedModels,
    TROUBLESHOOTING_GUIDE,
)
from ..operator_config import ForecastOperatorConfig, ForecastOperatorSpec
from .forecast_datasets import ForecastDatasets, ForecastResults

logging.getLogger("report_creator").setLevel(logging.WARNING)


class ForecastOperatorBaseModel(ABC):
    """The base class for the forecast operator models."""

    def __init__(self, config: ForecastOperatorConfig, datasets: ForecastDatasets):
        """Instantiates the ForecastOperatorBaseModel instance.

        Properties
        ----------
        config: ForecastOperatorConfig
            The forecast operator configuration.
        """
        self.config: ForecastOperatorConfig = config
        self.spec: ForecastOperatorSpec = config.spec
        self.datasets: ForecastDatasets = datasets
        self.target_cat_col = self.spec.target_category_columns
        self.full_data_dict = datasets.get_data_by_series()

        self.test_eval_metrics = None
        self.original_target_column = self.spec.target_column
        self.dt_column_name = self.spec.datetime_column.name

        self.model_parameters = {}
        self.loaded_models = None

        # these fields are populated in the _build_model() method
        self.models = None

        # "outputs" is a list of outputs generated by the models. These should only be generated when the framework requires the original output for plotting
        self.outputs = None
        self.forecast_output = None
        self.errors_dict = {}
        self.le = {}

        self.formatted_global_explanation = None
        self.formatted_local_explanation = None

        self.forecast_col_name = "yhat"
        self.perform_tuning = (self.spec.tuning is not None) and (
            self.spec.tuning.n_trials is not None
        )

    def build_model(self):
        """Builds the model and returns the result DataFrame and elapsed time."""
        import time

        start_time = time.time()
        result_df = self._build_model()
        elapsed_time = time.time() - start_time
        logger.info("Building the models completed in %s seconds", elapsed_time)
        return result_df, elapsed_time

    def generate_report(
        self, result_df=None, elapsed_time=None, save_sub_reports=False
    ):
        """Generates the forecasting report. Optionally accepts a precomputed result_df and elapsed_time.
        If save_sub_reports is True, unique filenames are generated for all outputs."""
        from sklearn.exceptions import ConvergenceWarning

        with warnings.catch_warnings():
            warnings.simplefilter(action="ignore", category=FutureWarning)
            warnings.simplefilter(action="ignore", category=UserWarning)
            warnings.simplefilter(action="ignore", category=RuntimeWarning)
            warnings.simplefilter(action="ignore", category=ConvergenceWarning)

            # load models if given
            if self.spec.previous_output_dir is not None:
                self._load_model()

            if result_df is None or elapsed_time is None:
                result_df, elapsed_time = self.build_model()

            # Generate metrics
            summary_metrics = None
            test_data = self.datasets.test_data
            self.eval_metrics = None

            if self.spec.generate_report or self.spec.generate_metrics:
                self.eval_metrics = self.generate_train_metrics()
                if not self.target_cat_col:
                    self.eval_metrics.rename(
                        {"Series 1": self.original_target_column}, axis=1, inplace=True
                    )

                if self.datasets.test_data is not None:
                    try:
                        (self.test_eval_metrics, summary_metrics) = (
                            self._test_evaluate_metrics(
                                elapsed_time=elapsed_time,
                            )
                        )
                        if not self.target_cat_col:
                            self.test_eval_metrics.rename(
                                {"Series 1": self.original_target_column},
                                axis=1,
                                inplace=True,
                            )
                    except Exception:
                        logger.warning("Unable to generate Test Metrics.")
                        logger.debug(f"Full Traceback: {traceback.format_exc()}")
            report_sections = []

            if self.spec.generate_report:
                # build the report
                (
                    model_description,
                    other_sections,
                ) = self._generate_report()
                header_section = rc.Block(
                    rc.Heading(self.spec.report_title, level=1),
                    rc.Text(
                        f"You selected the {self.spec.model} model.\nBased on your dataset, you could have also selected any of the models: {SupportedModels.keys()}."
                    ),
                    model_description,
                    rc.Group(
                        rc.Metric(
                            heading="Analysis was completed in ",
                            value=human_time_friendly(elapsed_time),
                        ),
                        rc.Metric(
                            heading="Starting time index",
                            value=self.datasets.get_earliest_timestamp().strftime(
                                "%B %d, %Y"
                            ),
                        ),
                        rc.Metric(
                            heading="Ending time index",
                            value=self.datasets.get_latest_timestamp().strftime(
                                "%B %d, %Y"
                            ),
                        ),
                        rc.Metric(
                            heading="Num series",
                            value=len(self.datasets.list_series_ids()),
                        ),
                    ),
                )

                first_5_rows_blocks = [
                    rc.DataTable(
                        df.head(5),
                        label=s_id if self.target_cat_col else None,
                        index=True,
                    )
                    for s_id, df in self.full_data_dict.items()
                ]

                last_5_rows_blocks = [
                    rc.DataTable(
                        df.tail(5),
                        label=s_id if self.target_cat_col else None,
                        index=True,
                    )
                    for s_id, df in self.full_data_dict.items()
                ]

                data_summary_blocks = [
                    rc.DataTable(
                        df.describe(),
                        label=s_id if self.target_cat_col else None,
                        index=True,
                    )
                    for s_id, df in self.full_data_dict.items()
                ]

                series_name = merged_category_column_name(
                    self.spec.target_category_columns
                )
                # series_subtext = rc.Text(f"Indexed by {series_name}")
                first_10_title = rc.Heading("First 5 Rows of Data", level=3)
                last_10_title = rc.Heading("Last 5 Rows of Data", level=3)
                summary_title = rc.Heading("Data Summary Statistics", level=3)

                data_summary_sec = rc.Block(
                    rc.Block(
                        first_10_title,
                        # series_subtext,
                        rc.Select(blocks=first_5_rows_blocks)
                        if self.target_cat_col
                        else first_5_rows_blocks[0],
                    ),
                    rc.Block(
                        last_10_title,
                        # series_subtext,
                        rc.Select(blocks=last_5_rows_blocks)
                        if self.target_cat_col
                        else last_5_rows_blocks[0],
                    ),
                    rc.Block(
                        summary_title,
                        # series_subtext,
                        rc.Select(blocks=data_summary_blocks)
                        if self.target_cat_col
                        else data_summary_blocks[0],
                    ),
                    rc.Separator(),
                )

                summary = rc.Block(
                    header_section,
                    data_summary_sec,
                )

                test_metrics_sections = []
                if (
                    self.test_eval_metrics is not None
                    and not self.test_eval_metrics.empty
                ):
                    sec7_text = rc.Heading("Test Data Evaluation Metrics", level=2)
                    sec7 = rc.DataTable(self.test_eval_metrics, index=True)
                    test_metrics_sections = test_metrics_sections + [sec7_text, sec7]

                if summary_metrics is not None and not summary_metrics.empty:
                    sec8_text = rc.Heading("Test Data Summary Metrics", level=2)
                    sec8 = rc.DataTable(summary_metrics, index=True)
                    test_metrics_sections = test_metrics_sections + [sec8_text, sec8]

                train_metrics_sections = []
                if self.eval_metrics is not None and not self.eval_metrics.empty:
                    sec9_text = rc.Heading("Training Data Metrics", level=2)
                    sec9 = rc.DataTable(self.eval_metrics, index=True)
                    train_metrics_sections = [sec9_text, sec9]

                backtest_sections = []
                output_dir = self.spec.output_directory.url
                file_path = f"{output_dir}/{BACKTEST_REPORT_NAME}"
                if self.spec.model == AUTO_SELECT:
                    backtest_sections.append(
                        rc.Heading(
                            "Auto-Select Backtesting and Performance Metrics", level=2
                        )
                    )
                    if not os.path.exists(file_path):
                        failure_msg = rc.Text(
                            "auto-select could not be executed. Please check the "
                            "logs for more details."
                        )
                        backtest_sections.append(failure_msg)
                    else:
                        backtest_stats = pd.read_csv(file_path)
                        model_metric_map = backtest_stats.drop(
                            columns=["metric", "backtest"]
                        )
                        average_dict = {
                            k: round(v, 4)
                            for k, v in model_metric_map.mean().to_dict().items()
                        }
                        best_model = min(average_dict, key=average_dict.get)
                        summary_text = rc.Text(
                            f"Overall, the average {self.spec.metric} scores for the models are {average_dict}, with"
                            f" {best_model} being identified as the top-performing model during backtesting."
                        )
                        backtest_table = rc.DataTable(backtest_stats, index=True)
                        liner_plot = get_auto_select_plot(backtest_stats)
                        backtest_sections.extend(
                            [backtest_table, summary_text, liner_plot]
                        )

                forecast_plots = []
                if len(self.forecast_output.list_series_ids()) > 0:
                    forecast_text = rc.Heading(
                        "Forecasted Data Overlaying Historical", level=2
                    )
                    forecast_sec = get_forecast_plots(
                        self.forecast_output,
                        horizon=self.spec.horizon,
                        test_data=test_data,
                        ci_interval_width=self.spec.confidence_interval_width,
                        target_category_column=self.target_cat_col,
                    )
                    if (
                        series_name is not None
                        and len(self.datasets.list_series_ids()) > 1
                    ):
                        forecast_plots = [
                            forecast_text,
                            forecast_sec,
                        ]  # series_subtext,
                    else:
                        forecast_plots = [forecast_text, forecast_sec]

                yaml_appendix_title = rc.Heading("Reference: YAML File", level=2)
                config_dict = self.config.to_dict()
                # pop the data incase it isn't json serializable
                config_dict["spec"]["historical_data"].pop("data")
                if config_dict["spec"].get("additional_data"):
                    config_dict["spec"]["additional_data"].pop("data")
                if config_dict["spec"].get("test_data"):
                    config_dict["spec"]["test_data"].pop("data")
                yaml_appendix = rc.Yaml(config_dict)
                report_sections = (
                    [summary]
                    + backtest_sections
                    + forecast_plots
                    + other_sections
                    + test_metrics_sections
                    + train_metrics_sections
                    + [yaml_appendix_title, yaml_appendix]
                )

            # save the report and result CSV
            return self._save_report(
                report_sections=report_sections,
                result_df=result_df,
                metrics_df=self.eval_metrics,
                test_metrics_df=self.test_eval_metrics,
                test_data=test_data,
                save_sub_reports=save_sub_reports,
            )

    def _test_evaluate_metrics(self, elapsed_time=0):
        total_metrics = pd.DataFrame()
        summary_metrics = pd.DataFrame()
        data = self.datasets.test_data

        # Generate y_pred and y_true for each series
        for s_id in self.forecast_output.list_series_ids():
            try:
                y_true = data.get_data_for_series(s_id)[data.target_name].values[
                    -self.spec.horizon :
                ]
            except KeyError as ke:
                logger.warning(
                    f"Error Generating Metrics: Unable to find {s_id} in the test data. Error: {ke.args}"
                )
            y_pred = self.forecast_output.get_forecast(s_id)["forecast_value"].values[
                -self.spec.horizon :
            ]

            drop_na_mask = ~np.isnan(y_true) & ~np.isnan(y_pred)
            if not drop_na_mask.all():  # There is a missing value
                if drop_na_mask.any():  # All values are missing
                    logger.debug(
                        f"No values in the test data for series: {s_id}. This will affect the test metrics."
                    )
                    continue
                logger.debug(
                    f"Missing values in the test data for series: {s_id}. This will affect the test metrics."
                )
                y_true = y_true[drop_na_mask]
                y_pred = y_pred[drop_na_mask]

            metrics_df = _build_metrics_df(
                y_true=y_true,
                y_pred=y_pred,
                series_id=s_id,
            )
            total_metrics = pd.concat([total_metrics, metrics_df], axis=1)

        if total_metrics.empty:
            return total_metrics, summary_metrics

        summary_metrics = pd.DataFrame(
            {
                SupportedMetrics.MEAN_SMAPE: np.mean(
                    total_metrics.loc[SupportedMetrics.SMAPE]
                ),
                SupportedMetrics.MEDIAN_SMAPE: np.median(
                    total_metrics.loc[SupportedMetrics.SMAPE]
                ),
                SupportedMetrics.MEAN_MAPE: np.mean(
                    total_metrics.loc[SupportedMetrics.MAPE]
                ),
                SupportedMetrics.MEDIAN_MAPE: np.median(
                    total_metrics.loc[SupportedMetrics.MAPE]
                ),
                SupportedMetrics.MEAN_RMSE: np.mean(
                    total_metrics.loc[SupportedMetrics.RMSE]
                ),
                SupportedMetrics.MEDIAN_RMSE: np.median(
                    total_metrics.loc[SupportedMetrics.RMSE]
                ),
                SupportedMetrics.MEAN_R2: np.mean(
                    total_metrics.loc[SupportedMetrics.R2]
                ),
                SupportedMetrics.MEDIAN_R2: np.median(
                    total_metrics.loc[SupportedMetrics.R2]
                ),
                SupportedMetrics.MEAN_EXPLAINED_VARIANCE: np.mean(
                    total_metrics.loc[SupportedMetrics.EXPLAINED_VARIANCE]
                ),
                SupportedMetrics.MEDIAN_EXPLAINED_VARIANCE: np.median(
                    total_metrics.loc[SupportedMetrics.EXPLAINED_VARIANCE]
                ),
                SupportedMetrics.ELAPSED_TIME: elapsed_time,
            },
            index=["All Targets"],
        )

        """Calculates Mean sMAPE, Median sMAPE, Mean MAPE, Median MAPE, Mean wMAPE, Median wMAPE values for each horizon
        if horizon <= 10."""
        if self.spec.horizon <= SUMMARY_METRICS_HORIZON_LIMIT:
            metrics_per_horizon = _build_metrics_per_horizon(
                test_data=data,
                output=self.forecast_output,
            )
            if not metrics_per_horizon.empty:
                summary_metrics = pd.concat([summary_metrics, metrics_per_horizon])

                new_column_order = [
                    SupportedMetrics.MEAN_SMAPE,
                    SupportedMetrics.MEDIAN_SMAPE,
                    SupportedMetrics.MEAN_MAPE,
                    SupportedMetrics.MEDIAN_MAPE,
                    SupportedMetrics.MEAN_WMAPE,
                    SupportedMetrics.MEDIAN_WMAPE,
                    SupportedMetrics.MEAN_RMSE,
                    SupportedMetrics.MEDIAN_RMSE,
                    SupportedMetrics.MEAN_R2,
                    SupportedMetrics.MEDIAN_R2,
                    SupportedMetrics.MEAN_EXPLAINED_VARIANCE,
                    SupportedMetrics.MEDIAN_EXPLAINED_VARIANCE,
                    SupportedMetrics.ELAPSED_TIME,
                ]
                summary_metrics = summary_metrics[new_column_order]

        return total_metrics, summary_metrics

    def _save_report(
        self,
        report_sections: Tuple,
        result_df: pd.DataFrame,
        metrics_df: pd.DataFrame,
        test_metrics_df: pd.DataFrame,
        test_data: pd.DataFrame,
        save_sub_reports: bool = False,
    ):
        """Saves resulting reports to the given folder. If save_sub_reports is True, use unique filenames."""

        unique_output_dir = self.spec.output_directory.url
        results = ForecastResults()

        storage_options = (
            default_signer()
            if ObjectStorageDetails.is_oci_path(unique_output_dir)
            else {}
        )

        def get_path(filename):
            path = os.path.join(unique_output_dir, filename)
            if save_sub_reports:
                return self._get_unique_filename(path, storage_options)
            return path

        # report-creator html report
        if self.spec.generate_report:
            with tempfile.TemporaryDirectory() as temp_dir:
                report_local_path = os.path.join(temp_dir, "___report.html")
                disable_print()
                with rc.ReportCreator(self.spec.report_title) as report:
                    report.save(rc.Block(*report_sections), report_local_path)
                enable_print()

                report_path = get_path(self.spec.report_filename)
                write_file(
                    local_filename=report_local_path,
                    remote_filename=report_path,
                    storage_options=storage_options,
                )

        # forecast csv report
        # todo: add test data into forecast.csv
        # if self.spec.test_data is not None:
        #     test_data_dict = test_data.get_dict_by_series()
        #     for series_id, test_data_values in test_data_dict.items():
        #         result_df[DataColumns.Series] = test_data_values[]
        result_df = (
            result_df
            if self.target_cat_col
            else result_df.drop(DataColumns.Series, axis=1)
        )
        if self.spec.generate_forecast_file:
            forecast_path = get_path(self.spec.forecast_filename)
            write_data(
                data=result_df,
                filename=forecast_path,
                format="csv",
                storage_options=storage_options,
            )
        results.set_forecast(result_df)

        # metrics csv report
        if self.spec.generate_metrics:
            metrics_col_name = (
                self.original_target_column
                if self.datasets.has_artificial_series()
                else "Series 1"
            )
            if metrics_df is not None:
                metrics_df_formatted = metrics_df.reset_index().rename(
                    {"index": "metrics", "Series 1": metrics_col_name}, axis=1
                )
                if self.spec.generate_metrics_file:
                    metrics_path = get_path(self.spec.metrics_filename)
                    write_data(
                        data=metrics_df_formatted,
                        filename=metrics_path,
                        format="csv",
                        storage_options=storage_options,
                        index=False,
                    )
                results.set_metrics(metrics_df_formatted)
            else:
                logger.warning(
                    f"Attempted to generate the {self.spec.metrics_filename} file with the training metrics, however the training metrics could not be properly generated."
                )

            # test_metrics csv report
            if self.datasets.test_data is not None:
                if test_metrics_df is not None:
                    test_metrics_df_formatted = test_metrics_df.reset_index().rename(
                        {"index": "metrics", "Series 1": metrics_col_name}, axis=1
                    )
                    if self.spec.generate_metrics_file:
                        test_metrics_path = get_path(self.spec.test_metrics_filename)
                        write_data(
                            data=test_metrics_df_formatted,
                            filename=test_metrics_path,
                            format="csv",
                            storage_options=storage_options,
                            index=False,
                        )
                    results.set_test_metrics(test_metrics_df_formatted)
                else:
                    logger.warning(
                        f"Attempted to generate the {self.spec.test_metrics_filename} file with the test metrics, however the test metrics could not be properly generated."
                    )

        # explanations csv reports
        if self.spec.generate_explanations:
            try:
                if not self.formatted_global_explanation.empty:
                    # Round to 4 decimal places before writing
                    global_expl_rounded = self.formatted_global_explanation.copy()
                    global_expl_rounded = global_expl_rounded.apply(
                        lambda col: np.round(col, 4)
                        if np.issubdtype(col.dtype, np.number)
                        else col
                    )
                    if self.spec.generate_explanation_files:
                        global_exp_path = get_path(
                            self.spec.global_explanation_filename
                        )
                        write_data(
                            data=global_expl_rounded,
                            filename=global_exp_path,
                            format="csv",
                            storage_options=storage_options,
                            index=True,
                        )
                    results.set_global_explanations(global_expl_rounded)
                else:
                    logger.warning(
                        f"Attempted to generate global explanations for the {self.spec.global_explanation_filename} file, but an issue occurred in formatting the explanations."
                    )

                if not self.formatted_local_explanation.empty:
                    # Round to 4 decimal places before writing
                    local_expl_rounded = self.formatted_local_explanation.copy()
                    local_expl_rounded = local_expl_rounded.apply(
                        lambda col: np.round(col, 4)
                        if np.issubdtype(col.dtype, np.number)
                        else col
                    )
                    if self.spec.generate_explanation_files:
                        local_exp_path = get_path(self.spec.local_explanation_filename)
                        write_data(
                            data=local_expl_rounded,
                            filename=local_exp_path,
                            format="csv",
                            storage_options=storage_options,
                            index=True,
                        )
                    results.set_local_explanations(local_expl_rounded)
                else:
                    logger.warning(
                        f"Attempted to generate local explanations for the {self.spec.local_explanation_filename} file, but an issue occurred in formatting the explanations."
                    )
            except AttributeError as e:
                logger.warning(
                    "Unable to generate explanations for this model type or for this dataset."
                )
                logger.debug(f"Got error: {e.args}")

        if self.spec.generate_model_parameters:
            # model params
            model_params_path = get_path("model_params.json")
            write_data(
                data=pd.DataFrame.from_dict(self.model_parameters),
                filename=model_params_path,
                format="json",
                storage_options=storage_options,
                index=True,
                indent=4,
            )
            results.set_model_parameters(self.model_parameters)

        # model pickle
        if self.spec.generate_model_pickle:
            pickle_path = get_path("model.pkl")
            write_pkl(
                obj=self.models,
                filename=os.path.basename(pickle_path),
                output_dir=os.path.dirname(pickle_path),
                storage_options=storage_options,
            )
            results.set_models(self.models)

        logger.info(
            f"The outputs have been successfully "
            f"generated and placed into the directory: {unique_output_dir}."
        )
        print(
            f"The outputs have been successfully generated and placed into the directory: {unique_output_dir}."
        )
        if self.errors_dict:
            errors_path = get_path(self.spec.errors_dict_filename)
            write_json(
                json_dict=self.errors_dict,
                filename=errors_path,
                storage_options=storage_options,
            )
            results.set_errors_dict(self.errors_dict)
        else:
            logger.info("All modeling completed successfully.")
        return results

    def preprocess(self, df, series_id):
        """The method that needs to be implemented on the particular model level."""
        data = df.rename(
            {self.dt_column_name: "ds", self.original_target_column: "y"}, axis=1
        )
        self.le[series_id], df_encoded = _label_encode_dataframe(
            data, no_encode={"ds", "y"}
        )
        return df_encoded

    @abstractmethod
    def _generate_report(self):
        """
        Generates the report for the particular model.
        The method that needs to be implemented on the particular model level.
        """

    @abstractmethod
    def _build_model(self) -> pd.DataFrame:
        """
        Build the model.
        The method that needs to be implemented on the particular model level.
        """

    def drop_horizon(self, df: pd.DataFrame) -> pd.DataFrame:
        return df.iloc[: -self.spec.horizon]

    def get_horizon(self, df: pd.DataFrame) -> pd.DataFrame:
        return df.iloc[-self.spec.horizon :]

    def generate_train_metrics(self) -> pd.DataFrame:
        """
        Generate Training Metrics when fitted data is not available.
        The method that needs to be implemented on the particular model level.
        """
        return evaluate_train_metrics(self.forecast_output)

    def _load_model(self):
        try:
            self.loaded_models = load_pkl(self.spec.previous_output_dir + "/model.pkl")
        except Exception as e:
            logger.info(f"model.pkl is not present. Error: {e}")

    def _save_model(self, output_dir, storage_options):
        write_pkl(
            obj=self.models,
            filename="model.pkl",
            output_dir=output_dir,
            storage_options=storage_options,
        )

    def _validate_automlx_explanation_mode(self):
        if (
            self.spec.model != SupportedModels.AutoMLX
            and self.spec.explanations_accuracy_mode == SpeedAccuracyMode.AUTOMLX
        ):
            raise ValueError(
                "AUTOMLX explanation accuracy mode is only supported for AutoMLX models. "
                "Please select mode other than AUTOMLX from the available explanations_accuracy_mode options"
                f"\nPlease refer to the troubleshooting guide at {TROUBLESHOOTING_GUIDE} for resolution steps."
            )

    @runtime_dependency(
        module="shap",
        err_msg=(
            "Please run `python3 -m pip install shap` to install the required dependencies for model explanation."
        ),
    )
    def explain_model(self):
        """
        Generates an explanation for the model by using the SHAP (Shapley Additive exPlanations) library.
        This function calculates the SHAP values for each feature in the dataset and stores the results in the `global_explanation` dictionary.

        Returns
        -------
            dict: A dictionary containing the global explanation for each feature in the dataset.
                    The keys are the feature names and the values are the average absolute SHAP values.
        """
        from shap import PermutationExplainer

        datetime_col_name = self.datasets._datetime_column_name

        exp_start_time = time.time()
        global_ex_time = 0
        local_ex_time = 0
        logger.info(
            f"Calculating explanations using {self.spec.explanations_accuracy_mode} mode"
        )
        ratio = SpeedAccuracyMode.ratio[self.spec.explanations_accuracy_mode]

        # validate the automlx mode is use for automlx model
        self._validate_automlx_explanation_mode()

        for s_id, data_i in self.datasets.get_data_by_series(
            include_horizon=False
        ).items():
            if s_id in self.models:
                try:
                    explain_predict_fn = self.get_explain_predict_fn(series_id=s_id)
                    data_trimmed = data_i.tail(
                        max(int(len(data_i) * ratio), 5)
                    ).reset_index(drop=True)
                    data_trimmed[datetime_col_name] = data_trimmed[
                        datetime_col_name
                    ].apply(lambda x: x.timestamp())

                    # Explainer fails when boolean columns are passed

                    _, data_trimmed_encoded = _label_encode_dataframe(
                        data_trimmed,
                        no_encode={datetime_col_name, self.original_target_column},
                    )

                    kernel_explnr = PermutationExplainer(
                        model=explain_predict_fn, masker=data_trimmed_encoded
                    )
                    kernel_explnr_vals = kernel_explnr.shap_values(data_trimmed_encoded)
                    exp_end_time = time.time()
                    global_ex_time = global_ex_time + exp_end_time - exp_start_time
                    self.local_explainer(
                        kernel_explnr,
                        series_id=s_id,
                        datetime_col_name=datetime_col_name,
                    )
                    local_ex_time = local_ex_time + time.time() - exp_end_time

                    if not len(kernel_explnr_vals):
                        logger.warning(
                            "No explanations generated. Ensure that additional data has been provided."
                        )
                    else:
                        self.global_explanation[s_id] = dict(
                            zip(
                                data_trimmed.columns[1:],
                                np.average(
                                    np.absolute(kernel_explnr_vals[:, 1:]), axis=0
                                ),
                            )
                        )
                except Exception as e:
                    if s_id in self.errors_dict:
                        self.errors_dict[s_id]["explainer_error"] = str(e)
                        self.errors_dict[s_id]["explainer_error_trace"] = (
                            traceback.format_exc()
                        )
                    else:
                        self.errors_dict[s_id] = {
                            "model_name": self.spec.model,
                            "explainer_error": str(e),
                            "explainer_error_trace": traceback.format_exc(),
                        }
            else:
                logger.warning(
                    f"Skipping explanations for {s_id}, as forecast was not generated."
                )

        logger.info(
            "Global explanations generation completed in %s seconds", global_ex_time
        )
        logger.info(
            "Local explanations generation completed in %s seconds", local_ex_time
        )

    def local_explainer(self, kernel_explainer, series_id, datetime_col_name) -> None:
        """
        Generate local explanations using a kernel explainer.

        Parameters
        ----------
            kernel_explainer: The kernel explainer object to use for generating explanations.
        """
        data = self.datasets.get_horizon_at_series(s_id=series_id)
        # columns that were dropped in train_model in arima, should be dropped here as well
        data[datetime_col_name] = datetime_to_seconds(data[datetime_col_name])
        data = data.reset_index(drop=True)

        # Explainer fails when boolean columns are passed
        _, data = _label_encode_dataframe(
            data, no_encode={datetime_col_name, self.original_target_column}
        )
        # Generate local SHAP values using the kernel explainer
        local_kernel_explnr_vals = kernel_explainer.shap_values(data)

        # Convert the SHAP values into a DataFrame
        local_kernel_explnr_df = pd.DataFrame(
            local_kernel_explnr_vals, columns=data.columns
        )

        # Add date column to local explanation DataFrame
        local_kernel_explnr_df[ForecastOutputColumns.DATE] = (
            self.datasets.get_horizon_at_series(s_id=series_id)[
                self.spec.datetime_column.name
            ].reset_index(drop=True)
        )
        self.local_explanation[series_id] = local_kernel_explnr_df

    def get_explain_predict_fn(self, series_id, fcst_col_name="yhat"):
        def _custom_predict(
            data,
            model=self.models[series_id]["model"],
            dt_column_name=self.datasets._datetime_column_name,
        ):
            """
            data: ForecastDatasets.get_data_at_series(s_id)
            """
            data[dt_column_name] = seconds_to_datetime(
                data[dt_column_name], dt_format=self.spec.datetime_column.format
            )
            data = self.preprocess(df=data, series_id=series_id)
            data[self.original_target_column] = None
            fcst = model.predict(data)[fcst_col_name]
            return fcst

        return _custom_predict

    def _get_unique_filename(self, base_path: str, storage_options: dict = None) -> str:
        """Generate a unique filename by appending a sequential number if file exists.

        Args:
            base_path: The original file path to check
            storage_options: Optional storage options for OCI paths

        Returns:
            A unique file path that doesn't exist
        """
        if not ObjectStorageDetails.is_oci_path(base_path):
            # For local files
            directory = os.path.dirname(base_path)
            basename = os.path.basename(base_path)
            name, ext = os.path.splitext(basename)

            model_suffix = "_" + self.spec.model
            new_name = f"{name}{model_suffix}"
            new_path = os.path.join(directory, f"{new_name}{ext}")
            counter = 1
            while os.path.exists(new_path):
                new_path = os.path.join(directory, f"{new_name}_{counter}{ext}")
                counter += 1
            return new_path
        else:
            # For OCI paths, we need to list objects and check
            try:
                from oci.object_storage import ObjectStorageClient

                client = ObjectStorageClient(config=storage_options)

                # Parse OCI path components
                bucket_name = ObjectStorageDetails.get_bucket_name(base_path)
                namespace = ObjectStorageDetails.get_namespace(base_path)
                object_name = ObjectStorageDetails.get_object_name(base_path)

                name, ext = os.path.splitext(object_name)

                model_suffix = "_" + self.spec.model
                new_name = f"{name}{model_suffix}"
                new_object_name = f"{new_name}{ext}"
                counter = 1
                while True:
                    try:
                        # Try to head the object to see if it exists
                        client.head_object(namespace, bucket_name, new_object_name)
                        # If we get here, the object exists
                        new_object_name = f"{new_name}_{counter}{ext}"
                        counter += 1
                    except:
                        # Object doesn't exist, we can use this name
                        break

                # Reconstruct the full path
                return ObjectStorageDetails.get_path(
                    namespace, bucket_name, new_object_name
                )
            except Exception as e:
                logger.warning(
                    f"Error checking OCI path existence: {e}. Using original path."
                )
                return base_path

    def generate_explanation_report_from_data(self) -> tuple[rc.Block, rc.Block]:
        if not self.target_cat_col:
            self.formatted_global_explanation = (
                self.formatted_global_explanation.rename(
                    {"Series 1": self.original_target_column},
                    axis=1,
                )
            )
            self.formatted_local_explanation.drop(
                "Series", axis=1, inplace=True
            )

        # Create a markdown section for the global explainability
        global_explanation_section = rc.Block(
            rc.Heading("Global Explainability", level=2),
            rc.Text(
                "The following tables provide the feature attribution for the global explainability."
            ),
            rc.DataTable(self.formatted_global_explanation, index=True),
        )

        blocks = [
            rc.DataTable(
                local_ex_df.drop("Series", axis=1),
                label=s_id if self.target_cat_col else None,
                index=True,
            )
            for s_id, local_ex_df in self.local_explanation.items()
        ]
        local_explanation_section = rc.Block(
            rc.Heading("Local Explanation of Models", level=2),
            rc.Select(blocks=blocks) if len(blocks) > 1 else blocks[0],
        )
        return global_explanation_section, local_explanation_section