import math
import itertools

from dataclasses import dataclass, KW_ONLY, replace


@dataclass
class GraphicPrimitive:
    _ : KW_ONLY
    polarity_dark : bool = True


def rotate_point(x, y, angle, cx=0, cy=0):
    """ rotate point (x,y) around (cx,cy) clockwise angle radians """

    return (cx + (x - cx) * math.cos(-angle) - (y - cy) * math.sin(-angle),
            cy + (x - cx) * math.sin(-angle) + (y - cy) * math.cos(-angle))

def min_none(a, b):
    if a is None:
        return b
    if b is None:
        return a
    return min(a, b)

def max_none(a, b):
    if a is None:
        return b
    if b is None:
        return a
    return max(a, b)

def add_bounds(b1, b2):
    (min_x_1, min_y_1), (max_x_1, max_y_1) = b1
    (min_x_2, min_y_2), (max_x_2, max_y_2) = b2
    min_x, min_y = min_none(min_x_1, min_x_2), min_none(min_y_1, min_y_2)
    max_x, max_y = max_none(max_x_1, max_x_2), max_none(max_y_1, max_y_2)
    return ((min_x, min_y), (max_x, max_y))

def rad_to_deg(x):
    return x/math.pi * 180

@dataclass
class Circle(GraphicPrimitive):
    x : float
    y : float
    r : float # Here, we use radius as common in modern computer graphics, not diameter as gerber uses.

    def bounding_box(self):
        return ((self.x-self.r, self.y-self.r), (self.x+self.r, self.y+self.r))

    def to_svg(self, tag, color='black'):
        return tag('circle', cx=self.x, cy=self.y, r=self.r, style=f'fill: {color}')


@dataclass
class Obround(GraphicPrimitive):
    x : float
    y : float
    w : float
    h : float
    rotation : float # radians!

    def to_line(self):
        if self.w > self.h:
            w, a, b = self.h, self.w, 0
        else:
            w, a, b = self.w, 0, self.h
        return Line(
                *rotate_point(self.x-a/2, self.y-b/2, self.rotation, self.x, self.y),
                *rotate_point(self.x+a/2, self.y+b/2, self.rotation, self.x, self.y),
                w)

    def bounding_box(self):
        return self.to_line().bounding_box()

    def to_svg(self, tag, color='black'):
        return self.to_line().to_svg(tag, color)


def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise):
    # This is one of these problems typical for computer geometry where out of nowhere a seemingly simple task just
    # happens to be anything but in practice.
    #
    # Online there are a number of algorithms to be found solving this problem. Often, they solve the more general
    # problem for elliptic arcs. We can keep things simple here since we only have circular arcs.
    # 
    # This solution manages to handle circular arcs given in gerber format (with explicit center and endpoints, plus
    # sweep direction instead of a format with e.g. angles and radius) without any trigonometric functions (e.g. atan2).

    # Center arc on cx, cy
    x1 -= cx
    x2 -= cx
    y1 -= cy
    y2 -= cy
    clockwise = bool(clockwise) # bool'ify for XOR/XNOR below

    # Calculate radius
    r = math.sqrt(x1**2 + y1**2)

    # Calculate in which half-planes (north/south, west/east) P1 and P2 lie.
    # Note that we assume the y axis points upwards, as in Gerber and maths.
    # SVG has its y axis pointing downwards.
    p1_west = x1 < 0
    p1_north = y1 > 0
    p2_west = x2 < 0
    p2_north = y2 > 0

    # Calculate bounding box of P1 and P2
    min_x = min(x1, x2)
    min_y = min(y1, y2)
    max_x = max(x1, x2)
    max_y = max(y1, y2)

    #               North
    #                 ^
    #                 |
    #                 |(0,0)
    #      West <-----X-----> East
    #                 |
    #  +Y             |
    #   ^             v
    #   |           South
    #   |
    #   +-----> +X
    #
    # Check whether the arc sweeps over any coordinate axes. If it does, add the intersection point to the bounding box.
    # Note that, since this intersection point is at radius r, it has coordinate e.g. (0, r) for the north intersection.
    # Since we know that the points lie on either side of the coordinate axis, the '0' coordinate of the intersection
    # point will not change the bounding box in that axis--only its 'r' coordinate matters. We also know that the
    # absolute value of that coordinate will be greater than or equal to the old coordinate in that direction since the
    # intersection with the axis is the point where the full circle is tangent to the AABB. Thus, we can blindly set the
    # corresponding coordinate of the bounding box without min()/max()'ing first.

    # Handle north/south halfplanes
    if p1_west != p2_west: # arc starts in west half-plane, ends in east half-plane
        if p1_west == clockwise: # arc is clockwise west -> east or counter-clockwise east -> west
            max_y = r # add north to bounding box
        else: # arc is counter-clockwise west -> east or clockwise east -> west
            min_y = -r # south
    else: # Arc starts and ends in same halfplane west/east
        # Since both points are on the arc (at same radius) in one halfplane, we can use the y coord as a proxy for
        # angle comparisons. 
        small_arc_is_north_to_south = y1 > y2
        small_arc_is_clockwise = small_arc_is_north_to_south == p1_west
        if small_arc_is_clockwise != clockwise:
            min_y, max_y = -r, r # intersect aabb with both north and south

    # Handle west/east halfplanes
    if p1_north != p2_north:
        if p1_north == clockwise:
            max_x = r # east
        else:
            min_x = -r # west
    else:
        small_arc_is_west_to_east = x1 < x2
        small_arc_is_clockwise = small_arc_is_west_to_east == p1_north
        if small_arc_is_clockwise != clockwise:
            min_x, max_x = -r, r # intersect aabb with both north and south

    return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy)


# FIXME use math.dist instead
def point_distance(a, b):
    return math.sqrt((b[0] - a[0])**2 + (b[1] - a[1])**2)

def point_line_distance(l1, l2, p):
    x1, y1 = l1
    x2, y2 = l2
    x0, y0 = p
    length = point_distance(l1, l2)
    if math.isclose(length, 0):
        return point_distance(l1, p)
    return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / length

def svg_arc(old, new, center, clockwise):
    r = point_distance(old, center)
    d = point_line_distance(old, new, center)
    # invert sweep flag since the svg y axis is mirrored
    sweep_flag = int(not clockwise)
    # In the degenerate case where old == new, we always take the long way around. To represent this "full-circle arc"
    # in SVG, we have to split it into two.
    if math.isclose(point_distance(old, new), 0):
        intermediate = center[0] + (center[0] - old[0]), center[1] + (center[1] - old[1])
        # Note that we have to preserve the sweep flag to avoid causing self-intersections by flipping the direction of
        # a circular cutin
        return f'A {r:.6} {r:.6} 0 1 {sweep_flag} {intermediate[0]:.6} {intermediate[1]:.6} ' +\
               f'A {r:.6} {r:.6} 0 1 {sweep_flag} {new[0]:.6} {new[1]:.6}'

    else: # normal case
        large_arc = int((d > 0) == clockwise)
        return f'A {r:.6} {r:.6} 0 {large_arc} {sweep_flag} {new[0]:.6} {new[1]:.6}'

@dataclass
class ArcPoly(GraphicPrimitive):
    """ Polygon whose sides may be either straight lines or circular arcs """

    # list of (x : float, y : float) tuples. Describes closed outline, i.e. first and last point are considered
    # connected.
    outline : [(float,)]
    # must be either None (all segments are straight lines) or same length as outline.
    # Straight line segments have None entry.
    arc_centers : [(float,)] = None

    @property
    def segments(self):
        ol = self.outline
        return itertools.zip_longest(ol, ol[1:] + [ol[0]], self.arc_centers or [])

    def bounding_box(self):
        bbox = (None, None), (None, None)
        for (x1, y1), (x2, y2), arc in self.segments:
            if arc:
                clockwise, center = arc
                bbox = add_bounds(bbox, arc_bounds(x1, y1, x2, y2, *center, clockwise))

            else:
                line_bounds = (min(x1, x2), min(y1, y2)), (max(x1, x2), max(y1, y2))
                bbox = add_bounds(bbox, line_bounds)
        return bbox

    def __len__(self):
        return len(self.outline)

    def __bool__(self):
        return bool(len(self))

    def _path_d(self):
        if len(self.outline) == 0:
            return

        yield f'M {self.outline[0][0]:.6}, {self.outline[0][1]:.6}'
        for old, new, arc in self.segments:
            if not arc:
                yield f'L {new[0]:.6} {new[1]:.6}'
            else:
                clockwise, center = arc
                yield svg_arc(old, new, center, clockwise)

    def to_svg(self, tag, color='black'):
        return tag('path', d=' '.join(self._path_d()), style=f'fill: {color}')


@dataclass
class Line(GraphicPrimitive):
    x1 : float
    y1 : float
    x2 : float
    y2 : float
    width : float

    def bounding_box(self):
        r = self.width / 2
        return add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())

    def to_svg(self, tag, color='black'):
        return tag('path', d=f'M {self.x1:.6} {self.y1:.6} L {self.x2:.6} {self.y2:.6}',
                style=f'stroke: {color}; stroke-width: {self.width:.6}; stroke-linecap: round')

@dataclass
class Arc(GraphicPrimitive):
    x1 : float
    y1 : float
    x2 : float
    y2 : float
    # absolute coordinates
    cx : float
    cy : float
    clockwise : bool
    width : float

    def bounding_box(self):
        r = self.width/2
        endpoints = add_bounds(Circle(self.x1, self.y1, r).bounding_box(), Circle(self.x2, self.y2, r).bounding_box())

        arc_r = point_distance((self.cx, self.cy), (self.x1, self.y1))

        # extend C -> P1 line by line width / 2 along radius
        dx, dy = self.x1 - self.cx, self.y1 - self.cy
        x1 = self.x1 + dx/arc_r * r
        y1 = self.y1 + dy/arc_r * r

        # same for C -> P2
        dx, dy = self.x2 - self.cx, self.y2 - self.cy
        x2 = self.x2 + dx/arc_r * r
        y2 = self.y2 + dy/arc_r * r

        arc = arc_bounds(x1, y1, x2, y2, self.cx, self.cy, self.clockwise)
        return add_bounds(endpoints, arc) # FIXME add "include_center" switch

    def to_svg(self, tag, color='black'):
        arc = svg_arc((self.x1, self.y1), (self.x2, self.y2), (self.cx, self.cy), self.clockwise)
        return tag('path', d=f'M {self.x1:.6} {self.y1:.6} {arc}',
                style=f'stroke: {color}, stroke-width: {self.width:.6}; stroke-linecap: round; fill: none')

def svg_rotation(angle_rad, cx=0, cy=0):
    return f'rotate({float(rad_to_deg(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'

@dataclass
class Rectangle(GraphicPrimitive):
    # coordinates are center coordinates
    x : float
    y : float
    w : float
    h : float
    rotation : float # radians, around center!

    def bounding_box(self):
        return self.to_arc_poly().bounding_box()

    def to_arc_poly(self):
        sin, cos = math.sin(self.rotation), math.cos(self.rotation)
        sw, cw = sin*self.w/2, cos*self.w/2
        sh, ch = sin*self.h/2, cos*self.h/2
        x, y = self.x, self.y
        return ArcPoly([
            (x - (cw+sh), y - (ch+sw)),
            (x - (cw+sh), y + (ch+sw)),
            (x + (cw+sh), y + (ch+sw)),
            (x + (cw+sh), y - (ch+sw)),
            ])

    @property
    def center(self):
        return self.x + self.w/2, self.y + self.h/2

    def to_svg(self, tag, color='black'):
        x, y = self.x - self.w/2, self.y - self.h/2
        return tag('rect', x=x, y=y, width=self.w, height=self.h,
                transform=svg_rotation(self.rotation, self.x, self.y), style=f'fill: {color}')

@dataclass
class RegularPolygon(GraphicPrimitive):
    x : float
    y : float
    r : float
    n : int
    rotation : float # radians!

    def to_arc_poly(self):
        ''' convert n-sided gerber polygon to normal ArcPoly defined by outline '''

        delta = 2*math.pi / self.n

        return ArcPoly([
                (self.x + math.cos(self.rotation + i*delta) * self.r,
                 self.y + math.sin(self.rotation + i*delta) * self.r)
                for i in range(self.n) ])

    def bounding_box(self):
        return self.to_arc_poly().bounding_box()

    def to_svg(self, tag, color='black'):
        return self.to_arc_poly().to_svg(tag, color)