"""Session 60: explicit manual candidate labels, source checks and known control.
Run after three_dot_scan.py, beside retained data. NumPy/Pillow required.
This is a reproducible record of a manual source review, not automated OCR.
"""
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,lp,vals,SEQS
ROOT=Path(__file__).resolve().parent
sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
# Candidate ID: physical line, number of runes before the separator on that line.
ALIGN={7:(5,21),13:(11,22),14:(7,0),15:(2,5),16:(5,10),21:(1,19)}
SPLITS={7:([601,1667,610,1676],[5,6],[3,1]),13:([601,2797,610,2806],[11,12],[3,1]),14:([1782,1667,1791,1676],[6,7],[1,3])}

def main():
 scan=json.loads((ROOT/'three_dot_scan_results.json').read_text());records=[];text=[]
 for c in scan['candidates']:
  i=int(c['id'].split('-')[-1]);p=c['page'];f=ROOT/c['source']
  assert sha(f)==scan['pages'][p]['sha256']
  rec={**c,'source_sha256':sha(f),'review_method':'Manual inspection of source crop and surrounding original page; labels encoded in this script.'}
  if i in ALIGN:
   line,k=ALIGN[i];lines=[x for x in lp[p].split('/')if any(ch in R for ch in x)]
   gap=sum(sum(ch in R for ch in s)for s in lines[:line-1])+k
   off=0;marks=''
   for ch in lp[p]:
    if ch in R:off+=1
    elif off==gap and ch in '-.;,&$§':marks+=ch
   rec.update(preceding_cipher_rune=([ch for ch in lp[p]if ch in R][gap-1]if gap else None),review_class='text',line=line,line_runes_before_mark=k,page_rune_gap=gap,master_marks_at_gap=marks,master_line=lines[line-1].strip(),alignment_status='Manual line/separator alignment to the master; not a fresh whole-page rune audit.',physical_class='inline_three_dot'if i not in SPLITS else'split_three_plus_one'if i!=14 else'split_one_plus_three',role_status='unresolved')
   assert len(c['detections'])==3
   assert all(d['bbox']==c['bbox']and d['dots']==c['detections'][0]['dots']for d in c['detections'])
   dots=sorted(c['detections'][0]['dots'],key=lambda d:d[1]);top,middle,bottom=dots
   rec['triangle_points']='right'if middle[0]>top[0]else'left'
   if i in SPLITS:
    b,ln,counts=SPLITS[i];checks=[]
    for run in scan['pages'][p]['runs']:
     if run['color']=='black':
      gs=[g for g in run['groups']if g['bbox']==b and g['dot_count']==1];assert len(gs)==1
      checks.append({'threshold':run['threshold'],'bbox':b,'dots':gs[0]['dots']})
    rec['paired_fragment']={'line':ln[1]if i!=14 else ln[0],'bbox':b,'checks':checks,'same_rune_gap':gap,'physical_line_order':ln,'dot_counts_in_reading_order':counts,'interpretation':'Four visible dots across one line break, with no intervening rune. A wrapped four-dot symbol is plausible, but not imposed.'}
   else:
    cx=(top[0]+top[2]+bottom[0]+bottom[2])/4
    slot=[int(2*cx-middle[2]),middle[1],int(2*cx-middle[0]),middle[3]]
    a=np.asarray(Image.open(f).convert('RGB'));checks=[]
    for th in [80,100,120]:
     m=a.max(axis=2)<th;s=m[slot[1]:slot[3],slot[0]:slot[2]]
     checks.append({'threshold':th,'ink':int(s.sum()),'white':int((~s).sum()),'total':int(s.size)})
    rec['expected_fourth_slot']={'bbox':slot,'method':'Reflect middle square through the upper/lower square centerline. Assumes standard diamond geometry.','checks':checks,'conclusion':'A filled fourth square is absent at this predicted position.'}
    assert all(x['white']>0 for x in checks)
   text.append(rec)
  elif i in [1,2,3,4,5,6,8,9,10,11]:rec.update(review_class='border_motif',reason='Three small dots adjacent to repeated spiral borders, outside text.')
  else:rec.update(review_class='illustration_fragment',reason='Components lie within tree/branch or insect illustrations; no textual separator alignment.')
  records.append(rec)
 # Fixed reproduction of an already solved page, no fitted unknown parameter.
 v=vals(lp[56]);ix=np.arange(len(v));ix[57:]-=1
 d=(v-SEQS['prime_minus_one'][ix])%29;d[56]=v[56]
 it=iter(d);plain=''.join(LET[int(next(it))]if c in R else c for c in lp[56])
 old=json.loads((ROOT/'solved_punctuation_results.json').read_text())
 expected=next(r['plaintext_with_source_delimiters']for r in old['records']if r['file']=='73.jpg')
 assert plain==expected
 gap=next(x['page_rune_gap']for x in text if x['page']==56);assert gap==19
 n=10;actual=d[gap:gap+n];reset=(v[gap:gap+n]-SEQS['prime_minus_one'][:n])%29
 render=lambda a:''.join(LET[int(x)]for x in a)
 assert render(actual)=='THEREEXISTS'
 control={'page':56,'source':'73.jpg','runes':len(v),'gap':gap,'full_known_plaintext_record_matches':True,'retained_plaintext_source_sha256':sha(ROOT/'solved_punctuation_results.json'),'formula':'plaintext = (cipher - (prime[index] - 1)) mod29. Rune56 is an unenciphered F and does not consume a key position. Zero-based indices.','unenciphered_F_offset':56,'window':[gap,gap+n],'continuous_indices':list(map(int,ix[gap:gap+n])),'reset_indices':list(range(n)),'known_continuation':render(actual),'reset_continuation':render(reset),'changed_rune_positions':list(map(int,np.flatnonzero(actual!=reset)+gap)),'conclusion':'The known method continues its prime sequence across this three-dot mark. Resetting to index0 here fails this ten-rune known continuation. This supplies no new plaintext or universal rule for unsolved sections.'}
 out={'scan_sha256':sha(ROOT/'three_dot_scan_results.json'),'followup_plan_sha256':sha(ROOT/'three_dot_followup_plan.json'),'master_sha256':sha(ROOT/'liber-primus__transcription--master.txt'),'counts':dict(Counter(x['review_class']for x in records)),'text_class_counts':dict(Counter(x['physical_class']for x in text)),'candidate_records':records,'known_solution_control':control,'limitations':['Fixed-size isolated-component screen is not an exhaustive inventory of merged or differently sized marks.','Manual candidate review and alignment are not independent full-page transcription.','Repeated decorative copies and threshold reruns are not independent evidence.','Master remains unchanged; this ledger carries the physical classification.']}
 (ROOT/'three_dot_review_results.json').write_text(json.dumps(out,ensure_ascii=False,indent=2)+'\n')
 # Compact native-pixel contact sheet, pairing line-break fragments explicitly.
 outim=Image.new('RGB',(1000,3*410),'#eef1f5');dr=ImageDraw.Draw(outim);font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',18);small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',15)
 for j,c in enumerate(text):
  x=j%2*500;y=j//2*410;dr.text((x+15,y+12),f"LP2 {c['page']} | gap {c['page_rune_gap']} | {c['physical_class'].replace('_',' ')}",font=font,fill='#162432')
  im=Image.open(ROOT/c['source']);b=c['bbox'];cx=(b[0]+b[2])//2;cy=(b[1]+b[3])//2
  crop=im.crop((cx-65,cy-68,cx+65,cy+68)).resize((260,272),Image.Resampling.NEAREST);outim.paste(crop,(x+15,y+60))
  if 'paired_fragment'in c:
   b=c['paired_fragment']['bbox'];cx=(b[0]+b[2])//2;cy=(b[1]+b[3])//2
   crop=im.crop((cx-25,cy-68,cx+75,cy+68)).resize((200,272),Image.Resampling.NEAREST);outim.paste(crop,(x+285,y+60));dr.text((x+285,y+340),f"Line {c['paired_fragment']['line']}: single dot",font=small,fill='#162432')
  else:dr.text((x+285,y+140),'Inline group\n\nExpected fourth\nsquare not filled',font=small,fill='#162432',spacing=6)
  dr.text((x+15,y+380),f"Master: {c['master_marks_at_gap']!r}; pixels stable at 80 / 100 / 120",font=small,fill='#162432')
 outim.save(ROOT/'three_dot_text_contact.png')
 print(json.dumps({'counts':out['counts'],'text_class_counts':out['text_class_counts'],'text':[{k:c[k]for k in ['page','line','page_rune_gap','master_marks_at_gap','physical_class','triangle_points']}for c in text],'inline_slots':[{ 'page':c['page'],**c['expected_fourth_slot']}for c in text if'expected_fourth_slot'in c],'known_control':control},indent=2))
if __name__=='__main__':main()
