#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 jondale

import math

try:
    from inkex import Path
except ImportError:
    Path = None


def subpath_to_points(subpath, precision=1.0):
    points = []
    current_point = (0, 0)

    for cmd in subpath:
        if isinstance(cmd, tuple):
            letter = cmd[0].upper()
            coord = cmd[1]
        else:
            letter = cmd.letter.upper()
            args = cmd.args

        if letter == 'M':
            if isinstance(cmd, tuple):
                current_point = coord
            else:
                current_point = (args[0], args[1])
            points.append(current_point)

        elif letter == 'L':
            if isinstance(cmd, tuple):
                current_point = coord
            else:
                current_point = (args[0], args[1])
            points.append(current_point)

        elif letter == 'H':
            if isinstance(cmd, tuple):
                current_point = (coord[0], current_point[1])
            else:
                current_point = (args[0], current_point[1])
            points.append(current_point)

        elif letter == 'V':
            if isinstance(cmd, tuple):
                current_point = (current_point[0], coord[1])
            else:
                current_point = (current_point[0], args[0])
            points.append(current_point)

        elif letter == 'C':
            p0 = current_point
            p1 = (args[0], args[1])
            p2 = (args[2], args[3])
            p3 = (args[4], args[5])

            chord_length = distance(p0, p3)
            control_length = distance(p0, p1) + distance(p1, p2) + distance(p2, p3)
            approx_length = (chord_length + control_length) / 2

            num_segments = max(2, int(approx_length / precision))

            for i in range(1, num_segments + 1):
                t = i / num_segments
                point = cubic_bezier_point(p0, p1, p2, p3, t)
                points.append(point)

            current_point = p3

        elif letter == 'S':
            p0 = current_point
            p3 = (args[2], args[3])

            chord_length = distance(p0, p3)
            num_segments = max(2, int(chord_length / precision))

            for i in range(1, num_segments + 1):
                t = i / num_segments
                x = p0[0] + t * (p3[0] - p0[0])
                y = p0[1] + t * (p3[1] - p0[1])
                points.append((x, y))

            current_point = p3

        elif letter == 'Q':
            p0 = current_point
            p1 = (args[0], args[1])
            p2 = (args[2], args[3])

            chord_length = distance(p0, p2)
            control_length = distance(p0, p1) + distance(p1, p2)
            approx_length = (chord_length + control_length) / 2

            num_segments = max(2, int(approx_length / precision))

            for i in range(1, num_segments + 1):
                t = i / num_segments
                point = quadratic_bezier_point(p0, p1, p2, t)
                points.append(point)

            current_point = p2

        elif letter == 'T':
            p0 = current_point
            p2 = (args[0], args[1])

            chord_length = distance(p0, p2)
            num_segments = max(2, int(chord_length / precision))

            for i in range(1, num_segments + 1):
                t = i / num_segments
                x = p0[0] + t * (p2[0] - p0[0])
                y = p0[1] + t * (p2[1] - p0[1])
                points.append((x, y))

            current_point = p2

        elif letter == 'A':
            rx = abs(args[0])
            ry = abs(args[1])
            x_axis_rotation = args[2]
            large_arc_flag = args[3]
            sweep_flag = args[4]
            end_x = args[5]
            end_y = args[6]

            start_x, start_y = current_point

            beziers = arc_to_beziers(start_x, start_y, rx, ry, x_axis_rotation,
                                     large_arc_flag, sweep_flag, end_x, end_y)

            for bez in beziers:
                p0, p1, p2, p3 = bez
                chord_length = distance(p0, p3)
                control_length = distance(p0, p1) + distance(p1, p2) + distance(p2, p3)
                approx_length = (chord_length + control_length) / 2

                num_segments = max(2, int(approx_length / precision))

                for i in range(1, num_segments + 1):
                    t = i / num_segments
                    point = cubic_bezier_point(p0, p1, p2, p3, t)
                    points.append(point)

            current_point = (end_x, end_y)

    return points


def arc_to_beziers(x1, y1, rx, ry, phi, large_arc, sweep, x2, y2):
    if (x1, y1) == (x2, y2):
        return []
    if rx == 0 or ry == 0:
        return [((x1, y1), (x1, y1), (x2, y2), (x2, y2))]

    phi_rad = math.radians(phi)
    cos_phi = math.cos(phi_rad)
    sin_phi = math.sin(phi_rad)

    dx = (x1 - x2) / 2
    dy = (y1 - y2) / 2
    x1_prime = cos_phi * dx + sin_phi * dy
    y1_prime = -sin_phi * dx + cos_phi * dy

    lambda_ = (x1_prime / rx) ** 2 + (y1_prime / ry) ** 2
    if lambda_ > 1:
        rx *= math.sqrt(lambda_)
        ry *= math.sqrt(lambda_)

    sq = max(0, (rx * ry) ** 2 - (rx * y1_prime) ** 2 - (ry * x1_prime) ** 2)
    sq = math.sqrt(sq / ((rx * y1_prime) ** 2 + (ry * x1_prime) ** 2))

    if large_arc == sweep:
        sq = -sq

    cx_prime = sq * rx * y1_prime / ry
    cy_prime = -sq * ry * x1_prime / rx

    cx = cos_phi * cx_prime - sin_phi * cy_prime + (x1 + x2) / 2
    cy = sin_phi * cx_prime + cos_phi * cy_prime + (y1 + y2) / 2

    def angle_between(ux, uy, vx, vy):
        n = math.sqrt(ux * ux + uy * uy) * math.sqrt(vx * vx + vy * vy)
        c = (ux * vx + uy * vy) / n
        c = max(-1, min(1, c))
        angle = math.acos(c)
        if ux * vy - uy * vx < 0:
            angle = -angle
        return angle

    theta1 = angle_between(1, 0, (x1_prime - cx_prime) / rx, (y1_prime - cy_prime) / ry)
    dtheta = angle_between(
        (x1_prime - cx_prime) / rx, (y1_prime - cy_prime) / ry,
        (-x1_prime - cx_prime) / rx, (-y1_prime - cy_prime) / ry
    )

    if sweep == 0 and dtheta > 0:
        dtheta -= 2 * math.pi
    elif sweep == 1 and dtheta < 0:
        dtheta += 2 * math.pi

    segments = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2))))
    delta = dtheta / segments

    beziers = []
    for i in range(segments):
        theta_start = theta1 + i * delta
        theta_end = theta_start + delta

        alpha = math.sin(delta) * (math.sqrt(4 + 3 * math.tan(delta / 2) ** 2) - 1) / 3

        cos_start = math.cos(theta_start)
        sin_start = math.sin(theta_start)
        cos_end = math.cos(theta_end)
        sin_end = math.sin(theta_end)

        q1x = cos_start
        q1y = sin_start
        q2x = cos_end
        q2y = sin_end

        cp1x = q1x - q1y * alpha
        cp1y = q1y + q1x * alpha
        cp2x = q2x + q2y * alpha
        cp2y = q2y - q2x * alpha

        def transform(x, y):
            x *= rx
            y *= ry
            nx = cos_phi * x - sin_phi * y
            ny = sin_phi * x + cos_phi * y
            return (nx + cx, ny + cy)

        p0 = transform(q1x, q1y)
        p1 = transform(cp1x, cp1y)
        p2 = transform(cp2x, cp2y)
        p3 = transform(q2x, q2y)

        beziers.append((p0, p1, p2, p3))

    return beziers


def distance(p1, p2):
    dx = p2[0] - p1[0]
    dy = p2[1] - p1[1]
    return math.sqrt(dx * dx + dy * dy)


def cubic_bezier_point(p0, p1, p2, p3, t):
    mt = 1 - t
    mt2 = mt * mt
    mt3 = mt2 * mt
    t2 = t * t
    t3 = t2 * t

    x = mt3 * p0[0] + 3 * mt2 * t * p1[0] + 3 * mt * t2 * p2[0] + t3 * p3[0]
    y = mt3 * p0[1] + 3 * mt2 * t * p1[1] + 3 * mt * t2 * p2[1] + t3 * p3[1]

    return (x, y)


def quadratic_bezier_point(p0, p1, p2, t):
    mt = 1 - t
    mt2 = mt * mt
    t2 = t * t

    x = mt2 * p0[0] + 2 * mt * t * p1[0] + t2 * p2[0]
    y = mt2 * p0[1] + 2 * mt * t * p1[1] + t2 * p2[1]

    return (x, y)


def point_in_polygon(point, polygon):
    x, y = point
    n = len(polygon)
    inside = False

    p1x, p1y = polygon[0]
    for i in range(1, n + 1):
        p2x, p2y = polygon[i % n]

        if y > min(p1y, p2y):
            if y <= max(p1y, p2y):
                if x <= max(p1x, p2x):
                    if p1y != p2y:
                        xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
                    if p1x == p2x or x <= xinters:
                        inside = not inside

        p1x, p1y = p2x, p2y

    return inside


def perpendicular_distance(point, line_start, line_end):
    x0, y0 = point
    x1, y1 = line_start
    x2, y2 = line_end

    dx = x2 - x1
    dy = y2 - y1
    line_length_sq = dx * dx + dy * dy

    if line_length_sq == 0:
        return distance(point, line_start)

    numerator = abs(dy * x0 - dx * y0 + x2 * y1 - y2 * x1)
    return numerator / math.sqrt(line_length_sq)


def simplify_path_rdp(points, epsilon):
    if len(points) < 3:
        return points

    max_dist = 0.0
    max_index = 0

    for i in range(1, len(points) - 1):
        dist = perpendicular_distance(points[i], points[0], points[-1])
        if dist > max_dist:
            max_dist = dist
            max_index = i

    if max_dist > epsilon:
        left = simplify_path_rdp(points[:max_index + 1], epsilon)
        right = simplify_path_rdp(points[max_index:], epsilon)

        return left[:-1] + right
    else:
        return [points[0], points[-1]]


def simplify_closed_path(points, epsilon):
    if len(points) < 4:
        return points

    simplified = simplify_path_rdp(points, epsilon)

    if simplified[0] != points[0]:
        simplified.insert(0, points[0])

    return simplified


def segments_intersect(p1, p2, p3, p4):
    x1, y1 = p1
    x2, y2 = p2
    x3, y3 = p3
    x4, y4 = p4

    denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)

    if abs(denom) < 1e-10:
        return False, None

    t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
    u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom

    if 0 < t < 1 and 0 < u < 1:
        ix = x1 + t * (x2 - x1)
        iy = y1 + t * (y2 - y1)
        return True, (ix, iy)

    return False, None


def remove_self_intersections(points, offset_distance, debug=False):
    if len(points) < 4:
        return points

    centroid_x = sum(p[0] for p in points) / len(points)
    centroid_y = sum(p[1] for p in points) / len(points)

    max_iterations = 100
    iteration = 0

    while iteration < max_iterations:
        iteration += 1
        found_intersection = False

        n = len(points)
        for i in range(n):
            if found_intersection:
                break

            p1 = points[i]
            p2 = points[(i + 1) % n]

            for j in range(i + 2, n):
                if j == (i + n - 1) % n:
                    continue

                p3 = points[j]
                p4 = points[(j + 1) % n]

                intersects, int_point = segments_intersect(p1, p2, p3, p4)

                if intersects:
                    if debug:
                        print(f"  Found intersection between segments {i}-{i+1} and {j}-{j+1}")
                        print(f"    Intersection point: {int_point}")

                    path_a = points[:i+1] + [int_point] + points[j+1:]
                    path_b = [int_point] + points[i+1:j+1]

                    def signed_area(pts):
                        if len(pts) < 3:
                            return 0.0
                        area = 0.0
                        for k in range(len(pts)):
                            x1, y1 = pts[k]
                            x2, y2 = pts[(k + 1) % len(pts)]
                            area += (x2 - x1) * (y2 + y1)
                        return area / 2.0

                    area_a = abs(signed_area(path_a))
                    area_b = abs(signed_area(path_b))

                    if debug:
                        print(f"    Path A area (without loop): {area_a:.3f}")
                        print(f"    Path B area (loop only): {area_b:.3f}")

                    if area_a >= area_b:
                        points = path_a
                        if debug:
                            print(f"    Keeping path A (larger/outer envelope)")
                    else:
                        points = path_b
                        if debug:
                            print(f"    Keeping path B (the loop was larger)")

                    found_intersection = True
                    break

        if not found_intersection:
            break

    if debug and iteration > 1:
        print(f"  Removed self-intersections in {iteration} iterations")

    if len(points) < 3:
        if debug:
            print(f"  WARNING: Ended with too few points ({len(points)}), this may indicate a problem")

    return points


def calculate_polygon_winding(points):
    signed_area = 0.0
    n = len(points)
    for i in range(n):
        x1, y1 = points[i]
        x2, y2 = points[(i + 1) % n]
        signed_area += (x2 - x1) * (y2 + y1)

    return -1 if signed_area > 0 else 1


def calculate_perpendicular_offset(point, prev_point, next_point, offset_distance, polygon_winding, debug=False):
    e1x = point[0] - prev_point[0]
    e1y = point[1] - prev_point[1]
    e2x = next_point[0] - point[0]
    e2y = next_point[1] - point[1]

    if debug:
        print(f"    Edge vectors: e1=({e1x:.3f}, {e1y:.3f}), e2=({e2x:.3f}, {e2y:.3f})")

    len1 = math.sqrt(e1x * e1x + e1y * e1y)
    len2 = math.sqrt(e2x * e2x + e2y * e2y)

    if len1 > 0:
        e1x /= len1
        e1y /= len1
    if len2 > 0:
        e2x /= len2
        e2y /= len2

    if debug:
        print(f"    Normalized edges: e1=({e1x:.3f}, {e1y:.3f}), e2=({e2x:.3f}, {e2y:.3f})")

    tangent_x = e1x + e2x
    tangent_y = e1y + e2y

    tangent_len = math.sqrt(tangent_x * tangent_x + tangent_y * tangent_y)
    if tangent_len > 0:
        tangent_x /= tangent_len
        tangent_y /= tangent_len

    if debug:
        print(f"    Average tangent: ({tangent_x:.3f}, {tangent_y:.3f})")

    normal_x = tangent_y
    normal_y = -tangent_x

    if debug:
        print(f"    Normal (perpendicular to tangent, 90° CW): ({normal_x:.3f}, {normal_y:.3f})")

    offset_dir_x = normal_x
    offset_dir_y = normal_y

    if debug:
        print(f"    Offset direction (before flip): ({offset_dir_x:.3f}, {offset_dir_y:.3f})")
        print(f"    Global polygon winding: {'CCW' if polygon_winding > 0 else 'CW'}")

    should_flip = False
    if offset_distance > 0 and polygon_winding < 0:
        should_flip = True
    elif offset_distance < 0 and polygon_winding > 0:
        should_flip = True

    if should_flip:
        offset_dir_x = -offset_dir_x
        offset_dir_y = -offset_dir_y
        if debug:
            if offset_distance < 0:
                print(f"    Flipped for inward offset on CCW polygon: ({offset_dir_x:.3f}, {offset_dir_y:.3f})")
            else:
                print(f"    Flipped for outward offset on CW polygon: ({offset_dir_x:.3f}, {offset_dir_y:.3f})")

    dot_product = e1x * e2x + e1y * e2y
    dot_product = max(-1.0, min(1.0, dot_product))
    direction_angle = math.acos(dot_product)

    turn_angle = math.pi - direction_angle

    if debug:
        print(f"    Direction angle: {math.degrees(direction_angle):.1f}°, Turn angle: {math.degrees(turn_angle):.1f}°")

    if turn_angle > math.pi * 0.9:
        miter_limit = 1.0
    else:
        half_turn = turn_angle / 2
        if abs(math.sin(half_turn)) > 0.01:
            miter_limit = 1.0 / math.sin(half_turn)
            miter_limit = min(miter_limit, 2.0)
        else:
            miter_limit = 2.0

    if debug:
        print(f"    Miter limit: {miter_limit:.3f}")

    adjusted_distance = abs(offset_distance) * miter_limit
    offset_x = point[0] + offset_dir_x * adjusted_distance
    offset_y = point[1] + offset_dir_y * adjusted_distance

    if debug:
        print(f"    Adjusted distance: {adjusted_distance:.3f}")
        print(f"    Final offset point: ({offset_x:.3f}, {offset_y:.3f})")

    return (offset_x, offset_y)


def offset_path(subpath, offset_distance, precision=0.05, debug=False):
    try:
        try:
            from inkex import Path
            if isinstance(subpath, Path):
                subpath = list(subpath)
        except (ImportError, NameError):
            pass

        points = subpath_to_points(subpath, precision)

        if len(points) < 3:
            if debug:
                print(f"ERROR: Not enough points ({len(points)}) after approximation")
            return None

        if len(points) > 1:
            first = points[0]
            last = points[-1]
            dist = math.sqrt((last[0] - first[0])**2 + (last[1] - first[1])**2)
            if dist < 0.001:
                points = points[:-1]
                if debug:
                    print(f"Removed duplicate closing point (distance: {dist:.6f})")

        original_count = len(points)
        points = simplify_closed_path(points, epsilon=precision * 2)
        if debug:
            print(f"Pre-simplified from {original_count} to {len(points)} points")

        if len(points) < 3:
            if debug:
                print(f"ERROR: Not enough points ({len(points)}) after pre-simplification")
            return None

        polygon_winding = calculate_polygon_winding(points)

        if debug:
            print(f"\n=== OFFSET DEBUG ===")
            print(f"Original polygon ({len(points)} points after pre-simplification):")
            print(f"  First 10 points: {points[:10]}")
            print(f"  Last 5 points: {points[-5:]}")
            print(f"Offset distance: {offset_distance}")
            print(f"Precision: {precision}")
            print(f"Global polygon winding: {'CCW' if polygon_winding > 0 else 'CW'}")
            print(f"\nProcessing first 5 points to show edge vectors:")

        offset_points = []
        n = len(points)
        for i, point in enumerate(points):
            prev_point = points[(i - 1) % n]
            next_point = points[(i + 1) % n]

            offset_point = calculate_perpendicular_offset(point, prev_point, next_point, offset_distance, polygon_winding, debug=(debug and i < 5))

            if debug and i < 5:
                e1_dx = point[0] - prev_point[0]
                e1_dy = point[1] - prev_point[1]
                e2_dx = next_point[0] - point[0]
                e2_dy = next_point[1] - point[1]
                print(f"\n  Point [{i}]: {point}")
                print(f"    Prev point: {prev_point}")
                print(f"    Next point: {next_point}")
                print(f"    Edge 1 vector (from prev): ({e1_dx:.3f}, {e1_dy:.3f})")
                print(f"    Edge 2 vector (to next): ({e2_dx:.3f}, {e2_dy:.3f})")
                print(f"    Offset point: {offset_point}")
                print(f"    Movement: ({offset_point[0] - point[0]:.3f}, {offset_point[1] - point[1]:.3f})")

            if offset_point:
                offset_points.append(offset_point)

        if debug:
            print(f"\nResult:")
            print(f"  Offset points generated: {len(offset_points)}")
            print(f"  First 5 offset points: {offset_points[:5]}")
            print(f"  Last 5 offset points: {offset_points[-5:]}")

        if len(offset_points) < 3:
            if debug:
                print(f"ERROR: Not enough offset points ({len(offset_points)})")
            return None

        if debug:
            print(f"\nRemoving self-intersections...")
        cleaned_points = remove_self_intersections(offset_points, offset_distance, debug=debug)

        if debug:
            print(f"  Points after cleaning: {len(cleaned_points)}")
            if len(cleaned_points) != len(offset_points):
                print(f"  Removed {len(offset_points) - len(cleaned_points)} points from self-intersecting loops")

        simplified_points = simplify_closed_path(cleaned_points, epsilon=precision)

        if debug:
            print(f"\nSimplification:")
            print(f"  Cleaned offset points: {len(cleaned_points)}")
            print(f"  Simplified points: {len(simplified_points)}")
            print(f"  Reduction: {len(cleaned_points) - len(simplified_points)} points ({100 * (1 - len(simplified_points) / len(cleaned_points)):.1f}%)")

        offset_points = simplified_points

        path_str = f"M {offset_points[0][0]},{offset_points[0][1]}"
        for point in offset_points[1:]:
            path_str += f" L {point[0]},{point[1]}"
        path_str += " Z"

        if debug:
            print(f"  Path string (first 100 chars): {path_str[:100]}...")
            print(f"=== END DEBUG ===\n")

        try:
            from inkex import Path as InkexPath
            return InkexPath(path_str)
        except ImportError:
            result = [('M', offset_points[0])]
            for point in offset_points[1:]:
                result.append(('L', point))
            result.append(('Z', None))
            return result

    except Exception as e:
        print(f"Offset failed: {e}")
        import traceback
        traceback.print_exc()
        return None


def offset_lpe(element, offset_distance, unit="mm"):
    lpe_str = (
        f"offset,"
        f"0,1,"
        f"offset:{offset_distance},"
        f"linejoin_type:miter,"
        f"miter_limit:4,"
        f"attempt_force_join:false,"
        f"update_on_knot_move:true;"
    )

    existing_lpe = element.get('inkscape:path-effect', '')
    if existing_lpe:
        new_lpe = existing_lpe + lpe_str
    else:
        new_lpe = lpe_str

    element.set('inkscape:path-effect', new_lpe)

    if not element.get('inkscape:original-d'):
        element.set('inkscape:original-d', element.get('d'))

    return element


def boolean_lpe(svg, element, operand_elements, operation="union"):
    if not operand_elements:
        return element

    defs = svg.defs
    if defs is None:
        from lxml import etree
        defs = etree.SubElement(svg.getroot(), 'defs')

    hidder_filter_id = "selectable_hidder_filter"
    hidder_filter = svg.getElementById(hidder_filter_id)
    if hidder_filter is None:
        from lxml import etree
        nsmap = {'inkscape': 'http://www.inkscape.org/namespaces/inkscape'}
        hidder_filter = etree.SubElement(defs, 'filter', {
            'id': hidder_filter_id,
            'width': '1',
            'height': '1',
            'x': '0',
            'y': '0',
            'style': 'color-interpolation-filters:sRGB;',
            '{http://www.inkscape.org/namespaces/inkscape}label': 'LPE boolean visibility'
        })
        fe_composite = etree.SubElement(hidder_filter, 'feComposite', {
            'id': 'boolops_hidder_primitive',
            'result': 'composite1',
            'operator': 'arithmetic',
            'in2': 'SourceGraphic',
            'in': 'BackgroundImage'
        })

    lpe_refs = []

    for operand in operand_elements:
        operand_id = operand.get('id')
        if not operand_id:
            continue

        lpe_id = svg.get_unique_id('path-effect')

        from lxml import etree
        path_effect = etree.SubElement(defs, '{http://www.inkscape.org/namespaces/inkscape}path-effect', {
            'effect': 'bool_op',
            'operand-path': f'#{operand_id}',
            'id': lpe_id,
            'is_visible': 'true',
            'lpeversion': '1',
            'operation': operation,
            'swap-operands': 'false',
            'filltype-this': 'from-curve',
            'filter': '',
            'filltype-operand': 'from-curve'
        })

        lpe_refs.append(f'#{lpe_id}')

        existing_style = operand.get('style', '')
        if 'filter:' not in existing_style:
            if existing_style and not existing_style.endswith(';'):
                existing_style += ';'
            operand.set('style', existing_style + f'filter:url(#{hidder_filter_id})')

    existing_lpe = element.get('{http://www.inkscape.org/namespaces/inkscape}path-effect', '')
    if existing_lpe:
        new_lpe = existing_lpe + ';' + ';'.join(lpe_refs)
    else:
        new_lpe = ';'.join(lpe_refs)

    element.set('{http://www.inkscape.org/namespaces/inkscape}path-effect', new_lpe)

    return element
