"""Session 55: page structure and pixel-backed delimiter alignment.

Run beside the retained JPEGs and lp_core.py with NumPy, SciPy and Pillow.
No OCR, image synthesis, deblurring, cipher search, or proposed plaintext.
Line centers/regions and block roles are manually assigned from the images.
Delimiter components and their boxes are measured; offsets come from the
existing transcription, matched line by line in left-to-right order.
"""
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, R, vals

ROOT = Path(__file__).resolve().parent
ROW_CENTERS = {44: [974+188.3*i for i in range(11)],
               **{n:[730+188.3*i for i in range(12)] for n in range(45,49)},
               49:[730+188.3*i for i in range(6)],
               50:[730+188.3*i for i in range(5)]+[1971+188.3*i for i in range(6)]}
# Half-open rune spans; image selections are deliberately generous manual boxes.
HEADINGS = {44:[(0,19)],50:[(0,10),(91,99)]}
DROP_CAPS = {44:[{'rune_span':[0,1],'selection_bbox':[590,910,855,1420]}],
             50:[{'rune_span':[0,1],'selection_bbox':[590,660,840,1175]},
                 {'rune_span':[91,92],'selection_bbox':[590,1900,840,2415]}]}
ILLUSTRATIONS = {
    44:[{'description':'wing-like outline above opening text', 'bbox':[630,505,1820,870]}],
    49:[{'description':'blurred tree-like silhouette below text', 'bbox':[640,2020,1770,3200]}],
    50:[{'description':'two rows of cuneiform-style signs below lower text block', 'bbox':[1020,3050,1430,3450]}]
}

def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest()

def components(a, color, threshold):
    if color == 'red':
        mask=(a[:,:,0]>100)&(a[:,:,0]-a[:,:,1]>threshold)&(a[:,:,0]-a[:,:,2]>threshold)
    else: mask=a.max(axis=2)<threshold
    lab,_=ndimage.label(mask); dots=[]
    for k,bb in enumerate(ndimage.find_objects(lab),1):
        if bb is None: continue
        y,x=bb; area=int(np.count_nonzero(lab[bb]==k))
        if 30<=area<=150 and 6<=x.stop-x.start<=13 and 6<=y.stop-y.start<=13 and 580<x.start<1840 and 650<y.start<3020:
            dots.append([x.start,y.start,x.stop,y.stop,area])
    groups=[]
    for d in sorted(dots,key=lambda b:(b[1],b[0])):
        near=[g for g in groups if any(abs(d[0]-e[0])<28 and abs(d[1]-e[1])<28 for e in g)]
        if not near: groups.append([d])
        else:
            g=near[0];g.append(d)
            for other in near[1:]: g.extend(other);groups.remove(other)
    return [{'color':color,'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)],'dot_boxes':g} for g in groups]

def transcribed_lines(text):
    out=[]; off=0
    for chunk in text.split('/'):
        if not any(c in R for c in chunk): continue
        start=off; marks=[]
        for c in chunk:
            if c in R: off+=1
            elif c in '-.': marks.append({'transcription_character':c,'page_rune_offset':off})
        out.append({'rune_span':[start,off],'text':chunk.strip(),'marks':marks})
    return out

def align(n, black_threshold=100, red_threshold=65):
    a=np.asarray(Image.open(ROOT/f'{n}.jpg').convert('RGB')).astype(np.int16)
    groups=components(a,'black',black_threshold)+components(a,'red',red_threshold)
    rows=[[] for _ in ROW_CENTERS[n]]
    for g in groups:
        cy=(g['bbox'][1]+g['bbox'][3])/2
        i=min(range(len(rows)),key=lambda i:abs(cy-ROW_CENTERS[n][i]))
        assert abs(cy-ROW_CENTERS[n][i])<45,(n,g)
        rows[i].append(g)
    lines=transcribed_lines(lp[n-17]); assert len(lines)==len(rows)
    events=[]
    for i,(line,gs) in enumerate(zip(lines,rows)):
        gs.sort(key=lambda g:g['bbox'][0])
        assert len(gs)==len(line['marks']),(n,i,len(gs),len(line['marks']))
        for g,m in zip(gs,line['marks']):
            assert g['dot_count'] in ([1] if m['transcription_character']=='-' else [4,13]),(n,i,g,m)
            events.append(dict(g,**m,event_id=f'{n}.jpg:delimiter:{len(events)}',line=i+1,
                               alignment='line-wise ordinal match to retained transcription, checked by dot class'))
        line['selection_bbox']=[580,round(ROW_CENTERS[n][i]-78),1840,round(ROW_CENTERS[n][i]+78)]
        del line['marks']
    return lines,events

def seam(a,b):
    text=lp[a]+'%'+lp[b]; i=text.index('%')
    j=i-1; left=''
    while j>=0 and text[j] in R+' /\n\t':
        if text[j] in R: left=text[j]+left
        j-=1
    k=i+1;right=''
    while k<len(text) and text[k] in R+' /\n\t':
        if text[k] in R:right+=text[k]
        k+=1
    return {'from_page':a,'to_page':b,'joins_word_in_transcription':bool(left and right),
            'left_fragment':left,'right_fragment':right,
            'caveat':'Joining follows the source delimiter convention; it does not establish a plaintext word.'}

def sheet(records):
    width=1600; h=690
    out=Image.new('RGB',(width,4*h),'#eeeeee'); draw=ImageDraw.Draw(out)
    font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',20)
    specs=[(r['image'],f"LP2 {r['lp2_page']} | {r['runes']} runes") for r in records]
    specs.append(('74.jpg','Solved LP2 57 | PARABLE + terminal art'))
    for ix,(name,label) in enumerate(specs):
        x=(ix%2)*800;y=(ix//2)*h
        im=Image.open(ROOT/name);im.thumbnail((432,648));out.paste(im,(x+350,y+35))
        draw.text((x+15,y+15),label,font=font,fill='#111111')
        if ix<len(records):
            r=records[ix]; yy=y+65
            labels=[f"{len(r['lines'])} printed lines",f"{len(r['delimiter_events'])} delimiter groups"]
            labels += [f"Red title: offsets {a} to {b-1}" for a,b in r['red_heading_spans']]
            labels += [f"Red {e['dot_count']} dots at {e['page_rune_offset']}" for e in r['delimiter_events'] if e['color']=='red' and e['dot_count']>1]
            if name=='49.jpg':labels+=['Tree below ending at 121','Next page opens a red title']
            if name=='50.jpg':labels+=['Large 3-dot divider at 91','Two distinct red headings']
            for label in labels:draw.text((x+15,yy),label,font=font,fill='#333333');yy+=30
    out.save(ROOT/'page_structure_contact.png')

def main():
    manifest=json.loads((ROOT/'jpeg_source_manifest.json').read_text())
    hashes={r['file']:r['sha256'] for r in manifest['files']}
    records=[]
    for n in range(44,51):
        p=ROOT/f'{n}.jpg';assert sha(p)==hashes[p.name]
        lines,events=align(n)
        sig=lambda es:[(e['color'],e['dot_count'],e['line'],e['page_rune_offset']) for e in es]
        checks=[]
        for bt,rt in [(80,40),(100,65),(120,90)]:
            _,es=align(n,bt,rt);assert sig(es)==sig(events);checks.append({'black_threshold':bt,'red_dominance':rt,'same_event_sequence':True})
        headings=HEADINGS.get(n,[])
        blocks=[]; cursor=0; count=len(vals(lp[n-17]))
        for start,end in headings:
            if start>cursor:blocks.append({'role_interpretation':'body text','rune_span':[cursor,start]})
            blocks.append({'role_interpretation':'heading','rune_span':[start,end]})
            cursor=end
        if cursor<count:blocks.append({'role_interpretation':'body text','rune_span':[cursor,count]})
        for e in events:
            e['role_interpretation']=('word separator' if e['dot_count']==1 else
                 'heading terminator' if e['color']=='red' and any(e['page_rune_offset']==b for a,b in headings) else
                 'passage terminator' if e['color']=='red' else 'sentence-type delimiter')
        records.append({'image':p.name,'lp2_page':n-17,'image_sha256':sha(p),'size':list(Image.open(p).size),
                        'runes':count,'lines':lines,'red_heading_spans':headings,'text_blocks':blocks,'red_drop_capitals':DROP_CAPS.get(n,[]),
                        'heading_basis':'manual visual identification of red drop capital and adjacent red runes; semantic title role remains interpretation',
                        'illustrations':ILLUSTRATIONS.get(n,[]),'delimiter_events':events,'threshold_checks':checks})
    full='%'.join(lp[27:34]); before,after=full.split('$',1)
    assert len(vals(before))==1524
    assert sum(r['runes'] for r in records[:6])==1433
    controls=json.loads((ROOT/'solved_punctuation_results.json').read_text())
    solved=[r for r in controls['records'] if r['file'] in ['73.jpg','74.jpg']]
    for r in solved:assert sha(ROOT/r['file'])==r['sha256']
    result={'scope':'LP2 pages 27-32 plus full page33 transition; original retained images',
      'transcription_sha256':sha(ROOT/'liber-primus__transcription--master.txt'),
      'source_manifest_revision':manifest['repository_revision'],
      'method_limitations':['Not independent OCR: offsets inherit the existing rune transcription.',
        'All small delimiter groups in the selected text regions matched in number, order and dot class; rune shapes themselves were not fully re-transcribed.',
        'Heading roles and illustration selection boxes are manual annotations.',
        'Three paired threshold settings measure segmentation stability, not statistical significance.',
        'The master dollar-sign section convention is an analytic grouping, not a printed rune or proven cipher boundary.'],
      'pages':records,'page_seams':[seam(p,p+1) for p in range(27,33)],
      'transition':{'six_page_runes':1433,'page33_upper_block':91,'master_section_runes':1524,
        'page33_upper_heading_span':[0,10],'page33_lower_heading_span':[91,99],
        'large_divider':{'image':'50.jpg','page_rune_offset':91,'description':'three large black circular dots in a horizontal row',
          'selection_bbox':[720,1670,1730,1800],'alignment':'manual visual comparison'},
        'warning':'Tree precedes a separately headed passage; it is not at the master section boundary.'},
      'counts':dict(Counter(f"{e['color']}_{e['dot_count']}" for r in records for e in r['delimiter_events'])),
      'solved_comparison':[{'image':r['file'],'image_sha256':r['sha256'],'red_groups':r['groups']} for r in solved],
      'conclusion':'Map completed; no distinctive cipher instruction established. No new plaintext or new-to-community claim.'}
    (ROOT/'page_structure_results.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
    sheet(records)
    print(json.dumps({'mapped_pages':len(records),'delimiter_groups':sum(len(r['delimiter_events']) for r in records),
       'counts':result['counts'],'page_seams':result['page_seams'],'transition':result['transition']},ensure_ascii=False))

if __name__=='__main__':main()
