import logging
import os
import time
from typing import Any, Dict

import mlflow
from mlflow.exceptions import BAD_REQUEST, INVALID_PARAMETER_VALUE, MlflowException
from mlflow.recipes.artifacts import DataframeArtifact, RegisteredModelVersionInfo
from mlflow.recipes.cards import BaseCard
from mlflow.recipes.step import BaseStep, StepClass
from mlflow.recipes.steps.register import _REGISTERED_MV_INFO_FILE
from mlflow.recipes.utils.execution import get_step_output_path
from mlflow.recipes.utils.step import get_pandas_data_profiles
from mlflow.recipes.utils.tracking import (
    TrackingConfig,
    apply_recipe_tracking_config,
    get_recipe_tracking_config,
)
from mlflow.utils._spark_utils import (
    _create_local_spark_session_for_recipes,
    _get_active_spark_session,
)
from mlflow.utils.databricks_utils import get_databricks_env_vars
from mlflow.utils.file_utils import write_spark_dataframe_to_parquet_on_local_disk

_logger = logging.getLogger(__name__)


# This should maybe imported from the ingest scoring step for consistency
_INPUT_FILE_NAME = "scoring-dataset.parquet"
_SCORED_OUTPUT_FILE_NAME = "scored.parquet"
_PREDICTION_COLUMN_NAME = "prediction"

# Max dataframe size for profiling after scoring
_MAX_PROFILE_SIZE = 10000
# Environment manager for Spark UDF model restoration
_ENV_MANAGER = "virtualenv"


class PredictStep(BaseStep):
    def __init__(self, step_config: Dict[str, Any], recipe_root: str) -> None:
        super().__init__(step_config, recipe_root)
        self.tracking_config = TrackingConfig.from_dict(self.step_config)

    def _validate_and_apply_step_config(self):
        required_configuration_keys = ["using", "location"]
        for key in required_configuration_keys:
            if key not in self.step_config:
                raise MlflowException(
                    f"The `{key}` configuration key must be specified for the predict step.",
                    error_code=INVALID_PARAMETER_VALUE,
                )
        if self.step_config["using"] not in {"parquet", "delta", "table"}:
            raise MlflowException(
                "Invalid `using` in predict step configuration.",
                error_code=INVALID_PARAMETER_VALUE,
            )
        if "model_uri" not in self.step_config:
            try:
                register_config = self.step_config["model_registry"]
                model_name = register_config["model_name"]
            except KeyError:
                raise MlflowException(
                    "No model specified for batch scoring: model_registry does not have "
                    "`model_uri` and does not have `model_name` configuration key.",
                    error_code=INVALID_PARAMETER_VALUE,
                )
            else:
                self.step_config["model_uri"] = f"models:/{model_name}/latest"
        self.registry_uri = self.step_config.get("registry_uri", None)
        self.skip_data_profiling = self.step_config.get("skip_data_profiling", False)
        self.save_mode = self.step_config.get("save_mode", "overwrite")
        self.run_end_time = None
        self.execution_duration = None

    def _build_profiles_and_card(self, scored_sdf) -> BaseCard:
        # Build profiles for scored dataset
        card = BaseCard(self.recipe_name, self.name)

        scored_size = scored_sdf.count()

        if not self.skip_data_profiling:
            _logger.info("Profiling scored dataset")
            if scored_size > _MAX_PROFILE_SIZE:
                _logger.info("Sampling scored dataset for profiling because dataset size is large.")
                sample_percentage = _MAX_PROFILE_SIZE / scored_size
                scored_sdf = scored_sdf.sample(sample_percentage)
            scored_df = scored_sdf.toPandas()
            scored_dataset_profile = get_pandas_data_profiles(
                [["Profile of Scored Dataset", scored_df]]
            )

            # Optional tab : data profile for scored data:
            card.add_tab("Scored Data Profile", "{{PROFILE}}").add_pandas_profile(
                "PROFILE", scored_dataset_profile
            )

        # Tab #1/2: run summary.
        (
            card.add_tab(
                "Run Summary",
                """
                {{ SCORED_DATA_NUM_ROWS }}
                {{ EXE_DURATION }}
                {{ LAST_UPDATE_TIME }}
                """,
            ).add_markdown(
                "SCORED_DATA_NUM_ROWS",
                f"**Number of scored dataset rows:** `{scored_size}`",
            )
        )

        return card

    def _run(self, output_directory):
        import pandas as pd
        from pyspark.sql.functions import struct

        run_start_time = time.time()

        apply_recipe_tracking_config(self.tracking_config)
        if self.registry_uri:
            mlflow.set_registry_uri(self.registry_uri)

        # Get or create spark session
        try:
            spark = _get_active_spark_session()
            if spark:
                _logger.info("Found active spark session")
            else:
                _logger.info("Creating new spark session")
                spark = _create_local_spark_session_for_recipes()
        except Exception as e:
            raise MlflowException(
                message=(
                    "Encountered an error while getting or creating an active Spark session to"
                    " score dataset with spark UDF."
                ),
                error_code=BAD_REQUEST,
            ) from e

        # read cleaned dataset
        ingested_data_path = get_step_output_path(
            recipe_root_path=self.recipe_root,
            step_name="ingest_scoring",
            relative_path=_INPUT_FILE_NAME,
        )
        # Because the cached parquet file is not on DBFS, we have to first load it as a pandas df
        input_pdf = pd.read_parquet(ingested_data_path)
        input_sdf = spark.createDataFrame(input_pdf)
        if _PREDICTION_COLUMN_NAME in input_sdf.columns:
            _logger.warning(
                f"Input scoring dataframe already contains a column '{_PREDICTION_COLUMN_NAME}'. "
                f"This column will be dropped in favor of the predict output column name."
            )

        # get model uri
        model_uri = self.step_config["model_uri"]
        registered_model_file_path = get_step_output_path(
            recipe_root_path=self.recipe_root,
            step_name="register",
            relative_path=_REGISTERED_MV_INFO_FILE,
        )
        if os.path.exists(registered_model_file_path):
            rmi = RegisteredModelVersionInfo.from_json(path=registered_model_file_path)
            model_uri = f"models:/{rmi.name}/{rmi.version}"

        # scored dataset
        result_type = self.step_config.get("result_type", "double")
        predict = mlflow.pyfunc.spark_udf(
            spark, model_uri, result_type=result_type, env_manager=_ENV_MANAGER
        )
        scored_sdf = input_sdf.withColumn(
            _PREDICTION_COLUMN_NAME, predict(struct(*input_sdf.columns))
        )

        # check if output location is already populated for non-delta output formats
        output_format = self.step_config["using"]
        output_location = self.step_config["location"]
        output_populated = False
        if self.save_mode in ["default", "error", "errorifexists"]:
            if output_format == "parquet" or output_format == "delta":
                output_populated = os.path.exists(output_location)
            else:
                try:
                    output_populated = spark._jsparkSession.catalog().tableExists(output_location)
                except Exception:
                    # swallow spark failures
                    pass
        if output_populated:
            raise MlflowException(
                message=(
                    f"Output location `{output_location}` using format `{output_format}` is "
                    "already populated. To overwrite, please change the spark `save_mode` in "
                    "the predict step configuration."
                ),
                error_code=BAD_REQUEST,
            )

        if output_format == "table":
            try:
                from delta.tables import DeltaTable

                output_populated = DeltaTable.forName(spark, output_location)
            except Exception:
                # swallow spark failures
                pass

            if output_populated:
                _logger.info(f"Table already exists at {output_location}")
                # If the table already exists, we are just setting up the table properties to
                # ensure that the table can be written with column names with spaces.
                spark.sql(
                    f"ALTER TABLE {output_location} SET TBLPROPERTIES "
                    "('delta.columnMapping.mode'='name','delta.minReaderVersion'='2',"
                    "'delta.minWriterVersion'='5')"
                )
            else:
                _logger.info(f"Creating a new table at {output_location}")
                from delta.tables import DeltaTable

                # If the table location specified doesn't exist, we are creating a new table
                # with properties required to ensure that column names can have spaces.
                DeltaTable.create().addColumns(scored_sdf.schema).property(
                    "delta.minReaderVersion", "2"
                ).property("delta.minWriterVersion", "5").property(
                    "delta.columnMapping.mode", "name"
                ).tableName(output_location).execute()
                # We are overriding the save_mode to append for the create case, since the table
                # is already created above, so adding any record to the empty table can be
                # appended to the table
                self.save_mode = "append"

        # save predictions
        if output_format in ["parquet", "delta"]:
            scored_sdf.coalesce(1).write.format(output_format).mode(self.save_mode).save(
                output_location
            )
        else:
            scored_sdf.write.format("delta").mode(self.save_mode).saveAsTable(output_location)

        # predict step artifacts
        write_spark_dataframe_to_parquet_on_local_disk(
            scored_sdf, os.path.join(output_directory, _SCORED_OUTPUT_FILE_NAME)
        )

        self.run_end_time = time.time()
        self.execution_duration = self.run_end_time - run_start_time
        return self._build_profiles_and_card(scored_sdf)

    @classmethod
    def from_recipe_config(cls, recipe_config, recipe_root):
        step_config = {}
        if recipe_config.get("steps", {}).get("predict", {}) is not None:
            step_config.update(recipe_config.get("steps", {}).get("predict", {}))
        if recipe_config.get("steps", {}).get("predict", {}).get("output", {}) is not None:
            step_config.update(recipe_config.get("steps", {}).get("predict", {}).get("output", {}))
        step_config["register"] = recipe_config.get("steps", {}).get("register", {})
        step_config["model_registry"] = recipe_config.get("model_registry", {})
        step_config["recipe"] = recipe_config.get("recipe", "regression/v1")
        if recipe_config.get("model_registry", {}).get("registry_uri") is not None:
            step_config["registry_uri"] = recipe_config.get("model_registry", {}).get(
                "registry_uri"
            )

        step_config.update(
            get_recipe_tracking_config(
                recipe_root_path=recipe_root,
                recipe_config=recipe_config,
            ).to_dict()
        )
        return cls(step_config, recipe_root)

    @property
    def name(self):
        return "predict"

    @property
    def environment(self):
        return get_databricks_env_vars(tracking_uri=self.tracking_config.tracking_uri)

    def get_artifacts(self):
        return [
            DataframeArtifact("scored_data", self.recipe_root, self.name, _SCORED_OUTPUT_FILE_NAME)
        ]

    def step_class(self):
        return StepClass.PREDICTION
