#!/usr/bin/env python3
from __future__ import annotations
import asyncio, json, re, base64, mimetypes
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'qa_screenshots'; OUT.mkdir(exist_ok=True)
SEQ=json.load(open(ROOT/'assets/data/image-admin/exercise-pose-sequences.json'))
LIB=json.load(open(ROOT/'assets/data/image-admin/pose-sprite-library.json'))
assert SEQ.get('version')=='v97-rc17-pose-runtime-polish'
assert len(SEQ.get('sequences',{}))>=22
assert all((p.get('cleanup') or {}).get('status')=='ok' for p in LIB.get('poses',[]))

def read_asset(src):
    clean=src.split('?',1)[0].split('#',1)[0]
    return (ROOT/clean).read_text(encoding='utf-8')

def inline_preview():
    html=(ROOT/'preview.html').read_text(encoding='utf-8')
    qa=f"""<base href="file://{ROOT.as_posix()}/">
<script>
(function(){{
 const jsonMap={{
  'api/image-admin/templates.php': {json.dumps(json.load(open(ROOT/'assets/data/image-admin/athlete-templates.json')),ensure_ascii=False)},
  'api/image-admin/status.php': {json.dumps(json.load(open(ROOT/'assets/data/image-admin/status.json')),ensure_ascii=False)},
  'assets/data/image-admin/master-prompts.json': {json.dumps(json.load(open(ROOT/'assets/data/image-admin/master-prompts.json')),ensure_ascii=False)},
  'assets/data/image-admin/exercise-motion-prompts.json': {json.dumps(json.load(open(ROOT/'assets/data/image-admin/exercise-motion-prompts.json')),ensure_ascii=False)},
  'assets/data/image-admin/pose-sprite-library.json': {json.dumps(json.load(open(ROOT/'assets/data/image-admin/pose-sprite-library.json')),ensure_ascii=False)},
  'assets/data/image-admin/exercise-pose-sequences.json': {json.dumps(json.load(open(ROOT/'assets/data/image-admin/exercise-pose-sequences.json')),ensure_ascii=False)}
 }};
 try {{
   const nativeReplace=history.replaceState.bind(history);
   const nativePush=history.pushState.bind(history);
   history.replaceState=function(state,title,url){{try{{return nativeReplace(state,title,url);}}catch(e){{return nativeReplace(state,title,location.href);}}}};
   history.pushState=function(state,title,url){{try{{return nativePush(state,title,url);}}catch(e){{return nativePush(state,title,location.href);}}}};
 }} catch(e) {{}}
 const nativeFetch=window.fetch?window.fetch.bind(window):null;
 window.fetch=function(url,opts){{
   const s=(typeof url==='string')?url:((url&&url.url)?url.url:String(url||''));
   if(s.includes('api/image-admin/health.php')) return Promise.resolve({{ok:true,json:()=>Promise.resolve({{ok:true,php:'8.qa',curl:true}})}});
   for(const k in jsonMap){{if(s.includes(k)) return Promise.resolve({{ok:true,json:()=>Promise.resolve(jsonMap[k])}});}}
   if(s.includes('ota_youtube_proxy.php')||s.includes('video_backup_api.php')) return Promise.resolve({{ok:true,json:()=>Promise.resolve({{status:'missing'}})}});
   return nativeFetch?nativeFetch(url,opts):Promise.reject(new Error('fetch disabled '+s));
 }};
}})();
</script>"""
    html=html.replace('<head>','<head>'+qa,1)
    html=re.sub(r'<link\s+([^>]*rel=["\']stylesheet["\'][^>]*)>', lambda m: f"<style>\n{read_asset(re.search(r'href=[\"\']([^\"\']+)[\"\']',m.group(1)).group(1))}\n</style>", html)
    html=re.sub(r'<script\s+([^>]*src=["\'][^"\']+["\'][^>]*)>\s*</script>', lambda m: f"<script>\n{read_asset(re.search(r'src=[\"\']([^\"\']+)[\"\']',m.group(1)).group(1))}\n</script>", html)
    return html

def data_uri(src):
    if not src or src.startswith(('data:','http')): return None
    clean=src.split('?',1)[0].split('#',1)[0]
    p=Path(clean[7:]) if clean.startswith('file://') else ROOT/clean.lstrip('/')
    if not p.exists() or not p.is_file(): return None
    mime=mimetypes.guess_type(p.name)[0] or 'application/octet-stream'
    return clean, 'data:%s;base64,%s'%(mime,base64.b64encode(p.read_bytes()).decode('ascii'))

async def inline_images(page):
    imgs=await page.evaluate("() => Array.from(document.images).map((img,i)=>({i,src:img.getAttribute('src')||''}))")
    for im in imgs[:240]:
        d=data_uri(im['src'])
        if not d: continue
        clean,uri=d
        await page.evaluate("({i,clean,uri})=>{const img=document.images[i]; if(img){img.dataset.qaOriginalSrc=clean; img.src=uri;}}", {'i':im['i'],'clean':clean,'uri':uri})
    await page.wait_for_timeout(140)

async def setup_pass_page(page, html, exercise):
    await page.set_content(html,wait_until='domcontentloaded')
    await page.wait_for_function("() => window.NEXTPoseMotionService && window.NEXTPoseMotionService.isReady && window.NEXTPoseMotionService.isReady()", timeout=5000)
    await page.evaluate("""(exercise) => {
      document.body.classList.add('exercise-active','motion-fallback-enabled');
      document.querySelectorAll('.screen').forEach(x=>x.classList.remove('active'));
      document.getElementById('exerciseScreen')?.classList.add('active');
      document.body.dataset.screen='exerciseScreen';
      const wrap=document.getElementById('videoWrap');
      const html=window.NEXTPoseMotionService.render({exercise}, {variant:'qa-runtime'});
      wrap.innerHTML='<div class="no-video exercise-visual-fallback next-pass-media-only has-pose-motion" data-media-only="pose-motion"><div class="exercise-visual-media next-pass-media-stage">'+html+'</div></div>';
      window.NEXTPoseMotionService.scanRuntimeVisuals?.(wrap);
      document.getElementById('exerciseName').textContent=exercise;
      document.getElementById('exerciseReps').textContent='QA · pose motion';
      document.getElementById('exerciseMeta').textContent='QA · RC18 Safari-safe motion fallback';
    }""", exercise)
    await inline_images(page)

async def render_view(browser, html, name, width, height):
    page=await browser.new_page(viewport={'width':width,'height':height})
    await setup_pass_page(page, html, 'Lateral Line Hops')
    await page.wait_for_timeout(120)
    before=await page.evaluate("""() => document.querySelector('#videoWrap .next-pose-runtime-img.is-runtime-active')?.dataset.poseKey || ''""")
    await page.wait_for_timeout(520)
    after=await page.evaluate("""() => document.querySelector('#videoWrap .next-pose-runtime-img.is-runtime-active')?.dataset.poseKey || ''""")
    metrics=await page.evaluate("""() => {
      const stage=document.querySelector('#videoWrap .next-pose-preview-stage');
      const srect=stage?.getBoundingClientRect();
      const visual=document.querySelector('#videoWrap .next-pose-motion-visual');
      const imgs=Array.from(document.querySelectorAll('#videoWrap .next-pose-runtime-img'));
      return {
        viewport:{width:window.innerWidth,height:window.innerHeight},
        hasService:!!window.NEXTPoseMotionService,
        serviceReady:!!window.NEXTPoseMotionService?.isReady?.(),
        serviceVersion:window.NEXTPoseMotionService?.version||'',
        scaleVersion:window.NEXTPoseScale?.version||'',
        hasMotion:!!visual,
        isJsDriven:visual?.classList.contains('is-js-driven')||false,
        mediaOnly:document.querySelector('#videoWrap .exercise-visual-fallback')?.dataset.mediaOnly||'',
        exerciseKey:visual?.dataset.exerciseKey||'',
        scaleMode:visual?.dataset.scaleMode||'',
        referencePx:visual?.dataset.referencePx||'',
        loopMs:visual?.dataset.loopMs||'',
        playhead:!!document.querySelector('#videoWrap .next-pose-motion-playhead'),
        playheadProgress:document.querySelector('#videoWrap .next-pose-motion-playhead')?.dataset.progress||'',
        activeCount:document.querySelectorAll('#videoWrap .next-pose-runtime-img.is-runtime-active').length,
        firstAnimation:imgs[0]?getComputedStyle(imgs[0]).animationName:'',
        poseDelays:imgs.map(img=>img.style.getPropertyValue('--pose-delay')),
        imgCount:imgs.length,
        slotGuides:document.querySelectorAll('#videoWrap .next-pose-slot-guide').length,
        backgroundSrc:document.querySelector('#videoWrap .next-exercise-visual-bg')?.dataset.qaOriginalSrc || '',
        floorY:getComputedStyle(stage).getPropertyValue('--pose-floor-y').trim(),
        boxes:imgs.map(img=>{const r=img.getBoundingClientRect();return {key:img.dataset.poseKey,kind:img.dataset.poseKind,w:Math.round(r.width),h:Math.round(r.height),bottom:Math.round(r.bottom-(srect?.top||0))};}),
        brokenImages:Array.from(document.images).filter(img=>img.naturalWidth===0&&!String(img.src||'').startsWith('http')).map(img=>img.dataset.qaOriginalSrc||img.getAttribute('src'))
      };
    }""")
    metrics['activeBefore']=before
    metrics['activeAfter']=after
    await page.screenshot(path=str(OUT/f'rc18_pose_motion_runtime_safari_scale_{name}_{width}x{height}.png'),full_page=False)
    await page.close()
    return metrics

async def render_scale_matrix(browser, html):
    page=await browser.new_page(viewport={'width':1440,'height':1024})
    await page.set_content(html,wait_until='domcontentloaded')
    await page.wait_for_function("() => window.NEXTPoseMotionService && window.NEXTPoseMotionService.isReady && window.NEXTPoseMotionService.isReady()", timeout=5000)
    exercises=['Burpee','Chin Up','Plank','Single Arm Row','Goblet Squat','Side Plank Hip Lift']
    await page.evaluate("""(exercises) => {
      document.body.classList.add('exercise-active','motion-fallback-enabled');
      document.querySelectorAll('.screen').forEach(x=>x.classList.remove('active'));
      document.getElementById('exerciseScreen')?.classList.add('active');
      document.body.dataset.screen='exerciseScreen';
      const wrap=document.getElementById('videoWrap');
      wrap.innerHTML='<div class="next-scale-qa-grid">'+exercises.map(exercise=>{
        const visual=window.NEXTPoseMotionService.render({exercise},{variant:'scale-qa'});
        return '<article class="next-scale-qa-card"><h3>'+exercise+'</h3><div class="next-pass-media-stage">'+visual+'</div></article>';
      }).join('')+'</div>';
      window.NEXTPoseMotionService.scanRuntimeVisuals?.(wrap);
      const style=document.createElement('style');
      style.textContent='.next-scale-qa-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:12px;background:#050b17}.next-scale-qa-card{border:1px solid rgba(99,210,255,.25);border-radius:20px;background:#07162b;padding:10px}.next-scale-qa-card h3{margin:0 0 6px;color:#eaf8ff;font:800 15px system-ui}.next-scale-qa-card .next-pass-media-stage{height:220px;position:relative}.next-scale-qa-card .next-pose-motion-visual,.next-scale-qa-card .next-pose-preview-stage{height:100%!important;min-height:0!important;border-radius:16px!important}';
      document.head.appendChild(style);
    }""", exercises)
    await inline_images(page)
    await page.wait_for_timeout(360)
    metrics=await page.evaluate("""() => Array.from(document.querySelectorAll('.next-scale-qa-card')).map(card=>{
      const visual=card.querySelector('.next-pose-motion-visual');
      const imgs=Array.from(card.querySelectorAll('.next-pose-runtime-img'));
      return {
        title:card.querySelector('h3')?.textContent||'',
        exerciseKey:visual?.dataset.exerciseKey||'',
        scaleMode:visual?.dataset.scaleMode||'',
        referencePx:Number(visual?.dataset.referencePx||0),
        boxes:imgs.map(img=>{const r=img.getBoundingClientRect();return {key:img.dataset.poseKey,kind:img.dataset.poseKind,w:Math.round(r.width),h:Math.round(r.height)}})
      };
    })""")
    await page.screenshot(path=str(OUT/'rc18_pose_scale_matrix_desktop_1440x1024.png'),full_page=False)
    await page.close()
    return metrics

async def main():
    from playwright.async_api import async_playwright
    html=inline_preview()
    async with async_playwright() as p:
        browser=await p.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage','--disable-gpu','--disable-gpu-sandbox','--use-gl=swiftshader','--enable-unsafe-swiftshader'])
        metrics_all={}
        for name,w,h in [('mobile',390,844),('ipad',820,1180),('desktop',1440,1024)]:
            metrics_all[name]=await render_view(browser,html,name,w,h)
        metrics_all['scaleMatrix']=await render_scale_matrix(browser,html)
        await browser.close()
    (OUT/'rc18_pose_motion_runtime_safari_scale_dom_metrics.json').write_text(json.dumps(metrics_all,indent=2,ensure_ascii=False),encoding='utf-8')
    errors=[]
    for name,metrics in metrics_all.items():
      if name=='scaleMatrix': continue
      if not metrics.get('serviceReady'): errors.append(f'{name}: service not ready')
      if not metrics.get('hasMotion'): errors.append(f'{name}: motion visual missing')
      if metrics.get('mediaOnly')!='pose-motion': errors.append(f'{name}: mediaOnly not pose-motion')
      if 'rc18' not in metrics.get('serviceVersion',''): errors.append(f'{name}: service version not RC18')
      if 'rc18' not in metrics.get('scaleVersion',''): errors.append(f'{name}: scale helper version not RC18')
      if metrics.get('exerciseKey')!='lateral-line-hops': errors.append(f'{name}: exercise key mismatch')
      if metrics.get('scaleMode')!='sequence-upright': errors.append(f'{name}: sequence-upright scale missing')
      if not metrics.get('isJsDriven'): errors.append(f'{name}: JS runtime driver missing')
      if metrics.get('activeCount')!=1: errors.append(f'{name}: active pose count is not 1')
      if metrics.get('activeBefore')==metrics.get('activeAfter'): errors.append(f'{name}: active pose did not advance')
      if not metrics.get('playhead'): errors.append(f'{name}: playhead missing')
      if not metrics.get('playheadProgress'): errors.append(f'{name}: playhead progress missing')
      if not all(metrics.get('poseDelays') or []): errors.append(f'{name}: pose delays missing')
      if metrics.get('imgCount',0)<4: errors.append(f'{name}: too few pose runtime images')
      if metrics.get('slotGuides',0)<5: errors.append(f'{name}: slot guides missing')
      if '-real.webp' not in metrics.get('backgroundSrc',''): errors.append(f'{name}: real background missing')
      if metrics.get('brokenImages'): errors.append(f'{name}: broken images '+str(metrics['brokenImages'][:5]))
    upright_heights=[]
    floor_heights=[]
    for item in metrics_all.get('scaleMatrix',[]):
      for b in item.get('boxes',[]):
        if b.get('kind')=='upright': upright_heights.append(b.get('h',0))
        if b.get('kind')=='floor': floor_heights.append(b.get('h',0))
    if upright_heights and (max(upright_heights)-min(upright_heights)>70):
      errors.append(f'upright scale spread too wide: {min(upright_heights)}-{max(upright_heights)}')
    if floor_heights and max(floor_heights)>135:
      errors.append(f'floor poses too tall in scale matrix: {max(floor_heights)}')
    if errors: raise SystemExit('\n'.join(errors))
    print('RC18 pose motion runtime Safari/scale real-DOM QA OK')
if __name__=='__main__': asyncio.run(main())
