#!/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],
        )
    )



# ------------------------------------------------------------
# SVG PATH FLATTENER
# ------------------------------------------------------------
#
# Handles the path commands used by the existing Arachnid Hero
# SVG artwork:
#
# M / L / H / V / C / Z
#
# Cubic Bezier curves are converted into sufficiently dense
# polylines for the physical decorative geometry.
# ------------------------------------------------------------

PATH_TOKEN = re.compile(
    r"[MLHVCSQTAZmlhvcsqtaz]|"
    r"[-+]?(?:\d*\.\d+|\d+\.?)"
    r"(?:[eE][-+]?\d+)?"
)


def parse_svg_path_points(
    d,
    curve_steps=16,
):
    tokens = PATH_TOKEN.findall(d or "")

    if not tokens:
        return []

    points = []

    i = 0
    cmd = None

    current_x = 0.0
    current_y = 0.0

    start_x = None
    start_y = None

    def is_command(token):
        return len(token) == 1 and token.isalpha()

    def number():
        nonlocal i

        if i >= len(tokens):
            raise ValueError(
                "Unexpected end of SVG path"
            )

        if is_command(tokens[i]):
            raise ValueError(
                "Expected SVG number, got command "
                + tokens[i]
            )

        value = float(tokens[i])
        i += 1

        return value

    while i < len(tokens):

        if is_command(tokens[i]):
            cmd = tokens[i]
            i += 1

        if cmd is None:
            raise ValueError(
                "SVG path data started without command"
            )

        relative = cmd.islower()
        op = cmd.upper()

        if op == "M":
            x = number()
            y = number()

            if relative:
                x += current_x
                y += current_y

            current_x = x
            current_y = y

            start_x = x
            start_y = y

            points.append(
                (current_x, current_y)
            )

            # Subsequent coordinate pairs after M are L.
            cmd = "l" if relative else "L"

        elif op == "L":
            x = number()
            y = number()

            if relative:
                x += current_x
                y += current_y

            current_x = x
            current_y = y

            points.append(
                (current_x, current_y)
            )

        elif op == "H":
            x = number()

            if relative:
                x += current_x

            current_x = x

            points.append(
                (current_x, current_y)
            )

        elif op == "V":
            y = number()

            if relative:
                y += current_y

            current_y = y

            points.append(
                (current_x, current_y)
            )

        elif op == "C":
            x1 = number()
            y1 = number()
            x2 = number()
            y2 = number()
            x3 = number()
            y3 = number()

            if relative:
                x1 += current_x
                y1 += current_y
                x2 += current_x
                y2 += current_y
                x3 += current_x
                y3 += current_y

            x0 = current_x
            y0 = current_y

            for step in range(
                1,
                curve_steps + 1,
            ):
                t = step / curve_steps
                mt = 1.0 - t

                x = (
                    mt ** 3 * x0
                    + 3.0 * mt ** 2 * t * x1
                    + 3.0 * mt * t ** 2 * x2
                    + t ** 3 * x3
                )

                y = (
                    mt ** 3 * y0
                    + 3.0 * mt ** 2 * t * y1
                    + 3.0 * mt * t ** 2 * y2
                    + t ** 3 * y3
                )

                points.append(
                    (x, y)
                )

            current_x = x3
            current_y = y3

        elif op == "Z":
            if (
                start_x is not None
                and start_y is not None
            ):
                if (
                    not points
                    or points[-1]
                    != (start_x, start_y)
                ):
                    points.append(
                        (start_x, start_y)
                    )

                current_x = start_x
                current_y = start_y

            cmd = None

        else:
            raise ValueError(
                f"Unsupported SVG path command: {cmd}"
            )

    return points


def filled_path_mask(points):
    if len(points) < 3:
        return None

    converted = [
        svg_to_xz(x, y)
        for x, y in points
    ]

    # CadQuery closes the polygon itself.
    if (
        len(converted) > 2
        and converted[0] == converted[-1]
    ):
        converted = converted[:-1]

    return (
        cq.Workplane("XZ")
        .polyline(converted)
        .close()
        .extrude(
            250.0,
            both=True,
        )
    )



# ------------------------------------------------------------
# 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)



# ------------------------------------------------------------
# RED HERO ARMOUR PANELS
# ------------------------------------------------------------

red_tree = ET.parse(
    ART / "02-red-hero-panels.svg"
)

red_root = red_tree.getroot()

red_panel_mask = None
red_panel_count = 0

for elem in red_root.iter():

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

    if tag != "path":
        continue

    points = parse_svg_path_points(
        elem.get("d", ""),
        curve_steps=18,
    )

    part = filled_path_mask(points)

    if part is None:
        continue

    red_panel_mask = add_mask(
        red_panel_mask,
        part,
    )

    red_panel_count += 1


if red_panel_count != 4:
    raise RuntimeError(
        "Expected 4 Arachnid red armour panels, "
        f"parsed {red_panel_count}"
    )



# ------------------------------------------------------------
# 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_svg_path_points(
            elem.get("d", ""),
            curve_steps=12,
        )

        if len(pts) > 1:
            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,
        250,
        halign="center",
        valign="center",
    )
)

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
# ------------------------------------------------------------
# ------------------------------------------------------------
# DIRECT FRONT / REAR CLAMSHELL SOLIDS
# ------------------------------------------------------------
#
# IMPORTANT:
# Do not create one complete shell and boolean-split it.
#
# OCC proved unreliable for that topology.
# Each printable panel is lofted directly as its own closed
# half-annulus solid.
# ------------------------------------------------------------

def half_shell_wire(
    z,
    width,
    depth,
    front=True,
    segments=64,
    radial_extra=0.0,
):
    outer_a = (
        width / 2.0
        + CLEARANCE
        + WALL
        + radial_extra
    )

    outer_b = (
        depth / 2.0
        + CLEARANCE
        + WALL
        + radial_extra
    )

    inner_a = width / 2.0 + CLEARANCE
    inner_b = depth / 2.0 + CLEARANCE

    if front:
        outer_angles = [
            math.pi * i / segments
            for i in range(segments + 1)
        ]

        inner_angles = [
            math.pi * (segments - i) / segments
            for i in range(segments + 1)
        ]

    else:
        outer_angles = [
            -math.pi * i / segments
            for i in range(segments + 1)
        ]

        inner_angles = [
            -math.pi * (segments - i) / segments
            for i in range(segments + 1)
        ]

    points = []

    for theta in outer_angles:
        points.append(
            cq.Vector(
                outer_a * math.cos(theta),
                outer_b * math.sin(theta),
                z,
            )
        )

    for theta in inner_angles:
        points.append(
            cq.Vector(
                inner_a * math.cos(theta),
                inner_b * math.sin(theta),
                z,
            )
        )

    return cq.Wire.makePolygon(
        points,
        close=True,
    )


def make_half_shell(
    front=True,
    radial_extra=0.0,
):
    wires = [
        half_shell_wire(
            z,
            width,
            depth,
            front=front,
            radial_extra=radial_extra,
        )
        for z, width, depth in PROFILES
    ]

    solid = cq.Solid.makeLoft(
        wires,
        ruled=False,
    )

    if not solid.isValid():
        raise RuntimeError(
            "Direct clamshell loft produced invalid geometry"
        )

    return solid


front_base_solid = make_half_shell(
    front=True,
)

rear_base_solid = make_half_shell(
    front=False,
)

front = (
    cq.Workplane("XY")
    .newObject([front_base_solid])
)

rear = (
    cq.Workplane("XY")
    .newObject([rear_base_solid])
)


# ------------------------------------------------------------
# APPLY FRONT AESTHETIC GEOMETRY
# ------------------------------------------------------------
#
# Decoration carriers are generated directly from the FRONT
# half-shell.  They deliberately overlap the existing wall,
# rather than merely touching its outer face.
#
# This guarantees:
#   - no artwork can appear on the rear half
#   - raised features physically fuse to the front shell
#   - no floating decoration solids
# ------------------------------------------------------------

if red_panel_mask is not None:
    front_panel_carrier = (
        cq.Workplane("XY")
        .newObject([
            make_half_shell(
                front=True,
                radial_extra=PANEL_HEIGHT,
            )
        ])
    )

    front_red_panels = (
        front_panel_carrier
        .intersect(red_panel_mask)
    )

    front = front.union(
        front_red_panels
    )


if web_mask is not None:
    front_web_carrier = (
        cq.Workplane("XY")
        .newObject([
            make_half_shell(
                front=True,
                radial_extra=WEB_HEIGHT,
            )
        ])
    )

    front_web = front_web_carrier.intersect(
        web_mask
    )

    front = front.union(front_web)


if emblem_mask is not None:
    front_emblem_carrier = (
        cq.Workplane("XY")
        .newObject([
            make_half_shell(
                front=True,
                radial_extra=EMBLEM_HEIGHT,
            )
        ])
    )

    front_emblem = front_emblem_carrier.intersect(
        emblem_mask
    )

    front = front.union(front_emblem)


# SHiRE engraving is retained separately.
# It may only remove material from the front shell.
front = front.cut(engrave)


try:
    front = front.clean()
except Exception:
    pass


# ------------------------------------------------------------
# APPLY REAR KNEE-FLEX RELIEF
# ------------------------------------------------------------

rear = rear.cut(relief)


# Remove redundant topology where OCC can safely do so.
try:
    front = front.clean()
except Exception:
    pass

try:
    rear = rear.clean()
except Exception:
    pass


# ------------------------------------------------------------
# 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."
)
