# Copyright (c) 2020, Manfred Moitzi
# License: MIT License
from typing import List, TYPE_CHECKING, Iterable, Tuple, Dict, Union, cast, Sequence
from abc import abstractmethod
from collections import namedtuple
import math
from ezdxf.math import (
    Vec2, BSpline, linspace, ConstructionRay, ParallelRaysError, bulge_to_arc,
    ConstructionArc
)

if TYPE_CHECKING:
    from ezdxf.eztypes import Vertex, Drawing, DXFGraphic

__all__ = ['TraceBuilder', 'LinearTrace', 'CurvedTrace']

LinearStation = namedtuple('LinearStation', ('vertex', 'start_width', 'end_width'))
# start_width of the next (following) segment
# end_width of the next (following) segment

CurveStation = namedtuple('CurveStation', ('vertex0', 'vertex1'))

Face = Tuple[Vec2, Vec2, Vec2, Vec2]
Polygon = Sequence[Vec2]
Quadrilateral = Union['Solid', 'Trace', 'Face3d']


class AbstractTrace:
    @abstractmethod
    def faces(self) -> Iterable[Face]:
        # vertex order: up1, down1, down2, up2
        # faces connections:
        # up2 -> next up1
        # down2 -> next down1
        pass

    def polygon(self) -> Polygon:
        def merge(vertices: Polygon) -> Polygon:
            if not len(vertices):
                return

            vertices = iter(vertices)
            prev = next(vertices)
            yield prev
            for vertex in vertices:
                if not prev.isclose(vertex):
                    yield vertex
                    prev = vertex

        forward_contour: List[Vec2] = []
        backward_contour: List[Vec2] = []
        for up1, down1, down2, up2 in self.faces():
            forward_contour.extend((down1, down2))
            backward_contour.extend((up1, up2))

        contour = list(merge(forward_contour))
        contour.extend(reversed(list(merge(backward_contour))))
        return contour

    def virtual_entities(self, dxftype='TRACE', dxfattribs: Dict = None, doc: 'Drawing' = None) -> Quadrilateral:
        """
        Yields faces as SOLID, TRACE or 3DFACE entities with DXF attributes given in `dxfattribs`.

        If a document is given, the doc attribute of the new entities will be set and the new
        entities will be automatically added to the entity database of that document.

        Args:
            dxftype: DXF type as string, "SOLID", "TRACE" or "3DFACE"
            dxfattribs: DXF attributes for SOLID, TRACE or 3DFACE entities
            doc: associated document

        """
        from ezdxf.entities.factory import new

        if dxftype not in {'SOLID', 'TRACE', '3DFACE'}:
            raise TypeError(f'Invalid dxftype {dxftype}.')
        dxfattribs = dxfattribs or {}
        for face in self.faces():
            for i in range(4):
                dxfattribs[f'vtx{i}'] = face[i]

            if dxftype != '3DFACE':
                # weird vertex order for SOLID and TRACE
                dxfattribs['vtx2'] = face[3]
                dxfattribs['vtx3'] = face[2]
            entity = new(dxftype, dxfattribs, doc)
            if doc:
                doc.entitydb.add(entity)
            yield entity


class LinearTrace(AbstractTrace):
    """ Linear 2D banded lines like polylines with start- and end width.

    Accepts 3D input, but z-axis is ignored.

    """

    def __init__(self):
        self._stations: List[LinearStation] = []
        self.abs_tol = 1e-12

    def __len__(self):
        return len(self._stations)

    def __getitem__(self, item):
        return self._stations[item]

    @property
    def is_started(self) -> bool:
        """ `True` if at least one station exist. """
        return bool(self._stations)

    def add_station(self, point: 'Vertex', start_width: float, end_width: float = None) -> None:
        """
        Add a trace station (like a vertex) at location `point`, `start_width` is the width of the next
        segment starting at this station, `end_width` is the end width of the next segment.

        Adding the last location again, replaces the actual last location e.g. adding lines (a, b), (b, c),
        creates only 3 stations (a, b, c), this is very important to connect to/from splines.

        Args:
            point: 2D location (vertex), z-axis of 3D vertices is ignored.
            start_width: start width of next segment
            end_width:  end width of next segment

        """
        if end_width is None:
            end_width = start_width
        point = Vec2(point)
        stations = self._stations

        if bool(stations) and stations[-1].vertex.isclose(point, self.abs_tol):
            # replace last station
            stations.pop()
        stations.append(LinearStation(point, float(start_width), float(end_width)))

    def faces(self) -> Iterable[Face]:
        """ Yields all faces as 4-tuples of :class:`~ezdxf.math.Vec2` objects.

        First and last miter is 90 degrees if the path is not closed, otherwise the
        intersection of first and last segment is taken into account,
        a closed path has to have explicit the same last and first vertex.

        """
        stations = self._stations
        count = len(stations)
        if count < 2:  # Two or more stations required to create faces
            return

        def offset_rays(segment: int) -> Tuple[ConstructionRay, ConstructionRay]:
            """ Create offset rays from segment offset vertices. """

            def ray(v1, v2):
                if v1.isclose(v2):
                    # vertices too close to define a ray, offset ray is parallel to segment:
                    angle = (stations[segment].vertex - stations[segment + 1].vertex).angle
                    return ConstructionRay(v1, angle)
                else:
                    return ConstructionRay(v1, v2)

            left1, left2, right1, right2 = segments[segment]
            return ray(left1, left2), ray(right1, right2)

        def intersect(ray1: ConstructionRay, ray2: ConstructionRay, default: Vec2) -> Vec2:
            """ Intersect two rays but take parallel rays into account. """
            try:
                v = ray1.intersect(ray2)
            except ParallelRaysError:
                v = default
            return v

        # Path has to be explicit closed by vertices:
        is_closed = stations[0].vertex.isclose(stations[-1].vertex)

        segments = []
        # Each segment has 4 offset vertices normal to the line from start- to end vertex
        # 1st vertex left of line at the start, distance = start_width/2
        # 2nd vertex left of line at the end, distance = end_width/2
        # 3rd vertex right of line at the start, distance = start_width/2
        # 4th vertex right of line at the end, distance = end_width/2
        for station in range(count - 1):
            start_vertex, start_width, end_width = stations[station]
            end_vertex = stations[station + 1].vertex
            # Start- and end vertex are never to close together, close stations will be merged
            # in method LinearTrace.add_station().
            segments.append(_normal_offset_points(start_vertex, end_vertex, start_width, end_width))

        # offset rays:
        # 1 is the upper or left of line
        # 2 is the lower or right of line
        offset_ray1, offset_ray2 = offset_rays(0)
        prev_offset_ray1 = None
        prev_offset_ray2 = None

        # Store last vertices explicit, they get modified for closed paths.
        last_up1, last_up2, last_down1, last_down2 = segments[-1]

        for i in range(len(segments)):
            up1, up2, down1, down2 = segments[i]
            if i == 0:
                # Set first vertices of the first face.
                if is_closed:
                    # Compute first two vertices as intersection of first and last segment
                    last_offset_ray1, last_offset_ray2 = offset_rays(len(segments) - 1)
                    vtx0 = intersect(last_offset_ray1, offset_ray1, up1)
                    vtx1 = intersect(last_offset_ray2, offset_ray2, down1)

                    # Store last vertices for the closing face.
                    last_up2 = vtx0
                    last_down2 = vtx1
                else:
                    # Set first two vertices of the first face for an open path.
                    vtx0 = up1
                    vtx1 = down1
                prev_offset_ray1 = offset_ray1
                prev_offset_ray2 = offset_ray2
            else:
                # Compute first two vertices for the actual face.
                vtx0 = intersect(prev_offset_ray1, offset_ray1, up1)
                vtx1 = intersect(prev_offset_ray2, offset_ray2, down1)

            if i < len(segments) - 1:
                # Compute last two vertices for the actual face.
                next_offset_ray1, next_offset_ray2 = offset_rays(i + 1)
                vtx2 = intersect(next_offset_ray2, offset_ray2, down2)
                vtx3 = intersect(next_offset_ray1, offset_ray1, up2)
                prev_offset_ray1 = offset_ray1
                prev_offset_ray2 = offset_ray2
                offset_ray1 = next_offset_ray1
                offset_ray2 = next_offset_ray2
            else:
                # Pickup last two vertices for the last face.
                vtx2 = last_down2
                vtx3 = last_up2
            yield vtx0, vtx1, vtx2, vtx3


def _normal_offset_points(start: Vec2, end: Vec2, start_width: float, end_width: float) -> Face:
    dir_vector = (end - start).normalize()
    ortho = dir_vector.orthogonal(True)
    offset_start = ortho.normalize(start_width / 2)
    offset_end = ortho.normalize(end_width / 2)
    return start + offset_start, end + offset_end, start - offset_start, end - offset_end


_NULLVEC2 = Vec2((0, 0))


class CurvedTrace(AbstractTrace):
    """ 2D banded curves like arcs or splines with start- and end width.

    Represents always only one curved entity and all miter of curve segments
    are perpendicular to curve tangents.

    Accepts 3D input, but z-axis is ignored.

    """

    def __init__(self):
        self._stations: List[CurveStation] = []

    def __len__(self):
        return len(self._stations)

    def __getitem__(self, item):
        return self._stations[item]

    @classmethod
    def from_spline(cls, spline: BSpline, start_width: float, end_width: float, segments: int) -> 'CurvedTrace':
        """
        Create curved trace from a B-spline.

        Args:
            spline: :class:`~ezdxf.math.BSpline` object
            start_width: start width
            end_width: end width
            segments: count of segments for approximation

        """
        curve_trace = cls()
        count = segments + 1
        t = linspace(0, spline.max_t, count)
        for ((point, derivative), width) in zip(spline.derivatives(t, n=1), linspace(start_width, end_width, count)):
            normal = Vec2(derivative).orthogonal(True)
            curve_trace._append(Vec2(point), normal, width)
        return curve_trace

    @classmethod
    def from_arc(cls, arc: ConstructionArc, start_width: float, end_width: float, segments: int = 64) -> 'CurvedTrace':
        """
        Create curved trace from an arc.

        Args:
            arc: :class:`~ezdxf.math.ConstructionArc` object
            start_width: start width
            end_width: end width
            segments: count of segments for full circle (360 degree) approximation, partial arcs have proportional
                      less segments, but at least 3

        Raises:
            ValueError: if arc.radius <= 0

        """
        if arc.radius <= 0:
            raise ValueError(f'Invalid radius: {arc.radius}.')
        curve_trace = cls()
        count = max(math.ceil(arc.angle_span / 360.0 * segments), 3) + 1
        center = Vec2(arc.center)
        for point, width in zip(arc.vertices(arc.angles(count)), linspace(start_width, end_width, count)):
            curve_trace._append(point, point - center, width)
        return curve_trace

    def _append(self, point: Vec2, normal: Vec2, width: float) -> None:
        """
        Add a curve trace station (like a vertex) at location `point`.

        Args:
            point: 2D curve location (vertex), z-axis of 3D vertices is ignored.
            normal: curve normal
            width:  width of station

        """
        if _NULLVEC2.isclose(normal):
            normal = _NULLVEC2
        else:
            normal = normal.normalize(width / 2)
        self._stations.append(CurveStation(point + normal, point - normal))

    def faces(self) -> Iterable[Face]:
        """ Yields all faces as 4-tuples of :class:`~ezdxf.math.Vec2` objects.
        """
        count = len(self._stations)
        if count < 2:  # Two or more stations required to create faces
            return

        vtx0 = None
        vtx1 = None
        for vtx2, vtx3 in self._stations:
            if vtx0 is None:
                vtx0 = vtx3
                vtx1 = vtx2
                continue
            yield vtx0, vtx1, vtx2, vtx3
            vtx0 = vtx3
            vtx1 = vtx2


class TraceBuilder(Sequence):
    """ Sequence of 2D banded lines like polylines with start- and end width or curves with start- and end width.

    Accepts 3D input, but z-axis is ignored.

    """

    def __init__(self):
        self._traces: List[AbstractTrace] = []
        self.abs_tol = 1e-12

    def __len__(self):
        return len(self._traces)

    def __getitem__(self, item):
        return self._traces[item]

    def append(self, trace: AbstractTrace) -> None:
        """ Append a new trace. """
        self._traces.append(trace)

    def faces(self) -> Iterable[Face]:
        """ Yields all faces as 4-tuples of :class:`~ezdxf.math.Vec2` objects.
        """
        for trace in self._traces:
            yield from trace.faces()

    def polygons(self) -> Iterable[Polygon]:
        """ Yields for each sub-trace a single polygon as sequence of :class:`~ezdxf.math.Vec2` objects.
        """
        for trace in self._traces:
            yield trace.polygon()

    def virtual_entities(self, dxftype='TRACE', dxfattribs: Dict = None, doc: 'Drawing' = None) -> Quadrilateral:
        """
        Yields faces as SOLID, TRACE or 3DFACE entities with DXF attributes given in `dxfattribs`.

        If a document is given, the doc attribute of the new entities will be set and the new
        entities will be automatically added to the entity database of that document.

        Args:
            dxftype: DXF type as string, "SOLID", "TRACE" or "3DFACE"
            dxfattribs: DXF attributes for SOLID, TRACE or 3DFACE entities
            doc: associated document

        """
        for trace in self._traces:
            yield from trace.virtual_entities(dxftype, dxfattribs, doc)

    def close(self):
        """ Close multi traces by merging first and last trace, if linear traces. """
        traces = self._traces
        if len(traces) < 2:
            return
        if isinstance(traces[0], LinearTrace) and isinstance(traces[-1], LinearTrace):
            first = cast(LinearTrace, traces.pop(0))
            last = cast(LinearTrace, traces[-1])
            for point, start_width, end_width in first:
                last.add_station(point, start_width, end_width)

    @classmethod
    def from_polyline(cls, polyline: 'DXFGraphic', segments: int = 64) -> 'TraceBuilder':
        """
        Create a complete trace from a LWPOLYLINE or a 2D POLYLINE entity, the trace
        consist of multiple sub-traces if :term:`bulge` values are present.

        Args:
            polyline: :class:`~ezdxf.entities.LWPolyline` or 2D :class:`~ezdxf.entities.Polyline`
            segments: count of segments for bulge approximation, given count is for a full circle,
                      partial arcs have proportional less segments, but at least 3

        """
        dxftype = polyline.dxftype()
        if dxftype == 'LWPOLYLINE':
            polyline = cast('LWPOLYLINE', polyline)
            const_width = polyline.dxf.const_width
            points = []
            for x, y, start_width, end_width, bulge in polyline.lwpoints:
                location = Vec2(x, y)
                if const_width:
                    # This is AutoCAD behavior, BricsCAD uses const width
                    # only for missing width values.
                    start_width = const_width
                    end_width = const_width
                points.append((location, start_width, end_width, bulge))
            closed = polyline.closed
        elif dxftype == 'POLYLINE':
            polyline = cast('POLYLINE', polyline)
            if not polyline.is_2d_polyline:
                raise TypeError('2D POLYLINE required')
            closed = polyline.is_closed
            default_start_width = polyline.dxf.default_start_width
            default_end_width = polyline.dxf.default_end_width
            points = []
            for vertex in polyline.vertices:
                location = Vec2(vertex.dxf.location)
                if vertex.dxf.hasattr('start_width'):
                    start_width = vertex.dxf.start_width
                else:
                    start_width = default_start_width
                if vertex.dxf.hasattr('end_width'):
                    end_width = vertex.dxf.end_width
                else:
                    end_width = default_end_width
                bulge = vertex.dxf.bulge
                points.append((location, start_width, end_width, bulge))
        else:
            raise TypeError(f'Invalid DXF type {dxftype}')

        if closed and not points[0][0].isclose(points[-1][0]):
            # close polyline explicit
            points.append(points[0])

        trace = cls()
        store_bulge = None
        store_start_width = None
        store_end_width = None
        store_point = None

        linear_trace = LinearTrace()
        for point, start_width, end_width, bulge in points:
            if store_bulge:
                center, start_angle, end_angle, radius = bulge_to_arc(store_point, point, store_bulge)
                if radius > 0:
                    arc = ConstructionArc(
                        center,
                        radius,
                        math.degrees(start_angle),
                        math.degrees(end_angle),
                        is_counter_clockwise=True,
                    )
                    if arc.start_point.isclose(point):
                        sw = store_end_width
                        ew = store_start_width
                    else:
                        ew = store_end_width
                        sw = store_start_width
                    trace.append(CurvedTrace.from_arc(arc, sw, ew, segments))
                store_bulge = None

            if bulge != 0:  # arc from prev_point to point
                if linear_trace.is_started:
                    linear_trace.add_station(point, start_width, end_width)
                    trace.append(linear_trace)
                    linear_trace = LinearTrace()
                store_bulge = bulge
                store_start_width = start_width
                store_end_width = end_width
                store_point = point
                continue

            linear_trace.add_station(point, start_width, end_width)
        if linear_trace.is_started:
            trace.append(linear_trace)

        if closed and len(trace) > 1:
            # This is required for traces with multiple paths to create the correct
            # miter at the closing point. (only linear to linear trace).
            trace.close()
        return trace
