#!/usr/bin/env python3
"""Independent, portable Edition 07 source/reader/art verification.

Python 3.10+, Pillow for raster decoding. Node is optional for actual syntax and
retained navigation-logic checks; missing Node is NOT RUN, never PASS. This
verifier does not generate images, install packages, launch a game or publish.
"""
from pathlib import Path,PureWindowsPath
from collections import Counter
from urllib.parse import urlsplit,unquote
import copy,hashlib,json,re,shutil,subprocess,sys,tempfile
import xml.etree.ElementTree as ET
from build_edition06 import Inventory,words,original_json_script
from parse_substellar_blocks import blocks_from_text

HERE=Path(__file__).resolve().parent;ROOT=HERE.parent
def read(n):return json.loads((HERE/n).read_text(encoding='utf-8'))
def sha(b):return hashlib.sha256(b).hexdigest()
def check(name,fn):
    try:detail=fn();checks.append(dict(name=name,status='PASS',detail=detail))
    except Exception as e:checks.append(dict(name=name,status='FAIL',detail=str(e)))
def require(value,msg):
    if not value:raise AssertionError(msg)
def gitsha(b):return hashlib.sha1(b'blob '+str(len(b)).encode()+b'\0'+b).hexdigest()

checks=[]
def main():
    text=(ROOT/'index.html').read_text(encoding='utf-8');old=(HERE/'reader_06_r1_preserved.html').read_text(encoding='utf-8');current=Inventory(text);prior=Inventory(old)
    manifest=read('edition-manifest.json');oldmanifest=read('edition06_r1_manifest_preserved.json');map07=read('source_anchor_map_07.json');map06=read('source_anchor_map_06.json');figs=read('figures_07.json')['figures']
    def baseline():
      overrides={'index.html':'sources/reader_06_r1_preserved.html','README.txt':'sources/README_06_r1.txt','sources/edition-manifest.json':'sources/edition06_r1_manifest_preserved.json'}
      rows=read('baseline_06_r1_inventory.json')['files'];bad=[]
      for r in rows:
        p=ROOT/overrides.get(r['path'],r['path'])
        if not p.is_file()or sha(p.read_bytes())!=r['sha256']:bad.append(r['path'])
      require(not bad,str(bad));return dict(files=len(rows),overrides=overrides,altered_prior_payloads=0)
    check('All 313 R1 file payloads preserved',baseline)
    def originals():
      require(len(oldmanifest['source_content'])==22,'Wrong baseline source count')
      for e in oldmanifest['source_content']:require(sha((HERE/e['file']).read_bytes())==e['sha256'],e['file'])
      return '22 exact prior narrative files; original artwork is independently covered by the 313-file inventory.'
    check('Prior narrative source hashes',originals)
    def blocks():
      bad=[k for k,v in prior.source_blocks.items()if current.source_blocks.get(k)!=v];require(not bad,str(bad[:10]));require(len(prior.source_blocks)==3131,'Wrong baseline count');return dict(prior_blocks_preserved=3131,new_blocks=len(current.source_blocks)-3131)
    check('Literal source-block preservation',blocks)
    def anchors():
      require(set(prior.ids)<=set(current.ids),'Lost prior IDs');require(not [k for k,n in current.ids.items()if n!=1],'Duplicate IDs');return dict(prior_ids=4767,current_ids=len(current.ids),duplicate_ids=0)
    check('Every prior anchor retained and all IDs unique',anchors)
    def narratives():
      complete=(HERE/'complete_direction_07.txt').read_text(encoding='utf-8');wc=0;bc=0;chap=0
      for e in manifest['source_content']:
        p=HERE/e['file'];t=p.read_text(encoding='utf-8');require(sha(p.read_bytes())==e['sha256'],e['file']);require(t in complete,'Missing exact source '+e['file']);require(words(t)==e['words'],'Word method mismatch');wc+=words(t);b=blocks_from_text(t);bc+=len(b);chap+=sum(x[0]=='heading'and x[1]==1 for x in b)
      require((wc,bc,chap)==(manifest['total_source_words'],len(current.source_blocks),75),'Narrative inventory differs');require(len(manifest['source_content'])==25,'Expected 25 sources');require(sha((HERE/'complete_direction_07.txt').read_bytes())==manifest['complete_text_sha256'],'Aggregate digest mismatch');return dict(words=wc,blocks=bc,chapters=chap,narrative_sources=25)
    check('Complete text contains every exact source once and counts reconcile',narratives)
    def nav():
      require(len(map07['chapters'])==75,'Chapter map count');require(sum('data-chapter-guide'in t['attrs']for t in current.tags)==75,'Chapter guide count')
      e=current.elements['sidebar'];side=text[e['start']:e['end']]
      for c in map07['chapters']:
        require(c['id']in current.ids,c['id'])
        if c.get('new_in_edition07'):
          for s in [c,*c['sections']]:require(f'href="#{s["id"]}"'in side,s['id'])
      return '75 mapped chapters and directory cards; all eight new chapters and 54 new sections are in following navigation.'
    check('Chapter, section and following-sidebar registration',nav)
    def mappings():
      for k,v in map06['canonical_symbol_anchors'].items():require(map07['canonical_symbol_anchors'].get(k)==v,'Changed canonical '+k)
      for k,v in map07['canonical_symbol_anchors'].items():require(v.lstrip('#')in current.ids,'Unresolved symbol '+k)
      for r in read('prose_mappings_06_r1.json')['mappings']:require(r['proposed_prose_target']in current.ids,r['id'])
      return 'Inherited canonical mappings unchanged; all 26 R1 prose targets and current symbols resolve.'
    check('R1 mappings and additive current symbol map',mappings)
    def search():
      entries=original_json_script(text,'search-data');require(all(r['id']in current.ids for r in entries),'Unresolved search record')
      ids={r.get('block_id')for r in entries}
      for (source,bid)in current.source_blocks:
        if source.startswith('c07-'):require(bid in ids,'Unindexed new source block '+bid)
      return dict(records=len(entries),all_new_blocks_searchable=True)
    check('Search destinations and new-block coverage',search)
    def links():
      errors=[];n=0
      for tag,attr,link in current.links:
        u=urlsplit(link)
        if u.scheme or u.netloc or link.startswith('//'):continue
        n+=1
        if u.path and not(ROOT/unquote(u.path)).is_file():errors.append(link)
        elif not u.path and u.fragment and unquote(u.fragment)not in current.ids:errors.append(link)
      require(not errors,str(sorted(set(errors))[:20]));return dict(local_references=n,unresolved=0)
    check('Every reader-local file and same-page fragment resolves',links)
    def visual():
      v=read('visual_catalogue_07.json')['records'];o=original_json_script(old,'visual-data')['records'];require(v[:len(o)]==o,'Changed prior visual metadata');require(len(v)==102 and len({r['id']for r in v})==102,'Visual inventory');require(sum('data-visual-row'in t['attrs']for t in current.tags)==102,'Visual directory count')
      for f in figs:
        require(f['target']in current.ids,f['id']);require(text.count('data-figure-id="'+f['id']+'"')==1,'Figure placement count '+f['id']);require(sha((ROOT/f['file']).read_bytes())==f['sha256_file'],f['file'])
        for s in f['section_ids']:require(s in current.ids,s)
        if f.get('original'):require(sha((ROOT/f['original']).read_bytes())==f['sha256_original'],f['original'])
      return dict(visual_identities=102,prior_visuals_byte_equal=84,new_contextual_figures=18)
    check('Visual identity, contextual placement and asset hashes',visual)
    def rasters():
      from PIL import Image,ImageChops
      files=sorted(p for p in (ROOT/'images').rglob('*')if p.suffix.lower()in('.png','.webp','.jpg','.jpeg'));decoded=[]
      for p in files:
        with Image.open(p)as im:im.load();require(im.width>0 and im.height>0,str(p));decoded.append(dict(file=p.relative_to(ROOT).as_posix(),format=im.format,width=im.width,height=im.height))
      for n in ['05','07']:
        with Image.open(ROOT/f'images/c06-a{n}.webp')as a,Image.open(ROOT/f'images/originals/c06-a{n}.png')as b:
          require(a.size==b.size and ImageChops.difference(a.convert('RGBA'),b.convert('RGBA')).getbbox()is None,'R1 repaired pixels changed '+n)
      return dict(raster_files=len(decoded),all_fully_decoded=True,r1_repaired_pixel_equality=['C06-A05','C06-A07'],files=decoded)
    check('Full raster decoding and retained R1 lossless repairs',rasters)
    def svgs():
      files=sorted((ROOT/'images/diagrams').glob('*.svg'))
      for p in files:
        root=ET.fromstring(p.read_text(encoding='utf-8'));require(root.tag.endswith('svg'),str(p));require(root.get('viewBox')or(root.get('width')and root.get('height')),str(p))
      return dict(svg_files=len(files),new_svg_files=8,render_review='Eight new figures rendered with Inkscape and visually inspected. D06 footer overlap corrected. Rendering did not verify game behavior.')
    check('Editable SVG validity and recorded visual review',svgs)
    def generation():
      d=read('image_generation_07.json');require([c['call']for c in d['calls']]==list(range(1,9)),'Call ledger sequence');selected=[c for c in d['calls']if c['status']=='selected'];require(len(selected)==6 and len({c['target']for c in selected})==6,'Selected boards');require(d['calls_used']==8 and d['maximum_calls']==8,'Budget mismatch')
      for c in selected:require(sha((ROOT/c['original']).read_bytes())==c['original_sha256'],c['target'])
      require({c['target']for c in selected}=={f'C07-A{i:02d}'for i in range(1,7)},'Wrong board identity set')
      return dict(calls=8,cap=8,selected=6,correction_history_preserved=True,additional_generation_authorized=False)
    check('Image budget, selected originals and rejected-attempt provenance',generation)
    def style():
      d=read('style_reference_07.json');require(d['primary_style_reference']=='REF03'and d['machinery_reference']=='CD05-A03','Style hierarchy');require(len(d['records'])==4,'Imported reference count')
      for r in d['records']:b=(ROOT/r['file']).read_bytes();require(sha(b)==r['sha256']and gitsha(b)==r['git_blob_sha'],r['file'])
      return 'Four unchanged Website JPEGs match pinned Git blob identities; two existing user examples retain their original IDs.'
    check('User-confirmed style examples and source identity',style)
    def machines():
      a=read('industry_operations_06.json')['records'];b=read('machine_relationships_07.json')['records'];require(len(a)==len(b)==66,'Machine count')
      for x,y in zip(a,b):require(all(y.get(k)==v for k,v in x.items()),x['id']);require(y['edition07_review']['section_id']in current.ids,x['id']);require(y['edition07_review']['numeric_recipe']is None,'Unselected numeric recipe filled')
      return 'All original fields of 66 responsibilities retained; related sections resolve; recommended host and participation are separate.'
    check('Complete machine overlay preserves original responsibilities',machines)
    def mars():
      d=read('mars_campaign_07.json');require({r['id']for r in d['regions']}=={'MA-S1','MA-S2','MA-L1','MA-L2','MA-L3'},'Region identities');require([r['id']for r in d['journeys']]==['MA-EXP-01','MA-EXP-02','MA-EXP-03'],'Journey IDs');require(len(d['yard_stages'])==6 and len(d['signature_investigation']['steps'])==7,'Campaign counts')
      regions={r['id']:r for r in d['regions']}
      for id,r in regions.items():
        for n in r['routes_to']:require(n in regions and id in regions[n]['routes_to'],'Disconnected/asymmetric proposed region adjacency')
      require(all(r['kind']=='underground'for r in d['regions']if r['id'].startswith('MA-L')),'Layer context')
      for key in ['numeric_depths','vehicle_capacity','weather_times','recipe_coefficients']:require(d[key]is None,'Unsupported numeric field')
      for r in d['products']:require(r['prearrival_requirement']is False and r['numeric_coefficients']is None,'Circular/unselected product gate')
      return 'Canonical region/journey identities, connected adjacency and counts reconcile; unselected quantities and post-arrival product status remain qualified. This is not terrain or balance testing.'
    check('Mars cross-record consistency and unresolved fields',mars)
    def views():
      a=read('artwork_backlog.json')['records'];b=read('current_visual_coverage_07.json')['records'];require(len(a)==len(b)==1076,'Coverage row count')
      for x,y in zip(a,b):require(all(y.get(k)==v for k,v in x.items()),x['id']);require('edition07_current'in y,x['id'])
      d=Counter(r['edition07_current']['coverage_class']for r in b);require(d['exact illustrative view delivered']==12,'Unsupported exact-view completion');return dict(rows=1076,classes=dict(d),new_detailed_completions_claimed=0)
    check('All historical view records reconciled without erasing original status',views)
    def portable():
      paths=[r['path']for r in read('baseline_06_r1_inventory.json')['files']];require(all('\\'not in p and not p.startswith('/')for p in paths),'Nonportable baseline keys')
      example=PureWindowsPath('C:/project/substellar/sources/decisions_07.json').relative_to(PureWindowsPath('C:/project/substellar')).as_posix();require(example=='sources/decisions_07.json','Windows key normalization');return 'Portable relative POSIX keys verified using path semantics; no native Windows execution claimed.'
    check('Portable Windows/POSIX inventory path semantics',portable)
    def js_preservation():
      require(re.findall(r'<script>(.*?)</script>',old,re.S)==re.findall(r'<script>(.*?)</script>',text,re.S),'Prior inline scripts changed');require(re.search(r'<figure class="hero-art">.*?</figure>',old,re.S).group(0)in text,'Hero art changed');return 'Prior application/navigation scripts, storage keys and hero artwork markup are unchanged.'
    check('Preserved reader behavior inputs and hero artwork',js_preservation)
    node=shutil.which('node')
    if node:
      def syntax():
        count=0
        with tempfile.TemporaryDirectory()as td:
          for attrs,body in re.findall(r'<script\b([^>]*)>(.*?)</script>',text,re.S):
            if 'application/json'in attrs or not body.strip():continue
            p=Path(td)/f'inline-{count}.js';p.write_text(body,encoding='utf-8');r=subprocess.run([node,'--check',str(p)],capture_output=True,text=True);require(r.returncode==0,r.stderr);count+=1
        return dict(inline_scripts_syntax_checked=count,browser_behavior='NOT RUN')
      check('Node syntax check of actual inline JavaScript',syntax)
      def nav_logic():
        with tempfile.TemporaryDirectory()as td:
          p=Path(td)
          for n in ['substellar_reader_navigation.js','test_reader_navigation.js']:shutil.copy2(HERE/n,p/n)
          r=subprocess.run([node,str(p/'test_reader_navigation.js')],capture_output=True,text=True);require(r.returncode==0,r.stderr);d=json.loads((p/'reader_navigation_test_report.json').read_text());require(d['passed'],'Retained logic tests failed');return dict(tests=len(d['tests']),scope='Retained pure navigation selection/scroll arithmetic, in a temporary copy; no DOM, browser or geometry execution.')
      check('Retained navigation logic checks without changing baseline reports',nav_logic)
    else:checks.append(dict(name='Node syntax and navigation logic',status='NOT RUN',detail='Node unavailable'))
    # The report path must exist for the local-link check above; initial bootstrap
    # receipt is replaced by this actual execution record.
    failures=[c for c in checks if c['status']=='FAIL']
    report=dict(edition='workshop-mars-07',result='FAIL'if failures else'PASS',method='Independent source, asset and exact-HTML inventory; actual local checks listed separately from Website evidence.',checks=checks,counts=dict(passed=sum(c['status']=='PASS'for c in checks),failed=len(failures),not_run=sum(c['status']=='NOT RUN'for c in checks)),reader_sha256=sha((ROOT/'index.html').read_bytes()),source_baseline='37e53c694a862411c161ec08da92c0294712959c',browser_attempt=dict(status='NOT RUN',reason='No Chromium executable. A Playwright browser-download attempt returned an invalid/empty archive; launch remained unavailable. No live page checks executed.'),not_run=['Rendered browser interaction and responsive geometry','axe or Lighthouse accessibility','Printed-page layout','Independent full public Website UI review','Public Website release/build/deployment or live production verification','Performance benchmarking','Firefox/WebKit','Native Windows process execution','Game/launcher implementation or runtime'],attribution='Prior Website release and production checks remain attributed committed reports, with the additional 18 final checks retained as chat-reported only.',actual_mod_development_authorized=False)
    (HERE/'verification_07.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
    print(json.dumps(dict(result=report['result'],counts=report['counts'],failures=failures,report=str(HERE/'verification_07.json')),indent=2))
    return 1 if failures else 0

if __name__=='__main__':sys.exit(main())
