"""Parametric instrument enclosure. All geometric quantities are millimeters."""
from dataclasses import dataclass
from math import pi,sqrt,isfinite
import cadquery as cq
import re

@dataclass
class Part:
    name: str
    shape: object
    material: str
    color: str
    kind: str = 'manufactured'


def validate(p):
    if p['units'] != 'mm':raise ValueError('inputs must use mm units')
    for name in ['density_kg_m3','tray_side_margin','thickness','inside_radius','depth','height','post_size','side_clearance','panel_clearance','tray_depth','tray_standoff_height','pcb_width','pcb_depth','pcb_height','pcb_standoff_height','rear_connector_depth','cable_radius','cable_bend_radius','fastener_clearance_diameter','thread_envelope_diameter']:
        if not isinstance(p[name],(float,int)) or not isfinite(p[name]) or p[name]<=0:
            raise ValueError(f'{name} must be positive and finite')
    if not 0<p['k_factor']<1: raise ValueError('k_factor must lie between zero and one')
    if p['panel_clearance']>=p['inside_radius']:raise ValueError('panel clearance must be smaller than inside radius')
    if p['height']<=2*(p['inside_radius']+p['thickness']):raise ValueError('height is too small for bends')
    if p['fastener_clearance_diameter']<=p['thread_envelope_diameter']:raise ValueError('fastener clearance must exceed nominal thread envelope')
    for name,value in p['manufacturing'].items():
        if not isinstance(value,(int,float)) or not isfinite(value) or value<0:raise ValueError(f'manufacturing {name} must be finite and nonnegative')
    ids=set()
    for c in p['configurations']:
        if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*',c['id']):raise ValueError('configuration ID must be a safe lowercase identifier')
        if c['id'] in ids:raise ValueError('configuration IDs must be unique')
        ids.add(c['id'])
        w=c['width']
        if not isfinite(w) or w< p['pcb_width']+2*p['tray_side_margin']+10: raise ValueError('width does not fit electronics and tray margins')
    if p['pcb_depth']+20>p['tray_depth']: raise ValueError('PCB does not fit tray')
    if p['tray_depth']+2*(p['post_size']+p['thickness'])>=p['depth']:raise ValueError('tray touches corner posts')
    if p['tray_standoff_height']+p['pcb_standoff_height']+p['pcb_height']+3*p['thickness']>=p['height']:raise ValueError('electronics does not fit enclosure height')


def box(x,y,z,cx=0,cy=0,z0=0):
    return cq.Workplane('XY').box(x,y,z,centered=(True,True,False)).translate((cx,cy,z0)).val()


def cylinder(radius,length,x,y,z,direction=(0,0,1)):
    return cq.Solid.makeCylinder(radius,length,cq.Vector(x,y,z),cq.Vector(*direction))


def cut_holes(shape,positions,radius,length,direction):
    for x,y,z in positions: shape=shape.cut(cylinder(radius,length,x,y,z,direction))
    return shape.clean()


def dimensions(p,c):
    t,R,H,W,D=p['thickness'],p['inside_radius'],p['height'],c['width'],p['depth']
    ro=R+t; flat=W-2*ro; leg=H-t-ro; ba=pi/2*(R+p['k_factor']*t)
    px=W/2-t-p['side_clearance']-p['post_size']/2
    py=D/2-t-p['post_size']/2
    tray_w=W-2*p['tray_side_margin']; tray_d=p['tray_depth']
    return dict(t=t,R=R,H=H,W=W,D=D,ro=ro,flat=flat,leg=leg,ba=ba,blank=flat+2*leg+2*ba,px=px,py=py,tray_w=tray_w,tray_d=tray_d,
                post_centers=[(sx*px,sy*py) for sx in [-1,1] for sy in [-1,1]],tray_centers=[(sx*(tray_w/2-8),sy*(tray_d/2-8)) for sx in [-1,1] for sy in [-1,1]])


def shell(p,c):
    d=dimensions(p,c);t,R,W,D,H,ro=(d[k] for k in ['t','R','W','D','H','ro']);a=W/2-ro;s=sqrt(2)
    # A true constant-thickness U section with concentric quarter-annular bends.
    wire=(cq.Workplane('XZ',origin=(0,D/2,0)).moveTo(-W/2,H-t).lineTo(-W/2,ro)
          .threePointArc((-a-ro/s,ro-ro/s),(-a,0)).lineTo(a,0)
          .threePointArc((a+ro/s,ro-ro/s),(W/2,ro)).lineTo(W/2,H-t).lineTo(W/2-t,H-t).lineTo(W/2-t,ro)
          .threePointArc((a+R/s,ro-R/s),(a,t)).lineTo(-a,t)
          .threePointArc((-a-R/s,ro-R/s),(-W/2+t,ro)).lineTo(-W/2+t,H-t).close().extrude(D).val())
    points=d['post_centers']+d['tray_centers']
    return cut_holes(wire,[(x,y,-1) for x,y in points],p['fastener_clearance_diameter']/2,t+2,(0,0,1))


def panel(p,c,back=False):
    d=dimensions(p,c);t,R,H,W,D,ro=(d[k] for k in ['t','R','H','W','D','ro']);gap=p['panel_clearance'];a=W/2-ro;r=R-gap;s=sqrt(2);top=H-t-gap
    # Lower corner arcs follow the inside bend with a radial clearance gap.
    wp=(cq.Workplane('XZ',origin=(0,(D/2-t) if back else (-D/2+t),0)).moveTo(-a,t+gap).lineTo(a,t+gap)
        .threePointArc((a+r/s,ro-r/s),(a+r,ro)).lineTo(a+r,top).lineTo(-a-r,top).lineTo(-a-r,ro)
        .threePointArc((-a-r/s,ro-r/s),(-a,t+gap)).close().extrude(-t if back else t).val())
    ys=D/2-t-1 if back else -D/2-1
    holes=[(sx*d['px'],ys,z) for sx in [-1,1] for z in [20,H-20]]
    wp=cut_holes(wp,holes,p['fastener_clearance_diameter']/2,t+2,(0,1,0))
    if back:
        wp=wp.cut(cylinder(8,t+2,30,ys,40,(0,1,0)))
        # Service/connector opening, deliberately no unverified vendor footprint.
        wp=wp.cut(box(24,t+2,12,-35,ys+(t+2)/2,30))
    else:
        wp=wp.cut(box(68,t+2,28,-30,ys+(t+2)/2,40))
        wp=wp.cut(cylinder(6,t+2,60,ys,38,(0,1,0)))
    return wp.clean()


def build(p,c):
    validate(p);d=dimensions(p,c);t,H,D=d['t'],d['H'],d['D'];hole=p['thread_envelope_diameter']/2;parts=[]
    parts.append(Part('u-shell',shell(p,c),'aluminum','#8da792'))
    parts.append(Part('front-panel',panel(p,c),'aluminum','#c88d72'))
    parts.append(Part('rear-panel',panel(p,c,True),'aluminum','#a9bcb4'))
    lid=cut_holes(box(d['W'],D,t,z0=H-t),[(x,y,H-t-1) for x,y in d['post_centers']],p['fastener_clearance_diameter']/2,t+2,(0,0,1))
    parts.append(Part('lid',lid,'aluminum','#aabfb2'))
    tray=cut_holes(box(d['tray_w'],d['tray_d'],t,z0=t+p['tray_standoff_height']),[(x,y,t+p['tray_standoff_height']-1) for x,y in d['tray_centers']],p['fastener_clearance_diameter']/2,t+2,(0,0,1))
    # PCB mounting holes match the declared reference PCB stand-off centers.
    pcb_centers=[(sx*(p['pcb_width']/2-6),sy*(p['pcb_depth']/2-6)) for sx in [-1,1] for sy in [-1,1]]
    tray=cut_holes(tray,[(x,y,t+p['tray_standoff_height']-1) for x,y in pcb_centers],p['fastener_clearance_diameter']/2,t+2,(0,0,1))
    parts.append(Part('electronics-tray',tray,'aluminum','#c5b58c'))
    for i,(x,y) in enumerate(d['post_centers']):
        post=box(p['post_size'],p['post_size'],H-2*t,x,y,t)
        post=post.cut(cylinder(hole,8,x,y,t)).cut(cylinder(hole,8,x,y,H-t,(0,0,-1)))
        side=1 if y>0 else -1
        for z in [20,H-20]:post=post.cut(cylinder(hole,8,x,side*(D/2-t),z,(0,-side,0)))
        parts.append(Part(f'corner-post-{i+1}',post.clean(),'aluminum','#738e99'))
    for i,(x,y) in enumerate(d['tray_centers']):
        spacer=cylinder(3,p['tray_standoff_height'],x,y,t).cut(cylinder(hole,p['tray_standoff_height'],x,y,t))
        parts.append(Part(f'tray-spacer-{i+1}',spacer,'steel','#aeb6b3','hardware'))
    for i,(x,y) in enumerate(pcb_centers):
        z=2*t+p['tray_standoff_height'];spacer=cylinder(3,p['pcb_standoff_height'],x,y,z).cut(cylinder(hole,p['pcb_standoff_height'],x,y,z))
        parts.append(Part(f'pcb-spacer-{i+1}',spacer,'steel','#8da0a9','hardware'))
    # Simplified fasteners use nominal M3 major-diameter cylinders. No helical threads.
    def screw(name,x,y,z,direction,shank):
        head=cylinder(2.75,2.5,x,y,z,tuple(-v for v in direction));shaft=cylinder(1.5,shank,x,y,z,direction)
        parts.append(Part(name,head.fuse(shaft).clean(),'steel','#c4ccc7','fastener'))
    for i,(x,y) in enumerate(d['post_centers']):
        screw(f'lid-screw-{i+1}',x,y,H,(0,0,-1),8)
        screw(f'base-screw-{i+1}',x,y,0,(0,0,1),8)
        side=1 if y>0 else -1
        for j,z in enumerate([20,H-20]):screw(f'panel-screw-{i+1}-{j+1}',x,side*D/2,z,(0,-side,0),8)
    for i,(x,y) in enumerate(d['tray_centers']):
        # Short screws enter each end of the threaded spacer without meeting.
        screw(f'tray-screw-{i+1}',x,y,2*t+p['tray_standoff_height'],(0,0,-1),6)
        screw(f'tray-base-screw-{i+1}',x,y,0,(0,0,1),6)
    for i,(x,y) in enumerate(pcb_centers):
        screw(f'pcb-spacer-screw-{i+1}',x,y,t+p['tray_standoff_height'],(0,0,1),8)
    for i,(x,y) in enumerate(d['post_centers']):
        foot=cylinder(8,6,x,y,-6).cut(cylinder(3,6,x,y,-6))
        parts.append(Part(f'foot-{i+1}',foot,'elastomer','#526458','hardware'))
    # Electronics and connectors are separate reference envelopes, not fabricated parts.
    z=2*t+p['tray_standoff_height']+p['pcb_standoff_height']
    envs=[Part('electronics-envelope',box(p['pcb_width'],p['pcb_depth'],p['pcb_height'],z0=z),'reference','#658c9b','envelope'),
          Part('display-envelope',box(74,20,34,-30,-D/2+t+10,37),'reference','#668778','envelope'),
          Part('rear-connector-envelope',cylinder(8,p['rear_connector_depth'],30,D/2-t,40,(0,-1,0)),'reference','#668778','envelope')]
    return parts,envs,d


def compound(parts):return cq.Compound.makeCompound([p.shape for p in parts])


def mass_kg(part,p):
    density=7850 if part.material=='steel' else 1100 if part.material=='elastomer' else p['density_kg_m3']
    return part.shape.Volume()*density/1e9
