"""Bounded, reproducible LP2 experiment. Requires Python 3 and numpy.
Run from any directory. Reads saved community transcription; no networking.
This is a heuristic search, not an exhaustive cryptanalysis or new solution.
"""
from pathlib import Path
from collections import Counter
import json, re, hashlib
import numpy as np

ROOT = Path(__file__).resolve().parent
R = 'ᚠᚢᚦᚩᚱᚳᚷᚹᚻᚾᛁᛄᛇᛈᛉᛋᛏᛒᛖᛗᛚᛝᛟᛞᚪᚫᚣᛡᛠ'
LET = 'F U TH O R K G W H N I J EO P X S T B E M L ING OE D A AE Y IO EA'.split()
raw = (ROOT/'liber-primus__transcription--master.txt').read_text()
pages = raw[raw.index('ᚱ-'):].split('%')[:73]
assert len(pages) == 73
lp = pages[15:]
assert lp[0].lstrip().startswith('ᛋᚻᛖᚩᚷᛗᛡᚠ') and len(lp) == 58
def vals(s): return np.array([R.index(c) for c in s if c in R], dtype=np.int64)
def render(s, v):
    it=iter(v)
    out=''.join(LET[int(next(it))] if c in R else c for c in s)
    out=out.replace('/\n','').replace('/', '').replace('\n','')
    return re.sub(r'[-.&$§;]+',' ',out).strip()

limit=250000
sieve=np.ones(limit, dtype=bool);sieve[:2]=False
for i in range(2,int(limit**0.5)+1):
    if sieve[i]: sieve[i*i::i]=False
pr=np.flatnonzero(sieve)
N=18000
phi=np.arange(N,dtype=np.int64)
for p in range(2,N):
    if phi[p]==p: phi[p::p]-=phi[p::p]//p
fib=np.zeros(N,dtype=np.int64);fib[1]=1
for i in range(2,N): fib[i]=(fib[i-1]+fib[i-2])%29
seqs={'prime':pr[:N]%29,'prime_minus_one':(pr[:N]-1)%29,
      'fibonacci':fib,'integer_totient':phi%29,'integer':np.arange(N)%29,
      'square':np.arange(N)**2%29,'cumulative_prime':np.cumsum(pr[:N])%29,
      'prime_gap':np.diff(pr[:N+1])%29}

# Known LP2 control: the F in OF, zero-based rune position 56, is skipped.
c=vals(lp[56]); idx=np.arange(len(c));idx[57:]-=1
v=(c-seqs['prime_minus_one'][idx])%29;v[56]=c[56]
control=render(lp[56],v)
assert control.startswith('AN END WITHIN THE DEEP WEB')
assert control.endswith('IT IS THE DUTY OF EUERY PILGRIM TO SEEK OUT THIS PAGE')
assert np.array_equal((v[np.arange(len(c))!=56]+seqs['prime_minus_one'][idx[np.arange(len(c))!=56]])%29,c[np.arange(len(c))!=56])
control57=render(lp[57],vals(lp[57]))
assert control57.startswith('PARABLE LIKE THE INSTAR')

# Small rune-language ranking model, using known LP1 plaintext only.
# LP2 controls are held out of training. This model is NOT a solution verifier.
train=[(28-vals(pages[0]))%29]
train += [(2-vals(pages[i]))%29 for i in range(4,8)]
train += [vals(pages[i]) for i in [3,8,9,10,11,14]]
bi=np.full((29,29),0.2);tri=np.full((29,29,29),0.2)
for t in train:
    for a,b in zip(t,t[1:]):bi[a,b]+=1
    for a,b,c0 in zip(t,t[1:],t[2:]):tri[a,b,c0]+=1
bi=np.log(bi/bi.sum(axis=1,keepdims=True))
tri=np.log(tri/tri.sum(axis=2,keepdims=True))
def scores(v):
    return bi[v[:,:-1],v[:,1:]].mean(axis=1)+tri[v[:,:-2],v[:,1:-1],v[:,2:]].mean(axis=1)
def ic(v):
    n=len(v)
    if n<2:return None
    ct=Counter(v.tolist());return sum(x*(x-1) for x in ct.values())/(n*(n-1))

best=[];counts=Counter();pageinfo=[]
offsets=np.arange(257)
for page,s in enumerate(lp):
    if page>=56:continue
    base=vals(s);pageinfo.append({'page':page,'runes':len(base),'ic':ic(base)})
    if len(base)<3:continue
    # Page scope and body-only scope after first period (only pages with headings).
    variants=[('whole_page',s)]
    if '.' in s:
        body=s.split('.',1)[1]
        if len(vals(body))>=40:variants.append(('after_first_period',body))
    pagebest=[]
    for scope,text in variants:
        c=vals(text)
        modes=[('none',np.zeros(len(c),bool)),('all_cipher_F',c==0)]
        for skipname,skip in modes:
            ix=np.arange(len(c))-np.cumsum(skip)+skip
            for name,seq in seqs.items():
                shifts=seq[offsets[:,None]+ix[None,:]]
                for a in (1,-1):
                    for sign in (-1,1):
                        dec=(a*c[None,:]+sign*shifts)%29
                        dec[:,skip]=c[skip]
                        sc=scores(dec);j=int(np.argmax(sc))
                        pagebest.append({'page':page,'scope':scope,'skip':skipname,'sequence':name,'cipher_multiplier':a,'sequence_sign':sign,'offset':int(offsets[j]),'score':float(sc[j]),'text':render(text,dec[j])})
                        counts['sequence_candidates']+=len(offsets)
        # All invertible affine substitutions, a*x+b mod 29.
        for a in range(1,29):
            dec=(a*c[None,:]+np.arange(29)[:,None])%29;sc=scores(dec);j=int(np.argmax(sc))
            pagebest.append({'page':page,'scope':scope,'sequence':'affine','a':a,'b':j,'score':float(sc[j]),'text':render(text,dec[j])})
            counts['affine_candidates']+=29
    best.extend(sorted(pagebest,key=lambda x:x['score'],reverse=True)[:3])
    if page%10==0:print('Completed page',page,flush=True)

# Locate the community-noted six-rune repetition, preserving page positions.
needle='ᛞᛄᚢᛒᛖᛁ';repeat=[]
for page,s in enumerate(lp[:56]):
    r=''.join(c for c in s if c in R)
    for m in re.finditer(needle,r):repeat.append({'page':page,'rune_offset':m.start()})

# Test the same decoder on its held-out positive control by enumerating F skips.
control_candidates=[]
c=vals(lp[56])
for skippos in [None]+np.where(c==0)[0].tolist():
    ix=np.arange(len(c));skip=np.zeros(len(c),bool)
    if skippos is not None:skip[skippos]=True;ix[skippos+1:]-=1
    for off in range(257):
        d=(c-seqs['prime_minus_one'][ix+off])%29;d[skip]=c[skip]
        control_candidates.append((float(scores(d[None,:])[0]),skippos,off,render(lp[56],d)))
control_candidates.sort(key=lambda x:x[0],reverse=True)
result={'status':'No verified new plaintext; inspect candidates, do not interpret word fragments as solutions.',
        'source':'https://github.com/cicada-solvers/iddqd/blob/master/liber-primus__transcription--master/liber-primus__transcription--master.txt',
        'transcription_sha256':hashlib.sha256(raw.encode()).hexdigest(),
        'counting':'Candidate count includes duplicate transforms; it is not a count of distinct cipher families.',
        'counts':dict(counts),'page_info':pageinfo,
        'control56':control,'control56_score':float(scores(v[None,:])[0]),
        'control57':control57,'control57_score':float(scores(vals(lp[57])[None,:])[0]),
        'control_search_top3':control_candidates[:3],
        'repeated_six_runes':repeat,
        'top_candidates':sorted(best,key=lambda x:x['score'],reverse=True),
        'limitations':['Community transcription not fully checked against images or authenticated through PGP.',
        'Page 50 has no runes; non-runic content on pages 49, 50 and 51 is excluded.',
        'Only offsets 0..256, specified arithmetic rules, per-page resets and optional first-clause exclusion.',
        'Unsolved-page scan tests no skipped symbols or all ciphertext F symbols, not arbitrary individual F subsets.',
        'Language model is small and only a ranking heuristic; negative result does not rule out a cipher family.',
        'Candidate ranking rewards local resemblance and can surface chance fragments; whole-passage evidence is needed.']}
(ROOT/'results.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
print(json.dumps({k:result[k] for k in ['counts','control56','control56_score','control57_score','control_search_top3','repeated_six_runes']},ensure_ascii=False,indent=2))
print('TOP CANDIDATES')
for x in result['top_candidates'][:8]: print(json.dumps(x,ensure_ascii=False))

# Follow-up: one selectively unencrypted F, as used in the solved control.
# Test first three-page section as one stream, and its three individual pages.
focused=[];focused_count=0
for label,text in [('LP2_0_to_2','%'.join(lp[:3]))]+[(f'LP2_{i}',lp[i]) for i in range(3)]:
    for scope,source in [('whole',text),('after_first_period',text.split('.',1)[1])]:
        c=vals(source)
        for skippos in [None]+np.where(c==0)[0].tolist():
            ix=np.arange(len(c));skip=np.zeros(len(c),bool)
            if skippos is not None:skip[skippos]=True;ix[skippos+1:]-=1
            for name,seq in seqs.items():
                shifts=seq[offsets[:,None]+ix[None,:]]
                for a in (1,-1):
                    for sign in (-1,1):
                        d=(a*c[None,:]+sign*shifts)%29;d[:,skip]=c[skip]
                        sc=scores(d);j=int(np.argmax(sc))
                        focused.append({'target':label,'scope':scope,'skipped_F_position':skippos,'sequence':name,'cipher_multiplier':a,'sequence_sign':sign,'offset':int(offsets[j]),'score':float(sc[j]),'text':render(source,d[j])})
                        focused_count+=len(offsets)
    print('Focused search finished',label,flush=True)
focused.sort(key=lambda x:x['score'],reverse=True)
result['focused_count']=focused_count
result['focused_top_candidates']=focused[:20]
result['limitations'].append('Selective-F follow-up covers the first three pages and their combined stream, with at most one skipped F; arbitrary skip subsets remain untested.')
(ROOT/'results.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
print('Focused candidate count:',focused_count)
for x in focused[:3]:print(json.dumps(x,ensure_ascii=False))
