#  Copyright (c) 2022, Manfred Moitzi
#  License: MIT License
from __future__ import annotations
from typing import (
    Iterator,
    Sequence,
    TYPE_CHECKING,
    Callable,
    Any,
    Union,
    Optional,
    Tuple,
)
from typing_extensions import TypeAlias
from collections import defaultdict
import enum
import math
import dataclasses
from ezdxf.math import (
    Vec2,
    Vec3,
    Bezier3P,
    Bezier4P,
    intersection_ray_cubic_bezier_2d,
    quadratic_to_cubic_bezier,
)
from ezdxf import const
from ezdxf.path import Path, LineTo, MoveTo, Curve3To, Curve4To

if TYPE_CHECKING:
    from ezdxf.entities.polygon import DXFPolygon

MIN_HATCH_LINE_DISTANCE = 1e-4  # ??? what's a good choice
NONE_VEC2 = Vec2(math.nan, math.nan)
KEY_NDIGITS = 4
SORT_NDIGITS = 10


class IntersectionType(enum.IntEnum):
    NONE = 0
    REGULAR = 1
    START = 2
    END = 3
    COLLINEAR = 4


class HatchingError(Exception):
    """Base exception class of the :mod:`hatching` module."""

    pass


class HatchLineDirectionError(HatchingError):
    """Hatching direction is undefined or a (0, 0) vector."""

    pass


class DenseHatchingLinesError(HatchingError):
    """Very small hatching distance which creates too many hatching lines."""

    pass


@dataclasses.dataclass(frozen=True)
class Line:
    start: Vec2
    end: Vec2
    distance: float  # normal distance to the hatch baseline


@dataclasses.dataclass(frozen=True)
class Intersection:
    """Represents an intersection."""

    type: IntersectionType = IntersectionType.NONE
    p0: Vec2 = NONE_VEC2
    p1: Vec2 = NONE_VEC2


def side_of_line(distance: float, abs_tol=1e-12) -> int:
    if abs(distance) < abs_tol:
        return 0
    if distance > 0.0:
        return +1
    return -1


@dataclasses.dataclass(frozen=True)
class HatchLine:
    """Represents a single hatch line.

    Args:
        origin: the origin of the hatch line as :class:`~ezdxf.math.Vec2` instance
        direction: the hatch line direction as :class:`~ezdxf.math.Vec2` instance, must not (0, 0)
        distance: the normal distance to the base hatch line as float

    """

    origin: Vec2
    direction: Vec2
    distance: float

    def intersect_line(
        self,
        a: Vec2,
        b: Vec2,
        dist_a: float,
        dist_b: float,
    ) -> Intersection:
        """Returns the :class:`Intersection` of this hatch line and the line
        defined by the points `a` and `b`.
        The arguments `dist_a` and `dist_b` are the signed normal distances of
        the points `a` and `b` from the hatch baseline.
        The normal distances from the baseline are easy to calculate by the
        :meth:`HatchBaseLine.signed_distance` method and allow a fast
        intersection calculation by a simple point interpolation.

        Args:
            a: start point of the line as :class:`~ezdxf.math.Vec2` instance
            b: end point of the line as :class:`~ezdxf.math.Vec2` instance
            dist_a: normal distance of point `a` to the hatch baseline as float
            dist_b: normal distance of point `b` to the hatch baseline as float

        """
        # all distances are normal distances to the hatch baseline
        line_distance = self.distance
        side_a = side_of_line(dist_a - line_distance)
        side_b = side_of_line(dist_b - line_distance)
        if side_a == 0:
            if side_b == 0:
                return Intersection(IntersectionType.COLLINEAR, a, b)
            else:
                return Intersection(IntersectionType.START, a)
        elif side_b == 0:
            return Intersection(IntersectionType.END, b)
        elif side_a != side_b:
            factor = abs((dist_a - line_distance) / (dist_a - dist_b))
            return Intersection(IntersectionType.REGULAR, a.lerp(b, factor))
        return Intersection()  # no intersection

    def intersect_cubic_bezier_curve(self, curve: Bezier4P) -> Sequence[Intersection]:
        """Returns 0 to 3 :class:`Intersection` points of this hatch line with
        a cubic Bèzier curve.

        Args:
            curve: the cubic Bèzier curve as :class:`ezdxf.math.Bezier4P` instance

        """
        return [
            Intersection(IntersectionType.REGULAR, p, NONE_VEC2)
            for p in intersection_ray_cubic_bezier_2d(
                self.origin, self.origin + self.direction, curve
            )
        ]


class PatternRenderer:
    """
    The hatch pattern of a DXF entity has one or more :class:`HatchBaseLine`
    instances with an origin, direction, offset and line pattern.
    The :class:`PatternRenderer` for a certain distance from the
    baseline has to be acquired from the :class:`HatchBaseLine` by the
    :meth:`~HatchBaseLine.pattern_renderer` method.

    The origin of the hatch line is the starting point of the line
    pattern. The offset defines the origin of the adjacent
    hatch line and doesn't have to be orthogonal to the hatch line direction.

    **Line Pattern**

    The line pattern is a sequence of floats, where a value > 0.0 is a dash, a
    value < 0.0 is a gap and value of 0.0 is a point.

    Args:
        hatch_line: :class:`HatchLine`
        pattern: the line pattern as sequence of float values

    """

    def __init__(self, hatch_line: HatchLine, pattern: Sequence[float]):
        self.origin = hatch_line.origin
        self.direction = hatch_line.direction
        self.pattern = pattern
        self.pattern_length = math.fsum([abs(e) for e in pattern])

    def sequence_origin(self, index: float) -> Vec2:
        return self.origin + self.direction * (self.pattern_length * index)

    def render(self, start: Vec2, end: Vec2) -> Iterator[tuple[Vec2, Vec2]]:
        """Yields the pattern lines as pairs of :class:`~ezdxf.math.Vec2`
        instances from the start- to the end point on the hatch line.
        For points the start- and end point are the same :class:`~ezdxf.math.Vec2`
        instance and can be tested by the ``is`` operator.

        The start- and end points should be located collinear at the hatch line
        of this instance, otherwise the points a projected onto this hatch line.

        """
        if start.isclose(end):
            return
        length = self.pattern_length
        if length < 1e-9:
            yield start, end
            return

        direction = self.direction
        if direction.dot(end - start) < 0.0:
            # Line direction is reversed to the pattern line direction!
            start, end = end, start
        origin = self.origin
        s_dist = direction.dot(start - origin)
        e_dist = direction.dot(end - origin)
        s_index, s_offset = divmod(s_dist, length)
        e_index, e_offset = divmod(e_dist, length)

        if s_index == e_index:
            yield from self.render_offset_to_offset(s_index, s_offset, e_offset)
            return
        # line crosses pattern border
        if s_offset > 0.0:
            yield from self.render_offset_to_offset(
                s_index,
                s_offset,
                length,
            )
            s_index += 1

        while s_index < e_index:
            yield from self.render_full_pattern(s_index)
            s_index += 1

        if e_offset > 0.0:
            yield from self.render_offset_to_offset(
                s_index,
                0.0,
                e_offset,
            )

    def render_full_pattern(self, index: float) -> Iterator[tuple[Vec2, Vec2]]:
        # fast pattern rendering
        direction = self.direction
        start_point = self.sequence_origin(index)
        for dash in self.pattern:
            if dash == 0.0:
                yield start_point, start_point
            else:
                end_point = start_point + direction * abs(dash)
                if dash > 0.0:
                    yield start_point, end_point
                start_point = end_point

    def render_offset_to_offset(
        self, index: float, s_offset: float, e_offset: float
    ) -> Iterator[tuple[Vec2, Vec2]]:
        direction = self.direction
        origin = self.sequence_origin(index)
        start_point = origin + direction * s_offset
        distance = 0.0
        for dash in self.pattern:
            distance += abs(dash)
            if distance < s_offset:
                continue
            if dash == 0.0:
                yield start_point, start_point
            else:
                end_point = origin + direction * min(distance, e_offset)
                if dash > 0.0:
                    yield start_point, end_point
                if distance >= e_offset:
                    return
                start_point = end_point


class HatchBaseLine:
    """A hatch baseline defines the source line for hatching a geometry.
    A complete hatch pattern of a DXF entity can consist of one or more hatch
    baselines.

    Args:
        origin: the origin of the hatch line as :class:`~ezdxf.math.Vec2` instance
        direction: the hatch line direction as :class:`~ezdxf.math.Vec2` instance, must not (0, 0)
        offset: the offset of the hatch line origin to the next or to the previous hatch line
        line_pattern: line pattern as sequence of floats, see also :class:`PatternRenderer`

    Raises:
        HatchLineDirectionError: hatch baseline has no direction, (0, 0) vector
        DenseHatchingLinesError: hatching lines are too narrow

    """

    def __init__(
        self,
        origin: Vec2,
        direction: Vec2,
        offset: Vec2,
        line_pattern: Optional[list[float]] = None,
    ):
        self.origin = origin
        try:
            self.direction = direction.normalize()
        except ZeroDivisionError:
            raise HatchLineDirectionError("hatch baseline has no direction")
        self.offset = offset
        self.normal_distance: float = (-offset).det(self.direction - offset)
        if abs(self.normal_distance) < MIN_HATCH_LINE_DISTANCE:
            raise DenseHatchingLinesError("hatching lines are too narrow")
        self._end = self.origin + self.direction
        self.line_pattern: list[float] = line_pattern if line_pattern else []

    def __repr__(self):
        return (
            f"{self.__class__.__name__}(origin={self.origin!r}, "
            f"direction={self.direction!r}, offset={self.offset!r})"
        )

    def hatch_line(self, distance: float) -> HatchLine:
        """Returns the :class:`HatchLine` at the given signed `distance`."""
        factor = distance / self.normal_distance
        return HatchLine(self.origin + self.offset * factor, self.direction, distance)

    def signed_distance(self, point: Vec2) -> float:
        """Returns the signed normal distance of the given `point` from this
        hatch baseline.
        """
        # denominator (_end - origin).magnitude is 1.0 !!!
        return (self.origin - point).det(self._end - point)

    def pattern_renderer(self, distance: float) -> PatternRenderer:
        """Returns the :class:`PatternRenderer` for the given signed `distance`."""
        return PatternRenderer(self.hatch_line(distance), self.line_pattern)


def hatch_line_distances(
    point_distances: Sequence[float], normal_distance: float
) -> list[float]:
    """Returns all hatch line distances in the range of the given point
    distances.
    """
    assert normal_distance != 0.0
    normal_factors = [d / normal_distance for d in point_distances]
    max_line_number = int(math.ceil(max(normal_factors)))
    min_line_number = int(math.ceil(min(normal_factors)))
    return [normal_distance * num for num in range(min_line_number, max_line_number)]


def intersect_polygon(
    baseline: HatchBaseLine, polygon: Sequence[Vec2]
) -> Iterator[tuple[Intersection, float]]:
    """Yields all intersection points of the hatch defined by the `baseline` and
    the given `polygon`.

    Returns the intersection point and the normal-distance from the baseline,
    intersection points with the same normal-distance lay on the same hatch
    line.

    """
    count = len(polygon)
    if count < 3:
        return
    if polygon[0].isclose(polygon[-1]):
        count -= 1
        if count < 3:
            return

    prev_point = polygon[count - 1]  # last point
    dist_prev = baseline.signed_distance(prev_point)
    for index in range(count):
        point = polygon[index]
        dist_point = baseline.signed_distance(point)
        for hatch_line_distance in hatch_line_distances(
            (dist_prev, dist_point), baseline.normal_distance
        ):
            hatch_line = baseline.hatch_line(hatch_line_distance)
            ip = hatch_line.intersect_line(
                prev_point,
                point,
                dist_prev,
                dist_point,
            )
            if (
                ip.type != IntersectionType.NONE
                and ip.type != IntersectionType.COLLINEAR
            ):
                yield ip, hatch_line_distance

        prev_point = point
        dist_prev = dist_point


def hatch_polygons(
    baseline: HatchBaseLine,
    polygons: Sequence[Sequence[Vec2]],
    terminate: Optional[Callable[[], bool]] = None,
) -> Iterator[Line]:
    """Yields all pattern lines for all hatch lines generated by the given
    :class:`HatchBaseLine`, intersecting the given 2D polygons as :class:`Line`
    instances.
    The `polygons` should represent a single entity with or without holes, the
    order of the polygons and their winding orientation (cw or ccw) is not
    important. Entities which do not intersect or overlap should be handled
    separately!

    Each polygon is a sequence of :class:`~ezdxf.math.Vec2` instances, they are
    treated as closed polygons even if the last vertex is not equal to the
    first vertex.

    The hole detection is done by a simple inside/outside counting algorithm and
    far from perfect, but is able to handle ordinary polygons well.

    The terminate function WILL BE CALLED PERIODICALLY AND should return
    ``True`` to terminate execution. This can be used to implement a timeout,
    which can be required if using a very small hatching distance, especially
    if you get the data from untrusted sources.

    Args:
        baseline: :class:`HatchBaseLine`
        polygons: multiple sequences of :class:`~ezdxf.path.Vec2` instances of
            a single entity, the order of exterior- and hole paths and the
            winding orientation (cw or ccw) of paths is not important
        terminate: callback function which is called periodically and should
            return ``True`` to terminate the hatching function

    """
    yield from _hatch_geometry(baseline, polygons, intersect_polygon, terminate)


def intersect_path(
    baseline: HatchBaseLine, path: Path
) -> Iterator[tuple[Intersection, float]]:
    """Yields all intersection points of the hatch defined by the `baseline` and
    the given single `path`.

    Returns the intersection point and the normal-distance from the baseline,
    intersection points with the same normal-distance lay on the same hatch
    line.

    """
    for path_element in _path_elements(path):
        if isinstance(path_element, Bezier4P):
            distances = [
                baseline.signed_distance(p) for p in path_element.control_points
            ]
            for hatch_line_distance in hatch_line_distances(
                distances, baseline.normal_distance
            ):
                hatch_line = baseline.hatch_line(hatch_line_distance)
                for ip in hatch_line.intersect_cubic_bezier_curve(path_element):
                    yield ip, hatch_line_distance
        else:  # line
            a, b = Vec2.generate(path_element)
            dist_a = baseline.signed_distance(a)
            dist_b = baseline.signed_distance(b)
            for hatch_line_distance in hatch_line_distances(
                (dist_a, dist_b), baseline.normal_distance
            ):
                hatch_line = baseline.hatch_line(hatch_line_distance)
                ip = hatch_line.intersect_line(a, b, dist_a, dist_b)
                if (
                    ip.type != IntersectionType.NONE
                    and ip.type != IntersectionType.COLLINEAR
                ):
                    yield ip, hatch_line_distance


def _path_elements(path: Path) -> Union[Bezier4P, tuple[Vec2, Vec2]]:
    if len(path) == 0:
        return
    start = path.start
    path_start = start
    for command in path.commands():
        end = command.end
        if isinstance(command, MoveTo):
            if not path_start.isclose(start):
                yield start, path_start  # close sub-path
            path_start = end
        elif isinstance(command, LineTo) and not start.isclose(end):
            yield start, end
        elif isinstance(command, Curve4To):
            yield Bezier4P((start, command.ctrl1, command.ctrl2, end))
        elif isinstance(command, Curve3To):
            curve3 = Bezier3P((start, command.ctrl, end))
            yield quadratic_to_cubic_bezier(curve3)
        start = end

    if not path_start.isclose(start):  # close path
        yield start, path_start


def hatch_paths(
    baseline: HatchBaseLine,
    paths: Sequence[Path],
    terminate: Optional[Callable[[], bool]] = None,
) -> Iterator[Line]:
    """Yields all pattern lines for all hatch lines generated by the given
    :class:`HatchBaseLine`, intersecting the given 2D :class:`~ezdxf.path.Path`
    instances as :class:`Line` instances. The paths are handled as projected
    into the xy-plane the z-axis of path vertices will be ignored if present.

    Same as the :func:`hatch_polygons` function, but for :class:`~ezdxf.path.Path`
    instances instead of polygons build of vertices. This function **does not
    flatten** the paths into vertices, instead the real intersections of the
    Bézier curves and the hatch lines are calculated.

    For more information see the docs of the :func:`hatch_polygons` function.

    Args:
        baseline: :class:`HatchBaseLine`
        paths: sequence of :class:`~ezdxf.path.Path` instances of a single
            entity, the order of exterior- and hole paths and the winding
            orientation (cw or ccw) of the paths is not important
        terminate: callback function which is called periodically and should
            return ``True`` to terminate the hatching function

    """
    yield from _hatch_geometry(baseline, paths, intersect_path, terminate)


IFuncType: TypeAlias = Callable[
    [HatchBaseLine, Any], Iterator[Tuple[Intersection, float]]
]


def _hatch_geometry(
    baseline: HatchBaseLine,
    geometries: Sequence[Any],
    intersection_func: IFuncType,
    terminate: Optional[Callable[[], bool]] = None,
) -> Iterator[Line]:
    """Returns all pattern lines intersecting the given geometries.

    The intersection_func() should yield all intersection points between a
    HatchBaseLine() and as given geometry.

    The terminate function should return ``True`` to terminate execution
    otherwise ``False``. Can be used to implement a timeout.

    """
    points: dict[float, list[Intersection]] = defaultdict(list)
    for geometry in geometries:
        if terminate and terminate():
            return
        for ip, distance in intersection_func(baseline, geometry):
            assert ip.type != IntersectionType.NONE
            points[round(distance, KEY_NDIGITS)].append(ip)

    for distance, vertices in points.items():
        if terminate and terminate():
            return
        start = NONE_VEC2
        end = NONE_VEC2
        for line in _line_segments(vertices, distance):
            if start is NONE_VEC2:
                start = line.start
                end = line.end
                continue
            if line.start.isclose(end):
                end = line.end
            else:
                yield Line(start, end, distance)
                start = line.start
                end = line.end

        if start is not NONE_VEC2:
            yield Line(start, end, distance)


def _line_segments(vertices: list[Intersection], distance: float) -> Iterator[Line]:
    if len(vertices) < 2:
        return
    vertices.sort(key=lambda p: p.p0.round(SORT_NDIGITS))
    inside = False
    prev_point = NONE_VEC2
    for ip in vertices:
        if ip.type == IntersectionType.NONE or ip.type == IntersectionType.COLLINEAR:
            continue
        # REGULAR, START, END
        point = ip.p0
        if prev_point is NONE_VEC2:
            inside = True
            prev_point = point
            continue
        if inside:
            yield Line(prev_point, point, distance)

        inside = not inside
        prev_point = point


def hatch_entity(
    polygon: DXFPolygon, filter_text_boxes=True
) -> Iterator[tuple[Vec3, Vec3]]:
    """Yields the hatch pattern of the given HATCH or MPOLYGON entity as 3D lines.
    Each line is a pair of :class:`~ezdxf.math.Vec3` instances as start- and end
    vertex, points are represented as lines of zero length, which means the
    start vertex is equal to the end vertex.

    The function yields nothing if `polygon` has a solid- or gradient filling
    or does not have a usable pattern assigned.

    Args:
        polygon: :class:`~ezdxf.entities.Hatch` or :class:`~ezdxf.entities.MPolygon`
            entity
        filter_text_boxes: ignore text boxes if ``True``

    """
    if polygon.pattern is None or polygon.dxf.solid_fill:
        return
    if len(polygon.pattern.lines) == 0:
        return
    ocs = polygon.ocs()
    elevation = polygon.dxf.elevation.z
    paths = hatch_boundary_paths(polygon, filter_text_boxes)
    # todo: MPOLYGON offset
    # All paths in OCS!
    for baseline in pattern_baselines(polygon):
        for line in hatch_paths(baseline, paths):
            line_pattern = baseline.pattern_renderer(line.distance)
            for s, e in line_pattern.render(line.start, line.end):
                if ocs.transform:
                    yield ocs.to_wcs((s.x, s.y, elevation)), ocs.to_wcs(
                        (e.x, e.y, elevation)
                    )
                yield Vec3(s), Vec3(e)


def hatch_boundary_paths(polygon: DXFPolygon, filter_text_boxes=True) -> list[Path]:
    """Returns the hatch boundary paths as :class:`ezdxf.path.Path` instances
    of HATCH and MPOLYGON entities. Ignores text boxes if argument
    `filter_text_boxes` is ``True``.
    """
    from ezdxf.path import from_hatch_boundary_path

    loops = []
    for boundary in polygon.paths.rendering_paths(polygon.dxf.hatch_style):
        if filter_text_boxes and boundary.path_type_flags & const.BOUNDARY_PATH_TEXTBOX:
            continue
        path = from_hatch_boundary_path(boundary)
        for sub_path in path.sub_paths():
            if len(sub_path):
                sub_path.close()
                loops.append(sub_path)
    return loops


def pattern_baselines(polygon: DXFPolygon) -> Iterator[HatchBaseLine]:
    """Yields the hatch pattern baselines of HATCH and MPOLYGON entities as
    :class:`HatchBaseLine` instances.
    """
    pattern = polygon.pattern
    if not pattern:
        return
    # The hatch pattern parameters are already scaled and rotated for direct
    # usage!
    # The stored scale and angle is just for reconstructing the base pattern
    # when applying a new scaling or rotation.
    for line in pattern.lines:
        direction = Vec2.from_deg_angle(line.angle)
        yield HatchBaseLine(
            line.base_point, direction, line.offset, line.dash_length_items
        )
