# SPDX-License-Identifier: Apache-2.0
"""Goldfish v01 — parametric layout and fit check.

Coordinates: X along the body (head = +X, tail = -X), Y across (belly = -Y),
Z up (the eye faces +Z). Cavity floor is z = 0.

Every part is an axis-aligned keep-out box sized from vendor data
(see ../bom.md). `check()` proves each box sits inside the cavity and no two
boxes collide; `search()` finds the smallest body that passes.
"""
from __future__ import annotations

import itertools
import math
import sys
from dataclasses import dataclass, field

from build123d import Align, Box, Location, Part, fillet, Axis

PHI = (1 + 5 ** 0.5) / 2


@dataclass
class Params:
    L: float = 124.0          # outer length (v01, from search())
    W: float = 45.0           # outer width
    T: float = 22.0           # outer thickness
    wall: float = 1.8
    rz: float = 8.8           # outer radius of the top edges (the soft dome) = 0.4·T
    rz_bot: float = 5.0       # outer radius of the bottom edges (a flatter belly that sits still on a table)
    shape: str = "pill"       # "pill" (v01a) or "pebble" (v01b)
    h_bot_frac: float = 0.36  # pebble: share of T below the widest plane
    x_peak: float = 0.10      # pebble: widest point toward the head, fraction of L
    battery: tuple = (62.5, 37.0, 7.5)   # bay: fits 2000 mAh 60×36×7 (Adafruit 2011) or 1200 mAh 62×34×5 (Adafruit 258)
    eye_r: float = 10.5       # radius of the 5 LED centres
    clear: float = 0.3        # assembly clearance between stacked parts
    diffuser: float = 0.8     # eye diffuser floor above the LEDs
    port_gap: float | None = None  # None = board centred across the body
    usb_inset: float = 1.3    # cavity tail tip → USB-C receptacle face
    charger_inset: float = 9.0
    nfc_drop: float = 1.5     # how far the tag sits below the cavity ceiling (the tail roof curves down)


def cavity_dims(p: Params):
    return p.L - 2 * p.wall, p.W - 2 * p.wall, p.T - 2 * p.wall


def pebble_of(p: Params):
    from pebble import Pebble
    hb = p.T * p.h_bot_frac
    return Pebble(L=p.L, W=p.W, H_top=p.T - hb, H_bot=hb, x_peak=p.x_peak)


def make_body(p: Params, inset: float) -> Part:
    if p.shape == "pebble":
        pb = pebble_of(p)
        s = pb.solid(inset=inset)
        return s.moved(Location((0, 0, pb.H_bot - p.wall)))   # cavity floor at z = 0
    L, W, T = p.L - 2 * inset, p.W - 2 * inset, p.T - 2 * inset
    rt, rb = max(p.rz - inset, 0.5), max(p.rz_bot - inset, 0.5)
    b = Box(L, W, T, align=(Align.CENTER, Align.CENTER, Align.CENTER))
    b = fillet(b.edges().filter_by(Axis.Z), radius=W / 2 - 0.01)
    b = fillet(b.edges().group_by(Axis.Z)[-1], radius=min(rt, T / 2 - 0.05))
    b = fillet(b.edges().group_by(Axis.Z)[0], radius=min(rb, T / 2 - 0.05))
    zc = (p.T - 2 * p.wall) / 2          # centre so the cavity floor is z = 0
    return b.moved(Location((0, 0, zc)))


@dataclass
class Comp:
    name: str
    size: tuple            # (dx, dy, dz)
    at: tuple              # min corner (x, y, z)
    group: str = ""        # parts in the same group may touch (e.g. board + its own keep-outs)

    def solid(self) -> Part:
        return Box(*self.size, align=(Align.MIN, Align.MIN, Align.MIN)).moved(Location(self.at))


def layout(p: Params) -> tuple[list[Comp], dict]:
    Li, Wi, Ti = cavity_dims(p)
    head, tail = Li / 2, -Li / 2
    c: list[Comp] = []
    info: dict = {}
    cl = p.clear

    # --- tail tip: USB-C breakout (Adafruit 4090) feeding the charger's DC-in pad (JP1-2, via its Schottky)
    usb_len, usb_w, usb_h = 20.4, 14.2, 5.0
    usb_tip = tail + p.usb_inset
    # --- charger (Adafruit 6091, PCB 31.75 × 25.4, 7.2 tall with JST-PH) on the floor behind the tip
    ch_x0 = tail + p.charger_inset
    c.append(Comp("bq25185 charger", (25.4, 31.75, 7.2), (ch_x0, -31.75 / 2, 0.8)))
    usb_z = 0.8 + 7.2 + cl
    c.append(Comp("USB-C breakout (charge)", (usb_len, usb_w, usb_h), (usb_tip, -usb_w / 2, usb_z)))

    # --- battery + board stack, starting just past the charger
    x_t = ch_x0 + 25.4 + 0.5                       # tail end of battery and board
    bx, by, bz = p.battery
    c.append(Comp("battery", (bx, by, bz), (x_t, -by / 2, 0.2)))
    batt_top = 0.2 + bz
    pcb_bot = batt_top + cl + 3.7                  # deepest underside part (keep-out B) clears the battery
    pcb_top = pcb_bot + 1.6
    # board local x: 0 = old RJ45 end (tail), 65 = head. local y = 0 is the belly edge (USB-C ports).
    y0 = -15.0 if p.port_gap is None else -Wi / 2 + p.port_gap   # centred by default; USB is debug-only now
    def bl(lx0, lx1, ly0, ly1, z0, z1, name):
        c.append(Comp(name, (lx1 - lx0, ly1 - ly0, z1 - z0), (x_t + lx0, y0 + ly0, z0), group="board"))
    bl(0, 65, 0, 30, pcb_bot, pcb_top, "radxa pcb")
    bl(0, 65, 0, 30, pcb_top, pcb_top + 1.0, "radxa top parts (<=1.0)")
    bl(8, 17, -1.5, 10, pcb_top, pcb_top + 3.0, "radxa USB-C ports (3.0)")
    bl(9, 17, 6, 22, pcb_bot - 2.7, pcb_bot, "radxa underside A (2.7)")
    bl(49, 57, 6, 22, pcb_bot - 3.7, pcb_bot, "radxa underside B (3.7)")
    bl(61, 65, 6, 26, pcb_bot - 2.2, pcb_bot, "radxa underside C (2.2)")
    board_head = x_t + 65
    info.update(pcb_bot=round(pcb_bot, 2), pcb_top=round(pcb_top, 2), board_tail_x=round(x_t, 2), board_y0=round(y0, 2))

    # --- in the gap under the board (local x 19–47 is clear underneath): secure element, wires soldered (no STEMMA sockets)
    c.append(Comp("ATECC608 breakout (wired)", (17.7, 25.5, 2.6), (x_t + 18.5, y0 + 2.5, batt_top + cl)))
    # --- vibration disc on the battery beside it
    c.append(Comp("ERM disc", (10.0, 10.0, 2.7), (x_t + 37.5, y0 + 12, batt_top + cl)))

    # --- nose: RTC on the floor, boost above it
    c.append(Comp("RV3028 RTC", (19.0, 19.0, 4.0), (board_head - 2.2, -9.5, 0.4)))
    c.append(Comp("pololu U3V40F5 boost", (15.2, 15.2, 5.6), (board_head + 0.3, -7.6, 0.4 + 4.0 + cl)))

    # --- eye at the golden section from the head tip: 5 NeoPixel minis + the mic-live sequin
    x_eye = p.L / 2 - p.L / PHI ** 2
    led_z = Ti - p.diffuser - 3.2
    for i in range(5):
        a = math.radians(90 - i * 72)
        cx, cy = x_eye + p.eye_r * math.cos(a), p.eye_r * math.sin(a)
        c.append(Comp(f"LED {i+1}", (9.1, 9.1, 3.2), (cx - 4.55, cy - 4.55, led_z), group="eye"))
    c.append(Comp("mic-live sequin", (4.0, 9.0, 2.0), (x_eye - 2.0, -4.5, Ti - p.diffuser - 2.0), group="eye"))
    info.update(x_eye=round(x_eye, 2), led_z=round(led_z, 2))

    # --- two I2S mics under the top shell, spread along the body
    mic_z = Ti - 0.4 - 1.8
    c.append(Comp("mic head (SPH0645)", (16.7, 12.7, 1.8), (x_eye + 18.0, -6.35, mic_z)))
    c.append(Comp("mic tail (SPH0645)", (16.7, 12.7, 1.8), (x_t + 7.0, -4.6, mic_z)))
    # --- passive NFC tag (SparkFun ST25DV64KC, 25.4 × 25.4, wired — no Qwiic sockets) under the tail's top shell.
    # The phone powers it; its RF side is disabled in firmware except while the read button is held.
    nfc_t = 2.6
    c.append(Comp("NFC tag ST25DV64KC (wired)", (25.4, 25.4, nfc_t), (x_t - 1.0 - 25.4, -12.7, Ti - 0.6 - nfc_t - p.nfc_drop)))
    # --- the four board posts that hang the Radxa from the top shell (M2.5 heat-set inserts)
    for hx in (3.5, 61.5):
        for hy in (3.5, 26.5):
            c.append(Comp(f"post {hx},{hy}", (6.4, 6.4, Ti - pcb_top - 0.1), (x_t + hx - 3.2, y0 + hy - 3.2, pcb_top), group="board"))  # hole pads are component-free
    info.update(Ti=round(Ti, 2), Li=round(Li, 2), Wi=round(Wi, 2))
    return c, info


def _box_samples(k, n=7):
    (sx, sy, sz), (ax, ay, az) = k.size, k.at
    for i in range(n):
        for j in range(n):
            u, v = i / (n - 1), j / (n - 1)
            for fx, fy, fz in ((u, v, 0), (u, v, 1), (u, 0, v), (u, 1, v), (0, u, v), (1, u, v)):
                yield ax + fx * sx, ay + fy * sy, az + fz * sz


def check_fast(p: Params, verbose=False):
    """Analytic check for the pebble: sample each box's surface against the cavity equation."""
    from pebble import inside
    pb = pebble_of(p)
    zshift = pb.H_bot - p.wall
    comps, info = layout(p)
    errs = []
    for k in comps:
        bad = sum(1 for (x, y, z) in _box_samples(k) if not inside(pb, x, y, z - zshift, inset=p.wall))
        if bad:
            errs.append(f"outside: {k.name} ({bad} sample pts)")
    for a, b in itertools.combinations(comps, 2):
        if a.group and a.group == b.group:
            continue
        ov = [max(0, min(a.at[i] + a.size[i], b.at[i] + b.size[i]) - max(a.at[i], b.at[i])) for i in range(3)]
        if ov[0] * ov[1] * ov[2] > 0.05:
            errs.append(f"collide: {a.name} × {b.name} ({ov[0]*ov[1]*ov[2]:.1f} mm³)")
    if verbose:
        print(p, info)
        print("\n".join(errs) or "fits")
    return not errs, errs


def check(p: Params, verbose: bool = False) -> tuple[bool, list[str]]:
    if p.shape == "pebble":
        return check_fast(p, verbose)
    cav = make_body(p, p.wall)
    comps, info = layout(p)
    errs: list[str] = []
    solids = {k.name: k.solid() for k in comps}
    for k in comps:
        s = solids[k.name]
        inside = (s & cav).volume
        if s.volume - inside > 0.05:
            errs.append(f"outside: {k.name} by {s.volume - inside:.1f} mm³")
    for a, b in itertools.combinations(comps, 2):
        if a.group and a.group == b.group:
            continue
        v = (solids[a.name] & solids[b.name]).volume
        if v > 0.05:
            errs.append(f"collide: {a.name} × {b.name} ({v:.1f} mm³)")
    if verbose:
        print(p, info)
        print("\n".join(errs) or "fits")
    return not errs, errs


def search():
    best = None
    for T in (21.0, 21.5, 22.0, 22.5, 23.0, 24.0):
        for W in (42.0, 43.0, 44.0, 45.0, 46.0):
            for L in range(112, 134, 2):
                p = Params(L=L, W=W, T=T, rz=T * 0.4)
                ok, _ = check(p)
                if ok:
                    vol = L * W * T
                    if best is None or vol < best[0]:
                        best = (vol, p)
                    break
    return best


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "search":
        print(search())
    else:
        check(Params(), verbose=True)
