"""Session 33: exact frequency bounds for fixed one-rune feedback decoders.
No unknown-key optimization. Python + NumPy; all inputs local.
The candidate p[i]=(pi[c[i]]-pi[c[i-1]]+r)%29 is a subset of the
independent row-bijection family used to derive generous exact bounds.
"""
from pathlib import Path
from itertools import permutations,product
import json,hashlib
from word_core import R,LET,vals,lp,GROUPS
import numpy as np
ROOT=Path(__file__).resolve().parent
SEED=3301033

def entropy(counts):
    x=np.asarray(counts,dtype=float).ravel();x=x[x>0]
    if not len(x):return 0.0
    p=x/x.sum();return float(-np.sum(p*np.log2(p)))

def matrix(seqs,k=29):
    out=np.zeros((k,k),dtype=np.int64)
    for c in seqs:out+=np.bincount(k*c[:-1]+c[1:],minlength=k*k).reshape(k,k)
    return out

def bounds(m):
    k=len(m);n=int(m.sum());diag=int(np.trace(m))
    # Each row can assign its counts to output letters independently.
    free=np.sort(m,axis=1)[:,::-1].sum(axis=0)
    # In the candidate all diagonal entries must map to the same plaintext r.
    nonzero=np.array([np.sort(np.delete(row,a))[::-1] for a,row in enumerate(m)])
    fixed=np.r_[diag,nonzero.sum(axis=0)]
    conditional=sum(float(row.sum())/n*entropy(row) for row in m if row.sum())
    return dict(edges=n,doubles=diag,conditional_entropy_bits=conditional,
        minimum_marginal_entropy_any_row_bijection=entropy(free),
        minimum_marginal_entropy_shared_diagonal=entropy(fixed),
        maximum_single_plaintext_frequency=float(max(fixed)/n),
        most_concentrated_relaxed_histogram=fixed.tolist(),matrix=m.tolist())

def encode(p,pi,r,initial=0):
    inv=np.argsort(pi);c=np.empty(len(p),dtype=np.int64);prev=initial
    for i,x in enumerate(p):
        c[i]=inv[(pi[prev]+int(x)-r)%29];prev=int(c[i])
    return c

def decode(c,pi,r,initial=0):
    prev=np.r_[initial,c[:-1]]
    return (pi[c]-pi[prev]+r)%29

def frequencies(seqs):
    # Same scored scope as transition bounds: exclude first rune of each section.
    hist=np.bincount(np.concatenate([x[1:] for x in seqs]),minlength=29)
    return dict(runes=int(hist.sum()),histogram=hist.tolist(),entropy_bits=entropy(hist),
        most_frequent_rune=LET[int(hist.argmax())],maximum_frequency=float(hist.max()/hist.sum()))

def cipher_control(name,plain,seed,r):
    rng=np.random.default_rng(seed);pi=rng.permutation(29)
    encrypted=[encode(p,pi,r) for p in plain]
    recovered=[decode(c,pi,r) for c in encrypted]
    assert all(np.array_equal(a,b) for a,b in zip(plain,recovered))
    for c,p in zip(encrypted,plain):assert np.array_equal(c[1:]==c[:-1],p[1:]==r)
    b=bounds(matrix(encrypted));f=frequencies(plain)
    assert b['minimum_marginal_entropy_shared_diagonal']<=f['entropy_bits']+1e-12
    assert f['maximum_frequency']<=b['maximum_single_plaintext_frequency']+1e-12
    return dict(name=name,seed=seed,construction_permutation=pi.tolist(),r=r,
        exact_known_key_roundtrip=True,diagonal_identity_verified=True,
        plaintext=f,bounds=b,
        note='Known-key encoder/decoder and bound-admissibility control. No unknown-key recovery claimed.')

def changing_key_control(plain,seed,r):
    # At every position independently permute the 28 nonzero offsets.
    # Zero always means repeat, but the decoder has position-dependent state.
    rng=np.random.default_rng(seed);encrypted=[];recovered=[]
    for p in plain:
        c=np.empty(len(p),dtype=np.int64);d=np.empty(len(p),dtype=np.int64);prev=0
        for i,x in enumerate(p):
            order=rng.permutation(np.arange(1,29));v=(int(x)-r)%29
            step=0 if v==0 else int(order[v-1]);c[i]=(prev+step)%29
            obs=(int(c[i])-prev)%29
            d[i]=r if obs==0 else (int(np.flatnonzero(order==obs)[0])+1+r)%29
            prev=int(c[i])
        assert np.array_equal(p,d)
        assert np.array_equal(c[1:]==c[:-1],p[1:]==r)
        encrypted.append(c);recovered.append(d)
    b=bounds(matrix(encrypted));f=frequencies(plain)
    assert b['minimum_marginal_entropy_shared_diagonal']>f['entropy_bits']
    return dict(seed=seed,r=r,exact_known_key_roundtrip=True,diagonal_identity_verified=True,
        plaintext=f,bounds=b,
        note='Deliberately outside tested family. Changing per-position key recovers plaintext below the fixed-row bound and preserves rare doubles. The bound must not be generalized to this cipher.')

def exhaustive_checks():
    results=[]
    for a in [np.array([[2,7,1],[3,1,5],[6,2,0]]),np.array([[0,0,3],[2,0,0],[0,1,0]])]:
        perms=list(permutations(range(3)));best=99.0
        for choices in product(perms,repeat=3):
            h=sum(a[i,list(p)] for i,p in enumerate(choices));best=min(best,entropy(h))
        fixedbest=99.0
        rows=[np.delete(row,i) for i,row in enumerate(a)]
        for switches in product([False,True],repeat=3):
            h=np.r_[np.trace(a),sum(row[::-1] if sw else row for row,sw in zip(rows,switches))]
            fixedbest=min(fixedbest,entropy(h))
        b=bounds(a)
        assert abs(best-b['minimum_marginal_entropy_any_row_bijection'])<1e-12
        assert abs(fixedbest-b['minimum_marginal_entropy_shared_diagonal'])<1e-12
        results.append(dict(matrix=a.tolist(),unrestricted_assignments=216,shared_diagonal_assignments=8,
            exact_unrestricted_minimum=best,exact_shared_diagonal_minimum=fixedbest))
    return results

def main():
    sections=[(i,vals(s)) for i,s in enumerate('%'.join(lp[:56]).split('$')) if len(vals(s))>100]
    assert [i for i,_ in sections]==[0,1,2,4,5,6,7,8]
    seqs=[c for _,c in sections];lengths=[len(c) for c in seqs]
    known=[vals(s) for s in GROUPS.values()];base=np.concatenate(known)
    # Long controls match the eight unsolved section lengths, cycling source runes.
    def cycle(v):return [v[(np.arange(n)+j*37)%len(v)] for j,n in enumerate(lengths)]
    controls=[cipher_control('LP1 rune stream cycled to observed lengths',cycle(base),SEED+1,28),
        cipher_control('Solved LP2 page 57 cycled to observed lengths',cycle(vals(lp[57])),SEED+2,0)]
    fixture_pi=np.random.default_rng(SEED+3).permutation(29);r=28
    a=np.array([0,23,11,1,17,18,10]);b=np.array([14,23,11,1,17,18,10])
    da=decode(a,fixture_pi,r);db=decode(b,fixture_pi,r)
    assert da[1]!=db[1] and np.array_equal(da[2:],db[2:])
    result=dict(seed=SEED,formula='p[i] = (pi[c[i]] - pi[c[i-1]] + r) mod 29',
        assumptions=['One fixed permutation pi and one fixed r shared across the selected sections.',
            'One plaintext rune per ciphertext rune, same 29-symbol alphabet.',
            'Only previous ciphertext rune affects decoding; punctuation and pages do not alter the mapping.',
            'First rune of each section excluded: initial state cannot affect the scored bounds.',
            'Plaintext benchmark is solved LP rune text; this is not a theorem about every language or intentionally balanced text.'],
        scope='Eight substantial unsolved sections. Training/evaluation names retain the established split; no key is fitted. These are retrospective diagnostics on previously examined data.',
        training_sections=[i for i,_ in sections[:4]],evaluation_sections=[i for i,_ in sections[4:]],
        training=bounds(matrix(seqs[:4])),evaluation=bounds(matrix(seqs[4:])),pooled=bounds(matrix(seqs)),
        per_section=[dict(section=i,**bounds(matrix([c]))) for i,c in sections],
        lp1_benchmark=frequencies(known),lp1_groups=[dict(name=name,**frequencies([vals(s)])) for name,s in GROUPS.items()],
        lp2_page57_benchmark=frequencies([vals(lp[57])]),
        controls=controls,scope_counterexample=changing_key_control(cycle(base),SEED+4,28),
        exhaustive_small_alphabet_checks=exhaustive_checks(),
        repeated_phrase_fixture=dict(pi=fixture_pi.tolist(),r=r,cipher_a=a.tolist(),cipher_b=b.tolist(),
            decoded_a=da.tolist(),decoded_b=db.tolist(),first_plaintext_of_phrase_differs=True,last_five_plaintext_runes_equal=True),
        no_unknown_key_search=True,
        transcript_sha256=hashlib.sha256((ROOT/'liber-primus__transcription--master.txt').read_bytes()).hexdigest(),
        script_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
        limits=['Exact empirical bounds, not p-values or population entropy estimates.',
            'The relaxed rowwise optimum need not be achievable by any single permutation pi; it only makes the candidate look more favorable.',
            'A common mapping is assumed when pooling sections. Per-section values are supplied, but no claim that all separate-key models are excluded.',
            'Controls cycle solved rune streams and are deliberately constructed, not fresh natural-language samples.',
            'Positive controls check known-key inversion and mathematical bounds, not cryptanalytic key recovery.',
            'Additional key streams, position-dependent mappings, larger state, null insertion, and other intermediate encodings are outside scope.'])
    assert result['pooled']['edges']==12939 and result['pooled']['doubles']==86
    (ROOT/'keyed_difference_results.json').write_text(json.dumps(result,indent=2)+'\n')
    summary={k:{q:result[k][q] for q in ['edges','doubles','conditional_entropy_bits','minimum_marginal_entropy_shared_diagonal','maximum_single_plaintext_frequency']} for k in ['training','evaluation','pooled']}
    summary['lp1_benchmark']=result['lp1_benchmark']
    summary['controls']=[dict(name=c['name'],plaintext_H=c['plaintext']['entropy_bits'],minimum_H=c['bounds']['minimum_marginal_entropy_shared_diagonal'],doubles=c['bounds']['doubles'],roundtrip=c['exact_known_key_roundtrip']) for c in controls]
    sc=result['scope_counterexample'];summary['scope_counterexample']=dict(plaintext_H=sc['plaintext']['entropy_bits'],fixed_row_bound=sc['bounds']['minimum_marginal_entropy_shared_diagonal'],doubles=sc['bounds']['doubles'],roundtrip=sc['exact_known_key_roundtrip'])
    print(json.dumps(summary,indent=2))
if __name__=='__main__':main()
