"""1-dimensional lines of interest."""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
import shapely

from movement.roi.base import BaseRegionOfInterest, LineLike, PointLikeList
from movement.utils.broadcasting import broadcastable_method
from movement.utils.logging import logger

if TYPE_CHECKING:
    import xarray as xr
    from matplotlib.axes import Axes
    from matplotlib.figure import Figure, SubFigure
    from numpy.typing import ArrayLike


class LineOfInterest(BaseRegionOfInterest[LineLike]):
    """Representation of boundaries or other lines of interest.

    This class can be used to represent boundaries or other internal divisions
    of the area in which the experimental data was gathered. These might
    include segments of a wall that are removed partway through a behavioural
    study, or coloured marking on the floor of the experimental enclosure that
    have some significance. Instances of this class also constitute the
    boundary of two-dimensional regions (polygons) of interest.

    An instance of this class can be used to represent these "one dimensional
    regions" (lines of interest, LoIs) in an analysis. The basic usage is to
    construct an instance of this class by passing in a list of points, which
    will then be joined (in sequence) by straight lines between consecutive
    pairs of points, to form the LoI that is to be studied.
    """

    def __init__(
        self,
        points: PointLikeList,
        loop: bool = False,
        name: str | None = None,
    ) -> None:
        """Create a new line of interest (LoI).

        Parameters
        ----------
        points
            The points (in sequence) that make up the line segment. At least
            two points must be provided.
        loop
            If True, the final point in ``points`` will be connected by an
            additional line segment to the first, creating a closed loop.
            (See Notes).
        name
            Name of the LoI that is to be created. A default name will be
            inherited from the base class if not provided, and
            defaults are inherited from.

        Notes
        -----
        The constructor supports 'rings' or 'closed loops' via the ``loop``
        argument. However, if you want to define an enclosed region for your
        analysis, we recommend you create a
        :class:`PolygonOfInterest<movement.roi.PolygonOfInterest>`
        and use its ``exterior_boundary`` property instead.

        See Also
        --------
        movement.roi.BaseRegionOfInterest
            The base class that constructor arguments are passed to.

        """
        if len(points) < 2:
            raise logger.error(
                ValueError(
                    f"Need at least 2 points to define a 1D region "
                    f"(got {len(points)})."
                )
            )
        if len(points) < 3 and loop:
            raise logger.error(
                ValueError("Cannot create a loop from a single line segment.")
            )
        line: LineLike = (
            shapely.LinearRing(coordinates=points)
            if loop
            else shapely.LineString(coordinates=points)
        )
        line = shapely.normalize(line)
        super().__init__(line, name=name)

    def _plot(
        self, fig: Figure | SubFigure, ax: Axes, **matplotlib_kwargs
    ) -> tuple[Figure | SubFigure, Axes]:
        """LinesOfInterest can simply be plotted as lines."""
        ax.plot(
            [c[0] for c in self.coords],
            [c[1] for c in self.coords],
            **matplotlib_kwargs,
        )
        return fig, ax

    @broadcastable_method(
        only_broadcastable_along="space", new_dimension_name="normal"
    )
    def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray:
        """Compute the unit normal to this line.

        The unit normal is a vector perpendicular to the input line
        whose norm is equal to 1. The direction of the normal vector
        is not fully defined: the line divides the 2D plane in two
        halves, and the normal could be pointing to either of the half-planes.
        For example, a horizontal line divides the 2D plane in a
        bottom and a top half-plane, and we can choose whether
        the normal points "upwards" or "downwards". We use a sample
        point to define the half-plane the normal vector points to.

        If this is a multi-segment line, the method raises an error.

        Parameters
        ----------
        on_same_side_as
            A sample point in the (x,y) plane the normal is in. If multiple
            points are given, one normal vector is returned for each point
            given. By default, the origin is used.

        Returns
        -------
        numpy.ndarray
            Unit normal vector(s) perpendicular to the line segment, pointing
            toward the half-plane containing the
            sample point ``on_same_side_as``.

        Raises
        ------
        ValueError
            When the normal is requested for a multi-segment geometry.

        """
        # A multi-segment geometry always has at least 3 coordinates.
        if len(self.coords) > 2:
            raise ValueError(
                "Normal is not defined for multi-segment geometries."
            )

        on_same_side_as = np.array(on_same_side_as)

        parallel_to_line = np.array(self.region.coords[1]) - np.array(
            self.region.coords[0]
        )
        normal = np.array([parallel_to_line[1], -parallel_to_line[0]])
        normal /= np.sqrt(np.sum(normal**2))

        if np.dot((on_same_side_as - self.region.coords[0]), normal) < 0:
            normal *= -1.0
        return normal

    def compute_angle_to_normal(
        self,
        direction: xr.DataArray,
        position: xr.DataArray,
        in_degrees: bool = False,
    ) -> xr.DataArray:
        """Compute the angle between the normal to the segment and a direction.

        The returned angle is the signed angle between the normal to the
        segment and the ``direction`` vector(s) provided.

        Parameters
        ----------
        direction
            An array of vectors representing a given direction,
            e.g., the forward vector(s).
        position
            Spatial positions, considered the origin of the ``direction``.
        in_degrees
            If ``True``, angles are returned in degrees. Otherwise angles are
            returned in radians. Default ``False``.

        Returns
        -------
        xarray.DataArray
            The signed angle between the segment normal and the provided
            direction vector(s). Returned in radians unless
            ``in_degrees=True``.

        See Also
        --------
        movement.utils.vector.compute_signed_angle_2d :
            For the definition of the signed angle between two vectors.

        """
        return self._boundary_angle_computation(
            position=position,
            reference_vector=direction,
            how_to_compute_vector_to_region=lambda p: self._reassign_space_dim(
                -1.0 * self.normal(p), "normal"
            ),
            in_degrees=in_degrees,
        )
