# Copyright (c) 2012-2024, Manfred Moitzi
# License: MIT License
"""
B-Splines
=========

https://www.cl.cam.ac.uk/teaching/2000/AGraphHCI/SMEG/node4.html

Rational B-splines
==================

https://www.cl.cam.ac.uk/teaching/2000/AGraphHCI/SMEG/node5.html:

"The NURBS Book" by Les Piegl and Wayne Tiller

https://books.google.at/books/about/The_NURBS_Book.html?id=7dqY5dyAwWkC&redir_esc=y

"""
from __future__ import annotations
from typing import (
    Iterable,
    Iterator,
    Sequence,
    TYPE_CHECKING,
    Optional,
)
import math
import numpy as np

from ezdxf.math import (
    Vec3,
    UVec,
    NULLVEC,
    Basis,
    Evaluator,
    create_t_vector,
    estimate_end_tangent_magnitude,
    distance_point_line_3d,
    arc_angle_span_deg,
)
from ezdxf.math import linalg
from ezdxf.lldxf.const import DXFValueError


if TYPE_CHECKING:
    from ezdxf.math import (
        ConstructionArc,
        ConstructionEllipse,
        Matrix44,
        Bezier4P,
    )


__all__ = [
    # High level functions:
    "fit_points_to_cad_cv",
    "global_bspline_interpolation",
    "local_cubic_bspline_interpolation",
    "rational_bspline_from_arc",
    "rational_bspline_from_ellipse",
    "fit_points_to_cubic_bezier",
    "open_uniform_bspline",
    "closed_uniform_bspline",
    # B-spline representation with derivatives support:
    "BSpline",
    # Low level interpolation function:
    "unconstrained_global_bspline_interpolation",
    "global_bspline_interpolation_end_tangents",
    "cad_fit_point_interpolation",
    "global_bspline_interpolation_first_derivatives",
    "local_cubic_bspline_interpolation_from_tangents",
    # Low level knot parametrization functions:
    "knots_from_parametrization",
    "averaged_knots_unconstrained",
    "averaged_knots_constrained",
    "natural_knots_unconstrained",
    "natural_knots_constrained",
    "double_knots",
    # Low level knot function:
    "required_knot_values",
    "uniform_knot_vector",
    "open_uniform_knot_vector",
    "required_fit_points",
    "required_control_points",
]


def fit_points_to_cad_cv(
    fit_points: Iterable[UVec],
    tangents: Optional[Iterable[UVec]] = None,
) -> BSpline:
    """Returns a cubic :class:`BSpline` from fit points as close as possible
    to common CAD applications like BricsCAD.

    There exist infinite numerical correct solution for this setup, but some
    facts are known:

    - CAD applications use the global curve interpolation with start- and end
      derivatives if the end tangents are defined otherwise the equation system will
      be completed by setting the second derivatives of the start and end point to 0,
      for more information read this answer on stackoverflow: https://stackoverflow.com/a/74863330/6162864
    - The degree of the B-spline is always 3 regardless which degree is stored in the
      SPLINE entity, this is only valid for B-splines defined by fit points
    - Knot parametrization method is "chord"
    - Knot distribution is "natural"

    Args:
        fit_points: points the spline is passing through
        tangents: start- and end tangent, default is autodetect

    """
    # See also Spline class in ezdxf/entities/spline.py:
    points = Vec3.list(fit_points)
    if len(points) < 2:
        raise ValueError("two or more points required ")

    if tangents is None:
        control_points, knots = cad_fit_point_interpolation(points)
        return BSpline(control_points, order=4, knots=knots)
    t = Vec3.list(tangents)
    m1, m2 = estimate_end_tangent_magnitude(points, method="chord")
    start_tangent = t[0].normalize(m1)
    end_tangent = t[-1].normalize(m2)

    return global_bspline_interpolation(
        points,
        degree=3,
        tangents=(start_tangent, end_tangent),
        method="chord",
    )


def fit_points_to_cubic_bezier(fit_points: Iterable[UVec]) -> BSpline:
    """Returns a cubic :class:`BSpline` from fit points **without** end
    tangents.

    This function uses the cubic Bèzier interpolation to create multiple Bèzier
    curves and combine them into a single B-spline, this works for short simple
    splines better than the :func:`fit_points_to_cad_cv`, but is worse
    for longer and more complex splines.

    Args:
        fit_points: points the spline is passing through

    """
    points = Vec3.list(fit_points)
    if len(points) < 2:
        raise ValueError("two or more points required ")

    from ezdxf.math import cubic_bezier_interpolation, bezier_to_bspline

    bezier_curves = cubic_bezier_interpolation(points)
    return bezier_to_bspline(bezier_curves)


def global_bspline_interpolation(
    fit_points: Iterable[UVec],
    degree: int = 3,
    tangents: Optional[Iterable[UVec]] = None,
    method: str = "chord",
) -> BSpline:
    """`B-spline`_ interpolation by the `Global Curve Interpolation`_.
    Given are the fit points and the degree of the B-spline.
    The function provides 3 methods for generating the parameter vector t:

    - "uniform": creates a uniform t vector, from 0 to 1 evenly spaced, see
      `uniform`_ method
    - "chord", "distance": creates a t vector with values proportional to the
      fit point distances, see `chord length`_ method
    - "centripetal", "sqrt_chord": creates a t vector with values proportional
      to the fit point sqrt(distances), see `centripetal`_ method
    - "arc": creates a t vector with values proportional to the arc length
      between fit points.

    It is possible to constraint the curve by tangents, by start- and end
    tangent if only two tangents are given or by one tangent for each fit point.

    If tangents are given, they represent 1st derivatives and should be
    scaled if they are unit vectors, if only start- and end tangents given the
    function :func:`~ezdxf.math.estimate_end_tangent_magnitude` helps with an
    educated guess, if all tangents are given, scaling by chord length is a
    reasonable choice (Piegl & Tiller).

    Args:
        fit_points: fit points of B-spline, as list of :class:`Vec3` compatible
            objects
        tangents: if only two vectors are given, take the first and the last
            vector as start- and end tangent constraints or if for all fit
            points a tangent is given use all tangents as interpolation
            constraints (optional)
        degree: degree of B-spline
        method: calculation method for parameter vector t

    Returns:
        :class:`BSpline`

    """
    _fit_points = Vec3.list(fit_points)
    count = len(_fit_points)
    order: int = degree + 1

    if tangents:
        # two control points for tangents will be added
        count += 2
    if order > count and tangents is None:
        raise ValueError(f"More fit points required for degree {degree}")

    t_vector = list(create_t_vector(_fit_points, method))
    # natural knot generation for uneven degrees else averaged
    knot_generation_method = "natural" if degree % 2 else "average"
    if tangents is not None:
        _tangents = Vec3.list(tangents)
        if len(_tangents) == 2:
            control_points, knots = global_bspline_interpolation_end_tangents(
                _fit_points,
                _tangents[0],
                _tangents[1],
                degree,
                t_vector,
                knot_generation_method,
            )
        elif len(_tangents) == len(_fit_points):
            (
                control_points,
                knots,
            ) = global_bspline_interpolation_first_derivatives(
                _fit_points, _tangents, degree, t_vector
            )
        else:
            raise ValueError(
                "Invalid count of tangents, two tangents as start- and end "
                "tangent constrains or one tangent for each fit point."
            )
    else:
        control_points, knots = unconstrained_global_bspline_interpolation(
            _fit_points, degree, t_vector, knot_generation_method
        )
    bspline = BSpline(control_points, order=order, knots=knots)
    return bspline


def local_cubic_bspline_interpolation(
    fit_points: Iterable[UVec],
    method: str = "5-points",
    tangents: Optional[Iterable[UVec]] = None,
) -> BSpline:
    """`B-spline`_ interpolation by 'Local Cubic Curve Interpolation', which
    creates B-spline from fit points and estimated tangent direction at start-,
    end- and passing points.

    Source: Piegl & Tiller: "The NURBS Book" - chapter 9.3.4

    Available tangent estimation methods:

    - "3-points": 3 point interpolation
    - "5-points": 5 point interpolation
    - "bezier": cubic bezier curve interpolation
    - "diff": finite difference

    or pass pre-calculated tangents, which overrides tangent estimation.

    Args:
        fit_points: all B-spline fit points as :class:`Vec3` compatible objects
        method: tangent estimation method
        tangents: tangents as :class:`Vec3` compatible objects (optional)

    Returns:
        :class:`BSpline`

    """
    from .parametrize import estimate_tangents

    _fit_points = Vec3.list(fit_points)
    if tangents:
        _tangents = Vec3.list(tangents)
    else:
        _tangents = estimate_tangents(_fit_points, method)
    control_points, knots = local_cubic_bspline_interpolation_from_tangents(
        _fit_points, _tangents
    )
    return BSpline(control_points, order=4, knots=knots)


def required_knot_values(count: int, order: int) -> int:
    """Returns the count of required knot-values for a B-spline of `order` and
    `count` control points.

    Args:
        count: count of control points, in text-books referred as "n + 1"
        order: order of B-Spline, in text-books referred as "k"

    Relationship:

    "p" is the degree of the B-spline, text-book notation.

    - k = p + 1
    - 2 ≤ k ≤ n + 1

    """
    k = int(order)
    n = int(count) - 1
    p = k - 1
    if not (2 <= k <= (n + 1)):
        raise DXFValueError("Invalid count/order combination")
    # n + p + 2 = count + order
    return n + p + 2


def required_fit_points(order: int, tangents=True) -> int:
    """Returns the count of required fit points to calculate the spline
    control points.

    Args:
        order: spline order (degree + 1)
        tangents: start- and end tangent are given or estimated

    """
    if tangents:
        # If tangents are given or estimated two points for start- and end
        # tangent will be added automatically for the global bspline
        # interpolation. see function fit_points_to_cad_cv()
        order -= 2
    # required condition: order > count, see global_bspline_interpolation()
    return max(order, 2)


def required_control_points(order: int) -> int:
    """Returns the required count of control points for a valid B-spline.

    Args:
        order: spline order (degree + 1)

    Required condition: 2 <= order <= count, therefore:  count >= order

    """
    return max(order, 2)


def normalize_knots(knots: Sequence[float]) -> list[float]:
    """Normalize knot vector into range [0, 1]."""
    min_val = knots[0]
    max_val = knots[-1] - min_val
    return [(v - min_val) / max_val for v in knots]


def uniform_knot_vector(count: int, order: int, normalize=False) -> list[float]:
    """Returns an uniform knot vector for a B-spline of `order` and `count`
    control points.

    `order` = degree + 1

    Args:
        count: count of control points
        order: spline order
        normalize: normalize values in range [0, 1] if ``True``

    """
    if normalize:
        max_value = float(count + order - 1)
    else:
        max_value = 1.0
    return [knot_value / max_value for knot_value in range(count + order)]


def open_uniform_knot_vector(count: int, order: int, normalize=False) -> list[float]:
    """Returns an open (clamped) uniform knot vector for a B-spline of `order`
    and `count` control points.

    `order` = degree + 1

    Args:
        count: count of control points
        order: spline order
        normalize: normalize values in range [0, 1] if ``True``

    """
    k = count - order
    if normalize:
        max_value = float(count - order + 1)
        tail = [1.0] * order
    else:
        max_value = 1.0
        tail = [1.0 + k] * order

    knots = [0.0] * order
    knots.extend((1.0 + v) / max_value for v in range(k))
    knots.extend(tail)
    return knots


def knots_from_parametrization(
    n: int, p: int, t: Iterable[float], method="average", constrained=False
) -> list[float]:
    """Returns a 'clamped' knot vector for B-splines. All knot values are
    normalized in the range [0, 1].

    Args:
        n: count fit points - 1
        p: degree of spline
        t: parametrization vector, length(t_vector) == n, normalized [0, 1]
        method: "average", "natural"
        constrained: ``True`` for B-spline constrained by end derivatives

    Returns:
        List of n+p+2 knot values as floats

    """
    order = int(p + 1)
    if order > (n + 1):
        raise DXFValueError("Invalid n/p combination, more fit points required.")

    t = [float(v) for v in t]
    if t[0] != 0.0 or not math.isclose(t[-1], 1.0):
        raise ValueError("Parametrization vector t has to be normalized.")

    if method == "average":
        return (
            averaged_knots_constrained(n, p, t)
            if constrained
            else averaged_knots_unconstrained(n, p, t)
        )
    elif method == "natural":
        return (
            natural_knots_constrained(n, p, t)
            if constrained
            else natural_knots_unconstrained(n, p, t)
        )
    else:
        raise ValueError(f"Unknown knot generation method: {method}")


def averaged_knots_unconstrained(n: int, p: int, t: Sequence[float]) -> list[float]:
    """Returns an averaged knot vector from parametrization vector `t` for an
    unconstrained B-spline.

    Args:
        n: count of control points - 1
        p: degree
        t: parametrization vector, normalized [0, 1]

    """
    assert t[0] == 0.0
    assert math.isclose(t[-1], 1.0)

    knots = [0.0] * (p + 1)
    knots.extend(sum(t[j : j + p]) / p for j in range(1, n - p + 1))
    if knots[-1] > 1.0:
        raise ValueError("Normalized [0, 1] values required")
    knots.extend([1.0] * (p + 1))
    return knots


def averaged_knots_constrained(n: int, p: int, t: Sequence[float]) -> list[float]:
    """Returns an averaged knot vector from parametrization vector `t` for a
    constrained B-spline.

    Args:
        n: count of control points - 1
        p: degree
        t: parametrization vector, normalized [0, 1]

    """
    assert t[0] == 0.0
    assert math.isclose(t[-1], 1.0)

    knots = [0.0] * (p + 1)
    knots.extend(sum(t[j : j + p - 1]) / p for j in range(n - p))
    knots.extend([1.0] * (p + 1))
    return knots


def natural_knots_unconstrained(n: int, p: int, t: Sequence[float]) -> list[float]:
    """Returns a 'natural' knot vector from parametrization vector `t` for an
    unconstrained B-spline.

    Args:
        n: count of control points - 1
        p: degree
        t: parametrization vector, normalized [0, 1]

    """
    assert t[0] == 0.0
    assert math.isclose(t[-1], 1.0)

    knots = [0.0] * (p + 1)
    knots.extend(t[2 : n - p + 2])
    knots.extend([1.0] * (p + 1))
    return knots


def natural_knots_constrained(n: int, p: int, t: Sequence[float]) -> list[float]:
    """Returns a 'natural' knot vector from parametrization vector `t` for a
    constrained B-spline.

    Args:
        n: count of control points - 1
        p: degree
        t: parametrization vector, normalized [0, 1]

    """
    assert t[0] == 0.0
    assert math.isclose(t[-1], 1.0)

    knots = [0.0] * (p + 1)
    knots.extend(t[1 : n - p + 1])
    knots.extend([1.0] * (p + 1))
    return knots


def double_knots(n: int, p: int, t: Sequence[float]) -> list[float]:
    """Returns a knot vector from parametrization vector `t` for B-spline
    constrained by first derivatives at all fit points.

    Args:
        n: count of fit points - 1
        p: degree of spline
        t: parametrization vector, first value has to be 0.0 and last value has
            to be 1.0

    """
    assert t[0] == 0.0
    assert math.isclose(t[-1], 1.0)

    u = [0.0] * (p + 1)
    prev_t = 0.0

    u1 = []
    for t1 in t[1:-1]:
        if p == 2:
            # add one knot between prev_t and t
            u1.append((prev_t + t1) / 2.0)
            u1.append(t1)
        else:
            if prev_t == 0.0:  # first knot
                u1.append(t1 / 2)
            else:
                # add one knot at the 1st third and one knot
                # at the 2nd third between prev_t and t.
                u1.append((2 * prev_t + t1) / 3.0)
                u1.append((prev_t + 2 * t1) / 3.0)
        prev_t = t1
    u.extend(u1[: n * 2 - p])
    u.append((t[-2] + 1.0) / 2.0)  # last knot
    u.extend([1.0] * (p + 1))
    return u


def _get_best_solver(matrix: list| linalg.Matrix, degree: int) -> linalg.Solver:
    """Returns best suited linear equation solver depending on matrix configuration and 
    python interpreter.
    """
    # v1.2: added NumpySolver
    #   Acceleration of banded diagonal matrix solver is still a thing but only for 
    #   really big matrices N > 30 in pure Python and N > 20 for C-extension np_support
    # PyPy has no advantages when using the NumpySolver
    if not isinstance(matrix, linalg.Matrix):
        matrix =linalg.Matrix(matrix)

    if matrix.nrows < 20:  # use default equation solver
        return linalg.NumpySolver(matrix.matrix)
    else:
        # Theory: band parameters m1, m2 are at maximum degree-1, for
        # B-spline interpolation and approximation:
        # m1 = m2 = degree-1
        # But the speed gain is not that big and just to be sure:
        m1, m2 = linalg.detect_banded_matrix(matrix, check_all=False)
        A = linalg.compact_banded_matrix(matrix, m1, m2)
        return linalg.BandedMatrixLU(A, m1, m2)


def unconstrained_global_bspline_interpolation(
    fit_points: Sequence[UVec],
    degree: int,
    t_vector: Sequence[float],
    knot_generation_method: str = "average",
) -> tuple[list[Vec3], list[float]]:
    """Interpolates the control points for a B-spline by global interpolation
    from fit points without any constraints.

    Source: Piegl & Tiller: "The NURBS Book" - chapter 9.2.1

    Args:
        fit_points: points the B-spline has to pass
        degree: degree of spline >= 2
        t_vector: parametrization vector, first value has to be 0 and last
            value has to be 1
        knot_generation_method: knot generation method from parametrization
            vector, "average" or "natural"

    Returns:
        2-tuple of control points as list of Vec3 objects and the knot vector
        as list of floats

    """
    # Source: http://pages.mtu.edu/~shene/COURSES/cs3621/NOTES/INT-APP/CURVE-INT-global.html
    knots = knots_from_parametrization(
        len(fit_points) - 1,
        degree,
        t_vector,
        knot_generation_method,
        constrained=False,
    )
    N = Basis(knots=knots, order=degree + 1, count=len(fit_points))
    solver = _get_best_solver([N.basis_vector(t) for t in t_vector], degree)
    mat_B = np.array(fit_points, dtype=np.float64)
    control_points = solver.solve_matrix(mat_B)
    return Vec3.list(control_points.rows()), knots


def global_bspline_interpolation_end_tangents(
    fit_points: list[Vec3],
    start_tangent: Vec3,
    end_tangent: Vec3,
    degree: int,
    t_vector: Sequence[float],
    knot_generation_method: str = "average",
) -> tuple[list[Vec3], list[float]]:
    """Calculates the control points for a B-spline by global interpolation
    from fit points and the 1st derivative of the start- and end point as constraints.
    These 'tangents' are 1st derivatives and not unit vectors, if an estimation
    of the magnitudes is required use the :func:`estimate_end_tangent_magnitude`
    function.

    Source: Piegl & Tiller: "The NURBS Book" - chapter 9.2.2

    Args:
        fit_points: points the B-spline has to pass
        start_tangent: 1st derivative as start constraint
        end_tangent: 1st derivative as end constrain
        degree: degree of spline >= 2
        t_vector: parametrization vector, first value has to be 0 and last
            value has to be 1
        knot_generation_method: knot generation method from parametrization
            vector, "average" or "natural"

    Returns:
        2-tuple of control points as list of Vec3 objects and the knot vector
        as list of floats

    """
    n = len(fit_points) - 1
    p = degree
    if degree > 3:
        # todo: 'average' produces weird results for degree > 3, 'natural' is
        #  better but also not good
        knot_generation_method = "natural"
    knots = knots_from_parametrization(
        n + 2, p, t_vector, knot_generation_method, constrained=True
    )

    N = Basis(knots=knots, order=p + 1, count=n + 3)
    rows = [N.basis_vector(u) for u in t_vector]
    spacing = [0.0] * (n + 1)
    rows.insert(1, [-1.0, +1.0] + spacing)
    rows.insert(-1, spacing + [-1.0, +1.0])
    fit_points.insert(1, start_tangent * (knots[p + 1] / p))
    fit_points.insert(-1, end_tangent * ((1.0 - knots[-(p + 2)]) / p))

    solver = _get_best_solver(rows, degree)
    control_points = solver.solve_matrix(fit_points)
    return Vec3.list(control_points.rows()), knots


def cad_fit_point_interpolation(
    fit_points: list[Vec3],
) -> tuple[list[Vec3], list[float]]:
    """Calculates the control points for a B-spline by global interpolation
    from fit points without any constraints in the same way as AutoCAD and BricsCAD.

    Source: https://stackoverflow.com/a/74863330/6162864

    Args:
        fit_points: points the B-spline has to pass

    Returns:
        2-tuple of control points as list of Vec3 objects and the knot vector
        as list of floats

    """

    def coefficients1() -> list[float]:
        """Returns the coefficients for equation [1]."""
        # Piegl & Tiller: "The NURBS Book" formula (3.9)
        up1 = knots[p + 1]
        up2 = knots[p + 2]
        f = p * (p - 1) / up1
        return [
            f / up1,  # P0
            -f * (up1 + up2) / (up1 * up2),  # P1
            f / up2,  # P2
        ]

    def coefficients2() -> list[float]:
        """Returns the coefficients for equation [n-1]."""
        # Piegl & Tiller: "The NURBS Book" formula (3.10)
        m = len(knots) - 1
        ump1 = knots[m - p - 1]
        ump2 = knots[m - p - 2]
        f = p * (p - 1) / (1.0 - ump1)
        return [
            f / (1.0 - ump2),  # Pn-2
            -f * (2.0 - ump1 - ump2) / (1.0 - ump1) / (1.0 - ump2),  # Pn-1
            f / (1.0 - ump1),  # Pn
        ]

    t_vector = list(create_t_vector(fit_points, "chord"))
    n = len(fit_points) - 1
    p = 3
    knots = knots_from_parametrization(
        n + 2, p, t_vector, method="natural", constrained=True
    )

    N = Basis(knots=knots, order=p + 1, count=n + 3)
    rows = [N.basis_vector(u) for u in t_vector]
    spacing = [0.0] * n
    rows.insert(1, coefficients1() + spacing)
    rows.insert(-1, spacing + coefficients2())

    # C"(0) == 0
    fit_points.insert(1, Vec3(0, 0, 0))
    # C"(1) == 0
    fit_points.insert(-1, Vec3(0, 0, 0))

    solver = _get_best_solver(rows, p)
    control_points = solver.solve_matrix(fit_points)
    return Vec3.list(control_points.rows()), knots


def global_bspline_interpolation_first_derivatives(
    fit_points: list[Vec3],
    derivatives: list[Vec3],
    degree: int,
    t_vector: Sequence[float],
) -> tuple[list[Vec3], list[float]]:
    """Interpolates the control points for a B-spline by a global
    interpolation from fit points and 1st derivatives as constraints.

    Source: Piegl & Tiller: "The NURBS Book" - chapter 9.2.4

    Args:
        fit_points: points the B-spline has to pass
        derivatives: 1st derivatives as constrains, not unit vectors!
            Scaling by chord length is a reasonable choice (Piegl & Tiller).
        degree: degree of spline >= 2
        t_vector: parametrization vector, first value has to be 0 and last
            value has to be 1

    Returns:
        2-tuple of control points as list of Vec3 objects and the knot vector
        as list of floats

    """

    def nbasis(t: float):
        span = N.find_span(t)
        front = span - p
        back = count + p + 1 - span
        for basis in N.basis_funcs_derivatives(span, t, n=1):
            yield [0.0] * front + basis + [0.0] * back

    p = degree
    n = len(fit_points) - 1
    knots = double_knots(n, p, t_vector)
    count = len(fit_points) * 2
    N = Basis(knots=knots, order=p + 1, count=count)
    A = [
        [1.0] + [0.0] * (count - 1),  # Q0
        [-1.0, +1.0] + [0.0] * (count - 2),  # D0
    ]
    ncols = len(A[0])
    for f in (nbasis(t) for t in t_vector[1:-1]):
        A.extend([row[:ncols] for row in f])  # Qi, Di
    # swapped equations!
    A.append([0.0] * (count - 2) + [-1.0, +1.0])  # Dn
    A.append([0.0] * (count - 1) + [+1.0])  # Qn
    assert len(set(len(row) for row in A)) == 1, "inhomogeneous matrix detected"

    # Build right handed matrix B
    B: list[Vec3] = []
    for rows in zip(fit_points, derivatives):
        B.extend(rows)  # Qi, Di

    # also swap last rows!
    B[-1], B[-2] = B[-2], B[-1]  # Dn, Qn

    # modify equation for derivatives D0 and Dn
    B[1] *= knots[p + 1] / p
    B[-2] *= (1.0 - knots[-(p + 2)]) / p
    solver = _get_best_solver(A, degree)
    control_points = solver.solve_matrix(B)
    return Vec3.list(control_points.rows()), knots


def local_cubic_bspline_interpolation_from_tangents(
    fit_points: list[Vec3], tangents: list[Vec3]
) -> tuple[list[Vec3], list[float]]:
    """Interpolates the control points for a cubic B-spline by local
    interpolation from fit points and tangents as unit vectors for each fit
    point. Use the :func:`estimate_tangents` function to estimate end tangents.

    Source: Piegl & Tiller: "The NURBS Book" - chapter 9.3.4

    Args:
        fit_points: curve definition points - curve has to pass all given fit
            points
        tangents: one tangent vector for each fit point as unit vectors

    Returns:
        2-tuple of control points as list of Vec3 objects and the knot vector
        as list of floats

    """
    assert len(fit_points) == len(tangents)
    assert len(fit_points) > 2

    degree = 3
    order = degree + 1
    control_points = [fit_points[0]]
    u = 0.0
    params = []
    for i in range(len(fit_points) - 1):
        p0 = fit_points[i]
        p3 = fit_points[i + 1]
        t0 = tangents[i]
        t3 = tangents[i + 1]
        a = 16.0 - (t0 + t3).magnitude_square  # always > 0!
        b = 12.0 * (p3 - p0).dot(t0 + t3)
        c = -36.0 * (p3 - p0).magnitude_square
        try:
            alpha_plus, alpha_minus = linalg.quadratic_equation(a, b, c)
        except ValueError:  # complex solution
            continue
        p1 = p0 + alpha_plus * t0 / 3.0
        p2 = p3 - alpha_plus * t3 / 3.0
        control_points.extend((p1, p2))
        u += 3.0 * (p1 - p0).magnitude
        params.append(u)
    control_points.append(fit_points[-1])

    knots = [0.0] * order
    max_u = params[-1]
    for v in params[:-1]:
        knot = v / max_u
        knots.extend((knot, knot))
    knots.extend([1.0] * 4)

    assert len(knots) == required_knot_values(len(control_points), order)
    return control_points, knots


class BSpline:
    """B-spline construction tool.

    Internal representation of a `B-spline`_ curve. The default configuration
    of the knot vector is a uniform open `knot`_ vector ("clamped").

    Factory functions:

        - :func:`fit_points_to_cad_cv`
        - :func:`fit_points_to_cubic_bezier`
        - :func:`open_uniform_bspline`
        - :func:`closed_uniform_bspline`
        - :func:`rational_bspline_from_arc`
        - :func:`rational_bspline_from_ellipse`
        - :func:`global_bspline_interpolation`
        - :func:`local_cubic_bspline_interpolation`

    Args:
        control_points: iterable of control points as :class:`Vec3` compatible
            objects
        order: spline order (degree + 1)
        knots: iterable of knot values
        weights: iterable of weight values

    """

    __slots__ = ("_control_points", "_basis", "_clamped")

    def __init__(
        self,
        control_points: Iterable[UVec],
        order: int = 4,
        knots: Optional[Iterable[float]] = None,
        weights: Optional[Iterable[float]] = None,
    ):
        self._control_points = Vec3.tuple(control_points)
        count = len(self._control_points)
        order = int(order)
        if order > count:
            raise DXFValueError(
                f"got {count} control points, need {order} or more for order of {order}"
            )

        if knots is None:
            knots = open_uniform_knot_vector(count, order, normalize=True)
        else:
            knots = tuple(knots)
            required_knot_count = count + order
            if len(knots) != required_knot_count:
                raise ValueError(
                    f"{required_knot_count} knot values required, got {len(knots)}."
                )
            if knots[0] != 0.0:
                knots = normalize_knots(knots)
        self._basis = Basis(knots, order, count, weights=weights)
        self._clamped = len(set(knots[:order])) == 1 and len(set(knots[-order:])) == 1

    def __str__(self):
        return (
            f"BSpline degree={self.degree}, {self.count} "
            f"control points, {len(self.knots())} knot values, "
            f"{len(self.weights())} weights"
        )

    @property
    def control_points(self) -> Sequence[Vec3]:
        """Control points as tuple of :class:`~ezdxf.math.Vec3`"""
        return self._control_points

    @property
    def count(self) -> int:
        """Count of control points, (n + 1 in text book notation)."""
        return len(self._control_points)

    @property
    def max_t(self) -> float:
        """Biggest `knot`_ value."""
        return self._basis.max_t

    @property
    def order(self) -> int:
        """Order (k) of B-spline = p + 1"""
        return self._basis.order

    @property
    def degree(self) -> int:
        """Degree (p) of B-spline = order - 1"""
        return self._basis.degree

    @property
    def evaluator(self) -> Evaluator:
        return Evaluator(self._basis, self._control_points)

    @property
    def is_rational(self):
        """Returns ``True`` if curve is a rational B-spline. (has weights)"""
        return self._basis.is_rational

    @property
    def is_clamped(self):
        """Returns ``True`` if curve is a clamped (open) B-spline."""
        return self._clamped

    @staticmethod
    def from_fit_points(points: Iterable[UVec], degree=3, method="chord") -> BSpline:
        """Returns :class:`BSpline` defined by fit points."""
        return global_bspline_interpolation(points, degree, method=method)

    @staticmethod
    def ellipse_approximation(ellipse: ConstructionEllipse, num: int = 16) -> BSpline:
        """Returns an ellipse approximation as :class:`BSpline` with `num`
        control points.

        """
        return global_bspline_interpolation(
            ellipse.vertices(ellipse.params(num)), degree=2
        )

    @staticmethod
    def arc_approximation(arc: ConstructionArc, num: int = 16) -> BSpline:
        """Returns an arc approximation as :class:`BSpline` with `num`
        control points.

        """
        return global_bspline_interpolation(arc.vertices(arc.angles(num)), degree=2)

    @staticmethod
    def from_ellipse(ellipse: ConstructionEllipse) -> BSpline:
        """Returns the ellipse as :class:`BSpline` of 2nd degree with as few
        control points as possible.

        """
        return rational_bspline_from_ellipse(ellipse, segments=1)

    @staticmethod
    def from_arc(arc: ConstructionArc) -> BSpline:
        """Returns the arc as :class:`BSpline` of 2nd degree with as few control
        points as possible.

        """
        return rational_bspline_from_arc(
            arc.center, arc.radius, arc.start_angle, arc.end_angle, segments=1
        )

    @staticmethod
    def from_nurbs_python_curve(curve) -> BSpline:
        """Interface to the `NURBS-Python <https://pypi.org/project/geomdl/>`_
        package.

        Returns a :class:`BSpline` object from a :class:`geomdl.BSpline.Curve`
        object.

        """
        return BSpline(
            control_points=curve.ctrlpts,
            order=curve.order,
            knots=curve.knotvector,
            weights=curve.weights,
        )

    def reverse(self) -> BSpline:
        """Returns a new :class:`BSpline` object with reversed control point
        order.

        """

        def reverse_knots():
            for k in reversed(normalize_knots(self.knots())):
                yield 1.0 - k

        return self.__class__(
            control_points=reversed(self.control_points),
            order=self.order,
            knots=reverse_knots(),
            weights=reversed(self.weights()) if self.is_rational else None,
        )

    def knots(self) -> Sequence[float]:
        """Returns a tuple of `knot`_ values as floats, the knot vector
        **always** has order + count values (n + p + 2 in text book notation).

        """
        return self._basis.knots

    def weights(self) -> Sequence[float]:
        """Returns a tuple of weights values as floats, one for each control
        point or an empty tuple.

        """
        return self._basis.weights

    def approximate(self, segments: int = 20) -> Iterable[Vec3]:
        """Approximates curve by vertices as :class:`Vec3` objects, vertices
        count = segments + 1.

        """
        return self.evaluator.points(self.params(segments))

    def params(self, segments: int) -> Iterable[float]:
        """Yield evenly spaced parameters for given segment count."""
        # works for clamped and unclamped curves
        knots = self.knots()
        lower_bound = knots[self.order - 1]
        upper_bound = knots[self.count]
        return np.linspace(lower_bound, upper_bound, segments + 1)

    def flattening(self, distance: float, segments: int = 4) -> Iterator[Vec3]:
        """Adaptive recursive flattening. The argument `segments` is the
        minimum count of approximation segments between two knots, if the
        distance from the center of the approximation segment to the curve is
        bigger than `distance` the segment will be subdivided.

        Args:
            distance: maximum distance from the projected curve point onto the
                segment chord.
            segments: minimum segment count between two knots

        """

        def subdiv(s: Vec3, e: Vec3, start_t: float, end_t: float):
            mid_t = (start_t + end_t) * 0.5
            m = evaluator.point(mid_t)
            try:
                _dist = distance_point_line_3d(m, s, e)
            except ZeroDivisionError:  # s == e
                _dist = 0
            if _dist < distance:
                yield e
            else:
                yield from subdiv(s, m, start_t, mid_t)
                yield from subdiv(m, e, mid_t, end_t)

        evaluator = self.evaluator
        knots: list[float] = self.knots()  # type: ignore
        if self.is_clamped:
            lower_bound = 0.0
        else:
            lower_bound = knots[self.order - 1]
            knots = knots[: self.count + 1]

        knots = list(set(knots))
        knots.sort()
        t = lower_bound
        start_point = evaluator.point(t)
        yield start_point
        for t1 in knots[1:]:
            delta = (t1 - t) / segments
            while t < t1:
                next_t = t + delta
                if math.isclose(next_t, t1):
                    next_t = t1
                end_point = evaluator.point(next_t)
                yield from subdiv(start_point, end_point, t, next_t)
                t = next_t
                start_point = end_point

    def point(self, t: float) -> Vec3:
        """Returns point  for parameter `t`.

        Args:
            t: parameter in range [0, max_t]

        """
        return self.evaluator.point(t)

    def points(self, t: Iterable[float]) -> Iterable[Vec3]:
        """Yields points for parameter vector `t`.

        Args:
            t: parameters in range [0, max_t]

        """
        return self.evaluator.points(t)

    def derivative(self, t: float, n: int = 2) -> list[Vec3]:
        """Return point and derivatives up to `n` <= degree for parameter `t`.

        e.g. n=1 returns point and 1st derivative.

        Args:
            t: parameter in range [0, max_t]
            n: compute all derivatives up to n <= degree

        Returns:
            n+1 values as :class:`Vec3` objects

        """
        return self.evaluator.derivative(t, n)

    def derivatives(self, t: Iterable[float], n: int = 2) -> Iterable[list[Vec3]]:
        """Yields points and derivatives up to `n` <= degree for parameter
        vector `t`.

        e.g. n=1 returns point and 1st derivative.

        Args:
            t: parameters in range [0, max_t]
            n: compute all derivatives up to n <= degree

        Returns:
            List of n+1 values as :class:`Vec3` objects

        """
        return self.evaluator.derivatives(t, n)

    def insert_knot(self, t: float) -> BSpline:
        """Insert an additional knot, without altering the shape of the curve.
        Returns a new :class:`BSpline` object.

        Args:
            t: position of new knot 0 < t < max_t

        """
        if self._basis.is_rational:
            raise TypeError("Rational B-splines not supported.")

        knots = list(self._basis.knots)
        cpoints = list(self._control_points)
        p = self.degree

        def new_point(index: int) -> Vec3:
            a = (t - knots[index]) / (knots[index + p] - knots[index])
            return cpoints[index - 1] * (1 - a) + cpoints[index] * a

        if t <= 0.0 or t >= self.max_t:
            raise DXFValueError("Invalid position t")

        k = self._basis.find_span(t)
        if k < p:
            raise DXFValueError("Invalid position t")

        cpoints[k - p + 1 : k] = [new_point(i) for i in range(k - p + 1, k + 1)]
        knots.insert(k + 1, t)  # knot[k] <= t < knot[k+1]
        return BSpline(cpoints, self.order, knots)

    def knot_refinement(self, u: Iterable[float]) -> BSpline:
        """Insert multiple knots, without altering the shape of the curve.
        Returns a new :class:`BSpline` object.

        Args:
            u: vector of new knots t and for each t: 0 < t  < max_t

        """
        spline = self
        for t in u:
            spline = spline.insert_knot(t)
        return spline

    def transform(self, m: Matrix44) -> BSpline:
        """Returns a new :class:`BSpline` object transformed by a
        :class:`Matrix44` transformation matrix.

        """
        cpoints = m.transform_vertices(self.control_points)
        return BSpline(cpoints, self.order, self.knots(), self.weights())

    def bezier_decomposition(self) -> Iterable[list[Vec3]]:
        """Decompose a non-rational B-spline into multiple Bézier curves.

        This is the preferred method to represent the most common non-rational
        B-splines of 3rd degree by cubic Bézier curves, which are often supported
        by render backends.

        Returns:
            Yields control points of Bézier curves, each Bézier segment
            has degree+1 control points e.g. B-spline of 3rd degree yields
            cubic Bézier curves of 4 control points.

        """
        # Source: "The NURBS Book": Algorithm A5.6
        if self._basis.is_rational:
            raise TypeError("Rational B-splines not supported.")
        if not self.is_clamped:
            raise TypeError("Clamped B-Spline required.")

        n = self.count - 1
        p = self.degree
        knots = self._basis.knots  # U
        control_points = self._control_points  # Pw
        alphas = [0.0] * len(knots)

        m = n + p + 1
        a = p
        b = p + 1
        bezier_points = list(control_points[0 : p + 1])  # Qw

        while b < m:
            next_bezier_points = [NULLVEC] * (p + 1)
            i = b
            while b < m and math.isclose(knots[b + 1], knots[b]):
                b += 1
            mult = b - i + 1
            if mult < p:
                numer = knots[b] - knots[a]
                for j in range(p, mult, -1):
                    alphas[j - mult - 1] = numer / (knots[a + j] - knots[a])
                r = p - mult
                for j in range(1, r + 1):
                    save = r - j
                    s = mult + j
                    for k in range(p, s - 1, -1):
                        alpha = alphas[k - s]
                        bezier_points[k] = bezier_points[k] * alpha + bezier_points[
                            k - 1
                        ] * (1.0 - alpha)
                    if b < m:
                        next_bezier_points[save] = bezier_points[p]
            yield bezier_points

            if b < m:
                for i in range(p - mult, p + 1):
                    next_bezier_points[i] = control_points[b - p + i]
                a = b
                b += 1
                bezier_points = next_bezier_points

    def cubic_bezier_approximation(
        self, level: int = 3, segments: Optional[int] = None
    ) -> Iterable[Bezier4P]:
        """Approximate arbitrary B-splines (degree != 3 and/or rational) by
        multiple segments of cubic Bézier curves. The choice of cubic Bézier
        curves is based on the widely support of this curves by many render
        backends. For cubic non-rational B-splines, which is maybe the most
        common used B-spline, is :meth:`bezier_decomposition` the better choice.

        1. approximation by `level`: an educated guess, the first level of
           approximation segments is based on the count of control points
           and their distribution along the B-spline, every additional level
           is a subdivision of the previous level.

        E.g. a B-Spline of 8 control points has 7 segments at the first level,
        14 at the 2nd level and 28 at the 3rd level, a level >= 3 is recommended.

        2. approximation by a given count of evenly distributed approximation
           segments.

        Args:
            level: subdivision level of approximation segments (ignored if
                argument `segments` is not ``None``)
            segments: absolute count of approximation segments

        Returns:
            Yields control points of cubic Bézier curves as :class:`Bezier4P`
            objects

        """
        if segments is None:
            points = list(self.points(self.approximation_params(level)))
        else:
            points = list(self.approximate(segments))
        from .bezier_interpolation import cubic_bezier_interpolation

        return cubic_bezier_interpolation(points)

    def approximation_params(self, level: int = 3) -> Sequence[float]:
        """Returns an educated guess, the first level of approximation
        segments is based on the count of control points and their distribution
        along the B-spline, every additional level is a subdivision of the
        previous level.

        E.g. a B-Spline of 8 control points has 7 segments at the first level,
        14 at the 2nd level and 28 at the 3rd level.

        """
        params = list(create_t_vector(self._control_points, "chord"))
        if len(params) == 0:
            return params
        if self.max_t != 1.0:
            max_t = self.max_t
            params = [p * max_t for p in params]
        for _ in range(level - 1):
            params = list(subdivide_params(params))
        return params


def subdivide_params(p: list[float]) -> Iterable[float]:
    for i in range(len(p) - 1):
        yield p[i]
        yield (p[i] + p[i + 1]) / 2.0
    yield p[-1]


def open_uniform_bspline(
    control_points: Iterable[UVec],
    order: int = 4,
    weights: Optional[Iterable[float]] = None,
) -> BSpline:
    """Creates an open uniform (periodic) `B-spline`_ curve (`open curve`_).

    This is an unclamped curve, which means the curve passes none of the
    control points.

    Args:
        control_points: iterable of control points as :class:`Vec3` compatible
            objects
        order: spline order (degree + 1)
        weights: iterable of weight values

    """
    _control_points = Vec3.tuple(control_points)
    knots = uniform_knot_vector(len(_control_points), order, normalize=False)
    return BSpline(control_points, order=order, knots=knots, weights=weights)


def closed_uniform_bspline(
    control_points: Iterable[UVec],
    order: int = 4,
    weights: Optional[Iterable[float]] = None,
) -> BSpline:
    """Creates a closed uniform (periodic) `B-spline`_ curve (`open curve`_).

    This B-spline does not pass any of the control points.

    Args:
        control_points: iterable of control points as :class:`Vec3` compatible
            objects
        order: spline order (degree + 1)
        weights: iterable of weight values

    """
    _control_points = Vec3.list(control_points)
    _control_points.extend(_control_points[: order - 1])
    if weights is not None:
        weights = list(weights)
        weights.extend(weights[: order - 1])
    return open_uniform_bspline(_control_points, order, weights)


def rational_bspline_from_arc(
    center: Vec3 = (0, 0),
    radius: float = 1,
    start_angle: float = 0,
    end_angle: float = 360,
    segments: int = 1,
) -> BSpline:
    """Returns a rational B-splines for a circular 2D arc.

    Args:
        center: circle center as :class:`Vec3` compatible object
        radius: circle radius
        start_angle: start angle in degrees
        end_angle: end angle in degrees
        segments: count of spline segments, at least one segment for each
            quarter (90 deg), default is 1, for as few as needed.

    """
    center = Vec3(center)
    radius = float(radius)

    start_rad = math.radians(start_angle % 360)
    end_rad = start_rad + math.radians(arc_angle_span_deg(start_angle, end_angle))
    control_points, weights, knots = nurbs_arc_parameters(start_rad, end_rad, segments)
    return BSpline(
        control_points=(center + (p * radius) for p in control_points),
        weights=weights,
        knots=knots,
        order=3,
    )


PI_2 = math.pi / 2.0


def rational_bspline_from_ellipse(
    ellipse: ConstructionEllipse, segments: int = 1
) -> BSpline:
    """Returns a rational B-splines for an elliptic arc.

    Args:
        ellipse: ellipse parameters as :class:`~ezdxf.math.ConstructionEllipse`
            object
        segments: count of spline segments, at least one segment for each
            quarter (π/2), default is 1, for as few as needed.

    """
    start_angle = ellipse.start_param % math.tau
    end_angle = start_angle + ellipse.param_span

    def transform_control_points() -> Iterable[Vec3]:
        center = Vec3(ellipse.center)
        x_axis = ellipse.major_axis
        y_axis = ellipse.minor_axis
        for p in control_points:
            yield center + x_axis * p.x + y_axis * p.y

    control_points, weights, knots = nurbs_arc_parameters(
        start_angle, end_angle, segments
    )
    return BSpline(
        control_points=transform_control_points(),
        weights=weights,
        knots=knots,
        order=3,
    )


def nurbs_arc_parameters(start_angle: float, end_angle: float, segments: int = 1):
    """Returns a rational B-spline parameters for a circular 2D arc with center
    at (0, 0) and a radius of 1.

    Args:
        start_angle: start angle in radians
        end_angle: end angle in radians
        segments: count of segments, at least one segment for each quarter (π/2)

    Returns:
        control_points, weights, knots

    """
    # Source: https://www.researchgate.net/publication/283497458_ONE_METHOD_FOR_REPRESENTING_AN_ARC_OF_ELLIPSE_BY_A_NURBS_CURVE/citation/download
    if segments < 1:
        raise ValueError("Invalid argument segments (>= 1).")
    delta_angle = end_angle - start_angle
    arc_count = max(math.ceil(delta_angle / PI_2), segments)

    segment_angle = delta_angle / arc_count
    segment_angle_2 = segment_angle / 2
    arc_weight = math.cos(segment_angle_2)

    # First control point
    control_points = [Vec3(math.cos(start_angle), math.sin(start_angle))]
    weights = [1.0]

    angle = start_angle
    d = 1.0 / math.cos(segment_angle / 2.0)
    for _ in range(arc_count):
        # next control point between points on arc
        angle += segment_angle_2
        control_points.append(Vec3(math.cos(angle) * d, math.sin(angle) * d))
        weights.append(arc_weight)

        # next control point on arc
        angle += segment_angle_2
        control_points.append(Vec3(math.cos(angle), math.sin(angle)))
        weights.append(1.0)

    # Knot vector calculation for B-spline of order=3
    # Clamped B-Spline starts with `order` 0.0 knots and
    # ends with `order` 1.0 knots
    knots = [0.0, 0.0, 0.0]
    step = 1.0 / ((max(len(control_points) + 1, 4) - 4) / 2.0 + 1.0)
    g = step
    while g < 1.0:
        knots.extend((g, g))
        g += step
    knots.extend([1.0] * (required_knot_values(len(control_points), 3) - len(knots)))

    return control_points, weights, knots


def bspline_basis(u: float, index: int, degree: int, knots: Sequence[float]) -> float:
    """B-spline basis_vector function.

    Simple recursive implementation for testing and comparison.

    Args:
        u: curve parameter in range [0, max(knots)]
        index: index of control point
        degree: degree of B-spline
        knots: knots vector

    Returns:
        float: basis_vector value N_i,p(u)

    """
    cache: dict[tuple[int, int], float] = {}
    u = float(u)

    def N(i: int, p: int) -> float:
        try:
            return cache[(i, p)]
        except KeyError:
            if p == 0:
                retval = 1 if knots[i] <= u < knots[i + 1] else 0.0
            else:
                dominator = knots[i + p] - knots[i]
                f1 = (u - knots[i]) / dominator * N(i, p - 1) if dominator else 0.0

                dominator = knots[i + p + 1] - knots[i + 1]
                f2 = (
                    (knots[i + p + 1] - u) / dominator * N(i + 1, p - 1)
                    if dominator
                    else 0.0
                )

                retval = f1 + f2
            cache[(i, p)] = retval
            return retval

    return N(int(index), int(degree))


def bspline_basis_vector(
    u: float, count: int, degree: int, knots: Sequence[float]
) -> list[float]:
    """Create basis_vector vector at parameter u.

    Used with the bspline_basis() for testing and comparison.

    Args:
        u: curve parameter in range [0, max(knots)]
        count: control point count (n + 1)
        degree: degree of B-spline (order = degree + 1)
        knots: knot vector

    Returns:
        list[float]: basis_vector vector, len(basis_vector) == count

    """
    assert len(knots) == (count + degree + 1)
    basis: list[float] = [
        bspline_basis(u, index, degree, knots) for index in range(count)
    ]
    # pick up last point ??? why is this necessary ???
    if math.isclose(u, knots[-1]):
        basis[-1] = 1.0
    return basis
