from pathlib import Path
from zipfile import ZipFile
from lxml import etree as E
from collections import Counter
from PIL import Image,ImageDraw
import os,json,re,subprocess,hashlib,posixpath
R=Path(__file__).parent;D=R/'deliverables';ns={'a':'http://schemas.openxmlformats.org/drawingml/2006/main','p':'http://schemas.openxmlformats.org/presentationml/2006/main','r':'http://schemas.openxmlformats.org/officeDocument/2006/relationships'}
LO='/Users/jack/.cache/codex-runtimes/codex-primary-runtime/dependencies/bin/override/soffice'
env=os.environ.copy();env['FONTCONFIG_FILE']=str((R.parent/'2026-09-22-five-scene-solutions/deliverables/qa/fonts.conf').resolve())
rows=json.loads((R/'final-manifest.json').read_text());ins=json.loads((R/'insert-manifest.json').read_text());ins={x['id']:x for x in ins};all_company=[];results=[]
def part_order(z):
 rel={x.get('Id'):x.get('Target') for x in E.fromstring(z.read('ppt/_rels/presentation.xml.rels'))};p=E.fromstring(z.read('ppt/presentation.xml'));res=[]
 for x in p.find('p:sldIdLst',ns):
  t=rel[x.get('{'+ns['r']+'}id')];res.append(t[1:] if t.startswith('/') else posixpath.normpath('ppt/'+t))
 return res
def texts(root):return [x.text for x in root.findall('.//a:t',ns) if x.text]
def no_page(t):return [x for x in t if not re.fullmatch(r'\d{2}\s*/\s*\d+',x)]
def norm(t):return re.sub(r'\s+','',t)
for r in rows:
 f=Path(r['final']);q=D/'qa'/r['scene'];q.mkdir(parents=True,exist_ok=True)
 subprocess.run([LO,'--headless','--convert-to','pdf','--outdir',str(f.parent),str(f)],env=env,check=True,stdout=subprocess.DEVNULL)
 pdf=f.with_suffix('.pdf');subprocess.run(['pdftoppm','-jpeg','-r','85',str(pdf),str(q/'page')],check=True)
 ptext=subprocess.check_output(['pdftotext',str(pdf),'-']).decode();pages=ptext.split('\f')[:r['pages']];assert len(pages)==r['pages']
 fonts=subprocess.check_output(['pdffonts',str(pdf)]).decode();assert 'MicrosoftYaHei' in fonts and 'Arial' in fonts
 company=[];unchanged=0;warn=[];native=0
 with ZipFile(f) as z,ZipFile(r['base']) as bz:
  parts=part_order(z);oldparts=part_order(bz);assert len(parts)==r['pages']
  for m in r['page_mapping']:
   n=m['page'];root=E.fromstring(z.read(parts[n-1]));tx=texts(root);native+=len(root.findall('.//p:sp/p:txBody',ns))
   assert f'{n:02d} / {r["pages"]}' in tx,(r['scene'],n,'page number missing')
   if m['kind']=='old':
    oldroot=E.fromstring(bz.read(oldparts[m['original_page']-1]));newtx=no_page(tx)
    newtx=[t for t in newtx if t!='内部交流版｜案例材料仅供分享交流，不得用于公开宣传']
    assert newtx==no_page(texts(oldroot)),(r['scene'],n,'old content changed');unchanged+=1
    # Original referenced media/relationships are retained byte-for-byte.
    rp=posixpath.join(posixpath.dirname(parts[n-1]),'_rels',posixpath.basename(parts[n-1])+'.rels')
    if rp in bz.namelist():assert z.read(rp)==bz.read(rp)
   else:
    expected=ins[m['insert_id']]['text'];assert all(norm(x) in norm(pages[n-1]) for x in expected),(r['scene'],n,'insert text missing')
    if m['insert_id'].startswith('company-'):
     canon=E.fromstring(z.read(parts[n-1]));tree=canon.find('p:cSld/p:spTree',ns)
     for sh in list(tree):
      c=sh.find('p:nvSpPr/p:cNvPr',ns)
      if c is not None and c.get('name')=='Revision page number':tree.remove(sh)
     company.append(hashlib.sha256(E.tostring(canon)).hexdigest())
   missing=Counter(norm(''.join(tx)))-Counter(norm(pages[n-1]))
   if missing:warn.append({'page':n,'missing':dict(missing)})
  for name in bz.namelist():
   if name.startswith('ppt/media/') and not name.endswith('/'):assert z.read(name)==bz.read(name)
 all_company.append(company);assert len(company)==3
 pics=sorted(q.glob('page-*.jpg'));assert len(pics)==r['pages']
 changed=[q/f'page-{m["page"]:02d}.jpg' for m in r['page_mapping'] if m['kind']=='new']
 for kind,files in [('changed',changed),('all',pics)]:
  for off in range(0,len(files),12):
   ss=files[off:off+12];sheet=Image.new('RGB',(1600,((len(ss)+2)//3)*320),'#eee');dr=ImageDraw.Draw(sheet)
   for j,p in enumerate(ss):
    im=Image.open(p);im.thumbnail((520,293));x=j%3*533;y=j//3*320;sheet.paste(im,(x,y));dr.text((x+5,y+298),p.stem,fill='black')
   sheet.save(q/f'{kind}-{off//12+1}.jpg')
 result={'scene':r['scene'],'pages':r['pages'],'case_units':r['cases'],'company_xml_hashes':company,'old_body_pages_preserved':unchanged,'old_media_preserved':True,'native_text_shapes':native,'pdf_text_warnings':warn,'fonts_embedded':True,'first_party_import':'pass','visual_review':'pending','pptx':str(f),'pdf':str(pdf),'pptx_sha256':hashlib.sha256(f.read_bytes()).hexdigest(),'pdf_sha256':hashlib.sha256(pdf.read_bytes()).hexdigest()}
 results.append(result);print(r['scene'],r['pages'],'pages, preserved',unchanged,'body pages, warnings',len(warn),flush=True)
 (R/'validation.json').write_text(json.dumps(results,ensure_ascii=False,indent=2))
assert all(x==all_company[0] for x in all_company),'Company insert content differs among decks'
print('PASS: all six company slide XMLs identical after excluding page counters; all original body/media preserved.')
