import pandas as pd
import logging
from logging.handlers import RotatingFileHandler
import shutil
import os
import subprocess
from datetime import datetime, date
from ftfy import fix_text
import unicodedata
import re
import string

from lecrapaud.directories import logger_dir
from lecrapaud.config import LOGGING_LEVEL, PYTHON_ENV


_LECRAPAUD_LOGGER_ALREADY_CONFIGURED = False


def setup_logger():

    name = "lecrapaud"

    global _LECRAPAUD_LOGGER_ALREADY_CONFIGURED
    if _LECRAPAUD_LOGGER_ALREADY_CONFIGURED:  # ← bail out if done before
        return logging.getLogger(name)

    print(
        f"Setting up logger for {name} with PYTHON_ENV {PYTHON_ENV} and LOGGING_LEVEL {LOGGING_LEVEL}"
    )
    # ------------------------------------------------------------------ #
    #  Real configuration happens only on the FIRST call                 #
    # ------------------------------------------------------------------ #
    fmt = "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s - %(message)s"
    datefmt = "%Y-%m-%d %H:%M:%S"
    logging.basicConfig(format=fmt, datefmt=datefmt)  # root format
    formatter = logging.Formatter(fmt, datefmt=datefmt)

    logger = logging.getLogger(name)

    log_level = getattr(logging, LOGGING_LEVEL.upper(), logging.INFO)
    logger.setLevel(log_level)

    # pick a file according to environment
    env_file = {
        "Development": "dev.log",
        "Production": "prod.log",
        "Test": "test.log",
        "Worker": "worker.log",
    }.get(PYTHON_ENV, "app.log")

    if logger_dir:
        file_handler = RotatingFileHandler(
            f"{logger_dir}/{env_file}",
            maxBytes=5 * 1024 * 1024,
            backupCount=3,
        )
        file_handler.setFormatter(formatter)
        file_handler.setLevel(log_level)
        logger.addHandler(file_handler)

    try:
        from lecrapaud.integrations.sentry_integration import init_sentry

        if init_sentry():
            logger.info("Sentry logging enabled")
    except Exception as exc:
        logger.info(f"Sentry logging disabled: {exc}")

    _LECRAPAUD_LOGGER_ALREADY_CONFIGURED = True
    return logger


logger = setup_logger()


def get_df_name(obj, namespace):
    return [name for name in namespace if namespace[name] is obj][0]


def pprint(item):
    with pd.option_context("display.max_rows", None):
        logger.info(item)


def object_to_dict(obj):
    if isinstance(obj, dict):
        return {k: object_to_dict(v) for k, v in obj.items()}
    elif hasattr(obj, "__dict__"):
        return {k: object_to_dict(v) for k, v in obj.__dict__.items()}
    elif isinstance(obj, list):
        return [object_to_dict(i) for i in obj]
    else:
        return obj


def copy_any(src, dst):
    if os.path.isdir(src):
        # Copy folder using copytree
        shutil.copytree(src, dst)
    else:
        # Copy file using copy2 (which preserves metadata)
        shutil.copy2(src, dst)


def contains_best(folder_path):
    # Iterate over all files and folders in the specified directory
    for root, dirs, files in os.walk(folder_path):
        # Check each file and folder name for '.best' or '.keras'
        for name in files + dirs:
            if ".best" in name or ".keras" in name:
                return True
    return False


def get_folder_sizes(directory=os.path.expanduser("~")):
    folder_sizes = {}

    for folder in os.listdir(directory):
        folder_path = os.path.join(directory, folder)
        if os.path.isdir(folder_path):
            try:
                size = (
                    subprocess.check_output(["du", "-sk", folder_path])
                    .split()[0]
                    .decode("utf-8")
                )
                folder_sizes[folder] = int(size)
            except subprocess.CalledProcessError:
                logger.info(f"Skipping {folder_path}: Permission Denied")

    sorted_folders = sorted(folder_sizes.items(), key=lambda x: x[1], reverse=True)
    logger.info(f"{'Folder':<50}{'Size (MB)':>10}")
    logger.info("=" * 60)
    for folder, size in sorted_folders:
        logger.info(f"{folder:<50}{size / (1024*1024):>10.2f}")


def create_cron_job(
    script_path,
    venv_path,
    log_file,
    pythonpath,
    cwd,
    job_frequency="* * * * *",
    cron_name="My Custom Cron Job",
):
    """
    Creates a cron job to run a Python script with a virtual environment, logging output, and setting PYTHONPATH and CWD.

    Parameters:
    - script_path (str): Path to the Python script to run.
    - venv_path (str): Path to the virtual environment's Python interpreter.
    - log_file (str): Path to the log file for output.
    - pythonpath (str): Value for the PYTHONPATH environment variable.
    - cwd (str): Working directory from which the script should run.
    - job_frequency (str): Cron timing syntax (default is every minute).
    - cron_name (str): Name to identify the cron job.
    """
    # Construct the cron command
    cron_command = (
        f"{job_frequency} /bin/zsh -c 'pgrep -fl python | grep -q {os.path.basename(script_path)} "
        f'|| (echo -e "Cron job {cron_name} started at $(date)" >> {log_file} && cd {cwd} && '
        f"PYTHONPATH={pythonpath} {venv_path}/bin/python {script_path} >> {log_file} 2>&1)'"
    )

    # Check existing cron jobs and remove any with the same comment
    subprocess.run(f"(crontab -l | grep -v '{cron_name}') | crontab -", shell=True)

    # Add the new cron job with the comment
    full_cron_job = f"{cron_command} # {cron_name}\n"
    subprocess.run(f'(crontab -l; echo "{full_cron_job}") | crontab -', shell=True)
    logger.info(f"Cron job created: {full_cron_job}")


def remove_all_cron_jobs():
    """
    Removes all cron jobs for the current user.
    """
    try:
        # Clear the user's crontab
        subprocess.run("crontab -r", shell=True, check=True)
        logger.info("All cron jobs have been removed successfully.")
    except subprocess.CalledProcessError:
        logger.info(
            "Failed to remove cron jobs. There may not be any cron jobs to remove, or there could be a permissions issue."
        )


def serialize_timestamp(dict: dict):
    def convert(obj):
        if isinstance(obj, (datetime, date, pd.Timestamp)):
            return obj.isoformat()

        return obj

    return [{k: convert(v) for k, v in item.items()} for item in dict]


def remove_accents(text: str) -> str:
    """
    Cleans the text of:
    - Broken Unicode
    - Accents
    - Control characters (including \x00, \u0000, etc.)
    - Escape sequences
    - Non-printable characters
    - Excessive punctuation (like ........ or !!!!)
    """

    # Step 1: Fix mojibake and broken Unicode
    text = fix_text(text)

    # Step 2 bis: Normalize accents
    text = unicodedata.normalize("NFKD", text)
    text = text.encode("ASCII", "ignore").decode("utf8")

    # Step 3: Remove known weird tokens
    text = text.replace("<|endoftext|>", "")
    text = text.replace("\u0000", "").replace("\x00", "")

    # Step 4: Remove raw control characters (e.g., \x1f)
    text = "".join(c for c in text if unicodedata.category(c)[0] != "C" or c == "\n")

    # Step 5: Remove literal escape sequences like \xNN
    text = re.sub(r"\\x[0-9a-fA-F]{2}", "", text)

    # Step 6: Remove non-printable characters
    printable = set(string.printable)
    text = "".join(c for c in text if c in printable)

    # Step 7: Collapse repeated punctuation (e.g., ........ → .)
    text = re.sub(r"([!?.])\1{2,}", r"\1", text)  # !!!!!! → !
    text = re.sub(r"([-—])\1{1,}", r"\1", text)  # ------ → -
    text = re.sub(r"([,.]){4,}", r"\1", text)  # ...... → .

    return text.strip()


def serialize_for_json(obj):
    """
    Recursively convert any object into a JSON-serializable structure.
    Handles NumPy types, datetime objects, and class instances.
    """
    import numpy as np
    from datetime import datetime, date
    import pandas as pd

    # Handle NumPy types
    if isinstance(obj, (np.integer, np.int64, np.int32, np.int16)):
        return int(obj)
    elif isinstance(obj, (np.floating, np.float64, np.float32, np.float16)):
        return float(obj)
    elif isinstance(obj, np.ndarray):
        return obj.tolist()
    elif isinstance(obj, np.bool_):
        return bool(obj)

    # Handle datetime types
    elif isinstance(obj, (datetime, date, pd.Timestamp)):
        return obj.isoformat()

    # Handle basic Python types
    elif isinstance(obj, (str, int, float, bool, type(None))):
        return obj
    elif isinstance(obj, dict):
        return {str(k): serialize_for_json(v) for k, v in obj.items()}
    elif isinstance(obj, (list, tuple, set)):
        return [serialize_for_json(v) for v in obj]
    elif isinstance(obj, type):
        # A class/type object like int, str, etc.
        return obj.__name__
    elif hasattr(obj, "__class__"):
        # For other objects, return their string representation
        return f"{obj.__class__.__name__}()"
    else:
        return str(obj)


def strip_timestamp_suffix(name: str) -> str:
    # Matches an underscore followed by 8 digits, another underscore, then 6 digits at the end
    return re.sub(r"_\d{8}_\d{6}$", "", name)


# Metrics configuration for optimization
# direction: "minimize" or "maximize"
# valid_for: "classification", "regression", or "both"
METRICS_CONFIG = {
    # Classification metrics
    "LOGLOSS": {"direction": "minimize", "valid_for": "classification"},
    "ROC_AUC": {"direction": "maximize", "valid_for": "classification"},
    "AVG_PRECISION": {"direction": "maximize", "valid_for": "classification"},
    "ACCURACY": {"direction": "maximize", "valid_for": "classification"},
    "PRECISION": {"direction": "maximize", "valid_for": "classification"},
    "RECALL": {"direction": "maximize", "valid_for": "classification"},
    "F1": {"direction": "maximize", "valid_for": "classification"},
    # Regression metrics
    "RMSE": {"direction": "minimize", "valid_for": "regression"},
    "MAE": {"direction": "minimize", "valid_for": "regression"},
    "MAPE": {"direction": "minimize", "valid_for": "regression"},
    "R2": {"direction": "maximize", "valid_for": "regression"},
}


def get_default_metric(target_type: str) -> str:
    """Get the default optimization metric for a target type."""
    return "RMSE" if target_type == "regression" else "LOGLOSS"


def get_metric_direction(metric: str) -> str:
    """Get the optimization direction for a metric ('minimize' or 'maximize')."""
    if metric not in METRICS_CONFIG:
        raise ValueError(
            f"Unknown metric: {metric}. Valid metrics: {list(METRICS_CONFIG.keys())}"
        )
    return METRICS_CONFIG[metric]["direction"]


def is_metric_better(new_score: float, best_score: float, metric: str) -> bool:
    """Check if new_score is better than best_score for the given metric."""
    direction = get_metric_direction(metric)
    if direction == "minimize":
        return new_score < best_score
    else:
        return new_score > best_score


def get_initial_best_score(metric: str) -> float:
    """Get the initial 'worst' score for a metric (to be improved upon)."""
    import numpy as np

    direction = get_metric_direction(metric)
    return np.inf if direction == "minimize" else -np.inf


def validate_metric_for_target_type(metric: str, target_type: str) -> None:
    """Validate that a metric is appropriate for the given target type."""
    if metric not in METRICS_CONFIG:
        raise ValueError(
            f"Unknown metric: {metric}. Valid metrics: {list(METRICS_CONFIG.keys())}"
        )
    valid_for = METRICS_CONFIG[metric]["valid_for"]
    if valid_for != "both" and valid_for != target_type:
        raise ValueError(
            f"Metric '{metric}' is only valid for {valid_for}, not {target_type}. "
            f"Valid {target_type} metrics: {[k for k, v in METRICS_CONFIG.items() if v['valid_for'] in (target_type, 'both')]}"
        )


# Mapping from our metric names to library-specific eval_metric names
# Format: {metric: {library: {binary: name, multiclass: name} or name}}
EVAL_METRIC_MAPPING = {
    # XGBoost mappings
    "xgboost": {
        "LOGLOSS": {"binary": "logloss", "multiclass": "mlogloss"},
        "ROC_AUC": {"binary": "auc", "multiclass": "auc"},
        "AVG_PRECISION": {"binary": "aucpr", "multiclass": "aucpr"},
        "RMSE": "rmse",
        "MAE": "mae",
        "MAPE": "mape",
    },
    # LightGBM mappings
    "lightgbm": {
        "LOGLOSS": {"binary": "binary_logloss", "multiclass": "multi_logloss"},
        "ROC_AUC": {"binary": "auc", "multiclass": "auc_mu"},
        "AVG_PRECISION": {"binary": "average_precision", "multiclass": "average_precision"},
        "RMSE": "rmse",
        "MAE": "mae",
        "MAPE": "mape",
    },
    # CatBoost mappings
    "catboost": {
        "LOGLOSS": {"binary": "Logloss", "multiclass": "MultiClass"},
        "ROC_AUC": {"binary": "AUC", "multiclass": "AUC"},
        "AVG_PRECISION": {"binary": "PRAUC", "multiclass": "PRAUC"},
        "ACCURACY": {"binary": "Accuracy", "multiclass": "Accuracy"},
        "RMSE": "RMSE",
        "MAE": "MAE",
        "MAPE": "MAPE",
        "R2": "R2",
    },
}


def get_feature_search_range(n_samples: int, n_available_features: int, max_features: int = None) -> tuple[int, int]:
    """
    Calculate the [min, max] range of features to explore based on number of observations.

    Uses curse of dimensionality principles:
    - min_features: log₂(n) with floor at 10
    - max_features: min(√n, n/10) - the more conservative of the two

    Args:
        n_samples: Number of observations
        n_available_features: Number of features available after ranking
        max_features: Optional user-specified maximum (overrides computed max)

    Returns:
        (min_features, max_features): Tuple of min and max features to search
    """
    import numpy as np

    # Minimum: log₂(n) with floor at 10
    min_features = max(10, int(np.log2(max(n_samples, 1))))

    # Maximum: min(√n, n/10) - the more conservative of the two
    max_by_sqrt = int(np.sqrt(n_samples))
    max_by_ratio = n_samples // 10
    computed_max = min(max_by_sqrt, max_by_ratio)

    # Use user-specified max_features if provided, otherwise use computed
    if max_features is not None:
        final_max = min(max_features, n_available_features)
    else:
        final_max = min(computed_max, n_available_features)

    # Ensure max >= min
    final_max = max(final_max, min_features)

    # Cap at available features
    final_max = min(final_max, n_available_features)
    min_features = min(min_features, final_max)

    return min_features, final_max


def get_eval_metric(metric: str, library: str, is_binary: bool = True) -> str:
    """
    Get the library-specific eval_metric name for a given metric.

    Args:
        metric: Our metric name (e.g., "LOGLOSS", "ROC_AUC")
        library: The library name ("xgboost", "lightgbm", "catboost")
        is_binary: Whether it's binary classification (vs multiclass)

    Returns:
        The library-specific eval_metric name
    """
    library = library.lower()
    if library not in EVAL_METRIC_MAPPING:
        raise ValueError(f"Unknown library: {library}")

    lib_mapping = EVAL_METRIC_MAPPING[library]
    if metric not in lib_mapping:
        # Fallback to default for this library
        logger.warning(
            f"Metric '{metric}' not available as eval_metric for {library}. "
            f"Using library default."
        )
        return None

    mapping = lib_mapping[metric]
    if isinstance(mapping, dict):
        return mapping["binary"] if is_binary else mapping["multiclass"]
    return mapping
