#!/usr/bin/env python3
"""Generate simple ASCII STL fallback parts for the Enigma Witness Vault.

This intentionally uses only the Python standard library.  It is not a full CSG
engine; enclosure cutouts are approximated by composing printable boxes and a
few low-poly cylinders.  Use the OpenSCAD model for the authoritative CAD and
this script when an orchestrator needs dependency-free STL artifacts.
"""

from __future__ import annotations

import argparse
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence


WALL = 2.4
OUTER_W = 86.0
OUTER_L_NFC = 102.0
OUTER_L_NO_NFC = 78.0
BASE_H = 20.0
LID_H = 8.0
INSERT_INSET = 7.0
PI_ORIGIN_NFC = (10.5, 64.0)
PI_ORIGIN_NO_NFC = (10.5, 40.0)
PI_HOLES = ((3.5, 3.5), (61.5, 3.5), (3.5, 26.5), (61.5, 26.5))
BUTTON_D = 7.0
BUTTON_SPACING = 16.0
OLED_WINDOW = (27.5, 14.5)
PN532 = (43.6, 41.0, 3.9)


@dataclass(frozen=True)
class Triangle:
    a: tuple[float, float, float]
    b: tuple[float, float, float]
    c: tuple[float, float, float]


def fmt(value: float) -> str:
    if abs(value) < 0.000001:
        value = 0.0
    return f"{value:.5f}"


def normal(tri: Triangle) -> tuple[float, float, float]:
    ax, ay, az = tri.a
    bx, by, bz = tri.b
    cx, cy, cz = tri.c
    ux, uy, uz = bx - ax, by - ay, bz - az
    vx, vy, vz = cx - ax, cy - ay, cz - az
    nx = uy * vz - uz * vy
    ny = uz * vx - ux * vz
    nz = ux * vy - uy * vx
    length = math.sqrt(nx * nx + ny * ny + nz * nz)
    if length == 0:
        return (0.0, 0.0, 0.0)
    return (nx / length, ny / length, nz / length)


def box(x: float, y: float, z: float, sx: float, sy: float, sz: float) -> list[Triangle]:
    """Axis-aligned cuboid as outward-facing triangles."""
    x0, x1 = x, x + sx
    y0, y1 = y, y + sy
    z0, z1 = z, z + sz
    v000 = (x0, y0, z0)
    v100 = (x1, y0, z0)
    v110 = (x1, y1, z0)
    v010 = (x0, y1, z0)
    v001 = (x0, y0, z1)
    v101 = (x1, y0, z1)
    v111 = (x1, y1, z1)
    v011 = (x0, y1, z1)
    return [
        Triangle(v000, v010, v110), Triangle(v000, v110, v100),  # bottom
        Triangle(v001, v101, v111), Triangle(v001, v111, v011),  # top
        Triangle(v000, v100, v101), Triangle(v000, v101, v001),  # front
        Triangle(v010, v011, v111), Triangle(v010, v111, v110),  # rear
        Triangle(v000, v001, v011), Triangle(v000, v011, v010),  # left
        Triangle(v100, v110, v111), Triangle(v100, v111, v101),  # right
    ]


def cylinder(cx: float, cy: float, z: float, diameter: float, height: float, segments: int = 24) -> list[Triangle]:
    radius = diameter / 2.0
    top = z + height
    tris: list[Triangle] = []
    top_center = (cx, cy, top)
    bottom_center = (cx, cy, z)
    for i in range(segments):
        a0 = 2.0 * math.pi * i / segments
        a1 = 2.0 * math.pi * (i + 1) / segments
        p0 = (cx + radius * math.cos(a0), cy + radius * math.sin(a0), z)
        p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1), z)
        p2 = (p0[0], p0[1], top)
        p3 = (p1[0], p1[1], top)
        tris.append(Triangle(p0, p1, p3))
        tris.append(Triangle(p0, p3, p2))
        tris.append(Triangle(top_center, p2, p3))
        tris.append(Triangle(bottom_center, p1, p0))
    return tris


def plate_with_rect_holes(
    width: float,
    length: float,
    z: float,
    height: float,
    holes: Sequence[tuple[float, float, float, float]],
) -> list[Triangle]:
    """Approximate a perforated plate by tiling boxes around rectangular holes."""
    xs = [0.0, width]
    ys = [0.0, length]
    for x, y, w, h in holes:
        xs.extend([max(0.0, x), min(width, x + w)])
        ys.extend([max(0.0, y), min(length, y + h)])
    xs = sorted(set(round(v, 5) for v in xs if 0.0 <= v <= width))
    ys = sorted(set(round(v, 5) for v in ys if 0.0 <= v <= length))
    tris: list[Triangle] = []
    for xi in range(len(xs) - 1):
        for yi in range(len(ys) - 1):
            x0, x1 = xs[xi], xs[xi + 1]
            y0, y1 = ys[yi], ys[yi + 1]
            if x1 <= x0 or y1 <= y0:
                continue
            cx = (x0 + x1) / 2.0
            cy = (y0 + y1) / 2.0
            inside_hole = any(hx <= cx <= hx + hw and hy <= cy <= hy + hh for hx, hy, hw, hh in holes)
            if not inside_hole:
                tris.extend(box(x0, y0, z, x1 - x0, y1 - y0, height))
    return tris


def y_wall_with_rect_holes(
    y: float,
    thickness: float,
    width: float,
    z: float,
    height: float,
    holes: Sequence[tuple[float, float, float, float]],
) -> list[Triangle]:
    """Tile a vertical wall in the X/Z plane, omitting rectangular openings."""
    xs = [0.0, width]
    zs = [z, z + height]
    for hx, hz, hw, hh in holes:
        xs.extend([max(0.0, hx), min(width, hx + hw)])
        zs.extend([max(z, hz), min(z + height, hz + hh)])
    xs = sorted(set(round(v, 5) for v in xs if 0.0 <= v <= width))
    zs = sorted(set(round(v, 5) for v in zs if z <= v <= z + height))
    tris: list[Triangle] = []
    for xi in range(len(xs) - 1):
        for zi in range(len(zs) - 1):
            x0, x1 = xs[xi], xs[xi + 1]
            z0, z1 = zs[zi], zs[zi + 1]
            if x1 <= x0 or z1 <= z0:
                continue
            cx = (x0 + x1) / 2.0
            cz = (z0 + z1) / 2.0
            inside_hole = any(hx <= cx <= hx + hw and hz <= cz <= hz + hh for hx, hz, hw, hh in holes)
            if not inside_hole:
                tris.extend(box(x0, y, z0, x1 - x0, thickness, z1 - z0))
    return tris


def vent_holes() -> list[tuple[float, float, float, float]]:
    return [(16.0 + 10.0 * i, 12.0, 4.0, 8.0) for i in range(6)]


def pi_origin(include_nfc: bool) -> tuple[float, float]:
    return PI_ORIGIN_NFC if include_nfc else PI_ORIGIN_NO_NFC


def variant_length(include_nfc: bool) -> float:
    return OUTER_L_NFC if include_nfc else OUTER_L_NO_NFC


def base(include_nfc: bool) -> list[Triangle]:
    length = variant_length(include_nfc)
    tris: list[Triangle] = []
    # Floor and side walls. Front/rear walls are tiled so vent and connector
    # cutouts are approximated by omitted rectangular regions.
    tris.extend(box(0, 0, 0, OUTER_W, length, WALL))
    tris.extend(box(0, 0, WALL, WALL, length, BASE_H - WALL))
    tris.extend(box(OUTER_W - WALL, 0, WALL, WALL, length, BASE_H - WALL))

    origin_x, origin_y = pi_origin(include_nfc)
    rear_holes = vent_holes() + [
        (origin_x + 7.0, 7.0, 11.0, 6.0),
        (origin_x + 25.0, 7.0, 14.0, 6.0),
        (origin_x + 45.0, 7.0, 13.0, 7.0),
    ]
    tris.extend(y_wall_with_rect_holes(0.0, WALL, OUTER_W, WALL, BASE_H - WALL, vent_holes()))
    tris.extend(y_wall_with_rect_holes(length - WALL, WALL, OUTER_W, WALL, BASE_H - WALL, rear_holes))

    # Pi standoffs.
    for hx, hy in PI_HOLES:
        tris.extend(cylinder(origin_x + hx, origin_y + hy, WALL, 6.0, 5.0, 18))
    # Heat-set insert bosses in the four corners.
    for bx, by in ((INSERT_INSET, INSERT_INSET), (OUTER_W - INSERT_INSET, INSERT_INSET),
                   (INSERT_INSET, length - INSERT_INSET), (OUTER_W - INSERT_INSET, length - INSERT_INSET)):
        tris.extend(cylinder(bx, by, WALL, 4.8, 5.0, 18))
    # ATECC608A sidecar shelf and simple wire ribs.
    tris.extend(box(OUTER_W - WALL - 25.5 - 6, origin_y + 5, WALL, 29.5, 21.7, 2.0))
    tris.extend(box(WALL + 4, length / 2.0 - 1, WALL, OUTER_W - 2 * WALL - 8, 2.0, 2.0))
    tris.extend(box(WALL + 4, length / 2.0 + 9, WALL, OUTER_W - 2 * WALL - 8, 2.0, 2.0))
    if include_nfc:
        bay_w = PN532[0] + 8.0
        bay_l = PN532[1] + 7.0
        bay_x = (OUTER_W - bay_w) / 2.0
        tris.extend(box(bay_x, 12.0, WALL, bay_w, bay_l, 2.2))
        # Low retainers only; the bay pocket is represented by the printed perimeter slab.
        for rx, ry in ((bay_x + 4, 16), (bay_x + bay_w - 4, 16), (bay_x + 4, 12 + bay_l - 4), (bay_x + bay_w - 4, 12 + bay_l - 4)):
            tris.extend(cylinder(rx, ry, WALL + 2.0, 3.8, 2.0, 14))
    return tris


def lid(include_nfc: bool) -> list[Triangle]:
    length = variant_length(include_nfc)
    button_y = length - 21.0
    button_xs = (OUTER_W / 2.0 - BUTTON_SPACING / 2.0, OUTER_W / 2.0 + BUTTON_SPACING / 2.0)
    oled_center = (OUTER_W / 2.0, length - 47.0)
    holes = [
        (oled_center[0] - OLED_WINDOW[0] / 2.0, oled_center[1] - OLED_WINDOW[1] / 2.0, OLED_WINDOW[0], OLED_WINDOW[1]),
    ]
    for x in button_xs:
        # Square approximation of round button holes.
        holes.append((x - 4.0, button_y - 4.0, 8.0, 8.0))
    tris = plate_with_rect_holes(OUTER_W, length, 0.0, LID_H, holes)
    if include_nfc:
        # Raised outline marks the NFC tap zone without creating a large through-hole.
        x0, y0, w, h = OUTER_W / 2.0 - 24.0, 17.0, 48.0, 34.0
        tris.extend(box(x0, y0, LID_H, w, 1.2, 0.6))
        tris.extend(box(x0, y0 + h - 1.2, LID_H, w, 1.2, 0.6))
        tris.extend(box(x0, y0, LID_H, 1.2, h, 0.6))
        tris.extend(box(x0 + w - 1.2, y0, LID_H, 1.2, h, 0.6))
    lip_clear = WALL + 0.35
    lip_w = OUTER_W - 2.0 * lip_clear
    lip_l = length - 2.0 * lip_clear
    # Nested rim below lid top; four boxes leave the center open.
    tris.extend(box(lip_clear, lip_clear, -3.0, lip_w, WALL, 3.0))
    tris.extend(box(lip_clear, lip_clear + lip_l - WALL, -3.0, lip_w, WALL, 3.0))
    tris.extend(box(lip_clear, lip_clear + WALL, -3.0, WALL, lip_l - 2.0 * WALL, 3.0))
    tris.extend(box(lip_clear + lip_w - WALL, lip_clear + WALL, -3.0, WALL, lip_l - 2.0 * WALL, 3.0))
    return tris


def cable_clamp() -> list[Triangle]:
    # U-shaped clamp made from printable bars, leaving the cable channel open.
    tris: list[Triangle] = []
    tris.extend(box(0, 0, 0, 38.0, 2.0, 5.0))
    tris.extend(box(0, 10.0, 0, 38.0, 2.0, 5.0))
    tris.extend(box(0, 0, 0, 8.0, 12.0, 5.0))
    tris.extend(box(30.0, 0, 0, 8.0, 12.0, 5.0))
    # Screw ears; bores are marked by shallow square relief pockets.
    tris.extend(box(3.0, -4.0, 0, 8.0, 4.0, 5.0))
    tris.extend(box(27.0, -4.0, 0, 8.0, 4.0, 5.0))
    return tris


def button() -> list[Triangle]:
    tris: list[Triangle] = []
    tris.extend(cylinder(0.0, 0.0, 0.0, BUTTON_D - 0.4, 3.5, 20))
    tris.extend(cylinder(0.0, 0.0, 3.5, BUTTON_D + 1.6, 2.2, 20))
    tris.extend(box(-1.2, -1.2, -2.0, 2.4, 2.4, 2.1))
    return tris


def buttons() -> list[Triangle]:
    tris: list[Triangle] = []
    for offset in (0.0, 14.0):
        for tri in button():
            tris.append(Triangle(
                (tri.a[0] + offset, tri.a[1], tri.a[2]),
                (tri.b[0] + offset, tri.b[1], tri.b[2]),
                (tri.c[0] + offset, tri.c[1], tri.c[2]),
            ))
    return tris


def nfc_blank() -> list[Triangle]:
    # Simple plate fallback; finger scoop is engraved only in OpenSCAD.
    return box(0.0, 0.0, 0.0, PN532[0] + 2.0, PN532[1] + 2.0, 1.6)


def write_stl(path: Path, name: str, tris: Iterable[Triangle]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="ascii", newline="\n") as handle:
        handle.write(f"solid {name}\n")
        for tri in tris:
            nx, ny, nz = normal(tri)
            handle.write(f"  facet normal {fmt(nx)} {fmt(ny)} {fmt(nz)}\n")
            handle.write("    outer loop\n")
            for vx, vy, vz in (tri.a, tri.b, tri.c):
                handle.write(f"      vertex {fmt(vx)} {fmt(vy)} {fmt(vz)}\n")
            handle.write("    endloop\n")
            handle.write("  endfacet\n")
        handle.write(f"endsolid {name}\n")


def build_part(part: str, include_nfc: bool) -> list[Triangle]:
    builders = {
        "base": base,
        "lid": lid,
        "cable_clamp": lambda _include_nfc: cable_clamp(),
        "button_1": lambda _include_nfc: button(),
        "button_2": lambda _include_nfc: button(),
        "buttons": lambda _include_nfc: buttons(),
        "nfc_blank": lambda _include_nfc: nfc_blank(),
    }
    return builders[part](include_nfc)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Generate dependency-free ASCII STL approximations for the Enigma Witness Vault.")
    parser.add_argument("--out", default="stl", help="Output directory, default: ./stl")
    parser.add_argument("--part", choices=("all", "base", "lid", "cable_clamp", "button_1", "button_2", "buttons", "nfc_blank"), default="all")
    parser.add_argument("--include-nfc", dest="include_nfc", action="store_true", default=True, help="Generate the 86 x 102 x 28 mm NFC variant (default).")
    parser.add_argument("--no-nfc", dest="include_nfc", action="store_false", help="Generate the 86 x 78 x 28 mm no-NFC variant.")
    parser.add_argument("--all-variants", action="store_true", help="Generate both NFC and no-NFC variants.")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    out = Path(args.out)
    variants = (True, False) if args.all_variants else (args.include_nfc,)
    selected_parts = ("base", "lid", "cable_clamp", "button_1", "button_2") if args.part == "all" else (args.part,)
    for include_nfc in variants:
        suffix = "nfc" if include_nfc else "no_nfc"
        for selected in selected_parts:
            if selected == "nfc_blank" and not include_nfc:
                suffix_for_name = "blank"
            else:
                suffix_for_name = suffix
            name = f"enigma_witness_vault_{selected}_{suffix_for_name}"
            write_stl(out / f"{name}.stl", name, build_part(selected, include_nfc))


if __name__ == "__main__":
    main()
