"""Session 56: fixed-rule, manually classified LP2 layout comparison.

Requires NumPy, SciPy, Pillow and retained notebook files. No downloads or key
search. Classification is an explicit human image annotation, not automated
recognition. Source hashes, rune counts and selected large-dot counts are checked.
"""
from pathlib import Path
from collections import Counter
import hashlib,json
import numpy as np
from PIL import Image,ImageDraw,ImageFont
from scipy import ndimage
from lp_core import lp,pages,R,vals
from lexical_words import tokenize_words

ROOT=Path(__file__).resolve().parent
PLAN_SHA='2b292b35d1903be0d00c0e128aa17146041ecf921436c028cc3c37cdb4247139'
QUALIFIED={
 0:'Small infinity-shaped ornament below text; side crosses excluded',
 1:'Small infinity-shaped ornament below text; side crosses excluded',
 2:'Insect illustration and small infinity-shaped ornament below text',
 14:'Winged insect below text; side plant border excluded',
 22:'Horizontal cross-like graphic and reclining human figure below text',
 32:'Blurred tree below text; original anchor',
 53:'Moth-like insect below final red text',
 55:'Pale tree outline and moth-like insect below text',
 57:'Stippled insect-like figure below final text; no successor page'}
BORDERLINE={7:'Six large scattered dots below text: punctuation versus abstract graphic is uncertain',
            56:'Five large scattered dots below text: punctuation versus abstract graphic is uncertain'}
OTHER={}
for p in [3,4,5,6,*range(8,14),24,25,26,*range(40,53),54]:
    OTHER[p]='Side border only; no qualifying terminal illustration'
for p in [16,17,18,19,20,21,28,29,30,31]:OTHER[p]='No illustration visible'
OTHER[15]='Infinity-shaped graphic lies between text blocks, above later runes'
OTHER[23]='Scattered dots above the text, not below its final line'
OTHER[27]='Wing-like drawing above text'
OTHER[33]='Cuneiform-style inscription below text, explicitly excluded by the rule'
for p in range(34,40):OTHER[p]='Cuneiform-style inscription above text, excluded'

# Direct visual observations from full-page source pairs. Lengths are measured
# from retained runes, not estimated from the printed line count.
TRANSITIONS=[
 {'source_page':0,'red_initial_and_heading_at_next_start':False,'block_span':None,'three_dot_divider':False,'observation':'Page1 continues black text, without a new red heading.'},
 {'source_page':1,'red_initial_and_heading_at_next_start':False,'block_span':None,'three_dot_divider':False,'observation':'Page2 continues black text, without a new red heading.'},
 {'source_page':2,'red_initial_and_heading_at_next_start':True,'block_span':[3,0,3,119],'three_dot_divider':False,'observation':'119-rune upper passage, then blank space and a second red heading; no three-dot divider.'},
 {'source_page':14,'red_initial_and_heading_at_next_start':False,'block_span':[15,0,15,9],'three_dot_divider':False,'nonrunic_insert':'16 decimal numbers','observation':'Normal-sized red label over a number square; infinity-shaped graphic and later enlarged red initial. No three-dot divider.'},
 {'source_page':22,'red_initial_and_heading_at_next_start':True,'block_span':[23,0,26,265],'three_dot_divider':False,'observation':'Five scattered dots ABOVE the new heading; the headed passage continues through page26.'},
 {'source_page':32,'red_initial_and_heading_at_next_start':True,'block_span':[33,0,33,91],'three_dot_divider':True,'observation':'Original 91-rune anchor followed by three horizontal black dots and another red heading.'},
 {'source_page':53,'red_initial_and_heading_at_next_start':True,'block_span':[54,0,55,76],'three_dot_divider':False,'observation':'New red heading; passage continues through page55; no intervening three-dot divider.'},
 {'source_page':55,'red_initial_and_heading_at_next_start':True,'block_span':[56,0,56,85],'three_dot_divider':False,'nonrunic_insert':'hexadecimal digest','observation':'Known AN END passage; 85 runes plus a digest, then five scattered dots. No three-dot divider.'},
 {'source_page':57,'censored':True,'observation':'Final page; no following page to compare.'},
 {'source_page':7,'red_initial_and_heading_at_next_start':True,'block_span':[8,0,14,137],'three_dot_divider':False,'observation':'Next heading opens a long passage continuing through page14; no short block before three dots.'},
 {'source_page':56,'red_initial_and_heading_at_next_start':True,'block_span':[57,0,57,95],'three_dot_divider':False,'observation':'Known PARABLE passage, 95 runes, then insect illustration; no three-dot divider.'}
]

def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest()
def rune_span_text(text,start,end):
    off=0;out=[]
    for c in text:
        if start<=off<end:out.append(c)
        if c in R:off+=1
    return ''.join(out)
def span_len(span):
    a,s,b,e=span
    assert 0<=s<=len(vals(lp[a])) and 0<=e<=len(vals(lp[b]))
    return e-s if a==b else len(vals(lp[a]))-s+sum(len(vals(lp[p])) for p in range(a+1,b))+e

def dot_check(page,roi,expected):
    im=Image.open(ROOT/f'{page+17:02}.jpg').convert('RGB');a=np.asarray(im)
    x,y,xx,yy=roi;checks=[]
    for threshold in [80,100,120]:
        mask=a[y:yy,x:xx].max(axis=2)<threshold
        lab,_=ndimage.label(mask);dots=[]
        for k,bb in enumerate(ndimage.find_objects(lab),1):
            if bb is None:continue
            sy,sx=bb;w=sx.stop-sx.start;h=sy.stop-sy.start
            area=int(np.count_nonzero(lab[bb]==k))
            if 40<=w<=110 and 40<=h<=110 and .8<=w/h<=1.25 and area/(w*h)>.6:
                dots.append({'bbox':[x+sx.start,y+sy.start,x+sx.stop,y+sy.stop],'area':area})
        assert len(dots)==expected,(page,threshold,dots)
        checks.append({'threshold':threshold,'count':len(dots),'components':dots})
    return {'page':page,'selection_bbox':roi,'expected_from_visual_review':expected,'checks':checks,
            'method':'filled near-circular black components, width/height40-110, aspect0.8-1.25, fill>0.6; hand-selected source region'}

def figures():
    font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',18)
    for start in [0,20,40]:
        out=Image.new('RGB',(1400,1820),'#ddd');d=ImageDraw.Draw(out)
        for ix,p in enumerate(range(start,min(start+20,58))):
            x=ix%5*280;y=ix//5*455
            im=Image.open(ROOT/f'{p+17:02}.jpg');im.thumbnail((280,420));out.paste(im,(x,y+28))
            d.text((x+8,y+5),f'LP2 {p} | {p+17:02}.jpg',font=font,fill='black')
        out.save(ROOT/f'illustrated_inventory_{start:02}.png')
    out=Image.new('RGB',(1200,1920),'#eee');d=ImageDraw.Draw(out)
    font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',22)
    for row,p in enumerate([2,32]):
        y=row*960
        title='Near match: short passage, no three-dot divider' if p==2 else 'Original anchor: short passage + three-dot divider'
        d.text((15,y+8),title,font=font,fill='black')
        for col,q in enumerate([p,p+1]):
            im=Image.open(ROOT/f'{q+17:02}.jpg');im.thumbnail((590,885));out.paste(im,(col*600+5,y+70))
            d.text((col*600+15,y+40),f'LP2 {q}',font=font,fill='black')
    out.save(ROOT/'illustrated_transition_comparison.png')

def main():
    assert sha(ROOT/'illustrated_transition_plan.json')==PLAN_SHA
    assert set(QUALIFIED)|set(BORDERLINE)|set(OTHER)==set(range(58))
    assert len(QUALIFIED)+len(BORDERLINE)+len(OTHER)==58
    manifest=json.loads((ROOT/'jpeg_source_manifest.json').read_text())
    hashes={r['file']:r['sha256'] for r in manifest['files']}
    inventory=[]
    for p in range(58):
        image=f'{p+17:02}.jpg';actual=sha(ROOT/image);assert actual==hashes[image]
        cat='qualifying' if p in QUALIFIED else 'borderline_dot_graphic' if p in BORDERLINE else 'excluded'
        inventory.append({'page':p,'image':image,'sha256':actual,'category':cat,
            'observation':QUALIFIED.get(p,BORDERLINE.get(p,OTHER.get(p))),
            'classification_method':'manual all-page contact-sheet review, with full-page review of included and borderline images and their successors'})
    results=[]
    for original in TRANSITIONS:
        t=dict(original);p=t['source_page'];t['anchor']=p==32;t['sensitivity_only']=p in BORDERLINE
        if t.get('censored'):t['full_match']=None;results.append(t);continue
        t['next_page']=p+1
        t['headed_or_labeled_block_runes']=span_len(t['block_span']) if t['block_span'] else None
        t['at_most_150_runes']=t['headed_or_labeled_block_runes'] is not None and t['headed_or_labeled_block_runes']<=150
        t['full_match']=bool(t['red_initial_and_heading_at_next_start'] and t['at_most_150_runes'] and t['three_dot_divider'])
        results.append(t)
    real=[t for t in results if not t['anchor'] and not t['sensitivity_only'] and not t.get('censored')]
    comparison=[]
    for label,text,a,b in [('Unknown LP2 page33',lp[33],0,10),('AN INSTRUCTION, archive09',pages[7],103,115),('SOME WISDOM, archive13',pages[11],44,54),('AN INSTRUCTION, archive13',pages[11],129,141)]:
        s=rune_span_text(text,a,b)
        comparison.append({'label':label,'rune_span':[a,b],'rune_word_lengths':[len(w['values']) for w in tokenize_words(s)]})
    dot_regions=[dot_check(3,[590,1770,1810,2070],0),dot_check(33,[720,1670,1730,1800],3),
                 dot_check(56,[750,2300,1730,2870],5)]
    solved=json.loads((ROOT/'solved_punctuation_results.json').read_text())
    known=[]
    for r in solved['records']:
        if r['file'] in ['09.jpg','13.jpg','16.jpg']:
            assert sha(ROOT/r['file'])==r['sha256']
            known.append({'image':r['file'],'sha256':r['sha256'],'known_reading':r['plaintext_with_source_delimiters']})
    out={'plan_sha256':PLAN_SHA,'source_revision':manifest['repository_revision'],
       'transcription_sha256':sha(ROOT/'liber-primus__transcription--master.txt'),
       'inventory':inventory,'transitions':results,'large_dot_region_checks':dot_regions,
       'heading_length_comparison':comparison,'known_page_comparison':known,
       'summary':{'pages_reviewed':58,'qualifying_pages':len(QUALIFIED),'anchor_pages':1,'censored_final_pages':1,
          'additional_eligible_transitions':len(real),'additional_full_matches':sum(t['full_match'] for t in real),
          'borderline_transitions':2,'borderline_full_matches':sum(t['full_match'] for t in results if t['sensitivity_only']),
          'additional_matches_if_terminal_infinity_ornaments_omitted':sum(t['full_match'] for t in real if t['source_page'] not in [0,1])},
       'limitations':['Manual layout classification, not automated recognition or independent rune transcription.',
          'Descriptive census selected after noticing the anchor; no p-value or new-to-community claim.',
          'Length comparison rejects an exact known heading only if rune count and word boundaries are preserved.',
          'Images alone do not identify passage meaning or a change of cipher state.']}
    (ROOT/'illustrated_transition_results.json').write_text(json.dumps(out,ensure_ascii=False,indent=2)+'\n')
    figures();print(json.dumps(out['summary']))

if __name__=='__main__':main()
