#!/usr/bin/env python3
"""Offline source/data/link checks. These checks never claim browser acceptance."""
from pathlib import Path
from urllib.parse import urlparse,unquote
from collections import Counter
import argparse,hashlib,json,re,subprocess,sys
from bs4 import BeautifulSoup
R=Path(__file__).resolve().parents[1]

def run(content_only=False):
 checks=[]
 def check(name,ok,detail=''):
  checks.append({'check':name,'status':'PASS' if ok else 'FAIL','detail':detail})
 def load(p):return json.loads((R/p).read_text())
 nav=load('data/navigation.json');features=load('data/funktionsmatrix.json');models=load('data/modelle.json')
 sources=load('data/quellen.json');demos=load('data/demos.json');source_ids={x['id'] for x in sources}
 check('Twelve distinct HTML chapters',len(nav)==12 and len({x[0] for x in nav})==12)
 check('23 unique feature rows',len(features)==23 and len({x['id'] for x in features})==23)
 check('Every feature has a source, boundary and next step',all(x.get('quelle') in source_ids and x.get('beleggrenze') and x.get('naechster_schritt') for x in features))
 check('Schema groups sum to 348',len(models['schema_buckets'])==15 and sum(x[1] for x in models['schema_buckets'])==348)
 t=models['tickets'];check('Ticket counts reconcile',t['open']+t['resolved_including_other_outcomes']==324 and sum(t['by_year'].values())==324 and t['actually_done']==78)
 j=models['historical_jobs'];check('24 packages reconcile; 22 intervals are separate',j['strict_pass']+j['marked_without_pass']+j['open']==24 and j['intervals']==22)
 c=models['classic_effort_model'];check('Effort model sums to 224–432 h',sum(x[1] for x in c['rows'])==224 and sum(x[2] for x in c['rows'])==432)
 check('Five demo files match reviewed hashes',len(demos)==5 and all(hashlib.sha256((R/x['datei']).read_bytes()).hexdigest()==x['ausgabe_sha256'] for x in demos))
 docs={slug:BeautifulSoup((R/(slug+'.html')).read_text(),'html.parser') for slug,_,_ in nav}
 missing=[];bad=[];icons=0
 for slug,doc in docs.items():
  name=slug+'.html';ids=[x['id'] for x in doc.select('[id]')]
  check(name+': unique IDs',len(ids)==len(set(ids)))
  check(name+': one active navigation entry',len(doc.select('nav a[aria-current="page"]'))==1)
  check(name+': title, language, heading and main',bool(doc.title and doc.html.get('lang')=='de-AT' and len(doc.find_all('h1'))==1 and doc.main))
  check(name+': canonical logo in shell',all(x.get('src')=='brand/lunis-logo.svg' for x in doc.select('.sidebar .brand img,.topbar .brand img')) and len(doc.select('.sidebar .brand img,.topbar .brand img'))>=2)
  check(name+': accessible image labels',all(x.has_attr('alt') for x in doc.find_all('img')))
  csp=doc.find('meta',attrs={'http-equiv':'Content-Security-Policy'})
  check(name+': no network connection policy',bool(csp and "connect-src 'none'" in csp['content']))
  check(name+': plain executive copy does not use old identity',not re.search(r'Ober[oö]sterreich|\bLunes\b|#dc2438',str(doc),re.I))
  icons+=len(doc.select('svg.icon'))
  for el in doc.select('[src],[href],[data-image]'):
   for attr in ['src','href','data-image']:
    raw=el.get(attr,'')
    if not raw or raw.startswith(('data:','blob:')):continue
    url=urlparse(raw)
    if url.scheme in ('http','https'):
     if not (el.name=='a' and attr=='href'):bad.append(name+': external embedded resource')
     continue
    if url.scheme:bad.append(name+': unsupported URL scheme');continue
    p=((R/name).parent/unquote(url.path)).resolve() if url.path else R/name
    if not p.is_relative_to(R):bad.append(name+': path outside package');continue
    if content_only and (p.suffix=='.zip' or p.name in ('MANIFEST.json','REPORT.md','README.md')):continue
    if not p.exists():missing.append(str(p.relative_to(R)));continue
    if url.fragment and p.suffix=='.html':
     target=docs.get(p.stem) or BeautifulSoup(p.read_text(),'html.parser')
     if not target.find(id=unquote(url.fragment)):bad.append(name+': broken fragment '+unquote(url.fragment))
  for frame in doc.find_all('iframe'):
   check(name+': iframe local, labelled and sandboxed',bool(frame.get('title') and frame.get('src','').startswith('demos/') and frame.has_attr('sandbox') and 'allow-same-origin' not in frame['sandbox']))
 for demo in demos:
  d=BeautifulSoup((R/demo['datei']).read_text(),'html.parser');script_ok=True;external=[]
  for sc in d.find_all('script'):
   if sc.get('type','') in ('application/json','application/ld+json'):continue
   if sc.get('src'):
    if sc['src'].startswith(('http:','https:','//')):external.append(sc['src'])
    continue
   args=['node','--check']
   if sc.get('type')=='module':args=['node','--input-type=module','--check']
   result=subprocess.run(args,input=sc.get_text(),capture_output=True,text=True)
   script_ok=script_ok and result.returncode==0
  check(demo['id']+': inline demo scripts parse',script_ok)
  check(demo['id']+': no remote script includes',not external)
 check('All referenced local files exist',not missing,', '.join(sorted(set(missing))))
 check('No unsafe resources or broken anchors',not bad,', '.join(sorted(set(bad))))
 check('Pictograms integrated throughout chapters',icons>200,str(icons)+' inline SVG instances')
 check('Feature table contains 23 static rows',len(docs['funktion'].select('#feature-matrix tbody tr'))==23)
 check('Competition table contains 16 sourced rows',len(docs['markt'].select('#competitor-table tbody tr'))==16)
 for p in (R/'assets').glob('*.css'):
  urls=re.findall(r'url\([\'\"]?([^\)\'\"]+)',p.read_text())
  check(p.name+': CSS assets local',all(u.startswith(('data:','../','./')) or ':' not in u for u in urls))
 css=(R/'assets/site.css').read_text();check('Reduced-motion rules present','prefers-reduced-motion:reduce' in css and '.motion-off' in css)
 for p in (R/'assets').glob('*.js'):
  result=subprocess.run(['node','--check',str(p)],capture_output=True,text=True)
  check(p.name+': JavaScript parses',result.returncode==0,'Syntax check only')
 script="""const assert=require('node:assert/strict');const M=require('./assets/model.js');const defaults=require('./data/modelle.json').roi_model.default;
let n=0;function test(f){f();n++;}const r=M.roi(defaults);
test(()=>assert.equal(r.hours,31.25));test(()=>assert.equal(r.gross,1093.75));test(()=>assert.equal(r.net,943.75));test(()=>assert.ok(Math.abs(r.payback_months-6000/943.75)<1e-10));
test(()=>assert.equal(M.roi({...defaults,people:0}).payback_months,null));test(()=>assert.equal(M.roi({...defaults,running:9999}).payback_months,null));test(()=>assert.equal(M.roi({...defaults,initial:0}).payback_months,0));
for(const field of ['people','minutes','days','rate','adoption','running','initial'])test(()=>assert.throws(()=>M.roi({...defaults,[field]:-1}),RangeError));
test(()=>assert.throws(()=>M.roi({...defaults,adoption:2}),RangeError));test(()=>assert.throws(()=>M.roi({...defaults,rate:NaN}),RangeError));
test(()=>assert.ok(M.safeCell('=1+1').startsWith(\"\\\"'\")));test(()=>assert.ok(M.safeCell('  @SUM(A1)').startsWith(\"\\\"'\")));test(()=>assert.ok(M.csv([['a;b','c\\\"d']],['A','B']).startsWith('\\uFEFF')));
const rows=require('./data/funktionsmatrix.json');test(()=>assert.equal(M.filter(rows,'','alle').length,23));test(()=>assert.ok(M.filter(rows,'','blockiert').every(x=>x.status==='blockiert')));test(()=>assert.equal(M.filter(rows,'not-a-feature').length,0));test(()=>assert.ok(M.filter(rows,'ODOO').length>0));console.log(JSON.stringify({passed:n}));"""
 result=subprocess.run(['node','-e',script],cwd=R,capture_output=True,text=True)
 check('Pure data model tests',result.returncode==0,result.stdout.strip() if not result.returncode else 'Node assertion failed')
 if result.returncode:print(result.stderr,file=sys.stderr)
 from privacy import findings
 violations=[]
 for directory in ['data','quellen','demos','tools']:
  for p in (R/directory).glob('*'):
   if not p.is_file() or p.suffix not in ('.json','.md','.html','.py'):continue
   hit=findings(p.read_text())
   if hit:violations.append({'file':str(p.relative_to(R)),'counts':hit})
 check('Curated text contains no detected privacy markers',not violations,json.dumps(violations,ensure_ascii=False))
 failures=sum(x['status']=='FAIL' for x in checks)
 report={'scope':'Static HTML/assets/data checks; not browser or application acceptance','mode':'content-only' if content_only else 'full-package-links','status':'PASS' if not failures else 'FAIL','checks_passed':len(checks)-failures,'checks_failed':failures,'inline_svg_instances':icons,'checks':checks}
 (R/'proof/static-checks.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
 print(json.dumps({k:v for k,v in report.items() if k!='checks'},ensure_ascii=False))
 if failures:
  for x in checks:
   if x['status']=='FAIL':print(json.dumps(x,ensure_ascii=False))
 return 1 if failures else 0
if __name__=='__main__':
 parser=argparse.ArgumentParser();parser.add_argument('--content-only',action='store_true');args=parser.parse_args();raise SystemExit(run(args.content_only))
