"""
Plot interactions as a barcode --- :mod:`prolif.plotting.barcode`
=================================================================

.. versionadded:: 2.0.0

.. autoclass:: Barcode
   :members:

"""

from __future__ import annotations

from contextlib import suppress
from typing import TYPE_CHECKING, ClassVar, Literal

import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch

from prolif.exceptions import RunRequiredError
from prolif.plotting.utils import IS_NOTEBOOK, separated_interaction_colors

if TYPE_CHECKING:
    from matplotlib.axes import Axes
    from matplotlib.backend_bases import LocationEvent
    from matplotlib.figure import Figure
    from matplotlib.image import AxesImage

    from prolif.fingerprint import Fingerprint


class Barcode:
    """Creates a barcode plot of interactions.

    Parameters
    ----------
    df: pandas.DataFrame
        The DataFrame as obtained from :meth:`Fingerprint.to_dataframe()`

    Attributes
    ----------
    COLORS : dict
        Dictionnary of colors used in the plot for interactions.

    """

    COLORS: ClassVar[dict[str | None, str]] = {
        None: "white",
        **separated_interaction_colors,  # type: ignore[dict-item]
    }

    def __init__(self, df: pd.DataFrame) -> None:
        # mapping interaction type (HBond...etc.) to an arbitrary value which
        # corresponds to a color
        self.color_mapper = {
            interaction: value for value, interaction in enumerate(self.COLORS)
        }
        # reverse: map value to interaction type
        self.inv_color_mapper = {
            value: interaction for interaction, value in self.color_mapper.items()
        }
        # matplotlib colormap
        self.cmap = ListedColormap(list(self.COLORS.values()))

        # drop ligand level if single residue
        # else concatenate ligand with protein and drop ligand if peptide
        n_ligand_residues = len(np.unique(df.columns.get_level_values("ligand")))
        if n_ligand_residues == 1:
            df = df.droplevel("ligand", axis=1)
        else:
            df.columns = pd.MultiIndex.from_tuples(
                [(f"{items[0]}-{items[1]}", items[2]) for items in df.columns],
                names=["protein", "interaction"],
            )

        def _bit_to_color_value(s: pd.Series) -> pd.Series:
            """Replaces a bit value with it's corresponding color value"""
            interaction = s.name[-1]  # type: ignore[index]
            return s.apply(
                lambda v: (
                    self.color_mapper[interaction] if v else self.color_mapper[None]
                ),
            )

        self.df = df.astype(np.uint8).T.apply(_bit_to_color_value, axis=1)

    @classmethod
    def from_fingerprint(cls, fp: Fingerprint) -> Barcode:
        """Creates a barcode object from a fingerprint."""
        if not hasattr(fp, "ifp"):
            raise RunRequiredError(
                "Please run the fingerprint analysis before attempting to display"
                " results.",
            )
        return cls(fp.to_dataframe())

    def display(
        self,
        figsize: tuple[int, int] = (8, 10),
        dpi: int = 100,
        interactive: bool = IS_NOTEBOOK,
        n_frame_ticks: int = 10,
        residues_tick_location: Literal["top", "bottom"] = "top",
        xlabel: str = "Frame",
        subplots_kwargs: dict | None = None,
        tight_layout_kwargs: dict | None = None,
    ) -> "Axes":
        """Generate and display the barcode plot.

        Parameters
        ----------
        figsize: tuple[int, int] = (8, 10)
            Size of the matplotlib figure.
        dpi: int = 100
            DPI used for the matplotlib figure.
        interactive: bool
            Add hover interactivity to the plot (only relevant for notebooks). You may
            need to add ``%matplotlib notebook`` or ``%matplotlib ipympl`` for it to
            work as expected.
        n_frame_ticks: int = 10
            Number of ticks on the X axis. May use ±1 tick to have them evenly spaced.
        residues_tick_location: Literal["top", "bottom"] = "top"
            Whether the Y ticks appear at the top or at the bottom of the series of
            interactions of each residue.
        xlabel: str = "Frame"
            Label displayed for the X axis.
        subplots_kwargs: dict | None = None
            Other parameters passed to :func:`matplotlib.pyplot.subplots`.
        tight_layout_kwargs: dict | None = None
            Other parameters passed to :meth:`matplotlib.figure.Figure.tight_layout`.
        """
        if subplots_kwargs is None:
            subplots_kwargs = {}
        subplots_kwargs.setdefault("figsize", figsize)
        subplots_kwargs.setdefault("dpi", dpi)

        if tight_layout_kwargs is None:
            tight_layout_kwargs = {}
        tight_layout_kwargs.setdefault("pad", 1.2)

        # Plot as image
        fig, ax = plt.subplots(**subplots_kwargs)
        ax: "Axes"
        im = ax.imshow(
            self.df.values,
            aspect="auto",
            interpolation="none",
            cmap=self.cmap,
            vmin=0,
            vmax=max(self.color_mapper.values()),
        )

        # Frame ticks
        frames = self.df.columns
        max_ticks = len(frames) - 1
        # try to have evenly spaced ticks
        for effective_n_ticks in (n_frame_ticks, n_frame_ticks - 1, n_frame_ticks + 1):
            samples, step = np.linspace(0, max_ticks, effective_n_ticks, retstep=True)
            if step.is_integer():
                break
        else:
            samples = np.linspace(0, max_ticks, n_frame_ticks)
        indices = np.round(samples).astype(int)
        ax.xaxis.set_ticks(indices, frames[indices])
        ax.set_xlabel(xlabel)

        # Residues ticks
        n_items = len(self.df.index)
        residues = self.df.index.get_level_values("protein")
        interactions = self.df.index.get_level_values("interaction")
        if residues_tick_location == "top":
            indices = [
                i
                for i in range(n_items)
                if (i - 1 >= 0 and residues[i - 1] != residues[i]) or i == 0
            ]
        else:
            indices = [
                i
                for i in range(n_items)
                if (i + 1 < n_items and residues[i + 1] != residues[i])
                or i + 1 == n_items
            ]
        ax.yaxis.set_ticks(indices, residues[indices])

        # legend
        values: list[int] = np.unique(self.df.values).tolist()
        with suppress(ValueError):
            # 0 not in values (e.g. plotting a single frame)
            values.pop(values.index(0))  # remove None color
        legend_colors = {
            self.inv_color_mapper[value]: im.cmap(value) for value in values
        }
        patches = [
            Patch(color=color, label=interaction)
            for interaction, color in legend_colors.items()
        ]
        ax.legend(handles=patches, bbox_to_anchor=(1.01, 1), loc=2, borderaxespad=0)

        # interactive
        if interactive:
            self._add_interaction_callback(
                fig,
                ax,
                im=im,
                frames=frames,
                residues=residues,
                interactions=interactions,
            )

        fig.tight_layout(**tight_layout_kwargs)
        return ax

    def _add_interaction_callback(
        self,
        fig: "Figure",
        ax: "Axes",
        *,
        im: "AxesImage",
        frames: pd.Index,
        residues: pd.Index,
        interactions: pd.Index[str],
    ) -> None:
        annot = ax.annotate(
            "",
            xy=(0, 0),
            xytext=(5, 5),
            textcoords="offset points",
            alpha=0.8,
            bbox={"boxstyle": "round", "facecolor": "w"},
            wrap=True,
        )
        annot.set_visible(False)

        def hover_callback(event: "LocationEvent") -> None:
            if (
                event.inaxes is ax
                and event.xdata is not None
                and event.ydata is not None
            ):
                x, y = round(event.xdata), round(event.ydata)
                if self.df.to_numpy()[y, x]:
                    annot.xy = (x, y)
                    frame = frames[x]
                    interaction = interactions[y]
                    residue = residues[y]
                    annot.set_text(f"Frame {frame}: {residue}")
                    color = im.cmap(self.color_mapper[interaction])
                    if bbox := annot.get_bbox_patch():
                        bbox.set_facecolor(color)
                    annot.set_visible(True)
                    fig.canvas.draw_idle()
                    return
            if annot.get_visible():
                annot.set_visible(False)
                fig.canvas.draw_idle()

        fig.canvas.mpl_connect("motion_notify_event", hover_callback)  # type: ignore[arg-type]
        fig.canvas.header_visible = False  # type: ignore[attr-defined]
        fig.canvas.footer_visible = False  # type: ignore[attr-defined]
