import math

import pytest

from raygeo.geo.shape.bezier import (
    clip_bezier_with_rect,
    convert_cubic_bezier_to_quadratic,
    fit_cubic_bezier,
    flatten_bezier,
    get_bezier_bounds,
    get_bezier_flatness_sq,
    get_bezier_point_at,
    get_bezier_rect_intersections,
    get_perpendicular_dist_sq,
    is_bezier_flat,
    is_bezier_inside_polygons,
    linearize_bezier,
    linearize_bezier_adaptive,
    linearize_bezier_segment,
    nearest_tangent_circle_on_bezier,
    split_bezier,
)


def approx_pt(p, tol=1e-6):
    return pytest.approx(p, abs=tol)


class TestEvaluateBezier:
    def test_endpoints(self):
        p0, c1, c2, p1 = (0, 0), (1, 2), (3, 2), (4, 0)
        assert get_bezier_point_at(p0, c1, c2, p1, 0.0) == approx_pt(p0)
        assert get_bezier_point_at(p0, c1, c2, p1, 1.0) == approx_pt(p1)

    def test_midpoint(self):
        p0, c1, c2, p1 = (0, 0), (0, 2), (4, 2), (4, 0)
        mid = get_bezier_point_at(p0, c1, c2, p1, 0.5)
        expected_x = 0.125 * 0 + 0.375 * 0 + 0.375 * 4 + 0.125 * 4
        expected_y = 0.125 * 0 + 0.375 * 2 + 0.375 * 2 + 0.125 * 0
        assert mid == approx_pt((expected_x, expected_y))

    def test_line(self):
        p0, c1, c2, p1 = (0, 0), (1, 0), (2, 0), (3, 0)
        pt = get_bezier_point_at(p0, c1, c2, p1, 0.5)
        assert pt == approx_pt((1.5, 0.0))


class TestSubdivideBezier:
    def test_midpoint_split(self):
        p0, c1, c2, p1 = (0, 0), (1, 3), (3, 3), (4, 0)
        left, right = split_bezier(p0, c1, c2, p1, 0.5)
        assert left[0] == approx_pt(p0)
        assert left[3] == approx_pt(right[0])
        assert right[3] == approx_pt(p1)
        mid_from_left = left[3]
        mid_eval = get_bezier_point_at(p0, c1, c2, p1, 0.5)
        assert mid_from_left == approx_pt(mid_eval)

    def test_split_at_zero(self):
        p0, c1, c2, p1 = (0, 0), (1, 2), (3, 2), (4, 0)
        left, right = split_bezier(p0, c1, c2, p1, 0.0)
        assert left[0] == approx_pt(p0)
        assert left[3] == approx_pt(p0)
        assert right[3] == approx_pt(p1)

    def test_split_at_one(self):
        p0, c1, c2, p1 = (0, 0), (1, 2), (3, 2), (4, 0)
        left, right = split_bezier(p0, c1, c2, p1, 1.0)
        assert left[0] == approx_pt(p0)
        assert left[3] == approx_pt(p1)
        assert right[3] == approx_pt(p1)

    def test_split_halves_reconstruct(self):
        p0, c1, c2, p1 = (0, 0), (2, 8), (6, 8), (8, 0)
        left, right = split_bezier(p0, c1, c2, p1, 0.5)
        for t in [0.0, 0.1, 0.25, 0.4, 0.5]:
            orig = get_bezier_point_at(p0, c1, c2, p1, t)
            from_left = get_bezier_point_at(*left, t * 2)
            assert from_left == approx_pt(orig)
        for t in [0.5, 0.6, 0.75, 0.9, 1.0]:
            orig = get_bezier_point_at(p0, c1, c2, p1, t)
            from_right = get_bezier_point_at(*right, (t - 0.5) * 2)
            assert from_right == approx_pt(orig)


class TestBezierBounds:
    def test_straight_line(self):
        p0, c1, c2, p1 = (0, 0), (1, 0), (2, 0), (3, 0)
        bounds = get_bezier_bounds(p0, c1, c2, p1)
        assert bounds == approx_pt((0, 0, 3, 0))

    def test_symmetric_curve(self):
        p0, c1, c2, p1 = (0, 0), (0, 2), (4, 2), (4, 0)
        bounds = get_bezier_bounds(p0, c1, c2, p1)
        assert bounds[0] == pytest.approx(0.0, abs=1e-6)
        assert bounds[2] == pytest.approx(4.0, abs=1e-6)
        assert bounds[1] == pytest.approx(0.0, abs=1e-6)
        assert bounds[3] >= 1.4

    def test_bounds_contain_all_points(self):
        p0, c1, c2, p1 = (1, 1), (5, 10), (10, -3), (8, 2)
        bounds = get_bezier_bounds(p0, c1, c2, p1)
        for t_frac in [i / 100.0 for i in range(101)]:
            pt = get_bezier_point_at(p0, c1, c2, p1, t_frac)
            assert bounds[0] - 1e-6 <= pt[0] <= bounds[2] + 1e-6
            assert bounds[1] - 1e-6 <= pt[1] <= bounds[3] + 1e-6

    def test_single_point(self):
        p = (2, 3)
        bounds = get_bezier_bounds(p, p, p, p)
        assert bounds == approx_pt((2, 3, 2, 3))


class TestIntersectBezierRect:
    def test_fully_inside(self):
        p0, c1, c2, p1 = (2, 2), (3, 4), (5, 4), (6, 2)
        rect = (0, 0, 10, 10)
        params = get_bezier_rect_intersections(p0, c1, c2, p1, rect)
        assert 0.0 in params
        assert 1.0 in params

    def test_fully_outside(self):
        p0, c1, c2, p1 = (12, 12), (13, 14), (15, 14), (16, 12)
        rect = (0, 0, 10, 10)
        params = get_bezier_rect_intersections(p0, c1, c2, p1, rect)
        assert params == [0.0, 1.0]

    def test_crosses_one_edge(self):
        p0, c1, c2, p1 = (-5, 5), (2, 8), (8, 8), (15, 5)
        rect = (0, 0, 10, 10)
        params = get_bezier_rect_intersections(p0, c1, c2, p1, rect)
        assert len(params) >= 2
        crossings = [t for t in params if 0 < t < 1]
        assert len(crossings) >= 2

    def test_crosses_two_edges(self):
        p0, c1, c2, p1 = (-2, 5), (3, 12), (7, 12), (12, 5)
        rect = (0, 0, 10, 10)
        params = get_bezier_rect_intersections(p0, c1, c2, p1, rect)
        crossings = [t for t in params if 0 < t < 1]
        assert len(crossings) >= 2


class TestClipBezier:
    def test_fully_inside(self):
        p0, c1, c2, p1 = (2, 2), (3, 5), (7, 5), (8, 2)
        rect = (0, 0, 10, 10)
        segments = clip_bezier_with_rect(p0, c1, c2, p1, rect)
        assert len(segments) == 1
        start = get_bezier_point_at(*segments[0], 0.0)
        end = get_bezier_point_at(*segments[0], 1.0)
        assert start == approx_pt(p0)
        assert end == approx_pt(p1)

    def test_fully_outside(self):
        p0, c1, c2, p1 = (12, 12), (13, 14), (15, 14), (16, 12)
        rect = (0, 0, 10, 10)
        segments = clip_bezier_with_rect(p0, c1, c2, p1, rect)
        assert len(segments) == 0

    def test_crosses_left_edge(self):
        p0, c1, c2, p1 = (-5, 5), (2, 8), (8, 8), (5, 5)
        rect = (0, 0, 10, 10)
        segments = clip_bezier_with_rect(p0, c1, c2, p1, rect)
        assert len(segments) >= 1
        for seg in segments:
            start = seg[0]
            assert start[0] >= -1e-6

    def test_crosses_right_edge(self):
        p0, c1, c2, p1 = (5, 5), (8, 8), (12, 8), (15, 5)
        rect = (0, 0, 10, 10)
        segments = clip_bezier_with_rect(p0, c1, c2, p1, rect)
        assert len(segments) >= 1
        for seg in segments:
            end = get_bezier_point_at(*seg, 1.0)
            assert end[0] <= 10 + 1e-6

    def test_crosses_two_opposite_edges(self):
        p0, c1, c2, p1 = (-2, 5), (3, 8), (7, 8), (12, 5)
        rect = (0, 0, 10, 10)
        segments = clip_bezier_with_rect(p0, c1, c2, p1, rect)
        assert len(segments) >= 1
        for seg in segments:
            start = get_bezier_point_at(*seg, 0.0)
            end = get_bezier_point_at(*seg, 1.0)
            assert start[0] >= -1e-6
            assert end[0] >= -1e-6
            assert start[0] <= 10 + 1e-6
            assert end[0] <= 10 + 1e-6

    def test_clipped_segments_stay_inside(self):
        p0, c1, c2, p1 = (-3, 5), (2, 10), (8, 10), (13, 5)
        rect = (0, 0, 10, 10)
        segments = clip_bezier_with_rect(p0, c1, c2, p1, rect)
        assert len(segments) >= 1
        for seg in segments:
            for i in range(20):
                t = i / 19.0
                pt = get_bezier_point_at(seg[0], seg[1], seg[2], seg[3], t)
                assert pt[0] >= -2, f"x={pt[0]} far outside rect at t={t}"
                assert pt[0] <= 12, f"x={pt[0]} far outside rect at t={t}"
                assert pt[1] >= -2, f"y={pt[1]} far outside rect at t={t}"
                assert pt[1] <= 12, f"y={pt[1]} far outside rect at t={t}"

    def test_degenerate_point(self):
        p = (5, 5)
        segments = clip_bezier_with_rect(p, p, p, p, (0, 0, 10, 10))
        assert len(segments) == 1

    def test_start_inside_end_outside(self):
        p0, c1, c2, p1 = (5, 5), (8, 7), (12, 7), (15, 5)
        rect = (0, 0, 10, 10)
        segments = clip_bezier_with_rect(p0, c1, c2, p1, rect)
        assert len(segments) >= 1
        start = get_bezier_point_at(*segments[0], 0.0)
        assert start == approx_pt(p0)

    def test_start_outside_end_inside(self):
        p0, c1, c2, p1 = (-5, 5), (0, 7), (5, 7), (8, 5)
        rect = (0, 0, 10, 10)
        segments = clip_bezier_with_rect(p0, c1, c2, p1, rect)
        assert len(segments) >= 1
        end = get_bezier_point_at(*segments[-1], 1.0)
        assert end == approx_pt(p1)


class TestBezierToQuadratic:
    def test_endpoints_preserved(self):
        p0, c1, c2, p1 = (0, 0), (2, 6), (6, 6), (8, 0)
        qp0, qc, qp1 = convert_cubic_bezier_to_quadratic(p0, c1, c2, p1)
        assert qp0 == approx_pt(p0)
        assert qp1 == approx_pt(p1)

    def test_midpoint_approximation(self):
        p0, c1, c2, p1 = (0, 0), (0, 3), (3, 3), (3, 0)
        qp0, qc, qp1 = convert_cubic_bezier_to_quadratic(p0, c1, c2, p1)
        t = 0.5
        quad_mid = (
            (1 - t) ** 2 * qp0[0] + 2 * t * (1 - t) * qc[0] + t**2 * qp1[0],
            (1 - t) ** 2 * qp0[1] + 2 * t * (1 - t) * qc[1] + t**2 * qp1[1],
        )
        cubic_mid = get_bezier_point_at(p0, c1, c2, p1, t)
        err = math.hypot(
            quad_mid[0] - cubic_mid[0], quad_mid[1] - cubic_mid[1]
        )
        assert err < 0.5

    def test_linear_case(self):
        p0, c1, c2, p1 = (0, 0), (1, 0), (2, 0), (3, 0)
        qp0, qc, qp1 = convert_cubic_bezier_to_quadratic(p0, c1, c2, p1)
        assert qp0 == approx_pt((0, 0))
        assert qp1 == approx_pt((3, 0))
        assert qc[1] == pytest.approx(0.0, abs=1e-6)


def test_linearize_bezier_3d():
    """Tests linearization of a 3D Bézier curve."""
    p0 = (0.0, 0.0, 0.0)
    c1 = (1.0, 1.0, 5.0)
    c2 = (2.0, 1.0, 5.0)
    p1 = (3.0, 0.0, 10.0)
    num_steps = 10

    segments = linearize_bezier(p0, c1, c2, p1, num_steps)
    assert len(segments) == num_steps

    start_of_chain = segments[0][0]
    end_of_chain = segments[-1][1]

    assert start_of_chain == pytest.approx(p0)
    assert end_of_chain == pytest.approx(p1)

    midpoint = segments[5][0]
    expected_x = 0.125 * 0 + 0.375 * 1 + 0.375 * 2 + 0.125 * 3
    expected_y = 0.125 * 0 + 0.375 * 1 + 0.375 * 1 + 0.125 * 0
    expected_z = 0.125 * 0 + 0.375 * 5 + 0.375 * 5 + 0.125 * 10
    assert midpoint == pytest.approx((expected_x, expected_y, expected_z))


def test_linearize_bezier_adaptive_flat():
    """Tests adaptive linearization on a perfectly flat line."""
    p0 = (0.0, 0.0)
    c1 = (2.5, 0.0)
    c2 = (7.5, 0.0)
    p1 = (10.0, 0.0)

    points = linearize_bezier_adaptive(p0, c1, c2, p1, tolerance_sq=0.01)

    assert len(points) == 2
    assert points[0] == p0
    assert points[1] == p1


def test_linearize_bezier_adaptive_curved():
    """Tests adaptive linearization on a curve."""
    p0 = (0.0, 0.0)
    c1 = (0.0, 10.0)
    c2 = (10.0, 10.0)
    p1 = (10.0, 0.0)

    points_coarse = linearize_bezier_adaptive(p0, c1, c2, p1, tolerance_sq=1.0)
    points_fine = linearize_bezier_adaptive(
        p0, c1, c2, p1, tolerance_sq=0.0001
    )

    assert len(points_fine) > len(points_coarse)
    assert points_fine[0] == p0
    assert points_fine[-1] == p1
    assert points_coarse[0] == p0
    assert points_coarse[-1] == p1


class TestIsBezierFlat:
    def test_collinear_controls_is_flat(self):
        p0, c1, c2, p1 = (0.0, 0.0), (3.0, 0.0), (7.0, 0.0), (10.0, 0.0)
        assert is_bezier_flat(p0, c1, c2, p1, tolerance_sq=0.01)

    def test_curved_controls_not_flat(self):
        p0, c1, c2, p1 = (0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)
        assert not is_bezier_flat(p0, c1, c2, p1, tolerance_sq=0.01)

    def test_strict_tolerance_detects_curvature(self):
        p0, c1, c2, p1 = (0.0, 0.0), (1.0, 0.5), (9.0, 0.5), (10.0, 0.0)
        assert is_bezier_flat(p0, c1, c2, p1, tolerance_sq=1.0)
        assert not is_bezier_flat(p0, c1, c2, p1, tolerance_sq=0.01)

    def test_degenerate_coincident_endpoints(self):
        p = (5.0, 5.0)
        c1, c2 = (6.0, 8.0), (9.0, 6.0)
        assert not is_bezier_flat(p, c1, c2, p, tolerance_sq=0.01)

    def test_degenerate_all_coincident(self):
        p = (5.0, 5.0)
        assert is_bezier_flat(p, p, p, p, tolerance_sq=0.01)

    def test_zero_tolerance_implies_not_flat(self):
        p0, c1, c2, p1 = (0.0, 0.0), (1.0, 1.0), (9.0, 1.0), (10.0, 0.0)
        assert not is_bezier_flat(p0, c1, c2, p1, tolerance_sq=0.0)


def test_linearize_segment_start_end():
    """Polyline starts at p0 and ends at p1."""
    p0 = (0.0, 0.0, 0.0)
    c1 = (1.0, 3.0, 2.0)
    c2 = (3.0, 3.0, 4.0)
    p1 = (4.0, 0.0, 6.0)

    pts = linearize_bezier_segment(p0, c1, c2, p1)

    assert len(pts) >= 2
    assert pts[0] == pytest.approx(p0)
    assert pts[-1] == pytest.approx(p1)


def test_linearize_segment_flat_curve():
    """Collinear control points produce exactly two points (start, end)."""
    p0 = (0.0, 0.0, 0.0)
    c1 = (3.0, 0.0, 3.0)
    c2 = (7.0, 0.0, 7.0)
    p1 = (10.0, 0.0, 10.0)

    pts = linearize_bezier_segment(p0, c1, c2, p1, tolerance=0.1)

    assert len(pts) == 2
    assert pts[0] == pytest.approx(p0)
    assert pts[1] == pytest.approx(p1)


def test_linearize_segment_tolerance_controls_detail():
    """Tighter tolerance produces more points."""
    p0 = (0.0, 0.0, 0.0)
    c1 = (0.0, 10.0, 5.0)
    c2 = (10.0, 10.0, 5.0)
    p1 = (10.0, 0.0, 10.0)

    coarse = linearize_bezier_segment(p0, c1, c2, p1, tolerance=1.0)
    fine = linearize_bezier_segment(p0, c1, c2, p1, tolerance=0.001)

    assert len(fine) > len(coarse)


def test_linearize_segment_midpoint_accuracy():
    """Midpoint of the polyline matches the analytic B(0.5)."""
    p0 = (0.0, 0.0, 0.0)
    c1 = (1.0, 1.0, 5.0)
    c2 = (2.0, 1.0, 5.0)
    p1 = (3.0, 0.0, 10.0)

    pts = linearize_bezier_segment(p0, c1, c2, p1, tolerance=0.001)

    mid_idx = len(pts) // 2
    actual_mid = pts[mid_idx]

    expected_x = 0.125 * 0 + 0.375 * 1 + 0.375 * 2 + 0.125 * 3
    expected_y = 0.125 * 0 + 0.375 * 1 + 0.375 * 1 + 0.125 * 0
    expected_z = 0.125 * 0 + 0.375 * 5 + 0.375 * 5 + 0.125 * 10
    assert actual_mid == pytest.approx(
        (expected_x, expected_y, expected_z), abs=0.01
    )


def test_linearize_segment_default_tolerance():
    """Calling without tolerance uses the default (no crash)."""
    p0 = (0.0, 0.0, 0.0)
    c1 = (5.0, 10.0, 3.0)
    c2 = (10.0, 10.0, 6.0)
    p1 = (15.0, 0.0, 9.0)

    pts = linearize_bezier_segment(p0, c1, c2, p1)

    assert len(pts) >= 2
    assert pts[0] == pytest.approx(p0)
    assert pts[-1] == pytest.approx(p1)


def test_linearize_segment_zero_length():
    """Degenerate bezier where all points coincide returns two points."""
    p = (1.0, 2.0, 3.0)
    pts = linearize_bezier_segment(p, p, p, p)

    assert len(pts) == 2
    assert pts[0] == pytest.approx(p)
    assert pts[1] == pytest.approx(p)


def test_get_perpendicular_dist_sq_on_line():
    origin = (0.0, 0.0, 0.0)
    pt = (5.0, 0.0, 0.0)
    assert get_perpendicular_dist_sq(
        pt, origin, 1.0, 0.0, 0.0, 1.0
    ) == pytest.approx(0.0)


def test_get_perpendicular_dist_sq_off_line():
    origin = (0.0, 0.0, 0.0)
    pt = (0.0, 3.0, 0.0)
    assert get_perpendicular_dist_sq(
        pt, origin, 1.0, 0.0, 0.0, 1.0
    ) == pytest.approx(9.0)


def test_get_perpendicular_dist_sq_3d():
    origin = (0.0, 0.0, 0.0)
    pt = (0.0, 0.0, 4.0)
    assert get_perpendicular_dist_sq(
        pt, origin, 1.0, 0.0, 0.0, 1.0
    ) == pytest.approx(16.0)


def test_get_bezier_flatness_sq_collinear():
    a = (0.0, 0.0, 0.0)
    b = (3.0, 0.0, 0.0)
    c = (7.0, 0.0, 0.0)
    d = (10.0, 0.0, 0.0)
    assert get_bezier_flatness_sq(a, b, c, d) == pytest.approx(0.0, abs=1e-9)


def test_get_bezier_flatness_sq_nonzero():
    a = (0.0, 0.0, 0.0)
    b = (0.0, 5.0, 0.0)
    c = (10.0, 5.0, 0.0)
    d = (10.0, 0.0, 0.0)
    assert get_bezier_flatness_sq(a, b, c, d) > 0.0


def test_get_bezier_flatness_sq_coincident_endpoints():
    a = (1.0, 1.0, 1.0)
    d = (1.0, 1.0, 1.0)
    b = (1.0, 1.0, 1.0)
    c = (1.0, 1.0, 1.0)
    assert get_bezier_flatness_sq(a, b, c, d) == pytest.approx(0.0)


def test_get_bezier_flatness_sq_coincident_endpoints_offset_ctrl():
    a = (0.0, 0.0, 0.0)
    d = (0.0, 0.0, 0.0)
    b = (1.0, 0.0, 0.0)
    c = (0.0, 2.0, 0.0)
    result = get_bezier_flatness_sq(a, b, c, d)
    assert result == pytest.approx(4.0)


def test_flatten_bezier_produces_points():
    a = (0.0, 0.0, 0.0)
    b = (0.0, 10.0, 0.0)
    c = (10.0, 10.0, 0.0)
    d = (10.0, 0.0, 0.0)
    pts = [a]
    flatten_bezier(a, b, c, d, 0.01, 0, pts)
    assert len(pts) >= 3
    assert pts[0] == a
    assert pts[-1] == d


def test_flatten_bezier_flat_emits_endpoint():
    a = (0.0, 0.0, 0.0)
    b = (3.0, 0.0, 0.0)
    c = (7.0, 0.0, 0.0)
    d = (10.0, 0.0, 0.0)
    pts = [a]
    flatten_bezier(a, b, c, d, 0.1, 0, pts)
    assert len(pts) == 2
    assert pts[0] == a
    assert pts[1] == d


class TestIsBezierFullyInsideRegions:
    def _square(self, x0, y0, size):
        return [
            (x0, y0),
            (x0 + size, y0),
            (x0 + size, y0 + size),
            (x0, y0 + size),
        ]

    def test_fully_inside_single_region(self):
        region = self._square(0, 0, 10)
        p0, c1, c2, p1 = (2, 2), (3, 4), (5, 4), (6, 2)
        assert is_bezier_inside_polygons(p0, c1, c2, p1, [region])

    def test_fully_outside(self):
        region = self._square(0, 0, 10)
        p0, c1, c2, p1 = (12, 12), (13, 14), (15, 14), (16, 12)
        assert not is_bezier_inside_polygons(p0, c1, c2, p1, [region])

    def test_bbox_corner_outside(self):
        region = self._square(0, 0, 5)
        p0, c1, c2, p1 = (2, 2), (3, 4), (5, 4), (6, 2)
        assert not is_bezier_inside_polygons(p0, c1, c2, p1, [region])

    def test_inside_one_of_multiple_regions(self):
        r1 = self._square(0, 0, 5)
        r2 = self._square(10, 0, 5)
        p0, c1, c2, p1 = (11, 1), (12, 3), (13, 3), (14, 1)
        assert is_bezier_inside_polygons(p0, c1, c2, p1, [r1, r2])

    def test_spread_across_regions(self):
        r1 = self._square(0, 0, 5)
        r2 = self._square(10, 0, 5)
        p0, c1, c2, p1 = (2, 2), (5, 4), (8, 4), (12, 2)
        assert not is_bezier_inside_polygons(p0, c1, c2, p1, [r1, r2])

    def test_degenerate_point_inside(self):
        region = self._square(0, 0, 10)
        p = (5, 5)
        assert is_bezier_inside_polygons(p, p, p, p, [region])

    def test_degenerate_point_outside(self):
        region = self._square(0, 0, 10)
        p = (15, 15)
        assert not is_bezier_inside_polygons(p, p, p, p, [region])

    def test_empty_regions(self):
        p0, c1, c2, p1 = (2, 2), (3, 4), (5, 4), (6, 2)
        assert not is_bezier_inside_polygons(p0, c1, c2, p1, [])

    def test_midpoint_outside(self):
        region = self._square(0, 0, 5)
        p0, c1, c2, p1 = (2, 1), (3, 8), (4, 8), (3, 1)
        assert not is_bezier_inside_polygons(p0, c1, c2, p1, [region])

    def test_linear_segment_inside(self):
        region = self._square(0, 0, 10)
        p0, c1, c2, p1 = (1, 5), (3, 5), (5, 5), (7, 5)
        assert is_bezier_inside_polygons(p0, c1, c2, p1, [region])


class TestFitCubicBezier:
    def test_two_points_returns_degenerate(self):
        pts = [(0.0, 0.0), (10.0, 0.0)]
        bz = fit_cubic_bezier(pts)
        assert bz is not None
        assert bz[0] == approx_pt((0.0, 0.0))
        assert bz[3] == approx_pt((10.0, 0.0))

    def test_three_collinear_points(self):
        pts = [(0.0, 0.0), (5.0, 0.0), (10.0, 0.0)]
        bz = fit_cubic_bezier(pts)
        assert bz is not None
        assert bz[0] == approx_pt((0.0, 0.0))
        assert bz[3] == approx_pt((10.0, 0.0))
        # interior points should be near the line
        for t in [0.25, 0.5, 0.75]:
            pt = get_bezier_point_at(bz[0], bz[1], bz[2], bz[3], t)
            assert abs(pt[1]) < 1e-9

    def test_sine_arc(self):
        xs = [float(i) for i in range(30)]
        pts = [(x, 10 + 8 * math.sin(x * 0.25)) for x in xs]
        bz = fit_cubic_bezier(pts)
        assert bz is not None
        assert bz[0] == approx_pt(pts[0])
        assert bz[3] == approx_pt(pts[-1])
        # fit should be reasonable — max error under 4.0 (a single cubic
        # bezier cannot perfectly match a multi-period sine wave)
        errs = []
        for tx, (ex, ey) in zip(xs, pts):
            t = tx / xs[-1]
            bx, by = get_bezier_point_at(bz[0], bz[1], bz[2], bz[3], t)
            errs.append(math.hypot(bx - ex, by - ey))
        assert max(errs) < 4.0

    def test_quarter_circle(self):
        def _y(x):
            return 10 + math.sqrt(max(0.0, 400.0 - (x - 25.0) ** 2))

        pts = [(float(5 * i), _y(float(5 * i))) for i in range(12)]
        bz = fit_cubic_bezier(pts)
        assert bz is not None
        assert bz[0] == approx_pt(pts[0])
        assert bz[3] == approx_pt(pts[-1])

    def test_fewer_than_two_returns_none(self):
        assert fit_cubic_bezier([(0.0, 0.0)]) is None
        assert fit_cubic_bezier([]) is None

    def test_all_coincident(self):
        pts = [(3.0, 3.0)] * 5
        bz = fit_cubic_bezier(pts)
        assert bz is not None
        for p in [bz[0], bz[1], bz[2], bz[3]]:
            assert p == approx_pt((3.0, 3.0))


class TestNearestTangentCircleOnBezier:
    def _eval(self, bz, t):
        return get_bezier_point_at(bz[0], bz[1], bz[2], bz[3], t)

    def test_straight_line_bezier(self):
        """Circle tangent to a horizontal line Bezier, passing through
        a point above the line.  The centre should be directly above the
        tangent point at distance r."""
        bz = ((0.0, 0.0), (5.0, 0.0), (15.0, 0.0), (20.0, 0.0))
        radius = 5.0
        point = (10.0, 8.0)
        result = nearest_tangent_circle_on_bezier(point, bz, radius)
        assert result is not None, "should find a valid circle"
        center, tangent, t = result
        d = math.hypot(center[0] - point[0], center[1] - point[1])
        assert d == pytest.approx(radius, rel=1e-2)
        d = math.hypot(center[0] - tangent[0], center[1] - tangent[1])
        assert d == pytest.approx(radius, rel=1e-2)
        # tangent point on the Bezier
        on_curve = self._eval(bz, t)
        assert on_curve == approx_pt(tangent)
        # centre should be above the line (y > 0)
        assert center[1] > 0.0

    def test_circular_arc_bezier(self):
        """Bezier approximating a quarter circle.  The tangent circle should
        contact the curve smoothly."""
        # quarter-circle: (10, 0) → (0, 10) with centre at (0, 0)
        bz = ((10.0, 0.0), (10.0, 4.0), (4.0, 10.0), (0.0, 10.0))
        radius = 2.0
        point = (12.0, 5.0)
        result = nearest_tangent_circle_on_bezier(point, bz, radius)
        assert result is not None, "should find a valid circle"
        center, tangent, t = result
        d = math.hypot(center[0] - point[0], center[1] - point[1])
        assert d == pytest.approx(radius, rel=1e-2)
        d = math.hypot(center[0] - tangent[0], center[1] - tangent[1])
        assert d == pytest.approx(radius, rel=1e-2)
        on_curve = self._eval(bz, t)
        assert on_curve == approx_pt(tangent)

    def test_point_on_curve(self):
        """When the point IS on the Bezier, the circle should be tangent
        at that point with centre at distance r along the normal."""
        bz = ((0.0, 0.0), (3.0, 6.0), (7.0, 6.0), (10.0, 0.0))
        tangent = self._eval(bz, 0.3)
        r = 3.0
        point = (tangent[0], tangent[1] + r)
        result = nearest_tangent_circle_on_bezier(point, bz, r)
        assert result is not None
        center, _, _ = result
        d = math.hypot(center[0] - point[0], center[1] - point[1])
        assert d == pytest.approx(r, rel=2e-2)

    def test_too_small_radius_returns_none(self):
        bz = ((0.0, 0.0), (5.0, 0.0), (15.0, 0.0), (20.0, 0.0))
        point = (10.0, 100.0)
        result = nearest_tangent_circle_on_bezier(point, bz, 1.0)
        assert result is None

    def test_negative_radius_returns_none(self):
        bz = ((0.0, 0.0), (5.0, 0.0), (15.0, 0.0), (20.0, 0.0))
        point = (10.0, 10.0)
        result = nearest_tangent_circle_on_bezier(point, bz, -1.0)
        assert result is None

    def test_symmetric_case(self):
        """Symmetric Bezier with point directly above the peak."""
        bz = ((0.0, 0.0), (5.0, 10.0), (15.0, 10.0), (20.0, 0.0))
        tangent = self._eval(bz, 0.5)  # peak at (10, 7.5)
        r = 3.0
        # point is 2r above the curve peak
        point = (tangent[0], tangent[1] + 2.0 * r)
        result = nearest_tangent_circle_on_bezier(point, bz, r)
        assert result is not None
        center, tangent_pt, t = result
        d = math.hypot(center[0] - point[0], center[1] - point[1])
        assert d == pytest.approx(r, rel=2e-2)
        d = math.hypot(center[0] - tangent_pt[0], center[1] - tangent_pt[1])
        assert d == pytest.approx(r, rel=2e-2)
        assert 0.0 < t < 1.0
