# Model Imports (this will be replaced with the imports for the template)
None

# Template Placeholders
TEMPLATE_PARAMS = {
    "model_type": "regressor",
    "target_column": "solubility",
    "feature_list": ['molwt', 'mollogp', 'molmr', 'heavyatomcount', 'numhacceptors', 'numhdonors', 'numheteroatoms', 'numrotatablebonds', 'numvalenceelectrons', 'numaromaticrings', 'numsaturatedrings', 'numaliphaticrings', 'ringcount', 'tpsa', 'labuteasa', 'balabanj', 'bertzct'],
    "model_class": PyTorch,
    "model_metrics_s3_path": "s3://sandbox-sageworks-artifacts/models/aqsol-pytorch-reg/training",
    "train_all_data": False
}

import awswrangler as wr
from sklearn.preprocessing import LabelEncoder, StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline

from io import StringIO
import json
import argparse
import joblib
import os
import pandas as pd
from typing import List

# Global model_type for both training and inference
model_type = TEMPLATE_PARAMS["model_type"]


# Function to check if dataframe is empty
def check_dataframe(df: pd.DataFrame, df_name: str) -> None:
    """Check if the DataFrame is empty and raise an error if so."""
    if df.empty:
        msg = f"*** The training data {df_name} has 0 rows! ***STOPPING***"
        print(msg)
        raise ValueError(msg)


# Function to expand probability column into individual class probability columns
def expand_proba_column(df: pd.DataFrame, class_labels: List[str]) -> pd.DataFrame:
    """Expand 'pred_proba' column into separate columns for each class label."""
    proba_column = "pred_proba"
    if proba_column not in df.columns:
        raise ValueError('DataFrame does not contain a "pred_proba" column')

    # Create new columns for each class label's probability
    new_col_names = [f"{label}_proba" for label in class_labels]
    proba_df = pd.DataFrame(df[proba_column].tolist(), columns=new_col_names)

    # Drop the original 'pred_proba' column and reset the index
    df = df.drop(columns=[proba_column]).reset_index(drop=True)

    # Concatenate the new probability columns with the original DataFrame
    df = pd.concat([df, proba_df], axis=1)
    return df


# Function to match DataFrame columns to model features (case-insensitive)
def match_features_case_insensitive(df: pd.DataFrame, model_features: list) -> pd.DataFrame:
    """Match and rename DataFrame columns to match the model's features, case-insensitively."""
    # Create a set of exact matches from the DataFrame columns
    exact_match_set = set(df.columns)

    # Create a case-insensitive map of DataFrame columns
    column_map = {col.lower(): col for col in df.columns}
    rename_dict = {}

    # Build a dictionary for renaming columns based on case-insensitive matching
    for feature in model_features:
        if feature in exact_match_set:
            rename_dict[feature] = feature
        elif feature.lower() in column_map:
            rename_dict[column_map[feature.lower()]] = feature

    # Rename columns in the DataFrame to match model features
    return df.rename(columns=rename_dict)


#
# Training Section
#
if __name__ == "__main__":
    # Template Parameters
    target = TEMPLATE_PARAMS["target_column"]  # Can be None for unsupervised models
    feature_list = TEMPLATE_PARAMS["feature_list"]
    model_class = TEMPLATE_PARAMS["model_class"]
    model_metrics_s3_path = TEMPLATE_PARAMS["model_metrics_s3_path"]
    train_all_data = TEMPLATE_PARAMS["train_all_data"]
    validation_split = 0.2

    # Script arguments for input/output directories
    parser = argparse.ArgumentParser()
    parser.add_argument("--model-dir", type=str, default=os.environ.get("SM_MODEL_DIR", "/opt/ml/model"))
    parser.add_argument("--train", type=str, default=os.environ.get("SM_CHANNEL_TRAIN", "/opt/ml/input/data/train"))
    parser.add_argument(
        "--output-data-dir", type=str, default=os.environ.get("SM_OUTPUT_DATA_DIR", "/opt/ml/output/data")
    )
    args = parser.parse_args()

    # Load training data from the specified directory
    training_files = [
        os.path.join(args.train, file)
        for file in os.listdir(args.train) if file.endswith(".csv")
    ]
    all_df = pd.concat([pd.read_csv(file, engine="python") for file in training_files])

    # Check if the DataFrame is empty
    check_dataframe(all_df, "training_df")

    # Initialize the model using the specified model class
    model = model_class()

    # Determine if standardization is needed based on the model type
    needs_standardization = model_type in ["clusterer", "projection"]

    if needs_standardization:
        # Create a pipeline with standardization and the model
        model = Pipeline([
            ("scaler", StandardScaler()),
            ("model", model)
        ])

    # Handle logic based on the model_type
    if model_type in ["classifier", "regressor"]:
        # Supervised Models: Prepare for training
        if train_all_data:
            # Use all data for both training and validation
            print("Training on all data...")
            df_train = all_df.copy()
            df_val = all_df.copy()
        elif "training" in all_df.columns:
            # Split data based on a 'training' column if it exists
            print("Splitting data based on 'training' column...")
            df_train = all_df[all_df["training"]].copy()
            df_val = all_df[~all_df["training"]].copy()
        else:
            # Perform a random split if no 'training' column is found
            print("Splitting data randomly...")
            df_train, df_val = train_test_split(all_df, test_size=validation_split, random_state=42)

        # Encode the target variable if the model is a classifier
        label_encoder = None
        if model_type == "classifier" and target:
            label_encoder = LabelEncoder()
            df_train[target] = label_encoder.fit_transform(df_train[target])
            df_val[target] = label_encoder.transform(df_val[target])

        # Prepare features and targets for training
        X_train = df_train[feature_list]
        X_val = df_val[feature_list]
        y_train = df_train[target] if target else None
        y_val = df_val[target] if target else None

        # Train the model using the training data
        model.fit(X_train, y_train)

        # Make predictions and handle classification-specific logic
        preds = model.predict(X_val)
        if model_type == "classifier" and target:
            # Get class probabilities and expand them into separate columns
            probs = model.predict_proba(X_val)
            df_val["pred_proba"] = [p.tolist() for p in probs]
            df_val = expand_proba_column(df_val, label_encoder.classes_)

            # Decode the target and prediction labels
            df_val[target] = label_encoder.inverse_transform(df_val[target])
            preds = label_encoder.inverse_transform(preds)

        # Add predictions to the validation DataFrame
        df_val["prediction"] = preds

        # Save the validation predictions to S3
        output_columns = [target, "prediction"] + [col for col in df_val.columns if col.endswith("_proba")]
        wr.s3.to_csv(df_val[output_columns], path=f"{model_metrics_s3_path}/validation_predictions.csv", index=False)

    elif model_type == "clusterer":
        # Unsupervised Clustering Models: Assign cluster labels
        all_df["cluster"] = model.fit_predict(all_df[feature_list])

    elif model_type == "projection":
        # Projection Models: Apply transformation and label first three components as x, y, z
        transformed_data = model.fit_transform(all_df[feature_list])
        num_components = transformed_data.shape[1]

        # Special labels for the first three components, if they exist
        special_labels = ["x", "y", "z"]
        for i in range(num_components):
            if i < len(special_labels):
                all_df[special_labels[i]] = transformed_data[:, i]
            else:
                all_df[f"component_{i + 1}"] = transformed_data[:, i]

    elif model_type == "transformer":
        # Transformer Models: Apply transformation and use generic component labels
        transformed_data = model.fit_transform(all_df[feature_list])
        for i in range(transformed_data.shape[1]):
            all_df[f"component_{i + 1}"] = transformed_data[:, i]

    # Save the trained model and any necessary assets
    joblib.dump(model, os.path.join(args.model_dir, "model.joblib"))
    if model_type == "classifier" and label_encoder:
        joblib.dump(label_encoder, os.path.join(args.model_dir, "label_encoder.joblib"))

    # Save the feature list to validate input during predictions
    with open(os.path.join(args.model_dir, "feature_columns.json"), "w") as fp:
        json.dump(feature_list, fp)

#
# Inference Section
#
def model_fn(model_dir):
    """Load and return the model from the specified directory."""
    return joblib.load(os.path.join(model_dir, "model.joblib"))


def input_fn(input_data, content_type):
    """Parse input data and return a DataFrame."""
    if not input_data:
        raise ValueError("Empty input data is not supported!")

    # Decode bytes to string if necessary
    if isinstance(input_data, bytes):
        input_data = input_data.decode("utf-8")

    if "text/csv" in content_type:
        return pd.read_csv(StringIO(input_data))
    elif "application/json" in content_type:
        return pd.DataFrame(json.loads(input_data))  # Assumes JSON array of records
    else:
        raise ValueError(f"{content_type} not supported!")


def output_fn(output_df, accept_type):
    """Supports both CSV and JSON output formats."""
    if "text/csv" in accept_type:
        csv_output = output_df.fillna("N/A").to_csv(index=False)  # CSV with N/A for missing values
        return csv_output, "text/csv"
    elif "application/json" in accept_type:
        return output_df.to_json(orient="records"), "application/json"  # JSON array of records (NaNs -> null)
    else:
        raise RuntimeError(f"{accept_type} accept type is not supported by this script.")


def predict_fn(df, model):
    """Make predictions or apply transformations using the model and return the DataFrame with results."""
    model_dir = os.environ.get("SM_MODEL_DIR", "/opt/ml/model")

    # Load feature columns from the saved file
    with open(os.path.join(model_dir, "feature_columns.json")) as fp:
        model_features = json.load(fp)

    # Load label encoder if available (for classification models)
    label_encoder = None
    if os.path.exists(os.path.join(model_dir, "label_encoder.joblib")):
        label_encoder = joblib.load(os.path.join(model_dir, "label_encoder.joblib"))

    # Match features in a case-insensitive manner
    matched_df = match_features_case_insensitive(df, model_features)

    # Initialize a dictionary to store the results
    results = {}

    # Determine how to handle the model based on its available methods
    if hasattr(model, "predict"):
        # For supervised models (classifier or regressor)
        predictions = model.predict(matched_df[model_features])
        results["prediction"] = predictions

    elif hasattr(model, "fit_predict"):
        # For clustering models (e.g., DBSCAN)
        clusters = model.fit_predict(matched_df[model_features])
        results["cluster"] = clusters

    elif hasattr(model, "fit_transform") and not hasattr(model, "predict"):
        # For transformation/projection models (e.g., t-SNE, PCA)
        transformed_data = model.fit_transform(matched_df[model_features])

        # Handle 2D projection models specifically
        if model_type == "projection" and transformed_data.shape[1] == 2:
            results["x"] = transformed_data[:, 0]
            results["y"] = transformed_data[:, 1]
        else:
            # General case for any number of components
            for i in range(transformed_data.shape[1]):
                results[f"component_{i + 1}"] = transformed_data[:, i]

    else:
        # Raise an error if the model does not support the expected methods
        raise ValueError("Model does not support predict, fit_predict, or fit_transform methods.")

    # Decode predictions if using a label encoder (for classification)
    if label_encoder and "prediction" in results:
        results["prediction"] = label_encoder.inverse_transform(results["prediction"])

    # Add the results to the DataFrame
    for key, value in results.items():
        df[key] = value

    # Add probability columns if the model supports it (for classification)
    if hasattr(model, "predict_proba"):
        probs = model.predict_proba(matched_df[model_features])
        df["pred_proba"] = [p.tolist() for p in probs]
        df = expand_proba_column(df, label_encoder.classes_)

    # Return the modified DataFrame
    return df
