#!/usr/bin/env python3
"""Static consistency checker for locked BIND_8C board constraints."""
import argparse, csv, pathlib, re, sys
ap=argparse.ArgumentParser()
ap.add_argument('--wrapper', required=True); ap.add_argument('--lpf', required=True)
ap.add_argument('--pinmap', required=True); ap.add_argument('--official-pinout', default='')
ap.add_argument('--top', default='bind_8c_board_top'); a=ap.parse_args()
def die(msg): print('ERROR:',msg,file=sys.stderr); raise SystemExit(1)
def norm(s): return re.sub(r'\s+','',str(s or '')).upper()
def pick(row,names,required=True):
 m={norm(k):v for k,v in row.items()}
 for n in names:
  if norm(n) in m and str(m[norm(n)]).strip(): return str(m[norm(n)]).strip()
 if required: die(f'CSV lacks one of fields {names}; columns={list(row)}')
 return ''
wrapper=pathlib.Path(a.wrapper).read_text(errors='replace'); lpf=pathlib.Path(a.lpf).read_text(errors='replace')
wclean=re.sub(r'/\*.*?\*/',' ',wrapper,flags=re.S); wclean=re.sub(r'//.*',' ',wclean)
mm=re.search(r'\bmodule\s+'+re.escape(a.top)+r'\b\s*(?:#\s*\(.*?\)\s*)?\((.*?)\)\s*;',wclean,re.S)
if not mm: die(f'top module declaration not found: {a.top}; specify the exact locked wrapper with --wrapper')
ports=set()
for dm in re.finditer(r'\b(?:input|output|inout)\b\s*(?:wire|reg|logic|signed|unsigned|tri|var|\s)*\s*(\[[^\]]+\])?\s*([^;]+?)(?=(?:\binput\b|\boutput\b|\binout\b)|$)',mm.group(1),re.S):
 rng,names=dm.groups(); names=re.sub(r'=.*?(?=,|$)','',names)
 ids=[re.sub(r'[^A-Za-z0-9_$].*$','',x.strip()) for x in names.split(',')]
 ids=[x for x in ids if re.match(r'^[A-Za-z_$][A-Za-z0-9_$]*$',x)]
 if rng:
  rr=re.match(r'\[\s*(\d+)\s*:\s*(\d+)\s*\]',rng)
  if not rr: die(f'non-constant port range unsupported: {rng}')
  hi,lo=map(int,rr.groups()); step=-1 if hi>=lo else 1
  for n in ids:
   for i in range(hi,lo+step,step): ports.add(f'{n}[{i}]')
 else: ports.update(ids)
if not ports: die('no ANSI-style external ports parsed from wrapper')
loc={}
for m in re.finditer(r'LOCATE\s+COMP\s+"([^"]+)"\s+SITE\s+"?([A-Za-z0-9]+)"?\s*;',lpf,re.I):
 p,b=m.group(1),m.group(2).upper()
 if p in loc and loc[p]!=b: die(f'duplicate conflicting LPF LOCATE for {p}: {loc[p]} vs {b}')
 loc[p]=b
iobuf={m.group(1):m.group(2) for m in re.finditer(r'IOBUF\s+PORT\s+"([^"]+)"\s+([^;]+);',lpf,re.I)}
with open(a.pinmap,newline='',encoding='utf-8-sig') as f: rows=list(csv.DictReader(f))
if not rows: die('PINMAP.csv has no data rows')
pm={}; bank={}
for row in rows:
 p=pick(row,['port','signal','net','port_name','wrapper_port']); b=pick(row,['ball','site','pin','package_pin','location']).upper()
 if p in pm and pm[p]!=b: die(f'PINMAP duplicate conflict for {p}: {pm[p]} vs {b}')
 pm[p]=b; bank[p]=pick(row,['bank','io_bank','bank_number'],False)
errs=[]
for p in sorted(ports):
 if p not in pm: errs.append(f'wrapper port absent from PINMAP: {p}')
 if p not in loc: errs.append(f'wrapper port unconstrained in LPF: {p}')
 if p in pm and p in loc and pm[p]!=loc[p]: errs.append(f'ball mismatch {p}: PINMAP={pm[p]} LPF={loc[p]}')
 if p not in iobuf: errs.append(f'wrapper port lacks LPF IOBUF: {p}')
 elif 'LVCMOS33' not in iobuf[p].upper(): errs.append(f'{p} is not explicitly LVCMOS33: {iobuf[p]}')
for p in sorted(set(pm)-ports): errs.append(f'PINMAP entry not on wrapper: {p}')
for p in sorted(set(loc)-ports): errs.append(f'LPF LOCATE targets non-wrapper port: {p}')
balls={}
for p,b in pm.items(): balls.setdefault(b,[]).append(p)
for b,ps in balls.items():
 if len(ps)>1: errs.append(f'duplicate locked ball {b}: {", ".join(sorted(ps))}')
clks=[p for p in ports if p.lower() in ('clk','clock','clk_25mhz','osc_clk') or ('25' in p.lower() and 'clk' in p.lower())]
if len(clks)!=1: errs.append(f'expected one identifiable 25 MHz clock port, got {clks}')
elif pm.get(clks[0],'')!='A10': errs.append(f'25 MHz clock {clks[0]} must be A10, got {pm.get(clks[0],"missing")}')
for p,b in bank.items():
 if norm(b) in ('8','BANK8','BANK_8'): errs.append(f'ordinary wrapper port assigned to reserved bank 8: {p}')
if a.official_pinout:
 with open(a.official_pinout,newline='',encoding='utf-8-sig') as f: orows=list(csv.DictReader(f))
 official={pick(r,['ball','pin','package_pin','site','pin_number']).upper():r for r in orows}
 for p,b in pm.items():
  if b not in official: errs.append(f'locked ball absent from official CABGA381 CSV: {p}={b}')
  elif bank.get(p):
   ob=pick(official[b],['bank','io_bank','bank_number'],False)
   if ob and norm(ob)!=norm(bank[p]): errs.append(f'official bank mismatch {p}/{b}: PINMAP={bank[p]} official={ob}')
for p in ports:
 if re.search(r'(cfg_addr|cfg_wdata|cfg_rdata|sensor_a|sensor_b|effort|clusters_done_oh|n_clusters_ro)\[',p,re.I): errs.append(f'internal BIND fabric bus leaked through wrapper: {p}')
if errs:
 print('\n'.join('FAIL: '+e for e in errs)); raise SystemExit(1)
print(f'PASS: top={a.top}; external_ports={len(ports)}; locked_LOCATEs={len(loc)}; unique_balls={len(balls)}')
for p in sorted(ports): print(f'ASSIGN {p} {pm[p]} {bank.get(p,"")}')
