#!/usr/bin/env python3
"""
Paint the Learning Hub's live pictures to image files for the member app.

WHY. Two pictures on the website are live Vue, not files, so
`scripts/export-learning-content.mjs` can only export them as text:

  * a How-to point's `preview` — the REAL portal screen mounted with captured
    data and one element spotlit (`Components/PortalGuide/LivePreview.vue`).
    The export carries `picture: { id, alt }` and the app used to print the alt
    text beside a photo icon.
  * the Learning lab's drawing (`Components/Academy/PropertyScene.vue`) — an
    SVG that changes with each of the lab's three steps. The export carries
    `learning_lab.kind` + `steps`, so the app showed the photo and the step
    words only.

Owner, 2026-10-01: the app must show the same pictures as the website. This
script opens the website itself in headless Chrome (Playwright), walks every
How-to lesson point by point, and screenshots each preview as the website
draws it at phone width; then it screenshots the lab drawing at each step, for
both of its variants (`layers`: every kind except `evidence`; `map`: evidence).

It writes WebP files under `public/learning-content/` and the manifest
`resources/learning-content/learning-pictures.json`:

  { generated_at, viewport, scale,
    how_to: { "zh" | "en": { "<lesson>/<point>": { path, width, height, sha256 } } },
    lab_scenes: { "layers" | "map": [ { path, width, height, sha256 } x3 ] } }

`width` / `height` are CSS px at 1x. `Src\\Lms\\Services\\LearningPictures`
reads the manifest and `MobileLearningContentTransformer` attaches the images
to the served sets. A point or a step with no file keeps its text only; the
app never receives a URL that 404s.

Run BY HAND from the repo root when a How-to lesson, a previewed portal screen
or the lab drawing changes, then commit the images and the manifest. It needs:

  * Playwright for Python (`pip install playwright`) and Chrome at
    /usr/bin/google-chrome (or CHROME_BIN);
  * ImageMagick `convert` with WebP;
  * the site serving the CURRENT build (the screenshots are of what it serves);
  * a signed-in session cookie for any account that can open the Academy:
        php artisan tinker scripts/mint-session-cookie.php   (prints NAME=VALUE)
    then pass it as LEARNING_SESSION_COOKIE. The page's learning language is
    switched per run by rewriting the page props in the browser (no account
    setting is written).

  LEARNING_SESSION_COOKIE='name=value' python3 scripts/render-learning-pictures.py
  ... --only=how-to | --only=lab        one part only (the other is kept)
  ... --base=https://wk.propertylab.com.my
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timezone
from urllib.parse import urlparse

from playwright.sync_api import sync_playwright

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MANIFEST = os.path.join(ROOT, 'resources/learning-content/learning-pictures.json')
HOW_TO_DIR = 'learning-content/how-to'
LAB_DIR = 'learning-content/lab'
HUB_PATH = '/property/academy'

# A phone: the app shows these at phone width, so they are painted there.
VIEWPORT = {'width': 390, 'height': 844}
SCALE = 3
WEBP_QUALITY = 80

# Lessons whose lab draws the area map (PropertyScene's `map` variant); every
# other kind draws the layered building. Found from the exported sets.
MAP_KIND = 'evidence'

# The previews are inert copies of the portal; nothing on them should move.
STILL_CSS = """
*, *::before, *::after { transition: none !important; animation: none !important; caret-color: transparent !important; }
html { scroll-behavior: auto !important; }
[data-preview-caption] { display: none !important; }
"""


def sha256(path):
    with open(path, 'rb') as f:
        return hashlib.sha256(f.read()).hexdigest()


def to_webp(png, webp):
    """Re-encode a screenshot as WebP; returns its pixel size."""
    subprocess.run(['convert', png, '-strip', '-quality', str(WEBP_QUALITY), '-define', 'webp:method=6', webp], check=True)
    out = subprocess.run(['identify', '-format', '%w %h', webp], check=True, capture_output=True, text=True).stdout.split()
    os.unlink(png)
    return int(out[0]), int(out[1])


def entry(path_rel, pixels):
    full = os.path.join(ROOT, 'public', path_rel)
    return {
        'path': path_rel,
        'width': round(pixels[0] / SCALE),
        'height': round(pixels[1] / SCALE),
        'sha256': sha256(full),
    }


def load_set(name, lang):
    suffix = '.en' if lang == 'en' else ''
    with open(os.path.join(ROOT, f'resources/learning-content/{name}{suffix}.json'), encoding='utf-8') as f:
        return json.load(f)


def lab_lessons():
    """One How-to lesson per scene variant, to open the lab on."""
    data = load_set('how-to', 'zh')
    picks = {}
    for lesson in data['lessons']:
        lab = lesson.get('learning_lab') or {}
        if not lab or lesson.get('soon'):
            continue
        variant = 'map' if lab.get('kind') == MAP_KIND else 'layers'
        picks.setdefault(variant, lesson['key'])
    if 'map' not in picks:
        # No How-to lesson draws the map: look for one on the road or cases.
        picks.pop('map', None)
    return picks


class Painter:
    def __init__(self, base, cookie, lang):
        self.base = base.rstrip('/')
        self.cookie = cookie
        self.lang = lang

    def __enter__(self):
        name, _, value = self.cookie.partition('=')
        self.pw = sync_playwright().start()
        self.browser = self.pw.chromium.launch(
            executable_path=os.environ.get('CHROME_BIN', '/usr/bin/google-chrome'),
            args=['--force-prefers-reduced-motion', '--hide-scrollbars'],
        )
        self.ctx = self.browser.new_context(viewport=VIEWPORT, device_scale_factor=SCALE, reduced_motion='reduce')
        self.ctx.add_cookies([{
            'name': name, 'value': value, 'domain': urlparse(self.base).hostname,
            'path': '/', 'secure': True, 'httpOnly': True,
        }])
        lang = self.lang

        # The learning language is the account's (auth.user.learning_lang in
        # the Inertia props); rewrite it in the served page instead of the
        # account. Inertia XHR visits carry the same JSON.
        def rewrite(route):
            response = route.fetch()
            body = response.text()
            body = re.sub(r'(&quot;|")learning_lang\1:\1(zh|en)\1', lambda m: f'{m.group(1)}learning_lang{m.group(1)}:{m.group(1)}{lang}{m.group(1)}', body)
            route.fulfill(response=response, body=body)

        self.ctx.route(re.compile(re.escape(self.base) + re.escape(HUB_PATH) + r'.*'), rewrite)
        self.page = self.ctx.new_page()
        self.page.add_init_script(f"document.addEventListener('DOMContentLoaded', () => {{ const s = document.createElement('style'); s.textContent = {json.dumps(STILL_CSS)}; document.head.appendChild(s); }});")
        return self

    def __exit__(self, *exc):
        self.browser.close()
        self.pw.stop()

    def open_lesson(self, key):
        self.page.goto(f'{self.base}{HUB_PATH}?tab=how-to&lesson={key}', wait_until='networkidle')
        self.page.wait_for_selector('[data-point-stepper]', timeout=30000)
        time.sleep(1.0)

    def point(self, index, key):
        """Step the lesson to point `index` and wait for its preview to settle."""
        self.page.locator('[data-point-nav] button').nth(index).click()
        self.page.wait_for_selector(f'[data-point][data-point-key="{key}"]', timeout=15000)
        figure = self.page.locator('[data-live-preview]').first
        figure.wait_for(timeout=15000)
        # The component is async and its payload may be too; then the spotlight
        # is placed (and the stage scrolled to it) on the next frames.
        deadline = time.time() + 20
        while time.time() < deadline:
            busy = self.page.evaluate("""() => {
                const stage = document.querySelector('[data-live-preview] [data-preview-stage]');
                return !stage || !stage.querySelector('[inert]');
            }""")
            if not busy:
                break
            time.sleep(0.3)
        self.page.wait_for_load_state('networkidle')
        time.sleep(1.2)
        figure.scroll_into_view_if_needed()
        # The page's own pinned bars (the portal's mobile header, the stepper's
        # footer) would be painted over the frame: hide everything fixed or
        # sticky outside it.
        self.page.evaluate("""() => {
            const frame = document.querySelector('[data-live-preview]');
            for (const el of document.querySelectorAll('body *')) {
                if (frame.contains(el) || el.contains(frame)) continue;
                const position = getComputedStyle(el).position;
                if (position === 'fixed' || position === 'sticky') el.style.visibility = 'hidden';
            }
        }""")
        time.sleep(0.3)
        return figure


def paint_how_to(painter, lang, out):
    data = load_set('how-to', lang)
    shots = {}
    directory = os.path.join(ROOT, 'public', HOW_TO_DIR, lang)
    os.makedirs(directory, exist_ok=True)
    keep = set()
    for lesson in data['lessons']:
        if lesson.get('soon') or not any(p.get('picture') for p in lesson['points']):
            continue
        painter.open_lesson(lesson['key'])
        for i, point in enumerate(lesson['points']):
            if not point.get('picture'):
                continue
            figure = painter.point(i, point['key'])
            name = f"{lesson['key']}--{point['key']}.webp"
            png = os.path.join(tempfile.gettempdir(), f'learning-{os.getpid()}.png')
            figure.screenshot(path=png)
            pixels = to_webp(png, os.path.join(directory, name))
            rel = f'{HOW_TO_DIR}/{lang}/{name}'
            shots[f"{lesson['key']}/{point['key']}"] = entry(rel, pixels)
            keep.add(name)
            print(f"painted {lang} {lesson['key']}/{point['key']:<28} {shots[lesson['key'] + '/' + point['key']]['width']}x{shots[lesson['key'] + '/' + point['key']]['height']}", flush=True)
    for name in os.listdir(directory):
        if name.endswith('.webp') and name not in keep:
            os.unlink(os.path.join(directory, name))
            print(f'removed {HOW_TO_DIR}/{lang}/{name}')
    out[lang] = dict(sorted(shots.items()))


def paint_lab(painter, out):
    directory = os.path.join(ROOT, 'public', LAB_DIR)
    os.makedirs(directory, exist_ok=True)
    for variant, lesson in sorted(lab_lessons().items()):
        painter.open_lesson(lesson)
        studio = painter.page.locator('[data-learning-studio]').first
        toggle = studio.locator('button[aria-expanded="false"]').first
        if toggle.count():
            toggle.click()
        painter.page.locator('[data-studio-tab="visual"]').first.click()
        scene = studio.locator('svg[viewBox="0 0 600 340"]').first
        scene.wait_for(timeout=10000)
        frames = []
        steps = studio.locator('[aria-pressed]').filter(has_text=re.compile(r'^0[123]'))
        for step in range(3):
            steps.nth(step).click()
            time.sleep(0.6)
            scene.scroll_into_view_if_needed()
            # The selected step button lifts into the drawing's bottom edge.
            scene.evaluate("s => { if (s.nextElementSibling) s.nextElementSibling.style.visibility = 'hidden' }")
            png = os.path.join(tempfile.gettempdir(), f'learning-{os.getpid()}.png')
            scene.screenshot(path=png, omit_background=False)
            scene.evaluate("s => { if (s.nextElementSibling) s.nextElementSibling.style.visibility = '' }")
            name = f'{variant}-{step + 1}.webp'
            pixels = to_webp(png, os.path.join(directory, name))
            frames.append(entry(f'{LAB_DIR}/{name}', pixels))
            print(f'painted lab {variant} step {step + 1}', flush=True)
        out[variant] = frames


def main(argv):
    parser = argparse.ArgumentParser()
    parser.add_argument('--base', default='https://wk.propertylab.com.my')
    parser.add_argument('--only', choices=['how-to', 'lab'])
    args = parser.parse_args(argv)

    cookie = os.environ.get('LEARNING_SESSION_COOKIE', '')
    if '=' not in cookie:
        sys.exit('Set LEARNING_SESSION_COOKIE=name=value (php artisan tinker scripts/mint-session-cookie.php).')

    try:
        with open(MANIFEST, encoding='utf-8') as f:
            manifest = json.load(f)
    except FileNotFoundError:
        manifest = {'how_to': {}, 'lab_scenes': {}}

    previous = json.dumps(manifest, sort_keys=True)

    if args.only in (None, 'how-to'):
        for lang in ('zh', 'en'):
            with Painter(args.base, cookie, lang) as painter:
                paint_how_to(painter, lang, manifest.setdefault('how_to', {}))

    if args.only in (None, 'lab'):
        with Painter(args.base, cookie, 'zh') as painter:
            paint_lab(painter, manifest.setdefault('lab_scenes', {}))

    manifest['viewport'] = VIEWPORT
    manifest['scale'] = SCALE
    unchanged = json.dumps({k: v for k, v in manifest.items() if k != 'generated_at'}, sort_keys=True) == \
        json.dumps({k: v for k, v in json.loads(previous).items() if k != 'generated_at'}, sort_keys=True)
    if not unchanged or 'generated_at' not in manifest:
        manifest['generated_at'] = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
    ordered = {key: manifest[key] for key in ('generated_at', 'viewport', 'scale', 'how_to', 'lab_scenes') if key in manifest}
    with open(MANIFEST, 'w', encoding='utf-8') as f:
        json.dump(ordered, f, indent=1, ensure_ascii=False)
        f.write('\n')
    print(f"\nmanifest {'unchanged' if unchanged else 'written'}: {os.path.relpath(MANIFEST, ROOT)}")


if __name__ == '__main__':
    main(sys.argv[1:])
