"""Hyperplane class for the Ellipsoidal Toolbox.

A hyperplane H(v, c) in R^n is the set

    H = { x in R^n : <v, x> = c }

where *v* is the normal vector and *c* is the scalar shift.  A
hyperplane also implicitly defines a halfspace { x : <v, x> <= c }.

Reference
---------
MATLAB Ellipsoidal Toolbox v3, ``@hyperplane/hyperplane.m``
"""

from __future__ import annotations

from typing import Optional

import numpy as np
from numpy.typing import ArrayLike

from .config import ABS_TOL

__all__ = ["Hyperplane"]


class Hyperplane:
    """A hyperplane defined by a normal vector and a scalar shift.

    Parameters
    ----------
    normal : array_like or None
        Normal vector *v* of shape ``(n,)``.  If *None* an empty
        hyperplane is created.
    shift : float
        Scalar shift *c*.  Defaults to 0.

    Raises
    ------
    ValueError
        If *normal* is not a 1-D vector.

    Examples
    --------
    >>> H = Hyperplane([1, 0], 3.0)
    >>> H.normal
    array([1., 0.])
    >>> H.shift
    3.0
    """

    # ------------------------------------------------------------------
    # Construction
    # ------------------------------------------------------------------

    def __init__(
        self,
        normal: Optional[ArrayLike] = None,
        shift: float = 0.0,
    ) -> None:
        if normal is None:
            self._normal: Optional[np.ndarray] = None
            self._shift: float = 0.0
            return

        v = np.asarray(normal, dtype=float).ravel()
        c = float(shift)

        if np.linalg.norm(v) <= ABS_TOL and abs(c) > ABS_TOL:
            v = np.zeros_like(v)
            c = 0.0

        self._normal = v
        self._shift = c

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def normal(self) -> Optional[np.ndarray]:
        """Normal vector *v*, or *None* for an empty hyperplane."""
        return self._normal

    @property
    def shift(self) -> float:
        """Scalar shift *c*."""
        return self._shift

    # ------------------------------------------------------------------
    # Basic queries
    # ------------------------------------------------------------------

    def parameters(self) -> tuple[Optional[np.ndarray], float]:
        """Return ``(v, c)`` — the normal and shift.

        Returns
        -------
        v : ndarray or None
            Normal vector.
        c : float
            Scalar shift.
        """
        return self._normal, self._shift

    def dimension(self) -> int:
        """Return the dimension of the ambient space.

        Returns
        -------
        int
            Length of the normal vector, or 0 if the hyperplane is empty.
        """
        if self.is_empty():
            return 0
        assert self._normal is not None
        return self._normal.shape[0]

    def is_empty(self) -> bool:
        """Return *True* if this is an empty (uninitialised) hyperplane."""
        return self._normal is None

    # ------------------------------------------------------------------
    # Dunder / display
    # ------------------------------------------------------------------

    def __repr__(self) -> str:
        if self.is_empty():
            return "Hyperplane(empty)"
        return f"Hyperplane(dim={self.dimension()})"

    def __str__(self) -> str:
        if self.is_empty():
            return "Empty hyperplane."
        return (
            f"Hyperplane in R^{self.dimension()}:\n"
            f"  normal v = {self._normal}\n"
            f"  shift  c = {self._shift}"
        )

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Hyperplane):
            return NotImplemented
        if self.is_empty() and other.is_empty():
            return True
        if self.is_empty() or other.is_empty():
            return False
        assert self._normal is not None and other._normal is not None
        return bool(
            np.allclose(self._normal, other._normal, atol=ABS_TOL)
            and abs(self._shift - other._shift) <= ABS_TOL
        )

    def __ne__(self, other: object) -> bool:
        result = self.__eq__(other)
        if result is NotImplemented:
            return NotImplemented
        return not result

    def __neg__(self) -> Hyperplane:
        """Return the hyperplane with negated normal and shift."""
        if self.is_empty():
            return Hyperplane()
        assert self._normal is not None
        return Hyperplane(-self._normal, -self._shift)
