"""WKWebView host for the Lirelle bank window.

We render the actual design HTML/CSS inside the bank window via WKWebView
so the UI is pixel-perfect with the designer's intent — no manual AppKit
translation of CSS / SVG / animations. The pattern is the same one
Notion / Slack / Discord / Linear use: native shell, web view for the
content area, JS↔Python bridge for live data.

Architecture:
  • bank.py opens the NSWindow + titlebar (transparent, traffic lights only)
  • This module's `WebShell` swaps a WKWebView into the content host
  • The WebView loads `ui/{section}.html` from the .app bundle
  • JS calls `window.webkit.messageHandlers.lumo.postMessage({fn, args})`
  • Python (`LirelleBridge`) handles the message and replies via
    `webView.evaluateJavaScript_("lumoReply('<id>', ...)")`
  • Sections: Meetings, Transcripts, Account, Settings
  • Live updates (recording timer ticks, new transcript chunk, etc.) push
    via `webView.evaluateJavaScript_("lumoPush('<event>', ...)")`

Why we don't load over file:// directly:
  • WKWebView's file:// access is sandboxed per-folder. Setting baseURL
    on `loadHTMLString_baseURL_` allows relative paths inside `ui/`.
  • We also want to inject a tiny bridge polyfill before user code runs.
"""

from __future__ import annotations

import json
import sys
import threading

# Global lock for bridge handler execution — see comment in PythonBridge
# dispatch about the PyObjC class-cache race that crashed v0.1.14–0.1.16.
_BRIDGE_HANDLER_LOCK = threading.Lock()
from pathlib import Path
from typing import Any, Callable, Optional

import objc  # type: ignore
from objc import python_method  # type: ignore
from AppKit import (  # type: ignore
    NSMakeRect,
    NSURL,
    NSView,
)
from PyObjCTools import AppHelper  # type: ignore
from WebKit import (  # type: ignore
    WKUserContentController,
    WKUserScript,
    WKWebView,
    WKWebViewConfiguration,
)

# WKNavigationActionPolicyCancel / Allow — symbolic constants for the
# navigation delegate below. (PyObjC exposes the enum but importing it
# by name is unreliable across SDK versions; the underlying values are
# stable in the WebKit ABI.)
_NAV_ALLOW = 1
_NAV_CANCEL = 0


def ui_dir() -> Path:
    """Locate the bundled `ui/` directory in both dev and frozen .app."""
    here = Path(__file__).resolve().parent.parent
    if getattr(sys, "frozen", False):
        candidate = here.parent / "Resources" / "ui"
        if candidate.exists():
            return candidate
        candidate = here / "ui"
        if candidate.exists():
            return candidate
    return here / "ui"


# Map sidebar section IDs to design HTML files. Used by the shell to
# decide which page to load on a section switch.
SECTION_HTML = {
    "meetings": "Meetings.html",
    "transcripts": "Transcripts.html",
    "account": "Account.html",
    "settings": "Settings.html",
}


# ── JS bridge polyfill ─────────────────────────────────────────────────
# Injected before the page runs. Exposes:
#   window.lumo.call(fn, args)   → Promise<reply>
#   window.lumo.on(event, cb)    → live push subscription
#   window.lumoReply(id, value)  → resolves a pending Promise (called from Py)
#   window.lumoPush(event, val)  → fires subscribed callbacks (called from Py)

# CSS injected into every page to make the embedded design fill the
# content area we hand it (no body padding, no design-side titlebar /
# sidebar — the native AppKit sidebar already provides nav). Pixel-
# perfect alignment relies on this CSS running BEFORE the page paints.
EMBED_CSS = """
html, body { background: var(--bg, #0A0808) !important; padding: 0 !important; }
body { display: block !important; min-height: 100vh !important; }
.win-frame {
  width: 100% !important; height: 100vh !important;
  min-width: 0 !important; min-height: 0 !important;
  border-radius: 0 !important; resize: none !important;
}
.lumo-window { border-radius: 0 !important; border: none !important; box-shadow: none !important; }
/* The design's titlebar held 40pt of vertical space for traffic lights.
   We keep its space (visibility:hidden, not display:none) so the macOS
   transparent title bar overlays empty area, not the breadcrumb / record
   header underneath. */
.titlebar { visibility: hidden !important; height: 40px !important; flex: 0 0 40px !important; }
/* Make non-disabled nav items clickable cursor. */
.nav-item:not(.disabled) { cursor: pointer; }

/* Reserve gutter for the vertical scrollbar always, so switching from
   a short page (empty Transcripts filter) to a long one (43 cards)
   doesn't shift centered content sideways when the scrollbar appears.
   The design uses a custom ::-webkit-scrollbar 10px wide; scrollbar-
   gutter does NOT reserve space for those. Force overflow-y: scroll
   (instead of auto) so the 10px track is always present, then the
   centered .sec-wrap (margin: 0 auto, max-width 720px) stays put. */
.detail-scroll, .list-scroll {
  overflow-y: scroll !important;
  scrollbar-gutter: stable !important;
}
"""

# Attached after the page is parsed — wires the design sidebar's
# .nav-item clicks back through the bridge so Python can switch sections.
NAV_BRIDGE_JS = """
(function () {
  function attachNavHandlers () {
    var items = document.querySelectorAll(".nav-item");
    console.log("[lumo nav-bridge] attaching to", items.length, "nav-items");
    items.forEach(function (item) {
      if (item.classList.contains("disabled")) return;
      item.addEventListener("click", function (e) {
        e.preventDefault();
        var labelEl = item.querySelector(".label");
        if (!labelEl) return;
        var label = labelEl.textContent.trim().toLowerCase();
        console.log("[lumo nav-bridge] click on", label, "lumo present:", !!window.lumo);
        if (window.lumo && window.lumo.call) {
          window.lumo.call("switchSection", { id: label }).then(function (res) {
            console.log("[lumo nav-bridge] switchSection ok:", res);
          }).catch(function (err) {
            console.error("[lumo nav-bridge] switchSection failed:", err);
          });
        }
      });
    });
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", attachNavHandlers);
  } else {
    attachNavHandlers();
  }
})();
"""

BRIDGE_POLYFILL = """
(function () {
  var pending = {};
  var subs = {};
  window.lumo = {
    call: function (fn, args) {
      return new Promise(function (resolve, reject) {
        var id = Math.random().toString(36).slice(2) + Date.now().toString(36);
        pending[id] = { resolve: resolve, reject: reject };
        try {
          window.webkit.messageHandlers.lumo.postMessage({
            id: id, fn: fn, args: args || {},
          });
        } catch (e) {
          delete pending[id];
          reject(e);
        }
      });
    },
    on: function (event, cb) {
      (subs[event] = subs[event] || []).push(cb);
      return function () {
        subs[event] = (subs[event] || []).filter(function (x) { return x !== cb; });
      };
    },
  };
  window.lumoReply = function (id, value, error) {
    var p = pending[id];
    if (!p) return;
    delete pending[id];
    if (error) p.reject(new Error(error));
    else p.resolve(value);
  };
  window.lumoPush = function (event, value) {
    (subs[event] || []).slice().forEach(function (cb) {
      try { cb(value); } catch (e) { console.error("lumo.on(" + event + ") cb threw:", e); }
    });
  };
})();
"""


class LirelleUIDelegate(objc.lookUpClass("NSObject")):
    """WKUIDelegate that turns the page's window.alert() / confirm() /
    prompt() into native NSAlert dialogs. CR #7 — without it, those JS
    calls are silently no-ops in WKWebView (alert returns immediately,
    confirm returns false). Account → Sign out and the more-menu Archive
    flow both depend on confirm(), so they were dead in production until
    we wired a delegate."""

    def webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_(  # noqa: N802
        self, _wv, message, _frame, completion_handler
    ):
        try:
            from AppKit import NSAlert, NSAlertStyleInformational  # type: ignore
            alert = NSAlert.alloc().init()
            alert.setMessageText_("Lirelle")
            alert.setInformativeText_(str(message))
            alert.setAlertStyle_(NSAlertStyleInformational)
            alert.addButtonWithTitle_("OK")
            alert.runModal()
        except Exception as e:
            print(f"[web_shell] alert dialog failed: {e}", flush=True)
        finally:
            try: completion_handler()
            except Exception: pass

    def webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_(  # noqa: N802
        self, _wv, message, _frame, completion_handler
    ):
        confirmed = False
        try:
            from AppKit import NSAlert, NSAlertStyleWarning  # type: ignore
            alert = NSAlert.alloc().init()
            alert.setMessageText_("Lirelle")
            alert.setInformativeText_(str(message))
            alert.setAlertStyle_(NSAlertStyleWarning)
            alert.addButtonWithTitle_("OK")
            alert.addButtonWithTitle_("Cancel")
            # NSAlertFirstButtonReturn = 1000.
            response = alert.runModal()
            confirmed = (response == 1000)
        except Exception as e:
            print(f"[web_shell] confirm dialog failed: {e}", flush=True)
        try:
            completion_handler(bool(confirmed))
        except Exception:
            pass


class LirelleNavigationFence(objc.lookUpClass("NSObject")):
    """WKNavigationDelegate that pins the WebView to local file:// content.

    Audit #3 (2026-06-10). Without this, ANY link click in the bank
    window (including ones the LLM might paste into a meeting summary
    via prompt injection) navigates the whole WebView away — and our
    bridge polyfill is injected `forMainFrameOnly_=True` on EVERY main
    frame, so a remote page would receive `window.lumo` and could call
    `requestTranscripts`, `signOut`, etc. Block off-bundle navigation
    here, send those clicks to the system browser instead.
    """

    def webView_decidePolicyForNavigationAction_decisionHandler_(  # noqa: N802
        self, _wv, action, decision_handler
    ):
        try:
            req = action.request()
            url = req.URL() if req is not None else None
            scheme = (str(url.scheme()) if url and url.scheme() else "").lower()
            # Allow only local content (the bundled ui/ dir) and the
            # internal `about:blank` used during page swap.
            if scheme in ("file", "about", ""):
                decision_handler(_NAV_ALLOW)
                return
            # Everything else — typically http/https links in summaries
            # or transcripts — opens in the user's default browser, NOT
            # in our WebView.
            try:
                from AppKit import NSWorkspace  # type: ignore
                if url is not None:
                    NSWorkspace.sharedWorkspace().openURL_(url)
            except Exception as e:
                print(f"[web_shell] external link open failed: {e}",
                      flush=True)
            decision_handler(_NAV_CANCEL)
        except Exception as e:
            print(f"[web_shell] nav-fence error, cancelling: {e}",
                  flush=True)
            try:
                decision_handler(_NAV_CANCEL)
            except Exception:
                pass


class LirelleBridge(objc.lookUpClass("NSObject")):
    """Receives `postMessage` from the page and dispatches to registered
    Python handlers. Replies via `webView.evaluateJavaScript_`."""

    def init(self):  # type: ignore
        self = objc.super(LirelleBridge, self).init()
        if self is None:
            return None
        self._handlers: dict[str, Callable[[dict], Any]] = {}
        self._webview = None
        return self

    @python_method
    def attach_webview(self, wv) -> None:
        self._webview = wv

    @python_method
    def set_handler(self, fn_name: str, handler: Callable[[dict], Any]) -> None:
        """Register a JS-callable function. Handler receives args dict,
        returns JSON-serialisable value (or raises)."""
        self._handlers[fn_name] = handler

    # WKScriptMessageHandler
    def userContentController_didReceiveScriptMessage_(self, _ucc, message):  # noqa: N802, ARG002
        # Origin fence (audit #3): only honour messages from a local
        # file:// frame. If a future bug ever lets remote content into
        # the WebView (and the LirelleNavigationFence above is the first
        # line of defense), this stops it from calling sensitive
        # handlers like requestTranscripts / signOut.
        try:
            frame = message.frameInfo()
            req = frame.request() if frame is not None else None
            url = req.URL() if req is not None else None
            scheme = (str(url.scheme()) if url and url.scheme() else "").lower()
            if scheme and scheme != "file":
                print(f"[web_shell] dropped message from non-file origin: "
                      f"{scheme}", flush=True)
                return
        except Exception:
            # If we can't determine the origin (PyObjC selector mismatch on
            # an older WebKit) bias toward dropping — the bridge is too
            # privileged to dispatch a message of unknown provenance.
            print("[web_shell] dropped message — origin undeterminable",
                  flush=True)
            return

        body = message.body()
        # body arrives as NSDictionary (PyObjC bridge), not plain dict —
        # convert via dict() so .get works the way Python expects.
        try:
            body = dict(body)
        except (TypeError, ValueError):
            return
        call_id = body.get("id")
        fn = body.get("fn")
        args_raw = body.get("args", {}) or {}
        try:
            args = dict(args_raw)
        except (TypeError, ValueError):
            args = {}
        if not call_id or not fn:
            return
        # Coerce ObjC strings to Python strings.
        call_id = str(call_id)
        fn = str(fn)
        args = {str(k): (str(v) if hasattr(v, "UTF8String") else v)
                for k, v in args.items()}

        # Run handler on a worker thread so a slow handler doesn't freeze the
        # WebView. The reply marshals back through evaluateJavaScript on main.
        #
        # CR (2026-06-28) — serialise handler execution behind a module
        # global lock. v0.1.14–0.1.16 crashed with SIGABRT under
        # NSClassicMapTable race when two bridge handlers concurrently
        # registered PyObjC classes (e.g. requestAccountInfo +
        # requestSettings firing on page load). PyObjC's class cache
        # isn't thread-safe; the only reliable mitigation in this
        # bridge pattern is to keep one handler call in flight at a
        # time. Slow handlers (downloadOfflineModel etc.) already
        # background their own work, so the serialisation cost is just
        # the call dispatch, not the actual work.
        def _run():
            try:
                handler = self._handlers.get(fn)
                if handler is None:
                    return self._reply(call_id, None, f"no handler: {fn}")
                with _BRIDGE_HANDLER_LOCK:
                    value = handler(args)
                self._reply(call_id, value, None)
            except Exception as e:
                self._reply(call_id, None, f"{type(e).__name__}: {e}")

        threading.Thread(target=_run, daemon=True).start()

    @python_method
    def _reply(self, call_id: str, value: Any, error: Optional[str]) -> None:
        try:
            payload_value = json.dumps(value, ensure_ascii=False) if value is not None else "null"
        except (TypeError, ValueError):
            payload_value = "null"
            error = error or "non-serialisable result"
        if error is not None:
            err = json.dumps(error, ensure_ascii=False)
            js = f"window.lumoReply({json.dumps(call_id)}, null, {err});"
        else:
            js = f"window.lumoReply({json.dumps(call_id)}, {payload_value});"

        def _do():
            if self._webview is not None:
                self._webview.evaluateJavaScript_completionHandler_(js, None)
        AppHelper.callAfter(_do)

    @python_method
    def push(self, event: str, value: Any) -> None:
        """Fire a live update to JS subscribers."""
        try:
            payload = json.dumps(value, ensure_ascii=False) if value is not None else "null"
        except (TypeError, ValueError):
            payload = "null"
        js = f"window.lumoPush({json.dumps(event)}, {payload});"

        def _do():
            if self._webview is not None:
                self._webview.evaluateJavaScript_completionHandler_(js, None)
        AppHelper.callAfter(_do)


# ── Drag-overlay over the WKWebView title strip ────────────────────────


class LirelleDragOverlay(NSView):
    """Transparent NSView that occupies the top 40 px of the window and
    explicitly initiates a window-drag on every mouse-down.

    Background: WKWebView captures all mouse events in its area, so
    NSWindow's `setMovableByWindowBackground_(True)` never sees the
    mouse-down to start a drag. CSS `-webkit-app-region: drag` is Chromium-
    only and WebKit silently ignores it. The first attempt (returning True
    from mouseDownCanMoveWindow) didn't survive PyObjC's method bridging —
    AppKit looks up the selector on the bridged subclass and the override
    wasn't being called.

    This version uses the bullet-proof recipe: override `mouseDown_` and
    explicitly call `[self.window performWindowDragWithEvent:event]`.
    `performWindowDragWithEvent:` exists on NSWindow since 10.11 and does
    exactly what we want — start a window drag with the given event, end
    when the mouse comes up. No AppKit-side magic needed; we just tell the
    window to drag.

    Sized to 40 px (matching the design's `.titlebar`) and pinned to the
    top via NSViewWidthSizable | NSViewMinYMargin autoresizing so it
    sticks on resize."""

    def mouseDown_(self, event):  # noqa: N802
        print("[drag-overlay] mouseDown_ fired", flush=True)
        win = self.window()
        if win is not None:
            try:
                win.performWindowDragWithEvent_(event)
                return
            except Exception as e:  # noqa: BLE001
                print(f"[drag-overlay] performWindowDragWithEvent failed: {e}",
                      flush=True)
        # Fallback if the API isn't available (shouldn't happen on macOS 10.11+).
        # Use objc.super, not Python super(), per PyObjC rules — Python super
        # walks the MRO without going through the Obj-C runtime, which
        # would skip half the NSResponder chain.
        objc.super(LirelleDragOverlay, self).mouseDown_(event)

    def acceptsFirstMouse_(self, _event) -> bool:  # noqa: N802
        # True = drag works even when Lirelle is in the background and the
        # user clicks the title strip to bring it forward AND drag in one
        # gesture. Matches native title-bar feel.
        return True


# Height of the top drag-strip. Matches the design's `.titlebar` (40 px in
# bank, 28 px in onboarding). Picking the larger one is safe — the strip
# is transparent and only covers space the design reserves for the traffic-
# light row anyway.
_DRAG_STRIP_HEIGHT = 40.0


class WebShell:
    """One WebShell per bank window. Hosts a WKWebView, loads section
    HTMLs from the bundled `ui/` folder, exposes a Python→JS bridge."""

    def __init__(self) -> None:
        self.bridge = LirelleBridge.alloc().init()
        self.nav_fence = LirelleNavigationFence.alloc().init()
        self.ui_delegate = LirelleUIDelegate.alloc().init()
        self._webview = None
        self._container = None
        self._current_section: Optional[str] = None

    def view(self) -> NSView:
        # Return the container (WebView + drag overlay) so callers that
        # setContentView_ to this view get both children at once.
        return self._container if self._container is not None else self._webview

    def build(self, frame) -> NSView:
        ucc = WKUserContentController.alloc().init()
        # Inject embed CSS so the design fills the WebView (no extra
        # padding, no duplicate titlebar/sidebar).
        css_js = (
            "(function(){var s=document.createElement('style');"
            "s.textContent=" + json.dumps(EMBED_CSS) + ";"
            "document.head.appendChild(s);})();"
        )
        css_script = WKUserScript.alloc().initWithSource_injectionTime_forMainFrameOnly_(
            css_js, 1, True,  # 1 = atDocumentEnd
        )
        ucc.addUserScript_(css_script)
        # Nav bridge — wires .nav-item clicks back through lumo.call().
        nav_script = WKUserScript.alloc().initWithSource_injectionTime_forMainFrameOnly_(
            NAV_BRIDGE_JS, 1, True,  # 1 = atDocumentEnd
        )
        ucc.addUserScript_(nav_script)
        # Inject bridge polyfill at document START (before any page script).
        polyfill_script = WKUserScript.alloc().initWithSource_injectionTime_forMainFrameOnly_(
            BRIDGE_POLYFILL, 0, True,  # 0 = atDocumentStart
        )
        ucc.addUserScript_(polyfill_script)
        # Register message handler `lumo`.
        ucc.addScriptMessageHandler_name_(self.bridge, "lumo")

        config = WKWebViewConfiguration.alloc().init()
        config.setUserContentController_(ucc)

        # Right-click → Inspect Element. Useful while developing — but
        # in a shipped .app it lets anyone with the binary poke at the
        # bridge through DevTools. py2app sets sys.frozen; we keep it on
        # for dev runs (`python -m src.app`) and off in production.
        # Audit #6 (2026-06-10).
        prefs = config.preferences()
        if (prefs.respondsToSelector_(b"setDeveloperExtrasEnabled:")
                and not getattr(sys, "frozen", False)):
            prefs.setDeveloperExtrasEnabled_(True)

        # Container view holds the WebView underneath and the drag overlay
        # on top. Both autoresize with the container; the container itself
        # autoresizes with its parent (set by callers via setContentView_).
        container = NSView.alloc().initWithFrame_(frame)
        container.setAutoresizingMask_(2 | 16)  # Width | Height

        wv = WKWebView.alloc().initWithFrame_configuration_(
            container.bounds(), config,
        )
        wv.setAutoresizingMask_(2 | 16)  # Width | Height
        # Audit #3 — pin WebView to local file:// content; off-bundle
        # URLs are bounced to the system browser instead of being loaded
        # in-place. Without this, a remote nav would expose window.lumo
        # to attacker JS via the polyfill that runs on every main frame.
        wv.setNavigationDelegate_(self.nav_fence)
        # CR #7 — UI delegate makes window.alert / confirm actually open
        # a native dialog. Without this, account-bind.js Sign out and
        # the more-menu Archive flow's confirm() returned false and the
        # user got nothing.
        wv.setUIDelegate_(self.ui_delegate)
        container.addSubview_(wv)

        # Drag overlay: top strip, full-width, transparent. Added AFTER the
        # WebView so it's on top in z-order and intercepts mouse-down
        # before WebKit can consume it. Anchored to top via NSViewMinYMargin
        # (4) + NSViewWidthSizable (2) so it sticks to the title strip on
        # resize and grows in width.
        cb = container.bounds()
        strip_origin_y = cb.size.height - _DRAG_STRIP_HEIGHT
        strip_frame = NSMakeRect(0, strip_origin_y, cb.size.width,
                                  _DRAG_STRIP_HEIGHT)
        overlay = LirelleDragOverlay.alloc().initWithFrame_(strip_frame)
        overlay.setAutoresizingMask_(2 | 4)  # Width | MinYMargin (sticks to top)
        # Transparent: no setWantsLayer / backgroundColor → renders
        # invisible. The HTML beneath still paints normally; mouse events
        # just hit this overlay first.
        container.addSubview_(overlay)

        self._webview = wv
        self._container = container
        self.bridge.attach_webview(wv)
        return container

    def load_section(self, section: str) -> None:
        """Swap the WebView to a new section's HTML. Idempotent — calling
        with the same section reloads (useful after data changes).

        If a `{section}-bind.js` file exists in ui/, append it as a
        <script> tag inside the loaded HTML so the data-binder runs after
        the static markup parses."""
        html_name = SECTION_HTML.get(section)
        if html_name is None:
            print(f"[web_shell] unknown section: {section}", flush=True)
            return
        html_path = ui_dir() / html_name
        if not html_path.exists():
            print(f"[web_shell] missing HTML: {html_path}", flush=True)
            return
        base_url = NSURL.fileURLWithPath_(str(ui_dir()))
        try:
            html = html_path.read_text(encoding="utf-8")
        except OSError as e:
            print(f"[web_shell] couldn't read {html_path}: {e}", flush=True)
            return

        # Inject section-specific binder if present (e.g. ui/meetings-bind.js).
        binder_path = ui_dir() / f"{section}-bind.js"
        if binder_path.exists():
            try:
                binder_js = binder_path.read_text(encoding="utf-8")
                # Inline the script — keeps base_url scoping simple.
                tag = f"\n<script>\n{binder_js}\n</script>\n"
                if "</body>" in html:
                    html = html.replace("</body>", tag + "</body>")
                else:
                    html = html + tag
            except OSError as e:
                print(f"[web_shell] couldn't read binder {binder_path}: {e}",
                      flush=True)

        def _do():
            if self._webview is not None:
                self._webview.loadHTMLString_baseURL_(html, base_url)
        AppHelper.callAfter(_do)
        self._current_section = section

    @property
    def current_section(self) -> Optional[str]:
        return self._current_section
