"""
Shared parts for Control's consoles: cad/button, cad/set-list,
cad/loop-grid and cad/switchboard.

- Shapes: boxes, cylinders and simplified 20 mm V-slot, as in the element scripts.
- Desk: a slanted desk console. Four 2020 rails run across its width, a printed
  cheek closes each end, and a printed top panel lies on the two upper rails,
  in tiles small enough for an ordinary printer.
- Bought controls, drawn only well enough to show where they go: buttons,
  the stop, the light, the antenna, a knob, a lever, pads, jacks, a screen.
- export(): writes every file a console's page offers, as the element
  scripts do.

Coordinates, in millimeters: X across the console (left to right), Y from the
front edge to the back, Z up from the desk it stands on.
"""

from pathlib import Path
import math
import re
import shutil
import zipfile

from build123d import (
    Box,
    Color,
    Compound,
    Cylinder,
    ExportSVG,
    Plane,
    Polygon,
    Pos,
    Rot,
    Solid,
    Unit,
    export_gltf,
    export_step,
    export_stl,
    extrude,
)

from recipe import compact_glb

CAD = Path(__file__).resolve().parent
REPO = CAD.parent
PRINTED = (0.86, 0.42, 0.28)
M5 = 2.75  # clearance for an M5 screw

# ---------------------------------------------------------------------------
# Shapes
# ---------------------------------------------------------------------------


def box(x0, x1, y0, y1, z0, z1):
    return Pos((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2) * Box(x1 - x0, y1 - y0, z1 - z0)


def cyl_x(y, z, r, x0, x1):
    return Pos((x0 + x1) / 2, y, z) * Rot(0, 90, 0) * Cylinder(r, x1 - x0)


def cyl_y(x, z, r, y0, y1):
    return Pos(x, (y0 + y1) / 2, z) * Rot(90, 0, 0) * Cylinder(r, y1 - y0)


def cyl_z(x, y, r, z0, z1):
    return Pos(x, y, (z0 + z1) / 2) * Cylinder(r, z1 - z0)


def rod(p0, p1, r):
    d = [b - a for a, b in zip(p0, p1)]
    length = sum(c * c for c in d) ** 0.5
    return Solid.make_cylinder(r, length, Plane(origin=p0, z_dir=d))


def cord(points, r):
    """A cable through a list of points, as straight runs with a round at every bend."""
    shape = None
    for a, b in zip(points, points[1:]):
        piece = rod(a, b, r)
        shape = piece if shape is None else shape + piece
    for p in points[1:-1]:
        shape += Pos(*p) * Solid.make_sphere(r)
    return shape


def vslot(x0, x1, y0, y1, z0, z1):
    """Aluminum V-slot extrusion, simplified: a plain slot every 20 mm along each long face."""
    sizes = (x1 - x0, y1 - y0, z1 - z0)
    along = sizes.index(max(sizes))
    e = box(x0, x1, y0, y1, z0, z1)
    lo, hi = (x0, y0, z0), (x1, y1, z1)
    for face in range(3):
        if face == along:
            continue
        other = 3 - along - face
        for c in range(int(round((hi[other] - lo[other]) / 20))):
            mid = lo[other] + 10 + 20 * c
            for at_low in (True, False):
                a, b = [list(lo), list(hi)]
                a[other], b[other] = mid - 3.1, mid + 3.1
                if at_low:
                    b[face] = lo[face] + 6
                else:
                    a[face] = hi[face] - 6
                e -= box(a[0], b[0], a[1], b[1], a[2], b[2])
    return e


def lay_flat(shape, rotation):
    s = rotation * shape
    bb = s.bounding_box()
    return Pos(-(bb.min.X + bb.max.X) / 2, -(bb.min.Y + bb.max.Y) / 2, -bb.min.Z) * s


# ---------------------------------------------------------------------------
# The desk
# ---------------------------------------------------------------------------


class Desk:
    """A slanted desk: rails across, a printed cheek at each end, a printed panel on top.

    w: inside width, between the cheeks. d: depth, front to back.
    front, back: the panel's underside height at its front and back edges.
    tiles: where the panel is cut across, e.g. [0, 190, 380], each tile small
    enough to print.
    """

    T = 4.0  # panel thickness; most panel buttons clamp up to about 5 mm
    CHEEK = 6.0  # cheek thickness

    def __init__(self, w, d, front, back, tiles):
        self.w, self.d, self.front, self.back, self.tiles = w, d, front, back, tiles
        self.angle = math.degrees(math.atan2(back - front, d))
        self.tan = (back - front) / d
        self.slope = math.hypot(d, back - front)

    def under(self, y):
        """The panel's underside height at depth y."""
        return self.front + y * self.tan

    # Where each rail sits: its center across Y and Z.
    def rails_yz(self):
        return [
            (10.0, 10.0),  # front, at the bottom
            (self.d - 10.0, 10.0),  # back, at the bottom
            (10.0, self.under(0) - 10.0),  # front, under the panel
            (self.d - 10.0, self.under(self.d - 20) - 10.0),  # back, under the panel
        ]

    def rails(self):
        return [vslot(0, self.w, y - 10, y + 10, z - 10, z + 10) for y, z in self.rails_yz()]

    def cheek(self, right):
        """Prints flat. Its top edge is flush with the panel's top face."""
        top = self.T / math.cos(math.radians(self.angle))
        outline = Polygon((0, 0), (self.d, 0), (self.d, self.back + top), (0, self.front + top), align=None)
        x0 = self.w if right else -self.CHEEK
        p = Pos(x0, 0, 0) * extrude(Plane.YZ * outline, amount=self.CHEEK)
        for y, z in self.rails_yz():
            p -= cyl_x(y, z, M5, x0 - 1, x0 + self.CHEEK + 1)  # into the rail's tapped end
        return p

    def place(self, shape, u, v):
        """A shape drawn with its origin on the panel's top face, +Z out of the panel, placed at
        u across and v up the slope from the panel's front edge."""
        return Pos(0, 0, self.front) * Rot(self.angle, 0, 0) * Pos(u, v, self.T) * shape

    def tile(self, x0, x1, holes=(), windows=()):
        """One printed tile of the panel, from x0 to x1. holes: (u, v, diameter).
        windows: (u, v, width, height) cutouts, centered on (u, v)."""
        t = box(x0, x1, 0, self.slope, 0, self.T)
        for u, v, dia in holes:
            if x0 <= u <= x1:
                t -= cyl_z(u, v, dia / 2, -1, self.T + 1)
        for u, v, ww, hh in windows:
            if x0 <= u <= x1:
                t -= box(u - ww / 2, u + ww / 2, v - hh / 2, v + hh / 2, -1, self.T + 1)
        for y in (10.0, self.d - 10.0):  # screws into the top rails' slots
            v = y / math.cos(math.radians(self.angle))
            for u in (x0 + 15, x1 - 15):
                t -= cyl_z(u, v, M5, -1, self.T + 1)
        return Pos(0, 0, self.front) * Rot(self.angle, 0, 0) * t

    def tile_flat(self):
        """The rotation that lays a tile top face down for printing."""
        return Rot(180, 0, 0) * Rot(-self.angle, 0, 0) * Pos(0, 0, -self.front)

    def tray(self, x0, x1):
        """The printed plate the electronics sit on, screwed to the two bottom rails."""
        t = box(x0, x1, 0, self.d, 20, 23)
        for y in (10.0, self.d - 10.0):
            for x in (x0 + 12, x1 - 12):
                t -= cyl_z(x, y, M5, 19, 24)
        for x in (x0 + 30, x1 - 30):  # slots for the zip ties that hold the boards
            for y in (self.d * 0.3, self.d * 0.7):
                t -= box(x - 2.5, x + 2.5, y - 1.3, y + 1.3, 19, 24)
        return t

    def feet(self):
        return [cyl_z(x, y, 8, -4, 0) for x in (25, self.w - 25) for y in (10, self.d - 10)]


# ---------------------------------------------------------------------------
# Bought controls, with their origin on the panel's top face
# ---------------------------------------------------------------------------


def arcade_button(head=33.0, body=28.0, depth=35.0):
    return cyl_z(0, 0, head / 2, 0, 8) + cyl_z(0, 0, body / 2 - 0.5, -depth, 0)


def dome_button(head=60.0, body=28.0, depth=50.0, rise=18.0):
    """A lit dome: a ring on the panel and a round cap rising out of it."""
    a = head / 2 - 5  # the cap's radius where it meets the ring
    r = (a * a + rise * rise) / (2 * rise)
    cap = (Pos(0, 0, 5 + rise - r) * Solid.make_sphere(r)) & box(-a, a, -a, a, 5, 5 + rise)
    return cyl_z(0, 0, head / 2, 0, 5) + cap + cyl_z(0, 0, body / 2 - 0.5, -depth, 0)


# The bodies below start under the panel (`under` is its thickness): only a neck passes through the hole.


def stop_button(under=4.0):
    return cyl_z(0, 0, 20, 8, 22) + cyl_z(0, 0, 15, 0, 8) + cyl_z(0, 0, 11, -under, 0) + box(-15, 15, -20, 20, -under - 45, -under)


def push_button():
    return cyl_z(0, 0, 9, 0, 7) + cyl_z(0, 0, 8, -28, 0)


def light():
    return cyl_z(0, 0, 4.5, 0, 3) + cyl_z(0, 0, 2.5, 3, 7)


def antenna():
    return cyl_z(0, 0, 5, 0, 12) + cyl_z(0, 0, 4.5, 12, 190)


def knob(d=50.0, under=4.0):
    return cyl_z(0, 0, d / 2, 0, 22) + cyl_z(0, 0, 3.5, -under, 0) + box(-7, 7, -7, 7, -under - 20, -under)


def lever(under=4.0):
    """A toggle under a flip-up cover: a lever you have to mean to throw."""
    base = box(-16, 16, -11, 11, 0, 10)
    cover = Pos(0, 11, 10) * Rot(-60, 0, 0) * box(-13, 13, -36, 0, 0, 3)
    return base + cover + cyl_z(0, 0, 6, -under, 0) + box(-8, 8, -8, 8, -under - 30, -under)


def pads(nx, ny, pitch=15.0):
    """Silicone button pads on their boards, nx by ny keys at this pitch, centered on the origin."""
    shape = box(-nx * pitch / 2, nx * pitch / 2, -ny * pitch / 2, ny * pitch / 2, -6, -4.5)  # boards, under the panel
    for i in range(nx):
        for j in range(ny):
            x, y = (i - (nx - 1) / 2) * pitch, (j - (ny - 1) / 2) * pitch
            shape += box(x - 6.5, x + 6.5, y - 6.5, y + 6.5, -4.5, 3.5)
    return shape


def screen():
    """A small I2C screen module, glued under its window."""
    return box(-17.5, 17.5, -17, 17, -5, -0.5) + box(-14.5, 14.5, -7.5, 7.5, -0.5, 0)


def jack():
    """A 1/4 inch panel jack: its nut on the face, its body behind."""
    return cyl_z(0, 0, 7, 0, 2.5) + cyl_z(0, 0, 8, -26, 0)


def boards(w=72.0, d=60.0):
    """The ESP32-S3 on its screw-terminal board."""
    return box(-w / 2, w / 2, -d / 2, d / 2, 0, 12) + box(-w / 2 + 4, w / 2 - 6, -14, 14, 12, 17)


# ---------------------------------------------------------------------------
# Writing the files
# ---------------------------------------------------------------------------


def export(name, script, printed, bought, render_views, title):
    """printed: (part name, shape, rotation that lays it flat). bought: (name, shape, rgb).
    render_views: (None, eye, look_at, caption) for each figure of the whole model. Pass None rather than a group
    of the parts: the model takes the parts over, and a group made of them is left empty.
    Writes site/public/files/<name>/ and the line render in the console's drawings."""
    out = REPO / f"site/public/files/{name}"
    drawings = REPO / f"site/src/content/instruments/{name}/drawings"
    (out / "stl").mkdir(parents=True, exist_ok=True)
    drawings.mkdir(parents=True, exist_ok=True)
    for old in (out / "stl").glob("*.stl"):
        old.unlink()

    assembly = []
    print(f"{'part':18} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    for part_name, shape, rotation in printed:
        shape.label = f"printed:{part_name}"
        shape.color = Color(*PRINTED)
        assembly.append(shape)
        flat = lay_flat(shape, rotation)
        size = flat.bounding_box().size
        print(f"{part_name:18} {shape.volume / 1000:10.1f}  {size.X:5.0f} x {size.Y:4.0f} x {size.Z:4.0f}  {shape.is_valid}")
        export_stl(flat, out / "stl" / f"{name}-{part_name}.stl", tolerance=0.02, angular_tolerance=0.2)
    for part_name, shape, rgb in bought:
        shape.label = f"bought:{part_name}"
        shape.color = Color(*rgb)
        assembly.append(shape)

    model = Compound(children=assembly, label=name)
    export_step(model, out / f"{name}.step")
    export_gltf(model, out / f"{name}.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    compact_glb(out / f"{name}.glb")
    with zipfile.ZipFile(out / f"{name}-stl.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for stl in sorted((out / "stl").glob("*.stl")):
            z.write(stl, f"{name}/{stl.name}")
    shutil.copyfile(script, out / f"{name}-cad.py")
    shutil.copyfile(__file__, out / "console.py")

    views = [(model if shape is None else shape, eye, look, caption) for shape, eye, look, caption in render_views]
    sheet(drawings / f"{name}-render.svg", title, "Drawn from the CAD model.", views)
    print("written to", out)
    return model


def sheet(path, title, note, views):
    """Line drawings of the model, framed like the other drawing sheets.
    views: (shape, eye, look_at, caption) for each figure, stacked top to bottom."""
    figures, y = [], 110
    for i, (shape, eye, look_at, caption) in enumerate(views, start=1):
        visible, _hidden = shape.project_to_viewport(eye, viewport_up=(0, 0, 1), look_at=look_at)
        svg = ExportSVG(margin=4, line_weight=0.25)
        svg.add_layer("visible", line_color=(0.067, 0.067, 0.067), line_weight=0.25)
        svg.add_shape(visible, layer="visible")
        raw = path.with_suffix(f".{i}.svg")
        svg.write(raw)
        inner = raw.read_text()
        raw.unlink()
        view_box = re.search(r'viewBox="([^"]+)"', inner).group(1)
        body = inner[inner.index(">", inner.index("<svg")) + 1 : inner.rindex("</svg>")]
        _, _, vw, vh = (float(v) for v in view_box.split())
        w = min(1120, round(560 * vw / vh))
        h = round(w * vh / vw)
        figures.append(f'<svg x="{600 - w / 2:.0f}" y="{y}" width="{w}" height="{h}" viewBox="{view_box}">{body}</svg>')
        figures.append(f'<text x="600" y="{y + h + 34}" font-size="15" letter-spacing="2" text-anchor="middle" fill="#111">FIG. {i}   {caption}</text>')
        y += h + 80
    height = y
    path.write_text(
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 {height}" width="1200" height="{height}" '
        f'font-family="Georgia, \'Times New Roman\', serif">\n'
        f"<title>{title}</title>\n"
        f'<rect width="1200" height="{height}" fill="#fff"/>\n'
        '<text x="40" y="48" font-size="13" letter-spacing="4" fill="#111">THE MACHINE</text>\n'
        f'<text x="40" y="76" font-size="22" fill="#111">{title}</text>\n'
        f'<text x="1160" y="48" font-size="12" text-anchor="end" fill="#111">{note}</text>\n'
        '<text x="1160" y="66" font-size="12" text-anchor="end" fill="#111">2026-10-19</text>\n'
        '<line x1="40" y1="88" x2="1160" y2="88" stroke="#111" stroke-width="1"/>\n'
        + "\n".join(figures)
        + "\n</svg>\n"
    )
