from pathlib import Path
import hashlib, json, math, re, shutil

ROOT = Path(__file__).resolve().parents[2]
WORK = ROOT / 'work/tri-product-continuation-2026-10-10.nosync'
BASE = ROOT / 'outputs/三款PCBA工程包-v1.39-2026-10-08/硬件'
OUT = WORK / 'compact_bench_suite'
OUT.mkdir(parents=True, exist_ok=True)

SOURCES = {
    'drill_charge': BASE / 'drill_charge/evt/drill_charge_evt_a0.kicad_pcb',
    'drill_control': BASE / 'drill_control/evt/drill_control_evt_a1.kicad_pcb',
    'lamp_power': BASE / 'lamp_power/evt/lamp_power_evt_a1.kicad_pcb',
}

def sha(data):
    if isinstance(data, Path): data = data.read_bytes()
    elif isinstance(data, str): data = data.encode()
    return hashlib.sha256(data).hexdigest()

def blocks(src, pattern):
    result=[]
    for m in re.finditer(pattern, src, re.M):
        start=m.start(); depth=0; quoted=False; escaped=False
        for j in range(start,len(src)):
            c=src[j]
            if quoted:
                if escaped: escaped=False
                elif c=='\\': escaped=True
                elif c=='"': quoted=False
            else:
                if c=='"': quoted=True
                elif c=='(': depth+=1
                elif c==')':
                    depth-=1
                    if depth==0:
                        result.append((start,j+1,src[start:j+1])); break
    return result

def fp_records(src):
    out=[]
    for a,b,blk in blocks(src,r'^[ \t]*\(footprint\s+"[^"\n]+"'):
        rm=re.search(r'\(property\s+"Reference"\s+"([^"]+)"',blk)
        ref=rm.group(1) if rm else '?'
        am=re.search(r'^\s*\(at\s+([-\d.]+)\s+([-\d.]+)(?:\s+([-\d.]+))?',blk,re.M)
        at=tuple(float(x or 0) for x in am.groups()) if am else (0.0,0.0,0.0)
        pads=[]
        for _,_,pad in blocks(blk,r'^[ \t]*\(pad\s+"[^"\n]+"'):
            pn=re.match(r'\s*\(pad\s+"([^"]+)"',pad)
            xy=re.search(r'^\s*\(at\s+([-\d.]+)\s+([-\d.]+)(?:\s+([-\d.]+))?',pad,re.M)
            size=re.search(r'^\s*\(size\s+([\d.]+)\s+([\d.]+)',pad,re.M)
            net=re.search(r'\(net\s+(?:\d+\s+)?"([^"]+)"\)',pad)
            if pn and xy and size:
                pads.append({'num':pn.group(1),'x':float(xy.group(1)),'y':float(xy.group(2)),'angle':float(xy.group(3) or 0),'w':float(size.group(1)),'h':float(size.group(2)),'net':net.group(1) if net else ''})
        out.append({'start':a,'end':b,'block':blk,'ref':ref,'at':at,'pads':pads})
    return out

def board_geom(src):
    pts=[]
    for fp in fp_records(src):
        if fp['ref'].startswith('MH') or 'MountingHole' in fp['block'].splitlines()[0]:
            # Only remove a fixture hole when every pad is non-electrical.
            if any(x['net'] for x in fp['pads']):
                raise ValueError(f"refusing to remove net-connected mechanical footprint {fp['ref']}")
            continue
        fx,fy,fa=fp['at']; theta=math.radians(fa); co,si=math.cos(theta),math.sin(theta)
        pts.append((fx,fy))
        for p in fp['pads']:
            x=fx+p['x']*co-p['y']*si; y=fy+p['x']*si+p['y']*co
            a=math.radians(fa+p['angle']); ca,sa=abs(math.cos(a)),abs(math.sin(a))
            hx=(ca*p['w']+sa*p['h'])/2; hy=(sa*p['w']+ca*p['h'])/2
            pts += [(x-hx,y-hy),(x+hx,y+hy)]
    for _,_,seg in blocks(src,r'^[ \t]*\(segment\s*$'):
        wm=re.search(r'\(width\s+([\d.]+)\)',seg); r=float(wm.group(1))/2 if wm else 0
        for x,y in re.findall(r'\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)',seg):
            x=float(x);y=float(y);pts += [(x-r,y-r),(x+r,y+r)]
    for _,_,via in blocks(src,r'^[ \t]*\(via\s*$'):
        xy=re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)\)',via);sz=re.search(r'\(size\s+([\d.]+)\)',via)
        if xy:
            x,y=map(float,xy.groups());r=float(sz.group(1))/2 if sz else 0;pts += [(x-r,y-r),(x+r,y+r)]
    if not pts: raise ValueError('no copper/pad geometry to bound')
    xs=[x for x,y in pts]; ys=[y for x,y in pts]
    return math.floor(min(xs)-4),math.floor(min(ys)-4),math.ceil(max(xs)+4),math.ceil(max(ys)+4)

def outline_blocks(src):
    out=[]
    for a,b,blk in blocks(src,r'^\t\(gr_line\s*$'):
        if '(layer "Edge.Cuts")' in blk: out.append((a,b,blk))
    return out

def tracks_hash(src,tag):
    raw=sorted(b for _,_,b in blocks(src,r'^[ \t]*\('+tag+r'\s*$'))
    return len(raw),sha('\n'.join(raw))

def nonmechanical_fp_hashes(src):
    return {x['ref']:sha(x['block']) for x in fp_records(src) if not x['ref'].startswith('MH') and 'MountingHole' not in x['block'].splitlines()[0]}

def compact(src_path, out_dir, stem=None):
    source=src_path.read_text(); bounds=board_geom(source); x0,y0,x1,y1=bounds
    old_edges=outline_blocks(source)
    if len(old_edges)!=4: raise ValueError(f'{src_path.name}: expected rectangular 4-line Edge.Cuts, found {len(old_edges)}')
    edges=[]
    for _,_,block in old_edges:
        edges += [tuple(map(float,p)) for p in re.findall(r'\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)',block)]
    ex=[x for x,y in edges];ey=[y for x,y in edges]; old=(max(ex)-min(ex),max(ey)-min(ey))
    mounts=[x for x in fp_records(source) if x['ref'].startswith('MH') or 'MountingHole' in x['block'].splitlines()[0]]
    original_fp=nonmechanical_fp_hashes(source)
    original_tracks={t:tracks_hash(source,t) for t in ('segment','via')}
    spans=[(x['start'],x['end']) for x in mounts]
    spans += [(a,b) for a,b,_ in old_edges]
    # Board-level silk/drawing annotations with anchors outside the compact perimeter are carrier-only.
    removed_text=[]
    for a,b,blk in blocks(source,r'^\t\(gr_text\b'):
        at=re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)',blk)
        if at:
            x,y=map(float,at.groups())
            if x<x0+1.5 or x>x1-1.5 or y<y0+1.5 or y>y1-1.5:
                spans.append((a,b));removed_text.append(blk)
    for a,b in sorted(spans,reverse=True): source=source[:a]+source[b:]
    shape=(f'\n\t(gr_line (start {x0} {y0}) (end {x1} {y0}) (stroke (width 0.05) (type default)) (layer "Edge.Cuts"))\n'
           f'\t(gr_line (start {x1} {y0}) (end {x1} {y1}) (stroke (width 0.05) (type default)) (layer "Edge.Cuts"))\n'
           f'\t(gr_line (start {x1} {y1}) (end {x0} {y1}) (stroke (width 0.05) (type default)) (layer "Edge.Cuts"))\n'
           f'\t(gr_line (start {x0} {y1}) (end {x0} {y0}) (stroke (width 0.05) (type default)) (layer "Edge.Cuts"))\n')
    rootclose=source.rfind(')')
    if rootclose<0:raise ValueError('bad PCB root')
    source=source[:rootclose]+shape+source[rootclose:]
    out_dir.mkdir(parents=True,exist_ok=True)
    stem=stem or src_path.stem.replace('_evt_a0','_evt_a0_compact_bench').replace('_evt_a1','_evt_a1_compact_bench')
    target=out_dir/(stem+'.kicad_pcb')
    target.write_text(source)
    # The only remaining changes may be Edge.Cuts and out-of-bounds carrier text.
    if nonmechanical_fp_hashes(source)!=original_fp:raise ValueError(f'{target.name}: a functional footprint changed')
    for t in ('segment','via'):
        if tracks_hash(source,t)!=original_tracks[t]:raise ValueError(f'{target.name}: {t} geometry changed')
    pro=next(src_path.parent.glob('*.kicad_pro'))
    shutil.copy2(pro,out_dir/(stem+'.kicad_pro'))
    # Copy the matching sheet/library context if present.
    hwdir=src_path.parents[1]
    sch=hwdir/(src_path.parent.parent.name+'.kicad_sch')
    if sch.exists():shutil.copy2(sch,out_dir/sch.name)
    for item in ('fp-lib-table',):
        if (src_path.parent/item).exists():shutil.copy2(src_path.parent/item,out_dir/item)
    for lib in src_path.parent.glob('*.pretty'):
        shutil.copytree(lib,out_dir/lib.name,dirs_exist_ok=True)
    ow,oh=old; nw,nh=x1-x0,y1-y0
    result={'status':'GENERATED_DRC_PENDING','source_board':str(src_path),'source_sha256':sha(src_path),'candidate_board':str(target),'candidate_sha256':sha(target),'original_outline_mm':[ow,oh],'candidate_outline_mm':[nw,nh],'original_area_cm2':round(ow*oh/100,2),'candidate_area_cm2':round(nw*nh/100,2),'area_reduction_pct':round((1-(nw*nh)/(ow*oh))*100,2),'outline_xy_mm':[x0,y0,x1,y1],'removed_mechanical_refs':[x['ref'] for x in mounts],'removed_out_of_bounds_board_text_count':len(removed_text),'tracks_unchanged':True,'track_count':original_tracks['segment'][0],'track_sha256':original_tracks['segment'][1],'vias_unchanged':True,'via_count':original_tracks['via'][0],'via_sha256':original_tracks['via'][1],'nonmechanical_footprints_unchanged':True,'functional_footprint_count':len(original_fp),'physical_product_fit':'NOT_ESTABLISHED','manufacturing_release':False}
    (out_dir/'candidate_manifest.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
    return result

# Remove the out-of-board carrier label from the already-built motor compact candidate.
motor=WORK/'drill_motor_compact_bench_a1/drill_motor_evt_a1_compact_bench.kicad_pcb'
s=motor.read_text();bb=(8,5,166,59); rm=[]
for a,b,blk in blocks(s,r'^\t\(gr_text\b'):
    at=re.search(r'\(at\s+([-\d.]+)\s+([-\d.]+)',blk)
    if at:
        x,y=map(float,at.groups())
        if x<bb[0]+1.5 or x>bb[2]-1.5 or y<bb[1]+1.5 or y>bb[3]-1.5:rm.append((a,b))
for a,b in reversed(rm):s=s[:a]+s[b:]
motor.write_text(s)

results=[]
for name,source in SOURCES.items():
    result=compact(source,OUT/name)
    results.append(result)
(OUT/'suite_manifest.json').write_text(json.dumps({'status':'DRC_PENDING','candidates':results},ensure_ascii=False,indent=2)+'\n')
print(json.dumps(results,ensure_ascii=False,indent=2))
