#!/usr/bin/env python3

import sys
import os

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deps'))

import math
from lxml import etree

import inkex
from inkex import Transform, BoundingBox, Path, CubicSuperPath, PathElement, Group
from inkex.elements import ShapeElement, Rectangle, Circle, Ellipse, Polygon, Polyline, Line


TOLERANCE = 1e-10
MIN_HATCH_LENGTH_AS_FRACTION = 0.25
RECURSION_LIMIT = 500
BEZIER_OVERSHOOT = 0.75


def distance_squared(p1, p2):
    dx = p2[0] - p1[0]
    dy = p2[1] - p1[1]
    return dx * dx + dy * dy


def distance(p1, p2):
    return math.sqrt(distance_squared(p1, p2))


def intersect_lines(p1, p2, p3, p4):
    d21x = p2[0] - p1[0]
    d21y = p2[1] - p1[1]
    d43x = p4[0] - p3[0]
    d43y = p4[1] - p3[1]

    denom = d21x * d43y - d21y * d43x
    if abs(denom) < TOLERANCE:
        return -1.0

    num_a = (p1[1] - p3[1]) * d43x - (p1[0] - p3[0]) * d43y
    num_b = (p1[1] - p3[1]) * d21x - (p1[0] - p3[0]) * d21y

    sa = num_a / denom
    sb = num_b / denom

    if 0.0 <= sa <= 1.0 and 0.0 <= sb <= 1.0:
        return sa
    return -1.0


def subdivide_cubic_path(sp, flatness):
    while True:
        changed = False
        i = 1
        while i < len(sp):
            p0 = sp[i - 1][1]
            p1 = sp[i - 1][2]
            p2 = sp[i][0]
            p3 = sp[i][1]

            b = (p0[0] + p3[0]) / 2.0, (p0[1] + p3[1]) / 2.0
            c1 = (p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0
            c2 = (p2[0] + p3[0]) / 2.0, (p2[1] + p3[1]) / 2.0

            dx1 = c1[0] - b[0]
            dy1 = c1[1] - b[1]
            dx2 = c2[0] - b[0]
            dy2 = c2[1] - b[1]

            err = max(abs(dx1), abs(dy1), abs(dx2), abs(dy2))

            if err > flatness:
                m01 = ((p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0)
                m12 = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0)
                m23 = ((p2[0] + p3[0]) / 2.0, (p2[1] + p3[1]) / 2.0)
                m012 = ((m01[0] + m12[0]) / 2.0, (m01[1] + m12[1]) / 2.0)
                m123 = ((m12[0] + m23[0]) / 2.0, (m12[1] + m23[1]) / 2.0)
                mid = ((m012[0] + m123[0]) / 2.0, (m012[1] + m123[1]) / 2.0)

                sp[i - 1][2] = m01
                new_node = [m012, mid, m123]
                sp[i][0] = m23
                sp.insert(i, new_node)
                changed = True
            i += 1

        if not changed:
            break


class HatchFill(inkex.EffectExtension):

    def add_arguments(self, pars):
        pars.add_argument('--tab', type=str, default='hatch')
        pars.add_argument('--hatchSpacing', type=float, default=1.0,
                          help='Spacing between hatch lines')
        pars.add_argument('--hatchAngle', type=float, default=0.0,
                          help='Angle of hatch lines in degrees')
        pars.add_argument('--crossHatch', type=inkex.Boolean, default=False,
                          help='Generate cross-hatch pattern')
        pars.add_argument('--units', type=int, default=2,
                          help='Units: 1=px, 2=mm, 3=in')
        pars.add_argument('--insetDistance', type=float, default=0,
                          help='Inset distance from edges')
        pars.add_argument('--tolerance', type=float, default=1.0,
                          help='Curve tolerance')
        pars.add_argument('--connectEnds', type=inkex.Boolean, default=False,
                          help='Connect nearby hatch ends')
        pars.add_argument('--connectTolerance', type=float, default=1.0,
                          help='Tolerance for connecting ends')

    def effect(self):
        unit_factors = {1: 1.0, 2: 96.0 / 25.4, 3: 96.0}
        unit_factor = unit_factors.get(self.options.units, 1.0)

        self.hatch_spacing = self.options.hatchSpacing * unit_factor
        self.hatch_angle = math.radians(self.options.hatchAngle)
        self.cross_hatch = self.options.crossHatch
        self.inset_distance = self.options.insetDistance * unit_factor
        self.tolerance = self.options.tolerance
        self.connect_ends = self.options.connectEnds
        self.connect_tolerance = self.options.connectTolerance * unit_factor

        if self.hatch_spacing < 0.1:
            self.hatch_spacing = 0.1

        shape_types = (PathElement, Rectangle, Circle, Ellipse, Polygon, Polyline)

        if self.svg.selection:
            elements = list(self.svg.selection.filter(*shape_types))
        else:
            elements = list(self.svg.descendants().filter(*shape_types))

        if not elements:
            inkex.errormsg("No shapes selected. Please select one or more closed shapes.")
            return

        any_hatches = False

        for elem in elements:
            self.polygons = []
            self.transforms = []
            self.process_element(elem)

            if not self.polygons:
                continue

            hatches = self.generate_hatches()

            if self.cross_hatch:
                original_angle = self.hatch_angle
                self.hatch_angle += math.pi / 2
                hatches.extend(self.generate_hatches())
                self.hatch_angle = original_angle

            if not hatches:
                continue

            if self.connect_ends and self.connect_tolerance > 0:
                hatches = self.connect_hatches(hatches)

            self.add_hatches_for_element(elem, hatches)
            any_hatches = True

        if not any_hatches:
            inkex.errormsg("No hatch lines were generated. Check that paths are closed.")

    def process_element(self, elem):
        try:
            if hasattr(elem, 'get_path'):
                path = elem.get_path()
            elif hasattr(elem, 'path'):
                path = elem.path
            else:
                return

            if not path:
                return

            transform = elem.composed_transform()

            csp = path.to_superpath()

            for subpath in csp:
                if len(subpath) < 2:
                    continue

                subdivide_cubic_path(subpath, self.tolerance)

                vertices = [(pt[1][0], pt[1][1]) for pt in subpath]

                is_closed = False
                if len(vertices) >= 3:
                    d = distance(vertices[0], vertices[-1])
                    if d < 1.0:
                        is_closed = True
                    elif isinstance(elem, (Rectangle, Circle, Ellipse, Polygon)):
                        is_closed = True
                        if d >= 1.0:
                            vertices.append(vertices[0])

                if is_closed:
                    transformed = []
                    for v in vertices:
                        pt = transform.apply_to_point(v)
                        transformed.append((pt.x, pt.y))

                    self.polygons.append(transformed)
                    self.transforms.append(transform)

        except Exception as e:
            pass

    def get_bounding_box(self):
        if not self.polygons:
            return None

        min_x = min_y = float('inf')
        max_x = max_y = float('-inf')

        for poly in self.polygons:
            for x, y in poly:
                min_x = min(min_x, x)
                min_y = min(min_y, y)
                max_x = max(max_x, x)
                max_y = max(max_y, y)

        return (min_x, min_y, max_x, max_y)

    def generate_hatches(self):
        bbox = self.get_bounding_box()
        if not bbox:
            return []

        min_x, min_y, max_x, max_y = bbox
        cx = (min_x + max_x) / 2
        cy = (min_y + max_y) / 2

        diagonal = math.sqrt((max_x - min_x) ** 2 + (max_y - min_y) ** 2) / 2

        perp_angle = self.hatch_angle + math.pi / 2
        cos_a = math.cos(perp_angle)
        sin_a = math.sin(perp_angle)

        line_cos = math.cos(self.hatch_angle)
        line_sin = math.sin(self.hatch_angle)

        hatch_lines = []
        offset = -diagonal

        while offset <= diagonal:
            x1 = cx + offset * cos_a - diagonal * line_cos
            y1 = cy + offset * sin_a - diagonal * line_sin
            x2 = cx + offset * cos_a + diagonal * line_cos
            y2 = cy + offset * sin_a + diagonal * line_sin

            hatch_lines.append(((x1, y1), (x2, y2)))
            offset += self.hatch_spacing

        hatches = []

        for h_line in hatch_lines:
            segments = self.get_hatch_segments(h_line)
            hatches.extend(segments)

        return hatches

    def get_hatch_segments(self, h_line):
        p1, p2 = h_line
        intersections = []

        for poly in self.polygons:
            n = len(poly)
            for i in range(n):
                p3 = poly[i]
                p4 = poly[(i + 1) % n]

                t = intersect_lines(p1, p2, p3, p4)
                if t >= 0:
                    ix = p1[0] + t * (p2[0] - p1[0])
                    iy = p1[1] + t * (p2[1] - p1[1])
                    intersections.append((t, ix, iy))

        intersections.sort(key=lambda x: x[0])

        filtered = []
        prev_t = -1
        for item in intersections:
            if item[0] - prev_t > TOLERANCE:
                filtered.append(item)
                prev_t = item[0]

        segments = []
        i = 0
        while i < len(filtered) - 1:
            start = filtered[i]
            end = filtered[i + 1]

            seg_len = distance((start[1], start[2]), (end[1], end[2]))
            min_len = self.hatch_spacing * MIN_HATCH_LENGTH_AS_FRACTION

            if seg_len >= min_len:
                if self.inset_distance > 0 and seg_len > 2 * self.inset_distance:
                    dx = end[1] - start[1]
                    dy = end[2] - start[2]
                    factor = self.inset_distance / seg_len

                    sx = start[1] + dx * factor
                    sy = start[2] + dy * factor
                    ex = end[1] - dx * factor
                    ey = end[2] - dy * factor

                    segments.append(((sx, sy), (ex, ey)))
                else:
                    segments.append(((start[1], start[2]), (end[1], end[2])))

            i += 2

        return segments

    def connect_hatches(self, hatches):
        if not hatches:
            return hatches

        result = []
        used = [False] * len(hatches)
        tol_sq = self.connect_tolerance * self.connect_tolerance

        i = 0
        while i < len(hatches):
            if used[i]:
                i += 1
                continue

            used[i] = True
            current_path = [hatches[i][0], hatches[i][1]]

            changed = True
            iterations = 0
            while changed and iterations < RECURSION_LIMIT:
                changed = False
                iterations += 1

                end = current_path[-1]

                best_j = -1
                best_dist = tol_sq
                best_reverse = False

                for j, h in enumerate(hatches):
                    if used[j]:
                        continue

                    d1 = distance_squared(end, h[0])
                    d2 = distance_squared(end, h[1])

                    if d1 < best_dist:
                        best_dist = d1
                        best_j = j
                        best_reverse = False
                    if d2 < best_dist:
                        best_dist = d2
                        best_j = j
                        best_reverse = True

                if best_j >= 0:
                    used[best_j] = True
                    if best_reverse:
                        current_path.append(hatches[best_j][1])
                        current_path.append(hatches[best_j][0])
                    else:
                        current_path.append(hatches[best_j][0])
                        current_path.append(hatches[best_j][1])
                    changed = True

            for k in range(0, len(current_path) - 1, 2):
                result.append((current_path[k], current_path[k + 1]))

            i += 1

        return result

    def add_hatches_for_element(self, source_elem, hatches):
        if not hatches:
            return

        path_data = []
        for start, end in hatches:
            path_data.append(f'M {start[0]:.4f},{start[1]:.4f}')
            path_data.append(f'L {end[0]:.4f},{end[1]:.4f}')

        path_str = ' '.join(path_data)

        path_elem = PathElement()
        path_elem.path = Path(path_str)

        source_style = source_elem.style
        hatch_style = {
            'fill': 'none',
            'stroke-linecap': 'round',
            'stroke-linejoin': 'round'
        }

        if 'stroke' in source_style:
            hatch_style['stroke'] = source_style['stroke']
        else:
            if 'fill' in source_style and source_style['fill'] != 'none':
                hatch_style['stroke'] = source_style['fill']
            else:
                hatch_style['stroke'] = '#000000'

        if 'stroke-width' in source_style:
            hatch_style['stroke-width'] = source_style['stroke-width']
        else:
            hatch_style['stroke-width'] = '1'

        if 'stroke-opacity' in source_style:
            hatch_style['stroke-opacity'] = source_style['stroke-opacity']

        if 'opacity' in source_style:
            hatch_style['opacity'] = source_style['opacity']

        path_elem.style = hatch_style

        parent = source_elem.getparent()
        if parent is not None:
            index = list(parent).index(source_elem)
            parent.insert(index + 1, path_elem)
        else:
            self.svg.get_current_layer().append(path_elem)


if __name__ == '__main__':
    HatchFill().run()
