#!/usr/bin/env python3
"""RC120/v97 layered exercise visual real-DOM QA.

Verifies the new exercise visual contract:
- no giant exercise-illustrations.js manifest loaded
- visual uses two image layers in the same container: theme background + transparent/default figure
- SVG fallback catalogue and premium direct-composite paths are not used by runtime DOM
- library and pass player both use the same visual component classes
"""
from __future__ import annotations
import asyncio, base64, json, mimetypes, re
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "qa_screenshots"
VIEWPORTS = [("mobile",390,844),("ipad",820,1180),("desktop",1440,1024)]

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

def build_inlined_preview() -> str:
    html=(ROOT/"preview.html").read_text(encoding="utf-8")
    base=f'<base href="file://{ROOT.as_posix()}/">'
    qa_head="""
<script>
(function(){
  try{ history.replaceState=function(){}; history.pushState=function(){}; }catch(e){}
  const nativeFetch = window.fetch ? window.fetch.bind(window) : null;
  window.fetch=function(url, options){
    const s=String(url||'');
    if(s.includes('ota_youtube_proxy.php')){
      return Promise.resolve({ok:true,json:function(){return Promise.resolve({embedUrl:'https://www.youtube.com/embed/TESTID12345?rel=0&modestbranding=1&enablejsapi=1',watchUrl:'https://www.youtube.com/watch?v=TESTID12345',youtubeId:'TESTID12345'});}});
    }
    if(s.includes('video_backup_api.php')){
      return Promise.resolve({ok:true,json:function(){return Promise.resolve({status:'missing',exists:false});}});
    }
    return nativeFetch ? nativeFetch(url, options) : Promise.reject(new Error('fetch disabled in RC120 QA'));
  };
})();
</script>
"""
    html=html.replace("<head>", f"<head>{base}\n{qa_head}", 1)
    html=re.sub(r'<link\s+([^>]*rel=["\']stylesheet["\'][^>]*)>', lambda m: f"<style data-inline-src=\"{re.search(r'href=[\"\']([^\"\']+)[\"\']', m.group(1)).group(1)}\">\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 data-inline-src=\"{re.search(r'src=[\"\']([^\"\']+)[\"\']', m.group(1)).group(1)}\">\n{_read_asset(re.search(r'src=[\"\']([^\"\']+)[\"\']', m.group(1)).group(1))}\n</script>", html)
    return html

def as_data_uri(src: str):
    if not src or src.startswith(("data:","http://","https://","blob:")):
        return None
    clean=src.split("?",1)[0].split("#",1)[0]
    path=Path(clean[7:]) if clean.startswith("file://") else ROOT / clean.lstrip("/")
    if not path.exists() or not path.is_file():
        return None
    mime=mimetypes.guess_type(path.name)[0] or "application/octet-stream"
    return clean, f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"

async def inline_dom_images(page):
    await page.wait_for_timeout(250)
    imgs=await page.evaluate("""() => Array.from(document.images).map((img,i)=>({i,src:img.getAttribute('src')||''}))""")
    for img in imgs:
        converted=as_data_uri(img["src"])
        if not converted: continue
        original, data_uri=converted
        await page.evaluate("""
          ({index,original,dataUri})=>{
            const img=document.images[index]; if(!img) return;
            img.setAttribute('data-qa-original-src', original);
            img.src=dataUri;
          }
        """, {"index":img["i"],"original":original,"dataUri":data_uri})
    await page.wait_for_timeout(100)

async def setup_page(browser, html: str, width:int, height:int):
    ctx=await browser.new_context(viewport={"width":width,"height":height}, device_scale_factor=1)
    page=await ctx.new_page(); page.set_default_timeout(12000)
    await page.set_content(html, wait_until="domcontentloaded")
    return ctx,page

async def open_library_visual(page):
    await page.evaluate("""
      () => {
        if(typeof showQa === 'function') showQa();
        if(typeof renderLibraryList === 'function') renderLibraryList();
        if(typeof showExerciseDetail === 'function') showExerciseDetail(0);
      }
    """)
    await page.wait_for_timeout(500)
    await page.evaluate("() => { document.querySelector('.next-library-detail-inline')?.scrollIntoView({block:'center',inline:'nearest'}); }")
    await page.wait_for_timeout(150)
    await inline_dom_images(page)

async def open_pass_image_visual(page):
    await page.evaluate("""
      () => {
        state.stage='Stage 2'; state.phase='Phase 1'; state.week='1'; state.day='Day 1';
        state.projectorNoYoutube=true; state.playlistVisible=true; state.tvMode=false; state.liveMode=false; state.menuOpen=false;
        if(typeof startWorkout === 'function') startWorkout();
        workoutPrimed=true; state.current=1; state.inRest=false; state.restNextIndex=null;
        if(typeof renderStep === 'function') renderStep();
        if(typeof renderControls === 'function') renderControls();
      }
    """)
    await page.wait_for_timeout(700)
    await inline_dom_images(page)

async def metrics(page):
    return await page.evaluate("""
      () => {
        const rect = el => el ? JSON.parse(JSON.stringify(el.getBoundingClientRect())) : null;
        const visual = document.querySelector('.next-library-visual-panel .next-exercise-visual, #videoWrap .next-exercise-visual, .next-exercise-visual');
        const stage = visual?.querySelector('.next-exercise-visual-stage');
        const bg = visual?.querySelector('.next-exercise-visual-bg');
        const fig = visual?.querySelector('.next-exercise-visual-figure');
        const media = document.querySelector('.next-pass-media-card');
        const info = document.querySelector('.next-pass-info-card');
        const rStage=rect(stage), rBg=rect(bg), rFig=rect(fig), rMedia=rect(media), rInfo=rect(info);
        const eps=1.5;
        const html = document.documentElement.innerHTML;
        return {
          hasVisual:!!visual,
          hasStage:!!stage,
          hasBg:!!bg,
          hasFigure:!!fig,
          visualMode:visual?.dataset.backgroundMode || '',
          figureSrc:fig?.getAttribute('data-qa-original-src') || fig?.getAttribute('src') || '',
          bgSrc:bg?.getAttribute('data-qa-original-src') || bg?.getAttribute('src') || '',
          figureFallback:fig?.dataset.figureFallback || '',
          stageRect:rStage, bgRect:rBg, figureRect:rFig, mediaRect:rMedia, infoRect:rInfo,
          bgAndFigureShareStage:!!(stage && bg?.parentElement===stage && fig?.parentElement===stage),
          figureInsideStage:!!(rStage && rFig && rFig.left >= rStage.left-eps && rFig.right <= rStage.right+eps && rFig.top >= rStage.top-eps && rFig.bottom <= rStage.bottom+eps),
          imageOverlapsInfo:!!(rFig && rInfo && !(rFig.right <= rInfo.left+eps || rFig.left >= rInfo.right-eps || rFig.bottom <= rInfo.top+eps || rFig.top >= rInfo.bottom-eps)),
          loadedManifest:!!window.NEXTExerciseIllustrations,
          serviceVersion:window.NEXTExerciseIllustrationService?.version || '',
          previewHasManifestScript:!!document.querySelector('script[data-inline-src*="exercise-illustrations.js"]'),
          runtimeLegacyTokens:/fallback\/svg|premium\/s2p1|NEXTExerciseIllustrations/.test(html),
          brokenImages:Array.from(document.images).filter(img => img.naturalWidth===0 && !String(img.src||'').startsWith('http')).map(img => img.getAttribute('data-qa-original-src') || img.getAttribute('src'))
        };
      }
    """)

async def screenshot(page, name):
    out=OUT/name
    await page.screenshot(path=str(out), full_page=False, timeout=12000)
    return f"qa_screenshots/{name}"

async def main():
    OUT.mkdir(exist_ok=True)
    html=build_inlined_preview()
    results={}
    from playwright.async_api import async_playwright
    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"])
        try:
            for name,w,h in VIEWPORTS:
                print(f"RC120 QA library {name}", flush=True)
                ctx,page=await setup_page(browser, html, w, h)
                try:
                    await open_library_visual(page)
                    results[f"library_{name}"]=await metrics(page)
                    results[f"library_{name}"]["screenshot"]=await screenshot(page, f"rc120_layered_visual_library_{name}_{w}x{h}.png")
                finally:
                    await ctx.close()
                print(f"RC120 QA pass {name}", flush=True)
                ctx,page=await setup_page(browser, html, w, h)
                try:
                    await open_pass_image_visual(page)
                    results[f"pass_{name}"]=await metrics(page)
                    results[f"pass_{name}"]["screenshot"]=await screenshot(page, f"rc120_layered_visual_pass_{name}_{w}x{h}.png")
                finally:
                    await ctx.close()
        finally:
            await browser.close()
    errors=[]
    for key,m in results.items():
        if not m['hasVisual'] or not m['hasStage'] or not m['hasBg'] or not m['hasFigure']:
            errors.append(f"{key}: layered visual missing {m}")
        if m['visualMode'] != 'theme-layered-figure':
            errors.append(f"{key}: wrong visual mode {m['visualMode']}")
        if not m['bgAndFigureShareStage']:
            errors.append(f"{key}: bg/figure do not share stage")
        if not m['figureInsideStage']:
            errors.append(f"{key}: figure outside stage")
        if key.startswith('pass_') and m['imageOverlapsInfo']:
            errors.append(f"{key}: pass figure overlaps info-card")
        if m['loadedManifest'] or m['previewHasManifestScript'] or m['runtimeLegacyTokens']:
            errors.append(f"{key}: legacy manifest/path token active")
        if 'fallback/svg' in m['figureSrc'] or 'premium/s2p1' in m['figureSrc']:
            errors.append(f"{key}: legacy figure src {m['figureSrc']}")
        if 'themes/exercise-backgrounds' not in m['bgSrc']:
            errors.append(f"{key}: theme background not used {m['bgSrc']}")
        # Missing per-exercise figures should fall back to _default.webp through onerror.
        if '_default.webp' not in (m.get('figureSrc') or ''):
            errors.append(f"{key}: default figure fallback not active yet {m.get('figureSrc')}")
        if m['brokenImages']:
            errors.append(f"{key}: broken local images {m['brokenImages']}")
    (OUT/'rc120_layered_visual_dom_metrics.json').write_text(json.dumps(results,indent=2,ensure_ascii=False),encoding='utf-8')
    if errors:
        raise SystemExit("RC120 layered visual real-DOM QA failed:\n" + "\n".join(errors))
    print('RC120 layered exercise visual real-DOM QA OK')

if __name__ == '__main__':
    asyncio.run(main())
