"""
Lectores de datos para diferentes formatos de archivo.

Proporciona clases para leer y convertir archivos CSV, Excel, Parquet y JSON
a pandas DataFrames con inferencia automática de tipos.
"""
from __future__ import annotations
import csv
from abc import ABC, abstractmethod
from io import StringIO, BytesIO
from typing import Optional, Any, Union

from tai_alphi import Alphi

from .transformers import normalizar_texto, limpiar_valor
from .types import TypeInferencer, infer_and_convert_dataframe

try:
    import pandas as pd
    import numpy as np
    from openpyxl import load_workbook
    from openpyxl.worksheet.worksheet import Worksheet
    import warnings
    
    # Suprimir warnings de openpyxl
    warnings.filterwarnings('ignore', category=UserWarning, module='openpyxl')
except ImportError:
    raise ImportError(
        "El módulo 'data' requiere pandas, numpy y openpyxl. "
        "Instala con: pip install tai-storage[data]"
    )

logger = Alphi.get_logger_by_name('tai-storage')


class DataReader(ABC):
    """
    Clase base abstracta para lectores de datos.
    
    Define la interfaz común que todos los lectores deben implementar.
    """
    
    @abstractmethod
    def read(
        self,
        file_bytes: bytes,
        infer_types: bool = True,
        **kwargs
    ) -> pd.DataFrame:
        """
        Lee bytes y retorna un DataFrame.
        
        Args:
            file_bytes: Contenido del archivo en bytes
            infer_types: Si True, infiere y convierte tipos automáticamente
            **kwargs: Argumentos específicos del lector
            
        Returns:
            DataFrame con los datos leídos
        """
        pass


class CSVReader(DataReader):
    """
    Lector para archivos CSV con soporte de inferencia de tipos.
    
    Examples:
        >>> reader = CSVReader()
        >>> df = reader.read(
        ...     csv_bytes,
        ...     sep=';',
        ...     column_names=['id', 'nombre', 'valor'],
        ...     normalize_columns=True
        ... )
    """
    
    def read(
        self,
        file_bytes: bytes,
        column_names: Optional[list[str]] = None,
        sep: str = ",",
        encoding: str = 'utf-8',
        normalize_columns: bool = False,
        infer_types: bool = True,
        with_fields: Optional[list[str]] = None,
        **kwargs
    ) -> pd.DataFrame:
        """
        Lee un archivo CSV desde bytes.
        
        Args:
            file_bytes: Contenido del archivo CSV en bytes
            column_names: Nombres personalizados para las columnas (None = usar primera fila)
            sep: Separador de campos (default: coma)
            encoding: Codificación del archivo
            normalize_columns: Si True, normaliza nombres de columnas con normalizar_texto()
            infer_types: Si True, infiere tipos automáticamente
            with_fields: Lista de campos a incluir (None = todos)
            **kwargs: Argumentos adicionales para csv.DictReader
            
        Returns:
            DataFrame con los datos del CSV
        """
        logger.debug(f"Reading CSV with sep='{sep}', encoding='{encoding}'")
        
        content = StringIO(file_bytes.decode(encoding))
        
        # Leer nombres de columnas originales
        first_line = next(csv.reader(StringIO(file_bytes.decode(encoding)), delimiter=sep))
        origin_column_names = [col.strip() for col in first_line]
        
        # Determinar nombres finales de columnas
        if column_names is not None:
            if len(column_names) != len(origin_column_names):
                raise ValueError(
                    f"El número de column_names ({len(column_names)}) no coincide "
                    f"con el número de columnas del CSV ({len(origin_column_names)})"
                )
            final_column_names = column_names
        elif normalize_columns:
            final_column_names = [normalizar_texto(col) for col in origin_column_names]
        else:
            final_column_names = origin_column_names
        
        # Leer datos
        content.seek(0)  # Reset del stream
        reader = csv.DictReader(content, delimiter=sep, fieldnames=final_column_names, **kwargs)
        
        records = []
        for i, row in enumerate(reader):
            if i == 0:  # Saltar header row
                continue
            # Limpiar valores (quitar espacios)
            cleaned_row = {
                k.strip() if isinstance(k, str) else k: limpiar_valor(v)
                for k, v in row.items()
            }
            records.append(cleaned_row)
        
        # Crear DataFrame
        df = pd.DataFrame(records)
        
        if df.empty:
            return pd.DataFrame(columns=final_column_names)
        
        # Filtrar columnas si se especifica
        if with_fields:
            missing_fields = set(with_fields) - set(df.columns)
            if missing_fields:
                logger.warning(f"Campos no encontrados: {missing_fields}")
            df = df[[col for col in with_fields if col in df.columns]]
        
        # Inferir tipos
        if infer_types:
            df = infer_and_convert_dataframe(df)
        else:
            # Al menos limpiar valores vacíos
            df = df.replace({np.nan: pd.NA, r'^\s*$': pd.NA}, regex=True)
        
        logger.debug(f"CSV read successfully: {len(df)} rows, {len(df.columns)} columns")
        return df


class ExcelReader(DataReader):
    """
    Lector para archivos Excel (.xlsx, .xls) con soporte avanzado.
    
    Permite especificar:
    - Hoja específica
    - Punto de entrada (celda donde empiezan los datos)
    - Dimensiones del rango de datos
    - Inferencia automática de dimensiones
    
    Examples:
        >>> reader = ExcelReader()
        >>> 
        >>> # Lectura simple
        >>> df = reader.read(excel_bytes, sheet_name='Datos')
        >>> 
        >>> # Con punto de entrada
        >>> df = reader.read(
        ...     excel_bytes,
        ...     sheet_name='Ventas',
        ...     entrypoint='A5',  # Los datos empiezan en A5
        ...     infer_height=True
        ... )
        >>> 
        >>> # Con búsqueda de entrypoint
        >>> df = reader.read(
        ...     excel_bytes,
        ...     entrypoint='Código',  # Busca celda con "Código"
        ...     infer_dimensions=True
        ... )
    """
    
    def read(
        self,
        file_bytes: bytes,
        sheet_name: str = 'Hoja1',
        column_names: Optional[list[str]] = None,
        entrypoint: Optional[str] = None,
        width: Optional[int] = None,
        height: Optional[int] = None,
        regex: bool = False,
        infer_height: bool = False,
        infer_width: bool = False,
        infer_dimensions: bool = False,
        normalize_columns: bool = False,
        infer_types: bool = True,
        with_fields: Optional[list[str]] = None,
        **kwargs
    ) -> pd.DataFrame:
        """
        Lee un archivo Excel desde bytes.
        
        Args:
            file_bytes: Contenido del archivo Excel en bytes
            sheet_name: Nombre de la hoja a leer
            column_names: Nombres personalizados para columnas (None = usar primera fila)
            entrypoint: Celda o texto donde empiezan los datos (ej: 'A5' o 'Código')
            width: Ancho del rango de datos (None = auto-detect)
            height: Alto del rango de datos (None = auto-detect)
            regex: Si True, trata entrypoint como expresión regular
            infer_height: Si True, infiere altura hasta última fila con datos
            infer_width: Si True, infiere ancho hasta última columna con datos
            infer_dimensions: Si True, activa infer_height e infer_width
            normalize_columns: Si True, normaliza nombres de columnas
            infer_types: Si True, infiere tipos automáticamente
            with_fields: Lista de campos a incluir (None = todos)
            **kwargs: Argumentos adicionales
            
        Returns:
            DataFrame con los datos del Excel
        """
        logger.debug(f"Reading Excel sheet '{sheet_name}'")
        
        # Cargar workbook
        wb = load_workbook(BytesIO(file_bytes), read_only=True, data_only=True)
        
        if sheet_name not in wb.sheetnames:
            available = ', '.join(wb.sheetnames)
            raise ValueError(
                f"Hoja '{sheet_name}' no encontrada. "
                f"Hojas disponibles: {available}"
            )
        
        ws = wb[sheet_name]
        
        # Auto-inferir dimensiones si se solicita
        if infer_dimensions:
            infer_height = True
            infer_width = True
        
        # Crear rango de datos
        data_range = WorksheetDataRange(
            ws=ws,
            column_names=column_names,
            entrypoint=entrypoint,
            width=width,
            height=height,
            regex=regex,
            infer_height=infer_height,
            infer_width=infer_width,
            normalize_columns=normalize_columns
        )
        
        # Obtener datos
        df = data_range.get_dataframe(
            with_fields=with_fields,
            infer_types=infer_types
        )
        
        wb.close()
        
        logger.debug(f"Excel read successfully: {len(df)} rows, {len(df.columns)} columns")
        return df


class WorksheetDataRange:
    """
    Representa un rango de datos dentro de una hoja de Excel.
    
    Maneja la lógica de encontrar y extraer datos de una región específica
    de una hoja de cálculo.
    """
    
    def __init__(
        self,
        ws: Worksheet,
        column_names: Optional[list[str]] = None,
        entrypoint: Optional[str] = None,
        width: Optional[int] = None,
        height: Optional[int] = None,
        regex: bool = False,
        infer_height: bool = False,
        infer_width: bool = False,
        normalize_columns: bool = False
    ) -> None:
        self.ws = ws
        self.entrypoint = entrypoint
        self._width = width
        self._height = height
        self.regex = regex
        self.infer_height = infer_height
        self.infer_width = infer_width
        self.normalize_columns = normalize_columns
        
        self._origin_column_names: Optional[list[str]] = None
        self._column_names = column_names
        self._array: Optional[list[tuple]] = None
        self._records: Optional[list[dict]] = None
        
        self.min_row, self.min_col = self.data_origin
    
    @property
    def data_origin(self) -> tuple[int, int]:
        """
        Encuentra la esquina superior izquierda del rango de datos (base 1).
        
        Returns:
            Tupla (fila, columna) donde empiezan los datos
        """
        min_row = min_col = 1
        
        if self.entrypoint:
            import re
            
            for row in self.ws.iter_rows(min_row=min_row, min_col=min_col):
                for cell in row:
                    value = cell.value
                    
                    if not isinstance(value, str):
                        continue
                    
                    if self.regex:
                        match_str = re.match(self.entrypoint, value, re.IGNORECASE)
                    else:
                        match_str = value.strip().lower() == self.entrypoint.strip().lower()
                    
                    if match_str:
                        logger.debug(f"Entrypoint found at row={cell.row}, col={cell.column}")
                        return cell.row, cell.column
            
            raise ValueError(f"No se encuentra coincidencia para entrypoint: '{self.entrypoint}'")
        
        return min_row, min_col
    
    @property
    def width(self) -> int:
        """Ancho del rango de datos (número de columnas)."""
        if self._width is not None:
            return self._width
        
        if self.infer_width:
            self._width = self.ws.max_column - self.min_col + 1
        else:
            # Detectar ancho basándose en primera fila
            first_row = next(
                self.ws.iter_rows(
                    min_row=self.min_row,
                    max_row=self.min_row,
                    min_col=self.min_col,
                    values_only=True
                )
            )
            self._width = sum(1 for cell in first_row if cell is not None)
        
        return self._width
    
    @property
    def max_col(self) -> int:
        """Índice de la última columna del rango (base 1)."""
        return self.min_col + self.width - 1
    
    @property
    def height(self) -> int:
        """Alto del rango de datos (número de filas)."""
        if self._height is not None:
            return self._height
        
        if self.infer_height:
            self._height = self.ws.max_row - self.min_row + 1
        else:
            # Detectar altura hasta encontrar fila completamente vacía
            h = 0
            for row in self.ws.iter_rows(
                min_row=self.min_row,
                min_col=self.min_col,
                max_col=self.max_col,
                values_only=True
            ):
                # Verificar si la fila está completamente vacía
                if all(cell is None for cell in row):
                    break
                h += 1
            
            self._height = h if h > 0 else 1
        
        return self._height
    
    @property
    def max_row(self) -> int:
        """Índice de la última fila del rango (base 1)."""
        return self.min_row + self.height - 1
    
    @property
    def origin_column_names(self) -> list[str]:
        """Nombres originales de las columnas (primera fila del rango)."""
        if self._origin_column_names is None:
            first_row = self.array[0] if self.array else []
            if self.normalize_columns:
                self._origin_column_names = [
                    normalizar_texto(x) if x is not None else f'col_{i}'
                    for i, x in enumerate(first_row)
                ]
            else:
                self._origin_column_names = [
                    str(x).strip() if x is not None else f'col_{i}'
                    for i, x in enumerate(first_row)
                ]
        
        return self._origin_column_names
    
    @property
    def column_names(self) -> list[str]:
        """Nombres finales de las columnas."""
        if self._column_names is None:
            self._column_names = self.origin_column_names
        
        return self._column_names
    
    @property
    def array(self) -> list[tuple]:
        """Datos del rango como lista de tuplas (filas)."""
        if self._array is None:
            self._array = []
            for row in self.ws.iter_rows(
                self.min_row,
                self.max_row,
                self.min_col,
                self.max_col,
                values_only=True
            ):
                self._array.append(row)
        
        return self._array
    
    @property
    def records(self) -> list[dict[str, Any]]:
        """Datos del rango como lista de diccionarios."""
        if self._records is None:
            records = []
            for i, row in enumerate(self.array):
                if i == 0:  # Saltar fila de headers
                    continue
                
                record = {
                    k: limpiar_valor(v)
                    for k, v in zip(self.column_names, row)
                }
                records.append(record)
            
            self._records = records
        
        return self._records
    
    def get_dataframe(
        self,
        with_fields: Optional[list[str]] = None,
        infer_types: bool = True
    ) -> pd.DataFrame:
        """
        Convierte el rango a DataFrame.
        
        Args:
            with_fields: Campos a incluir (None = todos)
            infer_types: Si True, infiere tipos automáticamente
            
        Returns:
            DataFrame con los datos
        """
        df = pd.DataFrame(self.records)
        
        if df.empty:
            return pd.DataFrame(columns=self.column_names)
        
        # Filtrar columnas si se especifica
        if with_fields:
            missing_fields = set(with_fields) - set(df.columns)
            if missing_fields:
                logger.warning(f"Campos no encontrados: {missing_fields}")
            df = df[[col for col in with_fields if col in df.columns]]
        
        # Inferir tipos
        if infer_types:
            df = infer_and_convert_dataframe(df)
        else:
            df = df.replace({np.nan: pd.NA, r'^\s*$': pd.NA}, regex=True)
        
        return df


class ParquetReader(DataReader):
    """
    Lector para archivos Parquet.
    
    Examples:
        >>> reader = ParquetReader()
        >>> df = reader.read(parquet_bytes)
    """
    
    def read(
        self,
        file_bytes: bytes,
        infer_types: bool = False,  # Parquet ya tiene tipos
        **kwargs
    ) -> pd.DataFrame:
        """
        Lee un archivo Parquet desde bytes.
        
        Args:
            file_bytes: Contenido del archivo Parquet en bytes
            infer_types: Ignorado (Parquet ya tiene tipos nativos)
            **kwargs: Argumentos adicionales para pd.read_parquet
            
        Returns:
            DataFrame con los datos del Parquet
        """
        logger.debug("Reading Parquet file")
        
        try:
            df = pd.read_parquet(BytesIO(file_bytes), **kwargs)
            logger.debug(f"Parquet read successfully: {len(df)} rows, {len(df.columns)} columns")
            return df
        except Exception as e:
            logger.error(f"Error reading Parquet: {e}")
            raise


class JSONReader(DataReader):
    """
    Lector para archivos JSON.
    
    Examples:
        >>> reader = JSONReader()
        >>> df = reader.read(json_bytes, orient='records')
    """
    
    def read(
        self,
        file_bytes: bytes,
        orient: str = 'records',
        infer_types: bool = True,
        **kwargs
    ) -> pd.DataFrame:
        """
        Lee un archivo JSON desde bytes.
        
        Args:
            file_bytes: Contenido del archivo JSON en bytes
            orient: Formato del JSON ('records', 'index', 'columns', etc.)
            infer_types: Si True, infiere tipos automáticamente
            **kwargs: Argumentos adicionales para pd.read_json
            
        Returns:
            DataFrame con los datos del JSON
        """
        logger.debug(f"Reading JSON file with orient='{orient}'")
        
        try:
            df = pd.read_json(BytesIO(file_bytes), orient=orient, **kwargs)
            
            if infer_types:
                df = infer_and_convert_dataframe(df)
            
            logger.debug(f"JSON read successfully: {len(df)} rows, {len(df.columns)} columns")
            return df
        except Exception as e:
            logger.error(f"Error reading JSON: {e}")
            raise
