"""Session61 manual review ledger and matched-AE mask comparison.
No OCR, cipher search, or image synthesis. Source pixels only.
"""
from pathlib import Path
from collections import Counter
import json,hashlib
import numpy as np
from PIL import Image,ImageDraw,ImageFont
from lp_core import R,LET,pages
ROOT=Path(__file__).resolve().parent
sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
# Explicit manual alignment: candidate -> physical text line, rune gap, preceding rune.
ALIGN={9:(4,55,'AE'),13:(3,56,'AE'),41:(2,25,'K'),48:(10,171,'N'),49:(10,182,'N'),50:(2,27,'Y'),56:(6,110,'T'),59:(3,60,'O'),64:(1,19,'AE')}
GLYPHS={9:[891,1238,925,1352],13:[1267,1050,1301,1164],64:[1629,673,1663,787]}
def main():
 screen=json.loads((ROOT/'typography_screen_results.json').read_text());assert screen['plan_sha256']==sha(ROOT/'typography_plan.json');assert screen['candidate_count']==70
 hashes={x['source']:x['sha256']for x in screen['pages']};cs=[]
 for c in screen['candidates']:
  assert sha(ROOT/c['source'])==hashes[c['source']]
  i=c['id'];counts=sorted(set(d['dot_count']for d in c['detections']))
  rec={**c,'review_method':'Manual review of all candidate source crops; full-page context for selected alignments. Not an independent full transcription.','review_class':'illustration_fragment'if i>66 else'text_three_dot'if counts==[3]else'text_four_dot','source_sha256':hashes[c['source']]}
  if i<=66:
   assert len(c['detections'])==3
   assert all(d['bbox']==c['detections'][0]['bbox']and d['dots']==c['detections'][0]['dots']for d in c['detections'])
   rec['pixel_geometry_stable_at_all_thresholds']=True
  if i in ALIGN:
   n=int(c['source'][:2]);ix=0 if n==1 else n-2 if n<17 else n-2
   s=pages[ix];ln,gap,prev=ALIGN[i];v=[ch for ch in s if ch in R];assert LET[R.index(v[gap-1])]==prev
   lines=s.split('/');a=sum(ch in R for ch in ''.join(lines[:ln-1]));b=a+sum(ch in R for ch in lines[ln-1]);assert a<=gap<=b
   off=0;marks=''
   for ch in s:
    if ch in R:off+=1
    elif off==gap and ch in '-.;,&$§':marks+=ch
   rec.update(line=ln,page_rune_gap=gap,preceding_cipher_rune=prev,master_marks_at_gap=marks,alignment_status='Manual source-line/separator alignment to immutable master rune sequence.')
  if i in GLYPHS:
   box=GLYPHS[i];rec['preceding_glyph_bbox']=box;rec['visible_mark_bbox_width']=c['bbox'][2]-c['bbox'][0];rec['blank_columns_before_first_dot']=c['bbox'][0]-box[2]
   rec['dot_boxes_relative_to_glyph']=[[d[0]-box[0],d[1]-box[1],d[2]-box[0],d[3]-box[1]]for d in c['detections'][0]['dots']]
  cs.append(rec)
 by={c['id']:c for c in cs};four=by[13];three=by[64];checks=[]
 for th in [80,100,120]:
  masks=[np.asarray(Image.open(ROOT/c['source']).convert('RGB').crop(c['preceding_glyph_bbox'])).max(axis=2)<th for c in [four,three]]
  checks.append({'threshold':th,'four_dot_context_glyph_ink':int(masks[0].sum()),'three_dot_context_glyph_ink':int(masks[1].sum()),'glyph_mask_difference_pixels':int((masks[0]!=masks[1]).sum())})
 assert all(c['glyph_mask_difference_pixels']==0 and c['four_dot_context_glyph_ink']==1617 for c in checks)
 pair={'four_dot_candidate':13,'three_dot_candidate':64,'glyph':'AE','glyph_dimensions':[34,114],'checks':checks,'visible_group_widths':[four['visible_mark_bbox_width'],three['visible_mark_bbox_width']],'blank_columns_before_first_visible_dot':[four['blank_columns_before_first_dot'],three['blank_columns_before_first_dot']],'conclusion':'The exact same binary AE glyph occurs beside both forms. Glyph identity alone does not force a three-dot group. This does not exclude every spacing-dependent rendering history.'}
 prior=json.loads((ROOT/'three_dot_review_results.json').read_text());overlay=[]
 for c in prior['candidate_records']:
  if c.get('physical_class')!='inline_three_dot':continue
  p=c['page'];gb={38:[843,861,878,975],46:[1060,1426,1095,1540],56:[1629,673,1663,787]}[p];slot=c['expected_fourth_slot']['bbox']
  inside=gb[0]<=slot[0]and gb[1]<=slot[1]and gb[2]>=slot[2]and gb[3]>=slot[3]
  assert inside
  overlay.append({'page':p,'glyph_bbox':gb,'hypothetical_fourth_square_bbox':slot,'slot_entirely_inside_glyph_rectangle':inside,'interpretation':'A white-backed glyph rectangle could erase a hypothetical square here, but this is only geometric compatibility, not evidence that the authors rendered the page in that order.'})
 triples=[c for c in cs if c['review_class']=='text_three_dot']
 assert len(triples)==7 and sum(c['master_marks_at_gap']==';'for c in triples)==6
 out={'plan_sha256':sha(ROOT/'typography_plan.json'),'followup_plan_sha256':sha(ROOT/'typography_followup_plan.json'),'screen_sha256':sha(ROOT/'typography_screen_results.json'),'master_sha256':sha(ROOT/'liber-primus__transcription--master.txt'),'review_counts':dict(Counter(c['review_class']for c in cs)),'seven_triple_predecessors':[c['preceding_cipher_rune']for c in triples],'triples_matching_literal_master_semicolon':6,'candidate_records':cs,'matched_AE_comparison':pair,'rectangle_overpainting_geometry':overlay,'new_source_discrepancy':{'source':'15.jpg','candidate':59,'line':3,'rune_gap':60,'preceding_cipher_rune':'O','master':'-','observed':'three isolated dots','change_policy':'Keep master immutable and record a physical-class overlay. No rune change, semantic certainty or cipher reset inferred.'},'limits':['All-page screen for one small component class, not exhaustive punctuation OCR.','Source alignments and glyph boxes are manually reviewed.','No claim that the observations are new to the solving community.','No cipher search or new plaintext.']}
 (ROOT/'typography_review_results.json').write_text(json.dumps(out,ensure_ascii=False,indent=2)+'\n')
 font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',21);small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',17)
 im=Image.new('RGB',(1100,650),'#eef1f5');dr=ImageDraw.Draw(im)
 dr.text((22,18),'Identical AE glyph, different punctuation shapes',font=font,fill='#142432')
 for j,c in enumerate([four,three]):
  x=22+j*550;b=c['preceding_glyph_bbox'];crop=[b[0]-8,b[1]-8,b[0]+94,b[3]+8];pic=Image.open(ROOT/c['source']).crop(crop).resize((306,390),Image.Resampling.NEAREST);im.paste(pic,(x+85,115))
  dr.text((x,67),f"{c['source']} | {c['review_class'].replace('_',' ')} | gap {c['page_rune_gap']}",font=small,fill='#142432')
  dr.rectangle([x+85+24,115+24,x+85+(b[2]-crop[0])*3-1,115+(b[3]-crop[1])*3-1],outline='#3979a8',width=2)
  dr.text((x,530),f"AE mask: 1,617 black pixels\nGap before first visible dot: {c['blank_columns_before_first_dot']} pixels\nDot-group width: {c['visible_mark_bbox_width']} pixels",font=small,fill='#142432',spacing=6)
 dr.text((22,625),'Zero differing AE pixels at thresholds 80, 100 and 120. Original pixels enlarged; blue outlines added.',font=small,fill='#142432')
 im.save(ROOT/'typography_matched_AE.png')
 print(json.dumps({k:v for k,v in out.items()if k not in ['candidate_records','rectangle_overpainting_geometry']},indent=2))
if __name__=='__main__':main()
