"""Mockup bundle storage, serving tokens, and the viewer bridge.

The isolation design (goal §3, build plan D3/D4): mockups are hand-authored
HTML with scripts, so a served mockup must NEVER run with easel's origin.

  * Every /m/ response carries `Content-Security-Policy: sandbox allow-scripts`,
    which gives the document an opaque origin no matter how it is reached —
    direct navigation included. The viewer's <iframe sandbox="allow-scripts">
    is belt-and-suspenders on top.
  * Auth is a short-lived signed token embedded in the PATH (/m/{token}/...),
    so relative subresources inside a bundle carry it automatically and no
    cookie ever needs to reach the opaque-origin document.
  * The injected bridge is the only channel back to the app: postMessage with a
    per-token nonce; the parent additionally checks event.source. Payloads are
    data, never trusted.
"""

import logging
import posixpath
import re
import secrets

from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.config import get_settings
from app.models import MockupFile, Option, new_file_id

log = logging.getLogger(__name__)

# Long enough to cover an unbroken review sitting. The viewer binds the iframe
# to the FIRST token it gets and never re-swaps the src (a new src is a reload,
# which threw the client back to the top of a 4,600px page mid-review), so this
# window is what a mockup's lazy-loaded subresources have to live inside.
# Widening it is the cost of that fix; the token is still signed, per-option and
# expiring, and it only ever serves opaque-origin sandboxed files.
TOKEN_MAX_AGE = 60 * 60  # seconds

# The bundle allow-list (goal §3.4): deliberately script-capable — the sandbox +
# CSP controls above are the compensating control. Fonts/json/map admitted as a
# judgment call: real hand-built bundles carry them and none widens execution.
ALLOWED_TYPES = {
    ".html": "text/html; charset=utf-8",
    ".htm": "text/html; charset=utf-8",
    ".css": "text/css; charset=utf-8",
    ".js": "text/javascript; charset=utf-8",
    ".mjs": "text/javascript; charset=utf-8",
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".svg": "image/svg+xml",
    ".ico": "image/x-icon",
    ".woff": "font/woff",
    ".woff2": "font/woff2",
    ".ttf": "font/ttf",
    ".otf": "font/otf",
    ".json": "application/json",
    ".map": "application/json",
    ".txt": "text/plain; charset=utf-8",
}

MOCKUP_HEADERS = {
    # The load-bearing header: opaque origin however the file is reached.
    "Content-Security-Policy": "sandbox allow-scripts",
    "X-Content-Type-Options": "nosniff",
    "Referrer-Policy": "no-referrer",
    "Cache-Control": "private, max-age=300",
}


class MockupError(Exception):
    def __init__(self, message: str, code: str, status: int = 400):
        super().__init__(message)
        self.code = code
        self.status = status


def normalize_rel_path(rel_path: str) -> str:
    """A safe, canonical bundle path: posix separators, no traversal, no
    absolutes, no hidden segments. Raises on anything suspicious."""
    p = (rel_path or "").replace("\\", "/").strip()
    if not p or p.startswith("/") or p.startswith("."):
        raise MockupError("Bad file path in bundle.", "BAD_PATH")
    norm = posixpath.normpath(p)
    parts = norm.split("/")
    if norm.startswith("..") or any(seg in ("..", "") or seg.startswith(".") for seg in parts):
        raise MockupError("Bad file path in bundle.", "BAD_PATH")
    if len(parts) > 6 or len(norm) > 200:
        raise MockupError("Bundle path too deep or too long.", "BAD_PATH")
    return norm


def content_type_for(rel_path: str) -> str:
    ext = posixpath.splitext(rel_path)[1].lower()
    ct = ALLOWED_TYPES.get(ext)
    if ct is None:
        raise MockupError(
            f"File type '{ext or '(none)'}' is not allowed in a mockup bundle.",
            "TYPE_NOT_ALLOWED")
    return ct


def store_file(db: Session, option: Option, rel_path: str, data: bytes) -> MockupFile:
    """Persist one bundle file: allow-listed type, size cap, random stored name
    under data/mockups/<option_id>/. Replaces an existing rel_path in place."""
    settings = get_settings()
    rel = normalize_rel_path(rel_path)
    ct = content_type_for(rel)
    if len(data) > settings.max_mockup_file_bytes:
        raise MockupError("File exceeds the mockup size cap.", "TOO_LARGE", 413)
    if len(data) == 0:
        raise MockupError("Empty file.", "EMPTY_FILE")

    dirpath = settings.mockups_dir / str(option.id)
    dirpath.mkdir(parents=True, exist_ok=True)
    stored = new_file_id() + posixpath.splitext(rel)[1].lower()
    (dirpath / stored).write_bytes(data)

    existing = db.scalar(select(MockupFile).where(
        MockupFile.option_id == option.id, MockupFile.rel_path == rel))
    if existing:
        old = dirpath / existing.stored_name
        if old.exists():
            old.unlink()
        existing.stored_name = stored
        existing.content_type = ct
        existing.size = len(data)
        db.flush()
        return existing
    row = MockupFile(option_id=option.id, rel_path=rel, stored_name=stored,
                     content_type=ct, size=len(data))
    db.add(row)
    db.flush()
    return row


def file_bytes(row: MockupFile) -> bytes:
    path = get_settings().mockups_dir / str(row.option_id) / row.stored_name
    return path.read_bytes()


# ------------------------------------------------------------------ tokens

def _serializer() -> URLSafeTimedSerializer:
    return URLSafeTimedSerializer(get_settings().session_secret,
                                  salt="easel-mockup-bundle")


def mint_token(option_id: int) -> tuple[str, str]:
    """(token, nonce) for one option bundle. The nonce rides inside the token
    and inside the injected bridge, and the viewer learns it from the mint
    response — three-way agreement authenticates bridge messages."""
    nonce = secrets.token_urlsafe(16)
    return _serializer().dumps({"o": option_id, "n": nonce}), nonce


def verify_token(token: str) -> tuple[int, str]:
    """(option_id, nonce) or raises MockupError (expired/invalid -> 403)."""
    try:
        payload = _serializer().loads(token, max_age=TOKEN_MAX_AGE)
        return int(payload["o"]), str(payload["n"])
    except SignatureExpired as exc:
        raise MockupError("This preview link has expired.", "TOKEN_EXPIRED", 403) from exc
    except (BadSignature, KeyError, ValueError, TypeError) as exc:
        raise MockupError("Invalid preview link.", "TOKEN_INVALID", 403) from exc


# ------------------------------------------------------------------ bridge

_BODY_CLOSE = re.compile(rb"</body\s*>", re.IGNORECASE)

# Kept deliberately tiny and dependency-free; runs inside the OPAQUE-origin
# document. Inbound messages are origin-checked against the app origin; outbound
# messages carry the per-token nonce and are addressed to the app origin.
_BRIDGE_JS = """
(function () {
  var NONCE = "%(nonce)s";
  var APP = "%(app_origin)s";
  function post(msg) { msg.source = "easel-bridge"; msg.nonce = NONCE;
    try { parent.postMessage(msg, APP); } catch (e) {} }
  function height() {
    var d = document.documentElement, b = document.body;
    return Math.max(d ? d.scrollHeight : 0, b ? b.scrollHeight : 0); }
  function width() {
    var d = document.documentElement, b = document.body;
    return Math.max(d ? d.scrollWidth : 0, b ? b.scrollWidth : 0); }
  function report() { post({ type: "height", height: height(), width: width() }); }
  // Escape pressed while the design has focus would otherwise die inside the
  // frame — the app's own Escape handlers (close the panel, end a tour) never
  // hear it. Forward just that one key; nothing else typed here leaves it.
  document.addEventListener("keydown", function (e) {
    /* A REAL key press only. sendEscape() dispatches a synthetic Escape to
       close the design's own menus after a demo click, and forwarding that
       told the app the reader had pressed Escape, which ended the walkthrough
       a few seconds into the beat that opened the menu. isTrusted is exactly
       the difference between a person and a script. */
    if (e.key === "Escape" && e.isTrusted) post({ type: "key", key: "Escape" });
  });
  window.addEventListener("load", report);
  if (window.ResizeObserver && document.documentElement) {
    new ResizeObserver(report).observe(document.documentElement); }
  setTimeout(report, 400); setTimeout(report, 1500);

  /* Resolve a CSS selector to a rect in DOCUMENT PERCENTAGES, so a walkthrough
     step highlights the real element instead of a hand-guessed box. Selector
     strings arrive from the app (never from page content) and are only ever
     passed to querySelector — a bad one yields null, never an error. */
  function visible(el) {
    if (!el.getClientRects().length) return false;
    var st = window.getComputedStyle(el);
    if (st.visibility === "hidden" || st.display === "none") return false;
    if (parseFloat(st.opacity || "1") < 0.05) return false;
    /* An ancestor may be the thing hiding it (a hover mega-menu holds a
       laid-out copy of the header phone, for instance) — walk up. */
    var p = el.parentElement, depth = 0;
    while (p && depth++ < 12) {
      var ps = window.getComputedStyle(p);
      if (ps.visibility === "hidden" || ps.display === "none") return false;
      if (parseFloat(ps.opacity || "1") < 0.05) return false;
      p = p.parentElement;
    }
    return true;
  }
  function measure(selector) {
    var el = null;
    try {
      /* First VISIBLE match, not merely the first match: a selector often has
         hidden duplicates (menu copies, mobile variants) that would otherwise
         put the spotlight on an invisible element somewhere else entirely. */
      var all = document.querySelectorAll(selector);
      for (var i = 0; i < all.length; i++) {
        if (visible(all[i])) { el = all[i]; break; }
      }
      if (!el && all.length) el = all[0];
    } catch (e) { return null; }
    if (!el) return null;
    var r = el.getBoundingClientRect();
    if (!r || (r.width === 0 && r.height === 0)) return null;
    var W = width(), H = height();
    if (!W || !H) return null;
    var sx = window.scrollX || window.pageXOffset || 0;
    var sy = window.scrollY || window.pageYOffset || 0;
    return { x: ((r.left + sx) / W) * 100, y: ((r.top + sy) / H) * 100,
             w: (r.width / W) * 100, h: (r.height / H) * 100 };
  }

  var dismissTimer = 0, picking = false, demoClick = false;
  function firstVisible(selector) {
    try {
      var all = document.querySelectorAll(selector);
      for (var i = 0; i < all.length; i++) { if (visible(all[i])) return all[i]; }
      return all.length ? all[0] : null;
    } catch (e) { return null; }
  }
  function sendEscape() {
    var opts = { key: "Escape", code: "Escape", keyCode: 27, which: 27,
                 bubbles: true, cancelable: true };
    document.dispatchEvent(new KeyboardEvent("keydown", opts));
    document.dispatchEvent(new KeyboardEvent("keyup", opts));
    /* Many reveals close on a click elsewhere rather than on Escape, so follow
       with a click on the page background. Harmless when nothing is open. */
    try { document.body.click(); } catch (e) {}
    /* And a `:focus-within` menu closes only when focus LEAVES it, which
       neither Escape nor a background click does on its own. */
    try {
      var a = document.activeElement;
      if (a && a !== document.body && a.blur) a.blur();
    } catch (e) {}
    setTimeout(report, 120);
  }
  /* A concept page is full of links to pages that do not exist here — the
     league homepages carry 52 of them each, to lawyer profiles and inner pages
     that were never part of the bundle. Following one navigates the frame to a
     404 and the client is left staring at a blank rectangle with no way back.
     So: same-document anchors still work (they are part of the design), and
     everything else is stopped and explained.
     preventDefault only cancels NAVIGATION — the page's own click handlers
     still run, so a takeover or a reveal behaves exactly as designed. */
  /* A bare "#" (or an empty href) names no destination, but a real click on
     one still scrolls the document to the TOP. Mockups are full of them: every
     nav item and every service link is href="#" until the build is real. So a
     demo click meant to open a menu also threw the page back to the top, mid
     walkthrough, and any client clicking a link did the same to themselves.
     A genuine fragment (#practice-areas) is left alone: that IS navigation
     inside the design, and it works. */
  function noDestination(href) {
    return href === "" || href === "#";
  }
  function sameDocAnchor(href) {
    return href.charAt(0) === "#";
  }
  document.addEventListener("click", function (ev) {
    if (picking) return;                 /* author mode owns the click */
    if (demoClick) { ev.preventDefault(); return; }
    var el = ev.target;
    var a = null;
    while (el && el !== document) {
      if (el.tagName === "A" && el.hasAttribute("href")) { a = el; break; }
      el = el.parentElement;
    }
    if (!a) return;
    var href = a.getAttribute("href") || "";
    if (noDestination(href)) { ev.preventDefault(); return; }
    if (sameDocAnchor(href)) return;
    ev.preventDefault();
    post({ type: "blocked-nav", href: href.slice(0, 200) });
  }, true);

  /* A demo form must not post anywhere either — same blank frame, same
     confusion. */
  document.addEventListener("submit", function (ev) {
    ev.preventDefault();
    post({ type: "blocked-nav", href: "form" });
  }, true);

  /* Author mode: report a selector for whatever gets clicked. */
  function pathTo(el) {
    if (!el || el.nodeType !== 1) return "";
    if (el.id) return "#" + (window.CSS && CSS.escape ? CSS.escape(el.id) : el.id);
    var parts = [], depth = 0;
    while (el && el.nodeType === 1 && depth++ < 5) {
      var part = el.tagName.toLowerCase();
      if (el.id) { parts.unshift("#" + el.id); break; }
      var cls = Array.prototype.slice.call(el.classList || []).slice(0, 2);
      if (cls.length) part += "." + cls.join(".");
      var parent = el.parentElement;
      if (parent) {
        var same = Array.prototype.filter.call(parent.children, function (c) {
          return c.tagName === el.tagName; });
        if (same.length > 1) {
          part += ":nth-of-type(" + (same.indexOf(el) + 1) + ")";
        }
      }
      parts.unshift(part);
      el = parent;
    }
    return parts.join(" > ");
  }
  document.addEventListener("click", function (ev) {
    if (!picking) return;
    ev.preventDefault();
    ev.stopPropagation();
    picking = false;
    post({ type: "picked", selector: pathTo(ev.target) });
  }, true);

  /* Where a note sits, as a place ON THE DESIGN rather than a fraction of the
     page. A percentage of the page height moves whenever the page changes
     height: an accordion opened above a note slides it off the words it was
     left on, and a reader with a different set open sees it somewhere else.
     So a note names the element under its point, by a selector unique in
     this document, the point's place across the element (a fraction of its
     width) and its distance down from the element's top (pixels, because an
     element usually grows below a point, and a fraction of a height that
     grew slides the point with it); the app measures it back the way it
     measures a walkthrough's highlights.

     The element is the nearest box that is not plain inline text (a word
     wrapped in <b> or <span> is anchored to its line's block), because an
     inline run that wraps has a box of its own shape and a point inside it
     means less than a point inside the paragraph. */
  function blockOf(el) {
    while (el && el.nodeType === 1 && el !== document.body && el !== document.documentElement) {
      var d = "";
      try { d = window.getComputedStyle(el).display; } catch (e) {}
      if (d && d !== "inline" && d !== "contents") return el;
      el = el.parentElement;
    }
    return null;
  }
  function esc(v) { return window.CSS && CSS.escape ? CSS.escape(v) : v; }
  function uniquePath(el) {
    if (!el || el.nodeType !== 1) return "";
    try {
      if (el.id && document.querySelectorAll("#" + esc(el.id)).length === 1) return "#" + esc(el.id);
    } catch (e) {}
    var parts = [], node = el, depth = 0;
    while (node && node.nodeType === 1 && node !== document.documentElement && depth++ < 16) {
      var part = node.tagName.toLowerCase();
      var cls = Array.prototype.slice.call(node.classList || [], 0, 2);
      for (var c = 0; c < cls.length; c++) part += "." + esc(cls[c]);
      var parent = node.parentElement;
      if (parent) {
        var same = Array.prototype.filter.call(parent.children, function (k) {
          return k.tagName === node.tagName; });
        if (same.length > 1) part += ":nth-of-type(" + (same.indexOf(node) + 1) + ")";
      }
      parts.unshift(part);
      var sel = parts.join(" > ");
      try { if (sel.length <= 500 && document.querySelectorAll(sel).length === 1) return sel; } catch (e) {}
      node = parent;
    }
    return "";
  }
  function anchorAt(xPct, yPct) {
    var W = width(), H = height();
    if (!W || !H) return null;
    var sx = window.scrollX || window.pageXOffset || 0;
    var sy = window.scrollY || window.pageYOffset || 0;
    var cx = xPct / 100 * W - sx, cy = yPct / 100 * H - sy;
    var hit = blockOf(document.elementFromPoint(cx, cy));
    if (!hit) return null;
    var r = hit.getBoundingClientRect();
    var sel = uniquePath(hit);
    if (!sel || !r.width || !r.height) return null;
    var clamp = function (v) { return Math.max(0, Math.min(1, v)); };
    return { s: sel, fx: clamp((cx - r.left) / r.width), oy: Math.max(0, cy - r.top) };
  }
  /* Measure a named place back, in document percentages. An element that is
     hidden right now (inside a closed accordion answer) gives its nearest
     visible ancestor's middle instead: the note sits on the question whose
     answer it was about, rather than vanishing. */
  function pointOf(s, fx, oy) {
    if (typeof s !== "string" || s.length > 500 || typeof fx !== "number" || typeof oy !== "number") return null;
    var el = firstVisible(s);
    if (!el) return null;
    var W = width(), H = height();
    if (!W || !H) return null;
    var sx = window.scrollX || window.pageXOffset || 0;
    var sy = window.scrollY || window.pageYOffset || 0;
    var r = el.getBoundingClientRect();
    if (r.width && r.height && visible(el)) {
      return { x: (r.left + sx + fx * r.width) / W * 100, y: (r.top + sy + Math.min(oy, r.height)) / H * 100 };
    }
    for (var up = el.parentElement, i = 0; up && up !== document.body && i < 8; up = up.parentElement, i++) {
      var ur = up.getBoundingClientRect();
      if (ur.width && ur.height && visible(up)) {
        return { x: (ur.left + sx + ur.width / 2) / W * 100, y: (ur.top + sy + Math.min(ur.height / 2, 24)) / H * 100, hidden: true };
      }
    }
    return null;
  }

  window.addEventListener("message", function (ev) {
    if (ev.origin !== APP) return;
    var m = ev.data || {};
    if (m.type === "scroll" && typeof m.y === "number") {
      var top = Math.max(0, m.y * height() - window.innerHeight / 3);
      window.scrollTo({ top: top, behavior: m.smooth === false ? "auto" : "smooth" });
    } else if (m.type === "measure" && typeof m.selector === "string") {
      post({ type: "rect", id: m.id, rect: measure(m.selector) });
    } else if (m.type === "anchor" && typeof m.x === "number" && typeof m.y === "number") {
      post({ type: "anchored", id: m.id, anchor: anchorAt(m.x, m.y) });
    } else if (m.type === "measure-points" && Array.isArray(m.items)) {
      var items = m.items.slice(0, 2000), out = [];
      for (var k = 0; k < items.length; k++) {
        var it = items[k] || {};
        out.push({ id: it.id, a: pointOf(it.s, it.fx, it.oy),
                   b: it.s2 ? pointOf(it.s2, it.fx2, it.oy2) : null });
      }
      post({ type: "points", items: out });
    } else if (m.type === "click" && typeof m.selector === "string") {
      /* A demo click: the mockup clicks ITSELF. The bridge already runs inside
         the frame, so this adds no capability and no sandbox token — it is the
         page's own script dispatching on the page's own element, which is the
         only reason it is allowed to exist. */
      clearTimeout(dismissTimer);
      var hit = firstVisible(m.selector);
      if (hit) {
        /* Focus first, then click. A mockup's menus are usually pure CSS,
           opened by `:hover, :focus-within` with no script behind them, and
           element.click() dispatches a click WITHOUT moving focus. So a demo
           click on a nav item did nothing visible: the reveal it was meant to
           show never opened. Focusing is what makes the CSS idiom work, and it
           adds no capability the frame did not already have. */
        try { if (hit.focus) hit.focus({ preventScroll: true }); } catch (e) {}
        /* A demo click exists to REVEAL something. Whatever the href says, it
           must not move the page: the walkthrough is holding the reader at a
           particular spot and the point is read against it. */
        demoClick = true;
        try { hit.click(); } finally { demoClick = false; }
        post({ type: "clicked", selector: m.selector, ok: true });
        /* Give it back afterwards, so the beat leaves the design as it found
           it and the next beat is not read through an open panel. 0 means
           stay open — some reveals ARE the point. */
        if (typeof m.dismissAfter === "number" && m.dismissAfter > 0) {
          dismissTimer = setTimeout(sendEscape, m.dismissAfter * 1000);
        }
      } else {
        post({ type: "clicked", selector: m.selector, ok: false });
      }
    } else if (m.type === "escape") {
      clearTimeout(dismissTimer);
      sendEscape();
    } else if (m.type === "pick") {
      /* Arm "point at the thing": the next click reports a selector back
         instead of doing anything, so an author can define a spot by clicking
         it rather than by writing a selector from memory. */
      picking = !!m.on;
    } else if (m.type === "variant" && Array.isArray(m.all)) {
      /* Toggle a variant by swapping a class on <html>. Only classes the APP
         names are touched — every known one is removed, then the active ones
         added — so the page's own classes are never clobbered and page content
         can never influence which classes exist. One bundle, no reload, so the
         client keeps their scroll position while comparing. */
      var root = document.documentElement;
      var i;
      for (i = 0; i < m.all.length; i++) {
        if (typeof m.all[i] === "string" && m.all[i]) root.classList.remove(m.all[i]);
      }
      var on = Array.isArray(m.active) ? m.active : [];
      for (i = 0; i < on.length; i++) {
        if (typeof on[i] === "string" && on[i]) root.classList.add(on[i]);
      }
      setTimeout(report, 60);   /* a variant may change the page height */
    } else if (m.type === "ping") { report(); }
  });
})();
"""


def inject_bridge(html: bytes, nonce: str) -> bytes:
    """Inject the bridge script before </body> (append when absent). Never
    persisted — injection happens per response, so stored files stay pristine."""
    app_origin = get_settings().bw_app_domain
    js = _BRIDGE_JS % {"nonce": nonce, "app_origin": app_origin}
    tag = ("<script>" + js + "</script>").encode()
    m = _BODY_CLOSE.search(html)
    if m:
        return html[: m.start()] + tag + html[m.start():]
    return html + tag


# --------------------------------------------------- what variations exist

# A variant class is toggled onto <html> by the bridge, so the design declares
# one by scoping a rule to the root element: `html.v-brand-hive`,
# `html:not(.v-brand-hive)`, `:root.v-header-b`. That is the mechanism itself,
# not a naming convention — so reading these back is reading the design's own
# statement about which looks it can wear, and the designer never has to retype
# a class into a form and hope it matches.
_ROOT_CHAIN = re.compile(
    r"(?:\bhtml\b|:root)((?::not\()?\.[A-Za-z_-][A-Za-z0-9_-]*\)?)+")
_CLASS_IN_CHAIN = re.compile(r"\.([A-Za-z_-][A-Za-z0-9_-]*)")

# Enough to cover a page's stylesheets without reading a whole image bundle
# into memory if someone mislabels a file.
_SCAN_BUDGET = 4 * 1024 * 1024


def declared_root_classes(db: Session, option: Option) -> list[str]:
    """Every class the option's own bundle scopes to <html> or :root, sorted.

    Used to offer the author real choices instead of a free-text box whose
    typos fail silently — the frame simply would not change, which is a
    terrible way to learn you mistyped a class.
    """
    rows = db.scalars(select(MockupFile).where(
        MockupFile.option_id == option.id)).all()
    found: set[str] = set()
    budget = _SCAN_BUDGET
    for row in rows:
        if posixpath.splitext(row.rel_path)[1].lower() not in (
                ".html", ".htm", ".css"):
            continue
        if budget <= 0:
            break
        try:
            text = file_bytes(row)[:budget].decode("utf-8", "ignore")
        except (OSError, MockupError):
            continue          # a missing file must not break the form
        budget -= len(text)
        for m in _ROOT_CHAIN.finditer(text):
            found.update(_CLASS_IN_CHAIN.findall(m.group(0)))
    return sorted(found)
