# Copyright (c) 2020-2021, Manfred Moitzi
# License: MIT License
from typing import (
    TYPE_CHECKING, List, Iterable, Tuple, Optional, Dict, Sequence,
)

import math
import itertools
from ezdxf.math import (
    Vec3, Z_AXIS, OCS, Matrix44, BoundingBox, ConstructionEllipse,
    cubic_bezier_from_ellipse, Bezier4P, Bezier3P, BSpline,
    reverse_bezier_curves, bulge_to_arc,
)

from ezdxf.query import EntityQuery

from .path import Path
from .commands import Command
from . import converter

if TYPE_CHECKING:
    from ezdxf.eztypes import Vertex, Layout, EntityQuery

__all__ = [
    'bbox', 'fit_paths_into_box', 'transform_paths', 'transform_paths_to_ocs',
    'render_lwpolylines', 'render_polylines2d', 'render_polylines3d',
    'render_lines', 'render_hatches', 'render_splines_and_polylines',
    'add_bezier4p', 'add_bezier3p', 'add_ellipse', 'add_2d_polyline',
    'add_spline'
]

MAX_DISTANCE = 0.01
MIN_SEGMENTS = 4
G1_TOL = 1e-4
IS_CLOSE_TOL = 1e-10


def transform_paths(paths: Iterable[Path], m: Matrix44) -> List[Path]:
    """ Transform multiple :class:`Path` objects at once by transformation
    matrix `m`. Returns a list of the transformed :class:`Path` objects.

    Args:
        paths: iterable of :class:`Path` objects
        m: transformation matrix of type :class:`~ezdxf.math.Matrix44`

    """

    def decompose(path: Path):
        vertices.append(path.start)
        commands.append(Command.START_PATH)
        for cmd in path:
            commands.extend(itertools.repeat(cmd.type, len(cmd)))
            vertices.extend(cmd)

    def rebuild(vertices):
        path = None
        collect = []
        for vertex, cmd in zip(vertices, commands):
            if cmd == Command.START_PATH:
                if path is not None:
                    transformed_paths.append(path)
                path = Path(vertex)
            elif cmd == Command.LINE_TO:
                path.line_to(vertex)
            elif cmd == Command.CURVE3_TO:
                collect.append(vertex)
                if len(collect) == 2:
                    path.curve3_to(collect[0], collect[1])
                    collect.clear()
            elif cmd == Command.CURVE4_TO:
                collect.append(vertex)
                if len(collect) == 3:
                    path.curve4_to(collect[0], collect[1], collect[2])
                    collect.clear()
        if path is not None:
            transformed_paths.append(path)

    vertices = []
    commands = []
    transformed_paths = []

    for path in paths:
        decompose(path)
    if len(commands):
        rebuild(m.transform_vertices(vertices))
    return transformed_paths


def transform_paths_to_ocs(paths: Iterable[Path], ocs: OCS) -> List[Path]:
    """ Transform multiple :class:`Path` objects at once from WCS to OCS.
    Returns a list of the transformed :class:`Path` objects.

    Args:
        paths: iterable of :class:`Path` objects
        ocs: OCS transformation of type :class:`~ezdxf.math.OCS`

    """
    t = ocs.matrix.copy()
    t.transpose()
    return transform_paths(paths, t)


def bbox(paths: Iterable[Path], flatten=0.01,
         segments: int = 16) -> BoundingBox:
    """ Returns the :class:`~ezdxf.math.BoundingBox` for the given paths.

    Args:
        paths: iterable of :class:`~ezdxf.path.Path` objects
        flatten: value != 0  for bounding box calculation from the flattened
        path and value == 0 for bounding box from the control vertices.
        Default value is 0.01 as max flattening distance.
        segments: minimal segment count for flattening

    """
    box = BoundingBox()
    for p in paths:
        if flatten:
            box.extend(p.flattening(distance=abs(flatten), segments=segments))
        else:
            box.extend(p.control_vertices())
    return box


def fit_paths_into_box(paths: Iterable[Path],
                       size: Tuple[float, float, float],
                       uniform: bool = True,
                       source_box: BoundingBox = None) -> List[Path]:
    """ Scale the given `paths` to fit into a box of the given `size`,
    so that all path vertices are inside this borders.
    If `source_box` is ``None`` the default source bounding box is calculated
    from the control points of the `paths`.

    `Note:` if the target size has a z-size of 0, the `paths` are
    projected into the xy-plane, same is true for the x-size, projects into
    the yz-plane and the y-size, projects into and xz-plane.

    Args:
        paths: iterable of :class:`~ezdxf.path.Path` objects
        size: target box size as tuple of x-, y- ond z-size values
        uniform: ``True`` for uniform scaling
        source_box: pass precalculated source bounding box, or ``None`` to
            calculate the default source bounding box from the control vertices

    """
    paths = list(paths)
    if len(paths) == 0:
        return paths
    if source_box is None:
        current_box = bbox(paths, flatten=0)
    else:
        current_box = source_box
    if not current_box.has_data or current_box.size == (0, 0, 0):
        return paths
    target_size = Vec3(size)
    if target_size == (0, 0, 0) or min(target_size) < 0:
        raise ValueError('invalid target size')

    if uniform:
        sx, sy, sz = _get_uniform_scaling(current_box.size, target_size)
    else:
        sx, sy, sz = _get_non_uniform_scaling(current_box.size, target_size)
    m = Matrix44.scale(sx, sy, sz)
    return transform_paths(paths, m)


def _get_uniform_scaling(current_size: Vec3, target_size: Vec3):
    TOL = 1e-6
    scale_x = math.inf
    if current_size.x > TOL and target_size.x > TOL:
        scale_x = target_size.x / current_size.x
    scale_y = math.inf
    if current_size.y > TOL and target_size.y > TOL:
        scale_y = target_size.y / current_size.y
    scale_z = math.inf
    if current_size.z > TOL and target_size.z > TOL:
        scale_z = target_size.z / current_size.z

    uniform_scale = min(scale_x, scale_y, scale_z)
    if uniform_scale is math.inf:
        raise ArithmeticError('internal error')
    scale_x = uniform_scale if target_size.x > TOL else 0
    scale_y = uniform_scale if target_size.y > TOL else 0
    scale_z = uniform_scale if target_size.z > TOL else 0
    return scale_x, scale_y, scale_z


def _get_non_uniform_scaling(current_size: Vec3, target_size: Vec3):
    TOL = 1e-6
    scale_x = 1.0
    if current_size.x > TOL:
        scale_x = target_size.x / current_size.x
    scale_y = 1.0
    if current_size.y > TOL:
        scale_y = target_size.y / current_size.y
    scale_z = 1.0
    if current_size.z > TOL:
        scale_z = target_size.z / current_size.z
    return scale_x, scale_y, scale_z


# Path to entity converter and render utilities:


def render_lwpolylines(
        layout: 'Layout',
        paths: Iterable[Path],
        *,
        distance: float = MAX_DISTANCE,
        segments: int = MIN_SEGMENTS,
        extrusion: 'Vertex' = Z_AXIS,
        dxfattribs: Optional[Dict] = None) -> EntityQuery:
    """ Render the given `paths` into `layout` as
    :class:`~ezdxf.entities.LWPolyline` entities.
    The `extrusion` vector is applied to all paths, all vertices are projected
    onto the plane normal to this extrusion vector. The default extrusion vector
    is the WCS z-axis. The plane elevation is the distance from the WCS origin
    to the start point of the first path.

    Args:
        layout: the modelspace, a paperspace layout or a block definition
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve
        extrusion: extrusion vector for all paths
        dxfattribs: additional DXF attribs

    Returns:
        created entities in an :class:`~ezdxf.query.EntityQuery` object

    .. versionadded:: 0.16

    """
    lwpolylines = list(converter.to_lwpolylines(
        paths,
        distance=distance,
        segments=segments,
        extrusion=extrusion,
        dxfattribs=dxfattribs,
    ))
    for lwpolyline in lwpolylines:
        layout.add_entity(lwpolyline)
    return EntityQuery(lwpolylines)


def render_polylines2d(
        layout: 'Layout',
        paths: Iterable[Path],
        *,
        distance: float = 0.01,
        segments: int = 4,
        extrusion: 'Vertex' = Z_AXIS,
        dxfattribs: Optional[Dict] = None) -> EntityQuery:
    """ Render the given `paths` into `layout` as 2D
    :class:`~ezdxf.entities.Polyline` entities.
    The `extrusion` vector is applied to all paths, all vertices are projected
    onto the plane normal to this extrusion vector.The default extrusion vector
    is the WCS z-axis. The plane elevation is the distance from the WCS origin
    to the start point of the first path.

    Args:
        layout: the modelspace, a paperspace layout or a block definition
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve
        extrusion: extrusion vector for all paths
        dxfattribs: additional DXF attribs

    Returns:
        created entities in an :class:`~ezdxf.query.EntityQuery` object

    .. versionadded:: 0.16

    """
    polylines2d = list(converter.to_polylines2d(
        paths,
        distance=distance,
        segments=segments,
        extrusion=extrusion,
        dxfattribs=dxfattribs,
    ))
    for polyline2d in polylines2d:
        layout.add_entity(polyline2d)
    return EntityQuery(polylines2d)


def render_hatches(
        layout: 'Layout',
        paths: Iterable[Path],
        *,
        edge_path: bool = True,
        distance: float = MAX_DISTANCE,
        segments: int = MIN_SEGMENTS,
        g1_tol: float = G1_TOL,
        extrusion: 'Vertex' = Z_AXIS,
        dxfattribs: Optional[Dict] = None) -> EntityQuery:
    """ Render the given `paths` into `layout` as
    :class:`~ezdxf.entities.Hatch` entities.
    The `extrusion` vector is applied to all paths, all vertices are projected
    onto the plane normal to this extrusion vector. The default extrusion vector
    is the WCS z-axis. The plane elevation is the distance from the WCS origin
    to the start point of the first path.

    Args:
        layout: the modelspace, a paperspace layout or a block definition
        paths: iterable of :class:`Path` objects
        edge_path: ``True`` for edge paths build of LINE and SPLINE edges,
            ``False`` for only LWPOLYLINE paths as boundary paths
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve to flatten LWPOLYLINE paths
        g1_tol: tolerance for G1 continuity check to separate SPLINE edges
        extrusion: extrusion vector for all paths
        dxfattribs: additional DXF attribs

    Returns:
        created entities in an :class:`~ezdxf.query.EntityQuery` object

    .. versionadded:: 0.16

    """
    hatches = list(converter.to_hatches(
        paths,
        edge_path=edge_path,
        distance=distance,
        segments=segments,
        g1_tol=g1_tol,
        extrusion=extrusion,
        dxfattribs=dxfattribs,
    ))
    for hatch in hatches:
        layout.add_entity(hatch)
    return EntityQuery(hatches)


def render_polylines3d(
        layout: 'Layout',
        paths: Iterable[Path],
        *,
        distance: float = MAX_DISTANCE,
        segments: int = MIN_SEGMENTS,
        dxfattribs: Optional[Dict] = None) -> EntityQuery:
    """ Render the given `paths` into `layout` as 3D
    :class:`~ezdxf.entities.Polyline` entities.

    Args:
        layout: the modelspace, a paperspace layout or a block definition
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve
        dxfattribs: additional DXF attribs

    Returns:
        created entities in an :class:`~ezdxf.query.EntityQuery` object

    .. versionadded:: 0.16

    """

    polylines3d = list(converter.to_polylines3d(
        paths,
        distance=distance,
        segments=segments,
        dxfattribs=dxfattribs,
    ))
    for polyline3d in polylines3d:
        layout.add_entity(polyline3d)
    return EntityQuery(polylines3d)


def render_lines(
        layout: 'Layout',
        paths: Iterable[Path],
        *,
        distance: float = MAX_DISTANCE,
        segments: int = MIN_SEGMENTS,
        dxfattribs: Optional[Dict] = None) -> EntityQuery:
    """ Render the given `paths` into `layout` as
    :class:`~ezdxf.entities.Line` entities.

    Args:
        layout: the modelspace, a paperspace layout or a block definition
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve
        dxfattribs: additional DXF attribs

    Returns:
        created entities in an :class:`~ezdxf.query.EntityQuery` object

    .. versionadded:: 0.16

    """
    lines = list(converter.to_lines(
        paths,
        distance=distance,
        segments=segments,
        dxfattribs=dxfattribs,
    ))
    for line in lines:
        layout.add_entity(line)
    return EntityQuery(lines)


def render_splines_and_polylines(
        layout: 'Layout',
        paths: Iterable[Path],
        *,
        g1_tol: float = G1_TOL,
        dxfattribs: Optional[Dict] = None) -> EntityQuery:
    """ Render the given `paths` into `layout` as :class:`~ezdxf.entities.Spline`
    and 3D :class:`~ezdxf.entities.Polyline` entities.

    Args:
        layout: the modelspace, a paperspace layout or a block definition
        paths: iterable of :class:`Path` objects
        g1_tol: tolerance for G1 continuity check
        dxfattribs: additional DXF attribs

    Returns:
        created entities in an :class:`~ezdxf.query.EntityQuery` object

    .. versionadded:: 0.16

    """
    entities = list(converter.to_splines_and_polylines(
        paths,
        g1_tol=g1_tol,
        dxfattribs=dxfattribs,
    ))
    for entity in entities:
        layout.add_entity(entity)
    return EntityQuery(entities)


def add_ellipse(path: Path, ellipse: ConstructionEllipse, segments=1,
                reset=True) -> None:
    """ Add an elliptical arc as multiple cubic Bèzier-curves to the given
    `path`, use :meth:`~ezdxf.math.ConstructionEllipse.from_arc` constructor
    of class :class:`~ezdxf.math.ConstructionEllipse` to add circular arcs.

    Auto-detect the connection point to the given `path`, if neither the start-
    nor the end point of the ellipse is close to the path end point, a line from
    the path end point to the ellipse start point will be added automatically
    (see :func:`add_bezier4p`).

    By default the start of an **empty** path is set to the start point of
    the ellipse, setting argument `reset` to ``False`` prevents this
    behavior.

    Args:
        path: :class:`~ezdxf.path.Path` object
        ellipse: ellipse parameters as :class:`~ezdxf.math.ConstructionEllipse`
            object
        segments: count of Bèzier-curve segments, at least one segment for
            each quarter (pi/2), ``1`` for as few as possible.
        reset: set start point to start of ellipse if path is empty

    """
    if abs(ellipse.param_span) < 1e-9:
        return
    if len(path) == 0 and reset:
        path.start = ellipse.start_point
    add_bezier4p(path, cubic_bezier_from_ellipse(ellipse, segments))


def add_bezier4p(path: Path, curves: Iterable[Bezier4P]) -> None:
    """ Add multiple cubic Bèzier-curves to the given `path`.

    Auto-detect the connection point to the given `path`, if neither the start-
    nor the end point of the curves is close to the path end point, a line from
    the path end point to the start point of the first curve will be added
    automatically.

    .. versionchanged:: 0.16.2

        add linear Bézier curve segments as LINE_TO commands

    """
    curves = list(curves)
    if not len(curves):
        return
    end = curves[-1].control_points[-1]
    if path.end.isclose(end):
        # connect to new curves end point
        curves = reverse_bezier_curves(curves)

    for curve in curves:
        start, ctrl1, ctrl2, end = curve.control_points
        if not start.isclose(path.end):
            path.line_to(start)

        # add linear bezier segments as LINE_TO commands
        if start.isclose(ctrl1) and \
                end.isclose(ctrl2):
            path.line_to(end)
        else:
            path.curve4_to(end, ctrl1, ctrl2)


def add_bezier3p(path: Path, curves: Iterable[Bezier3P]) -> None:
    """ Add multiple quadratic Bèzier-curves to the given `path`.

    Auto-detect the connection point to the given `path`, if neither the start-
    nor the end point of the curves is close to the path end point, a line from
    the path end point to the start point of the first curve will be added
    automatically.

    .. versionchanged:: 0.16.2

        add linear Bézier curve segments as LINE_TO commands

    """
    curves = list(curves)
    if not len(curves):
        return
    end = curves[-1].control_points[-1]
    if path.end.isclose(end):
        # connect to new curves end point
        curves = reverse_bezier_curves(curves)

    for curve in curves:
        start, ctrl, end = curve.control_points
        if not start.isclose(path.end, abs_tol=0):  # only rel_tol=1e-9
            path.line_to(start)

        # add linear bezier segments as LINE_TO commands, use only rel_tol=1e-9
        if start.isclose(ctrl, abs_tol=0) or end.isclose(ctrl, abs_tol=0):
            path.line_to(end)
        else:
            path.curve3_to(end, ctrl)


def add_2d_polyline(path: Path, points: Iterable[Sequence[float]], close: bool,
                    ocs: OCS, elevation: float) -> None:
    """ Internal API to add 2D polylines which may include bulges to an
    **empty** path.

    """

    def bulge_to(p1: Vec3, p2: Vec3, bulge: float):
        if p1.isclose(p2, rel_tol=IS_CLOSE_TOL, abs_tol=0):
            return
        center, start_angle, end_angle, radius = bulge_to_arc(p1, p2, bulge)
        ellipse = ConstructionEllipse.from_arc(
            center, radius, Z_AXIS,
            math.degrees(start_angle),
            math.degrees(end_angle),
        )
        curves = list(cubic_bezier_from_ellipse(ellipse))
        curve0 = curves[0]
        cp0 = curve0.control_points[0]
        if cp0.isclose(p2, rel_tol=IS_CLOSE_TOL, abs_tol=0):
            curves = reverse_bezier_curves(curves)
        add_bezier4p(path, curves)

    if len(path):
        raise ValueError('Requires an empty path.')

    prev_point = None
    prev_bulge = 0
    for x, y, bulge in points:
        # Bulge values near 0 but != 0 cause crashes! #329
        if abs(bulge) < 1e-6:
            bulge = 0
        point = Vec3(x, y)
        if prev_point is None:
            path.start = point
            prev_point = point
            prev_bulge = bulge
            continue

        if prev_bulge:
            bulge_to(prev_point, point, prev_bulge)
        else:
            path.line_to(point)
        prev_point = point
        prev_bulge = bulge

    if close and not path.start.isclose(
            path.end, rel_tol=IS_CLOSE_TOL, abs_tol=0):
        if prev_bulge:
            bulge_to(path.end, path.start, prev_bulge)
        else:
            path.line_to(path.start)

    if ocs.transform or elevation:
        path.to_wcs(ocs, elevation)


def add_spline(path: Path, spline: BSpline, level=4, reset=True) -> None:
    """ Add a B-spline as multiple cubic Bèzier-curves.

    Non-rational B-splines of 3rd degree gets a perfect conversion to
    cubic bezier curves with a minimal count of curve segments, all other
    B-spline require much more curve segments for approximation.

    Auto-detect the connection point to the given `path`, if neither the start-
    nor the end point of the B-spline is close to the path end point, a line
    from the path end point to the start point of the B-spline will be added
    automatically. (see :meth:`add_bezier4p`).

    By default the start of an **empty** path is set to the start point of
    the spline, setting argument `reset` to ``False`` prevents this
    behavior.

    Args:
        path: :class:`~ezdxf.path.Path` object
        spline: B-spline parameters as :class:`~ezdxf.math.BSpline` object
        level: subdivision level of approximation segments
        reset: set start point to start of spline if path is empty

    """
    if len(path) == 0 and reset:
        path.start = spline.point(0)
    if spline.degree == 3 and not spline.is_rational and spline.is_clamped:
        curves = [Bezier4P(points) for points in
                  spline.bezier_decomposition()]
    else:
        curves = spline.cubic_bezier_approximation(level=level)
    add_bezier4p(path, curves)
