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

# M
import math

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

# P
import pytest

# ==============
# Module package
# ==============
# P
from src.strange.parser.configuration import (
    Configuration,
)
from src.strange.plip_detection import (
    InteractionIsGood,
    HydrogenBond,
    PiStacking,
    PiCharge,
    HalogenBond,
    SaltBridge,
    Hydrophobic,
)

# U
from src.strange.utils.generic import (
    KeyIdentifier,
)


@dataclasses.dataclass
class Pharmacophore:
    """Lightweight pharmacophoric point used in interaction test cases."""

    coordinate_x: float
    coordinate_y: float
    coordinate_z: float
    key: KeyIdentifier = KeyIdentifier.PHARMACOPHORE
    id: int = 0


def generate_distance_test(
    point_a: list[float],
    point_b: list[float],
    good_born: list[float],
    bad_born: list[float],
    excepted_distance: float,
) -> list[list[list[float] | float | bool]]:
    """Generate valid and invalid datasets for distance interaction tests.

    Parameters
    ----------
    point_a : `list[float]`
        Coordinates of the first point.

    point_b : `list[float]`
        Coordinates of the second point.

    good_born : `list[float]`
        Minimum and maximum bounds that should validate the distance.

    bad_born : `list[float]`
        Minimum and maximum bounds that should invalidate the distance.

    excepted_distance : `float`
        Expected computed distance between both points.

    Returns
    -------
    `list[list[list[float] | float | bool]]`
        A list containing valid and invalid parameter sets for parametrized
        tests.
    """
    good_test: list[list[float] | float | bool] = [
        point_a,
        point_b,
        *good_born,
        excepted_distance,
        True,
    ]

    bad_test: list[list[float] | float | bool] = [
        point_a,
        point_b,
        *bad_born,
        excepted_distance,
        False,
    ]

    return [good_test, bad_test]


@pytest.mark.parametrize(
    "point_a, point_b, min_born, max_born, excepted_distance, "
    + "excepted_condition",
    generate_distance_test(
        [8, 8, 12], [6, 6, 4], [6, 13], [0, 6], 6 * math.sqrt(2)
    )
    + generate_distance_test([1, -26, -1], [-3, -29, -1], [4, 6], [6, 7], 5)
    + generate_distance_test(
        [121.28, 2 * math.sqrt(4), 58.6],
        [21.28, math.sqrt(4), 3],
        [114, 114.44],
        [114, 114.43494],
        (6 * math.sqrt(9094)) / 5,
    ),
)
def test_distance(
    point_a: list[float],
    point_b: list[float],
    min_born: float,
    max_born: float,
    excepted_distance: float,
    excepted_condition: bool,
) -> None:
    """Test the distance constraint validation.

    Parameters
    ----------
    point_a : `list[float]`
        Coordinates of the first pharmacophore.

    point_b : `list[float]`
        Coordinates of the second pharmacophore.

    min_born : `float`
        Minimum allowed distance.

    max_born : `float`
        Maximum allowed distance.

    excepted_distance : `float`
        Expected calculated distance.

    excepted_condition : `bool`
        Expected boolean result of the distance validation.

    Returns
    -------
    `None`
    """
    data: dict[str, float] = {}

    is_distance_good: bool = InteractionIsGood.distance(
        data,
        [Pharmacophore(*point_a), Pharmacophore(*point_b)],
        [min_born, max_born],
    )

    assert numpy.isclose(data["distance"], excepted_distance)
    assert is_distance_good == excepted_condition


def generate_angle_test(
    point_a: list[float],
    point_b: list[float],
    point_c: list[float],
    good_born: list[float],
    bad_born: list[float],
    reverted: float,
    excepted_angle: float,
) -> list[list[list[float] | float | bool]]:
    """Generate valid and invalid datasets for angle interaction tests.

    Parameters
    ----------
    point_a : `list[float]`
        Coordinates of the first point.

    point_b : `list[float]`
        Coordinates of the vertex point.

    point_c : `list[float]`
        Coordinates of the third point.

    good_born : `list[float]`
        Angle bounds expected to validate the interaction.

    bad_born : `list[float]`
        Angle bounds expected to invalidate the interaction.

    reverted : `float`
        Angle correction or inversion parameter.

    excepted_angle : `float`
        Expected calculated angle.

    Returns
    -------
    `list[list[list[float] | float | bool]]`
        Generated parametrized datasets.
    """
    good_test: list[list[float] | float | bool] = [
        point_a,
        point_b,
        point_c,
        *good_born,
        reverted,
        excepted_angle,
        True,
    ]

    bad_test: list[list[float] | float | bool] = [
        point_a,
        point_b,
        point_c,
        *bad_born,
        reverted,
        excepted_angle,
        False,
    ]

    return [good_test, bad_test]


@pytest.mark.parametrize(
    "point_a, point_b, point_c, min_born, max_born, reverted, excepted_angle, "
    + "excepted_condition",
    generate_angle_test(
        [6.5, 8, 9],
        [6.5, 7, 9],
        [7.5, 7, 9],
        [89.9, 90.1],
        [89.9, 90.0],
        0,
        90,
    )
    + generate_angle_test(
        [-1, 0, 0],
        [0, 0, 0],
        [1, 0.1, 0],
        [5.70, 5.72],
        [5.69, 5.70],
        180,
        180 - (180 * math.acos(-(10 * math.sqrt(101)) / 101)) / math.pi,
    )
    + generate_angle_test(
        [-6, 2, 3],
        [2, 4, 3.6],
        [6, -2, -3],
        [101.4467, 101.4468],
        [101.4465, 101.4467],
        0,
        (180 * math.acos(-(401 * math.sqrt(4_082_801)) / 4_082_801)) / math.pi,
    )
    + generate_angle_test(
        [-2, -1, -1],
        [-1, -1, -1],
        [0, -1, -1],
        [179, 181],
        [178, 180],
        0,
        180,
    ),
)
def test_angle(
    point_a: list[float],
    point_b: list[float],
    point_c: list[float],
    min_born: float,
    max_born: float,
    reverted: float,
    excepted_angle: float,
    excepted_condition: bool,
) -> None:
    """Test the angle constraint validation.

    Parameters
    ----------
    point_a : `list[float]`
        First pharmacophore coordinate.

    point_b : `list[float]`
        Central pharmacophore coordinate.

    point_c : `list[float]`
        Third pharmacophore coordinate.

    min_born : `float`
        Minimum allowed angle.

    max_born : `float`
        Maximum allowed angle.

    reverted : `float`
        Angle inversion parameter.

    excepted_angle : `float`
        Expected computed angle.

    excepted_condition : `bool`
        Expected boolean result of the angle validation.

    Returns
    -------
    `None`
    """
    data: dict[str, float] = {}

    is_angle_good: bool = InteractionIsGood.angle(
        data,
        [
            Pharmacophore(*point_a),
            Pharmacophore(*point_b),
            Pharmacophore(*point_c),
        ],
        [min_born, max_born],
        reverted,
    )

    assert numpy.isclose(data["angle"], excepted_angle)
    assert is_angle_good == excepted_condition


def generate_offset_test(
    normal: list[float],
    point_a: list[float],
    point_b: list[float],
    good_born: float,
    bad_born: float,
    excepted_offset: float,
) -> list[list[list[float] | float | bool]]:
    """Generate valid and invalid datasets for offset interaction tests.

    Parameters
    ----------
    normal : `list[float]`
        Normal vector coordinates.

    point_a : `list[float]`
        First point coordinates.

    point_b : `list[float]`
        Second point coordinates.

    good_born : `float`
        Offset threshold expected to validate the interaction.

    bad_born : `float`
        Offset threshold expected to invalidate the interaction.

    excepted_offset : `float`
        Expected computed offset.

    Returns
    -------
    `list[list[list[float] | float | bool]]`

        Generated parametrized datasets.
    """
    good_test: list[list[float] | float | bool] = [
        normal,
        point_a,
        point_b,
        good_born,
        excepted_offset,
        True,
    ]

    bad_test: list[list[float] | float | bool] = [
        normal,
        point_a,
        point_b,
        bad_born,
        excepted_offset,
        False,
    ]

    return [good_test, bad_test]


@pytest.mark.parametrize(
    "normal, point_a, point_b, born, excepted_angle, excepted_condition",
    generate_offset_test(
        [0, 1, 0],
        [0, 1, 0],
        [1, 0, 0],
        1.1,
        1,
        1,
    )
    + generate_offset_test(
        [0, -20, 0],
        [8, 8, 0],
        [-8, -12, 0],
        17,
        16,
        16,
    )
    + generate_offset_test(
        [0, -20, 0],
        [-8, 8, 16],
        [-8, -12, 0],
        17,
        16,
        16,
    )
    + generate_offset_test(
        [-400 / 41, -500 / 41, 80 / math.sqrt(41)],
        [8, 8, 0],
        [-8, -12, 0],
        17,
        16,
        16,
    ),
)
def test_offset(
    normal: list[float],
    point_a: list[float],
    point_b: list[float],
    born: float,
    excepted_angle: float,
    excepted_condition: bool,
) -> None:
    """Test the offset constraint validation.

    Parameters
    ----------
    normal : `list[float]`
        Normal pharmacophore coordinates.

    point_a : `list[float]`
        First pharmacophore coordinates.

    point_b : `list[float]`
        Second pharmacophore coordinates.

    born : `float`
        Maximum allowed offset.

    excepted_angle : `float`
        Expected computed offset value.

    excepted_condition : `bool`
        Expected boolean validation result.

    Returns
    -------
    `None`
    """
    data: dict[str, float] = {}

    is_offset_good: bool = InteractionIsGood.offset(
        data,
        Pharmacophore(*normal),
        Pharmacophore(*point_a),
        Pharmacophore(*point_b),
        born,
    )

    assert numpy.isclose(data["offset"], excepted_angle)
    assert is_offset_good == excepted_condition


@pytest.fixture
def configuration() -> Configuration:
    """Provide an interaction configuration instance for tests.

    Returns
    -------
    `Configuration`
        Interaction configuration dataclass extracted from the
        configuration file.
    """
    return Configuration()["interaction_configuration"]


@pytest.mark.parametrize(
    "atom_a, atom_b, excepted_condition",
    [
        [[0, 0, 0], [0, 0, 0], False],
        [[-0.4, 0.06, 0.2], [-0.09, 0.1, 0], False],
        [[1.2, 0.2, 0.3], [1.2, 0.5, 0], False],
        [[0.3, -0.7, 0.5], [1.2, 0.5, 0], True],
        [[-1.2, -4.2, 0.5], [-0.4, 0.6, 0.4], True],
        [[7.5, 1.5, 4.4], [-0.4, 0.6, 0.4], False],
        [[-3.9, -3.6, -3.2], [2.4, 0.9, 2.7], False],
    ],
)
def test_salt_bridge(
    atom_a: list[float],
    atom_b: list[float],
    configuration: Configuration,
    excepted_condition: bool,
) -> None:
    """Test salt bridge interaction detection.

    Parameters
    ----------
    atom_a : `list[float]`
        Coordinates of the first atom.

    atom_b : `list[float]`
        Coordinates of the second atom.

    configuration : `Configuration`
        Interaction configuration fixture.

    excepted_condition : `bool`
        Expected interaction result.

    Returns
    -------
    `None`
    """
    salt_bridge = SaltBridge(configuration)
    is_interacting: bool = salt_bridge.operation(
        Pharmacophore(*atom_a), Pharmacophore(*atom_b), {}
    )

    print(is_interacting)

    assert is_interacting == excepted_condition


@pytest.mark.parametrize(
    "atom_a, atom_b, excepted_condition",
    [
        [[0, 0, 0], [0, 0, 0], False],
        [[-0.4, 0.06, 0.2], [-0.09, 0.1, 0], False],
        [[1.2, 0.2, 0.3], [1.2, 0.5, 0], False],
        [[1.4, 3.8, 1.8], [2.0, 0.3, 2.7], True],
        [[-1.0, 2.8, -0.7], [2.3, 1.1, 0.6], True],
        [[-1.04, 2.8, -0.7], [1.8, 0.2, 0.5], False],
        [[-3.9, -3.6, -3.2], [2.4, 0.9, 2.7], False],
    ],
)
def test_hydrophobic(
    atom_a: list[float],
    atom_b: list[float],
    configuration: Configuration,
    excepted_condition: bool,
) -> None:
    """Test hydrophobic interaction detection.

    Parameters
    ----------
    atom_a : `list[float]`
        Coordinates of the first hydrophobic atom.

    atom_b : `list[float]`
        Coordinates of the second hydrophobic atom.

    configuration : `Configuration`
        Interaction configuration fixture.

    excepted_condition : `bool`
        Expected interaction result.

    Returns
    -------
    `None`
    """
    hydrophobic = Hydrophobic(configuration, None)
    is_interacting: bool = hydrophobic.operation(
        Pharmacophore(*atom_a, key=KeyIdentifier.ATOM),
        Pharmacophore(*atom_b, key=KeyIdentifier.ATOM),
        {},
    )

    assert is_interacting == excepted_condition


@pytest.mark.parametrize(
    "acceptor, hydrogen_donor, donor, expected_condition",
    [
        ([1.31, -2.54, 1], [2.18, -2.1, 0], [4.4, -2.25, 0], True),
        # Wrong distance.
        ([1.1, -3.98, 1.9], [2, 0, 0], [5.06, 0, 0], False),
        ([2.21, -2.99, 1.9], [3, -1.5, 0], [3.11, -1.78, 0], False),
        # Wrong angle.
        ([1.84, -0.56, 1.69], [2.35, 1.17, 0.23], [4.17, 1.14, 0], False),
        ([3.5, 2.5, 0], [2.5, 2.5, 0], [1.5, 2.5, 0], False),
    ],
)
def test_hydrogen_bond(
    acceptor: list[float],
    hydrogen_donor: list[float],
    donor: list[float],
    configuration: Configuration,
    expected_condition: bool,
) -> None:
    """Test hydrogen bond interaction detection.

    Parameters
    ----------
    acceptor : `list[float]`
        Coordinates of the acceptor atom.

    hydrogen_donor : `list[float]`
        Coordinates of the hydrogen donor atom.

    donor : `list[float]`
        Coordinates of the donor pharmacophore.

    configuration : `Configuration`
        Interaction configuration fixture.

    expected_condition : `bool`
        Expected interaction result.

    Returns
    -------
    `None`
    """
    h_bond = HydrogenBond(configuration)

    HydrogenBond.donor = Pharmacophore(*donor, key=KeyIdentifier.PHARMACOPHORE)

    is_interacting = h_bond.operation(
        Pharmacophore(*acceptor, key=KeyIdentifier.ATOM),
        Pharmacophore(*hydrogen_donor, key=KeyIdentifier.ATOM),
        {},
    )

    assert is_interacting == expected_condition


@pytest.mark.parametrize(
    "centre_a, centre_b, norm_a, norm_b, expected_condition",
    [
        (
            [-0.67, 3.83, 1.07],
            [-0.17, 3.83, 0],
            [0.21, 0, -1.07],
            [0, 0, 1.17],
            True,
        ),
        (
            [-1, 3.83, 1.33],
            [-0.17, 3.83, 0],
            [0.83, 0, 0],
            [0, 0, 1.33],
            True,
        ),
        # Wrong offset.
        (
            [-2.5, 3.83, 2.33],
            [-0.17, 3.83, 0],
            [2.33, 0, 0],
            [0, 0, 2.33],
            False,
        ),
        (
            [-2.67, 3.83, 2.07],
            [-0.17, 3.83, 0],
            [0.41, 0, -2.07],
            [0, 0, 2.57],
            False,
        ),
        # Wrong angle.
        ([-1.17, 3.83, 2], [-0.17, 3.83, 0], [0, 0, -2], [0, 0, 2], False),
        (
            [-1.1, 3.83, 1.83],
            [-0.17, 3.83, 0],
            [0.93, 0, -0.56],
            [0, 0, 1.27],
            False,
        ),
        # Wrong distance.
        (
            [-1.17, 3.83, 5.47],
            [-0.17, 3.83, 0],
            [1.09, 0, -5.47],
            [0, 0, 5.67],
            False,
        ),
    ],
)
def test_pi_stacking(
    centre_a: list[float],
    centre_b: list[float],
    norm_a: list[float],
    norm_b: list[float],
    configuration: Configuration,
    expected_condition: bool,
) -> None:
    """Test π-stacking interaction detection.

    Parameters
    ----------
    centre_a : `list[float]`
        Coordinates of the first aromatic centre.

    centre_b : `list[float]`
        Coordinates of the second aromatic centre.

    norm_a : `list[float]`
        Normal vector of the first aromatic ring.

    norm_b : `list[float]`
        Normal vector of the second aromatic ring.

    configuration : `Configuration`
        Interaction configuration fixture.

    expected_condition : `bool`
        Expected interaction result.

    Returns
    -------
    `None`
    """
    pi_stack = PiStacking(configuration)

    PiStacking.centre_a = Pharmacophore(
        *centre_a, key=KeyIdentifier.PHARMACOPHORE
    )
    PiStacking.centre_b = Pharmacophore(
        *centre_b, key=KeyIdentifier.PHARMACOPHORE, id=1
    )

    is_interacting = pi_stack.operation(
        Pharmacophore(*norm_a, key=KeyIdentifier.NORM),
        Pharmacophore(*norm_b, key=KeyIdentifier.NORM, id=1),
        {},
    )

    assert is_interacting == expected_condition


@pytest.mark.parametrize(
    "centre_a, normal_a, atom_b, type, expected_condition",
    [
        ([-0.17, 3.83, 0], [0, 0, 5.66], [0.78, 2.15, 5.66], "cat", True),
        ([-0.17, 3.83, 0], [0, 0, -4.75], [-0.4, 2.44, -4.75], "an", True),
        # Wrong offset.
        ([-0.17, 3.83, 0], [0, 0, 3.85], [-2.51, 3.45, 3.85], "cat", False),
        ([-0.17, 3.83, 0], [0, 0, 3.85], [2.51, 3.45, 3.85], "an", False),
        # Wrong distance.
        ([-0.17, 3.83, 0], [0, 0, -5.69], [0.78, 2.15, -5.69], "cat", False),
        ([-0.17, 3.83, 0], [0, 0, 5], [-1.69, 4.34, 5], "an", False),
    ],
)
def test_pi_charge(
    centre_a: list[float],
    normal_a: list[float],
    atom_b: list[float],
    type: str,
    configuration: Configuration,
    expected_condition: bool,
) -> None:
    """Test π–charge interaction detection.

    Parameters
    ----------
    centre_a : `list[float]`
        Coordinates of the aromatic centre.

    normal_a : `list[float]`
        Normal vector of the aromatic system.

    atom_b : `list[float]`
        Coordinates of the charged atom.

    type : `str`
        Type of charge interaction ("cat" or "an").

    configuration : `Configuration`
        Interaction configuration fixture.

    expected_condition : `bool`
        Expected interaction result.

    Returns
    -------
    `None`
    """
    distance: float = getattr(configuration, f"pi{type}ion_dist_max")

    pi_charge = PiCharge(
        configuration,
        distance=distance,
    )

    PiCharge.centre = Pharmacophore(*centre_a, key=KeyIdentifier.PHARMACOPHORE)

    is_interacting = pi_charge.operation(
        Pharmacophore(*normal_a, key=KeyIdentifier.NORM),
        Pharmacophore(*atom_b, key=KeyIdentifier.PHARMACOPHORE),
        {},
    )

    assert is_interacting == expected_condition


@pytest.mark.parametrize(
    "acceptor, acceptor_neighbor, halogen, halogen_neighbor, "
    + "expected_condition",
    [
        (
            [-1.91, 3.19, 0],
            [-3.22, 3.42, 0.33],
            [-0.99, 1.87, 0.41],
            [-0.27, 0.9, 0.4],
            True,
        ),
        # Wrong angle.
        (
            [-1.91, 3.19, 0],
            [-3.22, 3.42, 0.33],
            [-0.99, 1.87, 0.41],
            [-0.34, 1.75, 1.3],
            False,
        ),
        (
            [-1.91, 3.19, 0],
            [-2.41, 3.09, 0.83],
            [-0.99, 1.87, 0.41],
            [-0.27, 0.9, 0.4],
            False,
        ),
        # Wrong distance.
        (
            [-4.43, 4.49, 0.35],
            [-35.83, 3.65, 0.76],
            [-0.99, 1.87, 0.41],
            [-0.27, 0.9, 0.4],
            False,
        ),
    ],
)
def test_halogen_bond(
    acceptor: list[float],
    acceptor_neighbor: list[float],
    halogen: list[float],
    halogen_neighbor: list[float],
    configuration: Configuration,
    expected_condition: bool,
) -> None:
    """Test halogen bond interaction detection.

    Parameters
    ----------
    acceptor : `list[float]`
        Coordinates of the acceptor pharmacophore.

    acceptor_neighbor : `list[float]`
        Coordinates of the acceptor neighbouring atom.

    halogen : `list[float]`
        Coordinates of the halogen pharmacophore.

    halogen_neighbor : `list[float]`
        Coordinates of the halogen neighbouring atom.

    configuration : `Configuration`
        Interaction configuration fixture.

    expected_condition : `bool`
        Expected interaction result.

    Returns
    -------
    `None`
    """
    halogen_bond = HalogenBond(configuration)

    HalogenBond.acceptor = Pharmacophore(
        *acceptor, key=KeyIdentifier.PHARMACOPHORE
    )
    HalogenBond.halogen = Pharmacophore(
        *halogen, key=KeyIdentifier.PHARMACOPHORE, id=1
    )

    is_interacting = halogen_bond.operation(
        Pharmacophore(*acceptor_neighbor, key=KeyIdentifier.ATOM),
        Pharmacophore(*halogen_neighbor, key=KeyIdentifier.ATOM, id=1),
        {},
    )

    assert is_interacting == expected_condition
