from pathlib import Path
import sys,json,hashlib,hmac,secrets,csv,base64
from io import BytesIO
P=Path(__file__).resolve().parent
local=P.parent.parent/'tmp'/'pydeps'
if local.is_dir():sys.path.insert(0,str(local))
import cryptography
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
from PIL import Image,ImageDraw
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
def mac(key, nonce, context):
    payload = json.dumps([nonce, context], separators=(',', ':')).encode()
    return hmac.new(key, payload, hashlib.sha256).hexdigest()

class Verifier:

    def __init__(self, key):
        self.key = key
        self.pending = {}

    def issue(self, context):
        nonce = secrets.token_hex(16)
        self.pending[nonce] = context
        return nonce

    def accept(self, nonce, context, response):
        expected_context = self.pending.pop(nonce, None)
        if expected_context != context:
            return False
        return hmac.compare_digest(mac(self.key, nonce, context), response)

def canonical_json(value: object) -> bytes:
    return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode('utf-8')

def file_record(name: str, payload: bytes, media_type: str) -> dict:
    return {'path': name, 'size': len(payload), 'sha256': hashlib.sha256(payload).hexdigest(), 'media_type': media_type}

def verify(manifest_bytes: bytes, signature: bytes, public_key: bytes, files: dict[str, bytes]) -> dict:
    try:
        from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
        Ed25519PublicKey.from_public_bytes(public_key).verify(signature, manifest_bytes)
    except Exception:
        return {'valid': False, 'reason': 'signature_invalid', 'files_valid': False}
    try:
        manifest = json.loads(manifest_bytes)
    except Exception:
        return {'valid': False, 'reason': 'manifest_invalid', 'files_valid': False}
    checks = {}
    for entry in manifest['files']:
        payload = files.get(entry['path'])
        checks[entry['path']] = bool(payload is not None and len(payload) == entry['size'] and (hashlib.sha256(payload).hexdigest() == entry['sha256']))
    files_valid = bool(checks) and all(checks.values())
    return {'valid': files_valid, 'reason': 'ok' if files_valid else 'file_integrity_failed', 'files_valid': files_valid, 'file_checks': checks}

def validate(case):
    docs = {d['id']: d for d in case['documents']}
    errors = []
    if len(docs) != len(case['documents']):
        errors.append('duplicate_document_id')
    for rel in case['relations']:
        if rel['source'] not in docs or rel['target'] not in docs:
            errors.append('dangling_relation')
        if rel['type'] == 'independent_of' and (not rel.get('evidence')):
            errors.append('unsupported_independence')
    graph = {key: [] for key in docs}
    for rel in case['relations']:
        if rel['type'] in {'copy_of', 'translation_of', 'ocr_of', 'derived_from'}:
            if rel['source'] in graph and rel['target'] in graph:
                graph[rel['source']].append(rel['target'])
    visiting, visited = (set(), set())

    def visit(node):
        if node in visiting:
            return True
        if node in visited:
            return False
        visiting.add(node)
        cycle = any((visit(child) for child in graph[node]))
        visiting.remove(node)
        visited.add(node)
        return cycle
    if any((visit(n) for n in graph if n not in visited)):
        errors.append('lineage_cycle')
    return sorted(set(errors))
rows=[]
def add(group,case,expected,actual,reason=''):
    rows.append(dict(group=group,case=case,expected=expected,actual=actual,passed=expected==actual,reason=reason))
key=secrets.token_bytes(32);v=Verifier(key);context='demo:read';fixed='PUBLIC-DEMO-CODE'
add('Replay','Fixed response first use',True,fixed==fixed);add('Replay','Fixed response replay',True,fixed==fixed)
n=v.issue(context);r=mac(key,n,context);add('Replay','Fresh valid response',True,v.accept(n,context,r));add('Replay','Same nonce replay',False,v.accept(n,context,r))
n=v.issue(context);add('Replay','Old response new nonce',False,v.accept(n,context,r))
n=v.issue(context);add('Replay','Wrong shared key',False,v.accept(n,context,mac(secrets.token_bytes(32),n,context)))
n=v.issue(context);add('Replay','Changed operation context',False,v.accept(n,'demo:write',mac(key,n,context)))
# Additional policy invariant, not an eighth outcome-matrix trial.
n=v.issue(context);v.accept(n,context,'bad');consumed=not v.accept(n,context,mac(key,n,context));assert consumed
img=Image.new('RGB',(24,24),'white');ImageDraw.Draw(img).rectangle((4,4,19,19),fill='navy');buf=BytesIO();img.save(buf,format='PNG')
files={'note.txt':b'Fabricated laboratory note.\n','emblem.png':buf.getvalue(),'transcription.txt':b'Illustrative transcription version one.\n'}
manifest={'schema':'teaching-bundle-v1','files':[file_record(n,p,'image/png' if n.endswith('.png') else 'text/plain')for n,p in sorted(files.items())]};mb=canonical_json(manifest)
private=Ed25519PrivateKey.generate();public=private.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw);sig=private.sign(mb)
def signature_case(label,fs,expected,manifest_bytes=mb,public_key=public):
    result=verify(manifest_bytes,sig,public_key,fs);add('Signature',label,expected,result['valid'],result['reason'])
signature_case('Untouched bundle',files,True);signature_case('Exact byte copy',dict(files),True)
changed=dict(files);changed['note.txt']=changed['note.txt'][:-1]+b'X';signature_case('One byte changed',changed,False)
page=Image.new('RGB',(320,140),'white');draw=ImageDraw.Draw(page);draw.text((10,10),'FABRICATED REPORT',fill='black');page.paste(img,(10,40));buf=BytesIO();page.save(buf,format='PNG');changed=dict(files);changed['emblem.png']=buf.getvalue();signature_case('Raster page variant',changed,False)
changed=dict(files);changed['transcription.txt']=b'Illustrative transcription corrected.\n';signature_case('Edited transcription',changed,False)
wrong=Ed25519PrivateKey.generate().public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw);signature_case('Wrong public key',files,False,public_key=wrong);signature_case('Changed manifest bytes',files,False,manifest_bytes=mb+b' ')
cases=[('Valid version chain',['a','b','c'],[('b','a','copy_of'),('c','b','translation_of')],[]),('Unsupported independence',['a','b'],[('a','b','independent_of')],['unsupported_independence']),('Cyclic version lineage',['a','b'],[('a','b','copy_of'),('b','a','ocr_of')],['lineage_cycle']),('Missing parent version',['a'],[('a','missing','copy_of')],['dangling_relation'])]
graphs=[]
for label,docs,rels,expected in cases:
    case={'documents':[{'id':i}for i in docs],'relations':[dict(source=a,target=b,type=t)for a,b,t in rels]};actual=validate(case);add('Provenance',label,not expected,not actual,';'.join(actual)or'ok');assert actual==expected;graphs.append(dict(label=label,graph=case,expected_errors=expected,actual_errors=actual))
assert all(r['passed']for r in rows)
with(P/'outcomes.csv').open('w',newline='',encoding='utf8')as f:w=csv.DictWriter(f,fieldnames=list(rows[0]));w.writeheader();w.writerows(rows)
(P/'provenance-cases.json').write_text(json.dumps(graphs,indent=2)+'\n')
(P/'signed-fixtures.json').write_text(json.dumps(dict(manifest_base64=base64.b64encode(mb).decode(),signature_base64=base64.b64encode(sig).decode(),public_key_base64=base64.b64encode(public).decode(),files_base64={n:base64.b64encode(p).decode()for n,p in files.items()},private_key_saved=False),indent=2)+'\n')
plt.rcParams.update({'font.size':10,'savefig.dpi':180})
fig,ax=plt.subplots(figsize=(10,8),layout='constrained');ax.axis('off')
table=ax.table(cellText=[[r['group'],r['case'],'Accept'if r['expected']else'Reject','Accept'if r['actual']else'Reject']for r in rows],colLabels=['Test family','Fabricated case','Expected','Actual'],colWidths=[.16,.48,.18,.18],cellLoc='left',loc='center');table.auto_set_font_size(False);table.set_fontsize(10);table.scale(1,1.65)
for (i,j),cell in table.get_celld().items():
    if i==0:cell.set_facecolor('#dce7f2')
    elif j in [2,3]:cell.set_facecolor('#e1eee7'if rows[i-1]['actual']else'#f3e6db')
ax.set_title('Exact outcomes for 18 selected teaching cases\nAcceptance means the defined check passes, not that content is true',pad=15)
for ext in ['png','svg','pdf']:fig.savefig(P/f'outcome-matrix.{ext}')
plt.close(fig)
(P/'validation.json').write_text(json.dumps(dict(all_pass=True,cases=18,replay=7,signature=7,provenance=4,failed_attempt_consumes_nonce=consumed,real_algorithms=['HMAC-SHA256','SHA-256','Ed25519']),indent=2)+'\n')
(P/'study.json').write_text(json.dumps(dict(runtime=dict(python=sys.version,cryptography=cryptography.__version__,matplotlib=matplotlib.__version__),fixtures='Fabricated English examples',key_policy='Fresh ephemeral demonstration keys; no private keys or shared secrets persisted',reproduction='Rerun analogous case definitions using clean fixtures; fresh keys change signatures but not expected outcomes',source_hashes={'generator':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()},limits=['Hand-selected cases are not accuracy estimates or security proof','Failed attempt consumes pending nonce before verification','No persistence expiry rate limits relay defense or key custody','Public key is not bound to a real identity','Graph evidence fields are not independently verified evidence']),indent=2)+'\n')
print('PASS 18 cases; real Ed25519/HMAC/SHA256')
