"""
Plot interactions in 3D --- :mod:`prolif.plotting.complex3d`
============================================================

.. versionadded:: 2.0.0

.. autoclass:: Complex3D
   :members:

"""

from __future__ import annotations

from contextlib import suppress
from copy import deepcopy
from typing import (
    TYPE_CHECKING,
    Any,
    ClassVar,
    Literal,
)

import numpy as np
import py3Dmol
from rdkit import Chem
from rdkit.Geometry import Point3D

from prolif.exceptions import RunRequiredError
from prolif.plotting.utils import metadata_iterator, separated_interaction_colors
from prolif.utils import get_centroid, get_residues_near_ligand, requires

with suppress(ModuleNotFoundError):
    from IPython.display import Javascript, display


if TYPE_CHECKING:
    from prolif.fingerprint import Fingerprint
    from prolif.ifp import IFP
    from prolif.molecule import Molecule
    from prolif.residue import Residue, ResidueId


class Complex3D:
    """Creates a py3Dmol plot of interactions.

    Parameters
    ----------
    ifp : IFP
        The interaction dictionary for a single frame.
    lig_mol : Molecule
        The ligand molecule to display.
    prot_mol : Molecule
        The protein molecule to display.
    water_mol : Optional[Molecule]
        Additional molecule (e.g. waters) to display.

    Attributes
    ----------
    COLORS : dict
        Dictionnary of colors used in the plot for interactions.
    LIGAND_STYLE : dict[str, dict] = {"stick": {"colorscheme": "cyanCarbon"}}
        Style object passed to ``3Dmol.js`` for the ligand.
    RESIDUES_STYLE : dict[str, dict] = {"stick": {}}
        Style object passed to ``3Dmol.js`` for the protein residues involved in
        interactions.
    PROTEIN_STYLE : dict[str, dict] = {"cartoon": {"style": "edged"}}
        Style object passed to ``3Dmol.js`` for the entire protein.
    PEPTIDE_STYLE : dict[str, dict] = "cartoon": {"style": "edged", "colorscheme": "cyanCarbon"}
        Style object passed to ``3Dmol.js`` for the ligand as a peptide if appropriate.
    PEPTIDE_THRESHOLD : int = 5
        Ligands with this number of residues or more will be displayed using
        ``PEPTIDE_STYLE`` in addition to the ``LIGAND_STYLE``.
    LIGAND_DISPLAYED_ATOM : dict[str, int]
        Which atom should be used to display an atom-to-atom interaction for the ligand.
        Refers to the order defined in the SMARTS pattern used in interaction
        definition. Interactions not specified here use ``0`` by default.
    PROTEIN_DISPLAYED_ATOM : dict[str, int]
        Same as :attr:`LIGAND_DISPLAYED_ATOM` for the protein.
    LIGAND_RING_INTERACTIONS : set[str]
        Which interactions should be displayed using the centroid instead of using
        :attr:`LIGAND_DISPLAYED_ATOM` for the ligand.
    PROTEIN_RING_INTERACTIONS : set[str]
        Which interactions should be displayed using the centroid instead of using
        :attr:`PROTEIN_DISPLAYED_ATOM` for the protein.
    BRIDGED_INTERACTIONS : dict[str, str]
        For bridged-interactions such as WaterBridge. The key is the interaction name,
        and the value is the name of the molecule in the metadata indices dictionary.
    RESIDUE_HOVER_CALLBACK : str
        JavaScript callback executed when hovering a residue involved in an interaction.
    INTERACTION_HOVER_CALLBACK : str
        JavaScript callback executed when hovering an interaction line.
    DISABLE_HOVER_CALLBACK : str
        JavaScript callback executed when the hovering event is finished.

    .. versionchanged:: 2.1.0
        Added ``water_mol`` parameter to the constructor to display waters
        involved in WaterBridge interactions. Added ``save_png`` method to save the
        current state of the 3D viewer to a PNG. Added ``remove_hydrogens`` parameter
        to the ``display`` and ``compare`` methods to remove non-polar hydrogens that
        aren't involved in an interaction. Added ``only_interacting`` parameter to the
        ``display`` and ``compare`` methods to show all protein residues in the
        vicinity of the ligand, or only the ones participating in an interaction.
    """  # noqa: E501

    COLORS: ClassVar[dict[str, str]] = {**separated_interaction_colors}
    LIGAND_STYLE: ClassVar[dict] = {"stick": {"colorscheme": "cyanCarbon"}}
    RESIDUES_STYLE: ClassVar[dict] = {"stick": {}}
    PROTEIN_STYLE: ClassVar[dict] = {"cartoon": {"style": "edged"}}
    PEPTIDE_STYLE: ClassVar[dict] = {
        "cartoon": {"style": "edged", "colorscheme": "cyanCarbon"},
    }
    PEPTIDE_THRESHOLD: ClassVar[int] = 5
    LIGAND_DISPLAYED_ATOM: ClassVar[dict] = {
        "HBDonor": 1,
        "XBDonor": 1,
    }
    PROTEIN_DISPLAYED_ATOM: ClassVar[dict] = {
        "HBAcceptor": 1,
        "XBAcceptor": 1,
    }
    RING_SYSTEMS: ClassVar[set[str]] = {
        "PiStacking",
        "EdgeToFace",
        "FaceToFace",
    }
    LIGAND_RING_INTERACTIONS: ClassVar[set[str]] = {*RING_SYSTEMS, "PiCation"}
    PROTEIN_RING_INTERACTIONS: ClassVar[set[str]] = {*RING_SYSTEMS, "CationPi"}
    BRIDGED_INTERACTIONS: ClassVar[dict[str, str]] = {"WaterBridge": "water"}
    RESIDUE_HOVER_CALLBACK: ClassVar[str] = """
    function(atom,viewer) {
        if(!atom.label) {
            atom.label = viewer.addLabel('%s:'+atom.atom+atom.serial,
                {position: atom, backgroundColor: 'mintcream', fontColor:'black'});
        }
    }"""
    INTERACTION_HOVER_CALLBACK: ClassVar[str] = """
    function(shape,viewer) {
        if(!shape.label) {
            shape.label = viewer.addLabel(shape.interaction,
                {position: shape, backgroundColor: 'black', fontColor:'white'});
        }
    }"""
    DISABLE_HOVER_CALLBACK: ClassVar[str] = """
    function(obj,viewer) {
        if(obj.label) {
            viewer.removeLabel(obj.label);
            delete obj.label;
        }
    }"""

    def __init__(
        self,
        ifp: IFP,
        lig_mol: Molecule,
        prot_mol: Molecule,
        water_mol: Molecule | None = None,
    ) -> None:
        self.ifp = ifp
        self.lig_mol = lig_mol
        self.prot_mol = prot_mol
        self.water_mol = water_mol
        self._view: py3Dmol.view | None = None  # type: ignore[no-any-unimported]

    @classmethod
    def from_fingerprint(
        cls,
        fp: Fingerprint,
        lig_mol: Molecule,
        prot_mol: Molecule,
        water_mol: Molecule | None = None,
        *,
        frame: int,
    ) -> Complex3D:
        """Creates a py3Dmol plot of interactions.

        Parameters
        ----------
        fp : prolif.fingerprint.Fingerprint
            The fingerprint object already executed using one of the ``run`` or
            ``run_from_iterable`` methods.
        frame : int
            The frame number chosen to select which interactions are going to be
            displayed.
        lig_mol : Molecule
            The ligand molecule to display.
        prot_mol : Molecule
            The protein molecule to display.
        water_mol : Optional[Molecule]
            Additional molecule (e.g. waters) to display.
        """
        if not hasattr(fp, "ifp"):
            raise RunRequiredError(
                "Please run the fingerprint analysis before attempting to display"
                " results.",
            )
        ifp = fp.ifp[frame]
        return cls(ifp, lig_mol, prot_mol, water_mol)

    @staticmethod
    def get_ring_centroid(mol: Molecule, indices: tuple[int, ...]) -> Point3D:
        """Get the centroid of a ring system."""
        centroid = mol.xyz[list(indices)].mean(axis=0)
        return Point3D(*centroid)

    def display(
        self,
        size: tuple[int, int] = (650, 600),
        display_all: bool = False,
        only_interacting: bool = True,
        remove_hydrogens: bool | Literal["ligand", "protein", "water"] = True,
        sanitize: bool | Literal["ligand", "protein"] = "protein",
    ) -> Complex3D:
        """Display as a py3Dmol widget view.

        Parameters
        ----------
        size: tuple[int, int] = (650, 600)
            The size of the py3Dmol widget view.
        display_all : bool = False
            Display all occurences for a given pair of residues and interaction, or only
            the shortest one. Not relevant if ``count=False`` in the ``Fingerprint``
            object.
        only_interacting : bool = True
            Whether to show all protein residues in the vicinity of the ligand, or
            only the ones participating in an interaction.
        remove_hydrogens: bool | Literal["ligand", "protein", "water"] = True
            Whether to remove non-polar hydrogens (unless they are involved in an
            interaction).
        sanitize: bool | Literal["ligand", "protein"] = "protein"
            Whether to sanitize the RDKit molecules used for visualization.
            This is to avoid unkekulization issues that may arise when using the
            coordinates of the molecule.


        .. versionchanged:: 2.1.0
            Added ``only_interacting=True`` and ``remove_hydrogens=True`` parameters.
            Non-polar hydrogen atoms that aren't involved in interactions are now
            hidden. Added support for waters involved in WaterBridge interactions.

        .. versionchanged:: 2.2.0
            Added ``sanitize`` parameter to allow sanitization of the RDKit
            molecules used for visualization, which can help avoid unkekulization
            issues when using the coordinates of the molecule directly.
        """
        v = py3Dmol.view(width=size[0], height=size[1], viewergrid=(1, 1), linked=False)
        v.removeAllModels()
        self._populate_view(
            v,
            position=(0, 0),
            display_all=display_all,
            only_interacting=only_interacting,
            remove_hydrogens=remove_hydrogens,
            sanitize=sanitize,
        )
        self._view = v
        return self

    def compare(
        self,
        other: Complex3D,
        *,
        size: tuple[int, int] = (900, 600),
        display_all: bool = False,
        linked: bool = True,
        color_unique: str | None = "magentaCarbon",
        only_interacting: bool = True,
        remove_hydrogens: bool | Literal["ligand", "protein", "water"] = True,
    ) -> Complex3D:
        """Displays the initial complex side-by-side with a second one for easier
        comparison.

        Parameters
        ----------
        other: Complex3D
            Other ``Complex3D`` object to compare to.
        size: tuple[int, int] = (900, 600)
            The size of the py3Dmol widget view.
        display_all : bool = False
            Display all occurences for a given pair of residues and interaction, or only
            the shortest one. Not relevant if ``count=False`` in the ``Fingerprint``
            object.
        linked: bool = True
            Link mouse interactions (pan, zoom, translate) on both views.
        color_unique: str | None = "magentaCarbon",
            Which color to use for residues that have interactions that are found in one
            complex but not the other. Use ``None`` to disable the color override.
        only_interacting : bool = True
            Whether to show all protein residues in the vicinity of the ligand, or
            only the ones participating in an interaction.
        remove_hydrogens: bool | Literal["ligand", "protein", "water"] = True
            Whether to remove non-polar hydrogens (unless they are involved in an
            interaction).

        .. versionadded:: 2.0.1

        .. versionchanged:: 2.1.0
            Added ``only_interacting=True`` and ``remove_hydrogens=True`` parameters.
            Non-polar hydrogen atoms that aren't involved in interactions are now
            hidden. Added support for waters involved in WaterBridge interactions.

        """
        v = py3Dmol.view(
            width=size[0],
            height=size[1],
            linked=linked,
            viewergrid=(1, 2),
        )
        v.removeAllModels()

        # get set of interactions for both poses
        interactions1 = {
            (resid[1], i)
            for resid, interactions in self.ifp.items()
            for i in interactions
        }
        interactions2 = {
            (resid[1], i)
            for resid, interactions in other.ifp.items()
            for i in interactions
        }

        # get residues with interactions specific to pose 1
        highlights = (
            {r[0]: color_unique for r in interactions1 - interactions2}
            if color_unique
            else {}
        )
        self._populate_view(
            v,
            position=(0, 0),
            display_all=display_all,
            colormap=highlights,
            only_interacting=only_interacting,
            remove_hydrogens=remove_hydrogens,
        )

        # get residues with interactions specific to pose 2
        highlights = (
            {r[0]: color_unique for r in interactions2 - interactions1}
            if color_unique
            else {}
        )
        other._populate_view(
            v,
            position=(0, 1),
            display_all=display_all,
            colormap=highlights,
            only_interacting=only_interacting,
            remove_hydrogens=remove_hydrogens,
        )
        self._view = v
        return self

    def _populate_view(  # type: ignore[no-any-unimported]  # noqa: PLR0912
        self,
        view: py3Dmol.view | Complex3D,
        position: tuple[int, int] = (0, 0),
        display_all: bool = False,
        colormap: dict[ResidueId, str] | None = None,
        only_interacting: bool = True,
        remove_hydrogens: bool | Literal["ligand", "protein", "water"] = True,
        sanitize: bool | Literal["ligand", "protein"] = "protein",
    ) -> None:
        if isinstance(view, Complex3D):
            # backwards compatibility for when display/compare used to return the view
            if view._view is None:
                raise ValueError(
                    "View not initialized, did you call `display`/`compare` first?",
                )
            v = view._view
        else:
            v = view
        self._colormap = {} if colormap is None else colormap
        self._models: dict[ResidueId, int] = {}
        self._mid = -1
        self._interacting_atoms: dict[str, set[int]] = {
            "ligand": set(),
            "protein": set(),
            "water": set(),
        }

        # show all interacting residues
        for (lresid, presid), interactions in self.ifp.items():
            lres = self.lig_mol[lresid]
            pres = self.prot_mol[presid]
            # set model ids for reusing later
            for resid, res, style, kekulize in [
                (lresid, lres, self.LIGAND_STYLE, sanitize in {"ligand", True}),
                (presid, pres, self.RESIDUES_STYLE, sanitize in {"protein", True}),
            ]:
                if resid not in self._models:
                    self._add_residue_to_view(
                        v, position, res, style, kekulize=kekulize
                    )
            for interaction, metadata_tuple in interactions.items():
                # whether to display all interactions or only the one with the shortest
                # distance
                for metadata in metadata_iterator(metadata_tuple, display_all):
                    # record indices of atoms interacting
                    self._interacting_atoms["ligand"].update(
                        metadata["parent_indices"]["ligand"]
                    )
                    self._interacting_atoms["protein"].update(
                        metadata["parent_indices"]["protein"]
                    )
                    if interaction in self.BRIDGED_INTERACTIONS and self.water_mol:
                        self._interacting_atoms["water"].update(
                            metadata["parent_indices"][
                                self.BRIDGED_INTERACTIONS[interaction]
                            ]
                        )
                        for wresid in metadata["water_residues"]:
                            wres = self.water_mol[wresid]
                            if wresid not in self._models:
                                self._add_residue_to_view(
                                    v, position, wres, self.RESIDUES_STYLE
                                )
                        # show cylinders for WaterBridge
                        distances = [d for d in metadata if d.startswith("distance_")]
                        for distlabel in distances:
                            _, src, dest = distlabel.split("_")
                            if src == "ligand":
                                p1 = lres.GetConformer().GetAtomPosition(
                                    metadata["indices"]["ligand"][
                                        self.LIGAND_DISPLAYED_ATOM.get(interaction, 0)
                                    ],
                                )
                            else:
                                p1 = (
                                    self.water_mol[src]
                                    .GetConformer()
                                    .GetAtomPosition(metadata["indices"][src][0])
                                )
                            if dest == "protein":
                                p2 = pres.GetConformer().GetAtomPosition(
                                    metadata["indices"]["protein"][
                                        self.PROTEIN_DISPLAYED_ATOM.get(interaction, 0)
                                    ],
                                )
                            else:
                                p2 = (
                                    self.water_mol[dest]
                                    .GetConformer()
                                    .GetAtomPosition(metadata["indices"][dest][0])
                                )

                            self._add_interaction(
                                v,
                                position,
                                lresid,
                                interaction,
                                metadata[distlabel],
                                p1,
                                p2,
                            )

                    else:
                        # get coordinates for both points of the interaction
                        if interaction in self.LIGAND_RING_INTERACTIONS:
                            p1 = self.get_ring_centroid(
                                lres, metadata["indices"]["ligand"]
                            )
                        else:
                            p1 = lres.GetConformer().GetAtomPosition(
                                metadata["indices"]["ligand"][
                                    self.LIGAND_DISPLAYED_ATOM.get(interaction, 0)
                                ],
                            )
                        if interaction in self.PROTEIN_RING_INTERACTIONS:
                            p2 = self.get_ring_centroid(
                                pres,
                                metadata["indices"]["protein"],
                            )
                        else:
                            p2 = pres.GetConformer().GetAtomPosition(
                                metadata["indices"]["protein"][
                                    self.PROTEIN_DISPLAYED_ATOM.get(interaction, 0)
                                ],
                            )
                        # add interaction line
                        dist = metadata.get("distance", float("nan"))
                        self._add_interaction(
                            v, position, lresid, interaction, dist, p1, p2
                        )

        # show "protein" residues that are close to the "ligand"
        if not only_interacting:
            pocket_residues = get_residues_near_ligand(self.lig_mol, self.prot_mol)
            pocket_residues = set(pocket_residues).difference(self._models)
            for resid in pocket_residues:
                res = self.prot_mol[resid]
                self._add_residue_to_view(
                    v,
                    position,
                    res,
                    self.RESIDUES_STYLE,
                    kekulize=sanitize in {"protein", True},
                )

        # hide non-polar hydrogens (except if they are involved in an interaction)
        if remove_hydrogens:
            to_remove = []
            if remove_hydrogens in {"ligand", True}:
                to_remove.append(("ligand", self.lig_mol))
            if remove_hydrogens in {"protein", True}:
                to_remove.append(("protein", self.prot_mol))
            if remove_hydrogens in {"water", True} and self.water_mol:
                to_remove.append(("water", self.water_mol))

            for resid in self._models:
                for moltype, mol in to_remove:
                    try:
                        modelID = self._models[resid]
                        res = mol[resid]
                    except KeyError:
                        continue
                    model = v.getModel(modelID, viewer=position)
                    int_atoms = self._interacting_atoms[moltype]
                    hide = [
                        a.GetIdx()
                        for a in res.GetAtoms()
                        if a.GetAtomicNum() == 1
                        and a.GetUnsignedProp("mapindex") not in int_atoms
                        and all(n.GetAtomicNum() in {1, 6} for n in a.GetNeighbors())
                    ]
                    model.setStyle({"index": hide}, {"stick": {"hidden": True}})

        # show protein
        mol = Chem.RemoveAllHs(
            self.prot_mol,
            sanitize=(sanitize in {"protein", True}),
        )
        pdb = Chem.MolToPDBBlock(mol, flavor=0x20 | 0x10)
        v.addModel(pdb, "pdb", viewer=position)
        model = v.getModel(viewer=position)
        model.setStyle({}, self.PROTEIN_STYLE)

        # do the same for ligand if multiple residues
        if self.lig_mol.n_residues >= self.PEPTIDE_THRESHOLD:
            mol = Chem.RemoveAllHs(
                self.lig_mol,
                sanitize=(sanitize in {"ligand", True}),
            )
            pdb = Chem.MolToPDBBlock(mol, flavor=0x20 | 0x10)
            v.addModel(pdb, "pdb", viewer=position)
            model = v.getModel(viewer=position)
            model.setStyle({}, self.PEPTIDE_STYLE)

        v.zoomTo({"model": list(self._models.values())}, viewer=position)

    def _add_interaction(  # type: ignore[no-any-unimported]
        self,
        v: py3Dmol.view,
        position: tuple[int, int],
        resid: ResidueId,
        interaction: str,
        distance: float,
        p1: Point3D,
        p2: Point3D,
    ) -> None:
        """Add an interaction line (and hover label) to the view."""
        v.addCylinder(
            {
                "start": {"x": p1.x, "y": p1.y, "z": p1.z},
                "end": {"x": p2.x, "y": p2.y, "z": p2.z},
                "color": self.COLORS.get(interaction, "grey"),
                "radius": 0.15,
                "dashed": True,
                "fromCap": 1,
                "toCap": 1,
            },
            viewer=position,
        )
        # add label when hovering the middle of the dashed line by adding a dummy atom
        arr = np.array([list(p1), list(p2)], dtype=float)
        c = Point3D(*get_centroid(arr))
        modelID = self._models[resid]
        model = v.getModel(modelID, viewer=position)
        interaction_label = f"{interaction}: {distance:.2f}Å"
        model.addAtoms(
            [
                {
                    "elem": "Z",
                    "x": c.x,
                    "y": c.y,
                    "z": c.z,
                    "interaction": interaction_label,
                },
            ],
        )
        model.setStyle(
            {"interaction": interaction_label},
            {"clicksphere": {"radius": 0.5}},
        )
        model.setHoverable(
            {"interaction": interaction_label},
            True,
            self.INTERACTION_HOVER_CALLBACK,
            self.DISABLE_HOVER_CALLBACK,
        )

    def _add_residue_to_view(  # type: ignore[no-any-unimported]
        self,
        v: py3Dmol.view,
        position: tuple[int, int],
        res: Residue,
        style: dict,
        kekulize: bool = True,
    ) -> None:
        """Add a residue to the view."""
        self._mid += 1
        resid = res.resid
        v.addModel(Chem.MolToMolBlock(res, kekulize=kekulize), "sdf", viewer=position)
        model = v.getModel(viewer=position)
        if resid in self._colormap:
            resid_style = deepcopy(style)
            for key in resid_style:
                resid_style[key]["colorscheme"] = self._colormap[resid]
        else:
            resid_style = style
        model.setStyle({}, resid_style)
        # add residue label
        model.setHoverable(
            {},
            True,
            self.RESIDUE_HOVER_CALLBACK % resid,
            self.DISABLE_HOVER_CALLBACK,
        )
        self._models[resid] = self._mid

    @requires("IPython.display")
    def save_png(self) -> None:
        """Saves the current state of the 3D viewer to a PNG. Not available outside of a
        notebook.

        .. versionadded:: 2.1.0
        """
        if self._view is None:
            raise ValueError(
                "View not initialized, did you call `display`/`compare` first?",
            )
        uid = self._view.uniqueid
        display(
            Javascript(f"""
            var png = viewer_{uid}.pngURI()
            var a = document.createElement('a')
            a.href = png
            a.download = "prolif-3d.png"
            a.click()
            a.remove()
            """),
        )

    def __getattr__(self, name: str) -> Any:
        """Get an attribute from the py3Dmol view."""
        if self._view is None:
            raise ValueError(
                "View not initialized, did you call `display`/`compare` first?",
            )
        return getattr(self._view, name)

    def _repr_html_(self) -> str | None:
        if self._view:
            return self._view._repr_html_()  # type: ignore[no-any-return]
        return None
