from __future__ import annotations

from dataclasses import dataclass
from fnmatch import fnmatch
from typing import ClassVar

from hofmann.model.colour import Colour, normalise_colour


class BondSpec:
    """Declarative rule for bond detection between species pairs.

    The *species* pair is stored in sorted order so that the data
    structure is invariant under exchange of the two labels.

    Species names support fnmatch-style wildcards (``*``, ``?``).

    Only *species* and *max_length* are required.  *radius* and
    *colour* default to ``None``, meaning "use the class-level
    default" (``BondSpec.default_radius`` and
    ``BondSpec.default_colour``).  The resolved value is returned
    by the corresponding property; ``repr()`` shows
    ``radius=<default 0.1>`` when unset so the intent is visible.

    To change the defaults for all specs that have not been
    explicitly set::

        BondSpec.default_radius = 0.15
        BondSpec.default_colour = "grey"

    Attributes:
        species: Sorted pair of species patterns.
        max_length: Maximum bond length threshold.
        min_length: Minimum bond length threshold.  Defaults to
            ``0.0``.
        complete: Controls single-pass bond completion across periodic
            boundaries.  A species string (e.g. ``"Zr"``) adds
            missing partners around visible atoms of that species.
            ``"*"`` completes around both species in the pair.
            ``False`` (default) disables completion.  Unlike
            *recursive*, newly added atoms are **not** themselves
            searched.
        recursive: If ``True``, atoms bonded via this spec are
            searched recursively across periodic boundaries.  When
            an image atom is materialised, its own bonded partners
            are checked on the next iteration, extending the
            expansion outward.  Useful for molecules that span
            periodic boundaries.
    """

    default_radius: ClassVar[float] = 0.1
    """Class-level default for *radius* when not set explicitly."""

    default_colour: ClassVar[Colour] = 0.5
    """Class-level default for *colour* when not set explicitly."""

    @staticmethod
    def _validate_max_length(value: float) -> None:
        if value <= 0:
            raise ValueError(
                f"max_length must be positive, got {value}"
            )

    @staticmethod
    def _validate_min_length(value: float) -> None:
        if value < 0:
            raise ValueError(
                f"min_length must be non-negative, got {value}"
            )

    @staticmethod
    def _validate_length_bounds(min_length: float, max_length: float) -> None:
        if min_length > max_length:
            raise ValueError(
                f"min_length ({min_length}) must not exceed "
                f"max_length ({max_length})"
            )

    @staticmethod
    def _validate_radius(value: float | None) -> None:
        if value is not None and value < 0:
            raise ValueError(
                f"radius must be non-negative, got {value}"
            )

    @staticmethod
    def _validate_colour(value: Colour | None) -> None:
        if value is not None:
            # normalise_colour raises ValueError for invalid input.
            normalise_colour(value)

    @staticmethod
    def _validate_complete(
        value: bool | str, species: tuple[str, str],
    ) -> None:
        if value is True:
            raise ValueError(
                "complete=True is not supported; use a species name "
                "(e.g. complete='Zr') or complete='*' for both directions"
            )
        if value is not False and not isinstance(value, str):
            raise ValueError(
                "complete must be False, a species name string "
                "(e.g. 'Zr'), or '*' for both directions"
            )
        if isinstance(value, str) and value != "*":
            if value == "":
                raise ValueError("complete must not be an empty string")
            if not any(fnmatch(sp, value) for sp in species):
                raise ValueError(
                    f"complete={value!r} does not match either "
                    f"species in the pair {species}"
                )

    def __init__(
        self,
        species: tuple[str, str],
        max_length: float,
        min_length: float = 0.0,
        radius: float | None = None,
        colour: Colour | None = None,
        complete: bool | str = False,
        recursive: bool = False,
    ) -> None:
        a, b = sorted(species)
        self.species = (a, b)
        self.max_length = max_length
        self.min_length = min_length
        self._radius = radius
        self._colour = colour
        self.complete = complete
        self.recursive = recursive

        self._validate()

    def _validate(self) -> None:
        self._validate_max_length(self.max_length)
        self._validate_min_length(self.min_length)
        self._validate_length_bounds(self.min_length, self.max_length)
        self._validate_radius(self._radius)
        self._validate_colour(self._colour)
        self._validate_complete(self.complete, self.species)

    @property
    def radius(self) -> float:
        """Visual radius of the bond cylinder."""
        return self.default_radius if self._radius is None else self._radius

    @radius.setter
    def radius(self, value: float | None) -> None:
        self._validate_radius(value)
        self._radius = value

    @property
    def colour(self) -> Colour:
        """Bond colour (resolved from class default when not set explicitly)."""
        return self.default_colour if self._colour is None else self._colour

    @colour.setter
    def colour(self, value: Colour | None) -> None:
        self._validate_colour(value)
        self._colour = value

    def __repr__(self) -> str:
        radius_repr = (
            f"<default {self.default_radius!r}>"
            if self._radius is None
            else repr(self._radius)
        )
        colour_repr = (
            f"<default {self.default_colour!r}>"
            if self._colour is None
            else repr(self._colour)
        )
        parts = [
            f"species={self.species!r}",
            f"max_length={self.max_length!r}",
            f"min_length={self.min_length!r}",
            f"radius={radius_repr}",
            f"colour={colour_repr}",
        ]
        if self.complete is not False:
            parts.append(f"complete={self.complete!r}")
        if self.recursive:
            parts.append(f"recursive={self.recursive!r}")
        return f"BondSpec({', '.join(parts)})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, BondSpec):
            return NotImplemented
        return (
            self.species == other.species
            and self.max_length == other.max_length
            and self.min_length == other.min_length
            and self._radius == other._radius
            and self._colour == other._colour
            and self.complete == other.complete
            and self.recursive == other.recursive
        )

    __hash__ = None  # type: ignore[assignment]

    def matches(self, species_1: str, species_2: str) -> bool:
        """Check whether this spec matches a given species pair.

        Matching is symmetric: ``BondSpec(("C", "H"), ...).matches("H", "C")``
        returns ``True``.

        Args:
            species_1: First species label.
            species_2: Second species label.

        Returns:
            ``True`` if the pair matches in either order.
        """
        a, b = self.species
        forward = fnmatch(species_1, a) and fnmatch(species_2, b)
        reverse = fnmatch(species_1, b) and fnmatch(species_2, a)
        return forward or reverse

    def to_dict(self) -> dict:
        """Serialise to a JSON-compatible dictionary.

        Fields at their default values are omitted (``min_length=0``,
        ``complete=False``, ``recursive=False``).  When ``radius`` or
        ``colour`` use the class-level default (i.e. were not set
        explicitly), they are omitted too.
        """
        d: dict = {
            "species": list(self.species),
            "max_length": self.max_length,
        }
        if self.min_length != 0.0:
            d["min_length"] = self.min_length
        if self._radius is not None:
            d["radius"] = self._radius
        if self._colour is not None:
            d["colour"] = list(normalise_colour(self._colour))
        if self.complete is not False:
            d["complete"] = self.complete
        if self.recursive:
            d["recursive"] = True
        return d

    @classmethod
    def from_dict(cls, d: dict) -> BondSpec:
        """Deserialise from a dictionary.

        Missing optional fields use their defaults.
        """
        return cls(
            species=tuple(d["species"]),
            max_length=d["max_length"],
            min_length=d.get("min_length", 0.0),
            radius=d.get("radius"),
            colour=d.get("colour"),
            complete=d.get("complete", False),
            recursive=d.get("recursive", False),
        )


@dataclass(frozen=True)
class Bond:
    """A computed bond between two atoms.

    Attributes:
        index_a: Index of the first atom.
        index_b: Index of the second atom.
        length: Interatomic distance.
        spec: The BondSpec rule that produced this bond.
        image: Lattice translation applied to atom b to form the bond.
            ``(0, 0, 0)`` means a direct bond within the cell.
            ``(1, 0, 0)`` means atom b is shifted by +1 along lattice
            vector **a**.  Always ``(0, 0, 0)`` for non-periodic scenes.
    """

    index_a: int
    index_b: int
    length: float
    spec: BondSpec
    image: tuple[int, int, int] = (0, 0, 0)

    def __hash__(self) -> int:
        # BondSpec is deliberately unhashable, so the auto-generated
        # frozen-dataclass hash would fail.  Hash on identity fields
        # only; the equality contract is satisfied because equal bonds
        # (all fields match) always produce equal hashes.
        return hash((self.index_a, self.index_b, self.image))
