#!/usr/bin/env python3
"""RC116 pass-list real DOM QA.

Uses actual preview.html + actual CSS/JS via page.set_content().
Verifies the pass list is standardized against the pass-player renderer contract:
active item renders first/top, active row has fill animation duration, rest rows use
actual rest seconds, exercise rows use central master exercise timing estimates,
and the list uses NEXT token-based classes/CSS.
"""
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 RC116 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(120)

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)
    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)
    await page.set_content(html, wait_until="domcontentloaded")
    return ctx, page

async def render_video_with_list(page, *, current=4):
    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(900)
    await inline_dom_images(page)

async def render_rest_with_list(page, *, current=5, seconds=45):
    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(700)
    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 activeStyle = style(active);
        const fill = active?.querySelector('.next-pass-mini-fill');
        const fillStyle = style(fill);
        const done = list?.querySelector('.next-pass-mini-item.is-done:not(.is-active) .next-pass-mini-fill, .next-pass-mini-item.done:not(.active) .next-pass-mini-fill');
        return {
          viewport:{width:innerWidth,height:innerHeight},
          bodyPlaylistVisible: document.body.classList.contains('playlist-visible'),
          listRect: rect(list),
          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 || '',
          activeFillAnimation: fillStyle?.animationName || '',
          activeFillDuration: fillStyle?.animationDuration || '',
          activeTopCloseToListTop: active && list ? Math.abs(active.getBoundingClientRect().top - list.getBoundingClientRect().top) <= 4 : false,
          doneFillWidth: style(done)?.width || '',
          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,
          usesRendererClass: !!active?.classList.contains('next-pass-mini-item'),
          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"rc116_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)
                    m=await list_metrics(page)
                    m['state']='video'
                    m['screenshot']=await capture(page,name,w,h,'video')
                    results.append(m)
                finally:
                    await ctx.close()
            # Dedicated rest-state check on desktop because rest duration is exact and easy to validate.
            ctx,page=await setup_page(browser,html,1440,1024)
            try:
                await render_rest_with_list(page,current=4,seconds=15)
                m=await list_metrics(page)
                m['state']='rest'
                m['screenshot']=await capture(page,'desktop',1440,1024,'rest')
                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['state']=='video' and m.get('activeFillMs') == 10000:
            errors.append(label+": exercise fill still uses old fixed 10s fallback")
        if m.get('catalogBurpeeSeconds') != 6:
            errors.append(label+f": catalog burpee timing broken {m.get('catalogBurpeeSeconds')}")
        if not m.get('usesRendererClass'):
            errors.append(label+": renderer next-pass mini class missing")
    payload={"ok":not errors,"method":"real preview DOM via page.set_content, actual CSS/JS, external network aborted","results":results,"errors":errors}
    (OUT/'rc116_pass_list_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)
