"""Offline, deterministic export. No fetching, AI generation or publication.
Run after editorial approval; pass --generated-at and optionally --previous collection.json.
"""
import argparse,json,hashlib,re
from pathlib import Path
p=argparse.ArgumentParser();p.add_argument('--generated-at',required=True);p.add_argument('--previous');p.add_argument('--release-id',default='oa-protocol-v5');args=p.parse_args()
D=Path(__file__).parent.parent;O=D/'data';O.mkdir(exist_ok=True)
def read(n):return json.loads((D/n).read_text())
def digest(x):return hashlib.sha256(json.dumps(x,sort_keys=True,separators=(',',':'),ensure_ascii=False).encode()).hexdigest()
def write(n,x):(O/n).write_text(json.dumps(x,ensure_ascii=False,indent=2)+'\n')
r=read('reader-data.json');rels=read('entry-relations.json');images=read('supporting-images.json');videos=read('videos.json');source_nodes={};records=[];lookup={}
def ident(href):
 page,id=href.split('#',1);return ('history:' if page=='vault.html' else 'theory:')+id
for href,raw in r.items():lookup[href]=ident(href)
def addsource(url):
 assert re.match(r'^https?://',url),url
 sid='source:'+hashlib.sha256(url.encode()).hexdigest()[:20];source_nodes[sid]={'id':sid,'url':url,'last_http_checked_at':None};return sid
def source_refs(raw):
 rows=list(raw.get('sources') or []);rows+=([{'url':raw['source_url'],'role':'institutional or documentary record'}] if raw.get('source_url') else [])
 result=[];seen=set()
 for row in rows:
  if isinstance(row,str):row={'url':row}
  url=row.get('url') or row.get('href')
  if not url or not url.startswith(('http://','https://')):continue
  role=row.get('role') or 'role not specified';key=(url,role)
  if key not in seen:result.append({'source_id':addsource(url),'role':role,'supports_claim_truth':None});seen.add(key)
 return result
for href,raw in r.items():
 label=raw.get('label','unverified');scope='Institutional record documented; does not authenticate interpretations.' if raw['kind']=='historical' else 'Editorial assessment of this proposition; not a numeric confidence score.'
 asset=images.get(href);visual={'embedded':bool(asset),'role':(asset or {}).get('visual_role','context_or_documentary_original') if asset else 'not_embedded','asset':asset,'reuse_notice':(asset or {}).get('rights') or raw.get('display_image',{}).get('rights') or raw.get('rights') or None,'limitations':'Image existence does not prove a linked claim. Consult image-specific captions and reuse conditions.'}
 entry={'id':lookup[href],'legacy_id':raw['id'],'kind':raw['kind'],'title':raw['title'],'display_title':raw.get('display_title') or raw['title'],'display_subtitle':raw.get('display_subtitle'),'url':href,'summary':raw.get('summary') or raw.get('claim') or raw.get('description') or '', 'claim':raw.get('claim'),'topics':[raw.get('browse_topic') or raw.get('category') or 'Uncategorized'],'assessment':{'label':label,'scope':scope,'text':raw.get('counterevidence') or raw.get('status') or None,'limitations':raw.get('limitations')},'sources':source_refs(raw),'visual':visual,'dates':{'artifact_date_text':raw.get('date'),'object_date_text':raw.get('chronology',{}).get('date_label'),'sort_start_year':raw.get('chronology',{}).get('sort_year'),'sort_month':raw.get('chronology',{}).get('sort_month'),'sort_day':raw.get('chronology',{}).get('sort_day'),'date_status':raw.get('chronology',{}).get('date_status'),'date_note':raw.get('chronology',{}).get('sort_note'),'date_basis':raw.get('chronology',{}).get('date_basis'),'related_date':raw.get('related_date'),'editorial_review_date_reported':raw.get('verification_date'),'review_date_basis':'inherited verification_date field; not newly verified by this export' if raw.get('verification_date') else 'not recorded','content_modified_at':None,'source_rechecked_at':None},'provenance':{'input':'reader-data.json','input_key':href,'input_record_sha256':digest(raw),'original_fields':raw},'record_sha256':None}
 entry['record_sha256']=digest({k:v for k,v in entry.items() if k!='record_sha256'});records.append(entry)
for v in videos:
 lookup['watch.html#video-'+v['id']]='video:'+v['id']
 entry={'id':'video:'+v['id'],'legacy_id':v['id'],'kind':'video','title':v['title'],'url':'watch.html#video-'+v['id'],'summary':v['description'],'claim':None,'topics':v.get('topics',['Watch']),'assessment':{'label':'unreviewed_video','scope':'Creator attribution and perspective are catalog metadata, not verification of every statement.','text':v['description'],'limitations':'Playback and embed availability not independently confirmed.'},'sources':[{'source_id':addsource(v['url']),'role':'original creator video','supports_claim_truth':None}]+([{'source_id':addsource(v['source_url']),'role':'primary production context','supports_claim_truth':None}] if v.get('source_url') else []),'visual':{'embedded':False,'role':'provider_served_preplay_preview_and_on_site_player','asset':None,'reuse_notice':'YouTube original-channel embed; no local video copy.','limitations':'Watch previews load lazily before play with external-media disclosure and hide control. Provider/network delivery varies; no local thumbnail copy.','service_preview':v.get('service_preview')},'dates':{'artifact_date_text':None,'editorial_review_date_reported':None,'review_date_basis':'not recorded','content_modified_at':None,'source_rechecked_at':None},'provenance':{'input':'videos.json','input_key':v['id'],'input_record_sha256':digest(v),'original_fields':v},'record_sha256':None};entry['record_sha256']=digest({k:v for k,v in entry.items() if k!='record_sha256'});records.append(entry)
links=[]
for href,rows in rels.items():
 for x in rows:
  assert href in lookup and x['href'] in lookup
  links.append({'id':'relation:'+digest([lookup[href],lookup[x['href']],'related_context'])[:20],'from_id':lookup[href],'to_id':lookup[x['href']],'predicate':'related_context','why':x['why'],'editorial_target_type':x.get('type'),'is_corroboration':False,'reviewed_at':None,'provenance':'entry-relations.json'})
records.sort(key=lambda x:x['id']);links.sort(key=lambda x:x['id']);sources=sorted(source_nodes.values(),key=lambda x:x['id']);counts={k:sum(x['kind']==k for x in records) for k in ['historical','theory','video']}
collection={'schema_version':'1.1.0','release_id':args.release_id,'generated_at':args.generated_at,'generation_is_not_source_review':True,'counts':counts,'records':records,'sources':sources,'relations':links,'rights':'Record/image rights are per source. This dataset does not grant blanket rights to linked or embedded third-party material.'}
write('collection.json',collection)
previous=json.loads(Path(args.previous).read_text()) if args.previous else None
before={x['id']:x['record_sha256'] for x in previous['records']} if previous else {};after={x['id']:x['record_sha256'] for x in records}
changes={'release_id':args.release_id,'generated_at':args.generated_at,'comparison': 'previous machine-readable snapshot' if previous else 'initial normalized baseline; not the creation date of historical content','added_ids':sorted(set(after)-set(before)),'removed_ids':sorted(set(before)-set(after)),'modified_ids':sorted(k for k in after.keys()&before.keys() if after[k]!=before[k]),'publication_status':'prepared_not_deployed','requires_editorial_approval':True};write('changes.json',changes)
inputs=['reader-data.json','entry-relations.json','supporting-images.json','videos.json'];write('manifest.json',{'schema_version':'1.1.0','release_id':args.release_id,'generated_at':args.generated_at,'counts':counts,'relation_count':len(links),'source_count':len(sources),'collection_sha256':hashlib.sha256((O/'collection.json').read_bytes()).hexdigest(),'inputs':[{ 'path':n,'sha256':hashlib.sha256((D/n).read_bytes()).hexdigest()} for n in inputs],'authority':'Canonical normalized read export for this release; inputs remain the editorial build sources. Do not edit both independently.','review_workflow':'propose → source/rights review → normalize → validate → human approval → deploy → verify','automatic_publication':False})
assert len(records)==len(r)+len(videos) and counts=={'historical':313,'theory':150,'video':len(videos)};assert len({x['id'] for x in records})==len(records);assert len({x['id'] for x in links})==len(links);assert all(x['sources'] for x in records)
print(counts,len(sources),'sources',len(links),'relations')
