"""Save a rendered portal page as a self-contained static HTML snapshot ("a screenshot in code").

The portal is an Inertia + Vue app: what the member sees exists only in the browser, after the scripts ran. This
takes that finished DOM, makes every stylesheet, font and picture it uses local, removes every script and every
token, and writes one HTML file that renders the same screen with no server behind it.

    lib = Snap(ctx, out_dir='/var/www/html/peta/public/cinematic/how/r1', pub='/cinematic/how/r1')
    await lib.save(page, 'dashboard')
"""
import asyncio, hashlib, json, os, re
from urllib.parse import urljoin, urlparse

PREP_JS = r"""
async (opts) => {
  const abs = u => { try { return new URL(u, location.href).href } catch (e) { return u } };
  try { await document.fonts.ready } catch (e) {}
  // 1. mark the ORIGINAL nodes with what a clone cannot carry: canvas pixels, form values, scroll offsets
  document.querySelectorAll('canvas').forEach(c => {
    try { const r = c.getBoundingClientRect(); c.setAttribute('data-snap-canvas', c.toDataURL('image/png')); c.setAttribute('data-snap-size', r.width + ',' + r.height) } catch (e) { c.setAttribute('data-snap-canvas', '') }
  });
  document.querySelectorAll('input').forEach(i => {
    if (i.type === 'checkbox' || i.type === 'radio') { i.checked ? i.setAttribute('checked', '') : i.removeAttribute('checked') }
    else if (i.type !== 'password' && i.type !== 'file' && i.type !== 'hidden') i.setAttribute('value', i.value)
  });
  document.querySelectorAll('textarea').forEach(t => t.setAttribute('data-snap-value', t.value));
  document.querySelectorAll('select').forEach(s => [...s.options].forEach(o => o.selected ? o.setAttribute('selected', '') : o.removeAttribute('selected')));
  document.querySelectorAll('*').forEach(el => { if (el !== document.documentElement && el !== document.body && (el.scrollTop > 0 || el.scrollLeft > 0)) el.setAttribute('data-snap-scroll', el.scrollTop + ',' + el.scrollLeft) });
  document.querySelectorAll('img').forEach(i => { if (i.currentSrc) i.setAttribute('data-snap-src', i.currentSrc) });
  document.querySelectorAll('video').forEach(v => {
    let url = v.poster ? abs(v.poster) : '';
    if (!url) { try { const c = document.createElement('canvas'); c.width = v.videoWidth; c.height = v.videoHeight; c.getContext('2d').drawImage(v, 0, 0); url = c.toDataURL('image/jpeg', .85) } catch (e) {} }
    const r = v.getBoundingClientRect(); v.setAttribute('data-snap-video', url); v.setAttribute('data-snap-size', r.width + ',' + r.height)
  });
  document.querySelectorAll('iframe').forEach(f => { const r = f.getBoundingClientRect(); f.setAttribute('data-snap-size', r.width + ',' + r.height) });
  // 2. stylesheets: linked ones by URL, everything else (runtime <style>, constructed sheets) by its rules
  const sheets = [];
  for (const s of document.styleSheets) {
    if (s.href) { sheets.push({ href: s.href }); continue }
    let css = ''; try { css = [...s.cssRules].map(r => r.cssText).join('\n') } catch (e) {}
    if (s.ownerNode) s.ownerNode.setAttribute('data-snap-css', String(sheets.length));
    sheets.push({ css });
  }
  const adopted = (document.adoptedStyleSheets || []).map(s => { try { return [...s.cssRules].map(r => r.cssText).join('\n') } catch (e) { return '' } });
  // 3. the clone
  const root = document.documentElement.cloneNode(true);
  const q = sel => [...root.querySelectorAll(sel)];
  // clean the originals again
  document.querySelectorAll('[data-snap-canvas],[data-snap-size],[data-snap-value],[data-snap-scroll],[data-snap-src],[data-snap-video],[data-snap-css]').forEach(el => {
    ['data-snap-canvas', 'data-snap-size', 'data-snap-value', 'data-snap-src', 'data-snap-video', 'data-snap-css'].forEach(a => el.removeAttribute(a)); el.removeAttribute('data-snap-scroll') });
  q('canvas').forEach(c => {
    const url = c.getAttribute('data-snap-canvas'), sz = (c.getAttribute('data-snap-size') || ',').split(',');
    const img = document.createElement('img');
    if (url) img.setAttribute('src', url);
    if (c.getAttribute('class')) img.setAttribute('class', c.getAttribute('class'));
    img.setAttribute('style', (c.getAttribute('style') || '') + ';width:' + sz[0] + 'px;height:' + sz[1] + 'px');
    img.setAttribute('data-snap', 'canvas'); img.setAttribute('alt', '');
    c.replaceWith(img);
  });
  q('textarea').forEach(t => { t.textContent = t.getAttribute('data-snap-value') || ''; t.removeAttribute('data-snap-value') });
  q('img').forEach(i => { const s = i.getAttribute('data-snap-src'); if (s) i.setAttribute('src', s); else if (i.getAttribute('src')) i.setAttribute('src', abs(i.getAttribute('src'))); i.removeAttribute('data-snap-src'); i.removeAttribute('srcset'); i.removeAttribute('sizes') });
  q('picture source').forEach(s => s.remove());
  q('video').forEach(v => {
    const url = v.getAttribute('data-snap-video'), sz = (v.getAttribute('data-snap-size') || ',').split(',');
    const img = document.createElement('img'); if (url) img.setAttribute('src', url);
    if (v.getAttribute('class')) img.setAttribute('class', v.getAttribute('class'));
    img.setAttribute('style', (v.getAttribute('style') || '') + ';width:' + sz[0] + 'px;height:' + sz[1] + 'px;object-fit:cover;background:#0b1530'); img.setAttribute('data-snap', 'video'); img.setAttribute('alt', '');
    v.replaceWith(img);
  });
  q('iframe').forEach(f => {
    const sz = (f.getAttribute('data-snap-size') || ',').split(','), d = document.createElement('div');
    if (f.getAttribute('class')) d.setAttribute('class', f.getAttribute('class'));
    d.setAttribute('style', (f.getAttribute('style') || '') + ';width:' + sz[0] + 'px;height:' + sz[1] + 'px;background:#0b1530'); d.setAttribute('data-snap', 'iframe'); d.setAttribute('data-snap-src', f.getAttribute('src') || '');
    f.replaceWith(d);
  });
  q('style').forEach(st => { const k = st.getAttribute('data-snap-css'); if (k !== null && sheets[+k]) { st.textContent = sheets[+k].css; st.removeAttribute('data-snap-css') } });
  if (adopted.length) { const st = document.createElement('style'); st.textContent = adopted.join('\n'); root.querySelector('head').appendChild(st) }
  q('link[rel="stylesheet"]').forEach(l => l.setAttribute('href', abs(l.getAttribute('href'))));
  // 4. nothing executable, nothing secret, nothing that phones home
  q('script,noscript,base,link[rel="modulepreload"],link[rel="preload"],link[rel="prefetch"],link[rel="icon"],link[rel="apple-touch-icon"],link[rel="manifest"],link[rel="canonical"],link[rel="alternate"],meta[name="csrf-token"],meta[property],meta[name="description"]').forEach(n => n.remove());
  q('[data-page]').forEach(n => n.removeAttribute('data-page'));
  q('input[type="hidden"]').forEach(n => n.remove());
  q('a[href]').forEach(a => a.setAttribute('href', '#'));
  q('form').forEach(f => { f.removeAttribute('action'); f.setAttribute('onsubmit', 'return false') });
  (opts.remove || []).forEach(sel => q(sel).forEach(n => n.remove()));
  const attrs = el => [...el.attributes].map(a => ` ${a.name}="${a.value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"`).join('');
  return { html: '<html' + attrs(document.documentElement) + '>' + root.innerHTML + '</html>', title: document.title, w: innerWidth, h: document.documentElement.scrollHeight };
}
"""

TAIL = ("<script>document.querySelectorAll('[data-snap-scroll]').forEach(function(e){var p=e.getAttribute('data-snap-scroll').split(',');e.scrollTop=+p[0];e.scrollLeft=+p[1]});"
        "document.addEventListener('click',function(e){var a=e.target.closest&&e.target.closest('a,button,[role=button],label,summary');if(a)e.preventDefault()},true);</script>")

EXT = {'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/svg+xml': '.svg', 'image/gif': '.gif', 'image/avif': '.avif',
       'font/woff2': '.woff2', 'font/woff': '.woff', 'application/font-woff2': '.woff2', 'font/ttf': '.ttf', 'application/octet-stream': ''}


class Snap:
    def __init__(self, ctx, out_dir, pub, origin='https://wk.propertylab.com.my'):
        self.ctx, self.out, self.pub, self.origin = ctx, out_dir, pub, origin
        self.assets, self.css_files, self.log = {}, {}, []
        for d in ('s', 'a', 'css'):
            os.makedirs(os.path.join(out_dir, d), exist_ok=True)

    async def _get(self, url):
        r = await self.ctx.request.get(url, headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'}, timeout=45000)
        if r.status != 200:
            raise RuntimeError(f'{r.status} {url[:120]}')
        return await r.body(), (r.headers.get('content-type') or '').split(';')[0].strip()

    async def asset(self, url):
        """Download once; return the public path of the local copy (or the original URL when it cannot be fetched)."""
        if url.startswith('data:') or url.startswith('#') or url.startswith('about:') or not url.strip():
            return url
        if url in self.assets:
            return self.assets[url]
        try:
            body, ctype = await self._get(url)
        except Exception as e:
            self.log.append(f'ASSET FAIL {e}')
            self.assets[url] = url
            return url
        ext = EXT.get(ctype) or os.path.splitext(urlparse(url).path)[1][:6] or ''
        name = hashlib.sha1(body).hexdigest()[:16] + ext
        path = os.path.join(self.out, 'a', name)
        if not os.path.exists(path):
            open(path, 'wb').write(body)
        self.assets[url] = f'{self.pub}/a/{name}'
        return self.assets[url]

    async def _css_urls(self, css, base):
        """Make every url(...) in a stylesheet local."""
        out, pos = [], 0
        for m in re.finditer(r'url\(\s*(["\']?)([^)"\']+)\1\s*\)', css):
            u = m.group(2).strip()
            out.append(css[pos:m.start()])
            if u.startswith('data:') or u.startswith('#'):
                out.append(m.group(0))
            else:
                out.append('url("' + await self.asset(urljoin(base, u)) + '")')
            pos = m.end()
        out.append(css[pos:])
        return ''.join(out)

    async def css_file(self, href):
        if href in self.css_files:
            return self.css_files[href]
        try:
            body, _ = await self._get(href)
        except Exception as e:
            self.log.append(f'CSS FAIL {e}')
            self.css_files[href] = href
            return href
        css = await self._css_urls(body.decode('utf-8', 'replace'), href)
        # @import of further sheets (Google Fonts style) is rare; flag it rather than follow it silently
        if '@import' in css:
            self.log.append(f'CSS has @import: {href[:100]}')
        name = hashlib.sha1(css.encode()).hexdigest()[:16] + '.css'
        open(os.path.join(self.out, 'css', name), 'w').write(css)
        self.css_files[href] = f'{self.pub}/css/{name}'
        return self.css_files[href]

    async def save(self, page, name, remove=None, note=''):
        data = await page.evaluate(PREP_JS, {'remove': remove or []})
        html = data['html']
        html = re.sub(r'<!--.*?-->', '', html, flags=re.S)
        # linked stylesheets
        for m in set(re.findall(r'<link[^>]+rel="stylesheet"[^>]*>', html)):
            h = re.search(r'href="([^"]+)"', m)
            if not h:
                continue
            local = await self.css_file(h.group(1).replace('&amp;', '&'))
            html = html.replace(m, f'<link rel="stylesheet" href="{local}">')
        # inline <style> blocks and style="" attributes
        parts, pos = [], 0
        for m in re.finditer(r'(<style[^>]*>)(.*?)(</style>)', html, flags=re.S):
            parts.append(html[pos:m.start()]); parts.append(m.group(1) + await self._css_urls(m.group(2), page.url) + m.group(3)); pos = m.end()
        parts.append(html[pos:]); html = ''.join(parts)
        parts, pos = [], 0
        for m in re.finditer(r'style="([^"]*url\([^"]*)"', html):
            css = m.group(1).replace('&quot;', "'").replace('&amp;', '&')
            parts.append(html[pos:m.start()]); parts.append('style="' + (await self._css_urls(css, page.url)).replace('"', "'") + '"'); pos = m.end()
        parts.append(html[pos:]); html = ''.join(parts)
        # pictures
        parts, pos = [], 0
        for m in re.finditer(r'(<(?:img|image|source|use)\b[^>]*?\s(?:src|href|xlink:href|poster)=")([^"]+)(")', html):
            u = m.group(2).replace('&amp;', '&')
            parts.append(html[pos:m.start()]); parts.append(m.group(1) + (u if u.startswith(('data:', '#')) else await self.asset(urljoin(page.url, u))) + m.group(3)); pos = m.end()
        parts.append(html[pos:]); html = ''.join(parts)
        html = html.replace('</body>', TAIL + '</body>') if '</body>' in html else html + TAIL
        head_note = f'<!-- PropertyLab portal screen "{name}", saved as static HTML for /how-it-works. {note} No scripts, no session data. -->\n'
        doc = '<!doctype html>\n' + head_note + html
        left = re.findall(r'(?:src|href)="(https?://[^"]+)"', doc)
        open(os.path.join(self.out, 's', name + '.html'), 'w').write(doc)
        return {'name': name, 'bytes': len(doc), 'h': data['h'], 'w': data['w'], 'external_left': sorted(set(left))[:8]}
