#!/usr/bin/env python3
"""ARRFLIX prod-vs-dev playback divergence test (2026-05-09).

Runs the SAME flow against arrflix.s8n.ru (prod) and dev.arrflix.s8n.ru (dev)
for the same physical file (Mike Nolan Show S01E04 — Ding Dong Delli.mkv,
H.264+AAC) and produces a side-by-side diff:

  - URL of master.m3u8 / Videos/{id}/stream
  - PlaybackInfo response MediaSources[0] (DirectPlay/DirectStream/Transcode)
  - Final <video> element state at t=5/10/20/30s after Play
  - Server ffmpeg cmdline (if transcoding) from docker logs
  - HTTP status of all /Videos /Items /master.m3u8 /PlaybackInfo /Audio
    /stream requests

Artifacts: /tmp/arrflix-prod-vs-dev/{prod,dev}/{...} + diff.json + diff.md.

Run:
  bin/prod-vs-dev-compare.py
"""
import sys, os, json, time, asyncio, ssl, urllib.request, urllib.error, urllib.parse, subprocess, re
from pathlib import Path
from playwright.async_api import async_playwright

OUT = "/tmp/arrflix-prod-vs-dev"
os.makedirs(OUT, exist_ok=True)

SIDES = [
    {"side": "prod", "url": "https://arrflix.s8n.ru",     "user": "s8n",  "pw": "2001dude",
     "container": "jellyfin"},
    {"side": "dev",  "url": "https://dev.arrflix.s8n.ru", "user": "test", "pw": "2001dude",
     "container": "jellyfin-dev"},
]

ITEM_ID = "9312799ca24979bd05aad9733ce7ee14"   # MNS S01E04 (same on both sides)
ITEM_LABEL = "Mike Nolan Show — S01E04 (Ding Dong Delli)"

DEVICE_ID = "prodvsdev-2026-05-09"
CLIENT = "ProdVsDev"
APIKEY_NAME = "arrflix-prodvsdev-2026-05-09"

CTX = ssl._create_unverified_context()


# ------------------- HTTP helpers -------------------

def auth_h(token=None):
    h = (f'MediaBrowser Client="{CLIENT}", Device="cli", DeviceId="{DEVICE_ID}", '
         f'Version="1.0"')
    if token:
        h += f', Token="{token}"'
    return h


def http(url, path, method="GET", body=None, token=None):
    data = json.dumps(body).encode() if body is not None else None
    headers = {
        "Authorization": auth_h(token),
        "Content-Type": "application/json",
    }
    req = urllib.request.Request(
        f"{url}{path}", data=data, headers=headers, method=method)
    raw = urllib.request.urlopen(req, context=CTX, timeout=20).read()
    return json.loads(raw) if raw else {}


def login(url, user, pw):
    last_err = None
    for attempt in range(3):
        try:
            return http(url, "/Users/AuthenticateByName", "POST",
                        {"Username": user, "Pw": pw})
        except urllib.error.HTTPError as e:
            last_err = e
            if e.code in (500, 503):
                time.sleep(3); continue
            raise
    raise last_err


def playbackinfo(url, item_id, user_id, token):
    """Mimic the web-client's /PlaybackInfo POST body for a generic browser."""
    body = {
        "DeviceProfile": {
            "MaxStreamingBitrate": 140000000,
            "MaxStaticBitrate": 100000000,
            "MusicStreamingTranscodingBitrate": 384000,
            "DirectPlayProfiles": [
                {"Container": "mp4,m4v", "Type": "Video",
                 "VideoCodec": "h264,hevc,vp9,av1",
                 "AudioCodec": "aac,mp3,ac3,eac3,opus,flac"},
                {"Container": "mkv", "Type": "Video",
                 "VideoCodec": "h264,hevc,vp9,av1",
                 "AudioCodec": "aac,mp3,ac3,eac3,opus,flac"},
                {"Container": "webm", "Type": "Video",
                 "VideoCodec": "vp9,av1", "AudioCodec": "opus,vorbis"},
            ],
            "TranscodingProfiles": [
                {"Container": "ts", "Type": "Video", "VideoCodec": "h264",
                 "AudioCodec": "aac", "Protocol": "hls", "Context": "Streaming",
                 "MaxAudioChannels": "2"},
                {"Container": "mp4", "Type": "Video", "VideoCodec": "h264",
                 "AudioCodec": "aac", "Context": "Static",
                 "MaxAudioChannels": "2"},
            ],
            "ContainerProfiles": [],
            "CodecProfiles": [],
            "SubtitleProfiles": [
                {"Format": "vtt", "Method": "External"},
                {"Format": "srt", "Method": "External"},
            ],
        },
        "AutoOpenLiveStream": True,
        "IsPlayback": True,
    }
    return http(url, f"/Items/{item_id}/PlaybackInfo?UserId={user_id}",
                "POST", body, token=token)


def make_apikey(url, token, name=APIKEY_NAME):
    """Issue an API key. Jellyfin only takes the name in query string."""
    try:
        http(url, f"/Auth/Keys?App={name}", "POST", token=token)
    except urllib.error.HTTPError:
        pass
    keys = http(url, "/Auth/Keys", token=token)
    for k in keys.get("Items", []):
        if k.get("AppName") == name:
            return k.get("AccessToken")
    return None


def del_apikey(url, token, name=APIKEY_NAME):
    try:
        keys = http(url, "/Auth/Keys", token=token)
        for k in keys.get("Items", []):
            if k.get("AppName") == name:
                http(url, f"/Auth/Keys/{k['AccessToken']}", "DELETE", token=token)
    except Exception as e:
        print(f"[!] del_apikey({name}): {e}")


# ------------------- Playwright run -------------------

async def run_side(p, side_cfg):
    side = side_cfg["side"]; url = side_cfg["url"]
    user = side_cfg["user"]; pw = side_cfg["pw"]
    side_dir = os.path.join(OUT, side)
    os.makedirs(side_dir, exist_ok=True)

    # API login
    auth = login(url, user, pw)
    token = auth["AccessToken"]; uid = auth["User"]["Id"]
    server_id = auth["ServerId"]
    is_admin = auth["User"].get("Policy", {}).get("IsAdministrator", False)
    print(f"\n=== {side} === user={user} uid={uid} admin={is_admin}")

    # API-side PlaybackInfo (independent of browser, for canonical record)
    pbi_api = playbackinfo(url, ITEM_ID, uid, token)
    with open(os.path.join(side_dir, "playbackinfo-api.json"), "w") as f:
        json.dump(pbi_api, f, indent=2)
    ms = pbi_api.get("MediaSources", [])
    if ms:
        m = ms[0]
        print(f"[{side}] PlaybackInfo (API): DirectPlay={m.get('SupportsDirectPlay')} "
              f"DirectStream={m.get('SupportsDirectStream')} "
              f"Transcoding={m.get('SupportsTranscoding')} "
              f"transcodeUrl={m.get('TranscodingUrl','-')[:80]}")

    # API key for this run (caller asked, even if not strictly needed here)
    apikey = make_apikey(url, token)
    print(f"[{side}] api key: {apikey[:8] if apikey else None}")

    # Browser pass
    browser = await p.chromium.launch(
        headless=True,
        args=["--no-sandbox", "--disable-dev-shm-usage",
              "--autoplay-policy=no-user-gesture-required",
              "--use-fake-ui-for-media-stream"])
    ctx = await browser.new_context(
        viewport={"width": 1600, "height": 900},
        ignore_https_errors=True)
    page = await ctx.new_page()

    requests, responses, console = [], [], []
    pbi_response_bodies = []

    def on_request(req):
        u = req.url
        if any(x in u for x in ["/Videos/", "/Items/", "/master.m3u8",
                                "/PlaybackInfo", "/Audio/", "/stream"]):
            requests.append({"method": req.method, "url": u,
                             "post": req.post_data[:300] if req.post_data else None})

    page.on("request", on_request)

    async def on_response(r):
        u = r.url
        if any(x in u for x in ["/Videos/", "/Items/", "/master.m3u8",
                                "/PlaybackInfo", "/Audio/", "/stream"]):
            entry = {"method": r.request.method, "url": u, "status": r.status}
            responses.append(entry)
            if "/PlaybackInfo" in u and r.request.method == "POST":
                try:
                    body = await r.json()
                    pbi_response_bodies.append({"url": u, "body": body})
                except Exception:
                    pass

    page.on("response", lambda r: asyncio.create_task(on_response(r)))
    page.on("console", lambda m: console.append({"type": m.type,
                                                 "text": m.text[:300]}))

    # Form login (handles both manual-form and user-avatar landing pages)
    await page.goto(f"{url}/web/", wait_until="networkidle", timeout=30000)
    await asyncio.sleep(3)
    # If we landed on the avatar/user-list selection screen, click "Manual Login"
    try:
        manual = await page.query_selector(".manualLoginForm a, .btnManual, a.button-link")
        if manual:
            txt = (await manual.inner_text()).strip().lower()
            if "manual" in txt:
                await manual.click()
                await asyncio.sleep(2)
        # Or there might be a direct "Manual Login" button on the avatar grid
        manual_btn = await page.query_selector("text=/Manual Login/i")
        if manual_btn:
            try:
                await manual_btn.click(timeout=2000); await asyncio.sleep(1)
            except Exception:
                pass
    except Exception as e:
        print(f"[{side}] manual-login click attempt: {e}")
    try:
        await page.wait_for_selector("input[type=password]", timeout=15000)
        # Use the canonical Jellyfin login fields
        u_sel = "#txtManualName"
        pw_sel = "#txtManualPassword"
        # Fall back to dynamic discovery if the canonical IDs are absent
        if not await page.query_selector(u_sel):
            inputs = await page.evaluate(
                "() => Array.from(document.querySelectorAll('input')).map(i => "
                "({id:i.id, name:i.name, type:i.type}))")
            u_sel = pw_sel = None
            for i in inputs:
                fid, fname, ftype = i.get("id", ""), i.get("name", ""), i.get("type", "")
                if not u_sel and (ftype == "text" or "user" in (fid+fname).lower()
                                  or "name" in (fid+fname).lower()):
                    u_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
                if not pw_sel and ftype == "password":
                    pw_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
        await page.fill(u_sel, user)
        await page.fill(pw_sel, pw)
        await page.keyboard.press("Enter")
        await page.wait_for_load_state("networkidle", timeout=20000)
        await asyncio.sleep(3)
        print(f"[{side}] form login OK as {user}")
    except Exception as e:
        print(f"[{side}] form login error: {e}")

    # Navigate to detail page
    target = f"{url}/web/#/details?id={ITEM_ID}&serverId={server_id}"
    print(f"[{side}] goto {target}")
    await page.goto(target, wait_until="networkidle", timeout=30000)
    await asyncio.sleep(4)
    await page.screenshot(path=os.path.join(side_dir, "detail.png"))

    # Click Play
    play_clicked = False
    used_sel = None
    for sel in [".btnPlay", "[data-action=\"play\"]"]:
        try:
            btn = await page.query_selector(sel)
            if btn:
                box = await btn.bounding_box()
                if box and box["width"] > 0:
                    await btn.click(timeout=5000)
                    play_clicked = True; used_sel = sel; break
        except Exception:
            pass
    if not play_clicked:
        try:
            await page.keyboard.press("p"); play_clicked = True; used_sel = "kbd:p"
        except Exception:
            pass
    print(f"[{side}] play clicked={play_clicked} via={used_sel}")

    # Sample state at t=5/10/20/30s
    timestamps = [5, 10, 20, 30]
    samples = []
    last = 0
    for t in timestamps:
        await asyncio.sleep(t - last)
        last = t
        snap = await page.evaluate("""() => {
            const v = document.querySelector('video');
            if (!v) return { present: false };
            // Sample whether the <video> is painting actual pixels by drawing
            // a thumbnail to a hidden canvas and checking the average luma.
            // If the average is ~0 (or all-near-zero), the video element is
            // rendering opaque black despite claiming to play.
            let paintLuma = null, paintRGBSum = null, paintOk = null, paintErr = null;
            try {
                const c = document.createElement('canvas');
                c.width = 32; c.height = 18;
                const ctx = c.getContext('2d', { willReadFrequently: true });
                ctx.drawImage(v, 0, 0, 32, 18);
                const d = ctx.getImageData(0, 0, 32, 18).data;
                let r=0,g=0,b=0,n=0;
                for (let i=0;i<d.length;i+=4){r+=d[i];g+=d[i+1];b+=d[i+2];n++;}
                paintLuma = (0.299*r + 0.587*g + 0.114*b) / n;
                paintRGBSum = (r+g+b)/n;
                paintOk = paintLuma > 4;  // > a few luma above pure black
            } catch (e) { paintErr = String(e); }
            // Stacking diagnosis: who's on top of the video center?
            const r = v.getBoundingClientRect();
            const cx = r.x + r.width/2, cy = r.y + r.height/2;
            let stackAtVideoCenter = [];
            try {
                const els = (typeof document.elementsFromPoint === 'function')
                    ? document.elementsFromPoint(cx, cy) : [];
                stackAtVideoCenter = els.slice(0, 6).map(e => {
                    const cs = getComputedStyle(e);
                    return {
                        tag: e.tagName.toLowerCase(),
                        id: e.id || null,
                        cls: (typeof e.className === 'string' ? e.className : '').slice(0, 80),
                        bg: cs.backgroundColor,
                        opacity: cs.opacity,
                        zIndex: cs.zIndex,
                        position: cs.position,
                        isVideo: e === v,
                    };
                });
            } catch (e) {}
            const osd = document.getElementById('videoOsdPage');
            const osdInfo = osd ? {
                bg: getComputedStyle(osd).backgroundColor,
                display: getComputedStyle(osd).display,
                opacity: getComputedStyle(osd).opacity,
                position: getComputedStyle(osd).position,
                zIndex: getComputedStyle(osd).zIndex,
                cls: osd.className,
                rect: osd.getBoundingClientRect().toJSON ? osd.getBoundingClientRect().toJSON() : null,
            } : null;
            return {
                present: true,
                src: v.src || '',
                currentSrc: v.currentSrc || '',
                currentTime: v.currentTime,
                duration: v.duration,
                paused: v.paused,
                ended: v.ended,
                readyState: v.readyState,
                networkState: v.networkState,
                error: v.error ? { code: v.error.code, message: v.error.message } : null,
                videoWidth: v.videoWidth,
                videoHeight: v.videoHeight,
                bufferedRanges: v.buffered.length,
                bufferedEnd: v.buffered.length ? v.buffered.end(v.buffered.length-1) : 0,
                paintLuma, paintRGBSum, paintOk, paintErr,
                stackAtVideoCenter,
                videoOsdPage: osdInfo,
            };
        }""")
        samples.append({"t": t, "video": snap})
        await page.screenshot(path=os.path.join(side_dir, f"play-t{t}.png"))
        ct = snap.get('currentTime')
        ct_s = f"{ct:.2f}" if isinstance(ct, (int, float)) else str(ct)
        pl = snap.get('paintLuma')
        pl_s = f"{pl:.1f}" if isinstance(pl, (int, float)) else str(pl)
        print(f"[{side}] t={t}s: time={ct_s} "
              f"paused={snap.get('paused')} err={snap.get('error')} "
              f"dim={snap.get('videoWidth')}x{snap.get('videoHeight')} "
              f"rs={snap.get('readyState')} paintLuma={pl_s} paintOk={snap.get('paintOk')}")

    # Final src URL fully decoded
    final_src = samples[-1]["video"].get("currentSrc") or samples[-1]["video"].get("src", "")
    final_src_decoded = urllib.parse.unquote(final_src) if final_src else ""

    await browser.close()

    # Server side ffmpeg / transcode log
    server_logs = ""
    try:
        server_logs = subprocess.check_output(
            ["ssh", "-o", "ConnectTimeout=5", "user@192.168.0.100",
             f"docker logs --since 2m {side_cfg['container']} 2>&1 | tail -300"],
            timeout=15).decode(errors="replace")
    except Exception as e:
        server_logs = f"(failed to fetch server logs: {e})"

    # Extract ffmpeg cmdline + transcode reasons from log
    ffmpeg_cmd = None
    for line in server_logs.splitlines():
        if "ffmpeg" in line.lower() and ("-i " in line or "-f hls" in line or "-c:v" in line):
            ffmpeg_cmd = line.strip()
            break
    transcode_reasons = []
    for line in server_logs.splitlines():
        if "transcode reason" in line.lower() or "TranscodeReasons" in line:
            transcode_reasons.append(line.strip())

    # Save artifacts
    side_out = {
        "side": side, "url": url, "user": user, "uid": uid, "is_admin": is_admin,
        "server_id": server_id, "item_id": ITEM_ID, "item_label": ITEM_LABEL,
        "play_clicked": play_clicked, "play_selector": used_sel,
        "samples": samples,
        "final_src": final_src,
        "final_src_decoded": final_src_decoded,
        "playbackinfo_api": pbi_api,
        "playbackinfo_browser_responses": pbi_response_bodies,
        "requests": requests,
        "responses": responses,
        "console": console[-200:],
        "ffmpeg_cmdline": ffmpeg_cmd,
        "transcode_reasons_log": transcode_reasons,
    }
    with open(os.path.join(side_dir, "result.json"), "w") as f:
        json.dump(side_out, f, indent=2, default=str)
    with open(os.path.join(side_dir, "server.log"), "w") as f:
        f.write(server_logs)

    # Cleanup the temp api key
    del_apikey(url, token)

    return side_out


# ------------------- Diff & report -------------------

def diff_results(prod, dev):
    """Build the comparison matrix."""
    def keyfields(pbi):
        ms = pbi.get("MediaSources", [])
        if not ms:
            return None
        m = ms[0]
        return {
            "Container": m.get("Container"),
            "Protocol": m.get("Protocol"),
            "SupportsDirectPlay": m.get("SupportsDirectPlay"),
            "SupportsDirectStream": m.get("SupportsDirectStream"),
            "SupportsTranscoding": m.get("SupportsTranscoding"),
            "TranscodingUrl": m.get("TranscodingUrl"),
            "TranscodingSubProtocol": m.get("TranscodingSubProtocol"),
            "TranscodingContainer": m.get("TranscodingContainer"),
            "TranscodeReasons": m.get("TranscodeReasons"),
            "Bitrate": m.get("Bitrate"),
            "Size": m.get("Size"),
            "Path": m.get("Path"),
        }
    p_pbi = keyfields(prod["playbackinfo_api"])
    d_pbi = keyfields(dev["playbackinfo_api"])
    last_p = prod["samples"][-1]["video"]
    last_d = dev["samples"][-1]["video"]

    out = {
        "item_id": ITEM_ID, "label": ITEM_LABEL,
        "prod_url": prod["url"], "dev_url": dev["url"],
        "playback_info_diff": {
            "prod": p_pbi, "dev": d_pbi,
            "differences": {
                k: {"prod": p_pbi.get(k), "dev": d_pbi.get(k)}
                for k in (set(p_pbi or {}) | set(d_pbi or {}))
                if (p_pbi or {}).get(k) != (d_pbi or {}).get(k)
            } if p_pbi and d_pbi else "missing-on-one-side",
        },
        "video_state_t30": {
            "prod": last_p,
            "dev": last_d,
            "differences": {
                k: {"prod": last_p.get(k), "dev": last_d.get(k)}
                for k in (set(last_p) | set(last_d))
                if last_p.get(k) != last_d.get(k)
            },
        },
        "stream_url_prod": prod.get("final_src_decoded"),
        "stream_url_dev": dev.get("final_src_decoded"),
        "ffmpeg_cmdline_prod": prod.get("ffmpeg_cmdline"),
        "ffmpeg_cmdline_dev": dev.get("ffmpeg_cmdline"),
        "transcode_reasons_log_prod": prod.get("transcode_reasons_log"),
        "transcode_reasons_log_dev": dev.get("transcode_reasons_log"),
        "http_status_diff": [],
    }

    # HTTP-status diff: for matched URL templates, show statuses where they differ.
    def normalise(u):
        # Strip /Videos/{id} → /Videos/* and quoting; keep last path segment
        u = re.sub(r"/Videos/[a-f0-9]{32}", "/Videos/*", u)
        u = re.sub(r"/Items/[a-f0-9]{32}", "/Items/*", u)
        u = re.sub(r"\?.*$", "", u)
        u = re.sub(r"^https?://[^/]+", "", u)
        return u

    def status_map(rs):
        out = {}
        for r in rs:
            k = (r["method"], normalise(r["url"]))
            out.setdefault(k, []).append(r["status"])
        return out

    sp = status_map(prod.get("responses", []))
    sd = status_map(dev.get("responses", []))
    keys = set(sp) | set(sd)
    for k in sorted(keys):
        if sp.get(k) != sd.get(k):
            out["http_status_diff"].append({
                "method": k[0], "path": k[1],
                "prod": sp.get(k), "dev": sd.get(k),
            })
    return out


def render_md(diff, prod, dev):
    pp = diff["playback_info_diff"].get("prod") or {}
    dp = diff["playback_info_diff"].get("dev") or {}
    last_p = diff["video_state_t30"]["prod"]
    last_d = diff["video_state_t30"]["dev"]

    def fmt_bool(x): return "Y" if x else ("N" if x is False else "—")

    def headline():
        # Three failure modes to recognise, in order:
        #   1. paused-at-zero  → MediaSource attach never fired
        #   2. <video>.error   → format/decode error
        #   3. paint-black     → video advances but renders no pixels (DRM-style
        #                        black, or codec-not-actually-decodable in this
        #                        chromium build despite advancing the clock)
        bp = bool(last_p.get("paused")) and (last_p.get("currentTime", 0) or 0) < 0.1
        bd = bool(last_d.get("paused")) and (last_d.get("currentTime", 0) or 0) < 0.1
        if bp and not bd:
            return ("prod fails because video stayed paused at t=0 while dev advanced")
        if bd and not bp:
            return ("dev fails because video stayed paused at t=0 while prod advanced")
        if last_p.get("error") and not last_d.get("error"):
            return f"prod fails because <video>.error code={last_p['error'].get('code')}"
        if last_d.get("error") and not last_p.get("error"):
            return f"dev fails because <video>.error code={last_d['error'].get('code')}"
        # Paint check
        pp_ok = last_p.get("paintOk"); dp_ok = last_d.get("paintOk")
        if pp_ok is False and dp_ok is True:
            return ("prod fails because <video> advances time but paints all-black "
                    "(paintLuma~0) while dev paints normally — pixels never reach the canvas")
        if dp_ok is False and pp_ok is True:
            return ("dev fails because <video> advances time but paints all-black "
                    "(paintLuma~0) while prod paints normally")
        # OSD overlay check
        def topel(s): return (s.get("stackAtVideoCenter") or [{}])[0]
        pt = topel(last_p); dt = topel(last_d)
        def is_opaque_black(bg):
            if not bg: return False
            try:
                nums = [int(x) for x in bg.replace("rgba(","").replace("rgb(","").replace(")","").split(",")[:3]]
                return sum(nums) < 30
            except Exception: return False
        if (not pt.get("isVideo")) and is_opaque_black(pt.get("bg")) \
           and (dt.get("isVideo") or not is_opaque_black(dt.get("bg"))):
            return (f"prod fails because an opaque-black `{pt.get('tag')}#{pt.get('id') or ''}"
                    f".{pt.get('cls')}` element is rendered on top of the <video> "
                    f"(bg={pt.get('bg')}); dev's video is uncovered")
        return "neither side errored or painted black explicitly — see HTTP/PlaybackInfo/cmdline diffs"

    md = []
    md.append(f"# Prod vs Dev — playback divergence test ({time.strftime('%Y-%m-%d %H:%M')})")
    md.append("")
    md.append(f"Item: **{diff['label']}** (ItemId `{diff['item_id']}`)")
    md.append("")
    md.append(f"**Headline:** {headline()}")
    md.append("")
    md.append("## Final video state at t=30s")
    md.append("| Field | prod | dev |")
    md.append("|---|---|---|")
    for k in ["present", "currentTime", "duration", "paused", "ended",
              "readyState", "networkState", "error",
              "videoWidth", "videoHeight", "bufferedRanges", "bufferedEnd",
              "paintLuma", "paintRGBSum", "paintOk"]:
        md.append(f"| {k} | `{last_p.get(k)}` | `{last_d.get(k)}` |")
    # OSD page styling diff (the smoking gun for prod black-screen)
    p_osd = last_p.get("videoOsdPage") or {}
    d_osd = last_d.get("videoOsdPage") or {}
    md.append("")
    md.append("## #videoOsdPage style (the OSD container painted on top of the <video>)")
    md.append("| Field | prod | dev |")
    md.append("|---|---|---|")
    for k in ["bg", "opacity", "position", "zIndex", "display", "cls"]:
        md.append(f"| {k} | `{p_osd.get(k)}` | `{d_osd.get(k)}` |")
    md.append("")
    md.append("## Stack at video center (top → bottom)")
    md.append("### prod")
    for s in (last_p.get("stackAtVideoCenter") or []):
        md.append(f"- `{s.get('tag')}#{s.get('id')}.{s.get('cls')}` "
                  f"bg=`{s.get('bg')}` z=`{s.get('zIndex')}` pos=`{s.get('position')}` "
                  f"isVideo={s.get('isVideo')}")
    md.append("### dev")
    for s in (last_d.get("stackAtVideoCenter") or []):
        md.append(f"- `{s.get('tag')}#{s.get('id')}.{s.get('cls')}` "
                  f"bg=`{s.get('bg')}` z=`{s.get('zIndex')}` pos=`{s.get('position')}` "
                  f"isVideo={s.get('isVideo')}")
    md.append("")
    md.append("## Stream URL (decoded)")
    md.append(f"- **prod**: `{diff.get('stream_url_prod') or '(empty)'}`")
    md.append(f"- **dev**:  `{diff.get('stream_url_dev')  or '(empty)'}`")
    md.append("")
    md.append("## PlaybackInfo MediaSources[0]")
    md.append("| Field | prod | dev |")
    md.append("|---|---|---|")
    for k in ["Container", "Protocol", "SupportsDirectPlay",
              "SupportsDirectStream", "SupportsTranscoding",
              "TranscodingUrl", "TranscodingSubProtocol", "TranscodingContainer",
              "TranscodeReasons", "Bitrate", "Size", "Path"]:
        md.append(f"| {k} | `{pp.get(k)}` | `{dp.get(k)}` |")
    md.append("")
    md.append("## ffmpeg cmdline (from docker logs)")
    md.append(f"- **prod**: `{diff.get('ffmpeg_cmdline_prod') or '(none — no transcoding observed)'}`")
    md.append(f"- **dev**:  `{diff.get('ffmpeg_cmdline_dev')  or '(none — no transcoding observed)'}`")
    md.append("")
    md.append("## HTTP status differences")
    if diff.get("http_status_diff"):
        md.append("| Method | Path | prod | dev |")
        md.append("|---|---|---|---|")
        for r in diff["http_status_diff"]:
            md.append(f"| {r['method']} | `{r['path']}` | {r['prod']} | {r['dev']} |")
    else:
        md.append("(none — all matched URLs returned the same status code)")
    md.append("")
    md.append("## Per-sample timeline")
    md.append("| t | prod time | prod paused | prod err | dev time | dev paused | dev err |")
    md.append("|---|---|---|---|---|---|---|")
    for ps, ds in zip(prod["samples"], dev["samples"]):
        pv, dv = ps["video"], ds["video"]
        md.append(f"| {ps['t']}s | {pv.get('currentTime')} | {pv.get('paused')} | "
                  f"{pv.get('error')} | {dv.get('currentTime')} | {dv.get('paused')} | "
                  f"{dv.get('error')} |")
    md.append("")
    return "\n".join(md)


# ------------------- main -------------------

async def main():
    print(f"[+] OUT: {OUT}")
    async with async_playwright() as p:
        prod = await run_side(p, SIDES[0])
        dev  = await run_side(p, SIDES[1])

    diff = diff_results(prod, dev)
    with open(os.path.join(OUT, "diff.json"), "w") as f:
        json.dump(diff, f, indent=2, default=str)
    md = render_md(diff, prod, dev)
    with open(os.path.join(OUT, "diff.md"), "w") as f:
        f.write(md)

    print("\n=== SUMMARY ===")
    last_p = diff["video_state_t30"]["prod"]; last_d = diff["video_state_t30"]["dev"]
    print(f"prod t=30: time={last_p.get('currentTime')} paused={last_p.get('paused')} "
          f"err={last_p.get('error')} dim={last_p.get('videoWidth')}x{last_p.get('videoHeight')}")
    print(f"dev  t=30: time={last_d.get('currentTime')} paused={last_d.get('paused')} "
          f"err={last_d.get('error')} dim={last_d.get('videoWidth')}x{last_d.get('videoHeight')}")
    print(f"diff.json: {os.path.join(OUT, 'diff.json')}")
    print(f"diff.md:   {os.path.join(OUT, 'diff.md')}")


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