"""Basic items."""
from __future__ import annotations

from dataclasses import dataclass
from math import atan2
from typing import TYPE_CHECKING, Iterable, List, Sequence

from typing_extensions import Protocol, runtime_checkable

from gaphas.connector import Handle, LinePort, Port
from gaphas.constraint import Constraint, EqualsConstraint, constraint
from gaphas.geometry import distance_line_point, distance_rectangle_point
from gaphas.matrix import Matrix
from gaphas.solver import REQUIRED, VERY_STRONG, variable
from gaphas.types import CairoContext

if TYPE_CHECKING:
    from gaphas.connections import Connections


@dataclass(frozen=True)
class DrawContext:
    cairo: CairoContext
    selected: bool
    focused: bool
    hovered: bool


@runtime_checkable
class Item(Protocol):
    """This protocol should be implemented by model items.

    All items that are rendered on a view.
    """

    @property
    def matrix(self) -> Matrix:
        """The "local", item-to-parent matrix."""

    @property
    def matrix_i2c(self) -> Matrix:
        """Matrix from item to toplevel."""

    def handles(self) -> Sequence[Handle]:
        """Return a list of handles owned by the item."""

    def ports(self) -> Sequence[Port]:
        """Return list of ports owned by the item."""

    def point(self, x: float, y: float) -> float:
        """Get the distance from a point (``x``, ``y``) to the item.

        ``x`` and ``y`` are in item coordinates.

        A distance of 0 means the point is on the item.
        """

    def draw(self, context: DrawContext) -> None:
        """Render the item to a canvas view. Context contains the following
        attributes:

        * `cairo`: the CairoContext use this one to draw
        * `selected`, `focused`, `hovered`: view state of items
          (True/False)
        """


def matrix_i2i(from_item: Item, to_item: Item) -> Matrix:
    i2c = from_item.matrix_i2c
    c2i = to_item.matrix_i2c.inverse()
    return i2c.multiply(c2i)


class Matrices:
    def __init__(self, **kwargs: object) -> None:
        super().__init__(**kwargs)  # type: ignore[call-arg]
        self._matrix = Matrix()
        self._matrix_i2c = Matrix()

    @property
    def matrix(self) -> Matrix:
        return self._matrix

    @property
    def matrix_i2c(self) -> Matrix:
        return self._matrix_i2c


[NW, NE, SE, SW] = list(range(4))


class Element(Matrices):
    """An Element has 4 handles (for a start)::

    NW +---+ NE    |   | SW +---+ SE
    """

    min_width = variable(strength=REQUIRED, varname="_min_width")
    min_height = variable(strength=REQUIRED, varname="_min_height")

    def __init__(
        self,
        connections: Connections,
        width: float = 10,
        height: float = 10,
        **kwargs: object
    ) -> None:
        super().__init__(**kwargs)
        self._handles = [h(strength=VERY_STRONG) for h in [Handle] * 4]

        handles = self._handles
        h_nw = handles[NW]
        h_ne = handles[NE]
        h_sw = handles[SW]
        h_se = handles[SE]

        # edge of element define default element ports
        self._ports = [
            LinePort(h_nw.pos, h_ne.pos),
            LinePort(h_ne.pos, h_se.pos),
            LinePort(h_se.pos, h_sw.pos),
            LinePort(h_sw.pos, h_nw.pos),
        ]

        # initialize min_x variables
        self.min_width, self.min_height = 10, 10

        add = connections.add_constraint
        add(self, constraint(horizontal=(h_nw.pos, h_ne.pos)))
        add(self, constraint(horizontal=(h_sw.pos, h_se.pos)))
        add(self, constraint(vertical=(h_nw.pos, h_sw.pos)))
        add(self, constraint(vertical=(h_ne.pos, h_se.pos)))

        # create minimal size constraints
        add(self, constraint(left_of=(h_nw.pos, h_se.pos), delta=self.min_width))
        add(self, constraint(above=(h_nw.pos, h_se.pos), delta=self.min_height))

        self.width = width
        self.height = height

        # Trigger solver to honour width/height by SE handle pos
        self._handles[SE].pos.x.dirty()
        self._handles[SE].pos.y.dirty()

    @property
    def width(self) -> float:
        """Width of the box, calculated as the distance from the left and right
        handle."""
        h = self._handles
        return float(h[SE].pos.x) - float(h[NW].pos.x)

    @width.setter
    def width(self, width: float) -> None:
        """
        >>> b=Element()
        >>> b.width = 20
        >>> b.width
        20.0
        >>> b._handles[NW].pos.x
        Variable(0, 40)
        >>> b._handles[SE].pos.x
        Variable(20, 40)
        """
        h = self._handles
        h[SE].pos.x = h[NE].pos.x = h[NW].pos.x + width

    @property
    def height(self) -> float:
        """Height."""
        h = self._handles
        return float(h[SE].pos.y) - float(h[NW].pos.y)

    @height.setter
    def height(self, height: float) -> None:
        """
        >>> b=Element()
        >>> b.height = 20
        >>> b.height
        20.0
        >>> b.height = 2
        >>> b.height
        2.0
        >>> b._handles[NW].pos.y
        Variable(0, 40)
        >>> b._handles[SE].pos.y
        Variable(2, 40)
        """
        h = self._handles
        h[SE].pos.y = h[SW].pos.y = h[NW].pos.y + height

    def handles(self) -> Sequence[Handle]:
        """Return a list of handles owned by the item."""
        return self._handles

    def ports(self) -> Sequence[Port]:
        """Return list of ports."""
        return self._ports

    def point(self, x: float, y: float) -> float:
        """Distance from the point (x, y) to the item.

        >>> e = Element()
        >>> e.point(20, 10)
        10.0
        """
        h = self._handles
        x0, y0 = h[NW].pos
        x1, y1 = h[SE].pos
        return distance_rectangle_point((x0, y0, x1 - x0, y1 - y0), (x, y))

    def draw(self, context: DrawContext) -> None:
        pass


def create_orthogonal_constraints(
    handles: Sequence[Handle], horizontal: bool
) -> Iterable[Constraint]:
    rest = 1 if horizontal else 0
    for pos, (h0, h1) in enumerate(zip(handles, handles[1:])):
        p0 = h0.pos
        p1 = h1.pos
        if pos % 2 == rest:
            yield EqualsConstraint(a=p0.x, b=p1.x)
        else:
            yield EqualsConstraint(a=p0.y, b=p1.y)


class Line(Matrices):
    """A Line item.

    Properties:
     - fuzziness (0.0..n): an extra margin that should be taken into
         account when calculating the distance from the line (using
         point()).
     - orthogonal (bool): whether or not the line should be
         orthogonal (only straight angles)
     - horizontal: first line segment is horizontal
     - line_width: width of the line to be drawn

    This line also supports arrow heads on both the begin and end of
    the line. These are drawn with the methods draw_head(context) and
    draw_tail(context). The coordinate system is altered so the
    methods do not have to know about the angle of the line segment
    (e.g. drawing a line from (10, 10) via (0, 0) to (10, -10) will
    draw an arrow point).
    """

    def __init__(self, connections: Connections, **kwargs: object) -> None:
        super().__init__(**kwargs)
        self._connections = connections
        self._handles = [Handle(connectable=True), Handle((10, 10), connectable=True)]
        self._ports: List[Port] = []
        self._update_ports()

        self._line_width = 2.0
        self._fuzziness = 0.0
        self._orthogonal_constraints: List[Constraint] = []
        self._horizontal = False

    @property
    def head(self) -> Handle:
        return self._handles[0]

    @property
    def tail(self) -> Handle:
        return self._handles[-1]

    def _set_line_width(self, line_width: float) -> None:
        self._line_width = line_width

    line_width = property(lambda s: s._line_width, _set_line_width)

    def _set_fuzziness(self, fuzziness: float) -> None:
        self._fuzziness = fuzziness

    fuzziness = property(lambda s: s._fuzziness, _set_fuzziness)

    def update_orthogonal_constraints(self, orthogonal: bool) -> None:
        """Update the constraints required to maintain the orthogonal line.

        The actual constraints attribute (``_orthogonal_constraints``)
        is observed, so the undo system will update the contents
        properly
        """
        for c in self._orthogonal_constraints:
            self._connections.remove_constraint(self, c)
        del self._orthogonal_constraints[:]

        if not orthogonal:
            return

        add = self._connections.add_constraint
        cons = [
            add(self, c)
            for c in create_orthogonal_constraints(self._handles, self._horizontal)
        ]
        self._set_orthogonal_constraints(cons)

    def _set_orthogonal_constraints(
        self, orthogonal_constraints: List[Constraint]
    ) -> None:
        """Setter for the constraints maintained.

        Required for the undo system.
        """
        self._orthogonal_constraints = orthogonal_constraints

    def _set_orthogonal(self, orthogonal: bool) -> None:
        """
        >>> a = Line()
        >>> a.orthogonal
        False
        """
        if orthogonal and len(self.handles()) < 3:
            raise ValueError("Can't set orthogonal line with less than 3 handles")
        self.update_orthogonal_constraints(orthogonal)

    orthogonal = property(lambda s: bool(s._orthogonal_constraints), _set_orthogonal)

    def _inner_set_horizontal(self, horizontal: bool) -> None:
        self._horizontal = horizontal

    def _set_horizontal(self, horizontal: bool) -> None:
        """
        >>> line = Line()
        >>> line.horizontal
        False
        >>> line.horizontal = False
        >>> line.horizontal
        False
        """
        self._inner_set_horizontal(horizontal)
        self.update_orthogonal_constraints(self.orthogonal)

    horizontal = property(lambda s: s._horizontal, _set_horizontal)

    def insert_handle(self, index: int, handle: Handle) -> None:
        self._handles.insert(index, handle)

    def remove_handle(self, handle: Handle) -> None:
        self._handles.remove(handle)

    def insert_port(self, index: int, port: Port) -> None:
        self._ports.insert(index, port)

    def remove_port(self, port: Port) -> None:
        self._ports.remove(port)

    def _update_ports(self) -> None:
        """Update line ports.

        This destroys all previously created ports and should only be
        used when initializing the line.
        """
        assert len(self._handles) >= 2, "Not enough segments"
        self._ports = []
        handles = self._handles
        for h1, h2 in zip(handles[:-1], handles[1:]):
            self._ports.append(LinePort(h1.pos, h2.pos))

    def opposite(self, handle: Handle) -> Handle:
        """Given the handle of one end of the line, return the other end."""
        handles = self._handles
        if handle is handles[0]:
            return handles[-1]
        elif handle is handles[-1]:
            return handles[0]
        else:
            raise KeyError("Handle is not an end handle")

    def handles(self) -> Sequence[Handle]:
        """Return a list of handles owned by the item."""
        return self._handles

    def ports(self) -> Sequence[Port]:
        """Return list of ports."""
        return self._ports

    def point(self, x: float, y: float) -> float:
        """
        >>> a = Line()
        >>> a.handles()[1].pos = 25, 5
        >>> a._handles.append(a._create_handle((30, 30)))
        >>> a.point(-1, 0)
        1.0
        >>> f"{a.point(5, 4):.3f}"
        '2.942'
        >>> f"{a.point(29, 29):.3f}"
        '0.784'
        """
        hpos = [h.pos for h in self._handles]

        distance, _point = min(
            map(distance_line_point, hpos[:-1], hpos[1:], [(x, y)] * (len(hpos) - 1))  # type: ignore[arg-type]
        )
        fuzziness: float = self.fuzziness
        return max(0.0, distance - fuzziness)

    def draw_head(self, context: DrawContext) -> None:
        """Default head drawer: move cursor to the first handle."""
        context.cairo.move_to(0, 0)

    def draw_tail(self, context: DrawContext) -> None:
        """Default tail drawer: draw line to the last handle."""
        context.cairo.line_to(0, 0)

    def draw(self, context: DrawContext) -> None:
        """Draw the line itself.

        See Item.draw(context).
        """

        def draw_line_end(pos, angle, draw):  # type: ignore[no-untyped-def]
            cr = context.cairo
            cr.save()
            try:
                cr.translate(*pos)
                cr.rotate(angle)
                draw(context)
            finally:
                cr.restore()

        cr = context.cairo
        cr.set_line_width(self.line_width)

        h0, h1 = self._handles[:2]
        p0, p1 = h0.pos, h1.pos
        head_angle = atan2(p1.y - p0.y, p1.x - p0.x)  # type: ignore[assignment]
        draw_line_end(self._handles[0].pos, head_angle, self.draw_head)

        for h in self._handles[1:-1]:
            cr.line_to(*h.pos)

        h1, h0 = self._handles[-2:]
        p1, p0 = h1.pos, h0.pos
        tail_angle = atan2(p1.y - p0.y, p1.x - p0.x)  # type: ignore[assignment]
        draw_line_end(self._handles[-1].pos, tail_angle, self.draw_tail)
        cr.stroke()


__test__ = {"Line._set_orthogonal": Line._set_orthogonal}
