from __future__ import annotations
from datetime import datetime, timedelta
import io
import json
from typing import Dict, List, Optional, Union
import httpx
import pandas as pd
from lexsi_sdk.core.alert import Alert
from lexsi_sdk.common.constants import BIAS_MONITORING_DASHBOARD_REQUIRED_FIELDS, DATA_DRIFT_DASHBOARD_REQUIRED_FIELDS, DATA_DRIFT_STAT_TESTS, MODEL_PERF_DASHBOARD_REQUIRED_FIELDS, MODEL_TYPES, SYNTHETIC_MODELS_DEFAULT_HYPER_PARAMS, TARGET_DRIFT_DASHBOARD_REQUIRED_FIELDS, TARGET_DRIFT_STAT_TESTS
from lexsi_sdk.common.monitoring import BiasMonitoringPayload, DataDriftPayload, ModelPerformancePayload, TargetDriftPayload
from lexsi_sdk.common.types import CatBoostParams, DataConfig, FoundationalModelParams, InferenceCompute, LightGBMParams, PEFTParams, ProcessorParams, ProjectConfig, RandomForestParams, SyntheticDataConfig, SyntheticModelHyperParams, TuningParams, XGBoostParams
from lexsi_sdk.common.utils import normalize_time, poll_events
from lexsi_sdk.common.validation import Validate
from lexsi_sdk.common.xai_uris import ALL_DATA_FILE_URI, AVAILABLE_BATCH_SERVERS_URI, AVAILABLE_SYNTHETIC_CUSTOM_SERVERS_URI, CASE_DTREE_URI, CASE_INFO_TEXT_URI, CASE_INFO_URI, CREATE_OBSERVATION_URI, CREATE_POLICY_URI, CREATE_SYNTHETIC_PROMPT_URI, DELETE_CASE_URI, DELETE_SYNTHETIC_MODEL_URI, DELETE_SYNTHETIC_TAG_URI, DOWNLOAD_DASHBOARD_LOGS_URI, DOWNLOAD_SYNTHETIC_DATA_URI, DOWNLOAD_TAG_DATA_URI, DUPLICATE_OBSERVATION_URI, DUPLICATE_POLICY_URI, GENERATE_DASHBOARD_URI, GET_CASES_URI, GET_DASHBOARD_SCORE_URI, GET_DATA_DIAGNOSIS_URI, GET_DATA_DRIFT_DIAGNOSIS_URI, GET_DATA_SUMMARY_URI, GET_FEATURE_IMPORTANCE_URI, GET_LABELS_URI, GET_MODELS_URI, GET_OBSERVATION_PARAMS_URI, GET_OBSERVATIONS_URI, GET_POLICIES_URI, GET_POLICY_PARAMS_URI, GET_PROJECT_CONFIG, GET_SYNTHETIC_DATA_TAGS_URI, GET_SYNTHETIC_MODEL_DETAILS_URI, GET_SYNTHETIC_MODEL_PARAMS_URI, GET_SYNTHETIC_MODELS_URI, GET_SYNTHETIC_PROMPT_URI, LIST_DATA_CONNECTORS, MODEL_INFERENCE_SETTINGS_URI, MODEL_INFERENCES_URI, MODEL_PARAMETERS_URI, MODEL_SUMMARY_URI, PROJECT_OVERVIEW_TEXT_URI, RUN_DATA_DRIFT_DIAGNOSIS_URI, RUN_MODEL_ON_DATA_URI, SEARCH_CASE_URI, TABULAR_ML, TEXT_MODEL_INFERENCE_SETTINGS_URI, TRAIN_MODEL_URI, TRAIN_SYNTHETIC_MODEL_URI, UPDATE_ACTIVE_INFERENCE_MODEL_URI, UPDATE_OBSERVATION_URI, UPDATE_POLICY_URI, UPDATE_SYNTHETIC_PROMPT_URI, UPLOAD_DATA_FILE_URI, UPLOAD_DATA_PROJECT_URI, UPLOAD_DATA_URI, UPLOAD_FILE_DATA_CONNECTORS, AVAILABLE_BATCH_SERVERS_URI, CREATE_TRIGGER_URI, DASHBOARD_LOGS_URI, DELETE_TRIGGER_URI, DUPLICATE_MONITORS_URI, EXECUTED_TRIGGER_URI, GENERATE_DASHBOARD_URI, GET_DASHBOARD_SCORE_URI, GET_DASHBOARD_URI, GET_EXECUTED_TRIGGER_INFO, GET_MODEL_TYPES_URI, GET_MODELS_URI, GET_MONITORS_ALERTS, GET_PROJECT_CONFIG, GET_TRIGGERS_URI, LIST_DATA_CONNECTORS, MODEL_PARAMETERS_URI, MODEL_PERFORMANCE_DASHBOARD_URI, UPLOAD_DATA_FILE_INFO_URI, UPLOAD_DATA_FILE_URI, UPLOAD_DATA_URI, UPLOAD_DATA_WITH_CHECK_URI, UPLOAD_FILE_DATA_CONNECTORS, UPLOAD_MODEL_URI, EXPLAINABILITY_SUMMARY, GET_TRIGGERS_DAYS_URI
from lexsi_sdk.core.dashboard import DASHBOARD_TYPES, Dashboard
from lexsi_sdk.core.project import Project, build_expression, generate_expression, validate_configuration
from lexsi_sdk.core.synthetic import SyntheticDataTag, SyntheticModel, SyntheticPrompt
from lexsi_sdk.core.utils import build_list_data_connector_url
from pydantic import BaseModel, ConfigDict
import plotly.graph_objects as go
from IPython.display import SVG, display
from lexsi_sdk.client.client import APIClient

class TabularProject(Project):
    """Tabular Project class extending the base Project class with tabular-specific methods."""

    def config(self) -> str:
        """Retrieve the full configuration of the project, including feature selections, encodings, and tags. Returns a dictionary.

        :return: response
        """
        res = self.api_client.get(
            f"{GET_PROJECT_CONFIG}?project_name={self.project_name}"
        )
        if res.get("details") != "Not Found":
            res["details"].pop("updated_by", None)
            res["details"]["metadata"].pop("path", None)
            res["details"]["metadata"].pop("avaialble_tags", None)

        return res.get("details")

    def get_labels(self, feature_name: str) -> list:
        """Get the unique values of a particular feature (column) in the dataset. Useful for enumerated categorical values.

        :param feature_name: feature name
        :return: unique values of feature
        """
        res = self.api_client.get(
            f"{GET_LABELS_URI}"
            f"?project_name={self.project_name}"
            f"&feature_name={feature_name}"
        )

        if not res.get("success"):
            raise Exception(res.get("details"))

        return res["labels"]
    
    def file_summary(self, file_name: str) -> pd.DataFrame:
        """Return a summary (e.g., preview) of a file uploaded to the project. Accepts the file name and returns a DataFrame summarizing its contents.

        :param file_name: user uploaded file name
        :return: file summary dataframe
        """
        files = self.api_client.get(
            f"{ALL_DATA_FILE_URI}?project_name={self.project_name}"
        )

        if not files.get("details"):
            raise Exception("Please upload files first")

        file_data = next(
            filter(
                lambda file: file["filepath"].split("/")[-1] == file_name,
                files["details"],
            ),
            None,
        )

        if not file_data:
            raise Exception("File Not Found, please pass valid file name")

        file_metadata = {
            "file_size_mb": file_data["metadata"]["file_size_mb"],
            "columns": file_data["metadata"]["columns"],
            "rows": file_data["metadata"]["rows"],
        }

        print(file_metadata)

        file_summary_df = pd.DataFrame(file_data["metadata"]["details"])

        return file_summary_df

    # def update_config(self, compute_type: str, config: DataConfig) -> str:
    #     """Update the project configurations. Accepts a config dictionary.
    #     :param compute_type: compute type to run model training
    #     :param config: updated config
    #                 {
    #                     "tags": List[str]
    #                     "feature_exclude": List[str]
    #                     "feature_encodings": Dict[str, str]   # {"feature_name":"labelencode | countencode"}
    #                     "drop_duplicate_uid": bool
    #                 },

    #     :return: response
    #     """
    #     if not config:
    #         raise Exception("Please upload config")

    #     project_config = self.config()

    #     if project_config == "Not Found":
    #         raise Exception("Config does not exist, please upload files first")

    #     available_tags = self.available_tags()

    #     if config.get("tags"):
    #         Validate.value_against_list(
    #             "tags", config["tags"], available_tags.get("user_tags")
    #         )

    #     all_unique_features = [
    #         *project_config["metadata"]["feature_exclude"],
    #         *project_config["metadata"]["feature_include"],
    #     ]

    #     if config.get("feature_exclude"):
    #         Validate.value_against_list(
    #             "feature_exclude",
    #             config["feature_exclude"],
    #             all_unique_features,
    #         )

    #     if config.get("feature_encodings"):
    #         Validate.value_against_list(
    #             "feature_encodings_feature",
    #             list(config["feature_encodings"].keys()),
    #             list(project_config["metadata"]["feature_encodings"].keys()),
    #         )
    #         Validate.value_against_list(
    #             "feature_encodings_feature",
    #             list(config["feature_encodings"].values()),
    #             ["labelencode", "countencode"],
    #         )

    #     if config.get("feature_exclude") is None:
    #         feature_exclude = project_config["metadata"]["feature_exclude"]
    #     else:
    #         feature_exclude = config.get("feature_exclude", [])

    #     feature_include = [
    #         feature for feature in all_unique_features if feature not in feature_exclude
    #     ]

    #     feature_encodings = (
    #         config.get("feature_encodings")
    #         or project_config["metadata"]["feature_encodings"]
    #     )

    #     drop_duplicate_uid = (
    #         config.get("drop_duplicate_uid")
    #         or project_config["metadata"]["drop_duplicate_uid"]
    #     )

    #     tags = config.get("tags") or project_config["metadata"]["tags"]

    #     payload = {
    #         "project_name": self.project_name,
    #         "project_type": project_config["project_type"],
    #         "unique_identifier": project_config["unique_identifier"],
    #         "true_label": project_config["true_label"],
    #         "pred_label": project_config.get("pred_label"),
    #         "instance_type": compute_type,
    #         "config_update": True,
    #         "metadata": {
    #             "feature_include": feature_include,
    #             "feature_exclude": feature_exclude,
    #             "feature_encodings": feature_encodings,
    #             "drop_duplicate_uid": drop_duplicate_uid,
    #             "tags": tags,
    #         },
    #     }

    #     print("Config :-")
    #     print(json.dumps(payload["metadata"], indent=1))

    #     res = self.api_client.post(TRAIN_MODEL_URI, payload)

    #     if not res["success"]:
    #         raise Exception(res.get("details", "Failed to update config"))

    #     poll_events(self.api_client, self.project_name, res.get("event_id"))

    
    def upload_data(
        self,
        data: str | pd.DataFrame,
        tag: str,
        config: Optional[ProjectConfig] = None,
        model_config: Optional[Union[XGBoostParams, LightGBMParams, CatBoostParams, RandomForestParams, FoundationalModelParams]] = None,
        tunning_config: Optional[TuningParams] = None,
        peft_config: Optional[PEFTParams] = None,
        processor_config: Optional[ProcessorParams] = None,
        finetune_mode: Optional[str] = None,
        tunning_strategy: Optional[str] = None,
        compute_type: Optional[str] = None
    ) -> str:
        """
        Upload dataset(s) to the project and triggers model training.
        If a model is specified, it trains the requested model; otherwise,
        an ``XGBoost`` model is trained by default.

        It executes the full end-to-end training pipeline:

        - dataset upload (tag-based, feature exclusion, sampling)
        - selects and prepares data (filtering, sampling, feature handling, imbalance handling)
        - applies preprocessing / feature engineering (optional)
        - trains either a **classic ML model** or a **tabular foundation model**
        - optionally performs hyperparameter tuning (classic or foundational depending on strategy)
        - optionally performs fine-tuning / PEFT for foundation models
        - produces a trained model artifact and returns its identifier/reference

        :param data: Dataset to upload. Can be a file path or an in-memory pandas DataFrame.
        :type data: str | pandas.DataFrame

        :param tag: Tag associated with the uploaded dataset, used for filtering
            and train/test selection.
        :type tag: str

        :param config: Dataset and training configuration controlling feature
            selection, encodings, sampling, and data behavior.
        :type config: ProjectConfig | None

        :param processor_config: Optional preprocessing and feature engineering
            configuration (e.g., imputation, scaling, resampling).
        :type processor_config: ProcessorParams | None

        :param model_config: Hyperparameters for the selected ``model_type``.
            Must match the chosen model family.
        :type model_config: XGBoostParams | LightGBMParams | CatBoostParams |
            RandomForestParams | FoundationalModelParams | None

        :param tunning_config: Optional tuning or adaptation configuration.
        :type tunning_config: TuningParams | None

        :param tunning_strategy: Training or fine-tuning strategy.

            - ``"inference"``: Zero-shot inference only
            - ``"base-ft"`` / ``"finetune"``: Full fine-tuning
            - ``"peft"``: Parameter-efficient fine-tuning (requires ``peft_config``)
        :type tunning_strategy: str | None

        :param finetune_mode: Fine-tuning mode for foundation models.

            - ``"meta-learning"``: Episodic meta-learning
            - ``"sft"``: Standard supervised fine-tuning
        :type finetune_mode: str | None

        :param peft_config: PEFT (e.g., LoRA) configuration, used when
            ``tunning_strategy="peft"``.
        :type peft_config: PEFTParams | None

        :param compute_type: Compute instance used for training.
            Examples: ``"shared"``, ``"small"``, ``"medium"``, ``"large"``,
            ``"T4.small"``, ``"A10G.xmedium"``.
        :type compute_type: str | None

        :return: Identifier or reference to the trained model artifact.
        :rtype: str
        """

        def build_upload_data(data):
            """Build a multipart-upload payload from a file path or DataFrame.
            Converts DataFrames to an in-memory CSV buffer and returns a `(filename, bytes)` tuple.

            :param data: Local file path or a pandas DataFrame.
            :return: A file handle (path input) or `(filename, bytes)` tuple (DataFrame input).
            """
            if isinstance(data, str):
                file = open(data, "rb")
                return file
            elif isinstance(data, pd.DataFrame):
                csv_buffer = io.BytesIO()
                data.to_csv(csv_buffer, index=False, encoding="utf-8")
                csv_buffer.seek(0)
                file_name = f"{tag}_sdk_{datetime.now().replace(microsecond=0)}.csv"
                file = (file_name, csv_buffer.getvalue())
                return file
            else:
                raise Exception("Invalid Data Type")

        def upload_file_and_return_path(data, data_type, tag=None) -> str:
            """Upload a data/model artifact to Lexsi file storage.
            Returns the server-side `filepath` that other project APIs reference.

            :param data: File path or DataFrame to upload.
            :param data_type: Upload type such as `data`, `model`, etc.
            :param tag: Optional tag to associate with the upload.
            :return: Server-side filepath for the uploaded artifact."""
            files = {"in_file": build_upload_data(data)}
            res = self.api_client.file(
                f"{UPLOAD_DATA_FILE_URI}?project_name={self.project_name}&data_type={data_type}&tag={tag}",
                files,
            )

            if not res["success"]:
                raise Exception(res.get("details"))
            uploaded_path = res.get("metadata").get("filepath")

            return uploaded_path

        project_config = self.config()

        if project_config == "Not Found":
            if not config:
                config = {
                    "project_type": "",
                    "unique_identifier": "",
                    "true_label": "",
                    "pred_label": "",
                    "feature_exclude": [],
                    "drop_duplicate_uid": False,
                    "handle_errors": False,
                    "handle_data_imbalance": False,
                }
                raise Exception(
                    f"Project Config is required, since no config is set for project \n {json.dumps(config,indent=1)}"
                )

            Validate.check_for_missing_keys(
                config, ["project_type", "unique_identifier", "true_label"]
            )

            Validate.value_against_list(
                "project_type", config, ["classification", "regression"]
            )

            uploaded_path = upload_file_and_return_path(data, "data", tag)

            file_info = self.api_client.post(
                UPLOAD_DATA_FILE_INFO_URI, {"path": uploaded_path}
            )

            column_names = file_info.get("details").get("column_names")

            Validate.value_against_list(
                "unique_identifier",
                config["unique_identifier"],
                column_names,
                lambda: self.delete_file(uploaded_path),
            )

            if config.get("feature_exclude"):
                Validate.value_against_list(
                    "feature_exclude",
                    config["feature_exclude"],
                    column_names,
                    lambda: self.delete_file(uploaded_path),
                )

            feature_exclude = [
                config["unique_identifier"],
                config["true_label"],
                *config.get("feature_exclude", []),
            ]

            feature_include = [
                feature
                for feature in column_names
                if feature not in feature_exclude
            ]

            feature_encodings = config.get("feature_encodings", {})
            if feature_encodings:
                Validate.value_against_list(
                    "feature_encodings_feature",
                    list(feature_encodings.keys()),
                    column_names,
                )
                Validate.value_against_list(
                    "feature_encodings_feature",
                    list(feature_encodings.values()),
                    ["labelencode", "countencode", "onehotencode"],
                )
            custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
            available_custom_batch_servers = custom_batch_servers.get("details", []) + custom_batch_servers.get("available_gpu_custom_servers", [])
            
            if config.get("model_name") and config.get("model_name") in ["TabPFN","TabICL","TabDPT","OrionMSP", "OrionBix","Mitra", "ContextTab"] and not compute_type:
                valid_list = [
                    server["instance_name"]
                    for server in available_custom_batch_servers
                ]
                self.delete_file(uploaded_path)
                raise Exception(f"For Foundational models compute_type is mandatory. select from \n {valid_list}")

            if tunning_strategy != "inference" and compute_type and "gova" not in compute_type:
                Validate.value_against_list(
                    "pod",
                    compute_type,
                    [
                        server["instance_name"]
                        for server in available_custom_batch_servers
                    ],
                )

            payload = {
                "project_name": self.project_name,
                "project_type": config["project_type"],
                "unique_identifier": config["unique_identifier"],
                "true_label": config["true_label"],
                "pred_label": config.get("pred_label"),
                "metadata": {
                    "path": uploaded_path,
                    "tag": tag,
                    "tags": [tag],
                    "drop_duplicate_uid": config.get("drop_duplicate_uid"),
                    "handle_errors": config.get("handle_errors", False),
                    "feature_exclude": feature_exclude,
                    "feature_include": feature_include,
                    "feature_encodings": feature_encodings,
                    "feature_actual_used": [],
                    "handle_data_imbalance": config.get(
                        "handle_data_imbalance", False
                    ),
                },
                # "gpu": gpu,
                "instance_type": compute_type,
                "sample_percentage": config.get("sample_percentage", None),
            }
            if config.get("model_name"):
                payload["metadata"]["model_name"] = config.get("model_name")

            if config.get("xai_method"):
                payload["metadata"]["explainability_method"] = config.get(
                    "xai_method"
                )
            if model_config:
                payload["metadata"]["model_parameters"] = model_config
            if tunning_config:
                payload["metadata"]["tunning_parameters"] = tunning_config
            if peft_config:
                payload["metadata"]["peft_parameters"] = peft_config
            if processor_config:
                payload["metadata"]["processor_parameters"] = processor_config
            if finetune_mode:
                payload["metadata"]["finetune_mode"] = finetune_mode
            if tunning_strategy:
                payload["metadata"]["tunning_strategy"] = tunning_strategy
            res = self.api_client.post(UPLOAD_DATA_WITH_CHECK_URI, payload)

            if not res["success"]:
                self.delete_file(uploaded_path)
                raise Exception(res.get("details"))
            try:
                poll_events(self.api_client, self.project_name, res["event_id"])
            except Exception as e:
                self.delete_file(uploaded_path)
                raise e
            return res.get("details")

        if project_config != "Not Found" and config:
            raise Exception("Config already exists, please remove config")

        uploaded_path = upload_file_and_return_path(data, "data", tag)

        payload = {
            "path": uploaded_path,
            "tag": tag,
            "type": "data",
            "project_name": self.project_name,
        }
        res = self.api_client.post(UPLOAD_DATA_URI, payload)

        if not res["success"]:
            self.delete_file(uploaded_path)
            raise Exception(res.get("details"))

        return res.get("details")
    
    def upload_data_dataconnectors(
        self,
        data_connector_name: str,
        tag: str,
        bucket_name: Optional[str] = None,
        file_path: str = None,
        config: Optional[ProjectConfig] = None,
        model_config: Optional[Union[XGBoostParams, LightGBMParams, CatBoostParams, RandomForestParams, FoundationalModelParams]] = None,
        tunning_config: Optional[TuningParams] = None,
        peft_config: Optional[PEFTParams] = None,
        processor_config: Optional[ProcessorParams] = None,
        finetune_mode: Optional[str] = None,
        tunning_strategy: Optional[str] = None,
        compute_type: Optional[str] = None
    ) -> str:
        """Uploads data for the current project with data connectors
        :param data_connector_name: name of the data connector
        :param tag: tag for data
        :param bucket_name: if data connector has buckets # Example: s3/gcs buckets
        :param file_path: filepath from the bucket for the data to read
        :param config: project config
                {
                    "project_type": "",
                    "unique_identifier": "",
                    "true_label": "",
                    "pred_label": "",
                    "feature_exclude": [],
                    "drop_duplicate_uid: "",
                    "handle_errors": False,
                    "feature_encodings": Dict[str, str]   # {"feature_name":"labelencode | countencode | onehotencode"}
                },
                defaults to None
        
        :param processor_config: Optional preprocessing and feature engineering
            configuration (e.g., imputation, scaling, resampling).
        :type processor_config: ProcessorParams | None

        :param model_config: Hyperparameters for the selected ``model_type``.
            Must match the chosen model family.
        :type model_config: XGBoostParams | LightGBMParams | CatBoostParams |
            RandomForestParams | FoundationalModelParams | None

        :param tunning_config: Optional tuning or adaptation configuration.
        :type tunning_config: TuningParams | None

        :param tunning_strategy: Training or fine-tuning strategy.

            - ``"inference"``: Zero-shot inference only
            - ``"base-ft"`` / ``"finetune"``: Full fine-tuning
            - ``"peft"``: Parameter-efficient fine-tuning (requires ``peft_config``)
        :type tunning_strategy: str | None

        :param finetune_mode: Fine-tuning mode for foundation models.

            - ``"meta-learning"``: Episodic meta-learning
            - ``"sft"``: Standard supervised fine-tuning
        :type finetune_mode: str | None

        :param peft_config: PEFT (e.g., LoRA) configuration, used when
            ``tunning_strategy="peft"``.
        :type peft_config: PEFTParams | None

        :param compute_type: Compute instance used for training.
            Examples: ``"shared"``, ``"small"``, ``"medium"``, ``"large"``,
            ``"T4.small"``, ``"A10G.xmedium"``.
        :type compute_type: str | None
        :return: response
        """
        print("Preparing Data Upload")

        def get_connector() -> str | pd.DataFrame:
            """Look up the configured data connector by name.
            Returns a one-row DataFrame (or an error string) with connector metadata."""
            url = build_list_data_connector_url(
                LIST_DATA_CONNECTORS, self.project_name, self.organization_id
            )
            res = self.api_client.post(url)

            if res["success"]:
                df = pd.DataFrame(res["details"])
                filtered_df = df.loc[df["link_service_name"] == data_connector_name]
                if filtered_df.empty:
                    return "No data connector found"
                return filtered_df

            return res["details"]

        connectors = get_connector()
        if isinstance(connectors, pd.DataFrame):
            value = connectors.loc[
                connectors["link_service_name"] == data_connector_name,
                "link_service_type",
            ].values[0]
            ds_type = value

            if ds_type == "s3" or ds_type == "gcs":
                if not bucket_name:
                    return "Missing argument bucket_name"
                if not file_path:
                    return "Missing argument file_path"
        else:
            return connectors

        def upload_file_and_return_path(file_path, data_type, tag=None) -> str:
            """Trigger a connector-to-Lexsi upload for a file path.
            Returns the stored `filepath` in Lexsi storage to be referenced by other APIs.

            :param file_path: Source path in the connector (bucket/object path, sftp path, etc.).
            :param data_type: Upload type such as `data`, `model`, etc.
            :param tag: Optional tag to associate with the upload.
            :return: Server-side filepath for the uploaded artifact."""
            if not self.project_name:
                return "Missing Project Name"
            query_params = f"project_name={self.project_name}&link_service_name={data_connector_name}&data_type={data_type}&tag={tag}&bucket_name={bucket_name}&file_path={file_path}"
            if self.organization_id:
                query_params += f"&organization_id={self.organization_id}"
            res = self.api_client.post(f"{UPLOAD_FILE_DATA_CONNECTORS}?{query_params}")
            if not res["success"]:
                raise Exception(res.get("details"))
            uploaded_path = res.get("metadata").get("filepath")

            return uploaded_path

        project_config = self.config()

        if project_config == "Not Found":
            if not config.get("project_type"):
                config["project_type"] = self.metadata.get("project_type")
            if not config:
                config = {
                    "project_type": "",
                    "unique_identifier": "",
                    "true_label": "",
                    "pred_label": "",
                    "feature_exclude": [],
                    "drop_duplicate_uid": False,
                    "handle_errors": False,
                }
                raise Exception(
                    f"Project Config is required, since no config is set for project \n {json.dumps(config,indent=1)}"
                )

            Validate.check_for_missing_keys(
                config, ["project_type", "unique_identifier", "true_label"]
            )

            Validate.value_against_list(
                "project_type", config, ["classification", "regression"]
            )

            uploaded_path = upload_file_and_return_path(file_path, "data", tag)

            file_info = self.api_client.post(
                UPLOAD_DATA_FILE_INFO_URI, {"path": uploaded_path}
            )

            column_names = file_info.get("details").get("column_names")

            Validate.value_against_list(
                "unique_identifier",
                config["unique_identifier"],
                column_names,
                lambda: self.delete_file(uploaded_path),
            )

            if config.get("feature_exclude"):
                Validate.value_against_list(
                    "feature_exclude",
                    config["feature_exclude"],
                    column_names,
                    lambda: self.delete_file(uploaded_path),
                )

            feature_exclude = [
                config["unique_identifier"],
                config["true_label"],
                *config.get("feature_exclude", []),
            ]

            feature_include = [
                feature
                for feature in column_names
                if feature not in feature_exclude
            ]

            feature_encodings = config.get("feature_encodings", {})
            if feature_encodings:
                Validate.value_against_list(
                    "feature_encodings_feature",
                    list(feature_encodings.keys()),
                    column_names,
                )
                Validate.value_against_list(
                    "feature_encodings_feature",
                    list(feature_encodings.values()),
                    ["labelencode", "countencode", "onehotencode"],
                )

            custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
            available_custom_batch_servers = custom_batch_servers.get("details", []) + custom_batch_servers.get("available_gpu_custom_servers", [])
            
            if config.get("model_name") and config.get("model_name") in ["TabPFN","TabICL","TabDPT","OrionMSP", "OrionBix","Mitra", "ContextTab"] and not compute_type:
                valid_list = [
                    server["instance_name"]
                    for server in available_custom_batch_servers
                ]
                self.delete_file(uploaded_path)
                raise Exception(f"For Foundational models compute_type is mandatory. select from \n {valid_list}")

            if tunning_strategy != "inference" and compute_type and "gova" not in compute_type:
                Validate.value_against_list(
                    "pod",
                    compute_type,
                    [
                        server["instance_name"]
                        for server in available_custom_batch_servers
                    ],
                )

            payload = {
                "project_name": self.project_name,
                "project_type": config["project_type"],
                "unique_identifier": config["unique_identifier"],
                "true_label": config["true_label"],
                "pred_label": config.get("pred_label"),
                "metadata": {
                    "path": uploaded_path,
                    "tag": tag,
                    "tags": [tag],
                    "drop_duplicate_uid": config.get("drop_duplicate_uid"),
                    "handle_errors": config.get("handle_errors", False),
                    "feature_exclude": feature_exclude,
                    "feature_include": feature_include,
                    "feature_encodings": feature_encodings,
                    "feature_actual_used": [],
                },
                "instance_type": compute_type
            }
            if config.get("model_name"):
                payload["metadata"]["model_name"] = config.get("model_name")
            if model_config:
                payload["metadata"]["model_parameters"] = model_config
            if tunning_config:
                payload["metadata"]["tunning_parameters"] = tunning_config
            if peft_config:
                payload["metadata"]["peft_parameters"] = peft_config
            if processor_config:
                payload["metadata"]["processor_parameters"] = processor_config
            if finetune_mode:
                payload["metadata"]["finetune_mode"] = finetune_mode
            if tunning_strategy:
                payload["metadata"]["tunning_strategy"] = tunning_strategy

            res = self.api_client.post(UPLOAD_DATA_WITH_CHECK_URI, payload)

            if not res["success"]:
                self.delete_file(uploaded_path)
                raise Exception(res.get("details"))

            poll_events(self.api_client, self.project_name, res["event_id"])

            return res.get("details")

        if project_config != "Not Found" and config:
            raise Exception("Config already exists, please remove config")

        uploaded_path = upload_file_and_return_path(file_path, "data", tag)

        payload = {
            "path": uploaded_path,
            "tag": tag,
            "type": "data",
            "project_name": self.project_name,
        }
        res = self.api_client.post(UPLOAD_DATA_URI, payload)

        if not res["success"]:
            self.delete_file(uploaded_path)
            raise Exception(res.get("details"))

        return res.get("details")


    def upload_model_types(self) -> dict:
        """Model types which can be uploaded using upload_model()

        :return: response
        """
        model_types = self.api_client.get(GET_MODEL_TYPES_URI)

        return model_types

    def upload_model(
        self,
        model_path: str,
        model_architecture: str,
        model_type: str,
        model_name: str,
        model_train: list,
        model_test: Optional[list],
        pod: Optional[str] = None,
        xai_method: Optional[list] = ["shap"],
        feature_list: Optional[list] = None,
    ):
        """Uploads a custom trained model to Lexsi.ai for inference and evaluation.

        :param model_path: path of the model
        :param model_architecture: architecture of model ["machine_learning", "deep_learning"]
        :param model_type: type of the model based on the architecture ["Xgboost","Lgboost","CatBoost","Random_forest","Linear_Regression","Logistic_Regression","Gaussian_NaiveBayes","SGD"]
                use upload_model_types() method to get all allowed model_types
        :param model_name: name of the model
        :param model_train: data tags for model
        :param model_test: test tags for model (optional)
        :param pod: pod to be used for uploading model (optional)
        :param xai_method: xai method to be used while uploading model ["shap", "lime"] (optional)
        :param feature_list: list of features in sequence which are to be passed in the model (optional)
        """

        def upload_file_and_return_path() -> str:
            """Upload a local model artifact to Lexsi file storage.
            Returns the stored `filepath` referenced by the model upload request."""
            files = {"in_file": open(model_path, "rb")}
            model_data_tags_str = ",".join(model_train)
            res = self.api_client.file(
                f"{UPLOAD_DATA_FILE_URI}?project_name={self.project_name}&data_type=model&tag={model_data_tags_str}",
                files,
            )

            if not res["success"]:
                raise Exception(res.get("details"))
            uploaded_path = res.get("metadata").get("filepath")

            return uploaded_path

        model_types = self.api_client.get(GET_MODEL_TYPES_URI)
        valid_model_architecture = model_types.get("model_architecture").keys()
        Validate.value_against_list(
            "model_achitecture", model_architecture, valid_model_architecture
        )

        valid_model_types = model_types.get("model_architecture")[model_architecture]
        Validate.value_against_list("model_type", model_type, valid_model_types)

        tags = self.tags()
        Validate.value_against_list("model_train", model_train, tags)

        if model_test:
            Validate.value_against_list("model_test", model_test, tags)

        uploaded_path = upload_file_and_return_path()

        if pod:
            custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
            Validate.value_against_list(
                "pod",
                pod,
                [
                    server["instance_name"]
                    for server in custom_batch_servers.get("details", [])
                ],
            )

        if xai_method:
            Validate.value_against_list(
                "explainability_method", xai_method, ["shap", "lime", "ig", "dlb"]
            )

        payload = {
            "project_name": self.project_name,
            "model_name": model_name,
            "model_architecture": model_architecture,
            "model_type": model_type,
            "model_path": uploaded_path,
            "model_data_tags": model_train,
            "model_test_tags": model_test,
            "explainability_method": xai_method,
            "feature_list": feature_list,
        }

        if pod:
            payload["instance_type"] = pod

        res = self.api_client.post(UPLOAD_MODEL_URI, payload)

        if not res.get("success"):
            raise Exception(res.get("details"))

        poll_events(
            self.api_client,
            self.project_name,
            res["event_id"],
            lambda: self.delete_file(uploaded_path),
        )

    def upload_model_dataconnectors(
        self,
        data_connector_name: str,
        model_architecture: str,
        model_type: str,
        model_name: str,
        model_train: list,
        model_test: Optional[list],
        pod: Optional[str] = None,
        xai_method: Optional[list] = ["shap"],
        bucket_name: Optional[str] = None,
        file_path: Optional[str] = None,
    ):
        """Uploads a custom trained model to Lexsi.ai for inference and evaluation.

        :param data_connector_name: name of the data connector
        :param model_architecture: architecture of model ["machine_learning", "deep_learning"]
        :param model_type: type of the model based on the architecture ["Xgboost","Lgboost","CatBoost","Random_forest","Linear_Regression","Logistic_Regression","Gaussian_NaiveBayes","SGD"]
                use upload_model_types() method to get all allowed model_types
        :param model_name: name of the model
        :param model_train: data tags for model
        :param model_test: test tags for model (optional)
        :param pod: pod to be used for uploading model (optional)
        :param xai_method: explainability method to be used while uploading model ["shap", "lime"] (optional)
        :param bucket_name: if data connector has buckets # Example: s3/gcs buckets
        :param file_path: filepath from the bucket for the data to read
        """

        def get_connector() -> str | pd.DataFrame:
            """Look up the configured data connector by name.
            Returns a one-row DataFrame (or an error string) with connector metadata."""
            url = build_list_data_connector_url(
                LIST_DATA_CONNECTORS, self.project_name, self.organization_id
            )
            res = self.api_client.post(url)

            if res["success"]:
                df = pd.DataFrame(res["details"])
                filtered_df = df.loc[df["link_service_name"] == data_connector_name]
                if filtered_df.empty:
                    return "No data connector found"
                return filtered_df

            return res["details"]

        connectors = get_connector()
        if isinstance(connectors, pd.DataFrame):
            value = connectors.loc[
                connectors["link_service_name"] == data_connector_name,
                "link_service_type",
            ].values[0]
            ds_type = value

            if ds_type == "s3" or ds_type == "gcs":
                if not bucket_name:
                    return "Missing argument bucket_name"
                if not file_path:
                    return "Missing argument file_path"
        else:
            return connectors

        def upload_file_and_return_path() -> str:
            """Trigger a connector-to-Lexsi upload for model artifacts.
            Returns the stored `filepath` referenced by the model upload request."""
            if not self.project_name:
                return "Missing Project Name"
            model_data_tags_str = ",".join(model_train)
            if self.organization_id:
                res = self.api_client.post(
                    f"{UPLOAD_FILE_DATA_CONNECTORS}?project_name={self.project_name}&organization_id={self.organization_id}&link_service_name={data_connector_name}&data_type=model&bucket_name={bucket_name}&file_path={file_path}&tag={model_data_tags_str}"
                )
            else:
                res = self.api_client.post(
                    f"{UPLOAD_FILE_DATA_CONNECTORS}?project_name={self.project_name}&link_service_name={data_connector_name}&data_type=model&bucket_name={bucket_name}&file_path={file_path}&tag={model_data_tags_str}"
                )
            print(res)
            if not res["success"]:
                raise Exception(res.get("details"))
            uploaded_path = res.get("metadata").get("filepath")

            return uploaded_path

        model_types = self.api_client.get(GET_MODEL_TYPES_URI)
        valid_model_architecture = model_types.get("model_architecture").keys()
        Validate.value_against_list(
            "model_achitecture", model_architecture, valid_model_architecture
        )

        valid_model_types = model_types.get("model_architecture")[model_architecture]
        Validate.value_against_list("model_type", model_type, valid_model_types)

        tags = self.tags()
        Validate.value_against_list("model_train", model_train, tags)

        if model_test:
            Validate.value_against_list("model_test", model_test, tags)

        uploaded_path = upload_file_and_return_path()

        if pod:
            custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
            Validate.value_against_list(
                "pod",
                pod,
                [
                    server["instance_name"]
                    for server in custom_batch_servers.get("details", [])
                ],
            )

        if xai_method:
            Validate.value_against_list(
                "explainability_method", xai_method, ["shap", "lime"]
            )

        payload = {
            "project_name": self.project_name,
            "model_name": model_name,
            "model_architecture": model_architecture,
            "model_type": model_type,
            "model_path": uploaded_path,
            "model_data_tags": model_train,
            "model_test_tags": model_test,
            "explainability_method": xai_method,
        }

        if pod:
            payload["instance_type"] = pod

        res = self.api_client.post(UPLOAD_MODEL_URI, payload)

        if not res.get("success"):
            raise Exception(res.get("details"))

        poll_events(
            self.api_client,
            self.project_name,
            res["event_id"],
            lambda: self.delete_file(uploaded_path),
        )

    def get_default_dashboard(self, type: str) -> Dashboard:
        """Returns the default dashboard for the specified type.

        :param type: type of the dashboard
        :return: Dashboard
        """

        res = self.api_client.get(
            f"{GET_DASHBOARD_URI}?type={type}&project_name={self.project_name}"
        )

        if res["success"]:
            auth_token = self.api_client.get_auth_token()
            query_params = f"?project_name={self.project_name}&type={type}&access_token={auth_token}"
            return Dashboard(
                config=res.get("config"),
                raw_data=res.get("details"),
                query_params=query_params,
            )

        raise Exception(
            "Cannot retrieve default dashboard, please create new dashboard"
        )

    def get_all_dashboards(self, type: str, page: Optional[int] = 1) -> pd.DataFrame:
        """Fetch all dashboards in the project

        :param type: type of the dashboard
        :param page: page number defaults to 1
        :return: Result DataFrame
        """

        Validate.value_against_list(
            "type",
            type,
            DASHBOARD_TYPES,
        )

        res = self.api_client.get(
            f"{DASHBOARD_LOGS_URI}?project_name={self.project_name}&type={type}&page={page}",
        )
        if not res["success"]:
            raise Exception(res.get("details", "Failed to get all dashboard"))
        res = res.get("details").get("dashboards")

        logs = pd.DataFrame(res)
        logs.drop(
            columns=[
                "max_features",
                "limit_features",
                "baseline_date",
                "current_date",
                "task_id",
                "date_feature",
                "stat_test_threshold",
                "project_name",
                "file_id",
                "updated_at",
                "features_to_use",
            ],
            inplace=True,
            errors="ignore",
        )
        return logs

    def get_dashboard_metadata(self, type: str, dashboard_id: str) -> Dashboard:
        """Get dashboard generated dashboard with id

        :param type: type of the dashboard
        :param dashboard_id: id of dashboard
        :return: A Dashboard object that provides the following capabilities:
            - plot(): Re-render the dashboard with custom width/height
            - get_config(): Retrieve the dashboard configuration (excluding metadata)
            - get_raw_data(): Access processed metric data underlying the dashboard
            - print_config(): Pretty-print the dashboard configuration
        :rtype: Dashboard
        """
        Validate.value_against_list(
            "type",
            type,
            DASHBOARD_TYPES,
        )

        res = self.api_client.get(
            f"{GET_DASHBOARD_URI}?type={type}&project_name={self.project_name}&dashboard_id={dashboard_id}"
        )

        if not res["success"]:
            raise Exception(res.get("details", "Failed to get dashboard"))

        auth_token = self.api_client.get_auth_token()
        query_params = f"?project_name={self.project_name}&type={type}&dashboard_id={dashboard_id}&access_token={auth_token}"

        return res

    def get_dashboard(self, type: str, dashboard_id: str) -> Dashboard:
        """Get dashboard generated dashboard with id

        :param type: type of the dashboard
        :param dashboard_id: id of dashboard
        :return: A Dashboard object that provides the following capabilities:
            - plot(): Re-render the dashboard with custom width/height
            - get_config(): Retrieve the dashboard configuration (excluding metadata)
            - get_raw_data(): Access processed metric data underlying the dashboard
            - print_config(): Pretty-print the dashboard configuration
        :rtype: Dashboard
        """
        Validate.value_against_list(
            "type",
            type,
            DASHBOARD_TYPES,
        )

        res = self.api_client.get(
            f"{GET_DASHBOARD_URI}?type={type}&project_name={self.project_name}&dashboard_id={dashboard_id}"
        )

        if not res["success"]:
            raise Exception(res.get("details", "Failed to get dashboard"))

        auth_token = self.api_client.get_auth_token()
        query_params = f"?project_name={self.project_name}&type={type}&dashboard_id={dashboard_id}&access_token={auth_token}"

        return Dashboard(
            config=res.get("config"),
            raw_data=res.get("details"),
            query_params=query_params,
        )

    def monitors(self) -> pd.DataFrame:
        """List of monitoring triggers for the project.

        :return: DataFrame
        :rtype: pd.DataFrame
        """
        url = f"{GET_TRIGGERS_URI}?project_name={self.project_name}"
        res = self.api_client.get(url)

        if not res["success"]:
            return Exception(res.get("details", "Failed to get triggers"))

        monitoring_triggers = res.get("details", [])

        if not monitoring_triggers:
            return "No monitoring triggers found."

        monitoring_triggers = pd.DataFrame(monitoring_triggers)
        monitoring_triggers = monitoring_triggers[
            monitoring_triggers["deleted"] == False
        ]
        monitoring_triggers = monitoring_triggers.drop("project_name", axis=1)

        return monitoring_triggers

    def duplicate_monitor(self, monitor_name: str, new_monitor_name: str) -> str:
        """Duplicate an existing monitoring trigger under a new name.
        Calls the backend duplication endpoint and returns the server response message.

        :param monitor_name: Existing monitor name to duplicate.
        :param new_monitor_name: New name for the duplicated monitor.
        :return: Backend response message.
        :rtype: str"""
        if monitor_name == new_monitor_name:
            return "Duplicate trigger name can't be same"
        url = f"{DUPLICATE_MONITORS_URI}?project_name={self.project_name}&trigger_name={monitor_name}&new_trigger_name={new_monitor_name}"
        res = self.api_client.post(url)

        if not res["success"]:
            return res.get("details", "Failed to clone triggers")

        return res["details"]

    def create_monitor(self, payload: dict) -> str:
        """Create monitoring trigger for project

        :param payload: **Data Drift Trigger Payload** 
                {
                    "trigger_type": "Data Drift",
                    "trigger_name": "",
                    "mail_list": [],
                    "frequency": "",   #['daily','weekly','monthly','quarterly','yearly']
                    "stat_test_name": "",
                    "stat_test_threshold": 0,
                    "datadrift_features_per": 7,
                    "dataset_drift_percentage": 50,
                    "features_to_use": [],
                    "date_feature": "",
                    "baseline_date": { "start_date": "", "end_date": ""},
                    "current_date": { "start_date": "", "end_date": ""},
                    "base_line_tag": [""],
                    "current_tag": [""],
                    "priority": 2, # between 1-5 
                    "pod": ""  #Pod type to used for running trigger
                } OR 
                **Target Drift Trigger Payload** 
                {
                    "trigger_type": "Target Drift",
                    "trigger_name": "",
                    "mail_list": [],
                    "frequency": "",   #['daily','weekly','monthly','quarterly','yearly']
                    "model_type": "",
                    "stat_test_name": ""
                    "stat_test_threshold": 0,
                    "date_feature": "",
                    "baseline_date": { "start_date": "", "end_date": ""},
                    "current_date": { "start_date": "", "end_date": ""},
                    "base_line_tag": [""],
                    "current_tag": [""],
                    "baseline_true_label": "",
                    "current_true_label": "",
                    "priority": 2, # between 1-5 
                    "pod": ""  #Pod type to used for running trigger
                } OR 
                **Model Performance Trigger Payload** 
                {
                    "trigger_type": "Model Performance",
                    "trigger_name": "",
                    "mail_list": [],
                    "frequency": "",   #['daily','weekly','monthly','quarterly','yearly']
                    "model_type": "",
                    "model_performance_metric": "",
                    "model_performance_threshold": "",
                    "date_feature": "",
                    "baseline_date": { "start_date": "", "end_date": ""},
                    "current_date": { "start_date": "", "end_date": ""},
                    "base_line_tag": [""],
                    "current_tag": [""],
                    "baseline_true_label": "",
                    "current_true_label": "",
                    "baseline_pred_label": "",
                    "current_pred_label": "",
                    "priority": 2, # between 1-5 
                    "pod": ""  #Pod type to used for running trigger
                }
        :return: response
        """
        payload["project_name"] = self.project_name

        required_payload_keys = [
            "trigger_type",
            "priority",
            "mail_list",
            "frequency",
            "trigger_name",
        ]

        Validate.check_for_missing_keys(payload, required_payload_keys)
        if payload.get("pod", None):
            payload["instance_type"] = payload["pod"]
        payload = {
            "project_name": self.project_name,
            "modify_req": {
                "create_trigger": payload,
            },
        }
        res = self.api_client.post(CREATE_TRIGGER_URI, payload)

        if not res["success"]:
            return Exception(res.get("details", "Failed to create trigger"))

        return "Trigger created successfully."

    def delete_monitor(self, name: str) -> str:
        """delete a monitoring trigger from project

        :param name: trigger name
        :return: str
        :rtype: str
        """
        payload = {
            "project_name": self.project_name,
            "modify_req": {
                "delete_trigger": name,
            },
        }

        res = self.api_client.post(DELETE_TRIGGER_URI, payload)

        if not res["success"]:
            return Exception(res.get("details", "Failed to delete trigger"))

        return "Monitoring trigger deleted successfully."

    def alerts(self, page_num: int = 1) -> pd.DataFrame:
        """Retrieves monitoring alerts for the project. Each page contains 20 alerts.

        :param page_num: page num, defaults to 1
        :return: alerts DataFrame
        :rtype: pd.DataFrame
        """
        payload = {"page_num": page_num, "project_name": self.project_name}

        res = self.api_client.post(EXECUTED_TRIGGER_URI, payload)

        if not res["success"]:
            return Exception(res.get("details", "Failed to get alerts"))

        monitoring_alerts = res.get("details", [])

        if not monitoring_alerts:
            return "No monitoring alerts found."

        return pd.DataFrame(monitoring_alerts)

    def get_alert_details(self, id: str) -> Alert:
        """Alert details of the provided id

        :param id: alert or trigger id
        :return: Alert
        :rtype: Alert
        """
        payload = {
            "project_name": self.project_name,
            "id": id,
        }
        res = self.api_client.post(GET_EXECUTED_TRIGGER_INFO, payload)

        if not res["success"]:
            return Exception(res.get("details", "Failed to get trigger details"))

        trigger_info = res["details"][0]

        if not trigger_info["successful"]:
            return Alert(info={}, detailed_report=[], not_used_features=[])

        trigger_info = trigger_info["details"]

        detailed_report = trigger_info.get("detailed_report")
        not_used_features = trigger_info.get("Not_Used_Features")

        trigger_info.pop("detailed_report", None)
        trigger_info.pop("Not_Used_Features", None)

        return Alert(
            info=trigger_info,
            detailed_report=detailed_report,
            not_used_features=not_used_features,
        )

    def get_monitors_alerts(self, monitor_id: str, time: int):
        """Retrieves alerts for a specific monitor within a given time window.

        :param monitor_id: Monitor identifier.
        :param time: Time range (in hours) from the current time used to fetch alerts.
        :return: Alerts as a pandas DataFrame.
        :rtype: pd.DataFrame"""
        url = f"{GET_MONITORS_ALERTS}?project_name={self.project_name}&monitor_id={monitor_id}&time={time}"
        res = self.api_client.get(url)
        data = pd.DataFrame(res.get("details"))
        return data

    def get_model_performance(self, model_name: str = None) -> Dashboard:
        """Get model performance dashboard data for this project.

        :param model_name: Optional model name to filter dashboard data.
        :return: A Dashboard object that provides the following capabilities:
            - plot(): Re-render the dashboard with custom width/height
            - get_config(): Retrieve the dashboard configuration (excluding metadata)
            - get_raw_data(): Access processed metric data underlying the dashboard
            - print_config(): Pretty-print the dashboard configuration
        :rtype: Dashboard"""
        auth_token = self.api_client.get_auth_token()
        dashboard_query_params = f"?type=model_performance&project_name={self.project_name}&access_token={auth_token}"
        raw_data_query_params = f"?project_name={self.project_name}"

        if model_name:
            dashboard_query_params = f"{dashboard_query_params}&model_name={model_name}"
            raw_data_query_params = f"{raw_data_query_params}&model_name={model_name}"

        raw_data = self.api_client.get(
            f"{MODEL_PERFORMANCE_DASHBOARD_URI}{raw_data_query_params}"
        )

        return Dashboard(
            config={},
            query_params=dashboard_query_params,
            raw_data=raw_data.get("details"),
        )

    def model_parameters(self) -> dict:
        """Model Parameters

        :return: response
        """

        model_params = self.api_client.get(MODEL_PARAMETERS_URI)

        return model_params

    def upload_feature_mapping(self, data: str | dict) -> str:
        """Upload feature mapping for the project

        :param data: string | dict
        :return: response
        """

        def build_upload_data():
            """Build the multipart payload for a feature-mapping upload.
            Accepts a file path or a Python dict and serializes dicts to JSON bytes.

            :return: A file handle (path input) or `(filename, bytes)` tuple (dict input).
            """
            if isinstance(data, str):
                file = open(data, "rb")
                return file
            elif isinstance(data, dict):
                json_buffer = io.BytesIO()
                json_str = json.dumps(data, ensure_ascii=False, indent=4)
                json_buffer.write(json_str.encode("utf-8"))
                json_buffer.seek(0)
                file_name = (
                    f"feature_mapping_sdk_{datetime.now().replace(microsecond=0)}.json"
                )
                file = (file_name, json_buffer.getvalue())
                return file
            else:
                raise Exception("Invalid Data Type")

        def upload_file_and_return_path() -> str:
            """Upload the feature-mapping artifact to Lexsi file storage.
            Returns the stored `filepath`, which is then registered to the project.

            :return: Server-side filepath for the uploaded feature-mapping artifact."""
            files = {"in_file": build_upload_data()}
            res = self.api_client.file(
                f"{UPLOAD_DATA_FILE_URI}?project_name={self.project_name}&data_type=feature_mapping",
                files,
            )

            if not res["success"]:
                raise Exception(res.get("details"))
            uploaded_path = res.get("metadata").get("filepath")

            return uploaded_path

        uploaded_path = upload_file_and_return_path()

        payload = {
            "path": uploaded_path,
            "type": "feature_mapping",
            "project_name": self.project_name,
        }
        res = self.api_client.post(UPLOAD_DATA_URI, payload)

        if not res["success"]:
            self.delete_file(uploaded_path)
            raise Exception(res.get("details"))

        return res.get("details", "Feature mapping upload successful")

    def upload_data_description(self, data: str | pd.DataFrame) -> str:
        """Uploads data description for the project

        :param data: Dataset to upload. Can be a file path or an in-memory pandas DataFrame.
        :return: response
        """

        def build_upload_data():
            """Build the multipart payload for a data-description upload.
            Accepts a CSV path or DataFrame and serializes DataFrames to CSV bytes.

            :return: A file handle (path input) or `(filename, bytes)` tuple (DataFrame input).
            """
            if isinstance(data, str):
                file = open(data, "rb")
                return file
            elif isinstance(data, pd.DataFrame):
                csv_buffer = io.BytesIO()
                data.to_csv(csv_buffer, index=False, encoding="utf-8")
                csv_buffer.seek(0)
                file_name = (
                    f"data_description_sdk_{datetime.now().replace(microsecond=0)}.csv"
                )
                file = (file_name, csv_buffer.getvalue())
                return file
            else:
                raise Exception("Invalid Data Type")

        def upload_file_and_return_path() -> str:
            """Upload the data-description artifact to Lexsi file storage.
            Returns the stored `filepath`, which is then registered to the project.

            :return: Server-side filepath for the uploaded data-description artifact."""
            files = {"in_file": build_upload_data()}
            res = self.api_client.file(
                f"{UPLOAD_DATA_FILE_URI}?project_name={self.project_name}&data_type=data_description",
                files,
            )

            if not res["success"]:
                raise Exception(res.get("details"))
            uploaded_path = res.get("metadata").get("filepath")

            return uploaded_path

        uploaded_path = upload_file_and_return_path()

        payload = {
            "path": uploaded_path,
            "type": "data_description",
            "project_name": self.project_name,
        }
        res = self.api_client.post(UPLOAD_DATA_URI, payload)

        if not res["success"]:
            self.delete_file(uploaded_path)
            raise Exception(res.get("details"))

        return res.get("details", "Data description upload successful")

    def upload_feature_mapping_dataconnectors(
        self,
        data_connector_name: str,
        bucket_name: Optional[str] = None,
        file_path: Optional[str] = None,
    ) -> str:
        """Upload a feature-mapping file to the project using a configured data connector.

        :param data_connector_name: name of the data connector
        :param bucket_name: if data connector has buckets # Example: s3/gcs buckets
        :param file_path: filepath from the bucket for the data to read
        :return: response
        """

        def get_connector() -> str | pd.DataFrame:
            """Look up the configured data connector by name.
            Returns a one-row DataFrame (or an error string) with connector metadata."""
            url = build_list_data_connector_url(
                LIST_DATA_CONNECTORS, self.project_name, self.organization_id
            )
            res = self.api_client.post(url)

            if res["success"]:
                df = pd.DataFrame(res["details"])
                filtered_df = df.loc[df["link_service_name"] == data_connector_name]
                if filtered_df.empty:
                    return "No data connector found"
                return filtered_df

            return res["details"]

        connectors = get_connector()
        if isinstance(connectors, pd.DataFrame):
            value = connectors.loc[
                connectors["link_service_name"] == data_connector_name,
                "link_service_type",
            ].values[0]
            ds_type = value

            if ds_type == "s3" or ds_type == "gcs":
                if not bucket_name:
                    return "Missing argument bucket_name"
                if not file_path:
                    return "Missing argument file_path"
        else:
            return connectors

        def upload_file_and_return_path() -> str:
            """Trigger a connector-to-Lexsi upload for the feature-mapping file.
            Returns the stored `filepath` to be used in the subsequent register call."""
            if not self.project_name:
                return "Missing Project Name"
            if self.organization_id:
                res = self.api_client.post(
                    f"{UPLOAD_FILE_DATA_CONNECTORS}?project_name={self.project_name}&organization_id={self.organization_id}&link_service_name={data_connector_name}&data_type=feature_mapping&bucket_name={bucket_name}&file_path={file_path}"
                )
            else:
                res = self.api_client.post(
                    f"{UPLOAD_FILE_DATA_CONNECTORS}?project_name={self.project_name}&link_service_name={data_connector_name}&data_type=feature_mapping&bucket_name={bucket_name}&file_path={file_path}"
                )
            print(res)
            if not res["success"]:
                raise Exception(res.get("details"))
            uploaded_path = res.get("metadata").get("filepath")

            return uploaded_path

        uploaded_path = upload_file_and_return_path()

        payload = {
            "path": uploaded_path,
            "type": "feature_mapping",
            "project_name": self.project_name,
        }
        res = self.api_client.post(UPLOAD_DATA_URI, payload)

        if not res["success"]:
            self.delete_file(uploaded_path)
            raise Exception(res.get("details"))

        return res.get("details", "Feature mapping upload successful")

    def upload_data_description_dataconnectors(
        self,
        data_connector_name: str,
        bucket_name: Optional[str] = None,
        file_path: Optional[str] = None,
    ) -> str:
        """Upload data description for the project.

        :param data_connector_name: name of the data connector
        :param bucket_name: if data connector has buckets # Example: s3/gcs buckets
        :param file_path: filepath from the bucket for the data to read
        :return: response
        """

        def get_connector() -> str | pd.DataFrame:
            """Look up the configured data connector by name.
            Returns a one-row DataFrame (or an error string) with connector metadata."""
            url = build_list_data_connector_url(
                LIST_DATA_CONNECTORS, self.project_name, self.organization_id
            )
            res = self.api_client.post(url)

            if res["success"]:
                df = pd.DataFrame(res["details"])
                filtered_df = df.loc[df["link_service_name"] == data_connector_name]
                if filtered_df.empty:
                    return "No data connector found"
                return filtered_df

            return res["details"]

        connectors = get_connector()
        if isinstance(connectors, pd.DataFrame):
            value = connectors.loc[
                connectors["link_service_name"] == data_connector_name,
                "link_service_type",
            ].values[0]
            ds_type = value

            if ds_type == "s3" or ds_type == "gcs":
                if not bucket_name:
                    return "Missing argument bucket_name"
                if not file_path:
                    return "Missing argument file_path"
        else:
            return connectors

        def upload_file_and_return_path() -> str:
            """Trigger a connector-to-Lexsi upload for the data-description file.
            Returns the stored `filepath` to be used in the subsequent register call."""
            if not self.project_name:
                return "Missing Project Name"
            if self.organization_id:
                res = self.api_client.post(
                    f"{UPLOAD_FILE_DATA_CONNECTORS}?project_name={self.project_name}&organization_id={self.organization_id}&link_service_name={data_connector_name}&data_type=data_description&bucket_name={bucket_name}&file_path={file_path}"
                )
            else:
                res = self.api_client.post(
                    f"{UPLOAD_FILE_DATA_CONNECTORS}?project_name={self.project_name}&link_service_name={data_connector_name}&data_type=data_description&bucket_name={bucket_name}&file_path={file_path}"
                )
            print(res)
            if not res["success"]:
                raise Exception(res.get("details"))
            uploaded_path = res.get("metadata").get("filepath")

            return uploaded_path

        uploaded_path = upload_file_and_return_path()

        payload = {
            "path": uploaded_path,
            "type": "data_description",
            "project_name": self.project_name,
        }
        res = self.api_client.post(UPLOAD_DATA_URI, payload)

        if not res["success"]:
            self.delete_file(uploaded_path)
            raise Exception(res.get("details"))

        return res.get("details", "Data description upload successful")

    def data_observations(self, tag: str) -> pd.DataFrame:
        """Available observations for the specified dataset tag.

        :param tag: tag for data ["Training", "Testing", "Validation", "Custom"]
        :return: data observations dataframe
        """
        payload = {"project_name": self.project_name, "refresh": "false"}
        res = self.api_client.post(f"{GET_DATA_SUMMARY_URI}", payload)
        valid_tags = res["data"]["data"].keys()

        if not valid_tags:
            raise Exception("Data summary not available, please upload data first.")

        if tag not in valid_tags:
            raise Exception(f"Not a vaild tag. Pick a valid tag from {valid_tags}")

        data = {
            "Total Data Volume": res["data"]["overview"]["Total Data Volumn"],
            "Unique Features": res["data"]["overview"]["Unique Features"],
        }

        print(data)
        summary = pd.DataFrame(res["data"]["data"][tag])
        return summary

    def data_warnings(self, tag: str) -> pd.DataFrame:
        """Data warnings for the project

        :param tag: tag for data ["Training", "Testing", "Validation", "Custom"]
        :return: data warnings dataframe
        """
        res = self.api_client.get(
            f"{GET_DATA_DIAGNOSIS_URI}?project_name={self.project_name}"
        )
        valid_tags = res["details"].keys()

        if not valid_tags:
            raise Exception("Data warnings not available, please upload data first.")

        Validate.value_against_list("tag", tag, valid_tags)

        data_warnings = pd.DataFrame(res["details"][tag]["alerts"])
        data_warnings[["Tag", "Description"]] = data_warnings[0].str.extract(
            r"\['(.*?)'] (.+?) #"
        )
        data_warnings["Description"] = data_warnings["Description"].str.replace(
            r"[^\w\s]", "", regex=True
        )
        data_warnings = data_warnings[["Description", "Tag"]]

        data = {"Warnings": len(data_warnings)}
        print(data)

        return data_warnings

    def data_drift_diagnosis(
        self,
        baseline_tags: Optional[List[str]] = None,
        current_tags: Optional[List[str]] = None,
        pod: Optional[str] = None,
    ) -> pd.DataFrame:
        """Generate Data Drift Diagnosis for the project with specified baseline and current tags.

        :param baseline_tags: tag for data ["Training", "Testing", "Validation", "Custom"]
        :param current_tags: tag for data ["Training", "Testing", "Validation", "Custom"]
        If baseline_tags and current_tags are not provided, the previously executed data drift diagnosis will be fetched.
        :param pod: pod to be used for generating data drift diagnosis (optional)
        :return: data drift diagnosis dataframe
        """

        if baseline_tags and current_tags:
            if pod not in [
                "small",
                "xsmall",
                "2xsmall",
                "3xsmall",
                "medium",
                "xmedium",
                "2xmedium",
                "3xmedium",
                "large",
                "xlarge",
                "2xlarge",
                "3xlarge",
            ]:
                return "pod is not valid. Valid types are small, xsmall, 2xsmall, 3xsmall, medium, xmedium, 2xmedium, 3xmedium, large, xlarge, 2xlarge, 3xlarge"

            payload = {
                "project_name": self.project_name,
                "baseline_tags": baseline_tags,
                "current_tags": current_tags,
                "instance_type": pod,
            }
            res = self.api_client.post(RUN_DATA_DRIFT_DIAGNOSIS_URI, payload)

            if not res["success"]:
                if res.get("details").get("reason"):
                    raise Exception(res.get("details").get("reason"))
                else:
                    raise Exception(res.get("message"))
            poll_events(self.api_client, self.project_name, res["task_id"])

        res = self.api_client.post(
            f"{GET_DATA_DRIFT_DIAGNOSIS_URI}?project_name={self.project_name}"
        )
        if not res.get("status"):
            raise Exception(res.get("details", "Data drift diagnosis not found"))
        data_drift_diagnosis = pd.DataFrame(res["details"]["detailed_report"]).drop(
            ["current_small_hist", "ref_small_hist"], axis=1
        )

        return data_drift_diagnosis

    def get_data_drift_dashboard(
        self,
        payload: DataDriftPayload = {},
        pod: Optional[str] = None,
        run_in_background: bool = False,
    ) -> Dashboard:
        """Generate Data Drift Dashboard for the project with specified parameters.

        :param run_in_background: runs in background without waiting for dashboard generation to complete
        :param pod: pod to be used for generating data drift (optional)
        :param payload: data drift payload
            {
                "base_line_tag": [""],
                "current_tag": [""],
                "stat_test_name": "",
                "stat_test_threshold": "",
                "features_to_use": []
                "date_feature": "",
                "baseline_date": { "start_date": "", "end_date": ""},
                "current_date": { "start_date": "", "end_date": ""},
            }
            defaults to None
            key values for payload:
                stat_test_name=
                    chisquare (Chi-Square test):
                        default for categorical features if the number of labels for feature > 2
                        only for categorical features
                        returns p_value
                        default threshold 0.05
                        drift detected when p_value < threshold
                    jensenshannon (Jensen-Shannon distance):
                        for numerical and categorical features
                        returns distance
                        default threshold 0.05
                        drift detected when distance >= threshold
                    ks (Kolmogorov–Smirnov (K-S) test):
                        default for numerical features
                        only for numerical features
                        returns p_value
                        default threshold 0.05
                        drift detected when p_value < threshold
                    kl_div (Kullback-Leibler divergence):
                        for numerical and categorical features
                        returns divergence
                        default threshold 0.05
                        drift detected when divergence >= threshold,
                    psi (Population Stability Index):
                        for numerical and categorical features
                        returns psi_value
                        default_threshold=0.1
                        drift detected when psi_value >= threshold
                    wasserstein (Wasserstein distance (normed)):
                        only for numerical features
                        returns distance
                        default threshold 0.05
                        drift detected when distance >= threshold
                    z (Ztest):
                        default for categorical features if the number of labels for feature <= 2
                        only for categorical features
                        returns p_value
                        default threshold 0.05
                        drift detected when p_value < threshold
        :return: A Dashboard object that provides the following capabilities:
            - plot(): Re-render the dashboard with custom width/height
            - get_config(): Retrieve the dashboard configuration (excluding metadata)
            - get_raw_data(): Access processed metric data underlying the dashboard
            - print_config(): Pretty-print the dashboard configuration
        :rtype: Dashboard
        """
        if not payload:
            return self.get_default_dashboard("data_drift")

        payload["project_name"] = self.project_name

        # validate required fields
        Validate.check_for_missing_keys(payload, DATA_DRIFT_DASHBOARD_REQUIRED_FIELDS)

        # validate tags and labels
        tags_info = self.available_tags()
        all_tags = tags_info["alltags"]

        Validate.value_against_list("base_line_tag", payload["base_line_tag"], all_tags)
        Validate.value_against_list("current_tag", payload["current_tag"], all_tags)

        Validate.validate_date_feature_val(payload, tags_info["alldatetimefeatures"])

        if payload.get("features_to_use"):
            Validate.value_against_list(
                "features_to_use",
                payload.get("features_to_use", []),
                tags_info["alluniquefeatures"],
            )

        Validate.value_against_list(
            "stat_test_name", payload["stat_test_name"], DATA_DRIFT_STAT_TESTS
        )

        custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
        Validate.value_against_list(
            "pod",
            pod,
            [
                server["instance_name"]
                for server in custom_batch_servers.get("details", [])
            ],
        )

        if payload.get("pod", None):
            payload["instance_type"] = payload["pod"]
        if pod:
            payload["instance_type"] = pod

        res = self.api_client.post(f"{GENERATE_DASHBOARD_URI}?type=data_drift", payload)

        if not res["success"]:
            error_details = res.get("details", "Failed to generate dashboard")
            raise Exception(error_details)

        if not run_in_background:
            poll_events(self.api_client, self.project_name, res["task_id"])
            return self.get_default_dashboard("data_drift")

        return "Data Drift dashboard generation initiated"

    def get_target_drift_dashboard(
        self,
        payload: TargetDriftPayload = {},
        pod: Optional[str] = None,
        run_in_background: bool = False,
    ) -> Dashboard:
        """Generate Target Drift Diagnosis for the project with specified parameters.

        :param run_in_background: runs in background without waiting for dashboard generation to complete
        :param pod: pod to be used for generating target drift diagnosis (optional)
        :param payload: target drift payload
                {
                    "base_line_tag": [""],
                    "current_tag": [""],
                    "stat_test_name": "",
                    "stat_test_threshold": "",
                    "date_feature": "",
                    "baseline_date": { "start_date": "", "end_date": ""},
                    "current_date": { "start_date": "", "end_date": ""},
                    "model_type": "",
                    "baseline_true_label": "",
                    "current_true_label": ""
                }
                defaults to None
                key values for payload:
                    stat_test_name=
                        chisquare (Chi-Square test):
                        default for categorical features if the number of labels for feature > 2
                        only for categorical features
                        returns p_value
                        default threshold 0.05
                        drift detected when p_value < threshold
                    jensenshannon (Jensen-Shannon distance):
                        for numerical and categorical features
                        returns distance
                        default threshold 0.05
                        drift detected when distance >= threshold
                    kl_div (Kullback-Leibler divergence):
                        for numerical and categorical features
                        returns divergence
                        default threshold 0.05
                        drift detected when divergence >= threshold,
                    psi (Population Stability Index):
                        for numerical and categorical features
                        returns psi_value
                        default_threshold=0.1
                        drift detected when psi_value >= threshold
                    z (Ztest):
                        default for categorical features if the number of labels for feature <= 2
                        only for categorical features
                        returns p_value
                        default threshold 0.05
                        drift detected when p_value < threshold
        :return: A Dashboard object that provides the following capabilities:
            - plot(): Re-render the dashboard with custom width/height
            - get_config(): Retrieve the dashboard configuration (excluding metadata)
            - get_raw_data(): Access processed metric data underlying the dashboard
            - print_config(): Pretty-print the dashboard configuration
        :rtype: Dashboard
        """
        if not payload:
            return self.get_default_dashboard("target_drift")

        payload["project_name"] = self.project_name

        # validate required fields
        Validate.check_for_missing_keys(payload, TARGET_DRIFT_DASHBOARD_REQUIRED_FIELDS)

        # validate tags and labels
        tags_info = self.available_tags()
        all_tags = tags_info["alltags"]

        Validate.value_against_list("base_line_tag", payload["base_line_tag"], all_tags)
        Validate.value_against_list("current_tag", payload["current_tag"], all_tags)

        Validate.validate_date_feature_val(payload, tags_info["alldatetimefeatures"])

        Validate.value_against_list("model_type", payload["model_type"], MODEL_TYPES)

        Validate.value_against_list(
            "stat_test_name", payload["stat_test_name"], TARGET_DRIFT_STAT_TESTS
        )

        Validate.value_against_list(
            "baseline_true_label",
            [payload["baseline_true_label"]],
            tags_info["alluniquefeatures"],
        )

        Validate.value_against_list(
            "current_true_label",
            [payload["current_true_label"]],
            tags_info["alluniquefeatures"],
        )

        custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
        Validate.value_against_list(
            "pod",
            pod,
            [
                server["instance_name"]
                for server in custom_batch_servers.get("details", [])
            ],
        )

        if payload.get("pod", None):
            payload["instance_type"] = payload["pod"]
        if pod:
            payload["instance_type"] = pod

        res = self.api_client.post(
            f"{GENERATE_DASHBOARD_URI}?type=target_drift", payload
        )

        if not res["success"]:
            error_details = res.get("details", "Failed to get dashboard url")
            raise Exception(error_details)

        if not run_in_background:
            poll_events(self.api_client, self.project_name, res["task_id"])
            return self.get_default_dashboard("target_drift")

        return "Target drift dashboard generation initiated"

    def get_bias_monitoring_dashboard(
        self,
        payload: BiasMonitoringPayload = {},
        pod: Optional[str] = None,
        run_in_background: bool = False,
    ) -> Dashboard:
        """Generate Bias Monitoring Dashboard for the given parameters.

        :param run_in_background: runs in background without waiting for dashboard generation to complete
        :param pod: pod to be used for generating bias monitoring diagnosis (optional)
        :param payload: bias monitoring payload
                {
                    "base_line_tag": [""],
                    "date_feature": "",
                    "baseline_date": { "start_date": "", "end_date": ""},
                    "current_date": { "start_date": "", "end_date": ""},
                    "model_type": "",
                    "baseline_true_label": "",
                    "baseline_pred_label": "",
                    "features_to_use": []
                }
                defaults to None
        :return: A Dashboard object that provides the following capabilities:
            - plot(): Re-render the dashboard with custom width/height
            - get_config(): Retrieve the dashboard configuration (excluding metadata)
            - get_raw_data(): Access processed metric data underlying the dashboard
            - print_config(): Pretty-print the dashboard configuration
        :rtype: Dashboard
        """
        if not payload:
            return self.get_default_dashboard("biasmonitoring")

        payload["project_name"] = self.project_name

        # validate required fields
        Validate.check_for_missing_keys(
            payload, BIAS_MONITORING_DASHBOARD_REQUIRED_FIELDS
        )

        # validate tags and labels
        tags_info = self.available_tags()
        all_tags = tags_info["alltags"]

        Validate.value_against_list("base_line_tag", payload["base_line_tag"], all_tags)

        Validate.validate_date_feature_val(payload, tags_info["alldatetimefeatures"])

        Validate.value_against_list("model_type", payload["model_type"], MODEL_TYPES)

        Validate.value_against_list(
            "baseline_true_label",
            [payload["baseline_true_label"]],
            tags_info["alluniquefeatures"],
        )

        Validate.value_against_list(
            "baseline_pred_label",
            [payload["baseline_pred_label"]],
            tags_info["alluniquefeatures"],
        )

        if payload.get("features_to_use"):
            Validate.value_against_list(
                "features_to_use",
                payload.get("features_to_use", []),
                tags_info["alluniquefeatures"],
            )

        custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
        Validate.value_against_list(
            "pod",
            pod,
            [
                server["instance_name"]
                for server in custom_batch_servers.get("details", [])
            ],
        )

        if payload.get("pod", None):
            payload["instance_type"] = payload["pod"]
        if pod:
            payload["instance_type"] = pod

        res = self.api_client.post(
            f"{GENERATE_DASHBOARD_URI}?type=biasmonitoring", payload
        )

        if not res["success"]:
            error_details = res.get("details", "Failed to get dashboard url")
            raise Exception(error_details)

        if not run_in_background:
            poll_events(self.api_client, self.project_name, res["task_id"])
            return self.get_default_dashboard("biasmonitoring")

        return "Bias monitoring dashboard generation initiated"

    def get_model_performance_dashboard(
        self,
        payload: ModelPerformancePayload = {},
        pod: Optional[str] = None,
        run_in_background: bool = False,
    ) -> Dashboard:
        """Generate Model Performance Dashboard for the given parameters.

        :param run_in_background: runs in background without waiting for dashboard generation to complete
        :param pod: pod to be used for generating model performance diagnosis (optional)
        :param payload: model performance payload
                {
                    "base_line_tag": [""],
                    "current_tag": [""],
                    "date_feature": "",
                    "baseline_date": { "start_date": "", "end_date": ""},
                    "current_date": { "start_date": "", "end_date": ""},
                    "model_type": "",
                    "baseline_true_label": "",
                    "baseline_pred_label": "",
                    "current_true_label": "",
                    "current_pred_label": ""
                }
                defaults to None
        :return: A Dashboard object that provides the following capabilities:
            - plot(): Re-render the dashboard with custom width/height
            - get_config(): Retrieve the dashboard configuration (excluding metadata)
            - get_raw_data(): Access processed metric data underlying the dashboard
            - print_config(): Pretty-print the dashboard configuration
        :rtype: Dashboard
        """
        if not payload:
            return self.get_default_dashboard("performance")

        payload["project_name"] = self.project_name

        tags_info = self.available_tags()
        all_tags = tags_info["alltags"]

        if self.metadata.get("modality") == "image":
            Validate.check_for_missing_keys(payload, ["base_line_tag", "current_tag"])

        Validate.value_against_list("base_line_tag", payload["base_line_tag"], all_tags)
        Validate.value_against_list("current_tag", payload["current_tag"], all_tags)

        if self.metadata.get("modality") == "tabular":
            Validate.check_for_missing_keys(
                payload, MODEL_PERF_DASHBOARD_REQUIRED_FIELDS
            )
            Validate.validate_date_feature_val(
                payload, tags_info["alldatetimefeatures"]
            )

            Validate.value_against_list(
                "model_type", payload["model_type"], MODEL_TYPES
            )

            Validate.value_against_list(
                "baseline_true_label",
                [payload["baseline_true_label"]],
                tags_info["alluniquefeatures"],
            )

            Validate.value_against_list(
                "baseline_pred_label",
                [payload["baseline_pred_label"]],
                tags_info["alluniquefeatures"],
            )

            Validate.value_against_list(
                "current_true_label",
                [payload["current_true_label"]],
                tags_info["alluniquefeatures"],
            )

            Validate.value_against_list(
                "current_pred_label",
                [payload["current_pred_label"]],
                tags_info["alluniquefeatures"],
            )

        custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
        Validate.value_against_list(
            "pod",
            pod,
            [
                server["instance_name"]
                for server in custom_batch_servers.get("details", [])
            ],
        )

        if payload.get("pod", None):
            payload["instance_type"] = payload["pod"]
        if pod:
            payload["instance_type"] = pod

        res = self.api_client.post(
            f"{GENERATE_DASHBOARD_URI}?type=performance", payload
        )

        if not res["success"]:
            error_details = res.get("details", "Failed to get dashboard url")
            raise Exception(error_details)

        if not run_in_background:
            poll_events(self.api_client, self.project_name, res["task_id"])
            return self.get_default_dashboard("performance")

        return "Model performance dashboard generation initiated"

    def get_dashboard_log_data(self, type: str):
        """List of dashboards of the specified type.

        :param type: type of the dashboard
        :return: DataFrame
        :rtype: pd.DataFrame
        """
        Validate.value_against_list(
            "type",
            type,
            DASHBOARD_TYPES,
        )
        self.api_client.refresh_bearer_token()
        auth_token = self.api_client.get_auth_token()
        query_params = (
            f"project_name={self.project_name}&dashboard_type={type}&token={auth_token}"
        )

        uri = f"{DOWNLOAD_DASHBOARD_LOGS_URI}?{query_params}"
        res = self.api_client.base_request("GET", uri)

        if res.status_code != 200:
            raise Exception(
                res.get(
                    "details", f"Error Downloading Dasboard Logs, {res.status_code}"
                )
            )

        try:
            df = pd.read_csv(io.StringIO(res.text))
        except:
            df = pd.DataFrame()

        return df
    

    def model_inference(
        self,
        tag: Optional[str] = None,
        file_name: Optional[str] = None,
        model_name: Optional[str] = None,
        pod: Optional[str] = None
    ) -> pd.DataFrame:
        """Run model inference on tag or file_name data. Either tag or file_name is required for running inference

        :param tag: data tag for running inference
        :param file_name: data file name for running inference
        :param model_name: name of the model, defaults to active model for the project
        :param pod: pod for running inference
        :return: model inference dataframe
        :rtype: pd.DataFrame
        """

        if not tag and not file_name:
            raise Exception("Either tag or file_name is required.")
        if tag and file_name:
            raise Exception("Provide either tag or file_name, not both.")
        available_tags = self.tags()
        if tag and tag not in available_tags:
            raise Exception(
                f"{tag} tag is not valid, select valid tag from :\n{available_tags}"
            )

        files = self.api_client.get(
            f"{ALL_DATA_FILE_URI}?project_name={self.project_name}"
        )
        file_names = []
        for file in files.get("details"):
            file_names.append(file.get("filepath").split("/")[-1])

        if file_name and file_name not in file_names:
            raise Exception(
                f"{file_name} file name is not valid, select valid tag from :\n{file_names.join(',')}"
            )
        filepath = None
        for file in files["details"]:
            file_path = file["filepath"]
            curr_file_name = file_path.split("/")[-1]
            if file_name == curr_file_name:
                filepath = file_path
                break

        models = self.models()

        available_models = models["model_name"].to_list()

        if model_name:
            Validate.value_against_list("model_name", model_name, available_models)

        model = (
            model_name
            or models.loc[models["status"] == "active"]["model_name"].values[0]
        )

        if pod and self.metadata.get("modality") == "tabular":
            custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
            available_custom_batch_servers = custom_batch_servers.get("details", []) + custom_batch_servers.get("available_gpu_custom_servers", [])
            Validate.value_against_list(
                "pod",
                pod,
                [
                    server["instance_name"]
                    for server in available_custom_batch_servers
                ],
            )
        else:
            Validate.value_against_list(
                "pod",
                pod,
                [
                    server["instance_name"]
                    for server in custom_batch_servers.get("details", [])
                ],
            )

        run_model_payload = {
            "project_name": self.project_name,
            "model_name": model,
            "tags": tag,
            "instance_type": pod
        }
        if filepath:
            run_model_payload["filepath"] = filepath

        run_model_res = self.api_client.post(RUN_MODEL_ON_DATA_URI, run_model_payload)

        if not run_model_res["success"]:
            raise Exception(run_model_res["details"])

        poll_events(
            api_client=self.api_client,
            project_name=self.project_name,
            event_id=run_model_res["event_id"],
        )

        auth_token = self.api_client.get_auth_token()

        if tag:
            uri = f"{DOWNLOAD_TAG_DATA_URI}?project_name={self.project_name}&tag={tag}_{model}_Inference&token={auth_token}"
        else:
            file_name = file_name.replace(".", "_")
            uri = f"{DOWNLOAD_TAG_DATA_URI}?project_name={self.project_name}&tag={file_name}_{model}_Inference&token={auth_token}"
        tag_data = self.api_client.base_request("GET", uri)

        tag_data_df = pd.read_csv(io.StringIO(tag_data.text))

        return tag_data_df

    def model_inferences(self) -> pd.DataFrame:
        """returns model inferences for the project

        :return: model inferences dataframe
        """

        res = self.api_client.get(
            f"{MODEL_INFERENCES_URI}?project_name={self.project_name}"
        )

        if not res["success"]:
            raise Exception(res.get("details"))

        model_inference_df = pd.DataFrame(res["details"]["inference_details"])

        return model_inference_df

    
    def train_model(
        self,
        model_type: str,
        compute_type: str,
        data_config: Optional[DataConfig] = None,
        model_config: Optional[Union[XGBoostParams, LightGBMParams, CatBoostParams, RandomForestParams, FoundationalModelParams]] = None,
        tunning_config: Optional[TuningParams] = None,
        peft_config: Optional[PEFTParams] = None,
        processor_config: Optional[ProcessorParams] = None,
        finetune_mode: Optional[str] = None,
        tunning_strategy: Optional[str] = None,
    ) -> str:

        """
        Train a Classic ML model or a Tabular Foundational model.

        This method is the single entry-point to train models in Lexsi. It applies the full
        training pipeline end-to-end:

        - selects and prepares data (filtering, sampling, feature handling, imbalance handling)
        - applies preprocessing / feature engineering (optional)
        - trains either a **classic ML model** or a **tabular foundation model**
        - optionally performs hyperparameter tuning (classic or foundational depending on strategy)
        - optionally performs fine-tuning / PEFT for foundation models
        - produces a trained model artifact and returns its identifier/reference

        :param model_type: Name of the model to train. Must be one of the supported following values
            **Classic ML models**
            - ``XGBoost``
            - ``LGBoost``
            - ``CatBoost``
            - ``RandomForest``
            - ``SGD``
            - ``LogisticRegression``
            - ``LinearRegression``
            - ``GaussianNaiveBayes``

        **Tabular foundation models (TabTune wrapper)**
            - ``TabPFN``
            - ``TabICL``
            - ``TabDPT``
            - ``OrionMSP``
            - ``OrionBix``
            - ``Mitra``
            - ``ContextTab``
        :type model_type: str

        :param compute_type: Compute instance used for training (CPU/GPU).
            This is used by the computation layer to select the appropriate runtime environment we have CPU/GPU runtime with small medium large with 2x 3x nomeclature with GPU T4 and A10G .
            Example values: ``"small"``, ``"medium"``,``"large"``, ``"2xsmall"``, ``"T4.small"``, ``"A10G.xmedium"``
        :type compute_type: str | None

        :param data_config: Dataset selection + training-time data behavior such as:
            tag-based filtering, train/test tags, feature exclusion/encodings, optional Optuna usage,
            sampling fractions, and explainability controls.
            See :class:`lexsi_sdk.common.types.DataConfig`.
        :type data_config: DataConfig | None

        :param processor_config: Optional preprocessing / feature engineering configuration applied
            before training (e.g., imputation, scaling, resampling strategy).
            See :class:`lexsi_sdk.common.types.ProcessorParams`.
        :type processor_config: ProcessorParams | None

        :param model_config: Model hyperparameters for the chosen ``model_type``.
            Use the matching config type:
            
            - For ``XGBoost``: :class:`lexsi_sdk.common.types.XGBoostParams`
            - For ``LGBoost``: :class:`lexsi_sdk.common.types.LightGBMParams`
            - For ``CatBoost``: :class:`lexsi_sdk.common.types.CatBoostParams`
            - For ``RandomForest``: :class:`lexsi_sdk.common.types.RandomForestParams`
            - For foundation models (e.g., ``TabPFN``, ``TabICL``, ...):
            :class:`lexsi_sdk.common.types.FoundationalModelParams`

            For models like ``SGD``, ``LogisticRegression``, ``LinearRegression``,
            ``GaussianNaiveBayes``, parameters may be taken from defaults in the wrapper if not
            explicitly exposed through a typed config.
        :type model_config: XGBoostParams | LightGBMParams | CatBoostParams | RandomForestParams | FoundationalModelParams | None

        :param tunning_config: Optional tuning / adaptation loop configuration.
            Used for episodic / few-shot / fine-tuning style training and some tuning strategies.
            See :class:`lexsi_sdk.common.types.TuningParams`.
        :type tunning_config: TuningParams | None

        :param tuning_strategy: Training / fine-tuning strategy to apply.

        Supported values:

            - ``"inference"``:
            Zero-shot inference only. No training or parameter updates are performed.

            - ``"base-ft"``:
            Full fine-tuning of all model parameters.

            - ``"peft"``:
            Parameter-efficient fine-tuning using LoRA adapters.
            Requires ``peft_config`` and a foundation model that supports PEFT.

            - ``"finetune"``:
            Alias for ``"base-ft"``.

        If not provided, the default behavior depends on the selected ``model_type``.
        :type tuning_strategy: str | None

        :param finetune_mode: Fine-tuning mode for episodic / foundation models.

        Supported values:

            - ``"meta-learning"``:
            Episodic meta-learning mode (default). Uses support/query splits
            and episodic optimization.

            - ``"sft"``:
            Standard supervised fine-tuning using conventional batches.

        This parameter is applicable only to episodic or foundational models and
        is ignored for classic ML models.
        :type finetune_mode: str | None

        :param peft_config: Parameter-efficient fine-tuning configuration (e.g., LoRA params).
            Used only when ``finetune_mode="peft"`` and the selected foundation model supports PEFT.
            See :class:`lexsi_sdk.common.types.PEFTParams`.
        :type peft_config: PEFTParams | None

        Foundational model params example:
        data_config= {
            "tags": ['training'],
            "drop_duplicate_uid": True,
            "feature_encodings" : feature_encodings,
            "feature_exclude": feature_exclude,
            "xai_method": ["lime"]
        }

        model_config = {
            'n_estimators': 16, 
            'softmax_temperature': 0.9,
            'fit_mode' : 'batched',
        }
        tunning_config = {
            'device': 'cuda',
            'epochs': 3,
            'learning_rate': 1e-5,
            'batch_size': 512,
            'show_progress': True
        }
        peft_config= {
            'r':8, 'lora_alpha':16, 'lora_dropout':0.05
        }
        processor_config={
            'imputation_strategy': 'mean',
            'scaling_strategy': 'standard',
            'resampling_strategy': 'smote'
        }

        Classic ML model params example:
        data_config= {
            "tags": ['training'],
            "drop_duplicate_uid": True,
            "feature_encodings" : feature_encodings,
            "feature_exclude": feature_exclude,
            "xai_method": ["lime"]
        }

        model_config = {
            'max_depth': 10,
            'max_leaves' : 32
        }

        :return: Identifier or reference for the trained model artifact (e.g., model ID / artifact URI).
        :rtype: str
        """


        project_config = self.config()

        if project_config == "Not Found":
            raise Exception("Upload files first")

        available_models = self.available_models()

        Validate.value_against_list("model_type", model_type, available_models)

        all_unique_features = [
            *project_config["metadata"]["feature_exclude"],
            *project_config["metadata"]["feature_include"],
        ]

        if tunning_strategy != "inference" and compute_type and "gova" not in compute_type:
            custom_batch_servers = self.api_client.get(AVAILABLE_BATCH_SERVERS_URI)
            available_custom_batch_servers = custom_batch_servers.get("details", []) + custom_batch_servers.get("available_gpu_custom_servers", [])
            Validate.value_against_list(
                "pod",
                compute_type,
                [
                    server["instance_name"]
                    for server in available_custom_batch_servers
                ],
            )

        if data_config:
            if data_config.get("feature_exclude"):
                Validate.value_against_list(
                    "feature_exclude",
                    data_config["feature_exclude"],
                    all_unique_features,
                )

            if data_config.get("tags"):
                available_tags = self.tags()
                Validate.value_against_list("tags", data_config["tags"], available_tags)

            if data_config.get("test_tags"):
                available_tags = self.tags()
                Validate.value_against_list(
                    "test_tags", data_config["test_tags"], available_tags
                )

            if data_config.get("feature_encodings"):
                Validate.value_against_list(
                    "feature_encodings_feature",
                    list(data_config["feature_encodings"].keys()),
                    list(project_config["metadata"]["feature_encodings"].keys()),
                )
                Validate.value_against_list(
                    "feature_encodings_feature",
                    list(data_config["feature_encodings"].values()),
                    ["labelencode", "countencode", "onehotencode"],
                )

            if data_config.get("sample_percentage"):
                if (
                    data_config["sample_percentage"] < 0
                    or data_config["sample_percentage"] > 1
                ):
                    raise Exception(
                        "Data sample percentage is invalid, select between 0 and 1"
                    )

            if data_config.get("explainability_sample_percentage"):
                if (
                    data_config["explainability_sample_percentage"] < 0
                    or data_config["explainability_sample_percentage"] > 1
                ):
                    raise Exception(
                        "Explainability sample percentage is invalid, select between 0 and 1"
                    )

            if data_config.get("lime_explainability_iterations"):
                if (
                    data_config["lime_explainability_iterations"] < 1
                    or data_config["lime_explainability_iterations"] > 10000
                ):
                    raise Exception(
                        "Lime explainability iterations is invalid, select between 1 and 10000"
                    )

            if data_config.get("xai_method"):
                Validate.value_against_list(
                    "xai_method",
                    data_config["xai_method"],
                    ["shap", "lime"],
                )

        if model_config:
            model_params = self.api_client.get(MODEL_PARAMETERS_URI)
            model_name = f"{model_type}_{project_config['project_type']}".lower()
            model_parameters = model_params.get(model_name)

            if model_parameters:

                def validate_params(param_group, config_group):
                    """Validate config values against model parameter constraints.
                    Checks select options and numeric min/max bounds, raising exceptions on invalid values.

                    :param param_group: Parameter definition dict (select/input types with constraints).
                    :param config_group: User-supplied config dict to validate against `param_group`.
                    :raises Exception: If any value violates the declared constraints.
                    """
                    if config_group:
                        for param_name, param_value in config_group.items():
                            model_param = param_group.get(param_name)
                            if not model_param:
                                # raise Exception(
                                #     f"Invalid model config for {model_type} \n {json.dumps(model_parameters)}"
                                # )
                                continue

                            param_type = model_param["type"]

                            if param_type == "select":
                                Validate.value_against_list(
                                    param_name, param_value, model_param["value"]
                                )
                            elif param_type == "input":
                                if param_value > model_param["max"]:
                                    raise Exception(
                                        f"{param_name} value cannot be greater than {model_param['max']}"
                                    )
                                if param_value < model_param["min"]:
                                    raise Exception(
                                        f"{param_name} value cannot be less than {model_param['min']}"
                                    )

                if model_type in ["TabPFN","TabICL","TabDPT","OrionMSP", "OrionBix","Mitra", "ContextTab"]:
                    validate_params(
                        model_parameters.get("model_params", {}), model_config
                    )
                    validate_params(
                        model_parameters.get("tunning_params", {}), tunning_config
                    )
                    validate_params(
                        model_parameters.get("processor_params", {}), processor_config
                    )
                    validate_params(
                        model_parameters.get("peft_params", {}), peft_config
                    )
                else:
                    validate_params(model_parameters, model_config)
        if finetune_mode:
            Validate.value_against_list(
                "finetune_mode",
                finetune_mode,
                ["meta-learning", "sft"],
            )
        if tunning_strategy:
            Validate.value_against_list(
                "tunning_strategy",
                tunning_strategy,
                ["base-ft", "inference", "peft", "finetune"],
            )
        data_conf = data_config or {}

        feature_exclude = [
            *data_conf.get("feature_exclude", []),
        ]

        feature_include = [
            feature for feature in all_unique_features if feature not in feature_exclude
        ]

        feature_encodings = (
            data_conf.get("feature_encodings")
            or project_config["metadata"]["feature_encodings"]
        )

        drop_duplicate_uid = (
            data_conf.get("drop_duplicate_uid")
            or project_config["metadata"]["drop_duplicate_uid"]
        )

        explainability_method = (
            data_conf.get("explainability_method") 
            or data_conf.get("xai_method")
            or project_config.get("metadata", {}).get("xai_method")
        )

        tags = data_conf.get("tags") or project_config["metadata"]["tags"]
        test_tags = (
            data_conf.get("test_tags") or project_config["metadata"]["test_tags"] or []
        )
        use_optuna = (
            data_conf.get("use_optuna")
            or project_config["metadata"]["use_optuna"]
            or False
        )
        handle_data_imbalance = (
            data_conf.get("handle_data_imbalance")
            or project_config["metadata"]["handle_data_imbalance"]
            or False
        )

        payload = {
            "project_name": self.project_name,
            "project_type": project_config["project_type"],
            "unique_identifier": project_config["unique_identifier"],
            "true_label": project_config["true_label"],
            "metadata": {
                "model_name": model_type,
                "model_parameters": model_config,
                "feature_include": feature_include,
                "feature_exclude": feature_exclude,
                "feature_encodings": feature_encodings,
                "drop_duplicate_uid": drop_duplicate_uid,
                "tags": tags,
                "test_tags": test_tags,
                "use_optuna": use_optuna,
                "explainability_method": explainability_method,
                "handle_data_imbalance": handle_data_imbalance,
            },
            "sample_percentage": data_conf.get("sample_percentage"),
            "explainability_sample_percentage": data_conf.get(
                "explainability_sample_percentage"
            ),
            "lime_explainability_iterations": data_conf.get(
                "lime_explainability_iterations"
            )
        }

        if tunning_config:
            payload["metadata"]["tunning_parameters"] = tunning_config
        if peft_config:
            payload["metadata"]["peft_parameters"] = peft_config
        if processor_config:
            payload["metadata"]["processor_parameters"] = processor_config
        if finetune_mode:
            payload["metadata"]["finetune_mode"] = finetune_mode
        if tunning_strategy:
            payload["metadata"]["tunning_strategy"] = tunning_strategy

        if compute_type:
            payload["instance_type"] = compute_type

        print("Config :-")
        print(json.dumps(payload["metadata"], indent=1))

        res = self.api_client.post(TRAIN_MODEL_URI, payload)

        if not res["success"]:
            raise Exception(res["details"])

        print("\nTraining Initiated")
        poll_events(self.api_client, self.project_name, res["event_id"])

        return "Model Trained Successfully"


    def cases(
        self,
        unique_identifier: Optional[str] = None,
        tag: Optional[str] = None,
        start_date: Optional[str] = None,
        end_date: Optional[str] = None,
        page: Optional[int] = 1,
    ) -> pd.DataFrame:
        """Cases for the Project

        :param unique_identifier: unique identifier of the case for filtering, defaults to None
        :param tag: data tag for filtering, defaults to None
        :param start_date: start date for filtering, defaults to None
        :param end_date: end data for filtering, defaults to None
        :return: case details dataframe
        """

        def get_cases():
            """Fetch paginated cases without any search filters.
            Used when no identifier/tag/date filters are provided."""
            payload = {
                "project_name": self.project_name,
                "page_num": page,
            }
            res = self.api_client.post(GET_CASES_URI, payload)
            return res

        def search_cases():
            """Search cases using identifier/tag/date filters.
            Posts the filter payload to the search endpoint and returns the raw API response.
            """
            payload = {
                "project_name": self.project_name,
                "unique_identifier": unique_identifier,
                "start_date": start_date,
                "end_date": end_date,
                "tag": tag,
                "page_num": page,
            }
            res = self.api_client.post(SEARCH_CASE_URI, payload)
            return res

        cases = (
            search_cases()
            if unique_identifier or tag or start_date or end_date
            else get_cases()
        )

        if not cases["success"]:
            raise Exception("No cases found")

        cases_df = pd.DataFrame(cases.get("details"))

        return cases_df

    def case_predict(
        self,
        unique_identifier: str,
        case_id: Optional[str] = None,
        tag: Optional[str] = None,
        model_name: Optional[str] = None,
        serverless_type: Optional[str] = None,
        xai: Optional[list] = [],
        risk_policies: Optional[bool] = False,
    ) -> CaseTabular:
        """Case Prediction for given unique identifier

        :param unique_identifier: unique identifier of case
        :param case_id: case id, defaults to None
        :param tag: case tag, defaults to None
        :param model_name: trained model name, defaults to None
        :param serverless_type: instance to be used for case
                Eg:- nova-0.5, nova-1, nova-1.5
        :param xai: xai methods for explainability you want to run
                Eg:- ['shap', 'lime', 'dtree', 'ig', 'gradcam', 'dlb']
        :param risk_policies: Whether to run policies during prediction. Set to True to run policies. Defaults to False.
        :return: Case object with details
        """
        payload = {
            "project_name": self.project_name,
            "case_id": case_id,
            "unique_identifier": unique_identifier,
            "tag": tag,
            "model_name": model_name,
            "instance_type": serverless_type,
            "risk_policies": risk_policies,
            "xai": xai,
        }
        res = self.api_client.post(CASE_INFO_URI, payload)
        if not res["success"]:
            raise Exception(res["details"])

        if "dtree" in xai:
            prediction_path_payload = {
                "project_name": self.project_name,
                "unique_identifier": unique_identifier,
                "case_id": case_id,
                "model_name": res["details"]["model_name"],
                "data_id": res["details"]["data_id"],
                "instance_type": serverless_type,
            }

            dtree_res = self.api_client.post(CASE_DTREE_URI, prediction_path_payload)
            if dtree_res["success"]:
                res["details"]["case_prediction_svg"] = dtree_res["details"][
                    "case_prediction_svg"
                ]
                res["details"]["case_prediction_path"] = dtree_res["details"][
                    "case_prediction_path"
                ]
                res["details"]["audit_trail"]["cost"]["xai_dtree"] = dtree_res[
                    "details"
                ]["cost_dtree"]
                res["details"]["audit_trail"]["time"]["xai_dtree"] = dtree_res[
                    "details"
                ]["time_dtree"]
                res["details"]["audit_trail"]["compute_type"]["xai_dtree"] = dtree_res[
                    "details"
                ]["compute_type"]
        res["details"]["project_name"] = self.project_name
        res["details"]["api_client"] = self.api_client
        case = CaseTabular(**res["details"])
        return case

    def delete_cases(
        self,
        unique_identifier: Optional[str] = None,
        start_date: Optional[str] = None,
        end_date: Optional[str] = None,
        tag: Optional[str] = None,
    ) -> str:
        """Delete Case with given filters. Atleast one filter is required to delete cases.

        :param unique_identifier: unique identifier of case, defaults to None
        :param start_date: start date of case, defaults to None
        :param end_date: end date of case, defaults to None
        :param tag: tag of case, defaults to None
        :return: response
        """
        if tag:
            all_tags = self.all_tags()
            Validate.value_against_list("tag", tag, all_tags)

        paylod = {
            "project_name": self.project_name,
            "unique_identifier": [unique_identifier],
            "start_date": start_date,
            "end_date": end_date,
            "tag": tag,
        }

        res = self.api_client.post(DELETE_CASE_URI, paylod)

        if not res["success"]:
            raise Exception(res["details"])

        return res["details"]

    def available_models(self) -> List[str]:
        """Returns all models which can be trained on Lexsi

        :return: list of all models
        """
        res = self.api_client.get(f"{GET_MODELS_URI}?project_name={self.project_name}")

        if not res["success"]:
            raise Exception(res["details"])

        available_models = list(
            map(lambda data: data["model_name"], res["details"]["available"])
        )

        available_models.extend(
            list(
                map(
                    lambda data: data["model_name"], res["details"]["foundation_models"]
                )
            )
        )

        return available_models

    def update_inference_model_status(self, model_name: str, activate: bool) -> str:
        """Sets the provided model to active for inferencing

        :param model_name: name of the model
        :param activate: Boolean flag to control the model state.
                     - True: Activates the model inference.
                     - False: Deactivates the model inference.
        :return: response
        """
        payload = {
            "project_name": self.project_name,
            "model_name": model_name,
            "activate": activate,
        }

        res = self.api_client.post(UPDATE_ACTIVE_INFERENCE_MODEL_URI, payload)

        if not res["success"]:
            raise Exception(res["details"])

        return res.get("details")

    def model_inference_settings(
        self, model_name: str, inference_compute: InferenceCompute
    ) -> str:
        """Update Model Inference Settings

        :param model_name: name of the model to update inference settings
        :param inference_compute: inference compute settings
        {
            "compute_type": "2xlargeA10G",  # compute_type for running inference
            "custom_server_config": {
                "start": "14:00+05:30" or "14:00",  # Start time ("HH:MM±HH:MM" or "HH:MM"; assumed UTC if no offset)
                "stop": "15:00+05:30" or "15:00"",  # Stop time ("HH:MM±HH:MM" or "HH:MM"; assumed UTC if no offset)
                "shutdown_after": 5,  # Operation hours for custom server
                "op_hours": True / False  # Whether to restrict to business hours
                "auto_start": True / False  # Automatically start the server when requested.
            }
        }
        :return: response
        """
        if inference_compute.get("compute_type", None):
            inference_compute["instance_type"] = inference_compute["compute_type"]
        server_config = inference_compute.get("custom_server_config", {})
        server_config["start"] = normalize_time(server_config.get("start"))
        server_config["stop"] = normalize_time(server_config.get("stop"))
        if server_config["start"] and not server_config["stop"]:
            raise ValueError("If start is provided, stop cannot be None.")

        if server_config["stop"] and not server_config["start"]:
            raise ValueError("If stop is provided, start cannot be None.")

        if server_config["start"] and server_config["stop"]:
            start_dt = datetime.fromisoformat(server_config["start"])
            stop_dt = datetime.fromisoformat(server_config["stop"])

            if stop_dt - start_dt < timedelta(minutes=15):
                raise ValueError("Stop time must be at least 15 minutes greater than start time.")

            if not server_config.get("op_hours") and server_config.get("auto_start"):
                server_config["op_hours"] = True

        payload = {
            "model_name": model_name,
            "project_name": self.project_name,
            "inference_compute": inference_compute,
        }

        res = self.api_client.post(f"{MODEL_INFERENCE_SETTINGS_URI}", payload)
        if not res["success"]:
            raise Exception(res.get("details", "Failed to update inference settings"))

    def observations(self) -> pd.DataFrame:
        """List of Observations

        :return: observation details dataframe
        """
        res = self.api_client.get(
            f"{GET_OBSERVATIONS_URI}?project_name={self.project_name}"
        )

        observation_df = pd.DataFrame(res.get("details"))

        if observation_df.empty:
            return observation_df

        observation_df = observation_df[
            observation_df["status"].isin(["active", "inactive"])
        ]

        if observation_df.empty:
            return observation_df

        observation_df["expression"] = observation_df["metadata"].apply(
            lambda metadata: generate_expression(metadata["expression"])
        )

        observation_df = observation_df.drop(
            columns=[
                "project_name",
                "configuration",
                "metadata",
                "updated_by",
                "created_by",
                "updated_keys",
            ],
            errors="ignore",
        )
        observation_df = observation_df.reindex(
            [
                "observation_id",
                "observation_name",
                "status",
                "statement",
                "linked_features",
                "expression",
                "created_at",
                "updated_at",
            ],
            axis=1,
        )
        observation_df.reset_index(inplace=True, drop=True)
        return observation_df

    def observation_trail(self) -> pd.DataFrame:
        """List of Observation Trail

        :return: observation trail details dataframe
        """
        res = self.api_client.get(
            f"{GET_OBSERVATIONS_URI}?project_name={self.project_name}"
        )

        if not res.get("details"):
            raise Exception("No observations found")

        observation_df = pd.DataFrame(res.get("details"))
        observation_df = observation_df[
            observation_df["status"].isin(["updated", "deleted"])
        ]
        if observation_df.empty:
            raise Exception("No observation trail found")
        observation_df = observation_df.rename(
            columns={
                "statement": "old_statement",
                "linked_features": "old_linked_features",
            }
        )
        observation_df["updated_keys"].replace(float("nan"), None, inplace=True)
        observation_df["updated_statement"] = observation_df["updated_keys"].apply(
            lambda data: data.get("statement") if data else None
        )

        observation_df["updated_linked_features"] = observation_df[
            "updated_keys"
        ].apply(lambda data: data.get("linked_features") if data else None)

        observation_df["old_expression"] = observation_df["metadata"].apply(
            lambda metadata: generate_expression(metadata["expression"])
        )

        observation_df["updated_expression"] = observation_df["updated_keys"].apply(
            lambda data: (
                generate_expression(data.get("metadata", {}).get("expression"))
                if data
                else None
            )
        )

        observation_df = observation_df.drop(
            columns=[
                "project_name",
                "configuration",
                "metadata",
                "updated_by",
                "created_by",
                "updated_keys",
            ],
            errors="ignore",
        )

        observation_df = observation_df.reindex(
            [
                "observation_id",
                "observation_name",
                "status",
                "old_statement",
                "updated_statement",
                "old_linked_features",
                "updated_linked_features",
                "old_expression",
                "updated_expression",
                "created_at",
                "updated_at",
            ],
            axis=1,
        )

        observation_df.reset_index(inplace=True, drop=True)
        return observation_df

    def duplicate_observation(self, observation_name: str, new_observation_name: str) -> str:
        """Duplicate an existing observation under a new name.
        Calls the backend duplication endpoint and returns the server response message.

        :param observation_name: Existing observation name to duplicate.
        :param new_observation_name: New name for the duplicated observation.
        :return: Backend response message."""
        if observation_name == new_observation_name:
            return "Duplicate observation name can't be same"
        url = f"{DUPLICATE_OBSERVATION_URI}?project_name={self.project_name}&observation_name={observation_name}&new_observation_name={new_observation_name}"
        res = self.api_client.post(url)

        if not res["success"]:
            return res.get("details", "Failed to clone triggers")

        return res["details"]

    def create_observation(
        self,
        observation_name: str,
        expression: str,
        statement: str,
        linked_features: List[str],
    ) -> str:
        """Creates New Observation

        :param observation_name: name of observation
        :param expression: expression of observation
            Eg: BldgType !== Duplex and Neighborhood == OldTown
                Ensure that the left side of the conditional operator corresponds to a feature name,
                and the right side represents the comparison value for the feature.
                Valid conditional operators include "!==," "==," ">,", and "<."
                You can perform comparisons between two or more features using
                logical operators such as "and" or "or."
                Additionally, you have the option to use parentheses () to group and prioritize certain conditions.
        :param statement: statement of observation
            Eg: The building type is {BldgType}
                the content inside the curly brackets represents the feature name
        :param linked_features: linked features of observation
        :return: response
        """
        observation_params = self.api_client.get(
            f"{GET_OBSERVATION_PARAMS_URI}?project_name={self.project_name}"
        )

        Validate.string("expression", expression)

        Validate.string("statement", statement)

        Validate.value_against_list(
            "linked_feature",
            linked_features,
            list(observation_params["details"]["features"].keys()),
        )
        configuration, expression = build_expression(expression)

        validate_configuration(
            configuration,
            observation_params["details"],
            self.project_name,
            self.api_client,
            True,
        )

        payload = {
            "project_name": self.project_name,
            "observation_name": observation_name,
            "status": "active",
            "configuration": configuration,
            "metadata": {"expression": expression},
            "statement": [statement],
            "linked_features": linked_features,
        }

        res = self.api_client.post(CREATE_OBSERVATION_URI, payload)
        if not res["success"]:
            raise Exception(res.get("details"))

        return "Observation Created"

    def update_observation(
        self,
        observation_id: str,
        observation_name: str,
        status: Optional[str] = None,
        expression: Optional[str] = None,
        statement: Optional[str] = None,
        linked_features: Optional[List[str]] = None,
    ) -> str:
        """Updates existing Observation with id

        :param observation_id: id of observation
        :param observation_name: name of observation
        :param status: status of observation ["active","inactive"]
        :param expression: new expression for observation, defaults to None
            Eg: BldgType !== Duplex and Neighborhood == OldTown
                Ensure that the left side of the conditional operator corresponds to a feature name,
                and the right side represents the comparison value for the feature.
                Valid conditional operators include "!==," "==," ">,", and "<."
                You can perform comparisons between two or more features using
                logical operators such as "and" or "or."
                Additionally, you have the option to use parentheses () to group and prioritize certain conditions.
        :param statement: new statement for observation, defaults to None
            Eg: The building type is {BldgType}
                the content inside the curly brackets represents the feature name
        :param linked_features: new linked features for observation, defaults to None
        :return: response
        """
        if not status and not expression and not statement and not linked_features:
            raise Exception("update parameters for observation not passed")

        payload = {
            "project_name": self.project_name,
            "observation_id": observation_id,
            "observation_name": observation_name,
            "update_keys": {},
        }

        observation_params = self.api_client.get(
            f"{GET_OBSERVATION_PARAMS_URI}?project_name={self.project_name}"
        )

        if expression:
            Validate.string("expression", expression)
            configuration, expression = build_expression(expression)
            validate_configuration(
                configuration,
                observation_params["details"],
                self.project_name,
                self.api_client,
            )
            payload["update_keys"]["configuration"] = configuration
            payload["update_keys"]["metadata"] = {"expression": expression}

        if linked_features:
            Validate.value_against_list(
                "linked_feature",
                linked_features,
                list(observation_params["details"]["features"].keys()),
            )
            payload["update_keys"]["linked_features"] = linked_features

        if statement:
            Validate.string("statement", statement)
            payload["update_keys"]["statement"] = [statement]

        if status:
            Validate.value_against_list("status", status, ["active", "inactive"])
            payload["update_keys"]["status"] = status

        res = self.api_client.post(UPDATE_OBSERVATION_URI, payload)

        if not res["success"]:
            raise Exception(res.get("details"))

        return "Observation Updated"

    def delete_observation(
        self,
        observation_id: str,
        observation_name: str,
    ) -> str:
        """Deletes Observation

        :param observation_id: id of observation
        :param observation_name: name of observation
        :return: response
        """
        payload = {
            "project_name": self.project_name,
            "observation_id": observation_id,
            "observation_name": observation_name,
            "delete": True,
            "update_keys": {},
        }

        res = self.api_client.post(UPDATE_OBSERVATION_URI, payload)

        if not res["success"]:
            raise Exception(res.get("details"))

        return "Observation Deleted"

    def policies(self) -> pd.DataFrame:
        """List of Policies

        :return: policy details dataframe
        """
        res = self.api_client.get(
            f"{GET_POLICIES_URI}?project_name={self.project_name}"
        )

        policy_df = pd.DataFrame(res.get("details"))

        if policy_df.empty:
            return policy_df

        policy_df = policy_df[policy_df["status"].isin(["active", "inactive"])]

        if policy_df.empty:
            return policy_df

        policy_df["expression"] = policy_df["metadata"].apply(
            lambda metadata: generate_expression(metadata["expression"])
        )

        policy_df = policy_df.drop(
            columns=[
                "project_name",
                "configuration",
                "metadata",
                "linked_features",
                "updated_by",
                "created_by",
                "updated_keys",
            ],
            errors="ignore",
        )
        policy_df = policy_df.reindex(
            [
                "policy_id",
                "policy_name",
                "status",
                "statement",
                "decision",
                "expression",
                "created_at",
                "updated_at",
            ],
            axis=1,
        )
        policy_df.reset_index(inplace=True, drop=True)
        return policy_df

    def policy_trail(self) -> pd.DataFrame:
        """List of Policy Trail

        :return: observation details dataframe
        """
        res = self.api_client.get(
            f"{GET_POLICIES_URI}?project_name={self.project_name}"
        )

        if not res.get("details"):
            raise Exception("No policies found")

        policy_df = pd.DataFrame(res.get("details"))
        policy_df = policy_df[policy_df["status"].isin(["updated", "deleted"])]
        if policy_df.empty:
            raise Exception("No policy trail found")
        policy_df = policy_df.rename(
            columns={
                "statement": "old_statement",
                "decision": "old_decision",
            }
        )

        policy_df["updated_keys"].replace(float("nan"), None, inplace=True)

        policy_df["updated_statement"] = policy_df["updated_keys"].apply(
            lambda data: data.get("statement") if data else None
        )

        policy_df["updated_decision"] = policy_df["updated_keys"].apply(
            lambda data: data.get("decision") if data else None
        )

        policy_df["old_expression"] = policy_df["metadata"].apply(
            lambda metadata: generate_expression(metadata["expression"])
        )

        policy_df["updated_expression"] = policy_df["updated_keys"].apply(
            lambda data: (
                generate_expression(data.get("metadata", {}).get("expression"))
                if data
                else None
            )
        )

        policy_df = policy_df.drop(
            columns=[
                "project_name",
                "configuration",
                "metadata",
                "updated_by",
                "created_by",
                "updated_keys",
                "linked_features",
            ],
            errors="ignore",
        )
        policy_df = policy_df.reindex(
            [
                "policy_id",
                "policy_name",
                "status",
                "old_statement",
                "updated_statement",
                "old_decision",
                "updated_decision",
                "old_expression",
                "updated_expression",
                "created_at",
                "updated_at",
            ],
            axis=1,
        )
        policy_df.reset_index(inplace=True, drop=True)

        return policy_df

    def duplicate_policy(self, policy_name, new_policy_name) -> str:
        """Duplicate an existing policy under a new name.
        Calls the backend duplication endpoint and returns the server response message.

        :param policy_name: Existing policy name to duplicate.
        :param new_policy_name: New name for the duplicated policy.
        :return: Backend response message."""
        if policy_name == new_policy_name:
            return "Duplicate observation name can't be same"
        url = f"{DUPLICATE_POLICY_URI}?project_name={self.project_name}&policy_name={policy_name}&new_policy_name={new_policy_name}"
        res = self.api_client.post(url)

        if not res["success"]:
            return res.get("details", "Failed to clone Policy")

        return res["details"]

    def create_policy(
        self,
        policy_name: str,
        expression: str,
        statement: str,
        decision: str,
        input: Optional[str] = None,
        models: Optional[list] = [],
        priority: Optional[int] = 5,
    ) -> str:
        """Creates New Policy

        :param policy_name: name of policy
        :param expression: expression of policy
            Eg: BldgType !== Duplex and Neighborhood == OldTown
                Ensure that the left side of the conditional operator corresponds to a feature name,
                and the right side represents the comparison value for the feature.
                Valid conditional operators include "!==," "==," ">,", and "<."
                You can perform comparisons between two or more features using
                logical operators such as "and" or "or."
                Additionally, you have the option to use parentheses () to group and prioritize certain conditions.
        :param statement: statement of policy
            Eg: The building type is {BldgType}
                the content inside the curly brackets represents the feature name
        :param decision: decision of policy
        :param input: custom input for the decision if input selected for decision of policy
        :param models: List of trained model names - The policy will only execute for the selected model. In case of empty list will execute for all models
        :param priority: Priority of the policy. Lower number indicates higher priority. Defaults to 5
        :return: response
        """
        configuration, expression = build_expression(expression)

        policy_params = self.api_client.get(
            f"{GET_POLICY_PARAMS_URI}?project_name={self.project_name}"
        )

        validate_configuration(
            configuration, policy_params["details"], self.project_name, self.api_client
        )

        Validate.value_against_list(
            "decision", decision, list(policy_params["details"]["decision"].values())[0]
        )

        if decision == "input":
            Validate.string("Decision input", input)

        payload = {
            "project_name": self.project_name,
            "policy_name": policy_name,
            "status": "active",
            "configuration": configuration,
            "metadata": {"expression": expression},
            "statement": [statement],
            "decision": input if decision == "input" else decision,
            "models": models,
            "priority": priority,
        }

        res = self.api_client.post(CREATE_POLICY_URI, payload)
        if not res["success"]:
            raise Exception(res.get("details"))

        return "Policy Created"

    def update_policy(
        self,
        policy_id: str,
        policy_name: str,
        status: Optional[str] = None,
        expression: Optional[str] = None,
        statement: Optional[str] = None,
        decision: Optional[str] = None,
        input: Optional[str] = None,
        models: Optional[list] = None,
        priority: Optional[int] = None,
    ) -> str:
        """Updates Existing Policy

        :param policy_id: id of policy
        :param policy_name: name of policy
        :param status: status of policy ["active","inactive"]
        :param expression: new expression for policy, defaults to None
            Eg: BldgType !== Duplex and Neighborhood == OldTown
                Ensure that the left side of the conditional operator corresponds to a feature name,
                and the right side represents the comparison value for the feature.
                Valid conditional operators include "!==," "==," ">,", and "<."
                You can perform comparisons between two or more features using
                logical operators such as "and" or "or."
                Additionally, you have the option to use parentheses () to group and prioritize certain conditions.
        :param statement: new statment for policy, defaults to None
            Eg: The building type is {BldgType}
                the content inside the curly brackets represents the feature name
        :param decision: new decision for policy, defaults to None
        :param input: custom input for the decision if input selected for decision of policy
        :param models: List of trained model names - The policy will only execute for the selected model. In case of empty list will execute for all models
        :param priority: Priority of the policy. Lower number indicates higher priority. Defaults to 5
        :return: response
        """
        if not status and not expression and not statement and not decision:
            raise Exception("update parameters for policy not passed")

        payload = {
            "project_name": self.project_name,
            "policy_id": policy_id,
            "policy_name": policy_name,
            "update_keys": {},
        }

        policy_params = self.api_client.get(
            f"{GET_POLICY_PARAMS_URI}?project_name={self.project_name}"
        )

        if expression:
            Validate.string("expression", expression)
            configuration, expression = build_expression(expression)
            validate_configuration(
                configuration,
                policy_params["details"],
                self.project_name,
                self.api_client,
            )
            payload["update_keys"]["configuration"] = configuration
            payload["update_keys"]["metadata"] = {"expression": expression}

        if statement:
            Validate.string("statement", statement)
            payload["update_keys"]["statement"] = [statement]

        if status:
            Validate.value_against_list("status", status, ["active", "inactive"])
            payload["update_keys"]["status"] = status

        if decision:
            Validate.value_against_list(
                "decision",
                decision,
                list(policy_params["details"]["decision"].values())[0],
            )
            if decision == "input":
                Validate.string("Decision input", input)
            payload["update_keys"]["decision"] = (
                input if decision == "input" else decision
            )

        if models:
            payload["update_keys"]["models"] = models

        if priority:
            payload["update_keys"]["priority"] = priority

        res = self.api_client.post(UPDATE_POLICY_URI, payload)

        if not res["success"]:
            raise Exception(res.get("details"))

        return "Policy Updated"

    def delete_policy(
        self,
        policy_id: str,
        policy_name: str,
    ) -> str:
        """Deletes Policy with given id and name

        :param policy_id: id of policy
        :param policy_name: name of policy
        :return: response
        """
        payload = {
            "project_name": self.project_name,
            "policy_id": policy_id,
            "policy_name": policy_name,
            "delete": True,
            "update_keys": {},
        }

        res = self.api_client.post(UPDATE_POLICY_URI, payload)

        if not res["success"]:
            raise Exception(res.get("details"))

        return "Policy Deleted"

    def get_synthetic_model_params(self) -> dict:
        """get hyper parameters of synthetic models

        :return: hyper params
        """
        return self.api_client.get(GET_SYNTHETIC_MODEL_PARAMS_URI)

    def train_synthetic_model(
        self,
        model_name: str,
        node: str,
        data_config: Optional[SyntheticDataConfig] = {},
        hyper_params: Optional[SyntheticModelHyperParams] = {},
    ) -> str:
        """Train synthetic model

        :param model_name: model name ['GPT2', 'CTGAN']
        :param data_config: config for the data
            {
                "tags": List[str]
                "feature_exclude": List[str]
                "feature_include": List[str]
                "drop_duplicate_uid": bool
            },
            defaults to {}
        :param hyper_params: hyper parameters for the model. check param type and value range below,
            For GPT2 (Generative Pretrained Transformer) model - Works well on high dimensional tabular data,
            {
                "batch_size": int [1, 500] defaults to 100
                "early_stopping_patience": int [1, 100], defaults to 10
                "early_stopping_threshold": float [0.1, 100], defaults to 0.0001
                "epochs": int [1, 150], defaults to 100
                "model_type": "tabular",
                "random_state": int [1, 150], defaults to 1
                "tabular_config": "GPT2Config",
                "train_size": float [0, 0.9] defaults to 0.8
            }
            For CTGAN (Conditional Tabular GANs) model - Balances between training computation and dimensionality,
            {
                "epochs": int, [1, 150] defaults to 100
                "test_ratio": float [0, 1] defaults to 0.2
            }
            defaults to {}
        :param node: type of node to run training
            for all available GPU nodes check lexsi.available_node_servers(type="GPU")

        :return: response
        """

        project_config = self.config()

        if project_config == "Not Found":
            raise Exception("Upload files first")

        project_config = project_config["metadata"]

        if node != "shared":
            available_servers = self.api_client.get(
                AVAILABLE_SYNTHETIC_CUSTOM_SERVERS_URI
            )["details"]
            servers = list(
                map(lambda instance: instance["instance_name"], available_servers)
            )
            Validate.value_against_list("instance_type", node, servers)

        all_models_param = self.get_synthetic_model_params()

        try:
            model_params = all_models_param[model_name]
        except KeyError as e:
            availabel_models = list(all_models_param.keys())
            Validate.value_against_list("model", [model_name], availabel_models)

        # validate and prepare data config
        data_config["model_name"] = model_name

        available_tags = self.tags()
        tags = data_config.get("tags", available_tags)

        Validate.value_against_list("tag", tags, available_tags)

        feature_exclude = data_config.get(
            "feature_exclude", project_config["feature_exclude"]
        )

        Validate.value_against_list(
            "feature_exclude", feature_exclude, project_config["avaialble_options"]
        )

        feature_include = data_config.get(
            "feature_include", project_config["feature_include"]
        )

        Validate.value_against_list(
            "feature_include",
            feature_include,
            project_config["avaialble_options"],
        )

        drop_duplicate_uid = data_config.get(
            "drop_duplicate_uid", project_config["drop_duplicate_uid"]
        )

        SYNTHETIC_MODELS_DEFAULT_HYPER_PARAMS[model_name].update(hyper_params)
        hyper_params = SYNTHETIC_MODELS_DEFAULT_HYPER_PARAMS[model_name]

        # validate model hyper parameters
        for key, value in hyper_params.items():
            model_param = model_params.get(key, None)

            if model_param:
                if model_param["type"] == "input":
                    if model_param["value"] == "int":
                        if not isinstance(value, int):
                            raise Exception(f"{key} value should be integer")
                    elif model_param["value"] == "float":
                        if not isinstance(value, float):
                            raise Exception(f"{key} value should be float")

                        if value < model_param["min"] or value > model_param["max"]:
                            raise Exception(
                                f"{key} value should be between {model_param['min']} and {model_param['max']}"
                            )
                    elif model_param["type"] == "select":
                        Validate.value_against_list(
                            "value", [value], model_param["value"]
                        )

        print(f"Using data config: {json.dumps(data_config, indent=4)}")
        print(f"Using hyper params: {json.dumps(hyper_params, indent=4)}")

        payload = {
            "project_name": self.project_name,
            "model_name": model_name,
            "instance_type": node,
            "metadata": {
                "model_name": model_name,
                "tags": tags,
                "feature_exclude": feature_exclude,
                "feature_include": feature_include,
                "feature_actual_used": [],
                "drop_duplicate_uid": drop_duplicate_uid,
                "model_parameters": hyper_params,
            },
        }

        res = self.api_client.post(TRAIN_SYNTHETIC_MODEL_URI, payload)

        if not res["success"]:
            raise Exception(res["details"])

        print("Training initiated...")
        poll_events(self.api_client, self.project_name, res["event_id"])

    def remove_synthetic_model(self, model_name: str) -> str:
        """deletes synthetic model

        :param model_name: model name
        :raises ValueError: _description_
        :raises Exception: _description_
        :return: response message
        """
        models_df = self.synthetic_models()
        valid_models = models_df["model_name"].tolist()

        if model_name not in valid_models:
            raise ValueError(
                f"{model_name} is not valid. Pick a valid value from {valid_models}"
            )

        payload = {"project_name": self.project_name, "model_name": model_name}

        res = self.api_client.post(DELETE_SYNTHETIC_MODEL_URI, payload)

        if not res["success"]:
            raise Exception(res["details"])

        return f"{model_name} deleted successfully."

    def synthetic_models(self) -> pd.DataFrame:
        """get synthetic models for the project

        :return: synthetic models
        """
        url = f"{GET_SYNTHETIC_MODELS_URI}?project_name={self.project_name}"

        res = self.api_client.get(url)

        if not res["success"]:
            raise Exception("Error while getting synthetics models.")

        models = res["details"]

        models_df = pd.DataFrame(models)

        return models_df

    def synthetic_model(self, model_name: str) -> SyntheticModel:
        """get synthetic model details

        :param model_name: model name
        :raises Exception: _description_
        :return: _description_
        """
        models_df = self.synthetic_models()
        valid_models = models_df["model_name"].tolist()

        if model_name not in valid_models:
            raise ValueError(
                f"{model_name} is not valid. Pick a valid value from {valid_models}"
            )

        url = f"{GET_SYNTHETIC_MODEL_DETAILS_URI}?project_name={self.project_name}&model_name={model_name}"

        res = self.api_client.get(url)

        if not res["success"]:
            raise Exception(res["details"])

        model_details = res["details"][0]

        metadata = model_details["metadata"]
        data_quality = model_details["results"]

        del model_details["metadata"]
        del model_details["results"]

        synthetic_model = SyntheticModel(
            **model_details,
            **data_quality,
            metadata=metadata,
            api_client=self.api_client,
            project=self,
        )

        return synthetic_model

    def synthetic_tags(self) -> pd.DataFrame:
        """get synthetic data tags of the model
        :raises Exception: _description_
        :return: list of tags
        """
        url = f"{GET_SYNTHETIC_DATA_TAGS_URI}?project_name={self.project_name}"

        res = self.api_client.get(url)

        if not res["success"]:
            raise Exception("Error while getting synthetics data tags.")

        data_tags = res["details"]

        for data_tag in data_tags:
            del data_tag["metadata"]
            del data_tag["plot_data"]

        return pd.DataFrame(data_tags)

    def synthetic_tag(self, tag: str) -> SyntheticDataTag:
        """get synthetic data tag by tag name
        :param tag: tag name
        :raises Exception: _description_
        :return: tag
        """
        url = f"{GET_SYNTHETIC_DATA_TAGS_URI}?project_name={self.project_name}"

        res = self.api_client.get(url)

        if not res["success"]:
            raise Exception("Error while getting synthetics data tags.")

        data_tags = res["details"]

        syn_data_tags = [
            SyntheticDataTag(
                **data_tag,
                api_client=self.api_client,
                project_name=self.project_name,
                project=self,
            )
            for data_tag in data_tags
        ]

        data_tag = next(
            (syn_data_tag for syn_data_tag in syn_data_tags if syn_data_tag.tag == tag),
            None,
        )

        if not data_tag:
            valid_tags = [syn_data_tag.tag for syn_data_tag in syn_data_tags]
            raise Exception(f"{tag} is invalid. Pick a valid value from {valid_tags}")

        return data_tag

    def synthetic_tag_datapoints(self, tag: str) -> pd.DataFrame:
        """get synthetic tag datapoints

        :param tag: tag name
        :raises Exception: _description_
        :return: datapoints
        """
        all_tags = self.all_tags()

        Validate.value_against_list(
            "tag",
            tag,
            all_tags,
        )

        res = self.api_client.base_request(
            "GET",
            f"{DOWNLOAD_SYNTHETIC_DATA_URI}?project_name={self.project_name}&tag={tag}&token={self.api_client.get_auth_token()}",
        )

        synthetic_data = pd.read_csv(io.StringIO(res.content.decode("utf-8")))

        return synthetic_data

    def remove_synthetic_tag(self, tag: str) -> str:
        """Delete synthetic data tag

        :param tag: Tag name to delete.
        :raises Exception: _description_
        :return: response messsage
        """
        all_tags = self.all_tags()

        Validate.value_against_list(
            "tag",
            tag,
            all_tags,
        )

        payload = {
            "project_name": self.project_name,
            "tag": tag,
        }

        res = self.api_client.post(DELETE_SYNTHETIC_TAG_URI, payload)

        if not res["success"]:
            raise Exception(res["details"])

        return f"{tag} deleted successfully."

    def get_observation_params(self) -> dict:
        """Fetches observation and policy expression parameters for the project. Used for validating expressions, linked features, and supported operators."""
        url = f"{GET_OBSERVATION_PARAMS_URI}?project_name={self.project_name}"

        res = self.api_client.get(url)

        return res["details"]

    def create_synthetic_prompt(self, name: str, expression: str) -> str:
        """Creates synthetic prompt for the project

        :param name: prompt name
        :param expression: expression of policy
            Eg: BldgType !== Duplex and Neighborhood == OldTown
                Ensure that the left side of the conditional operator corresponds to a feature name,
                and the right side represents the comparison value for the feature.
                Valid conditional operators include "!==," "==," ">,", and "<."
                You can perform comparisons between two or more features using
                logical operators such as "and" or "or."
                Additionally, you have the option to use parentheses () to group and prioritize certain conditions.
        :raises Exception: _description_
        :return: response message
        """
        name = name.strip()

        if not name:
            raise Exception("name is required")

        configuration, expression = build_expression(expression)

        prompt_params = self.get_observation_params()
        validate_configuration(
            configuration, prompt_params, self.project_name, self.api_client
        )

        payload = {
            "prompt_name": name,
            "project_name": self.project_name,
            "configuration": configuration,
            "metadata": {"expression": expression},
        }

        res = self.api_client.post(CREATE_SYNTHETIC_PROMPT_URI, payload)

        if not res["success"]:
            raise Exception(res["details"])

        return "Synthetic prompt created successfully."

    def update_synthetic_prompt(self, prompt_id: str, status: str) -> str:
        """Updates the status of a synthetic prompt.

        :param prompt_id: prompt id
        :param status: active or inactive
        :raises Exception: _description_
        :raises Exception: _description_
        :return: response message
        """
        if status not in ["active", "inactive"]:
            raise Exception(
                "Invalid status value. Pick a valid value from ['active', 'inactive']."
            )

        payload = {
            "delete": False,
            "project_name": self.project_name,
            "prompt_id": prompt_id,
            "update_keys": {"status": status},
        }

        res = self.api_client.post(UPDATE_SYNTHETIC_PROMPT_URI, payload)

        if not res["success"]:
            raise Exception(res["details"])

        return "Synthetic prompt updated successfully."

    def synthetic_prompts(self) -> pd.DataFrame:
        """get synthetic prompts for the project

        :raises Exception: _description_
        :return: _description_
        """
        url = f"{GET_SYNTHETIC_PROMPT_URI}?project_name={self.project_name}"

        res = self.api_client.get(url)

        if not res["success"]:
            raise Exception(res["details"])

        prompts = res["details"]

        return pd.DataFrame(prompts).reindex(
            columns=["prompt_id", "prompt_name", "status", "created_at", "updated_at"]
        )

    def synthetic_prompt(self, prompt_id: str) -> SyntheticPrompt:
        """get synthetic prompt by prompt id

        :param prompt_id: Prompt identifier.
        :raises Exception: _description_
        :return: _description_
        """
        url = f"{GET_SYNTHETIC_PROMPT_URI}?project_name={self.project_name}"
        res = self.api_client.get(url)

        if not res["success"]:
            raise Exception(res["details"])

        prompts = res["details"]

        curr_prompt = next(
            (prompt for prompt in prompts if prompt["prompt_id"] == prompt_id), None
        )

        if not curr_prompt:
            raise Exception(f"Invalid prompt_id")

        return SyntheticPrompt(**curr_prompt, api_client=self.api_client, project=self)

    def evals_tabular(self, model_name: str, tag: Optional[str] = None) -> pd.DataFrame:
        """get evals for ml tabular model

        :param model_name: model name
        :param tag: data tag
        :return: evals
        """
        url = f"{TABULAR_ML}?model_name={model_name}&project_name={self.project_name}&tag={tag}"
        res = self.api_client.post(url)
        if not res["success"]:
            raise Exception(res["message"])

        return pd.DataFrame(res["comparison_metrics"])

    def get_feature_importance(
        self, model_name: str, feature_name: str, xai_method: str
    ) -> float:
        """Fetches feature-importance values for a given model, feature, and XAI method.

        :param model_name: Trained model name.
        :param feature_name: Feature/column name.
        :param xai_method: Explainability method (e.g. `shap`, `lime`).
        :return: Feature importance values (backend response payload)."""
        url = f"{GET_FEATURE_IMPORTANCE_URI}?project_name={self.project_name}&model_name={model_name}&feature_name={feature_name}&xai_method={xai_method}"
        res = self.api_client.get(url)
        if not res["success"]:
            raise Exception(res["message"])
        return res.get("feature_importance", "")

    def get_score(self, dashboard_id: str, feature_name: str) -> dict:
        """Fetch dashboard score/drift details for a single feature.

        :param dashboard_id: Dashboard identifier to query.
        :param feature_name: Feature/column name to match within drift results.
        :return: The matched feature entry dict, or None if not found."""
        resp = self.api_client.get(
            f"{GET_DASHBOARD_SCORE_URI}?project_name={self.project_name}&dashboard_id={dashboard_id}&feature_name={feature_name}"
        )
        resp = resp.get("details").get("dashboards")
        logs = pd.DataFrame(resp)
        logs.drop(
            columns=[
                "max_features",
                "limit_features",
                "baseline_date",
                "current_date",
                "task_id",
                "date_feature",
                "stat_test_threshold",
                "project_name",
                "file_id",
                "updated_at",
                "features_to_use",
            ],
            inplace=True,
            errors="ignore",
        )
        column_drift_results = logs.metadata[0].get("DatasetColumnDriftResults")
        matched_column_info = next(
            (
                item
                for item in column_drift_results
                if item.get("column_name") == feature_name
            ),
            None,
        )
        return matched_column_info
    
    def register_case(
        self,
        token: str,
        client_id: str,
        unique_identifier: str,
        project_name: str,
        tag: str,
        data: str,
        serverless_type: Optional[str] = None,
        xai: Optional[List[str]] = None
    ) -> dict:
        """
        Register a new case entry with raw or processed data for the project and return the computed result.

        :param token: Lexsi API authentication token
        :param client_id: Lexsi username or client ID
        :param unique_identifier: Filename or unique identifier for the tabular case
        :param project_name: Target project name for this tabular case
        :param tag: Dataset tag to associate with this upload or prediction
        :param data: List of JSON objects containing feature key–value pairs
        :param serverless_type: Serverless type (e.g., nova-2, gova-2, or local)
        :param xai: Explainability technique to run (e.g., shap, lime, ig, dlb)

        :return: Response containing the prediction results for the registered case
        """
        form_data = {
            "client_id": client_id,
            "project_name": project_name,
            "unique_identifier": unique_identifier,
            "tag": tag,
            "data": json.dumps(data) if isinstance(data, list) else data,
            "serverless_type": serverless_type,
            "xai": xai
        }
        headers = {"x-api-token": token}
        form_data = {k: v for k, v in form_data.items() if v is not None}
        files = {}

        with httpx.Client(http2=True, timeout=None) as client:
            response = client.post(
                self.env.get_base_url() + "/" + UPLOAD_DATA_PROJECT_URI,
                data=form_data,
                files=files or None,
                headers=headers,
            )
            response.raise_for_status()
            response = response.json()

        return response


class CaseTabular(BaseModel):
    """Represents an explainability case for a prediction. Provides visualization helpers such as SHAP, LIME, DLB and decision paths for tabular data."""
    
    status: str
    true_value: str | int
    pred_value: str | int
    pred_category: str | int
    observations: List
    shap_feature_importance: Optional[Dict] = {}
    lime_feature_importance: Optional[Dict] = {}
    ig_features_importance: Optional[Dict] = {}
    dlb_feature_importance: Optional[Dict] = {}
    similar_cases: List
    is_automl_prediction: Optional[bool] = False
    model_name: str
    case_prediction_path: Optional[str] = ""
    case_prediction_svg: Optional[str] = ""
    observation_checklist: Optional[List] = []
    policy_checklist: Optional[List] = []
    final_decision: Optional[str] = ""
    unique_identifier: Optional[str] = ""
    tag: Optional[str] = ""
    created_at: Optional[str] = ""
    data: Optional[Dict] = {}
    similar_cases_data: Optional[List] = []
    audit_trail: Optional[dict] = {}
    project_name: Optional[str] = ""
    data_id: Optional[str] = ""
    summary: Optional[str] = ""
    model_config = ConfigDict(protected_namespaces=())

    api_client: APIClient

    def __init__(self, **kwargs):
        """Capture API client used to fetch additional explainability data.
        Stores configuration and prepares the object for use."""
        super().__init__(**kwargs)
        self.api_client = kwargs.get("api_client")

    def xai_shap(self):
        """Plot a horizontal bar chart showing SHAP-based feature importance for the case. Uses stored Shapley values for features."""
        fig = go.Figure()

        if len(list(self.shap_feature_importance.values())) < 1:
            return "No Shap Feature Importance for the case"

        if isinstance(list(self.shap_feature_importance.values())[0], dict):
            for col in self.shap_feature_importance.keys():
                fig.add_trace(
                    go.Bar(
                        x=list(self.shap_feature_importance[col].values()),
                        y=list(self.shap_feature_importance[col].keys()),
                        orientation="h",
                        name=col,
                    )
                )
        else:
            fig.add_trace(
                go.Bar(
                    x=list(self.shap_feature_importance.values()),
                    y=list(self.shap_feature_importance.keys()),
                    orientation="h",
                )
            )
        fig.update_layout(
            barmode="relative",
            height=800,
            width=800,
            yaxis_autorange="reversed",
            bargap=0.01,
            legend_orientation="h",
            legend_x=0.1,
            legend_y=1.1,
        )
        fig.show(config={"displaylogo": False})

    def xai_ig(self):
        """Plot a horizontal bar chart showing Integrated Gradients-based feature importance for the case."""
        fig = go.Figure()

        if len(list(self.ig_features_importance.values())) < 1:
            return "No IG Feature Importance for the case"

        if isinstance(list(self.ig_features_importance.values())[0], dict):
            for col in self.ig_features_importance.keys():
                fig.add_trace(
                    go.Bar(
                        x=list(self.ig_features_importance[col].values()),
                        y=list(self.ig_features_importance[col].keys()),
                        orientation="h",
                        name=col,
                    )
                )
        else:
            fig.add_trace(
                go.Bar(
                    x=list(self.ig_features_importance.values()),
                    y=list(self.ig_features_importance.keys()),
                    orientation="h",
                )
            )
        fig.update_layout(
            barmode="relative",
            height=800,
            width=800,
            yaxis_autorange="reversed",
            bargap=0.01,
            legend_orientation="h",
            legend_x=0.1,
            legend_y=1.1,
        )
        fig.show(config={"displaylogo": False})

    def xai_lime(self):
        """Plot a horizontal bar chart showing LIME-based feature importance for the case."""
        fig = go.Figure()

        if len(list(self.lime_feature_importance.values())) < 1:
            return "No Lime Feature Importance for the case"

        if isinstance(list(self.lime_feature_importance.values())[0], dict):
            for col in self.lime_feature_importance.keys():
                fig.add_trace(
                    go.Bar(
                        x=list(self.lime_feature_importance[col].values()),
                        y=list(self.lime_feature_importance[col].keys()),
                        orientation="h",
                        name=col,
                    )
                )
        else:
            fig.add_trace(
                go.Bar(
                    x=list(self.lime_feature_importance.values()),
                    y=list(self.lime_feature_importance.keys()),
                    orientation="h",
                )
            )
        fig.update_layout(
            barmode="relative",
            height=800,
            width=800,
            yaxis_autorange="reversed",
            bargap=0.01,
            legend_orientation="h",
            legend_x=0.1,
            legend_y=1.1,
        )
        fig.show(config={"displaylogo": False})

    def xai_dlb(self):
        """Plot a horizontal bar chart showing Deep Lift Bayesian (DLB)-based feature importance for the case."""
        fig = go.Figure()
        if len(list(self.dlb_feature_importance.values())) < 1:
            return "No DLB Feature Importance for the case"

        if isinstance(list(self.dlb_feature_importance.values())[0], dict):
            for col in self.dlb_feature_importance.keys():
                fig.add_trace(
                    go.Bar(
                        x=list(self.dlb_feature_importance[col].values()),
                        y=list(self.dlb_feature_importance[col].keys()),
                        orientation="h",
                        name=col,
                    )
                )
        else:
            fig.add_trace(
                go.Bar(
                    x=list(self.dlb_feature_importance.values()),
                    y=list(self.dlb_feature_importance.keys()),
                    orientation="h",
                )
            )
        fig.update_layout(
            barmode="relative",
            height=800,
            width=800,
            yaxis_autorange="reversed",
            bargap=0.01,
            legend_orientation="h",
            legend_x=0.1,
            legend_y=1.1,
        )
        fig.show(config={"displaylogo": False})

    def xai_prediction_path(self):
        """Display the model’s prediction path as a sequence of decision nodes for the case, typically visualized as an SVG or plot."""
        svg = SVG(self.case_prediction_svg)
        display(svg)

    def xai_raw_data(self) -> pd.DataFrame:
        """Return the raw data used for the case as a DataFrame, with feature names and values.

        :return: raw data dataframe
        """
        raw_data_df = (
            pd.DataFrame([self.data])
            .transpose()
            .reset_index()
            .rename(columns={"index": "Feature", 0: "Value"})
        )
        return raw_data_df

    def xai_observations(self) -> pd.DataFrame:
        """Return a DataFrame listing the checklist of observations (e.g., heuristics or warnings) associated with the case.

        :return: observations dataframe
        """
        observations_df = pd.DataFrame(self.observation_checklist)

        return observations_df

    def xai_policies(self) -> pd.DataFrame:
        """Return a DataFrame listing policies or rules applied during the model’s decision for the case.

        :return: policies dataframe
        """
        policy_df = pd.DataFrame(self.policy_checklist)

        return policy_df

    def inference_output(self) -> pd.DataFrame:
        """Return a DataFrame summarizing the final decision for the case, including the true value, predicted value, predicted category, and final decision.

        :return: decision dataframe
        """
        data = {
            "True Value": self.true_value,
            "Prediction Value": self.pred_value,
            "Prediction Category": self.pred_category,
            "Final Prediction": self.final_decision,
        }
        decision_df = pd.DataFrame([data])

        return decision_df

    def xai_similar_cases(self) -> pd.DataFrame | str:
        """Return a DataFrame of cases similar to the current case (if similar cases are available). If no similar cases are found, returns a message.

        :return: similar cases dataframe
        """
        if not self.similar_cases_data:
            return "No similar cases found. Or add 'similar_cases' in components case_info()"

        similar_cases_df = pd.DataFrame(self.similar_cases_data)
        return similar_cases_df
    
    def alerts_trail(self, page_num: Optional[int] = 1, days: Optional[int] = 7):
        """Fetch alerts for this case over the given window.
        Encapsulates a small unit of SDK logic and returns the computed result."""
        if days == 7:
            return pd.DataFrame(self.audit_trail.get("alerts", {}))
        resp = self.api_client.post(
            f"{GET_TRIGGERS_DAYS_URI}?project_name={self.project_name}&page_num={page_num}&days={days}"
        )
        if resp.get("details"):
            return pd.DataFrame(resp.get("details"))
        else:
            return "No alerts found."

    def audit(self):
        """Return stored audit trail.
        Encapsulates a small unit of SDK logic and returns the computed result."""
        return self.audit_trail

    def feature_importance(self, feature: str) -> float:
        """Return feature importance values for a specific feature.
        Encapsulates a small unit of SDK logic and returns the computed result.
        :param feature: name of the feature for which the importance score is to be fetched.
        :return: feature importance value
        """
        if self.shap_feature_importance:
            return self.shap_feature_importance.get(feature, {})
        elif self.lime_feature_importance:
            return self.lime_feature_importance.get(feature, {})
        elif self.ig_features_importance:
            return self.ig_features_importance.get(feature, {})
        else:
            return "No Feature Importance found for the case"

    def xai_summary(self):
        """Request or return cached explainability summary text.
        Encapsulates a small unit of SDK logic and returns the computed result."""
        if self.data_id and not self.summary:
            payload = {
                "project_name": self.project_name,
                "viewed_case_id": self.data_id,
            }
            res = self.api_client.post(EXPLAINABILITY_SUMMARY, payload)
            if not res.get("success"):
                raise Exception(res.get("details", "Failed to summarize"))

            self.summary = res.get("details")
            return res.get("details")

        return self.summary

