"""Column suggestion for automatic preprocessing configuration.

This module provides automatic analysis of DataFrame columns to suggest
appropriate preprocessing strategies for each column type.

Example:
    >>> from lecrapaud import ColumnSuggester
    >>> suggester = ColumnSuggester(data)
    >>> suggestions = suggester.suggest()
    >>> lc = LeCrapaud(**suggestions, target_numbers=[1])
"""

import pandas as pd
import numpy as np
from typing import Optional


class ColumnSuggester:
    """
    Analyzes a DataFrame and suggests columns for each preprocessing category.

    Heuristics:
    - columns_drop: ID columns, high-cardinality text (>90% unique), constant columns
    - columns_boolean: Columns with only True/False, 0/1, Yes/No values
    - columns_binary: Categorical with 10-100 unique values
    - columns_onehot: Categorical with 2-10 unique values
    - columns_ordinal: Detected ordered categories (low/medium/high, etc.)
    - columns_frequency: Categorical with 100+ unique values
    - columns_pca: Text columns (avg length > 50 chars) for embeddings
    - columns_date: Datetime columns

    Time Series Detection:
    - Detects if data looks like time series (has date column, sorted by time)
    - Detects panel data structure (multiple entities over time)
    - Suggests pca_temporal config (lag features for PCA)
    - Suggests pca_cross_sectional config (entity-time pivots)

    Example:
        >>> suggester = ColumnSuggester(data, target_columns=["TARGET_1"])
        >>> suggestions = suggester.suggest()
        >>> # Returns dict like:
        >>> # {
        >>> #     "columns_drop": ["ID", "NAME"],
        >>> #     "columns_boolean": ["IS_ACTIVE"],
        >>> #     "columns_binary": ["COUNTRY"],
        >>> #     ...
        >>> # }
    """

    # Thresholds (configurable)
    ONEHOT_MAX_CARDINALITY = 10
    BINARY_MAX_CARDINALITY = 100
    HIGH_CARDINALITY_THRESHOLD = 100
    UNIQUE_RATIO_DROP_THRESHOLD = 0.9  # Drop if >90% values are unique
    TEXT_AVG_LENGTH_THRESHOLD = 50
    MIN_ROWS_FOR_FREQUENCY = 50  # Minimum rows to use frequency encoding

    # Time series detection patterns
    DATE_COLUMN_PATTERNS = ["date", "time", "timestamp", "datetime", "dt", "period"]
    GROUP_COLUMN_PATTERNS = [
        "ticker",
        "symbol",
        "entity",
        "id",
        "code",
        "stock",
        "asset",
        "company",
        "user",
        "customer",
    ]

    # Ordinal patterns (case-insensitive)
    ORDINAL_PATTERNS = [
        {"low", "medium", "high"},
        {"low", "med", "high"},
        {"small", "medium", "large"},
        {"xs", "s", "m", "l", "xl"},
        {"poor", "fair", "good", "excellent"},
        {"bad", "average", "good"},
        {"junior", "mid", "senior"},
        {"bronze", "silver", "gold", "platinum"},
    ]

    def __init__(
        self,
        data: pd.DataFrame,
        target_columns: Optional[list[str]] = None,
        onehot_max_cardinality: int = None,
        binary_max_cardinality: int = None,
        unique_ratio_drop_threshold: float = None,
        text_avg_length_threshold: int = None,
    ):
        """
        Initialize ColumnSuggester.

        Args:
            data: DataFrame to analyze
            target_columns: List of target column names to exclude from suggestions
            onehot_max_cardinality: Max unique values for one-hot encoding (default: 10)
            binary_max_cardinality: Max unique values for binary encoding (default: 100)
            unique_ratio_drop_threshold: Ratio threshold for dropping high-cardinality columns
            text_avg_length_threshold: Avg length threshold for text/PCA columns
        """
        self.data = data
        self.target_columns = target_columns or []

        # Allow threshold customization
        if onehot_max_cardinality is not None:
            self.ONEHOT_MAX_CARDINALITY = onehot_max_cardinality
        if binary_max_cardinality is not None:
            self.BINARY_MAX_CARDINALITY = binary_max_cardinality
        if unique_ratio_drop_threshold is not None:
            self.UNIQUE_RATIO_DROP_THRESHOLD = unique_ratio_drop_threshold
        if text_avg_length_threshold is not None:
            self.TEXT_AVG_LENGTH_THRESHOLD = text_avg_length_threshold

        self._analyze()
        self._detect_time_series()

    def _analyze(self):
        """Analyze each column and categorize."""
        self.candidates = {
            "drop": [],
            "boolean": [],
            "binary": [],
            "onehot": [],
            "ordinal": [],
            "frequency": [],
            "pca": [],
            "date": [],
            "numeric": [],  # Passthrough
        }

        for col in self.data.columns:
            # Skip target columns
            if col in self.target_columns or col.upper().startswith("TARGET_"):
                continue

            category = self._categorize_column(col)
            self.candidates[category].append(col)

    def _categorize_column(self, col: str) -> str:
        """Determine the best category for a column."""
        series = self.data[col]
        dtype = series.dtype
        n_unique = series.nunique()
        n_total = len(series)
        unique_ratio = n_unique / n_total if n_total > 0 else 0

        # Check for constant columns (should be dropped)
        if n_unique <= 1:
            return "drop"

        # Date columns
        if pd.api.types.is_datetime64_any_dtype(dtype):
            return "date"

        # Try to parse as datetime if object type with date-like name
        if dtype == "object":
            col_lower = col.lower()
            if any(pattern in col_lower for pattern in self.DATE_COLUMN_PATTERNS):
                try:
                    pd.to_datetime(series.dropna().head(10))
                    return "date"
                except (ValueError, TypeError):
                    pass

        # Boolean detection
        unique_vals = set(series.dropna().unique())
        unique_vals_lower = {
            str(v).lower() if isinstance(v, str) else v for v in unique_vals
        }

        # Check for boolean-like values
        boolean_values = {True, False, 0, 1, "yes", "no", "y", "n", "true", "false"}
        if unique_vals_lower.issubset(boolean_values) and len(unique_vals) <= 2:
            return "boolean"

        # Numeric columns
        if pd.api.types.is_numeric_dtype(dtype):
            # Check if it's actually binary (0/1)
            if unique_vals == {0, 1} or unique_vals == {0.0, 1.0}:
                return "boolean"
            return "numeric"

        # Text/Object columns
        if dtype == "object" or pd.api.types.is_string_dtype(dtype):
            # Check if it's long text (for embeddings/PCA) - check this FIRST
            # High-cardinality long text is valuable for embeddings
            avg_length = series.astype(str).str.len().mean()
            if avg_length > self.TEXT_AVG_LENGTH_THRESHOLD:
                return "pca"

            # High-cardinality SHORT text (likely ID or name) should be dropped
            if unique_ratio > self.UNIQUE_RATIO_DROP_THRESHOLD:
                return "drop"

            # Check for ordinal patterns
            if self._is_ordinal(series):
                return "ordinal"

            # Categorical encoding based on cardinality
            if n_unique <= self.ONEHOT_MAX_CARDINALITY:
                return "onehot"
            elif n_unique <= self.BINARY_MAX_CARDINALITY:
                return "binary"
            else:
                return "frequency"

        # Fallback to numeric
        return "numeric"

    def _is_ordinal(self, series: pd.Series) -> bool:
        """Check if a series contains ordinal values."""
        unique_vals = {str(v).lower().strip() for v in series.dropna().unique()}

        for ordinal_set in self.ORDINAL_PATTERNS:
            if unique_vals.issubset(ordinal_set) and len(unique_vals) >= 2:
                return True

        return False

    def _detect_time_series(self):
        """Detect if data is time series and identify relevant columns."""
        self.is_time_series = False
        self.date_column: Optional[str] = None
        self.group_column: Optional[str] = None
        self.pca_temporal_candidates: list[dict] = []
        self.pca_cross_sectional_candidates: list[dict] = []

        # Find date column
        # First check detected date columns
        if self.candidates["date"]:
            self.date_column = self.candidates["date"][0]
            self.is_time_series = True
        else:
            # Search by column name pattern
            for col in self.data.columns:
                col_lower = col.lower()
                if any(
                    pattern in col_lower for pattern in self.DATE_COLUMN_PATTERNS
                ):
                    # Verify it looks like dates (many unique values)
                    if self.data[col].nunique() > 10:
                        self.date_column = col
                        self.is_time_series = True
                        break

        if not self.is_time_series:
            return

        # Find group column (for panel data)
        for col in self.data.columns:
            if col == self.date_column:
                continue
            col_lower = col.lower()
            if any(pattern in col_lower for pattern in self.GROUP_COLUMN_PATTERNS):
                n_unique = self.data[col].nunique()
                # Panel data: moderate number of groups (2-1000)
                if 2 <= n_unique <= 1000:
                    self.group_column = col
                    break

        # Suggest PCA temporal candidates (numeric columns good for lag features)
        for col in self.candidates["numeric"]:
            if col == self.date_column or col == self.group_column:
                continue
            # Good candidates: continuous numeric with variance
            if self.data[col].std() > 0:
                self.pca_temporal_candidates.append(
                    {
                        "name": f"LAG_{col}",
                        "column": col,
                        "lags": 20,  # Default
                    }
                )

        # Suggest PCA cross-sectional config (for panel data)
        if self.group_column and len(self.pca_temporal_candidates) > 0:
            # Use first numeric candidate as value column
            value_col = self.pca_temporal_candidates[0]["column"]
            self.pca_cross_sectional_candidates.append(
                {
                    "name": f"CS_{value_col}",
                    "index": self.date_column,
                    "columns": self.group_column,
                    "value": value_col,
                }
            )

    def suggest(self) -> dict:
        """
        Return suggested column configurations.

        Returns:
            dict: Dictionary with keys like columns_drop, columns_boolean, etc.
                  For time series data, also includes time_series, date_column,
                  group_column, pca_temporal, pca_cross_sectional.
        """
        suggestions = {
            "columns_drop": self.candidates["drop"],
            "columns_boolean": self.candidates["boolean"],
            "columns_binary": self.candidates["binary"],
            "columns_onehot": self.candidates["onehot"],
            "columns_ordinal": self.candidates["ordinal"],
            "columns_frequency": self.candidates["frequency"],
            "columns_pca": self.candidates["pca"],
            "columns_date": self.candidates["date"],
        }

        # Add time series specific suggestions
        if self.is_time_series:
            suggestions["time_series"] = True
            suggestions["date_column"] = self.date_column
            # Remove date_column from categorical encoding lists (it's handled via columns_date/fourier)
            if self.date_column:
                for key in ["columns_onehot", "columns_binary", "columns_frequency", "columns_ordinal"]:
                    if self.date_column in suggestions[key]:
                        suggestions[key] = [c for c in suggestions[key] if c != self.date_column]
            if self.group_column:
                suggestions["group_column"] = self.group_column
                # Move group_column to columns_ordinal (becomes numeric, usable as feature)
                for key in ["columns_onehot", "columns_binary", "columns_frequency"]:
                    if self.group_column in suggestions[key]:
                        suggestions[key] = [c for c in suggestions[key] if c != self.group_column]
                if self.group_column not in suggestions["columns_ordinal"]:
                    suggestions["columns_ordinal"] = suggestions["columns_ordinal"] + [self.group_column]
            # Only add PCA configs if candidates exist
            if self.pca_temporal_candidates:
                suggestions["pca_temporal"] = [
                    self.pca_temporal_candidates[0]
                ]  # Start with one
            if self.pca_cross_sectional_candidates:
                suggestions["pca_cross_sectional"] = (
                    self.pca_cross_sectional_candidates
                )

        return suggestions

    def create_search_space(
        self,
        include_subsets: bool = True,
        include_time_series: bool = True,
    ) -> dict:
        """
        Create hyperopt search space for column configurations.

        Args:
            include_subsets: If True, creates choices between different column subsets.
            include_time_series: If True and time series detected, includes TS params.

        Returns:
            dict: Hyperopt search space dictionary
        """
        from hyperopt import hp

        space = {}

        # For each column category, optionally include/exclude columns
        for category, columns in self.candidates.items():
            if category in ("numeric", "drop") or not columns:
                continue

            key = f"columns_{category}"

            if include_subsets and len(columns) > 1:
                # Create choices: all, none, or specific subsets
                subsets = [columns, []]  # All or none
                space[key] = hp.choice(f"cols_{category}", subsets)
            else:
                space[key] = columns

        # Add time series specific search space
        if include_time_series and self.is_time_series:
            # PCA temporal: try different lag configurations or none
            if self.pca_temporal_candidates:
                pca_temporal_options = [
                    [self.pca_temporal_candidates[0]],  # Use first candidate
                    [],  # No temporal PCA
                ]
                # Add variant with different lag count
                if len(self.pca_temporal_candidates) > 0:
                    variant = self.pca_temporal_candidates[0].copy()
                    variant["lags"] = 10  # Shorter window
                    variant["name"] = variant["name"] + "_SHORT"
                    pca_temporal_options.insert(1, [variant])

                space["pca_temporal"] = hp.choice(
                    "pca_temporal", pca_temporal_options
                )

            # PCA cross-sectional: with or without
            if self.pca_cross_sectional_candidates:
                space["pca_cross_sectional"] = hp.choice(
                    "pca_cross_sectional",
                    [self.pca_cross_sectional_candidates, []],
                )

        return space

    def get_summary(self) -> pd.DataFrame:
        """
        Get a summary DataFrame of column analysis.

        Returns:
            DataFrame with columns: column, dtype, n_unique, unique_ratio,
                                    avg_length (for object), suggested_category
        """
        rows = []
        for col in self.data.columns:
            if col in self.target_columns or col.upper().startswith("TARGET_"):
                continue

            series = self.data[col]
            n_unique = series.nunique()
            n_total = len(series)

            row = {
                "column": col,
                "dtype": str(series.dtype),
                "n_unique": n_unique,
                "unique_ratio": n_unique / n_total if n_total > 0 else 0,
                "null_ratio": series.isnull().sum() / n_total if n_total > 0 else 0,
            }

            # Add avg_length for object columns
            if series.dtype == "object" or pd.api.types.is_string_dtype(series.dtype):
                row["avg_length"] = series.astype(str).str.len().mean()
            else:
                row["avg_length"] = None

            # Find which category this column belongs to
            for category, columns in self.candidates.items():
                if col in columns:
                    row["suggested_category"] = category
                    break
            else:
                row["suggested_category"] = "unknown"

            rows.append(row)

        return pd.DataFrame(rows)

    def __repr__(self) -> str:
        """String representation of the suggester."""
        n_cols = len(self.data.columns)
        n_targets = len(
            [
                c
                for c in self.data.columns
                if c in self.target_columns or c.upper().startswith("TARGET_")
            ]
        )
        categories = {k: len(v) for k, v in self.candidates.items() if v}
        ts_info = f", time_series={self.is_time_series}" if self.is_time_series else ""
        return (
            f"ColumnSuggester({n_cols - n_targets} features, "
            f"categories={categories}{ts_info})"
        )
