#!/usr/bin/env python3
import csv,hashlib,json,re,subprocess,zipfile
from pathlib import Path

ROOT=Path(__file__).resolve().parent;BASE=ROOT.parent
DST=ROOT/'lamp_a3_r13_candidate';H=BASE/'engineering_handoff_local'
NEW='lamp_buck_lmr51450_a3_r13';CLI='/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli'
def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest()
def run(args,name):
    p=subprocess.run([CLI,*args],text=True,capture_output=True)
    (DST/name).write_text(p.stdout+'\nSTDERR:\n'+p.stderr)
    assert p.returncode==0,(p.stdout,p.stderr)
pcb=DST/(NEW+'.kicad_pcb');ger=DST/'gerbers';dr=DST/'drill';pos=DST/'centroid-front.csv'
checks=json.loads((ROOT/'r13_candidate_checks.json').read_text())
assert checks['candidate_board_sha256']==sha(pcb)
assert checks['candidate_schematic_sha256']==sha(DST/(NEW+'.kicad_sch'))
native=json.loads((DST/'drc-parity.json').read_text())
assert not native['violations'] and not native['unconnected_items']
assert len(native['schematic_parity'])==4 and all(v['type']=='missing_footprint' for v in native['schematic_parity'])
ger.mkdir(exist_ok=True);dr.mkdir(exist_ok=True)
run(['pcb','export','gerbers','--layers','F.Cu,B.Cu,F.Paste,B.Paste,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts,F.Fab,B.Fab','--crossout-DNP-footprints-on-fab-layers','--output',str(ger)+'/',str(pcb)],'gerber-export.log')
run(['pcb','export','drill','--format','excellon','--excellon-separate-th','--generate-map','--map-format','svg','--generate-report','--report-path',str(dr/'drill-report.txt'),'--output',str(dr)+'/',str(pcb)],'drill-export.log')
run(['pcb','export','pos','--format','csv','--units','mm','--side','front','--smd-only','--exclude-dnp','--output',str(pos),str(pcb)],'centroid-export.log')
old=H/'lamp_a2_quote_review_A20/kicad_cli_refresh'
def layer(p):return p.name.split('-',1)[1]
def geom(p):
    return '\n'.join(x for x in p.read_text().splitlines() if not x.startswith(('%TF.CreationDate,','%TF.ProjectId,','%TO.N,','G04 Created by KiCad')))
oldger={layer(p):p for p in (old/'gerbers_crossout').iterdir() if p.is_file() and p.suffix in ['.gbr','.gtl','.gbl','.gts','.gbs','.gtp','.gbp','.gto','.gbo','.gm1']}
newger={layer(p):p for p in ger.iterdir() if p.is_file() and p.suffix in ['.gbr','.gtl','.gbl','.gts','.gbs','.gtp','.gbp','.gto','.gbo','.gm1']}
# Layer suffixes may follow native modern/legacy extension settings.
def canon_key(n):return n.rsplit('.',1)[0]
oldger={canon_key(k):v for k,v in oldger.items()};newger={canon_key(k):v for k,v in newger.items()}
assert set(oldger)==set(newger),(oldger.keys(),newger.keys())
assert len(newger)==11
changed=[n for n in oldger if geom(oldger[n])!=geom(newger[n])]
assert set(changed)=={'F_Cu','F_Mask','F_Paste'},changed
def drill_geometry(p):return '\n'.join(x for x in p.read_text().splitlines() if not x.startswith(('; #@! TF.CreationDate,','; DRILL file KiCad')))
old_dr={layer(p):p for p in (old/'drill').glob('*.drl')};new_dr={layer(p):p for p in dr.glob('*.drl')}
assert set(old_dr)==set(new_dr)
assert all(drill_geometry(old_dr[k])==drill_geometry(new_dr[k]) for k in old_dr)
def read(p):
    with p.open(encoding='utf-8-sig',newline='') as f:return list(csv.DictReader(f))
def write(p,rows):
    with p.open('w',encoding='utf-8-sig',newline='') as f:
        w=csv.DictWriter(f,fieldnames=list(rows[0]));w.writeheader();w.writerows(rows)
oldpos=read(old/'centroid-front.csv');newpos=read(pos)
def posdata(rows):return {r['Ref']:{k:v for k,v in r.items() if k!='Package'} for r in rows}
assert posdata(oldpos)==posdata(newpos)
cam={'board_sha256':sha(pcb),'gerber_layers':len(newger),'changed_layers':changed,'expected_change':'R13 front copper/mask/paste pad geometry only; other board source bytes unchanged','drill_geometry_unchanged':True,'position_rows_unchanged':len(newpos),'timestamps_and_project_identity_excluded_in_comparison':True}
(ROOT/'cam_export_checks.json').write_text(json.dumps(cam,indent=2)+'\n')
receipts=json.loads((ROOT/'source_receipts.json').read_text())
for name,s in receipts.items():assert sha(ROOT/'sources'/(name+'.pdf'))==s['sha256']
checks=json.loads((ROOT/'r13_candidate_checks.json').read_text())
rows=read(BASE/'bom_candidates_A23/three_board_candidate_bom_A23.csv')
for r in rows:
    if r['Product']=='lamp':r['Board']='Lamp power A3 R13 engineering candidate'
    if r['Product']=='lamp' and r['Ref']=='R13':
        assert r['CandidateMPN']==''
        r.update({'CurrentPCBFootprint':'Lamp_Power:R0603_Panasonic_ERJPA3_Recommended','CandidateManufacturer':'Panasonic Industry','CandidateMPN':'ERJPA3F1000V','SelectionStatus':'CONDITIONAL_RATING_AND_LAND_RFQ_CANDIDATE','PrimarySourceURL':receipts['erjpa3']['url'],'PrimarySourceSHA256':receipts['erjpa3']['sha256'],'ChecksPerformed':'100 ohm, 1%, 0603; 0.33W with terminal <=130C; candidate land inside manufacturer range; native DRC/ERC pass. Assumed <=5.5V, R>=99ohm gives <=0.3056W. Circuit pin mapping unchanged.','RemainingQualification':'; '.join(checks['remaining_gates']),'UnitPriceUSD1000':'','QuoteStatus':'NO_NEW_FACTORY_OR_1K_PRICE_VERIFIED_A24'})
write(ROOT/'three_board_candidate_bom_A24.csv',rows)
for k in ['drill','vacuum','lamp']:write(ROOT/(k+'_candidate_bom_A24.csv'),[r for r in rows if r['Product']==k])
rfq=read(BASE/'bom_candidates_A23/three_board_1000_each_RFQ_A23.csv')
extra={k:'' for k in rfq[0]}
extra.update({'ManufacturerCandidate':'Panasonic Industry','MPN':'ERJPA3F1000V','DrillQtyFor1000':'0','VacuumQtyFor1000':'0','LampQtyFor1000':'1000','TotalRFQQty':'1000','Status':'CONDITIONAL_CANDIDATE_NOT_AVL_OR_ORDER'})
rfq.append(extra);write(ROOT/'three_board_1000_each_RFQ_A24.csv',rfq)
assert len(rows)==110 and sum(bool(r['CandidateMPN']) for r in rows)==86
assert len(rfq)==38 and sum(int(r['TotalRFQQty']) for r in rfq)==86000
assert not any(r['UnitPriceUSD'] for r in rfq)
report=H/'A24-R13-review.md'
include=[report]+[p for p in ROOT.rglob('*') if p.is_file() and '__pycache__' not in p.parts and 'sources' not in p.parts]
include=sorted(set(include))
manifest='\n'.join(sha(p)+'  A24/'+p.relative_to(BASE).as_posix() for p in include)+'\n'
zp=H/'Lamp-R13-CAD-and-BOM-candidate-A24.zip'
with zipfile.ZipFile(zp,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=6) as z:
    for p in include:z.write(p,'A24/'+p.relative_to(BASE).as_posix())
    z.writestr('A24/SHA256SUMS.txt',manifest)
with zipfile.ZipFile(zp) as z:
    assert z.testzip() is None
    for line in z.read('A24/SHA256SUMS.txt').decode().splitlines():
        digest,path=line.split('  ',1);assert hashlib.sha256(z.read(path)).hexdigest()==digest,path
(H/'A24-SHA256SUMS.txt').write_text(manifest)
ip=H/'LOCAL-PACKAGES.json';idx=json.loads(ip.read_text())
entry={'product':'Lamp R13 conditional CAD and three-board BOM candidate A24','archive':zp.name,'sha256':sha(zp),'bytes':zp.stat().st_size,'zip_entries':len(include)+1,'manifest_entries':len(include),'zip_crc':'PASS','manifest_readback':'PASS','mpn_coverage':'Drill 31/39; vacuum 33/43; lamp 22/28; conditional candidates only','rfq_groups':38,'candidate_pcb_sha256':sha(pcb),'physical_validation':'NOT_RUN_NO_SAMPLE','manufacturing_status':'FABRICATION_HOLD'}
idx['packages']=[x for x in idx['packages'] if x.get('archive')!=zp.name]+[entry]
idx['status']='A24_R13_CONDITIONAL_CAD_CANDIDATE_MPN_86_OF_110_ALL_FABRICATION_HOLD'
idx['latest_report']=report.name;idx['latest_machine_report']='../r13_candidate_A24/r13_candidate_checks.json';idx['latest_manifest']='A24-SHA256SUMS.txt'
idx['latest_candidate_sources']['lamp_power']='../r13_candidate_A24/lamp_a3_r13_candidate/'+NEW+'.kicad_pcb'
idx['latest_bom_candidates']={k:'../r13_candidate_A24/'+k+'_candidate_bom_A24.csv' for k in ['drill','vacuum','lamp']}
ip.write_text(json.dumps(idx,ensure_ascii=False,indent=2)+'\n')
print(json.dumps({'package':entry,'cam':cam},indent=2))
