"""Module used to compute interaction from given pharmacophore."""

# =======================
# Python internal package
# =======================
# D
import dataclasses

# T
import typing

# ================
# External package
# ================
# N
import numpy

# R
import rdkit.Chem as Chem
import rdkit.Geometry as Geometry

# ==============
# Module package
# ==============
# P
from .parser.molecule import (
    Molecule,
)
from .pharmacophore import (
    Pharmacophore,
)

# U
from .utils.data import (
    InteractionConfiguration,
)
from .utils.generic import (
    DataClass,
    PharmacophoreFamily,
    InteractionType,
    KeyIdentifier,
)


class InteractionIsGood:
    """Utility class providing geometric validation methods for interactions."""

    @staticmethod
    def distance(
        data: dict, point: list[Pharmacophore], born: list[float]
    ) -> bool:
        """Evaluate the distance constraint between two pharmacophoric points.

        Parameters
        ----------
        data : `dict`
            Dictionary used to store computed metrics.

        point : `list[Pharmacophore]`
            Two pharmacophoric points.

        born : `list[float]`
            Minimum (born[0]) and maximum (born[1]) allowed distance.

        Returns
        -------
        `bool`
            `True` if the distance is strictly within the given bounds,
            `False` otherwise.
        """
        receptor_coord: numpy.ndarray = numpy.array(
            [
                point[0].coordinate_x,
                point[0].coordinate_y,
                point[0].coordinate_z,
            ]
        )

        ligand_coord: numpy.ndarray = numpy.array(
            [
                point[1].coordinate_x,
                point[1].coordinate_y,
                point[1].coordinate_z,
            ]
        )

        distance: float = numpy.linalg.norm(receptor_coord - ligand_coord)
        data["distance"] = distance

        return born[0] < distance < born[1]

    @staticmethod
    def angle(
        data: dict,
        point: list[Pharmacophore | numpy.ndarray],
        born: list[float],
        reverted: float = 0.0,
    ) -> bool:
        """Evaluate the angular constraint formed by three points.

        The angle is computed at `point_b` between vectors (`point_a` →
        `point_b`) and (`point_c` → `point_b`). The resulting angle in degrees
        is stored in the `data` dictionary under the key `"angle"`. Optionally,
        a reverted angle (180° − angle) can be considered. The smallest of the
        two will be used in this case.

        Parameters
        ----------
        data : `dict`
            Dictionary used to store computed metrics.

        point : `list[Pharmacophore]`
            Three pharmacophoric points.

        born : `list[float]`
            Minimum (born[0]) and maximum (born[1]) allowed distance.

        reverted : `float`, optional
            If non-zero, enables consideration of the supplementary angle
            (reverted − angle). Default is `0`.

        Returns
        -------
        `bool`
            `True` if the angle is strictly within the given bounds,
            `False` otherwise.
        """
        a_coord: numpy.ndarray = numpy.array(
            [
                point[0].coordinate_x,
                point[0].coordinate_y,
                point[0].coordinate_z,
            ]
        )

        b_coord: numpy.ndarray

        if isinstance(point[1], numpy.ndarray):
            b_coord = point[1]
        else:
            b_coord = numpy.array(
                [
                    point[1].coordinate_x,
                    point[1].coordinate_y,
                    point[1].coordinate_z,
                ]
            )

        c_coord: numpy.ndarray = numpy.array(
            [
                point[2].coordinate_x,
                point[2].coordinate_y,
                point[2].coordinate_z,
            ]
        )

        vector_1: numpy.ndarray = a_coord - b_coord
        vector_2: numpy.ndarray = c_coord - b_coord

        dot_product: numpy.ndarray = numpy.dot(vector_1, vector_2)
        norm_product: float = numpy.linalg.norm(vector_1) * numpy.linalg.norm(
            vector_2
        )

        angle: float = numpy.degrees(numpy.arccos(dot_product / norm_product))

        if reverted != 0:
            # Taking in consideration angle and reverted angle.
            reverted_angle: float = numpy.abs(reverted - angle)
            angle = numpy.min([angle, reverted_angle])

        data["angle"] = angle

        return born[0] < angle < born[1]

    @staticmethod
    def offset(
        data: dict,
        data_class_normal: Pharmacophore,
        data_class_a: Pharmacophore,
        data_class_b: Pharmacophore,
        born: float,
    ) -> bool:
        """Evaluate the lateral offset relative to a normal vector.

        This method computes the perpendicular offset of `data_class_b` from
        the normal vector.

        Parameters
        ----------
        data : `dict`
            Dictionary used to store computed metrics.

        data_class_normal : `Pharmacophore`
            Pharmacophore defining the normal vector direction.

        data_class_a : `Pharmacophore`
            Reference centre point.

        data_class_b : `Pharmacophore`
            Point whose offset is evaluated.

        born : `float`
            Maximum allowed offset.

        Returns
        -------
        `bool`
            `True` if the offset is below the given threshold,
            `False` otherwise.
        """
        normal: numpy.ndarray = numpy.array(
            [
                data_class_normal.coordinate_x,
                data_class_normal.coordinate_y,
                data_class_normal.coordinate_z,
            ],
            dtype=float,
        )

        normal /= numpy.linalg.norm(normal)

        centre_a: numpy.ndarray = numpy.array(
            [
                data_class_a.coordinate_x,
                data_class_a.coordinate_y,
                data_class_a.coordinate_z,
            ]
        )

        centre_b: numpy.ndarray = numpy.array(
            [
                data_class_b.coordinate_x,
                data_class_b.coordinate_y,
                data_class_b.coordinate_z,
            ]
        )

        hypothenuse_vector: numpy.ndarray = centre_b - centre_a
        side_length: float = numpy.linalg.norm(
            numpy.dot(hypothenuse_vector, normal)
        )

        offset: float = numpy.sqrt(
            numpy.linalg.norm(hypothenuse_vector) ** 2 - side_length**2
        )

        data["offset"] = offset

        return offset < born


class CheckNeighbour:
    """Callable utility to filter intramolecular interactions by determining
    whether two atoms belong to the same covalently connected environment
    within a given neighbour depth.
    """

    __PRECISION: int = 10

    def __init__(
        self,
        molecule_a: Molecule,
        molecule_b: Molecule,
        miscellaneous: DataClass,
    ) -> None:
        """Initialize the neighbour checker.

        Parameters
        ----------
        molecule_a : `Molecule`
            Reference molecule in which covalent neighbourhoods are explored.

        molecule_b : `Molecule`
            Target molecule containing the atom to compare against.

        miscellaneous : `DataClass`
            Configuration container holding neighbour depth parameters.
        """
        self.__max_neighbour: int = miscellaneous.number_neighbour

        self.__molecule_a: Chem.rdchem.Mol = molecule_a.rdkit
        self.__molecule_b: Chem.rdchem.Mol = molecule_b.rdkit
        self.__position_b: list[float] | None = None
        self.__parsed_position: list[list[float]] = []

    def __get_position(
        self, molecule: Chem.rdchem.Mol, index: int
    ) -> list[float]:
        """Extract and normalize atomic coordinates.

        Parameters
        ----------
        molecule : `Chem.rdchem.Mol`
            RDKit molecule containing the atom.

        index : `int`
            Atom index within the molecule.

        Returns
        -------
        `list[float]`
            Rounded (x, y, z) coordinates of the atom.
        """
        point: Geometry.Point3D = molecule.GetConformer().GetAtomPosition(
            index
        )
        position: list[float] = [point.x, point.y, point.z]

        return list(
            map(
                lambda coordinate: round(
                    coordinate, CheckNeighbour.__PRECISION
                ),
                position,
            )
        )

    def __call__(self, index_a: list[int] | int, index_b: int) -> bool:
        """Check whether atoms are covalently connected within a neighbour
        depth.

        Parameters
        ----------
        index_a : `list[int] | int`
            Atom index or list of atom indices in `molecule_a`.

        index_b : `int`
            Atom index in `molecule_b`.

        Returns
        -------
        `bool`
            `True` if a covalent neighbourhood match is found, `False`
            otherwise.
        """
        if isinstance(index_a, int):
            index_a = [index_a]

        self.__position_b = self.__get_position(self.__molecule_b, index_b)

        for index_a_i in index_a:
            self.__parsed_position = []

            if self.__is_neighbour(index_a_i, 0):
                return True

        return False

    def __is_neighbour(
        self,
        index_a: int,
        neighbour_count: int,
    ) -> bool:
        """Recursively explore covalent neighbours of an atom to check if they
        are close neighbour, within a given range.

        Parameters
        ----------
        index_a : `int`
            Atom index in `molecule_a` currently being explored.

        neighbour_count : `int`
            Current recursion depth.

        Returns
        -------
        `bool`
            `True` if the reference atom is found within the allowed neighbour
            depth, `False` otherwise.
        """
        if neighbour_count > self.__max_neighbour:
            return False

        position_a: list[float] = self.__get_position(
            self.__molecule_a, index_a
        )

        self.__parsed_position.append(position_a)

        if position_a == self.__position_b:
            return True

        atom_a: Chem.Atom = self.__molecule_a.GetAtomWithIdx(index_a)

        for neighbour in atom_a.GetNeighbors():
            if self.__is_neighbour(neighbour.GetIdx(), neighbour_count + 1):
                return True

        return False


@dataclasses.dataclass
class Interaction:
    """Represents a pharmacophore point with optional attributes."""

    # Interaction attributes.
    distance: float
    angle: float | None
    offset: float | None
    stacking_shape: float | None

    # Molecule dataclass.
    molecule_a: DataClass
    molecule_b: DataClass


class InteractionDetection:
    """Compute intermolecular pharmacophoric interactions."""

    def __init__(
        self,
        collection_a: dict[PharmacophoreFamily, list[Pharmacophore]],
        collection_b: dict[PharmacophoreFamily, list[Pharmacophore]],
        configuration: InteractionConfiguration,
        miscellaneous: DataClass,
        check_neighbour: CheckNeighbour | None,
    ) -> None:
        """Construct and execute interaction detection between two molecules.

        Parameters
        ----------
        collection_a : `dict[PharmacophoreFamily, list[Pharmacophore]]`
            Pharmacophore collection for the first molecule.

        collection_b : `dict[PharmacophoreFamily, list[Pharmacophore]]`
            Pharmacophore collection for the second molecule.

        configuration : `InteractionConfiguration`
            Configuration object defining geometric for interaction detection.

        miscellaneous : `DataClass`
            Global configuration enabling or disabling interaction types and
            controlling filtering behavior.

        check_neighbour : `CheckNeighbour | None`
            Optional callable used to exclude intramolecular interactions
            between covalently connected atoms.
        """
        self.collection: dict[InteractionType, list[DataClass]] = {}
        # To delete duplicatas between same properties.
        self.__coordinate_set: dict[str, set] = {}
        self.__miscellaneous: DataClass = miscellaneous

        for key_a, value_a in collection_a.items():
            for key_b, value_b in collection_b.items():
                result: list = []
                key: InteractionType = None

                key_pair: list[PharmacophoreFamily] = [key_a, key_b]
                value_pair: list[list[Pharmacophore]] = [value_a, value_b]

                # If we have ["donor", "acceptor"], we HAVE TO reverse to
                # ["acceptor", "donor"] for latter process.
                if key_b < key_a:
                    key_pair.reverse()
                    value_pair.reverse()

                match key_pair:
                    case [
                        PharmacophoreFamily.HYDROPHOBE,
                        PharmacophoreFamily.HYDROPHOBE,
                    ]:
                        key = InteractionType.HYDROPHOBIC

                        result = self.__iterate(
                            *value_pair,
                            Hydrophobic(configuration, check_neighbour),
                            key,
                        )
                    case [
                        PharmacophoreFamily.ACCEPTOR,
                        PharmacophoreFamily.DONOR,
                    ]:
                        key = InteractionType.HYDROGEN_BOND

                        result = self.__iterate(
                            *value_pair,
                            HydrogenBond(configuration),
                            key,
                        )
                    case [
                        PharmacophoreFamily.AROMATIC,
                        PharmacophoreFamily.AROMATIC,
                    ]:
                        key = InteractionType.PI_STACKING

                        result = self.__iterate(
                            *value_pair,
                            PiStacking(configuration),
                            key,
                        )
                    case [
                        PharmacophoreFamily.AROMATIC,
                        PharmacophoreFamily.POSITIVE,
                    ]:
                        key = InteractionType.PI_CATION

                        result = self.__iterate(
                            *value_pair,
                            PiCharge(
                                configuration,
                                configuration.pication_dist_max,
                                check_neighbour,
                            ),
                            key,
                        )
                    case [
                        PharmacophoreFamily.AROMATIC,
                        PharmacophoreFamily.NEGATIVE,
                    ]:
                        key = InteractionType.PI_ANION

                        result = self.__iterate(
                            *value_pair,
                            PiCharge(
                                configuration,
                                configuration.pianion_dist_max,
                                check_neighbour,
                            ),
                            key,
                        )
                    case [
                        PharmacophoreFamily.NEGATIVE,
                        PharmacophoreFamily.POSITIVE,
                    ]:
                        key = InteractionType.SALT_BRIDGE

                        result = self.__iterate(
                            *value_pair,
                            SaltBridge(configuration),
                            key,
                        )
                    case [
                        PharmacophoreFamily.HALOGEN_ACCEPTOR,
                        PharmacophoreFamily.HALOGEN_DONOR,
                    ]:
                        key = InteractionType.HALOGEN_BOND

                        result = self.__iterate(
                            *value_pair,
                            HalogenBond(configuration),
                            key,
                        )

                if result != []:
                    if key in self.collection:
                        self.collection[key] += result
                    else:
                        self.collection[key] = result

    def __iterate(
        self,
        molecule_a: list[Pharmacophore],
        molecule_b: list[Pharmacophore],
        component: typing.Callable,
        interaction: InteractionType,
    ) -> list:
        """Evaluate pairwise interactions between two pharmacophore sets.

        Parameters
        ----------
        molecule_a : `list[Pharmacophore]`
            Pharmacophoric features from the first molecule.

        molecule_b : `list[Pharmacophore]`
            Pharmacophoric features from the second molecule.

        component : `typing.Callable`
            Interaction-specific computation component.

        interaction : `InteractionType`
            Identifier of the interaction being evaluated.

        Returns
        -------
        `list`
            A list of validated `Interaction` objects.
        """
        result: list = []

        if not getattr(self.__miscellaneous, f"enable_{interaction}"):
            return result

        if interaction not in self.__coordinate_set:
            self.__coordinate_set[interaction] = set()

        coordinate_set: set = self.__coordinate_set[interaction]

        for data_class_a in molecule_a:
            for data_class_b in molecule_b:
                data: dict[str, float | None] = {
                    "distance": None,
                    "angle": None,
                    "offset": None,
                    "stacking_shape": None,
                }

                if not component.operation(data_class_a, data_class_b, data):
                    continue

                if "data_class_a" in data:
                    data_class_a = data["data_class_a"]
                    del data["data_class_a"]

                if "data_class_b" in data:
                    data_class_b = data["data_class_b"]
                    del data["data_class_b"]

                coordinate_a: tuple = (
                    data_class_a.coordinate_x,
                    data_class_a.coordinate_y,
                    data_class_a.coordinate_z,
                )

                coordinate_b: tuple = (
                    data_class_b.coordinate_x,
                    data_class_b.coordinate_y,
                    data_class_b.coordinate_z,
                )

                coordinate_tuple: tuple = tuple(
                    sorted((coordinate_a, coordinate_b))
                )

                # We cannot filter before. As `data_class_a` or `data_class_b`
                # can be redefine.
                if coordinate_tuple in coordinate_set:
                    continue

                coordinate_set |= set([coordinate_tuple])

                data_dict_a: dict = dataclasses.asdict(data_class_a)
                data_dict_b: dict = dataclasses.asdict(data_class_b)

                del (
                    data_dict_a["key"],
                    data_dict_a["mda_id"],
                    data_dict_b["key"],
                    data_dict_b["mda_id"],
                )

                result += [
                    Interaction(
                        *data.values(),
                        molecule_a=data_class_a,
                        molecule_b=data_class_b,
                    )
                ]

        return result

# pylint: disable=too-few-public-methods, too-many-return-statements
# Component class, so without many methods. Also a lot of returns to optimize
# computation time.

class SaltBridge:
    """Detect salt bridge interactions between charged pharmacophores."""

    def __init__(self, configuration: InteractionConfiguration) -> None:
        """Initialize the salt bridge detector.

        Parameters
        ----------
        configuration : `InteractionConfiguration`
            Configuration containing distance thresholds for salt bridges.
        """
        self.__configuration: InteractionConfiguration = configuration

    def operation(
        self,
        data_class_a: Pharmacophore,
        data_class_b: Pharmacophore,
        data: dict,
    ) -> bool:
        """Evaluate whether two pharmacophores form a salt bridge. Distance is
        checked.

        Parameters
        ----------
        data_class_a : `Pharmacophore`
            First charged pharmacophore.

        data_class_b : `Pharmacophore`
            Second charged pharmacophore.

        data : `dict`
            Dictionary populated with computed geometric values.

        Returns
        -------
        `bool`
            `True` if a valid salt bridge is detected, otherwise `False`.
        """
        if KeyIdentifier.ATOM in [data_class_a.key, data_class_b.key]:
            return False

        return InteractionIsGood.distance(
            data,
            [data_class_a, data_class_b],
            [
                self.__configuration.min_dist,
                self.__configuration.saltbridge_dist_max,
            ],
        )


class Hydrophobic:
    """Detect hydrophobic interactions between nonpolar atoms."""

    def __init__(
        self,
        configuration: InteractionConfiguration,
        check_neighbour: CheckNeighbour | None,
    ) -> None:
        """Initialize the hydrophobic interaction detector.

        Parameters
        ----------
        configuration : `InteractionConfiguration`
            Configuration containing distance thresholds.

        check_neighbour : `CheckNeighbour | None`
            Optional topology-based filter to exclude intramolecular contacts.
        """
        self.__configuration: InteractionConfiguration = configuration
        self.__check_neighbour: CheckNeighbour | None = check_neighbour

    def operation(
        self,
        data_class_a: Pharmacophore,
        data_class_b: Pharmacophore,
        data: dict,
    ) -> bool:
        """Evaluate whether two atoms form a hydrophobic interaction. Distance
        is checked.

        Parameters
        ----------
        data_class_a : `Pharmacophore`
            First atom-based pharmacophore.

        data_class_b : `Pharmacophore`
            Second atom-based pharmacophore.

        data : `dict`
            Dictionary populated with computed distance.

        Returns
        -------
        `bool`
            `True` if a valid hydrophobic interaction is detected.
        """
        if KeyIdentifier.PHARMACOPHORE in [data_class_a.key, data_class_b.key]:
            return False

        if not InteractionIsGood.distance(
            data,
            [data_class_a, data_class_b],
            [
                self.__configuration.min_dist,
                self.__configuration.hydroph_dist_max,
            ],
        ):
            return False

        if self.__check_neighbour is None:
            return True

        # At this point, we know there is an interaction. We have to return
        # `True`. But, we need to check if the detected interaction are not
        # in the same molecule. So we check the topologycal distance with this.
        return not self.__check_neighbour(
            data_class_a.rdkit_atom_id, data_class_b.rdkit_atom_id[0]
        )


class HydrogenBond:
    """Detect hydrogen bond interactions."""

    donor: Pharmacophore | None

    def __init__(self, configuration: InteractionConfiguration) -> None:
        """Initialize the hydrogen bond detector.

        Parameters
        ----------
        configuration : `InteractionConfiguration`
            Configuration defining distance and angular constraints.
        """
        self.__configuration: InteractionConfiguration = configuration

    def operation(
        self,
        acceptor: Pharmacophore,
        hydrogen_donor: Pharmacophore,
        data: dict,
    ) -> bool:
        """Evaluate whether an acceptor–donor pair forms a hydrogen bond.
        Distance and angle are checked.

        This method is stateful: the donor pharmacophore is cached when
        encountered and reused for angle evaluation.

        Parameters
        ----------
        acceptor : `Pharmacophore`
            Hydrogen bond acceptor.

        hydrogen_donor : `Pharmacophore`
            Hydrogen donor atom or feature.

        data : `dict`
            Dictionary populated with distance and angle.

        Returns
        -------
        `bool`
            `True` if a valid hydrogen bond is detected.
        """
        if hydrogen_donor.key == KeyIdentifier.PHARMACOPHORE:
            HydrogenBond.donor = hydrogen_donor
            return False

        if HydrogenBond.donor.id != hydrogen_donor.id:
            return False

        if not InteractionIsGood.distance(
            data,
            [acceptor, hydrogen_donor],
            [
                self.__configuration.min_dist,
                self.__configuration.hbond_dist_max,
            ],
        ):
            return False

        return InteractionIsGood.angle(
            data,
            [acceptor, hydrogen_donor, HydrogenBond.donor],
            [
                self.__configuration.hbond_don_angle_min,
                self.__configuration.hbond_don_angle_max,
            ],
        )


class PiStacking:
    r"""Detect aromatic $\pi-\pi$ stacking interactions."""

    centre_a: Pharmacophore | None
    centre_b: Pharmacophore | None

    def __init__(self, configuration: InteractionConfiguration) -> None:
        r"""Initialize the $\pi-\pi$ stacking detector.

        Parameters
        ----------
        configuration : `InteractionConfiguration`
            Configuration defining distance, angular deviation, and offset
            thresholds.
        """
        self.__configuration: InteractionConfiguration = configuration

    def operation(
        self,
        data_class_a: Pharmacophore,
        data_class_b: Pharmacophore,
        data: dict,
    ) -> bool:
        r"""Evaluate whether two aromatic systems form a $\pi-\pi$ stacking
        interaction. Distance, angle and offset are checked. Both parallel and
        T-shaped stacking geometries are considered. This method is stateful:
        the centre pharmacophore are cached when encountered and reused for
        angle and offset computation.

        Parameters
        ----------
        data_class_a : `Pharmacophore`
            Aromatic centre or normal vector.

        data_class_b : `Pharmacophore`
            Aromatic centre or normal vector.

        data : `dict`
            Dictionary populated with distance, angle, offset, and stacking
            type.

        Returns
        -------
        `bool`
            `True` if a valid interaction is detected.
        """
        if KeyIdentifier.ATOM in (data_class_a.key, data_class_b.key):
            return False

        if data_class_a.key == KeyIdentifier.PHARMACOPHORE:
            PiStacking.centre_a = data_class_a

        if data_class_b.key == KeyIdentifier.PHARMACOPHORE:
            PiStacking.centre_b = data_class_b

        if (
            PiStacking.centre_a.key != KeyIdentifier.PHARMACOPHORE
            or PiStacking.centre_b.key != KeyIdentifier.PHARMACOPHORE
            or data_class_a.key != KeyIdentifier.NORM
            or data_class_b.key != KeyIdentifier.NORM
        ):
            return False

        if (
            PiStacking.centre_a.id != data_class_a.id
            or PiStacking.centre_b.id != data_class_b.id
            or data_class_a.id == data_class_b.id
        ):
            return False

        if not InteractionIsGood.distance(
            data,
            [PiStacking.centre_a, PiStacking.centre_b],
            [
                self.__configuration.min_dist,
                self.__configuration.pistack_dist_max,
            ],
        ):
            return False

        is_angle_good: bool = InteractionIsGood.angle(
            data,
            [data_class_a, numpy.array([0, 0, 0]), data_class_b],
            [0, self.__configuration.pistack_ang_dev],
            180,
        )

        data["stacking_shape"] = "parallel"

        if not is_angle_good:
            is_angle_good = InteractionIsGood.angle(
                data,
                [data_class_a, numpy.array([0, 0, 0]), data_class_b],
                [
                    90 - self.__configuration.pistack_ang_dev,
                    90 + self.__configuration.pistack_ang_dev,
                ],
                180,
            )

            data["stacking_shape"] = "T"

        if not is_angle_good:
            return False

        is_offset_good: bool = InteractionIsGood.offset(
            data,
            data_class_a,
            PiStacking.centre_a,
            PiStacking.centre_b,
            self.__configuration.pistack_offset_max,
        )

        offset: float = data["offset"]

        is_offset_good = is_offset_good or InteractionIsGood.offset(
            data,
            data_class_b,
            PiStacking.centre_b,
            PiStacking.centre_a,
            self.__configuration.pistack_offset_max,
        )

        data["offset"] = min(offset, data["offset"])
        data["data_class_a"] = PiStacking.centre_a
        data["data_class_b"] = PiStacking.centre_b

        return is_offset_good


class PiCharge:
    r"""Detect $\pi-$cation or $\pi-$anion interactions."""

    centre: Pharmacophore | None

    def __init__(
        self,
        configuration: InteractionConfiguration,
        distance: float,
        check_neighbour: CheckNeighbour | None = None,
    ) -> None:
        r"""Initialize the $\pi-$charge interaction detector.

        Parameters
        ----------
        configuration : `InteractionConfiguration`
            Configuration defining angular and offset constraints.

        distance : `float`
            Maximum allowed distance between aromatic centre and charged group.

        check_neighbour : `CheckNeighbour | None`
            Optional topology-based filter for intramolecular exclusion.
        """
        self.__configuration: InteractionConfiguration = configuration
        self.__distance: float = distance
        self.__check_neighbour: CheckNeighbour | None = check_neighbour

    def operation(
        self,
        data_class_a: Pharmacophore,
        data_class_b: Pharmacophore,
        data: dict,
    ) -> bool:
        r"""Evaluate whether a $\pi-$charge interaction is present.

        Parameters
        ----------
        data_class_a : `Pharmacophore`
            Aromatic centre or normal vector.

        data_class_b : `Pharmacophore`
            Charged pharmacophore.

        data : `dict`
            Dictionary populated with geometric descriptors.

        Returns
        -------
        `bool`
            `True` if a valid π–charge interaction is detected.
        """
        if KeyIdentifier.ATOM in (data_class_a.key, data_class_b.key):
            return False

        if data_class_a.key == KeyIdentifier.PHARMACOPHORE:
            PiCharge.centre = data_class_a
            return False

        if (
            PiCharge.centre.key != KeyIdentifier.PHARMACOPHORE
            or data_class_a.key != KeyIdentifier.NORM
            or data_class_b.key != KeyIdentifier.PHARMACOPHORE
        ):
            return False

        if PiCharge.centre.id != data_class_a.id:
            return False

        if not InteractionIsGood.distance(
            data,
            [PiCharge.centre, data_class_b],
            [self.__configuration.min_dist, self.__distance],
        ):
            return False

        data["data_class_a"] = PiCharge.centre

        if not InteractionIsGood.offset(
            data,
            data_class_a,
            PiCharge.centre,
            data_class_b,
            self.__configuration.pistack_offset_max,
        ):
            return False

        if self.__check_neighbour is None:
            return True

        # At this point, we know there is an interaction. We have to return
        # `True`. But, we need to check if the detected interaction are not
        # in the same molecule. So we check the topologycal distance with this.
        for atom_id in data_class_b.rdkit_atom_id:
            if self.__check_neighbour(PiCharge.centre.rdkit_atom_id, atom_id):
                return False

        return True


class HalogenBond:
    """Detect halogen bond interactions."""

    acceptor: Pharmacophore | None
    halogen: Pharmacophore | None

    def __init__(self, configuration: InteractionConfiguration) -> None:
        """Initialize the halogen bond detector.

        Parameters
        ----------
        configuration : `InteractionConfiguration`
            Configuration defining distance and angular constraints.
        """
        self.__configuration: InteractionConfiguration = configuration

    def operation(
        self,
        acceptor: Pharmacophore,
        halogen: Pharmacophore,
        data: dict,
    ) -> bool:
        """Evaluate whether an acceptor and a hallogen donors pair forms a
        halogen bond.

        Parameters
        ----------
        acceptor : `Pharmacophore`
            Halogen bond acceptor atom.

        halogen : `Pharmacophore`
            Halogen donor atom.

        data : `dict`
            Dictionary populated with distance and angle descriptors.

        Returns
        -------
        `bool`
            `True` if a valid halogen bond is detected.
        """
        if acceptor.key == KeyIdentifier.PHARMACOPHORE:
            HalogenBond.acceptor = acceptor

        if halogen.key == KeyIdentifier.PHARMACOPHORE:
            HalogenBond.halogen = halogen

        if (
            HalogenBond.acceptor.key != KeyIdentifier.PHARMACOPHORE
            or HalogenBond.halogen.key != KeyIdentifier.PHARMACOPHORE
            or acceptor.key != KeyIdentifier.ATOM
            or halogen.key != KeyIdentifier.ATOM
        ):
            return False

        if (
            HalogenBond.acceptor.id != acceptor.id
            or HalogenBond.halogen.id != halogen.id
            or acceptor.id == halogen.id
        ):
            return False

        if not InteractionIsGood.distance(
            data,
            [HalogenBond.acceptor, HalogenBond.halogen],
            [
                self.__configuration.min_dist,
                self.__configuration.halogen_dist_max,
            ],
        ):
            return False

        acc_angle: float = self.__configuration.halogen_acc_angle
        angle_dev: float = self.__configuration.halogen_angle_dev

        if not InteractionIsGood.angle(
            data,
            [acceptor, HalogenBond.acceptor, HalogenBond.halogen],
            [acc_angle - angle_dev, acc_angle + angle_dev],
        ):
            return False

        # Acceptor angle.
        data["offset"] = data["angle"]

        don_angle: float = self.__configuration.halogen_don_angle

        data["data_class_a"] = HalogenBond.acceptor
        data["data_class_b"] = HalogenBond.halogen

        return InteractionIsGood.angle(
            data,
            [HalogenBond.acceptor, HalogenBond.halogen, halogen],
            [don_angle - angle_dev, don_angle + angle_dev],
        )
