#!/usr/bin/env python3
"""record_walkthrough.py — turn a scripted click-through of ANY web page into an
animated GIF (plus MP4) with a visible cursor, highlight ring, click ripple and
step captions. This is the "how do I work this website" demo generator.

Usage:
  python3 record_walkthrough.py --url <file:///path or https://...> \
      --steps steps.json --out out.gif [--width 1000 --height 620]

steps.json:
  [
    {"caption":"1. Open Coverage",
     "selector":"#nav-coverage", "action":"click", "settle":700},
    {"caption":"4. Done — status updates itself",
     "selector":"#toast", "action":"hover", "settle":900}
  ]
  action: click | hover | none   (none = just point at it)
"""
import argparse, json, os, shutil, sys, time
from pathlib import Path

from playwright.sync_api import sync_playwright
from PIL import Image

CHROME = os.environ.get(
    "PW_CHROME",
    "/home/hermeswebui/.hermes/profiles/opencode-acct1/home/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
)

# ---------------------------------------------------------------- overlay ----
OVERLAY_CSS = """
#__cur{position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;
  transition:none;filter:drop-shadow(0 2px 5px rgba(0,0,0,.35))}
#__cur svg{display:block}
#__ring{position:fixed;z-index:2147483646;pointer-events:none;border-radius:12px;
  border:2px solid #0d9488;box-shadow:0 0 0 4px rgba(13,148,136,.18),0 6px 22px rgba(13,148,136,.22);
  display:none;box-sizing:border-box}
#__rip{position:fixed;z-index:2147483645;pointer-events:none;border-radius:50%;
  background:rgba(13,148,136,.42);display:none}
#__cap{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:2147483647;
  pointer-events:none;display:flex;align-items:center;gap:12px;
  background:rgba(15,23,32,.94);color:#fff;padding:11px 16px 11px 13px;border-radius:12px;
  font:600 13.5px/1.25 Inter,"DejaVu Sans",sans-serif;letter-spacing:-.1px;
  box-shadow:0 14px 34px rgba(15,23,32,.34);max-width:78%;white-space:nowrap}
#__cap .dots{display:flex;gap:4px}
#__cap .dots i{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,.28);display:block}
#__cap .dots i.on{background:#0d9488;width:16px;border-radius:3px}
"""

CURSOR_SVG = (
    '<svg width="26" height="26" viewBox="0 0 24 24" fill="none">'
    '<path d="M5 2.5l14.5 8.2-6.3.9 3.6 6.9-2.7 1.4-3.6-6.9-4 4.9z" '
    'fill="#ffffff" stroke="#0f1720" stroke-width="1.6" stroke-linejoin="round"/></svg>'
)


def inject(page):
    page.add_style_tag(content=OVERLAY_CSS)
    page.evaluate(
        """(svg) => {
        const mk = (id, html) => { const d = document.createElement('div');
            d.id = id; d.innerHTML = html; document.body.appendChild(d); return d; };
        mk('__cur', svg);
        mk('__ring', '');
        mk('__rip', '');
        document.getElementById('__rip').style.display = 'none';
        mk('__cap', '');
        document.getElementById('__cap').style.display = 'none';
        document.getElementById('__cur').style.left = '18px';
        document.getElementById('__cur').style.top = '18px';
    }""",
        CURSOR_SVG,
    )


def cursor(page, x, y, press=False):
    page.evaluate(
        """([x,y,press]) => { const c = document.getElementById('__cur');
        c.style.transformOrigin = '6px 4px';
        c.style.transform = press ? 'scale(.86)' : 'scale(1)';
        c.style.left = (x-6)+'px'; c.style.top = (y-3)+'px'; }""",
        [x, y, press],
    )


def ring(page, box=None):
    if box is None:
        page.evaluate("() => { document.getElementById('__ring').style.display='none'; }")
        return
    page.evaluate(
        """(b) => { const r = document.getElementById('__ring');
        r.style.display='block'; r.style.left=(b.x-7)+'px'; r.style.top=(b.y-7)+'px';
        r.style.width=(b.width+14)+'px'; r.style.height=(b.height+14)+'px'; }""",
        box,
    )


def ripple(page, x, y, r, a):
    page.evaluate(
        """([x,y,r,a]) => { const e = document.getElementById('__rip');
        e.style.display='block'; e.style.width=(r*2)+'px'; e.style.height=(r*2)+'px';
        e.style.left=(x-r)+'px'; e.style.top=(y-r)+'px'; e.style.opacity=a; }""",
        [x, y, r, a],
    )


def hide_ripple(page):
    page.evaluate("() => { document.getElementById('__rip').style.display='none'; }")


def caption(page, text, idx, total):
    page.evaluate(
        """([t,i,n]) => { const c = document.getElementById('__cap');
        c.style.display='flex';
        c.innerHTML = '<span class="dots">' + Array.from({length:n},
            (_,k) => '<i class="' + (k===i ? 'on' : '') + '"></i>').join('') + '</span>' +
            '<span>' + t + '</span>'; }""",
        [text, idx, total],
    )


def ease(t):
    return 1 - (1 - t) ** 3


# ---------------------------------------------------------------- capture ----
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--url", required=True)
    ap.add_argument("--steps", required=True)
    ap.add_argument("--out", default="walkthrough.gif")
    ap.add_argument("--width", type=int, default=1000)
    ap.add_argument("--height", type=int, default=620)
    ap.add_argument("--scale", type=int, default=880, help="output GIF width")
    ap.add_argument("--keep-frames", action="store_true")
    args = ap.parse_args()

    steps = json.loads(Path(args.steps).read_text())
    workdir = Path(args.out).resolve().parent / "_frames"
    if workdir.exists():
        shutil.rmtree(workdir)
    workdir.mkdir(parents=True, exist_ok=True)

    shot, durations = [], []
    n = [0]

    def grab(dur=60):
        p = workdir / f"f{n[0]:04d}.png"
        page.screenshot(path=str(p))
        shot.append(str(p))
        durations.append(dur)
        n[0] += 1

    with sync_playwright() as pw:
        browser = pw.chromium.launch(
            executable_path=CHROME,
            headless=True,
            args=["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
                  "--force-color-profile=srgb", "--hide-scrollbars"],
        )
        page = browser.new_page(
            viewport={"width": args.width, "height": args.height},
            device_scale_factor=2,          # crisp text, downsampled at the end
        )
        page.goto(args.url, wait_until="networkidle")
        inject(page)
        page.wait_for_timeout(500)
        grab(400)

        cx, cy = 18.0, 18.0
        for i, st in enumerate(steps):
            el = page.query_selector(st["selector"])
            if el is None:
                print(f"!! step {i}: selector not found: {st['selector']}", file=sys.stderr)
                continue
            el.scroll_into_view_if_needed()
            page.wait_for_timeout(120)
            box = el.bounding_box()
            if box is None:
                print(f"!! step {i}: no box for {st['selector']}", file=sys.stderr)
                continue
            tx, ty = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2

            caption(page, st["caption"], i, len(steps))
            grab(300)                                   # caption appears, held
            grab(180)

            # travel
            for k in range(1, 11):
                e = ease(k / 10)
                cursor(page, cx + (tx - cx) * e, cy + (ty - cy) * e)
                grab(45)
            cursor(page, tx, ty)
            grab(120)

            # highlight
            ring(page, box)
            grab(220)
            grab(180)

            action = st.get("action", "click")
            if action == "click":
                cursor(page, tx, ty, press=True)
                grab(70)
                for k in range(6):
                    ripple(page, tx, ty, 8 + k * 7, max(0.0, 0.5 - k * 0.09))
                    grab(60)
                cursor(page, tx, ty, press=False)
                hide_ripple(page)
                try:
                    el.click(timeout=3000)
                except Exception as e:
                    print(f"   (click via JS fallback: {str(e)[:60]})")
                    page.evaluate("(s)=>document.querySelector(s).click()", st["selector"])
            elif action == "hover":
                try:
                    el.hover(timeout=3000)
                except Exception:
                    pass

            settle = st.get("settle", 600)
            page.wait_for_timeout(settle)
            for k in range(4):                          # hold the result
                if k == 2:
                    ring(page, None)
                grab(240)
            cx, cy = tx, ty

        # closing beat
        page.wait_for_timeout(300)
        grab(700)
        browser.close()

    print(f"captured {len(shot)} frames")

    # ------------------------------------------------ assemble animated GIF --
    base = Image.open(shot[len(shot) // 2]).convert("RGB")
    w, h = base.size
    nw = args.scale
    nh = round(h * nw / w)
    base = base.resize((nw, nh), Image.LANCZOS).quantize(colors=200, method=Image.MEDIANCUT)

    frames = []
    for f in shot:
        im = Image.open(f).convert("RGB").resize((nw, nh), Image.LANCZOS)
        frames.append(im.quantize(palette=base, dither=Image.FLOYDSTEINBERG))
    frames[0].save(
        args.out,
        save_all=True,
        append_images=frames[1:],
        duration=durations,
        loop=0,
        optimize=True,
        disposal=1,
    )
    size = Path(args.out).stat().st_size
    print(f"GIF  : {args.out}  ({nw}x{nh}, {len(frames)} frames, {size/1024:.0f} KB)")

    if not args.keep_frames:
        shutil.rmtree(workdir, ignore_errors=True)


if __name__ == "__main__":
    main()
