#!/usr/bin/env python3
"""Align library/net metadata only; retain the physical design and circuit."""
import json
import re
import shutil
import xml.etree.ElementTree as ET
from pathlib import Path
from prepare_candidates import ROOT, BASE, SPECS, forms, run, sha

def netlist(p):
    root = ET.parse(p).getroot()
    fps = {c.attrib['ref']:c.findtext('footprint') or '' for c in root.findall('./components/comp')}
    pins = {}
    for n in root.findall('./nets/net'):
        for node in n.findall('node'):
            key = (node.attrib['ref'],node.attrib['pin'])
            assert key not in pins
            pins[key] = n.attrib['name']
    return fps, pins

def footprint_rows(text):
    for start,end,block in forms(text,'footprint'):
        ref = re.search(r'\(property\s+"Reference"\s+"([^"]+)"',block).group(1)
        ident = re.match(r'\(footprint\s+"([^"]+)"',block).group(1)
        yield start,end,block,ref,ident

def semantic_geometry(text):
    # Only these tokens are editable metadata. Every other token, including
    # all track/pad/zone coordinates, sizes, UUIDs and stackup, remains tested.
    text = re.sub(r'\(net\s+"(?:[^"\\]|\\.)*"\s*\)', '', text)
    text = re.sub(r'(\(footprint\s+)"[^"]+"',r'\1"<library-id>"',text)
    return re.sub(r'\s+', ' ', text).strip()

def main():
    results={}
    for key,(stem,sch_rel,name) in SPECS.items():
        dst=ROOT/key
        pcb=dst/(name+'.kicad_pcb'); sch=dst/(name+'.kicad_sch')
        source=BASE/(stem+'.kicad_pcb')
        before=source.read_text()
        fps,pins=netlist(dst/'netlist.xml')
        source_sch=BASE/sch_rel
        assert run(['sch','export','netlist','--format','kicadxml','--output',str(dst/'source_netlist.xml'),str(source_sch)],dst/'source-netlist.log')==0
        _,source_pins=netlist(dst/'source_netlist.xml')
        assert pins==source_pins,'Source/candidate schematic circuit differs'
        original_pins=dict(pins)
        board_rows=list(footprint_rows(before))
        schem_mpn_pending=[]
        if key=='drill':
            # These are already the PCB's selected engineering footprints.
            # Bind them to the matching schematic; this does not qualify an
            # MPN, connector fit, datasheet land pattern, or production part.
            fp_for_ref={ref:ident for _,_,_,ref,ident in board_rows}
            s=sch.read_text()
            for st,en,block in reversed(list(forms(s,'symbol'))):
                if not re.match(r'\(symbol\s+\(lib_id',block): continue
                ref=re.search(r'\(property\s+"Reference"\s+"([^"]+)"',block).group(1)
                if ref not in fps or fps[ref]: continue
                m=re.search(r'(\(property\s+"Footprint"\s+)"[^"]*"',block)
                assert m,ref
                b=block[:m.start()]+m.group(1)+json.dumps(fp_for_ref[ref])+block[m.end():]
                s=s[:st]+b+s[en:]; schem_mpn_pending.append(ref)
            sch.write_text(s)
            src_fp=source.parent
            shutil.copyfile(src_fp/'fp-lib-table',dst/'fp-lib-table')
            shutil.copytree(src_fp/'MotorStage.pretty',dst/'MotorStage.pretty',dirs_exist_ok=True)
            assert run(['sch','export','netlist','--format','kicadxml','--output',str(dst/'netlist.xml'),str(sch)],dst/'netlist.log')==0
            fps,pins=netlist(dst/'netlist.xml')
            assert pins==original_pins,'Schematic electrical pin mapping changed'

        connected=nc=0
        seen=set()
        for _,_,block,ref,_ in board_rows:
            for _,_,pad in forms(block,'pad'):
                pin=re.match(r'\(pad\s+"([^"]*)"',pad).group(1)
                if not pin: continue
                keypin=(ref,pin)
                if ref.startswith('MH'): continue
                assert keypin in pins,keypin
                seen.add(keypin)
                n=re.search(r'\(net\s+"([^"]+)"\s*\)',pad)
                existing=n.group(1) if n else None
                expected=pins[keypin]
                if expected.startswith('unconnected-'):
                    assert existing is None or existing.replace('{slash}','/')==expected,(keypin,existing,expected)
                    nc+=1
                else:
                    assert existing and existing.lstrip('/')==expected.lstrip('/'),(keypin,existing,expected)
                    connected+=1
        assert seen==set(pins),(set(pins)-seen,seen-set(pins))

        # Rebuild each footprint with library-qualified IDs and explicit NC
        # annotations. Named nets get their exact unique schematic root path.
        target_names={v.lstrip('/'):v for v in pins.values() if not v.startswith('unconnected-')}
        assert len(target_names)==len(set(v for v in pins.values() if not v.startswith('unconnected-')))
        def align_net(m):
            n=m.group(1)
            if n.startswith('unconnected-'):
                return '(net '+json.dumps(n.replace('/','{slash}'))+')'
            assert n.lstrip('/') in target_names,n
            return '(net '+json.dumps(target_names[n.lstrip('/')])+')'
        aligned=re.sub(r'\(net\s+"([^"]+)"\s*\)',align_net,before)
        for st,en,block,ref,ident in reversed(list(footprint_rows(aligned))):
            if ref.startswith('MH'): continue
            target=fps[ref]
            assert target and target.rsplit(':',1)[-1]==ident.rsplit(':',1)[-1],(ref,ident,target)
            updated=block
            for ps,pe,pad in reversed(list(forms(updated,'pad'))):
                pin=re.match(r'\(pad\s+"([^"]*)"',pad).group(1)
                expected=pins.get((ref,pin))
                if expected and expected.startswith('unconnected-') and not re.search(r'\(net\s+"',pad):
                    uu=re.search(r'(?m)^(\s*)\(uuid\s+',pad)
                    assert uu,(ref,pin)
                    # KiCad's native canonical NC names escape slash in pin
                    # names, while exported XML decodes it back to slash.
                    nc_name=expected.replace('/','{slash}')
                    newpad=pad[:uu.start()]+uu.group(1)+'(net '+json.dumps(nc_name)+')\n'+pad[uu.start():]
                    updated=updated[:ps]+newpad+updated[pe:]
            updated=re.sub(r'^(\(footprint\s+)"[^"]+"',lambda m:m.group(1)+json.dumps(target),updated,count=1)
            aligned=aligned[:st]+updated+aligned[en:]
        assert semantic_geometry(aligned)==semantic_geometry(before),'Physical tokens changed'
        pcb.write_text(aligned)

        run(['sch','erc','--severity-all','--format','json','--output',str(dst/'erc.json'),str(sch)],dst/'erc.log')
        rc=run(['pcb','drc','--severity-all','--all-track-errors','--schematic-parity','--format','json','--output',str(dst/'drc-parity.json'),str(pcb)],dst/'drc.log')
        d=json.loads((dst/'drc-parity.json').read_text())
        e=json.loads((dst/'erc.json').read_text())
        ev=[v for sheet in e.get('sheets',[]) for v in sheet.get('violations',[])]
        assert not d['violations'],d['violations']
        assert not d['unconnected_items'],d['unconnected_items']
        assert not ev,ev
        parity=d['schematic_parity']
        # Four mechanical mounting holes intentionally live only on the PCB.
        assert len(parity)==4 and all(v['type']=='extra_footprint' for v in parity),parity
        mount_refs={item['description'].split()[-1] for v in parity for item in v['items']}
        assert mount_refs=={'MH1','MH2','MH3','MH4'},mount_refs
        results[key]={'source_board_sha256':sha(source),'candidate_board_sha256':sha(pcb),'geometry_tokens_unchanged':True,'schematic_electrical_pin_mapping_unchanged':True,'connected_pin_pairs_checked':sum(not n.startswith('unconnected-') for n in pins.values()),'connected_pad_records_checked':connected,'explicit_nc_pairs_checked':nc,'erc_violations':0,'physical_drc_violations':0,'unconnected_items':0,'integrated_parity_run':'COMPLETED','parity_warning_count':4,'remaining_parity_refs':sorted(mount_refs),'remaining_parity_type':'PCB_ONLY_MECHANICAL_MOUNTING_HOLES','schematic_footprint_fields_bound_to_existing_board':sorted(schem_mpn_pending),'manufacturing_status':'FABRICATION_HOLD','physical_validation':'NOT_RUN_NO_SAMPLE'}
    (ROOT/'closure_checks.json').write_text(json.dumps(results,indent=2)+'\n')
    print(json.dumps(results,indent=2))

if __name__=='__main__': main()
