from __future__ import  annotations

from typing import  Callable, Iterable, Optional, Tuple, Union
import  matplotlib.pyplot   as plt
import  numpy               as np

FilterFn    =   Callable[[object], object]

def plot_xy(df, x_col, y_col, label:Optional[str] = None, ax=None,
            xlim:       Optional[Tuple[float, float]]   = None,
            ylim:       Optional[Tuple[float, float]]   = None,
            marker:     str = "o",
            linestyle:  str = "-",
            legen_loc=None,):
    
    """
    Gráfica y vs x para un DataFrame.
    - ax: para superponer curvas
    - xlim/ylim: zoom visual (No filtrado datos)
    """

    if ax   is  None:
        _, ax =   plt.subplots(figsize=(7,5))
    
    ax.plot(df[x_col], df[y_col], marker=marker, linestyle=linestyle, label=label)
    ax.set_xlabel(x_col)
    ax.set_ylabel(y_col)
    ax.grid(True)

    if xlim is not None:
        ax.set_xlim(xlim)
    if ylim is not None:
        ax.set_ylim(ylim)

    if legen_loc is not None:
        ax.legend(loc=legen_loc)
    elif  label:
        ax.legend()
    
    return  ax

def comparar_rondas(data: dict, experimento: int, x_col: str, y_col: str,
                    rondas: Iterable[int]   =   (1,2),
                    filter_fn: Optional[FilterFn] = None,
                    xlim: Optional[Tuple[float, float]] = None,
                    ylim: Optional[Tuple[float, float]] = None,
                    title: Optional[str] = None,
                    legend_loc: Optional[str] = None,):
    """
    Compara el mismo experimento entre rondas:
    data[(ronda, experimento)] -> df

    filter_fn: funcion opcional que recibe df y devuelve df filtrado.
        Ejemplo: filter_fn = lambda df: apply_filter(df, row_start=10, row_end=40)
    """
    fig, ax = plt.subplots(figsize=(7,5))

    for ronda in rondas:
        df = data[(ronda, experimento)]
        if filter_fn is not None:
            df = filter_fn(df)

        plot_xy(df, x_col, y_col, label=f"Ronda {ronda}", ax=ax,
                xlim=xlim, ylim=ylim, legen_loc=legend_loc)
        
    if title is "False":
        pass
    else:
        ax.set_title(title or f"Experimento {experimento} - Comparación de Rondas")
    
    plt.show()
    
    return fig, ax

def comparar_experimentos(data: dict, ronda: int, x_col: str, y_col: str, 
                          experimentos: Iterable[int] = (1,2,3,4),
                          show: bool = False,
                          filter_fn: Optional[FilterFn] = None,
                          xlim: Optional[Tuple[float, float]] = None,
                          ylim: Optional[Tuple[float, float]] = None,
                          title: Optional[str] = None,
                          legend_loc: Optional[str] = None, 
):
    """
    Comparar varios experimentos dentro de una misma ronda.
    """

    fig, ax = plt.subplots(figsize=(7, 5))

    for exp in experimentos:
        df = data[(ronda, exp)]
        if filter_fn is not None:
            df  = filter_fn(df)
        
        plot_xy(df, x_col, y_col, label=f"Experimento {exp}", ax=ax, xlim=xlim, ylim=ylim, legen_loc=legend_loc)
    if title is "False":
        pass
    else:
        ax.set_title(title or f"Ronda {ronda} - Comparación de Experimentos") 

    if show is True:
        plt.show()
    return fig, ax

def plot_con_ajuste(df, x_col: str, y_col: str, label_datos: str = "Datos",
                    label_ajuste: str = "Ajuste lineal",
                    show: bool = True 
):
    """
    Grafica scatter + ajuste lineal (y = mx + b). Devuelve (m, b)
    """

    x = np.asarray(df[x_col].values, dtype=float)
    y = np.asarray(df[y_col].values, dtype=float)

    m, b = np.polyfit(x, y, 1)

    fig, ax = plt.subplots(figsize=(7, 5))
    ax.scatter(x, y, label=label_datos)
    ax.plot(x, m * x + b, label=f"{label_ajuste} (m={m:.4f})")

    ax.set_xlabel(x_col)
    ax.set_ylabel(y_col)
    ax.grid(True)
    ax.legend()

    if show:
        plt.show()
    
    return m, b, fig, ax

def plot_3D(df, x_col: str, y_col: str, z_col: str, 
            title: Optional[str] = None,):
    """
    Gráfica 3D: (x, y, z) como dispersión. 
    """

    from mpl_toolkits.mplot3d import Axes3D #noqa: F401

    fig = plt.figure(figsize=(8, 6))
    ax  = fig.add_subplot(111, projection="3d")

    ax.scatter(df[x_col], df[y_col], df[z_col])
    ax.set_xlabel(x_col)
    ax.set_ylabel(y_col)
    ax.set_zlabel(z_col)
    ax.set_title(title or f"3D: {x_col} vs {y_col} vs {z_col}")

    plt.show()
    return fig, ax

def plot_color_map(df, x_col: str, y_col: str, z_col: str,
                   title: Optional[str] = None, ):
    
    """
    Scatter 2D con tercera variable como color 
    """
    fig, ax = plt.subplots(figsize=(7, 5))
    sc = ax.scatter(df[x_col], df[y_col], c=df[z_col], cmap="viridis")

    cbar = plt.colorbar(sc, ax=ax)
    cbar.set_label(z_col)

    ax.set_xlabel(x_col)
    ax.set_ylabel(y_col)
    ax.grid(True)
    ax.set_title(title or f"{y_col} vs {x_col} (color={z_col})")

    plt.show()
    return fig, ax

def plot_dual_axis(df, x_col: str, y1_col: str, y2_col: str, ax=None,
                   label1: Optional[str] = None,
                   label2: Optional[str] = None,
                   title:  Optional[str] = None,
                   show: bool = True):
    
    """
    Gráfica con doble eje Y:
     y1 vs x (izquierda) y y2 vs x (derecha)
    """
    if ax is None:
        fig, ax1 = plt.subplots(figsize=(7, 5))
    else:
        ax1 = ax
        fig = ax1.figure

    ax2 =   ax1.twinx()

    ax1.plot(df[x_col], df[y1_col], marker="o", linestyle="-", color="b",label= label1 or y1_col)
    ax2.plot(df[x_col], df[y2_col], marker="s", linestyle="--", color="r",label= label2 or y2_col)

    ax1.set_xlabel(x_col)
    ax1.set_ylabel(y1_col)
    ax2.set_ylabel(y2_col)

    ax1.grid(True)

    ax1.set_title(title or f"{y1_col} y {y2_col} vs {x_col}")

    # Leyendas Combinadas 
    lines, labels = [], []
    for a in (ax1, ax2):
        l, lab = a.get_legend_handles_labels()
        lines += l
        labels += lab
    ax1.legend(lines, labels, loc="best")
    
    if show is not True:
        plt.show()
    return fig, ax1, ax2

def marcar_zonas(ax, limites, labels=None, colors=None):
    """
    Marca zonas en una gráfica con líneas verticales.

    limites: lista de voltajes donde cambian las zonas
    lables:  nombre de cada zona 
    colors:  colores opcionales
    """

    if colors is None:
        colors  =   ["gray", "blue", "green", "red"]

    for i, v in enumerate(limites):

        ax.axvline(v, linestyle="--", color="black", alpha=0.6)

        if labels and i < len(labels):
            ax.text(
                v, 
                ax.get_ylim()[1]*0.65,
                labels[i],
                rotation=90,
                verticalalignment="top",
                horizontalalignment="right",
                fontsize=12
            )
def sombrear_zonas(ax, limites, colores=None, alpha=0.15):
    """
    Sombrea las zonas del experimento 
    """

    if colores is None:
        colores =   ["gray", "blue", "green", "red"]
    
    for i in range(len(limites)-1):
        ax.axvspan(
            limites[i],
            limites[i+1],
            color=colores[i],
            alpha=alpha
        )

def percent_change(data:dict, ronda: int, x_col: str, y_col: str, 
                   experimentos: Iterable[int] = (1,2,3,4),
                   show: bool = False,
                   btween_exp: bool = False, 
                   filter_fn: Optional[FilterFn] = None,
                   xlim: Optional[Tuple[float, float]] = None,
                   ylim: Optional[Tuple[float, float]] = None,
                   title: Optional[str] = None, ):
    
    fig, ax = plt.subplots(figsize=(7,5))

    if btween_exp is False:
        change_value = {}
        sum_mean    =   0

        for exp in experimentos:
            df  = data[(ronda, exp)]
            if filter_fn is not None:
                df  =   filter_fn(df)

            R   =   df[y_col].values
            x_  =   df[x_col].values
            for i in range(len(R) - 1):
                change_value[i] = ((R[i+1] - R[i]) / R[i]) * 100
                pct             =   change_value[i]
                sum_mean        =   sum_mean + abs(pct)
                print(f"Δ% Experiment:{exp} between data {i} and {i+1}: {pct:.2f}%")
                if show is True:
                    ax.annotate(f"{pct:.1f}%",
                                (x_[i+1], R[i+1]),
                                textcoords="offset points",
                                xytext=(0,8),
                                ha='center',
                                fontsize=8,
                                color="red")
            mean            =   sum_mean/len(change_value)
            change_mx_min   =   ((R[(len(R)-1)] - R[0])/R[0]) * 100
            print(f"Last_Value:{R[(len(R)-1)]:.3f} Fst_value:{R[0]:.3f}")
            print(f"The mean percent change value in Experiment {exp} are : {mean:.2f}%")
            print(f"The percent change between the last and first value of experiment {exp}: {change_mx_min:.2f}%")
            plot_xy(df, x_col, y_col, label=f"Experimento {exp}", ax=ax, xlim=xlim, ylim=ylim)
            sum_mean = 0
    else:
        change_value = {}
        df = {}
        i = 0
        for exp in experimentos:
            df[i]  =   data[(ronda, exp)]
            if filter_fn is not None:
                df[i] = filter_fn(df[i])
            i = i + 1
        R1  = df[0][y_col].values
        R2  = df[1][y_col].values
        x_1 = df[0][x_col].values
        x_2 = df[1][x_col].values
        for x in range(len(R1)):
            change_value[x] = ((R2[x] - R1[x]) / R1[x]) * 100
            pct = change_value[x]
            print(f"Δ% Punto {x}: Exp{experimentos[0]} vs Exp{experimentos[1]} = {pct:.2f}%")
            if show is True:
                # Line that connect each point
                ax.plot([x_1[x], x_2[x]], [R1[x], R2[x]], color="black", linestyle="-", linewidth=1)
                # Text with the change percent in the middle of the line
                ax.annotate(f"{pct:.1f}%",
                            xy=((x_1[x]+x_2[x])/2, (R1[x]+R2[x])/2),
                            textcoords="offset points",
                            xytext=(0,3),
                            ha='center', fontsize=8, color="red")
        plot_xy(df[0], x_col, y_col, label=f"Experimento {experimentos[0]}", ax=ax, xlim=xlim, ylim=ylim)
        plot_xy(df[1], x_col, y_col, label=f"Experimento {experimentos[1]}", ax=ax, xlim=xlim, ylim=ylim)

    ax.set_title(title or f"Round {ronda} - Experiments Comparative")

    if show is True:
        plt.show()
    return fig, ax