import functools
import numpy as np
import operator
import copy

from . import dn, d3, ease

# Constants

ORIGIN = np.array((0, 0))

X = np.array((1, 0))
Y = np.array((0, 1))

UP = Y

# SDF Class

_ops = {}


class SDF2:
    def __init__(self, f):
        self.f = f

    def __call__(self, p):
        return self.f(p).reshape((-1, 1))

    def __getattr__(self, name):
        if name in _ops:
            f = _ops[name]
            return functools.partial(f, self)
        raise AttributeError

    def __or__(self, other):
        return union(self, other)

    def __and__(self, other):
        return intersection(self, other)

    def __sub__(self, other):
        return difference(self, other)

    def fillet(self, r):
        newSelf = copy.deepcopy(self)
        newSelf._r = r
        return newSelf

    def radius(self, *args, **kwargs):
        return self.fillet(*args, **kwargs)

    def k(self, *args, **kwargs):
        return self.fillet(*args, **kwargs)

    def r(self, *args, **kwargs):
        return self.fillet(*args, **kwargs)

    def chamfer(self, c):
        newSelf = copy.deepcopy(self)
        newSelf._c = c
        return newSelf

    def c(self, *args, **kwargs):
        return self.chamfer(*args, **kwargs)


def sdf2(f):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        return SDF2(f(*args, **kwargs))

    return wrapper


def op2(f):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        return SDF2(f(*args, **kwargs))

    _ops[f.__name__] = wrapper
    return wrapper


def op23(f):
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        return d3.SDF3(f(*args, **kwargs))

    _ops[f.__name__] = wrapper
    return wrapper


# Helpers


def _length(a):
    return np.linalg.norm(a, axis=1)


def _normalize(a):
    return a / np.linalg.norm(a)


def _dot(a, b):
    return np.sum(a * b, axis=1)


def _vec(*arrs):
    return np.stack(arrs, axis=-1)


_min = np.minimum
_max = np.maximum

# Primitives


@sdf2
def circle(radius=None, diameter=None, center=ORIGIN):
    if (radius is not None) == (diameter is not None):
        raise ValueError(f"Specify either radius or diameter")
    if radius is None:
        radius = diameter / 2

    def f(p):
        return _length(p - center) - radius

    return f


@sdf2
def line(normal=UP, point=ORIGIN):
    normal = _normalize(normal)

    def f(p):
        return np.dot(point - p, normal)

    return f


@sdf2
def slab(x0=None, y0=None, x1=None, y1=None, k=None):
    fs = []
    if x0 is not None:
        fs.append(line(X, (x0, 0)))
    if x1 is not None:
        fs.append(line(-X, (x1, 0)))
    if y0 is not None:
        fs.append(line(Y, (0, y0)))
    if y1 is not None:
        fs.append(line(-Y, (0, y1)))
    return intersection(*fs, k=k)


@sdf2
def rectangle(size=1, center=ORIGIN, a=None, b=None):
    if a is not None and b is not None:
        a = np.array(a)
        b = np.array(b)
        size = b - a
        center = a + size / 2
        return rectangle(size, center)
    size = np.array(size)

    def f(p):
        q = np.abs(p - center) - size / 2
        return _length(_max(q, 0)) + _min(np.amax(q, axis=1), 0)

    return f


@sdf2
def rounded_rectangle(size, radius, center=ORIGIN):
    try:
        r0, r1, r2, r3 = radius
    except TypeError:
        r0 = r1 = r2 = r3 = radius

    def f(p):
        x = p[:, 0]
        y = p[:, 1]
        r = np.zeros(len(p)).reshape((-1, 1))
        r[np.logical_and(x > 0, y > 0)] = r0
        r[np.logical_and(x > 0, y <= 0)] = r1
        r[np.logical_and(x <= 0, y <= 0)] = r2
        r[np.logical_and(x <= 0, y > 0)] = r3
        q = np.abs(p) - size / 2 + r
        return (
            _min(_max(q[:, 0], q[:, 1]), 0).reshape((-1, 1))
            + _length(_max(q, 0)).reshape((-1, 1))
            - r
        )

    return f


@sdf2
def equilateral_triangle():
    def f(p):
        k = 3**0.5
        p = _vec(np.abs(p[:, 0]) - 1, p[:, 1] + 1 / k)
        w = p[:, 0] + k * p[:, 1] > 0
        q = _vec(p[:, 0] - k * p[:, 1], -k * p[:, 0] - p[:, 1]) / 2
        p = np.where(w.reshape((-1, 1)), q, p)
        p = _vec(p[:, 0] - np.clip(p[:, 0], -2, 0), p[:, 1])
        return -_length(p) * np.sign(p[:, 1])

    return f


@sdf2
def hexagon(radius=None, diameter=None):
    if (radius is not None) == (diameter is not None):
        raise ValueError(f"Specify either radius or diameter")
    if radius is None:
        radius = diameter / 2
    radius *= 3**0.5 / 2

    def f(p):
        k = np.array((3**0.5 / -2, 0.5, np.tan(np.pi / 6)))
        p = np.abs(p)
        p -= 2 * k[:2] * _min(_dot(k[:2], p), 0).reshape((-1, 1))
        p -= _vec(
            np.clip(p[:, 0], -k[2] * radius, k[2] * radius), np.zeros(len(p)) + radius
        )
        return _length(p) * np.sign(p[:, 1])

    return f


@sdf2
def rounded_x(w, r):
    def f(p):
        p = np.abs(p)
        q = (_min(p[:, 0] + p[:, 1], w) * 0.5).reshape((-1, 1))
        return _length(p - q) - r

    return f


def RegularPolygon(n, r=1):
    ri = r * np.cos(np.pi / n)
    return intersection(
        *[slab(y0=-ri).rotate(a) for a in np.arange(0, 2 * np.pi, 2 * np.pi / n)]
    )


@sdf2
def polygon(points):
    points = [np.array(p) for p in points]

    def f(p):
        n = len(points)
        d = _dot(p - points[0], p - points[0])
        s = np.ones(len(p))
        for i in range(n):
            j = (i + n - 1) % n
            vi = points[i]
            vj = points[j]
            e = vj - vi
            w = p - vi
            b = w - e * np.clip(np.dot(w, e) / np.dot(e, e), 0, 1).reshape((-1, 1))
            d = _min(d, _dot(b, b))
            c1 = p[:, 1] >= vi[1]
            c2 = p[:, 1] < vj[1]
            c3 = e[0] * w[:, 1] > e[1] * w[:, 0]
            c = _vec(c1, c2, c3)
            s = np.where(np.all(c, axis=1) | np.all(~c, axis=1), -s, s)
        return s * np.sqrt(d)

    return f


# Positioning


@op2
def translate(other, offset):
    def f(p):
        return other(p - offset)

    return f


@op2
def scale(other, factor):
    try:
        x, y = factor
    except TypeError:
        x = y = factor
    s = (x, y)
    m = min(x, y)

    def f(p):
        return other(p / s) * m

    return f


@op2
def rotate(other, angle):
    s = np.sin(angle)
    c = np.cos(angle)
    m = 1 - c
    matrix = np.array(
        [
            [c, -s],
            [s, c],
        ]
    ).T

    def f(p):
        return other(np.dot(p, matrix))

    return f


@op2
def circular_array(other, count):
    angles = [i / count * 2 * np.pi for i in range(count)]
    return union(*[other.rotate(a) for a in angles])


# Alterations


@op2
def elongate(other, size):
    def f(p):
        q = np.abs(p) - size
        x = q[:, 0].reshape((-1, 1))
        y = q[:, 1].reshape((-1, 1))
        w = _min(_max(x, y), 0)
        return other(_max(q, 0)) + w

    return f


# 2D => 3D Operations


@op23
def extrude(other, h=np.inf):
    def f(p):
        d = other(p[:, [0, 1]])
        w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
        return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))

    return f


@op23
def extrude_to(a, b, h, e=ease.linear):
    def f(p):
        d1 = a(p[:, [0, 1]])
        d2 = b(p[:, [0, 1]])
        t = e(np.clip(p[:, 2] / h, -0.5, 0.5) + 0.5)
        d = d1 + (d2 - d1) * t.reshape((-1, 1))
        w = _vec(d.reshape(-1), np.abs(p[:, 2]) - h / 2)
        return _min(_max(w[:, 0], w[:, 1]), 0) + _length(_max(w, 0))

    return f


@op23
def revolve(other, offset=0):
    def f(p):
        xy = p[:, [0, 1]]
        # use horizontal distance to Z axis as X coordinate in 2D shape
        # use Z coordinate as Y coordinate in 2D shape
        q = _vec(_length(xy) - offset, p[:, 2])
        return other(q)

    return f


# Common

union = op2(dn.union)
difference = op2(dn.difference)
intersection = op2(dn.intersection)
blend = op2(dn.blend)
negate = op2(dn.negate)
dilate = op2(dn.dilate)
erode = op2(dn.erode)
shell = op2(dn.shell)
repeat = op2(dn.repeat)
mirror = op2(dn.mirror)
modulate_between = op2(dn.modulate_between)
stretch = op2(dn.stretch)
