"""
Input validation utilities used across the package.

Provides reusable validation functions to ensure data quality and
prevent common errors across the mleakdetect package.
"""

from __future__ import annotations
from typing import Sequence, Optional, List
import pandas as pd


def validate_dataframe(
        df: pd.DataFrame,
        name: str = "df"
) -> None:
    """
    Check that input is a valid pandas DataFrame.

    Parameters
    ----------
    df : pd.DataFrame
        Object to validate.
    name : str, default="df"
        Variable name used in error messages for clarity.

    Raises
    ------
    TypeError
        If df is not a pandas DataFrame.
    """
    if not isinstance(df, pd.DataFrame):
        raise TypeError(
            f"{name} must be pd.DataFrame, got {type(df).__name__}."
        )

def validate_columns_exist(
        df: pd.DataFrame,
        columns: Optional[Sequence[str]],
        name: str = "df"
) -> None:
    """
    Check that all required columns exist in DataFrame.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame to check.
    columns : Optional[Sequence[str]]
        List of required column names.
        If None, validation is skipped (treated as "all columns").
    name : str, default="df"
        Variable name used in error messages.

    Raises
    ------
    ValueError
        If any required columns are missing.
    """
    if columns is None:
        return

    missing = [c for c in columns if c not in df.columns]

    if missing:
        raise ValueError(
            f"{name} is missing required columns: {missing}. "
            f"Available columns: {list(df.columns)}"
        )

def validate_numeric_columns(
    df: pd.DataFrame,
    columns: Optional[Sequence[str]] = None,
    name: str = "df"
) -> list[str]:
    """
    Validate and return numeric columns for similarity calculation.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame to validate.
    columns : Optional[Sequence[str]]
        Specific columns to check.
        If None, all numeric columns are used.
    name : str, default="df"
        Variable name used in error messages.

    Returns
    -------
    list[str]
        List of validated numeric column names.

    Raises
    ------
    ValueError
        If columns are missing, non-numeric, or no numeric columns found.
    """

    if columns is None:
        numeric_cols = df.select_dtypes(include=["number"]).columns.tolist()
    else:
        validate_columns_exist(df, columns, name=name)

        numeric_df_cols = df.select_dtypes(include=["number"]).columns
        non_numeric = [c for c in columns if c not in numeric_df_cols]

        if non_numeric:
            raise ValueError(
                f"Columns must be numeric. "
                f"Non-numeric columns found: {non_numeric}"
            )

        numeric_cols = list(columns)

    if len(numeric_cols) == 0:
        raise ValueError(
            "No numeric columns found for similarity calculation."
        )

    return numeric_cols

def validate_time_column(
        df: pd.DataFrame,
        time_column: str,
        name: str = "df"
) -> None:
    """
    Check that time column exists and contains parseable datetime values.

    This function verifies the column exists and can be converted to
    datetime format. It does not modify the original DataFrame.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame containing the time column.
    time_column : str
        Name of the time column.
    name : str, default="df"
        Variable name used in error messages.

    Raises
    ------
    ValueError
        If the column is missing or cannot be parsed as datetime.
    """
    # checking column exists
    validate_columns_exist(df, [time_column], name=name)

    try:
        pd.to_datetime(df[time_column])
    except Exception as e:
        raise ValueError(
            f"Column '{time_column}' in {name} cannot be parsed as datetime. "
            f"Error: {e}"
        ) from e


def validate_time_no_nat(
        df: pd.DataFrame,
        time_column: str,
        name: str = "df"
) -> None:
    """
    Check that time column contains no NaT (Not-a-Time) values.

    Purpose
    -------
    NaT values would cause issues in time difference calculations
    and exponential decay computations.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame to check.
    time_column : str
        Name of the timestamp column.
    name : str, default="df"
        Variable name used in error messages.

    Raises
    ------
    ValueError
        If time column contains NaT values.
    """
    # First ensure column exists and is parseable
    validate_time_column(df, time_column, name=name)

    # Convert to datetime to check for NaT
    time_series = pd.to_datetime(df[time_column])

    if time_series.isna().any():
        n_nat = time_series.isna().sum()
        raise ValueError(
            f"time_column '{time_column}' in {name} contains "
            f"{n_nat} NaT (Not-a-Time) values. "
            f"All timestamps must be valid."
        )

def validate_ratio(
    value: float,
    name: str,
    inclusive: bool = False
) -> None:
    """
    Validate that a ratio is numeric and within (0, 1) or [0, 1].

    Parameters
    ----------
    value : float
        Ratio value to validate.
    name : str
        Parameter name (used in error messages).
    inclusive : bool, default=False
        If True, allow 0 and 1.
        If False, require strict (0,1).

    Raises
    ------
    TypeError
        If value is not numeric.
    ValueError
        If value is not in valid range.
    """
    if not isinstance(value, (int, float)):
        raise TypeError(
            f"{name} must be numeric, got {type(value).__name__}."
        )

    ratio = float(value)

    if inclusive:
        if not (0.0 <= ratio <= 1.0):
            raise ValueError(
                f"{name} must be between 0 and 1 (inclusive), got {ratio}."
            )
    else:
        if not (0.0 < ratio < 1.0):
            raise ValueError(
                f"{name} must be between 0 and 1 (exclusive), got {ratio}."
            )


def validate_monotonic_time_per_group(
        df: pd.DataFrame,
        time_column: str,
        group_column: str,
        name: str = "df"
) -> None:
    """
    Check that timestamps are monotonically increasing within each group.

    Purpose
    -------
    The leakage measure assumes temporal ordering within groups.
    Non-monotonic timestamps suggest data quality issues.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame to check.
    time_column : str
        Name of the timestamp column.
    group_column : str
        Name of the group identifier column.
    name : str, default="df"
        Variable name used in error messages.

    Raises
    ------
    ValueError
        If timestamps are not monotonic within any group.

    Notes
    -----
    This is a strict check. If you expect unordered data,
    sort by time before calling measure functions.
    """
    # Ensure both columns exist and are valid
    validate_time_column(df, time_column, name=name)
    validate_group_column(df, group_column, name=name)

    # Convert to datetime
    df_check = df.copy()
    df_check[time_column] = pd.to_datetime(df_check[time_column])

    # Check each group
    non_monotonic_groups = []

    for group_id, group_df in df_check.groupby(group_column):
        if not group_df[time_column].is_monotonic_increasing:
            non_monotonic_groups.append(group_id)

    if non_monotonic_groups:
        raise ValueError(
            f"time_column '{time_column}' in {name} is not monotonically "
            f"increasing for {len(non_monotonic_groups)} groups. "
            f"Examples: {non_monotonic_groups[:3]}. "
            f"Please sort data by time within groups."
        )

def validate_timedelta_string(gap: str) -> pd.Timedelta:
    """
    Parse and validate a time gap string.

    Purpose
    -------
    Enforce a time-based gap that is independent of the dataset sampling rate.

    Examples
    --------
    - "0D"     -> 0 days
    - "1D"     -> 1 day
    - "12h"    -> 12 hours
    - "30min"  -> 30 minutes

    Parameters
    ----------
    gap : str
        Time duration string (pandas Timedelta format).

    Returns
    -------
    pd.Timedelta
        Parsed time gap.

    Raises
    ------
    TypeError
        If gap is not a string.
    ValueError
        If gap cannot be parsed or is negative.
    """
    # type check
    if not isinstance(gap, str):
        raise TypeError(
            f"gap must be a string (e.g., '1D', '12h'), "
            f"got {type(gap).__name__}"
        )

    # parse  string
    try:
        td = pd.to_timedelta(gap)
    except Exception as e:
        raise ValueError(
            f"Cannot parse gap='{gap}' as Timedelta. "
            f"Use format like '1D', '12h', '30min'. Error: {e}"
        ) from e

    # non-negative check
    if td < pd.Timedelta(0):
        raise ValueError(
            f"gap must be non-negative, got {gap} "
            f"(equivalent to {td})"
        )

    return td

def validate_non_empty_dataframe(df: pd.DataFrame, name: str):
    if df.empty:
        raise ValueError(f"{name} cannot be empty.")

def validate_non_empty_sequence(
        values: Sequence[str],
        name: str
) -> None:
    """
    Check that a sequence is non-empty.

    Parameters
    ----------
    values : Sequence[str]
        Sequence to validate.
    name : str
        Name used in error messages.

    Raises
    ------
    ValueError
        If the sequence is empty.
    """
    if values is None or len(values) == 0:
        raise ValueError(f"{name} must be a non-empty sequence.")


def validate_alpha(alpha: float) -> None:
    """
    Check that alpha is a valid leakage weight parameter.

    Parameters
    ----------
    alpha : float
        Weight for backward temporal leakage.

    Raises
    ------
    TypeError
        If alpha is not numeric.
    ValueError
        If alpha is negative.
    """
    if not isinstance(alpha, (int, float)):
        raise TypeError(
            f"alpha must be numeric, got {type(alpha).__name__}"
        )

    if alpha < 0:
        raise ValueError(
            f"alpha must be non-negative, got {alpha}"
        )

def validate_keep_parameter(
        keep: str,
        allow_false: bool = True
) -> None:
    """
    Check that keep parameter has a valid value.

    Parameters
    ----------
    keep : str
        Value to validate. Should be 'first', 'last', or False.
    allow_false : bool, default=True
        Whether to allow False as a valid value.
        - True: Valid values are 'first', 'last', False
        - False: Valid values are only 'first', 'last'

    Raises
    ------
    TypeError
        If keep is not a string or bool.
    ValueError
        If keep has an invalid value.

    Examples
    --------
    >>> validate_keep_parameter('first')  # OK
    >>> validate_keep_parameter('last')   # OK
    >>> validate_keep_parameter(False)    # OK
    >>> validate_keep_parameter('middle')  # ValueError
    >>> validate_keep_parameter(False, allow_false=False)  # ValueError

    Notes
    -----
    This validation is commonly used in duplicate detection functions
    where 'keep' controls which duplicate to preserve or mark.
    """
    # Type check
    if not isinstance(keep, (str, bool)):
        raise TypeError(
            f"keep must be a string or bool, got {type(keep).__name__}"
        )

    # Define valid values based on allow_false
    if allow_false:
        valid_values = ("first", "last", False)
        error_msg = "keep must be 'first', 'last', or False"
    else:
        valid_values = ("first", "last")
        error_msg = "keep must be 'first' or 'last'"

    # Value check
    if keep not in valid_values:
        raise ValueError(f"{error_msg}, got '{keep}'")

def validate_group_column(
        df: pd.DataFrame,
        group_column: str,
        name: str = "df"
) -> None:
    """
    Check that group column exists and contains no missing values.

    Purpose
    -------
    Ensures group column is valid for group-based operations.
    Missing values in group column would break merge operations.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame to check.
    group_column : str
        Name of the group identifier column.
    name : str, default="df"
        Variable name used in error messages.

    Raises
    ------
    ValueError
        If group column is missing or contains NaN values.
    """
    # First check if column exists
    validate_columns_exist(df, [group_column], name=name)

    # Check for missing values
    if df[group_column].isna().any():
        n_missing = df[group_column].isna().sum()
        raise ValueError(
            f"group_column '{group_column}' in {name} contains "
            f"{n_missing} missing values. "
            f"Group identifiers must be complete."
        )