#!/usr/bin/env python3
"""RC117 pass-list JS fill real-DOM QA.

Uses actual preview.html + actual CSS/JS via page.set_content().
RC117 specifically verifies that the active pass-list fill is driven by inline
width from JS instead of a CSS keyframe, because iPhone/Safari did not reliably
show the CSS animation. Also verifies reduced-motion no longer forces the active
fill to 100% immediately.
"""
from __future__ import annotations
import asyncio, base64, json, mimetypes, os, 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 RC117 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):
    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, *, reduce_motion: bool=False):
    ctx = await browser.new_context(viewport={"width": width, "height": height}, device_scale_factor=1)
    async def _route(route):
        url = route.request.url
        if url.startswith(("http://", "https://")):
            await route.abort()
        else:
            await route.continue_()
    await ctx.route("**/*", _route)
    page = await ctx.new_page()
    page.set_default_timeout(12000)
    if reduce_motion:
        await page.emulate_media(reduced_motion="reduce")
    await page.set_content(html, wait_until="domcontentloaded")
    return ctx, page

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

async def render_rest_with_list(page, *, current=4, seconds=15, wait_ms=900):
    await page.evaluate("""
      ({ current, seconds }) => {
        state.stage='Stage 2'; state.phase='Phase 1'; state.week='1'; state.day='Day 1';
        state.projectorNoYoutube=false; state.playlistVisible=true; state.tvMode=false; state.menuOpen=false;
        if(typeof startWorkout === 'function') startWorkout();
        workoutPrimed = true; state.current = current; state.playlistVisible=true;
        if(typeof renderRest === 'function') renderRest(seconds, current+1);
        if(typeof renderControls === 'function') renderControls();
      }
    """, {"current": current, "seconds": seconds})
    await page.wait_for_timeout(wait_ms)
    await inline_dom_images(page)

async def list_metrics(page):
    return await page.evaluate("""
      () => {
        const rect = el => el ? JSON.parse(JSON.stringify(el.getBoundingClientRect())) : null;
        const style = el => el ? getComputedStyle(el) : null;
        const list = document.getElementById('miniList');
        const items = Array.from(list?.querySelectorAll('.next-pass-mini-item') || []);
        const active = list?.querySelector('.next-pass-mini-item[data-active="true"]');
        const first = items[0];
        const fill = active?.querySelector('.next-pass-mini-fill');
        const activeRect = active?.getBoundingClientRect();
        const fillRect = fill?.getBoundingClientRect();
        const fillStyle = style(fill);
        const activeStyle = style(active);
        const ratio = activeRect && activeRect.width ? (fillRect.width / activeRect.width) : 0;
        return {
          viewport:{width:innerWidth,height:innerHeight},
          reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
          bodyPlaylistVisible: document.body.classList.contains('playlist-visible'),
          itemCount: items.length,
          firstKey: first?.dataset?.miniKey || '',
          activeKey: active?.dataset?.miniKey || '',
          activeIsFirst: !!first && !!active && first === active,
          activeGridColumn: activeStyle?.gridColumn || '',
          activeFillMs: Number(active?.dataset?.fillMs || 0),
          activeClasses: active?.className || '',
          fillInlineWidth: fill?.style?.width || '',
          fillComputedWidth: fillStyle?.width || '',
          fillAnimationName: fillStyle?.animationName || '',
          fillRatio: ratio,
          cssProgressVar: activeStyle?.getPropertyValue('--mini-fill-progress').trim() || '',
          activeTopCloseToListTop: active && list ? Math.abs(active.getBoundingClientRect().top - list.getBoundingClientRect().top) <= 4 : false,
          firstThree: items.slice(0,3).map(el=>({key:el.dataset.miniKey, text:el.textContent.trim(), fill:Number(el.dataset.fillMs||0), cls:el.className})),
          catalogBurpeeSeconds: window.NEXTExerciseCatalog?.estimateSecondsForPrescription?.({exercise:'Burpee',setWork:'3 reps'}) || null,
          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 capture(page, name, w, h, state):
    out = OUT / f"rc117_pass_list_{state}_{name}_{w}x{h}.png"
    await page.screenshot(path=str(out), full_page=(name == 'mobile'))
    return f"qa_screenshots/{out.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:
                ctx,page=await setup_page(browser,html,w,h)
                try:
                    await render_video_with_list(page,current=4,wait_ms=900)
                    m=await list_metrics(page)
                    m['state']='video'
                    m['screenshot']=await capture(page,name,w,h,'video')
                    results.append(m)
                finally:
                    await ctx.close()
            ctx,page=await setup_page(browser,html,1440,1024)
            try:
                await render_rest_with_list(page,current=4,seconds=15,wait_ms=900)
                m=await list_metrics(page)
                m['state']='rest'
                m['screenshot']=await capture(page,'desktop',1440,1024,'rest')
                results.append(m)
            finally:
                await ctx.close()
            # Simulate the iPhone/Safari failure-prone condition: reduced motion.
            ctx,page=await setup_page(browser,html,390,844,reduce_motion=True)
            try:
                await render_video_with_list(page,current=4,wait_ms=700)
                m=await list_metrics(page)
                m['state']='video-reduced-motion'
                m['screenshot']=await capture(page,'mobile_reduced_motion',390,844,'video')
                results.append(m)
            finally:
                await ctx.close()
        finally:
            await browser.close()
    errors=[]
    for m in results:
        label=f"{m['state']} {m['viewport']['width']}x{m['viewport']['height']} {m.get('screenshot','')}"
        if m.get('brokenImages'):
            errors.append(label+f": broken images {m['brokenImages']}")
        if not m.get('bodyPlaylistVisible'):
            errors.append(label+": playlist not visible")
        if m.get('itemCount',0) < 8:
            errors.append(label+f": too few mini items {m.get('itemCount')}")
        if not m.get('activeIsFirst'):
            errors.append(label+f": active item is not first; first={m.get('firstKey')} active={m.get('activeKey')}")
        if not m.get('activeTopCloseToListTop'):
            errors.append(label+": active item is not aligned to list top")
        if m['viewport']['width'] >= 900 and 'span' not in str(m.get('activeGridColumn','')) and '-1' not in str(m.get('activeGridColumn','')):
            errors.append(label+f": active item does not span desktop row ({m.get('activeGridColumn')})")
        if m.get('activeFillMs',0) <= 0:
            errors.append(label+": active fill duration missing")
        if m['state']=='rest' and abs(m.get('activeFillMs',0)-15000) > 1:
            errors.append(label+f": rest fill ms should be 15000, got {m.get('activeFillMs')}")
        if m.get('fillAnimationName') not in ('none', ''):
            errors.append(label+f": fill should not depend on CSS animation, got {m.get('fillAnimationName')}")
        if not str(m.get('fillInlineWidth','')).endswith('%'):
            errors.append(label+f": active fill has no inline percent width ({m.get('fillInlineWidth')})")
        if not (0.005 < float(m.get('fillRatio') or 0) < 0.90):
            errors.append(label+f": active fill ratio not plausible after wait ({m.get('fillRatio')})")
        if m.get('reducedMotion') and float(m.get('fillRatio') or 0) > 0.90:
            errors.append(label+": reduced-motion forced fill to 100%")
        if m.get('catalogBurpeeSeconds') != 6:
            errors.append(label+f": catalog burpee timing broken {m.get('catalogBurpeeSeconds')}")
    payload={"ok":not errors,"method":"real preview DOM via page.set_content, JS-driven mini fill, reduced-motion simulated, external network aborted","results":results,"errors":errors}
    (OUT/'rc117_pass_list_js_fill_dom_metrics.json').write_text(json.dumps(payload,ensure_ascii=False,indent=2),encoding='utf-8')
    if errors:
        raise SystemExit('\n'.join(errors))
    print(json.dumps({"ok":True,"screenshots":[m['screenshot'] for m in results]},ensure_ascii=False))

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