#!/usr/bin/env python3

from __future__ import annotations

import math
import re
import xml.etree.ElementTree as ET
from pathlib import Path

import cadquery as cq
from cadquery import exporters


ROOT = Path("/home/shire3d/ARMOR")

PROJECT = ROOT / (
    "data/agents/covercanvas/projects/"
    "CC-RAY-ARACHNID-HERO-RIGHT-LEG-0001"
)

ART = PROJECT / "artwork"

OUT = Path(
    "/SHiREVault/SHiRELimbs/Ray/CoverCanvas/"
    "CC-RAY-ARACHNID-HERO-RIGHT-LEG-0001/"
    "prototype-cad"
)

OUT.mkdir(parents=True, exist_ok=True)


# ------------------------------------------------------------
# PROTOTYPE GEOMETRY INPUT
# ------------------------------------------------------------
#
# These values come from the completed LimbForge measurement
# session but DO NOT constitute OssiGuard manufacturing release.
#
# This generator intentionally leaves the OI safety gate locked.
#

HEIGHT = 285.0

TOP_CIRC = 405.0
UPPER_CIRC = 400.0
DISTAL_CIRC = 225.0

TOP_WIDTH = 150.0

WALL = 3.0
CLEARANCE = 6.0

WEB_HEIGHT = 1.2
EMBLEM_HEIGHT = 1.6
PANEL_HEIGHT = 0.9
SHIRE_ENGRAVE = 0.45

CANVAS_W = 180.0
CANVAS_H = 360.0


# ------------------------------------------------------------
# MATH
# ------------------------------------------------------------

def ellipse_circumference(width: float, depth: float) -> float:
    a = width / 2.0
    b = depth / 2.0

    return math.pi * (
        3.0 * (a + b)
        - math.sqrt(
            (3.0 * a + b) *
            (a + 3.0 * b)
        )
    )


def depth_for_circumference(
    width: float,
    circumference: float,
) -> float:

    lo = 5.0
    hi = max(circumference, width * 2.0)

    for _ in range(100):
        mid = (lo + hi) / 2.0

        if ellipse_circumference(width, mid) < circumference:
            lo = mid
        else:
            hi = mid

    return (lo + hi) / 2.0


TOP_DEPTH = depth_for_circumference(
    TOP_WIDTH,
    TOP_CIRC,
)

ASPECT = TOP_DEPTH / TOP_WIDTH


def scaled_profile(circumference: float):
    scale = circumference / TOP_CIRC

    width = TOP_WIDTH * scale
    depth = TOP_DEPTH * scale

    return width, depth


UPPER_WIDTH, UPPER_DEPTH = scaled_profile(UPPER_CIRC)
DISTAL_WIDTH, DISTAL_DEPTH = scaled_profile(DISTAL_CIRC)


# ------------------------------------------------------------
# LOFT
# ------------------------------------------------------------

PROFILES = [
    (0.0, DISTAL_WIDTH, DISTAL_DEPTH),
    (HEIGHT * 0.50,
     (DISTAL_WIDTH + UPPER_WIDTH) / 2.0,
     (DISTAL_DEPTH + UPPER_DEPTH) / 2.0),
    (HEIGHT * 0.82, UPPER_WIDTH, UPPER_DEPTH),
    (HEIGHT, TOP_WIDTH, TOP_DEPTH),
]


def loft_solid(
    profiles,
    radial_add=0.0,
):

    wp = cq.Workplane("XY")
    previous_z = 0.0

    for index, (z, width, depth) in enumerate(profiles):

        a = width / 2.0 + radial_add
        b = depth / 2.0 + radial_add

        if index == 0:
            if z:
                wp = wp.workplane(offset=z)
        else:
            wp = wp.workplane(
                offset=z - previous_z
            )

        wp = wp.ellipse(a, b)
        previous_z = z

    return wp.loft(
        combine=True,
        ruled=False,
    )


inner = loft_solid(
    PROFILES,
    radial_add=CLEARANCE,
)

outer = loft_solid(
    PROFILES,
    radial_add=CLEARANCE + WALL,
)

shell = outer.cut(inner)


# ------------------------------------------------------------
# CURVED POSTERIOR KNEE RELIEF
# ------------------------------------------------------------

relief = (
    cq.Workplane("XZ")
    .center(
        0,
        HEIGHT - 8.0,
    )
    .circle(34.0)
    .extrude(
        -120.0,
        both=True,
    )
    .translate(
        (0, -48.0, 0)
    )
)

shell = shell.cut(relief)


# ------------------------------------------------------------
# SVG HELPERS
# ------------------------------------------------------------

NUMBER = re.compile(
    r"-?(?:\d+(?:\.\d*)?|\.\d+)"
)


def svg_to_xz(x, y):
    x3 = (
        (float(x) - CANVAS_W / 2.0)
        / (CANVAS_W / 2.0)
    ) * ((TOP_WIDTH / 2.0) - 8.0)

    z3 = HEIGHT * (
        1.0 - float(y) / CANVAS_H
    )

    return x3, z3


def line_mask(
    p1,
    p2,
    width,
):

    x1, z1 = svg_to_xz(*p1)
    x2, z2 = svg_to_xz(*p2)

    dx = x2 - x1
    dz = z2 - z1

    length = math.hypot(dx, dz)

    if length < 0.01:
        return None

    angle = math.degrees(
        math.atan2(dz, dx)
    )

    return (
        cq.Workplane("XZ")
        .center(
            (x1 + x2) / 2.0,
            (z1 + z2) / 2.0,
        )
        .rect(length + width, width)
        .extrude(250.0, both=True)
        .rotate(
            (0, 0, 0),
            (0, 1, 0),
            angle,
        )
    )


def polyline_mask(points, width):
    result = None

    for a, b in zip(points, points[1:]):
        part = line_mask(a, b, width)

        if part is None:
            continue

        result = (
            part
            if result is None
            else result.union(part)
        )

    return result


def parse_simple_line_path(d):
    nums = [
        float(x)
        for x in NUMBER.findall(d)
    ]

    if len(nums) == 4:
        return [
            (nums[0], nums[1]),
            (nums[2], nums[3]),
        ]

    return None


def parse_points(text):
    nums = [
        float(x)
        for x in NUMBER.findall(text)
    ]

    return list(
        zip(
            nums[0::2],
            nums[1::2],
        )
    )


# ------------------------------------------------------------
# CONFORMAL DECORATION SKINS
# ------------------------------------------------------------

outer_web = loft_solid(
    PROFILES,
    radial_add=(
        CLEARANCE +
        WALL +
        WEB_HEIGHT
    ),
)

web_skin = outer_web.cut(outer)

outer_emblem = loft_solid(
    PROFILES,
    radial_add=(
        CLEARANCE +
        WALL +
        EMBLEM_HEIGHT
    ),
)

emblem_skin = outer_emblem.cut(outer)

outer_panel = loft_solid(
    PROFILES,
    radial_add=(
        CLEARANCE +
        WALL +
        PANEL_HEIGHT
    ),
)

panel_skin = outer_panel.cut(outer)


# Front-only region.
front_region = (
    cq.Workplane("XY")
    .box(
        400,
        200,
        HEIGHT + 100,
        centered=(True, False, False),
    )
)


# ------------------------------------------------------------
# WEB
# ------------------------------------------------------------

web_tree = ET.parse(
    ART / "03-raised-web-network.svg"
)

web_root = web_tree.getroot()

web_mask = None


def add_mask(current, part):
    if part is None:
        return current

    return (
        part
        if current is None
        else current.union(part)
    )


for elem in web_root.iter():

    tag = elem.tag.split("}")[-1]

    if tag == "path":
        pts = parse_simple_line_path(
            elem.get("d", "")
        )

        if pts:
            web_mask = add_mask(
                web_mask,
                polyline_mask(
                    pts,
                    2.4,
                ),
            )

    elif tag == "polyline":
        pts = parse_points(
            elem.get("points", "")
        )

        if len(pts) > 1:
            pts.append(pts[0])

            web_mask = add_mask(
                web_mask,
                polyline_mask(
                    pts,
                    2.4,
                ),
            )


if web_mask is not None:
    web = (
        web_skin
        .intersect(front_region)
        .intersect(web_mask)
    )

    shell = shell.union(web)


# ------------------------------------------------------------
# EMBLEM
# ------------------------------------------------------------

emblem_tree = ET.parse(
    ART / "04-original-arachnid-emblem.svg"
)

emblem_root = emblem_tree.getroot()

emblem_mask = None


for elem in emblem_root.iter():

    tag = elem.tag.split("}")[-1]

    if tag == "path":

        pts = parse_simple_line_path(
            elem.get("d", "")
        )

        if pts:
            emblem_mask = add_mask(
                emblem_mask,
                polyline_mask(
                    pts,
                    3.2,
                ),
            )

    elif tag == "ellipse":

        cx = float(elem.get("cx"))
        cy = float(elem.get("cy"))
        rx = float(elem.get("rx"))
        ry = float(elem.get("ry"))

        points = []

        for i in range(49):
            t = (
                2.0 * math.pi *
                i / 48.0
            )

            points.append(
                (
                    cx + rx * math.cos(t),
                    cy + ry * math.sin(t),
                )
            )

        emblem_mask = add_mask(
            emblem_mask,
            polyline_mask(
                points,
                3.2,
            ),
        )


if emblem_mask is not None:

    emblem = (
        emblem_skin
        .intersect(front_region)
        .intersect(emblem_mask)
    )

    shell = shell.union(emblem)


# ------------------------------------------------------------
# SHIRE BRANDING
# ------------------------------------------------------------

brand = (
    cq.Workplane("XZ")
    .center(0, 18)
    .text(
        "SHiRE",
        11,
        6,
        halign="center",
        valign="center",
    )
    .extrude(
        250,
        both=True,
    )
)

engrave_inner = loft_solid(
    PROFILES,
    radial_add=(
        CLEARANCE +
        WALL -
        SHIRE_ENGRAVE
    ),
)

engrave_band = outer.cut(
    engrave_inner
)

engrave = (
    engrave_band
    .intersect(front_region)
    .intersect(brand)
)

shell = shell.cut(engrave)


# ------------------------------------------------------------
# SPLIT FRONT / REAR
# ------------------------------------------------------------

front_box = (
    cq.Workplane("XY")
    .box(
        500,
        250,
        HEIGHT + 150,
        centered=(True, False, False),
    )
)

rear_box = (
    cq.Workplane("XY")
    .box(
        500,
        250,
        HEIGHT + 150,
        centered=(True, False, False),
    )
    .translate(
        (0, -250, 0)
    )
)

front = shell.intersect(front_box)
rear = shell.intersect(rear_box)


# ------------------------------------------------------------
# VALIDATION
# ------------------------------------------------------------

def check(name, shape):

    solids = shape.solids().vals()

    if not solids:
        raise RuntimeError(
            f"{name}: no solids generated"
        )

    total_volume = sum(
        s.Volume()
        for s in solids
    )

    valid = all(
        s.isValid()
        for s in solids
    )

    bb = shape.val().BoundingBox()

    print()
    print(name)
    print(" solids:", len(solids))
    print(" valid:", valid)
    print(" volume_mm3:", round(total_volume, 2))
    print(
        " bounds_mm:",
        round(bb.xlen, 2),
        round(bb.ylen, 2),
        round(bb.zlen, 2),
    )

    if not valid:
        raise RuntimeError(
            f"{name}: invalid solid detected"
        )

    if total_volume <= 0:
        raise RuntimeError(
            f"{name}: zero volume"
        )


check("FRONT", front)
check("REAR", rear)


# ------------------------------------------------------------
# EXPORT
# ------------------------------------------------------------

front_stl = OUT / (
    "Arachnid-Hero-Right-Leg-FRONT-"
    "PROTOTYPE-NOT-OI-RELEASED.stl"
)

rear_stl = OUT / (
    "Arachnid-Hero-Right-Leg-REAR-"
    "PROTOTYPE-NOT-OI-RELEASED.stl"
)

front_step = OUT / (
    "Arachnid-Hero-Right-Leg-FRONT-"
    "PROTOTYPE-NOT-OI-RELEASED.step"
)

rear_step = OUT / (
    "Arachnid-Hero-Right-Leg-REAR-"
    "PROTOTYPE-NOT-OI-RELEASED.step"
)


exporters.export(
    front,
    str(front_stl),
)

exporters.export(
    rear,
    str(rear_stl),
)

exporters.export(
    front,
    str(front_step),
)

exporters.export(
    rear,
    str(rear_step),
)


print()
print("=== EXPORTS ===")

for path in (
    front_stl,
    rear_stl,
    front_step,
    rear_step,
):
    print(
        path,
        path.stat().st_size,
        "bytes",
    )


print()
print(
    "OI MANUFACTURING RELEASE: "
    "BLOCKED / UNCHANGED"
)

print(
    "Prototype CAD generated for "
    "fit/artwork/print evaluation."
)
