"""
Módulo de perfilado de datos para DataFrames de pandas.

Proporciona herramientas para analizar columnas y DataFrames:
- ColumnProfile: perfil estadístico de una columna
- Profiles: perfilado de un DataFrame completo
- CorrelationMatrix: matriz de coincidencias entre columnas
- RelatedColumns: análisis de columnas relacionadas por clave

Requiere dependencias adicionales:
    pip install tai-storage[data]
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional, Union

import pandas as pd
import numpy as np

try:
    from tabulate import tabulate
    _HAS_TABULATE = True
except ImportError:
    _HAS_TABULATE = False

try:
    import seaborn as sns
    import matplotlib.pyplot as plt
    _HAS_MATPLOTLIB = True
except ImportError:
    _HAS_MATPLOTLIB = False

from .types import TypeInferencer


@dataclass(eq=False)
class ColumnProfile:
    """
    Perfil estadístico de una columna de un DataFrame.

    Args:
        name: Nombre de la columna.
        raw_series: Serie original sin transformar.
        data_name: Nombre del dataset al que pertenece.
        max_unique: Máximo de valores únicos a mostrar en la distribución.
    """
    name: str
    raw_series: pd.Series
    data_name: str
    max_unique: int = 15

    _inferencer: Optional[TypeInferencer] = field(default=None, repr=False, compare=False)

    def __repr__(self) -> str:
        return f'ColumnProfile(name={self.name!r}, data={self.data_name!r})'

    @property
    def inferencer(self) -> TypeInferencer:
        """TypeInferencer para esta columna (lazy)."""
        if self._inferencer is None:
            self._inferencer = TypeInferencer(self.raw_series)
        return self._inferencer

    @property
    def series(self) -> pd.Series:
        """Serie convertida al tipo inferido."""
        return self.inferencer.convert()

    @property
    def total_records(self) -> int:
        """Número total de registros."""
        return len(self.series)

    @property
    def type(self) -> str:
        """
        Tipo inferido de la columna.

        Posibles valores: 'empty', 'bool', 'numeric', 'datetime', 'timedelta', 'string', 'unknown'.
        """
        _ = self.series  # trigger conversion so inferred_type is set
        return self.inferencer.inferred_type or 'unknown'

    @property
    def unicos(self) -> list:
        """Valores únicos (sin nulos), ordenados si es posible."""
        values = self.series.dropna().unique().tolist()
        if self.type == 'numeric':
            values = [float(v) for v in values]
        try:
            values.sort()
        except TypeError:
            pass  # unsortable mixed types
        return values

    @property
    def num_unicos(self) -> int:
        """Número de valores únicos."""
        return len(self.unicos)

    @property
    def num_vacios(self) -> int:
        """Número de valores nulos."""
        return int(self.series.isnull().sum())

    @property
    def relative_vacios(self) -> float:
        """Fracción (0-1) de valores nulos."""
        return float(self.series.isnull().mean())

    @property
    def num_ceros(self) -> Optional[int]:
        """Número de ceros (solo columnas numéricas)."""
        if self.type == 'numeric':
            return int((self.series == 0).sum())
        return None

    @property
    def relative_ceros(self) -> Optional[float]:
        """Fracción (0-1) de ceros (solo columnas numéricas)."""
        if self.type == 'numeric':
            return float((self.series == 0).mean())
        return None

    @property
    def distribucion(self) -> list[tuple]:
        """
        Distribución de frecuencias como lista de (valor, frecuencia%).

        Limitada a los primeros `max_unique` valores por frecuencia.
        Nulos aparecen como 'NULL'.
        """
        dist = self.series.value_counts(normalize=True, dropna=False)
        dist = dist.iloc[:self.max_unique]
        dist = dist.rename(index={pd.NA: 'NULL', np.nan: 'NULL', None: 'NULL'})
        dist = round(dist * 100, 3)
        return list(dist.items())

    def show(self) -> None:
        """Imprime el perfil de la columna en consola."""
        if not _HAS_TABULATE:
            raise ImportError(
                "El método show() requiere tabulate. "
                "Instala con: pip install tabulate"
            )
        print(f'Campo: {self.name}')
        print(f'Registros totales: {self.total_records}')
        print(f'Tipo: {self.type}')
        print(f'Vacíos: {round(self.relative_vacios * 100, 3)}% [{self.num_vacios} de {self.total_records}]')

        if self.type != 'empty':
            if self.num_unicos < self.max_unique:
                print(f'Únicos ({self.num_unicos}): {self.unicos}')
            else:
                print(f'Únicos: {self.num_unicos}')

        if self.type == 'numeric' and self.num_ceros is not None:
            print(f'Ceros: {round(self.relative_ceros * 100, 3)}% [{self.num_ceros} de {self.total_records}]')

        print('Distribución:')
        print(tabulate(self.distribucion, headers=['Valor', 'Frecuencia [%]'], tablefmt='orgtbl'))
        if self.num_unicos > self.max_unique:
            print(f'(distribución truncada a los {self.max_unique} primeros valores)')

    def referencial_integrity_with(self, other: ColumnProfile) -> None:
        """
        Verifica la integridad referencial entre esta columna y otra.

        Args:
            other: ColumnProfile con el que comparar.
        """
        print(f'Integridad referencial: "{self.data_name}[{self.name}]" <> "{other.data_name}[{other.name}]"\n')

        self_values = set(self.unicos)
        other_values = set(other.unicos)

        print(f'¿Todos los de "{self.data_name}[{self.name}]" están en "{other.data_name}[{other.name}]"?')
        if self_values.issubset(other_values):
            print(f'✅ SI. Considera: "{other.data_name}[{other.name}]" -- one to many -- "{self.data_name}[{self.name}]"')
            if self.num_vacios:
                print(f'⚠️  "{self.data_name}[{self.name}]" es NULLABLE ({round(self.relative_vacios * 100, 3)}% vacíos)')
        else:
            missing = self_values - other_values
            print(f'❌ NO. Faltan ({len(missing)}): {missing}\n')

        print(f'¿Todos los de "{other.data_name}[{other.name}]" están en "{self.data_name}[{self.name}]"?')
        if other_values.issubset(self_values):
            print(f'✅ SI. Considera: "{self.data_name}[{self.name}]" -- one to many -- "{other.data_name}[{other.name}]"')
            if other.num_vacios:
                print(f'⚠️  "{other.data_name}[{other.name}]" es NULLABLE ({round(other.relative_vacios * 100, 3)}% vacíos)')
        else:
            missing = other_values - self_values
            print(f'❌ NO. Faltan ({len(missing)}): {missing}\n')


class Profiles:
    """
    Perfilado de un DataFrame completo.

    Genera y gestiona perfiles individuales de cada columna.

    Args:
        df: DataFrame a perfilar.
        data_name: Nombre del dataset (para reportes y referencias cruzadas).
    """

    def __init__(self, df: pd.DataFrame, data_name: Optional[str] = None):
        self.df = df
        self.data_name = data_name or ''
        self._profiles: dict[str, ColumnProfile] = {}

    def __repr__(self) -> str:
        return (
            f'Profiles(data={self.data_name!r}, '
            f'profiled={len(self._profiles)}/{len(self.df.columns)})'
        )

    def generate(self) -> Profiles:
        """
        Genera perfiles para todas las columnas del DataFrame.

        Solo procesa columnas aún no perfiladas. Puede llamarse varias
        veces de forma segura.

        Returns:
            self (para encadenamiento).
        """
        pending = [c for c in self.df.columns if c not in self._profiles]
        if pending:
            print(f'Generando perfiles para {len(pending)} columna(s)...')
            for col in pending:
                self._profiles[col] = ColumnProfile(col, self.df[col], self.data_name)
        return self

    def get(self, column_name: str) -> ColumnProfile:
        """
        Obtiene el perfil de una columna (generándolo si no existe aún).

        Args:
            column_name: Nombre de la columna.

        Returns:
            ColumnProfile de la columna.

        Raises:
            KeyError: Si la columna no existe en el DataFrame.
        """
        if column_name not in self.df.columns:
            raise KeyError(f'La columna "{column_name}" no existe en el DataFrame')

        if column_name not in self._profiles:
            self._profiles[column_name] = ColumnProfile(
                column_name, self.df[column_name], self.data_name
            )

        return self._profiles[column_name]

    def show(self) -> None:
        """Muestra todos los perfiles generados."""
        if not self._profiles:
            print('No hay perfiles generados. Llama a generate() primero.')
            return
        for profile in self._profiles.values():
            profile.show()
            print()

    def show_duplicates_by_key(self, main_column: Union[str, list]) -> None:
        """
        Muestra las columnas que tienen valor constante dentro de cada grupo de `main_column`.

        Args:
            main_column: Columna o lista de columnas a usar como clave de agrupación.
        """
        RelatedColumns(self.df, main_column).show()

    def show_correlation_matrix(self, include_na: bool = True) -> None:
        """
        Muestra la matriz de coincidencias entre columnas como heatmap.

        Args:
            include_na: Si True, cuenta como coincidencia cuando ambas columnas son nulas.
        """
        CorrelationMatrix(self.df, include_na=include_na).show()


@dataclass(eq=False)
class CorrelationMatrix:
    """
    Matriz de coincidencias entre columnas de un DataFrame.

    Calcula, para cada par de columnas, el porcentaje de filas donde
    ambas tienen el mismo valor.

    Args:
        df: DataFrame a analizar.
        include_na: Si True, considera coincidencia cuando ambos valores son nulos.
    """
    df: pd.DataFrame
    include_na: bool = True

    @property
    def matrix(self) -> pd.DataFrame:
        """Calcula la matriz de coincidencias (valores entre 0 y 100)."""
        cols = list(self.df.columns)
        n = len(cols)
        data = np.zeros((n, n))

        for i, col1 in enumerate(cols):
            for j, col2 in enumerate(cols):
                s1, s2 = self.df[col1], self.df[col2]
                if self.include_na:
                    pct = ((s1 == s2) | (s1.isna() & s2.isna())).mean()
                else:
                    mask = s1.notna() & s2.notna()
                    pct = (s1[mask] == s2[mask]).mean() if mask.any() else 0.0
                data[i, j] = round(float(pct) * 100, 1)

        return pd.DataFrame(data, index=cols, columns=cols)

    def show(self) -> None:
        """Muestra la heatmap de la matriz de coincidencias."""
        if not _HAS_MATPLOTLIB:
            raise ImportError(
                "Se requieren matplotlib y seaborn para mostrar gráficos. "
                "Instala con: pip install matplotlib seaborn"
            )
        n = len(self.df.columns)
        fig_w = max(8, n)
        fig_h = max(6, n // 2 + 1)
        _, ax = plt.subplots(figsize=(fig_w, fig_h))

        hm = sns.heatmap(
            data=self.matrix,
            vmin=0,
            vmax=100,
            square=False,
            xticklabels='auto',
            yticklabels='auto',
            cmap='Greens',
            annot=True,
            linecolor='white',
            linewidths=1,
            ax=ax,
            cbar_kws={
                'label': '%',
                'orientation': 'vertical',
                'location': 'right',
                'shrink': 0.5,
                'aspect': 30,
            }
        )
        cbar = hm.collections[0].colorbar
        cbar.set_ticks([0, 25, 50, 75, 100])
        cbar.set_ticklabels(['0', '25', '50', '75', '100'])
        ax.set_title("% Coincidencias entre columnas")
        plt.tight_layout()
        plt.show()


@dataclass(eq=False)
class RelatedColumns:
    """
    Analiza si las columnas de un DataFrame tienen valor constante dentro de grupos.

    Para cada valor único de `main_column`, verifica qué otras columnas
    tienen siempre el mismo valor (es decir, están funcionalmente determinadas
    por la clave).

    Args:
        df: DataFrame a analizar.
        main_column: Columna o lista de columnas a usar como clave de agrupación.
    """
    df: pd.DataFrame
    main_column: Union[str, list]

    def __repr__(self) -> str:
        return f'RelatedColumns(key={self.main_column!r})'

    @property
    def grouped_df(self) -> pd.DataFrame:
        """DataFrame agrupado: True si la columna tiene un solo valor por grupo."""
        return self.df.groupby(self.main_column).agg(lambda x: x.nunique() == 1)

    @property
    def same_value(self) -> list[tuple]:
        """Lista de (columna, es_constante_en_todos_los_grupos)."""
        return list(self.grouped_df.all(axis=0).items())

    def show(self) -> None:
        """Imprime el reporte de columnas con valor constante por grupo."""
        if not _HAS_TABULATE:
            raise ImportError(
                "El método show() requiere tabulate. "
                "Instala con: pip install tabulate"
            )
        print(
            f'Agrupando por "{self.main_column}": '
            f'columnas con el mismo valor en cada grupo:\n'
        )
        print(tabulate(self.same_value, headers=['Campo', 'Se repite'], tablefmt='orgtbl'))
