import pandas as pd
import numpy as np
from dataclasses import dataclass, field
from .client import Datamart
from scipy.stats import norm

data = Datamart()

@dataclass
class DataFM():
    '''
    Classe para gestionar información de fondos mutuos con soporte para reglas de ajuste histórico.
    '''
    # Variables de entrada
    runs : list = None # lista de RUNs, no es obligatorio
    date : str = None # fecha de corte, formato 'YYYY-MM-DD', obligatorio
    categ_filter : str = None # categoría de fondos mutuos a filtrar, no es obligatorio
    vigente_filter : bool = True # indica si se deben filtrar los fondos mutuos vigentes, por defecto es True 
    ccy : str = 'clp' # moneda del valor cuota
    rules : list = field(default_factory=list) # lista de reglas para filtrar los fondos mutuos, no es obligatorio
    
    # Variables de salida
    start_date : str = None
    end_date : str = None
    df_data : pd.DataFrame = None
    df_info : pd.DataFrame = None

    def __post_init__(self) -> None:
        '''
        '''
        # info fondos
        self.df_info = self.info_fondos(date=self.date, bool_name=True)
        
        # fechas
        self.start_date : str = self.df_info['FECHA_MIN'].min()
        self.end_date : str = self.df_info['FECHA_MAX'].max()
        
        # valores cuota
        df_vc = self.vc_data()
        self.df_data = df_vc
    
    def _apply_replacement_rule(self, df: pd.DataFrame, rule: dict) -> pd.DataFrame:
        '''
        Aplica una regla de reemplazo y fusiona registros históricos si es necesario.
        
        Args:
            df: DataFrame con información de fondos
            rule: Diccionario con estructura {'RUN': '...', 'COLUMNA': '...', 'VALOR': {...}}
        
        Returns:
            DataFrame con la regla aplicada y registros fusionados si corresponde
        '''
        run = rule['RUN']
        columna = rule['COLUMNA']
        valor_map = rule['VALOR']  # diccionario de reemplazo
        
        # Obtener mascara para el RUN
        mask = df['RUN'] == run
        
        # Aplicar el reemplazo
        df.loc[mask, columna] = df.loc[mask, columna].replace(valor_map)
        
        # Si la columna es CATEGORIA, fusionar registros del mismo RUN y categoría
        if columna == 'CATEGORIA':
            df = self._merge_historical_records(df, run)
        
        return df
    
    def _merge_historical_records(self, df: pd.DataFrame, run: str) -> pd.DataFrame:
        '''
        Fusiona registros históricos del mismo RUN y CATEGORIA en un solo registro.
        Esto es útil cuando se aplican reglas que unifican categorías históricas.
        
        Args:
            df: DataFrame con información de fondos
            run: RUN a procesar
        
        Returns:
            DataFrame con registros fusionados
        '''
        # Separar datos del RUN en cuestión y el resto
        df_run = df[df['RUN'] == run].copy()
        df_otros = df[df['RUN'] != run].copy()
        
        if len(df_run) <= 1:
            return df
        
        # Ordenar por fecha para fusionar correctamente
        df_run = df_run.sort_values(by=['CATEGORIA', 'FECHA_MIN']).reset_index(drop=True)
        
        # Agrupar por RUN y CATEGORIA
        grouped_list = []
        for (run_val, categ), group in df_run.groupby(['RUN', 'CATEGORIA']):
            # Para cada grupo, tomar la fecha mínima y máxima
            record = {
                'RUN': run_val,
                'ADMINISTRADORA': group['ADMINISTRADORA'].iloc[0],
                'FONDO': group['FONDO'].iloc[0],
                'CATEGORIA': categ,
                'FECHA_MIN': group['FECHA_MIN'].min(),
                'FECHA_MAX': group['FECHA_MAX'].max(),
                'TIPO_CAMBIO': 'CAMBIO_CATEGORIA' if len(group) > 1 else group['TIPO_CAMBIO'].iloc[0],
            }
            
            # Recalcular DAYS y YEARS con la fecha fusionada
            fecha_min = pd.to_datetime(record['FECHA_MIN'])
            fecha_max = pd.to_datetime(record['FECHA_MAX'])
            record['DAYS'] = (fecha_max - fecha_min).days
            record['YEARS'] = record['DAYS'] / 365
            
            # Mantener VIGENTE si existe: tomar "Vigente" si algún registro lo tiene
            if 'VIGENTE' in df_run.columns:
                # Priorizar "Vigente" si existe, sino tomar el estado del registro más reciente
                if 'Vigente' in group['VIGENTE'].values:
                    record['VIGENTE'] = 'Vigente'
                else:
                    # Si todos son "No Vigente", tomar el del registro más reciente (FECHA_MAX)
                    record['VIGENTE'] = group.sort_values(by='FECHA_MAX', ascending=False).iloc[0]['VIGENTE']
            
            grouped_list.append(record)
        
        df_grouped = pd.DataFrame(grouped_list)
        
        # Combinar con otros RUNs
        df_resultado = pd.concat([df_otros, df_grouped], ignore_index=True)
        
        return df_resultado
    
    def info_fondos(self, date:str=None,bool_name:bool=True) -> pd.DataFrame:
        '''
        Obtiene y procesa información de fondos mutuos con aplicación de reglas.
        '''
        df : pd.DataFrame = data.get_fm_info(fecha_corte=date, ignorar_cambio_nombre=bool_name)

        if self.runs is not None:
            df['CHECK_RUN'] = df['RUN'].apply(lambda x: x in self.runs)
            df = df[df['CHECK_RUN']].reset_index(drop=True)
            df.dropna(subset=['CHECK_RUN'], inplace=True)
            df.drop(columns=['CHECK_RUN'], inplace=True)
        if self.categ_filter is not None:
            df = df[df['CATEGORIA'] == self.categ_filter].reset_index(drop=True)
        if self.vigente_filter:
            df = df[df['VIGENTE'] == 'Vigente'].reset_index(drop=True)
        
        # Aplicar reglas con soporte de fusión de registros
        for rule in self.rules:
            df = self._apply_replacement_rule(df, rule)

        return df
    
    def vc_data(self) -> pd.DataFrame:
        '''
        Obtiene valores cuota desde la API y los filtra para ser coherente con df_info.
        Solo retorna datos dentro de los rangos FECHA_MIN y FECHA_MAX definidos en df_info.
        '''
        df : pd.DataFrame = data.get_fm_valores_cuota_parallel(
            start=self.start_date,
            end=self.end_date,
            ccy=self.ccy,
            lst_run=self.runs 
        )
        
        # Filtrar df_data para que sea coherente con df_info
        df_filtered = self._filter_vc_by_info(df)
        
        return df_filtered
    
    def _filter_vc_by_info(self, df_vc: pd.DataFrame) -> pd.DataFrame:
        '''
        Filtra los datos de valores cuota para que sean coherentes con la información en df_info.
        
        Solo mantiene datos que estén dentro de los rangos FECHA_MIN y FECHA_MAX
        definidos para cada RUN en df_info.
        
        Args:
            df_vc: DataFrame con datos de valores cuota
        
        Returns:
            DataFrame filtrado
        '''
        if df_vc is None or df_vc.empty or self.df_info is None or self.df_info.empty:
            return df_vc
        
        # Convertir FECHA a datetime si está en df_vc
        if 'FECHA' in df_vc.columns:
            df_vc['FECHA'] = pd.to_datetime(df_vc['FECHA'], errors='coerce')
        
        # Lista para almacenar datos válidos
        valid_rows = []
        
        # Para cada registro en df_info
        for idx, row in self.df_info.iterrows():
            run = str(row['RUN'])
            fecha_min = pd.to_datetime(row['FECHA_MIN'])
            fecha_max = pd.to_datetime(row['FECHA_MAX'])
            
            # Filtrar datos de vc que correspondan a este RUN y estén en el rango de fechas
            mask = (df_vc['RUN'].astype(str) == run) & \
                   (df_vc['FECHA'] >= fecha_min) & \
                   (df_vc['FECHA'] <= fecha_max)
            
            valid_rows.append(df_vc[mask])
        
        # Concatenar todos los registros válidos
        if valid_rows:
            df_filtered = pd.concat(valid_rows, ignore_index=True)
            df_filtered = df_filtered.drop_duplicates().reset_index(drop=True)
            return df_filtered
        else:
            return pd.DataFrame()

@dataclass
class TimeSeriesFM():
    '''
    '''
    # Variables de entrada
    df_data : pd.DataFrame = None
    drop_weekends : bool = True 
    rolling_window : int = 20 # ventana para cálculo de retornos, no es obligatorio, por defecto es 20
    
    # Variables de salida
    df_price : pd.DataFrame = None
    df_dtd : pd.DataFrame = None
    df_wtw : pd.DataFrame = None
    df_mtm : pd.DataFrame = None
    df_rolling : pd.DataFrame = None
    
    
    def __post_init__(self) -> None:
        '''
        '''
        # ajuste de fin de semana
        if self.drop_weekends == True:
            self.df_data['FECHA'] = pd.to_datetime(self.df_data['FECHA'], errors='coerce')
            self.df_data = self.df_data[~self.df_data['FECHA'].dt.weekday.isin([5, 6])].reset_index(drop=True)
        
        # matrices de series de tiempo
        self.df_price = self.price_series()
        self.df_dtd = self.dtd_series()
        self.df_wtw = self.wtw_series()
        self.df_mtm = self.mtm_series()
        self.df_rolling = self.rolling_series()
        
    
    def price_series(self):
        '''
        '''
        pivot : pd.DataFrame = pd.pivot_table(
            self.df_data, 
            index='FECHA', 
            columns='RUN', values='VALORCUOTA'
            )
        return pivot

    def dtd_series(self):
        '''
        '''
        pivot : pd.DataFrame = pd.pivot_table(
            self.df_data, 
            index='FECHA', 
            columns='RUN', 
            values='DTD'
            )
        return pivot
    
    def wtw_series(self):
        '''
        '''
        df_wtw = self.df_data.copy()
        df_wtw['FECHA'] = pd.to_datetime(df_wtw['FECHA'], errors='coerce')
        
        # Ordenar por RUN y FECHA (de más antigua a más reciente)
        df_wtw = df_wtw.sort_values(by=['RUN', 'FECHA']).reset_index(drop=True)
        
        # Identificar último día de cada semana (viernes o último día disponible)
        df_wtw['WEEK'] = df_wtw['FECHA'].dt.isocalendar().week
        df_wtw['YEAR'] = df_wtw['FECHA'].dt.isocalendar().year
        
        # Marcar el último día de cada semana por RUN
        df_wtw['IS_LAST_DAY'] = df_wtw.groupby(['RUN', 'YEAR', 'WEEK'])['FECHA'].transform(lambda x: x == x.max())
        
        # Filtrar solo los últimos días de semana
        df_wtw_filtered = df_wtw[df_wtw['IS_LAST_DAY']].copy()
        
        # Calcular retorno semanal (WTW)
        df_wtw_filtered['WTW'] = df_wtw_filtered.groupby('RUN')['VALORCUOTA'].pct_change()
        
        # Crear pivot table
        pivot : pd.DataFrame = pd.pivot_table(
            df_wtw_filtered, 
            index='FECHA', 
            columns='RUN', 
            values='WTW'
        )
        return pivot
    
    def mtm_series(self):
        '''
        '''
        df_mtm = self.df_data.copy()
        df_mtm['FECHA'] = pd.to_datetime(df_mtm['FECHA'], errors='coerce')
        
        # Ordenar por RUN y FECHA (de más antigua a más reciente)
        df_mtm = df_mtm.sort_values(by=['RUN', 'FECHA']).reset_index(drop=True)
        
        # Identificar año y mes
        df_mtm['YEAR'] = df_mtm['FECHA'].dt.year
        df_mtm['MONTH'] = df_mtm['FECHA'].dt.month
        
        # Obtener último día del mes para cada fecha
        df_mtm['LAST_DAY_MONTH'] = df_mtm['FECHA'].dt.is_month_end
        
        # Filtrar solo los últimos días de mes
        df_mtm_filtered = df_mtm[df_mtm['LAST_DAY_MONTH']].copy()
        
        # Calcular retorno mensual (MTM)
        df_mtm_filtered['MTM'] = df_mtm_filtered.groupby('RUN')['VALORCUOTA'].pct_change()
        
        # Crear pivot table
        pivot : pd.DataFrame = pd.pivot_table(
            df_mtm_filtered, 
            index='FECHA', 
            columns='RUN', 
            values='MTM'
        )
        return pivot
    
    def rolling_series(self):
        '''
        Calcula los retornos de ventana móvil basados en la serie de precios.
        Utiliza la ventana definida en rolling_window.
        '''
        # Obtener la serie de precios
        df_price = self.price_series()
        
        # Calcular retornos de ventana móvil
        df_rolling = df_price.pct_change(periods=self.rolling_window)
        
        return df_rolling

@dataclass
class MetricsExpostFM():

    def volatility(self, df_returns: pd.DataFrame, ewma_factor: float = None) -> float:
        '''
        Cálculo de la volatilidad para una serie de retornos.
        
        Parámetros:
        df_returns: DataFrame con los retornos de los fondos mutuos o Series para un fondo.
        ewma_factor: Factor de suavizamiento para el cálculo de la volatilidad con EWMA (por defecto None, sin suavizamiento).
        
        Retorna:
        Float con la volatilidad calculada.
        '''
        sigma = df_returns.std()
        if ewma_factor is not None:
            # Manejo de Series vs DataFrame
            if isinstance(df_returns, pd.Series):
                ret = df_returns.iloc[-1]
            else:
                ret = df_returns.iloc[-1, 0]
            sigma = (ewma_factor * sigma**2) + ((1 - ewma_factor) * ret**2)
            sigma = np.sqrt(sigma)
        return sigma
    
    def VaR(self, df_returns: pd.DataFrame, sigma: float, confidence_level: float = 0.95, mu_0:bool=False) -> float:
        '''
        Cálculo del Value at Risk (VaR) para una serie de retornos.
        
        Parámetros:
        df_returns: DataFrame con los retornos de los fondos mutuos.
        confidence_level: Nivel de confianza para el cálculo del VaR (por defecto 0.95).
        
        Retorna:
        Serie con el VaR calculado para cada fondo mutuo.
        '''
        z_score = norm.ppf(1 - confidence_level)
        if mu_0 == True:
            mu = 0
        elif mu_0 == False:
            mu = df_returns.mean()
        var = z_score * sigma + mu
        return var
        
    
    def max_drawdown(self, df_returns: pd.DataFrame) -> float:
        '''
        Cálculo del Max Drawdown para una serie de retornos.
        '''
        cumulative_returns = (1 + df_returns).cumprod()
        rolling_max = cumulative_returns.cummax()
        drawdown = (cumulative_returns - rolling_max) / rolling_max
        max_drawdown = drawdown.min()
        return max_drawdown
    
    
    def tracking_error(self, df_returns: pd.DataFrame, benchmark_returns: pd.DataFrame) -> float:
        '''
        Cálculo del Tracking Error para una serie de retornos en comparación con un benchmark.
        '''
        diff = df_returns - benchmark_returns

    
    def beta(self, df_returns: pd.DataFrame, benchmark_returns: pd.DataFrame) -> float:
        '''
        Cálculo del Beta para una serie de retornos en comparación con un benchmark.
        '''
        cov = df_returns.cov(benchmark_returns)
        var_benchmark = benchmark_returns.var()
        beta = cov / var_benchmark
        return beta

        
@dataclass
class ValueAtRiskFM():
    '''
    Calcula el Value at Risk (VaR) para fondos mutuos a partir de series de retornos.
    '''
    # Variables de entrada
    df_dtd : pd.DataFrame = None
    df_wtw : pd.DataFrame = None
    df_mtm : pd.DataFrame = None
    df_rolling : pd.DataFrame = None
    df_info : pd.DataFrame = None
    rango : str = '1Y' # puede ser 1Y, 3Y o 5Y, por defecto es 1Y
    confidence_level : float = 0.95 # nivel de confianza para el cálculo de VaR, por defecto es 0.95
    ewma_factor : float = 0.94 # factor de decaimiento para el cálculo de volatilidad con método EWMA, por defecto es 0.94
    
    # Variables de salida
    ft_metricas : pd.DataFrame = None
    
    def __post_init__(self) -> None:
        '''
        Calcula VaR para cada RUN en los dataframes de retornos y genera ft_metricas.
        Solo calcula si el RUN tiene suficientes datos históricos para el rango especificado.
        '''
        # Instancia de MetricsExpostFM para usar sus funciones
        metrics = MetricsExpostFM()
        
        # Lista para almacenar todos los resultados
        resultados = []
        
        # Mapeo de dataframes a tipo de retorno
        df_map = {
            'dtd': self.df_dtd,
            'wtw': self.df_wtw,
            'mtm': self.df_mtm,
            'rolling': self.df_rolling
        }
        
        # Procesar cada tipo de retorno
        for tipo_retorno, df in df_map.items():
            if df is None or df.empty:
                continue
            
            # Filtrar por rango
            df_filtered = self.rango_filter(df)
            
            if df_filtered.empty:
                continue
            
            # Obtener la fecha máxima del dataframe filtrado
            fecha_max = df_filtered.index.max()
            if isinstance(fecha_max, pd.Timestamp):
                fecha_max = fecha_max.date()
            
            # Procesar cada RUN (columna) en el dataframe filtrado
            for run in df_filtered.columns:
                try:
                    # Obtener la serie de retornos para este RUN
                    retornos = df_filtered[run].dropna()
                    
                    if len(retornos) == 0:
                        continue
                    
                    # Validar que hay suficientes datos para el rango especificado
                    if not self._has_sufficient_data(retornos, self.rango):
                        print(f"RUN {run} ({tipo_retorno}): Datos insuficientes para calcular VaR en rango {self.rango}")
                        continue
                    
                    # Calcular volatilidad
                    volatility = metrics.volatility(retornos, ewma_factor=self.ewma_factor)
                    
                    # Calcular VaR
                    var_value = metrics.VaR(retornos, sigma=volatility, confidence_level=self.confidence_level)
                    
                    # Obtener información del RUN desde df_info
                    categoria_lva = None
                    dias_categoria = None
                    if self.df_info is not None and not self.df_info.empty:
                        run_info = self.df_info[self.df_info['RUN'].astype(str) == str(run)]
                        if not run_info.empty:
                            categoria_lva = run_info['CATEGORIA'].iloc[0]
                            dias_categoria = run_info['DAYS'].iloc[0]
                    
                    # Crear registro
                    record = {
                        'FECHA': fecha_max,
                        'ID_METRICA': 1,
                        'RUN': str(run),
                        'VALOR': var_value,
                        'RANGO': self.rango,
                        'TIPO_RETORNO': tipo_retorno,
                        'CATEGORIA_LVA': categoria_lva,
                        'DIAS_CATEGORIA': dias_categoria
                    }
                    
                    resultados.append(record)
                
                except Exception as e:
                    # Si hay error en un RUN, continuar con el siguiente
                    print(f"Error calculando VaR para RUN {run} ({tipo_retorno}): {e}")
                    continue
        
        # Crear dataframe con todos los resultados
        if resultados:
            self.ft_metricas = pd.DataFrame(resultados)
        else:
            # Crear dataframe vacío con la estructura correcta si no hay resultados
            self.ft_metricas = pd.DataFrame(columns=[
                'FECHA', 'ID_METRICA', 'RUN', 'VALOR', 'RANGO', 
                'TIPO_RETORNO', 'CATEGORIA_LVA', 'DIAS_CATEGORIA'
            ])
    
    def _has_sufficient_data(self, retornos: pd.Series, rango: str) -> bool:
        '''
        Valida que la serie de retornos cubra suficientemente el período del rango especificado.
        
        Args:
            retornos: Series con los retornos del fondo (sin NaN)
            rango: Rango especificado ('1Y', '3Y', '5Y')
        
        Returns:
            True si los datos cubren el rango, False en caso contrario
        '''
        if len(retornos) == 0:
            return False
        
        # Obtener fechas mínima y máxima de los datos disponibles
        fecha_min = retornos.index.min()
        fecha_max = retornos.index.max()
        
        # Calcular días disponibles
        dias_disponibles = (fecha_max - fecha_min).days
        
        # Definir días requeridos según el rango (80% de cobertura mínima)
        dias_requeridos = {
            '1Y': 365 * 0.8,      # 292 días (80% de 1 año)
            '3Y': 365 * 3 * 0.8,  # 876 días (80% de 3 años)
            '5Y': 365 * 5 * 0.8   # 1460 días (80% de 5 años)
        }
        
        dias_min = dias_requeridos.get(rango, 365 * 0.8)
        
        return dias_disponibles >= dias_min
    
    def rango_filter(self, df:pd.DataFrame) -> pd.DataFrame:
        '''
        Filtra el DataFrame para mantener solo los datos dentro del rango definido en la variable de entrada "rango".
        '''
        if df is None or df.empty:
            return df
        
        # Convertir el índice a datetime si no lo es
        if not isinstance(df.index, pd.DatetimeIndex):
            df.index = pd.to_datetime(df.index, errors='coerce')
        
        # Calcular la fecha de corte según el rango
        end_date = df.index.max()
        if self.rango == '1Y':
            start_date = end_date - pd.DateOffset(years=1)
        elif self.rango == '3Y':
            start_date = end_date - pd.DateOffset(years=3)
        elif self.rango == '5Y':
            start_date = end_date - pd.DateOffset(years=5)
        else:
            raise ValueError("Rango no válido. Use '1Y', '3Y' o '5Y'.")
        
        # Filtrar el DataFrame por el rango de fechas
        filtered_df = df[(df.index >= start_date) & (df.index <= end_date)].copy()
        
        return filtered_df
