"""Session 59: source-pixel delimiter inventory on the six Session 58 pages.
Aligned to retained image-first reviewed transcripts; not independent OCR.
Requires NumPy, SciPy and Pillow. Run beside the evidence files.
"""
from pathlib import Path
from collections import Counter
import json,hashlib
import numpy as np
from scipy import ndimage
from PIL import Image,ImageDraw,ImageFont
from image_first_audit import read_manual,read_master,R,sha
ROOT=Path(__file__).resolve().parent

def components(im,box,threshold):
 x0,y0,x1,y1=box;a=np.asarray(im)[y0:y1,x0:x1];lab,_=ndimage.label(a.max(axis=2)<threshold);dots=[]
 for k,b in enumerate(ndimage.find_objects(lab),1):
  y,x=b;ar=int(np.count_nonzero(lab[b]==k));w=x.stop-x.start;h=y.stop-y.start
  if 30<=ar<=150 and 6<=w<=13 and 6<=h<=13:dots.append([x0+x.start,y0+y.start,x0+x.stop,y0+y.stop,ar])
 groups=[]
 for dot in sorted(dots,key=lambda d:(d[0],d[1])):
  near=[g for g in groups if any(abs(dot[0]-e[0])<30 and abs(dot[1]-e[1])<30 for e in g)]
  if not near:groups.append([dot])
  else:
   g=near[0];g.append(dot)
   for other in near[1:]:g.extend(other);groups.remove(other)
 return sorted([{'dot_count':len(g),'bbox':[min(d[0]for d in g),min(d[1]for d in g),max(d[2]for d in g),max(d[3]for d in g)],'dots':g}for g in groups],key=lambda g:g['bbox'][0])

def run():
 assert sha(ROOT/'separator_audit_plan.json')=='c1192cdd6c7a916859598842112adea1a353ba07bacb0d45e54a4e1c39915d14'
 plan=json.loads((ROOT/'separator_audit_plan.json').read_text());prior=json.loads((ROOT/'image_first_results.json').read_text());boxes=json.loads((ROOT/'image_first_row_boxes.json').read_text());manifest=json.loads((ROOT/'jpeg_source_manifest.json').read_text());hashes={x['file']:x['sha256']for x in manifest['files']}
 assert sha(ROOT/'image_first_freeze.json')==prior['freeze_sha256'];assert sha(ROOT/'liber-primus__transcription--master.txt')==prior['master_sha256']
 raw=(ROOT/'liber-primus__transcription--master.txt').read_text();lp=raw[raw.index('ᚱ-'):].split('%')[:73][15:]
 records=[];issues=[]
 for p in plan['pages']:
  imfile=ROOT/f'{p+17:02d}.jpg';assert sha(imfile)==hashes[imfile.name];im=Image.open(imfile).convert('RGB');rf=ROOT/f'image_first_reviewed_p{p:02d}.txt';assert sha(rf)==next(x['reviewed_sha256']for x in prior['records']if x['page']==p)
  t=read_manual(rf.read_text());m=read_master(lp[p]);threshold_records=[]
  for threshold in plan['pixel_thresholds']:
   events=[]
   for ln,box in enumerate(boxes[str(p)],1):
    gs=components(im,box,threshold);refs=[e for e in t['events']if e['line']==ln and e['mark']!="'"]
    if len(gs)!=len(refs):issues.append({'page':p,'line':ln,'threshold':threshold,'detected':len(gs),'reference':len(refs)})
    for ix,(g,e)in enumerate(zip(gs,refs)):
     expected={'|':1,':':4,'@3':3,'@1':1,'@M3':3}[e['mark']]
     events.append(dict(g,line=ln,page_rune_gap=e['offset'],reading_mark=e['mark'],count_matches_reading=g['dot_count']==expected,event_id=f'lp2-{p}:line-{ln}:group-{ix}'))
   threshold_records.append({'threshold':threshold,'events':events})
  sig=lambda es:[(e['line'],e['page_rune_gap'],e['dot_count'],e['bbox'],sorted(d[:4]for d in e['dots']))for e in es]
  stable=all(sig(x['events'])==sig(threshold_records[1]['events'])for x in threshold_records)
  events=threshold_records[1]['events'];mastermarks={}
  for e in m['events']:mastermarks.setdefault(e['offset'],[]).append(e['mark'])
  for e in events:e['master_marks_at_gap']=mastermarks.get(e['page_rune_gap'],[])
  records.append({'page':p,'source':imfile.name,'source_sha256':sha(imfile),'runes':len(t['values']),'line_count':len(t['lines']),'physical_groups':len(events),'counts':dict(Counter(str(e['dot_count'])for e in events)),'stable_across_thresholds':stable,'events':events,'thresholds':[x['threshold']for x in threshold_records],'count_mismatches':[e for e in events if not e['count_matches_reading']]})
 assert not issues,issues
 assert all(not r['count_mismatches']for r in records)
 result={'plan_sha256':sha(ROOT/'separator_audit_plan.json'),'prior_results_sha256':sha(ROOT/'image_first_results.json'),'alignment_issues':issues,'records':records,'total_groups':sum(x['physical_groups']for x in records),'total_counts':dict(sum((Counter(x['counts'])for x in records),Counter())),'all_thresholds_stable':all(x['stable_across_thresholds']for x in records)}
 (ROOT/'separator_pixel_results.json').write_text(json.dumps(result,indent=2)+'\n')
 contact(result)
 print(json.dumps({k:v for k,v in result.items()if k!='records'},indent=2))
 for r in records:print({k:v for k,v in r.items()if k not in ('events',)})
 return result

def contact(result):
 items=[(r,e)for r in result['records']for e in r['events']if e['dot_count']>1 or(r['page']==11 and e['page_rune_gap']==250)]
 out=Image.new('RGB',(1000,((len(items)+3)//4)*220),'#e9eef3');d=ImageDraw.Draw(out);font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',14)
 for i,(rec,e)in enumerate(items):
  x=(i%4)*250;y=(i//4)*220;d.text((x+8,y+8),f"p{rec['page']} L{e['line']} gap{e['page_rune_gap']}: {e['dot_count']} dots",font=font,fill='black');im=Image.open(ROOT/rec['source']);b=e['bbox'];cx=(b[0]+b[2])/2;cy=(b[1]+b[3])/2
  c=im.crop((int(cx)-55,int(cy)-70,int(cx)+55,int(cy)+70)).resize((132,168));out.paste(c,(x+58,y+35))
 out.save(ROOT/'separator_multidot_contact.png')

if __name__=='__main__':run()
