#!/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[:180]:
        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(120)

async def render_view(browser, html, name, width, height):
    page=await browser.new_page(viewport={'width':width,'height':height})
    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("""() => {
      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:'Lateral Line Hops'}, {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>';
      document.getElementById('exerciseName').textContent='Lateral Line Hops';
      document.getElementById('exerciseReps').textContent='2 × 10 hopp';
      document.getElementById('exerciseMeta').textContent='QA · RC17 motion fallback';
    }""")
    await inline_images(page)
    await page.wait_for_timeout(250)
    metrics=await page.evaluate("""() => {
      const stage=document.querySelector('#videoWrap .next-pose-preview-stage');
      const srect=stage?.getBoundingClientRect();
      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?.(),
        hasMotion:!!document.querySelector('#videoWrap .next-pose-motion-visual'),
        isJsDriven:document.querySelector('#videoWrap .next-pose-motion-visual')?.classList.contains('is-js-driven')||false,
        serviceVersion:window.NEXTPoseMotionService?.version||'',
        mediaOnly:document.querySelector('#videoWrap .exercise-visual-fallback')?.dataset.mediaOnly||'',
        exerciseKey:document.querySelector('#videoWrap .next-pose-motion-visual')?.dataset.exerciseKey||'',
        loopMs:document.querySelector('#videoWrap .next-pose-motion-visual')?.dataset.loopMs||'',
        playhead:!!document.querySelector('#videoWrap .next-pose-motion-playhead'),
        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,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'))
      };
    }""")
    await page.screenshot(path=str(OUT/f'rc17_pose_motion_runtime_polish_{name}_{width}x{height}.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)
        await browser.close()
    (OUT/'rc17_pose_motion_runtime_polish_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 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 not any(v in metrics.get('serviceVersion','') for v in ['rc17','rc18']): errors.append(f'{name}: service version not RC17/RC18')
      if metrics.get('exerciseKey')!='lateral-line-hops': errors.append(f'{name}: exercise key mismatch')
      if not metrics.get('playhead'): errors.append(f'{name}: playhead missing')
      if metrics.get('firstAnimation')=='none' and not metrics.get('isJsDriven'): errors.append(f'{name}: runtime animation 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]))
    if errors: raise SystemExit('\n'.join(errors))
    print('RC17 pose motion runtime polish real-DOM QA OK')
if __name__=='__main__': asyncio.run(main())
