/* ==========================================================================
 * dtmcp-app.js — DEVONthink MCP Apps shared runtime
 *
 * Inlined into each app's index.html at +load time by
 * MCPSharedAppAssets.  Exposes a single global namespace `DTMCP` so apps
 * can `DTMCP.host`, `DTMCP.keyboardList(...)`, etc.
 *
 * Apps must use the bridge below instead of hand-rolling postMessage
 * scaffolding — the spec wiring (the `appInfo` handshake, the
 * `ui/notifications/*` topic names, the way structured content rides
 * inside `ui/notifications/tool-result`) has been a recurring source of
 * silent breakage and belongs in one place.
 * ========================================================================== */

(function (root)
{
    "use strict";

    // ============================================================
    //  Host bridge — postMessage transport + RPC
    // ============================================================

    function HostBridge()
    {
        this._nextId   = 1;
        this._pending  = Object.create(null);
        this._toolResultListeners  = [];
        this._initListeners        = [];
        this._hostContextListeners = [];
        this._initialized = false;
        var self = this;
        window.addEventListener("message", function (e) { self._onMessage(e); });
    }

    HostBridge.prototype._post = function (msg)
    {
        try { window.parent.postMessage(msg, "*"); }
        catch (_) { /* host not ready */ }
    };

    HostBridge.prototype._request = function (method, params)
    {
        var id   = this._nextId++;
        var self = this;
        return new Promise(function (resolve, reject) {
            self._pending[id] = { resolve: resolve, reject: reject };
            self._post({ jsonrpc: "2.0", id: id, method: method, params: params || {} });
        });
    };

    HostBridge.prototype._notify = function (method, params)
    {
        this._post({ jsonrpc: "2.0", method: method, params: params || {} });
    };

    HostBridge.prototype._onMessage = function (event)
    {
        var msg = event.data;
        if (!msg || typeof msg !== "object") return;

        // Response to a request we sent.
        if (msg.id != null && this._pending[msg.id])
        {
            var p = this._pending[msg.id];
            delete this._pending[msg.id];
            if (msg.error) p.reject(new Error(msg.error.message || "RPC error"));
            else           p.resolve(msg.result);
            return;
        }

        if (msg.method === "ui/notifications/tool-result")
        {
            // The host delivers the CallToolResult AS msg.params (per spec).
            var result = msg.params || {};
            for (var i = 0; i < this._toolResultListeners.length; i++)
            {
                try { this._toolResultListeners[i](result); }
                catch (e) { /* listener error must not break the bridge */ }
            }
            return;
        }

        /* Host pushes display-mode (and other context) changes here.  Fires
         * both after our own setDisplayMode() and when the user toggles
         * fullscreen via the native window chrome (green button / Esc),
         * so apps that re-layout on mode change should hook this rather
         * than relying on the setDisplayMode() return value alone. */
        if (msg.method === "ui/notifications/host-context-changed")
        {
            var ctx = msg.params || {};
            for (var k = 0; k < this._hostContextListeners.length; k++)
            {
                try { this._hostContextListeners[k](ctx); }
                catch (e) { /* listener error must not break the bridge */ }
            }
        }
    };

    /* Initialize handshake.  Sends `appInfo` and `appCapabilities` per
     * the MCP Apps spec rev 2026-01-26 (`src/generated/schema.ts` in the
     * upstream ext-apps repo is the source of truth).  Both keys are
     * required — the host's Zod validator silently drops the request if
     * either is missing or misnamed (e.g. `capabilities` instead of
     * `appCapabilities`, `clientInfo` instead of `appInfo`), the bridge
     * never sees a response, and `ui/notifications/initialized` never
     * fires, so the host (per spec) holds back tool-result and the
     * iframe stalls forever.  Apps may pass `appCapabilities` with
     * `availableDisplayModes` to advertise fullscreen support, and an
     * optional `{preferredHeight}` so the bridge fires an initial
     * `ui/notifications/size-changed` and Cowork gives us a tall enough
     * iframe instead of its 150 px default. */
    HostBridge.prototype.initialize = function (appInfo, appCapabilities, options)
    {
        if (this._initialized) return Promise.resolve();
        var self = this;
        var preferredHeight = (options && typeof options.preferredHeight === "number")
                                ? options.preferredHeight
                                : 900;
        var autoHeightFn    = (options && typeof options.autoHeight === "function")
                                ? options.autoHeight
                                : null;
        return this._request("ui/initialize", {
            protocolVersion: "2026-01-26",
            appInfo:         appInfo         || { name: "dtmcp-app", version: "1.0.0" },
            appCapabilities: appCapabilities || {}
        }).then(function (result) {
            self._initialized = true;
            self._notify("ui/notifications/initialized", {});
            /* If the app supplied an `autoHeight` curve, install the
             * width-observer immediately — its first tick re-sends the
             * size notification with `fn(initialWidth)`, so we don't
             * also need the static `preferredHeight` notification (it
             * would just be overwritten ~16 ms later). */
            if (autoHeightFn)
            {
                try { self.autoHeight(autoHeightFn); } catch (_) {}
            }
            else if (!self._sizeAnnounced && preferredHeight > 0)
            {
                try { self.setSize({ height: preferredHeight }); } catch (_) {}
            }
            try { self._wireFullscreenButtons(result); } catch (_) {}
            for (var i = 0; i < self._initListeners.length; i++)
            {
                try { self._initListeners[i](result); }
                catch (_) {}
            }
            return result;
        }).catch(function (err) {
            // Host may not implement ui/initialize — still treat the bridge
            // as live so subsequent tool-result fan-out works.
            self._initialized = true;
            return Promise.reject(err);
        });
    };

    HostBridge.prototype.onToolResult  = function (cb) { this._toolResultListeners.push(cb); };
    HostBridge.prototype.onInitialized = function (cb) { this._initListeners.push(cb); };

    /* Auto-wire any element with class `dtm-fullscreen-btn`:
     * - if the host supports fullscreen, the element is shown and a
     *   click handler fires `ui/request-display-mode` with mode
     *   "fullscreen";
     * - otherwise the element stays hidden.
     * Apps drop `<button class="dtm-fullscreen-btn dtm-button dtm-button--icon"
     *   hidden title="Fullscreen">⛶</button>` into their toolbar and the
     * bridge handles capability detection + wiring.  Knowledge Atlas
     * predates this helper and uses its own `#fullscreen-btn` wiring;
     * either pattern works. */
    HostBridge.prototype._wireFullscreenButtons = function (initResult)
    {
        /* Spec rev 2026-01-26 puts `availableDisplayModes` under
         * `hostContext`; Claude Desktop (and our own MCPAppViewController)
         * also expose it under `hostCapabilities` for liberal interop, so
         * read both. */
        var capModes = (initResult && initResult.hostCapabilities &&
                        initResult.hostCapabilities.availableDisplayModes) || [];
        var ctxModes = (initResult && initResult.hostContext &&
                        initResult.hostContext.availableDisplayModes) || [];
        var modes    = capModes.concat(ctxModes);
        var available = false;
        for (var i = 0; i < modes.length; i++)
        {
            if (modes[i] === "fullscreen") { available = true; break; }
        }
        var btns = document.querySelectorAll(".dtm-fullscreen-btn");
        var self = this;
        for (var j = 0; j < btns.length; j++)
        {
            var btn = btns[j];
            if (!available) { btn.hidden = true; continue; }
            btn.hidden = false;
            btn.addEventListener("click", function () {
                /* Three failure modes we have to handle distinctly:
                 *   1. promise rejects   → host returned an error
                 *   2. resolves with `mode` != "fullscreen" → silent decline
                 *      (spec-allowed: "Host SHOULD return the current
                 *      display mode in the response" if it can't honour)
                 *   3. promise never settles → host swallowed the request
                 *      without responding (observed: Claude Desktop chat
                 *      with stdio-installed MCP servers, May 2026)
                 * (3) is the worst UX — the button looks dead.  Race the
                 * request against a 4-second timeout so we always toast
                 * something. */
                var settled = false;
                var timeoutId = setTimeout(function () {
                    if (settled) return;
                    settled = true;
                    toast("Host did not respond to fullscreen request",
                          { kind: "warn" });
                }, 4000);
                self.setDisplayMode("fullscreen").then(function (result) {
                    if (settled) return; settled = true;
                    clearTimeout(timeoutId);
                    var got = result && result.mode;
                    if (got === "fullscreen")
                    {
                        /* Host claims success — if the visual hasn't
                         * actually changed it's still a host bug, but
                         * we've done our part. */
                        return;
                    }
                    toast("Host declined fullscreen (stayed " + (got || "unchanged") + ")",
                          { kind: "warn" });
                }).catch(function (err) {
                    if (settled) return; settled = true;
                    clearTimeout(timeoutId);
                    toast(err && err.message ? err.message
                                             : "Fullscreen unavailable",
                          { kind: "error" });
                });
            });
        }
    };

    /* Hard floor on the iframe height we ever request.  Our apps are
     * full-screen layouts (toolbar + body + meta strip); below 400 px
     * the body content gets cropped before the user can interact with
     * it.  Cowork (and any host that honours size-changed) will never
     * be told to make our iframe smaller than this. */
    var MIN_IFRAME_HEIGHT = 400;

    /* Tell the host to size the iframe via `ui/notifications/size-changed`.
     * Without this notification Cowork leaves the iframe at its default
     * (~150 px) and our apps' `min-height:100vh` just fills that small
     * box — circular sizing.  Our apps are designed to fill space, not
     * flow naturally, so the upstream ResizeObserver+`max-content` trick
     * doesn't help (`vh` resolves against the iframe's current size, so
     * intrinsic height collapses to 100vh of nothing).  Apps declare a
     * preferred height; the bridge fires it once after init. */
    HostBridge.prototype.setSize = function (params)
    {
        var p = params || {};
        var msg = {};
        if (typeof p.width  === "number") msg.width  = Math.ceil(p.width);
        if (typeof p.height === "number")
        {
            msg.height = Math.max(MIN_IFRAME_HEIGHT, Math.ceil(p.height));
            this._lastAnnouncedHeight = msg.height;
        }
        this._notify("ui/notifications/size-changed", msg);
        this._sizeAnnounced = true;
    };

    /* Shorthand for the common case where only the height needs to
     * change.  Idempotent — a no-op when called with the same height
     * we last announced, so apps can wire it to a ResizeObserver
     * without worrying about chatty notifications. */
    HostBridge.prototype.requestHeight = function (px)
    {
        if (typeof px !== "number" || !isFinite(px) || px <= 0) return;
        var h = Math.max(MIN_IFRAME_HEIGHT, Math.ceil(px));
        if (this._lastAnnouncedHeight === h) return;
        this._lastAnnouncedHeight = h;
        this.setSize({ height: h });
    };

    /* Observe `document.body`'s `scrollHeight` and request that height
     * from the host whenever it changes.  Use this when the app uses
     * natural-flow layout (no `height: 100vh`) at narrow widths and
     * wants the host to grow the iframe to fit content instead of
     * scrolling internally — chat-side hosts usually don't expose an
     * inner scrollbar so internal scroll regions trap content.
     *
     * Apps that switch between fixed-height (wide) and natural-flow
     * (narrow) layouts should still call this — when body is sized to
     * 100vh, `scrollHeight === clientHeight` so the request matches
     * the iframe and is a no-op via `requestHeight`'s dedupe. */
    HostBridge.prototype.fitToContent = function (opts)
    {
        opts = opts || {};
        var minH    = (typeof opts.minHeight === "number") ? opts.minHeight : 0;
        var maxH    = (typeof opts.maxHeight === "number") ? opts.maxHeight : 8000;
        var element = opts.element || document.body;
        if (!element) return function () {};
        var self   = this;
        var rafTok = 0;
        function tick()
        {
            rafTok = 0;
            /* `scrollHeight` is the height of the content including any
             * overflow — even when the element is clipping via
             * `overflow:hidden`, this returns the natural height we
             * want.  Falling back to `getBoundingClientRect().height`
             * for the documentElement case where scrollHeight may
             * report the viewport. */
            var h = Math.max(element.scrollHeight || 0,
                             (element.getBoundingClientRect &&
                              element.getBoundingClientRect().height) || 0);
            if (h <= 0) return;
            if (minH > 0 && h < minH) h = minH;
            if (maxH > 0 && h > maxH) h = maxH;
            self.requestHeight(h);
        }
        function schedule()
        {
            if (rafTok) return;
            rafTok = requestAnimationFrame(tick);
        }
        if (typeof ResizeObserver === "function")
        {
            try
            {
                var ro = new ResizeObserver(schedule);
                ro.observe(element);
                /* Layout reflows on container-query breakpoints don't
                 * always notify ResizeObserver immediately (the body
                 * stays the same size, only its children re-layout) —
                 * also observe documentElement so width changes trigger
                 * a recompute. */
                if (element !== document.documentElement)
                {
                    try { ro.observe(document.documentElement); } catch (_) {}
                }
                schedule();
                return function () { try { ro.disconnect(); } catch (_) {} };
            }
            catch (_) { /* fall through */ }
        }
        window.addEventListener("resize", schedule);
        schedule();
        return function () { window.removeEventListener("resize", schedule); };
    };

    /* Install a width-aware height curve.  `heightFor(width)` is called
     * whenever the iframe's documentElement width changes (rAF-coalesced)
     * and its return value is requested from the host via
     * `requestHeight()`.  Used by apps whose narrow layout stacks panels
     * vertically and therefore needs more vertical room than the wide
     * layout — Recent Activity, Daily Brief, etc.  Calls back through
     * `requestHeight` so the deduplication there suppresses no-op
     * notifications when only width changed.
     *
     * Returns an `unobserve` function so apps can tear down if needed
     * (rarely useful — the bridge lives for the iframe lifetime). */
    HostBridge.prototype.autoHeight = function (heightFor)
    {
        if (typeof heightFor !== "function") return function () {};
        var self    = this;
        var rafTok  = 0;
        function tick()
        {
            rafTok = 0;
            var w = (document.documentElement && document.documentElement.clientWidth) ||
                    window.innerWidth || 0;
            var h;
            try { h = heightFor(w); } catch (_) { return; }
            if (typeof h === "number" && isFinite(h) && h > 0)
            {
                self.requestHeight(h);
            }
        }
        function schedule()
        {
            if (rafTok) return;
            rafTok = requestAnimationFrame(tick);
        }
        if (typeof ResizeObserver === "function")
        {
            try
            {
                var ro = new ResizeObserver(schedule);
                ro.observe(document.documentElement);
                schedule();   /* fire once with the initial width */
                return function () { try { ro.disconnect(); } catch (_) {} };
            }
            catch (_) { /* fall through to the window-resize fallback */ }
        }
        window.addEventListener("resize", schedule);
        schedule();
        return function () { window.removeEventListener("resize", schedule); };
    };

    /* Convenience tool calls.  Resolve with the parsed result data
     * (`structuredContent` if present, otherwise the JSON in the first
     * text content block), or reject with the tool's error text. */
    HostBridge.prototype.callTool = function (name, args)
    {
        return this._request("tools/call", { name: name, arguments: args || {} })
                   .then(parseToolResult);
    };

    HostBridge.prototype.openRecord = function (uuid, dbUUID)
    {
        var args = { uuid: uuid };
        if (dbUUID) args.database_uuid = dbUUID;
        return this.callTool("open_record", args);
    };

    HostBridge.prototype.requestAI = function (params)
    {
        // sampling/createMessage — the host LLM completes a prompt.
        return this._request("sampling/createMessage", params || {});
    };

    HostBridge.prototype.setDisplayMode = function (mode)
    {
        return this._request("ui/request-display-mode", { mode: mode });
    };

    HostBridge.prototype.onHostContextChanged = function (cb)
    {
        this._hostContextListeners.push(cb);
    };

    /* Parses an MCP CallToolResult into { ok, data, error, raw }.
     * Throws on protocol errors via the returned promise; recovers
     * structuredContent or JSON-in-text. */
    function parseToolResult(result)
    {
        if (!result || typeof result !== "object")
        {
            throw new Error("Empty tool result");
        }
        if (result.isError)
        {
            var msg = "Tool error";
            if (Array.isArray(result.content))
            {
                for (var i = 0; i < result.content.length; i++)
                {
                    var c = result.content[i];
                    if (c && c.type === "text" && typeof c.text === "string")
                    {
                        msg = c.text; break;
                    }
                }
            }
            var err = new Error(msg);
            err.toolError = true;
            err.raw = result;
            throw err;
        }
        var data = result.structuredContent;
        if (data === undefined && Array.isArray(result.content))
        {
            for (var j = 0; j < result.content.length; j++)
            {
                var cc = result.content[j];
                if (cc && cc.type === "text" && typeof cc.text === "string")
                {
                    var trimmed = cc.text.replace(/^\s+/, "");
                    if (trimmed.charAt(0) === "{" || trimmed.charAt(0) === "[")
                    {
                        try { data = JSON.parse(cc.text); } catch (_) {}
                    }
                    /* Plain-text tools (e.g. chat_response without
                     * `format`) emit a single text block with no
                     * structuredContent and no JSON.  Hand the raw text
                     * back so callers don't have to reach into `raw`. */
                    if (data === undefined) data = cc.text;
                    break;
                }
            }
        }
        return data;
    }

    // ============================================================
    //  Keyboard navigation for .dtm-list / .dtm-table
    // ============================================================

    function KeyboardList(container, opts)
    {
        opts = opts || {};
        this.container   = container;
        this.itemSelector = opts.itemSelector || ".dtm-list__row, .dtm-table tbody tr";
        this.selectedClass = opts.selectedClass || "is-selected";
        this.multiselect  = !!opts.multiselect;
        this.onActivate   = opts.onActivate || null;
        this.onSelect     = opts.onSelect || null;
        this.onToggle     = opts.onToggle || null;
        this._focusedIdx  = -1;
        this._typeahead   = "";
        this._typeaheadAt = 0;
        this._bind();
    }

    KeyboardList.prototype._items = function ()
    {
        return this.container.querySelectorAll(this.itemSelector);
    };

    KeyboardList.prototype._bind = function ()
    {
        var self = this;
        this.container.addEventListener("keydown", function (e) { self._onKey(e); });
        this.container.addEventListener("click",   function (e) { self._onClick(e); });
        if (!this.container.hasAttribute("tabindex"))
        {
            this.container.setAttribute("tabindex", "0");
        }
    };

    KeyboardList.prototype._setFocus = function (idx)
    {
        var items = this._items();
        if (!items.length) { this._focusedIdx = -1; return; }
        if (idx < 0)            idx = 0;
        if (idx >= items.length) idx = items.length - 1;
        this._focusedIdx = idx;
        var el = items[idx];
        if (el && typeof el.focus === "function")
        {
            if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
            el.focus();
        }
        if (el && typeof el.scrollIntoView === "function")
        {
            el.scrollIntoView({ block: "nearest" });
        }
    };

    KeyboardList.prototype._select = function (el, additive)
    {
        if (!el) return;
        if (!additive && !this.multiselect)
        {
            var prev = this.container.querySelectorAll("." + this.selectedClass);
            for (var i = 0; i < prev.length; i++)
            {
                prev[i].classList.remove(this.selectedClass);
            }
        }
        el.classList.add(this.selectedClass);
        if (this.onSelect) this.onSelect(el);
    };

    KeyboardList.prototype._toggle = function (el)
    {
        if (!el) return;
        var on = !el.classList.contains(this.selectedClass);
        if (on) el.classList.add(this.selectedClass);
        else    el.classList.remove(this.selectedClass);
        if (this.onToggle) this.onToggle(el, on);
    };

    KeyboardList.prototype._activate = function (el)
    {
        if (this.onActivate) this.onActivate(el);
    };

    KeyboardList.prototype._onClick = function (e)
    {
        var items = this._items();
        for (var i = 0; i < items.length; i++)
        {
            if (items[i].contains(e.target))
            {
                this._focusedIdx = i;
                if (this.multiselect && (e.metaKey || e.ctrlKey)) this._toggle(items[i]);
                else                                              this._select(items[i], false);
                break;
            }
        }
    };

    KeyboardList.prototype._typeaheadJump = function (ch)
    {
        var now = Date.now();
        if (now - this._typeaheadAt > 800) this._typeahead = "";
        this._typeaheadAt = now;
        this._typeahead += ch.toLowerCase();
        var items = this._items();
        for (var i = 0; i < items.length; i++)
        {
            var idx  = (this._focusedIdx + 1 + i) % items.length;
            var text = (items[idx].textContent || "").trim().toLowerCase();
            if (text.indexOf(this._typeahead) === 0)
            {
                this._setFocus(idx);
                this._select(items[idx], false);
                return true;
            }
        }
        return false;
    };

    KeyboardList.prototype._onKey = function (e)
    {
        var items = this._items();
        if (!items.length) return;
        var key = e.key;
        var handled = false;

        switch (key)
        {
            case "ArrowDown":
                this._setFocus(this._focusedIdx + 1);
                this._select(items[this._focusedIdx], false);
                handled = true; break;
            case "ArrowUp":
                this._setFocus(this._focusedIdx <= 0 ? 0 : this._focusedIdx - 1);
                this._select(items[this._focusedIdx], false);
                handled = true; break;
            case "Home":
                this._setFocus(0);
                this._select(items[this._focusedIdx], false);
                handled = true; break;
            case "End":
                this._setFocus(items.length - 1);
                this._select(items[this._focusedIdx], false);
                handled = true; break;
            case "PageDown":
                this._setFocus(this._focusedIdx + 10);
                this._select(items[this._focusedIdx], false);
                handled = true; break;
            case "PageUp":
                this._setFocus(this._focusedIdx - 10);
                this._select(items[this._focusedIdx], false);
                handled = true; break;
            case "Enter":
                if (this._focusedIdx >= 0) { this._activate(items[this._focusedIdx]); handled = true; }
                break;
            case " ":
                if (this.multiselect && this._focusedIdx >= 0)
                {
                    this._toggle(items[this._focusedIdx]); handled = true;
                }
                break;
            case "Escape":
                var sel = this.container.querySelectorAll("." + this.selectedClass);
                for (var i = 0; i < sel.length; i++) sel[i].classList.remove(this.selectedClass);
                handled = true; break;
            default:
                // Type-ahead: single printable character with no modifier.
                if (key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey)
                {
                    if (/[\w\-]/.test(key))
                    {
                        if (this._typeaheadJump(key)) handled = true;
                    }
                }
                if ((e.metaKey || e.ctrlKey) && key.toLowerCase() === "a" && this.multiselect)
                {
                    for (var j = 0; j < items.length; j++) items[j].classList.add(this.selectedClass);
                    handled = true;
                }
                break;
        }
        if (handled) { e.preventDefault(); e.stopPropagation(); }
    };

    KeyboardList.prototype.refresh = function ()
    {
        // Items list re-rendered — re-clamp focused index and re-apply
        // tabindex on the current item so the next ↑/↓ doesn't have to
        // hunt for a focused element that no longer exists in the DOM.
        var items = this._items();
        if (!items.length) { this._focusedIdx = -1; return; }
        if (this._focusedIdx >= items.length) this._focusedIdx = items.length - 1;
        if (this._focusedIdx < 0) return;
        var el = items[this._focusedIdx];
        if (el && !el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
        // Re-focus only when the previous focus belonged to this list — don't
        // steal focus from an unrelated input (e.g. the toolbar's search box).
        if (el && document.activeElement &&
            (this.container.contains(document.activeElement) || document.activeElement === document.body))
        {
            try { el.focus({ preventScroll: true }); } catch (_) { try { el.focus(); } catch (__) {} }
        }
    };

    KeyboardList.prototype.focusFirst = function () { this._setFocus(0); };

    /* Move focus + selection to the first item matching `predicate(el)`.
     * Useful after a re-render when the host wants to keep selection on
     * a specific record (e.g. the currently-selected UUID in Catalyst). */
    KeyboardList.prototype.focusItem = function (predicate)
    {
        var items = this._items();
        for (var i = 0; i < items.length; i++)
        {
            if (predicate(items[i])) { this._focusedIdx = i; this._select(items[i], false); return true; }
        }
        return false;
    };

    /* Mark an item as selected without firing onSelect — used when the
     * host's own selection change shouldn't loop back through the list. */
    KeyboardList.prototype.markSelected = function (predicate)
    {
        var items = this._items();
        var prev = this.container.querySelectorAll("." + this.selectedClass);
        for (var i = 0; i < prev.length; i++) prev[i].classList.remove(this.selectedClass);
        for (var j = 0; j < items.length; j++)
        {
            if (predicate(items[j])) { items[j].classList.add(this.selectedClass); this._focusedIdx = j; return; }
        }
    };

    // ============================================================
    //  App-level hotkeys (?, R, /, …)
    // ============================================================

    function Hotkeys()
    {
        this._handlers = [];
        this._helpEl   = null;
        var self = this;
        window.addEventListener("keydown", function (e) { self._onKey(e); });
    }

    Hotkeys.prototype.register = function (keys, description, handler)
    {
        // `keys` may be a string ("?") or array of strings (["r", "R"]).
        var arr = Array.isArray(keys) ? keys : [keys];
        this._handlers.push({ keys: arr, description: description, handler: handler });
    };

    Hotkeys.prototype._onKey = function (e)
    {
        // Skip ALL hotkeys (including "?") when focus is in a text input
        // or contenteditable.  Previously "?" leaked through and toggled
        // the help overlay mid-typing.
        var t = e.target;
        var tag = t && t.tagName ? t.tagName.toLowerCase() : "";
        var inEditable = (tag === "input" && t.type !== "checkbox" && t.type !== "radio") ||
                         tag === "textarea" || (t && t.isContentEditable);
        if (inEditable && e.key !== "Escape") return;

        // While any modal overlay is up, app hotkeys would fire behind
        // it (e.g. Enter re-triggering the destructive action the dialog
        // is asking the user to confirm).  Modals own their keys via
        // their own listeners (Escape-dismiss etc.).
        if (document.querySelector(".dtm-modal-host")) return;

        // "?" (Shift+/) — toggle help overlay.  Suppress entirely when
        // the app didn't register any hotkeys; an empty help panel is
        // worse than no help affordance at all.
        if (e.key === "?" && !e.metaKey && !e.ctrlKey && this._handlers.length > 0)
        {
            this.toggleHelp();
            e.preventDefault();
            return;
        }

        for (var i = 0; i < this._handlers.length; i++)
        {
            var h = this._handlers[i];
            for (var j = 0; j < h.keys.length; j++)
            {
                if (e.key === h.keys[j])
                {
                    try { h.handler(e); }
                    catch (_) {}
                    e.preventDefault();
                    return;
                }
            }
        }
    };

    /* Drop every registered hotkey.  Apps whose UI language can change
     * at runtime re-register afterwards so the "?" overlay is not left
     * describing the shortcuts in the previous language. */
    Hotkeys.prototype.reset = function ()
    {
        this._handlers = [];
        this.hideHelp();
    };

    Hotkeys.prototype.toggleHelp = function ()
    {
        if (this._helpEl) { this.hideHelp(); }
        else              { this.showHelp(); }
    };

    /* `e.key` names like "ArrowDown" or "Escape" read like JS identifiers
     * in the help overlay — translate the common navigation keys to the
     * glyph the user actually presses on their keyboard. */
    var KEY_GLYPHS = {
        ArrowUp:    "↑",
        ArrowDown:  "↓",
        ArrowLeft:  "←",
        ArrowRight: "→",
        Escape:     "Esc",
        Enter:      "↵",
        " ":        "Space"
    };
    function formatKey(k) { return KEY_GLYPHS[k] || k; }

    Hotkeys.prototype.showHelp = function ()
    {
        if (this._helpEl) return;
        var el = document.createElement("div");
        el.className = "dtm-hotkeys";
        var rows = '<div class="dtm-hotkeys__title">Keyboard shortcuts</div>';
        rows += '<div class="dtm-hotkeys__row"><span class="dtm-hotkeys__keys">?</span>' +
                '<span class="dtm-hotkeys__desc">Toggle this help</span></div>';
        for (var i = 0; i < this._handlers.length; i++)
        {
            var h = this._handlers[i];
            var keys = [];
            for (var k = 0; k < h.keys.length; k++) keys.push(escapeHtml(formatKey(h.keys[k])));
            rows += '<div class="dtm-hotkeys__row">' +
                    '<span class="dtm-hotkeys__keys">' + keys.join(" / ") + '</span>' +
                    '<span class="dtm-hotkeys__desc">' + escapeHtml(h.description) + '</span>' +
                    '</div>';
        }
        el.innerHTML = rows;
        document.body.appendChild(el);
        this._helpEl = el;
    };

    Hotkeys.prototype.hideHelp = function ()
    {
        if (this._helpEl && this._helpEl.parentNode)
        {
            this._helpEl.parentNode.removeChild(this._helpEl);
        }
        this._helpEl = null;
    };

    // ============================================================
    //  Toast + modal
    // ============================================================

    function ensureToastHost()
    {
        var host = document.querySelector(".dtm-toast-host");
        if (host) return host;
        host = document.createElement("div");
        host.className = "dtm-toast-host";
        document.body.appendChild(host);
        return host;
    }

    function toast(message, opts)
    {
        opts = opts || {};
        var host = ensureToastHost();
        var el = document.createElement("div");
        el.className = "dtm-toast" + (opts.kind ? " dtm-toast--" + opts.kind : "");
        el.textContent = message;
        host.appendChild(el);
        var ttl = opts.ttl || 4000;
        setTimeout(function () {
            if (el.parentNode) el.parentNode.removeChild(el);
        }, ttl);
    }

    // ============================================================
    //  Status surfaces — shared loading / error / empty / busy chrome.
    //  Apps drive these via DTMCP.status.* instead of hand-rolling
    //  their own #status / #busy elements and spinner keyframes.
    //  Colour rule: "error" is red (genuine failures only); "loading"
    //  and "empty"/"info" are neutral.
    // ============================================================

    function ensurePositioned(el)
    {
        /* The overlays are position:absolute; their host must establish
         * a containing block. */
        if (getComputedStyle(el).position === "static")
        {
            el.style.position = "relative";
        }
    }

    var status = {
        /* Show / update / clear a centred message layered over a
         * visualization host.  `kind`: "loading" (spinner + text),
         * "error" (red), "empty" / "info" (neutral), or a falsy value
         * to clear.  Idempotent — safe to call on every render. */
        overlay: function (hostEl, text, kind)
        {
            if (!hostEl) return;
            var box = hostEl.querySelector(":scope > .dtm-statusbox");
            if (!text)
            {
                if (box && box.parentNode) box.parentNode.removeChild(box);
                return;
            }
            ensurePositioned(hostEl);
            if (!box)
            {
                box = document.createElement("div");
                box.className = "dtm-statusbox";
                hostEl.appendChild(box);
            }
            box.classList.toggle("is-error", kind === "error");
            box.textContent = "";
            if (kind === "loading")
            {
                var sp = document.createElement("span");
                sp.className = "dtm-spinner";
                box.appendChild(sp);
            }
            var lbl = document.createElement("span");
            lbl.textContent = text;
            box.appendChild(lbl);
        },

        /* Toggle a full-canvas spinner overlay on a host element. */
        busy: function (hostEl, active, label)
        {
            if (!hostEl) return;
            var box = hostEl.querySelector(":scope > .dtm-busy");
            if (!active)
            {
                if (box) box.classList.remove("is-active");
                return;
            }
            ensurePositioned(hostEl);
            if (!box)
            {
                box = document.createElement("div");
                box.className = "dtm-busy";
                var sp = document.createElement("span");
                sp.className = "dtm-spinner";
                var lbl = document.createElement("span");
                lbl.className = "dtm-busy__label";
                box.appendChild(sp);
                box.appendChild(lbl);
                hostEl.appendChild(box);
            }
            var lblEl = box.querySelector(".dtm-busy__label");
            if (lblEl) lblEl.textContent = label || "Working…";
            box.classList.add("is-active");
        },

        /* Meta-strip loading placeholder. */
        metaLoading: function (metaEl, text)
        {
            if (!metaEl) return;
            metaEl.classList.add("dtm-meta--placeholder");
            metaEl.textContent = text || "Loading…";
        },

        /* Meta-strip error caption (genuine failures only). */
        metaError: function (metaEl, message)
        {
            if (!metaEl) return;
            metaEl.classList.remove("dtm-meta--placeholder");
            var span = document.createElement("span");
            span.className = "dtm-meta__error";
            span.textContent = message || "Tool error";
            metaEl.textContent = "";
            metaEl.appendChild(span);
        }
    };

    function confirm(message, opts)
    {
        opts = opts || {};
        return new Promise(function (resolve) {
            var overlay = document.createElement("div");
            overlay.className = "dtm-modal-host";
            overlay.innerHTML =
                '<div class="dtm-modal" role="dialog">' +
                  '<h3 class="dtm-modal__title">' + escapeHtml(opts.title || "Confirm") + '</h3>' +
                  '<div class="dtm-modal__body">'  + escapeHtml(message) + '</div>' +
                  '<div class="dtm-modal__footer">' +
                    '<button type="button" class="dtm-button" data-act="cancel">' +
                      escapeHtml(opts.cancel || "Cancel") + '</button>' +
                    '<button type="button" class="dtm-button dtm-button--primary" data-act="ok">' +
                      escapeHtml(opts.confirm || "OK") + '</button>' +
                  '</div>' +
                '</div>';
            document.body.appendChild(overlay);
            overlay.addEventListener("click", function (e) {
                var act = e.target && e.target.getAttribute && e.target.getAttribute("data-act");
                if (act === "ok" || act === "cancel" || e.target === overlay)
                {
                    document.body.removeChild(overlay);
                    resolve(act === "ok");
                }
            });
        });
    }

    // ============================================================
    //  Helpers
    // ============================================================

    function escapeHtml(s)
    {
        return String(s == null ? "" : s)
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;");
    }

    /* Strip lightweight markdown leftovers from snippet text — drops
     * image/link wrappers, code fences, and excess whitespace.  Used by
     * Research Canvas (and any other app pulling raw markdown from a
     * tool result that should render as plain text). */
    function stripMarkdown(s)
    {
        if (s == null) return "";
        s = String(s);
        s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, "");                 // images
        s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");              // links
        s = s.replace(/`{1,3}[^`]*`{1,3}/g, function (m) {
            return m.replace(/`/g, "");
        });
        s = s.replace(/^\s*#{1,6}\s+/gm, "");                        // headings
        s = s.replace(/\s+/g, " ");
        return s.trim();
    }

    // ============================================================
    //  Tag editor — shared component for display + editing of a
    //  record's tag list.  Apps mount it on a container and get a
    //  uniform pill UI; readonly mode suppresses the × buttons and
    //  the trailing input.  An optional `colorFor` callback drives
    //  the per-tag coloured left-edge stripe (used by Image Atlas).
    //  Commits via `set_record_tags` when a uuid is set.
    // ============================================================

    function tagEditor(container, opts)
    {
        opts = opts || {};
        if (!container) return null;

        var state = {
            uuid:     opts.uuid || null,
            tags:     [],
            readonly: !!opts.readonly,
            colorFor: typeof opts.colorFor === "function" ? opts.colorFor : null,
            mode:     opts.mode || "replace"
        };
        var listeners = [];

        container.classList.add("dtm-tag-editor");
        if (state.readonly) container.classList.add("is-readonly");

        function fire(tags)
        {
            for (var i = 0; i < listeners.length; i++)
            {
                try { listeners[i](tags); } catch (_) {}
            }
        }

        function flashError(msg)
        {
            container.classList.add("is-error");
            if (msg) container.title = msg;
            setTimeout(function () {
                container.classList.remove("is-error");
                container.title = "";
            }, 1800);
        }

        function commit(next)
        {
            if (!state.uuid)
            {
                state.tags = next;
                render();
                fire(state.tags);
                return;
            }
            container.classList.add("is-busy");
            container.classList.remove("is-error");
            DTMCP.host.callTool("set_record_tags", {
                uuid: state.uuid,
                mode: state.mode,
                tags: next
            }).then(function () {
                container.classList.remove("is-busy");
                state.tags = next;
                render();
                fire(state.tags);
            }).catch(function (err) {
                container.classList.remove("is-busy");
                flashError(err && err.message ? err.message : "Save failed");
            });
        }

        function render()
        {
            container.innerHTML = "";
            for (var i = 0; i < state.tags.length; i++)
            {
                (function (idx, t) {
                    var chip = document.createElement("span");
                    chip.className = "dtm-tag";
                    chip.appendChild(document.createTextNode(t));
                    if (state.colorFor)
                    {
                        var c = state.colorFor(t);
                        if (c)
                        {
                            chip.dataset.color = "1";
                            chip.style.setProperty("--tag-color", c);
                        }
                    }
                    if (!state.readonly)
                    {
                        var x = document.createElement("button");
                        x.type = "button"; x.title = "Remove tag";
                        x.textContent = "×";
                        x.addEventListener("click", function (e) {
                            e.stopPropagation();
                            var next = state.tags.slice();
                            next.splice(idx, 1);
                            commit(next);
                        });
                        chip.appendChild(x);
                    }
                    container.appendChild(chip);
                })(i, state.tags[i]);
            }
            if (!state.readonly)
            {
                var input = document.createElement("input");
                input.type = "text";
                input.placeholder = state.tags.length ? "" : "add tags…";
                input.addEventListener("focus", function () { container.classList.add("is-focused"); });
                input.addEventListener("blur",  function () { container.classList.remove("is-focused"); });
                input.addEventListener("keydown", function (e) {
                    if (e.key === "Enter" || e.key === ",")
                    {
                        e.preventDefault();
                        var v = (input.value || "").trim();
                        if (!v) return;
                        if (state.tags.indexOf(v) >= 0) { input.value = ""; return; }
                        var next = state.tags.slice();
                        next.push(v);
                        input.value = "";
                        commit(next);
                    }
                });
                container.appendChild(input);
            }
        }

        /* Container click focuses the input — makes the whole pill row
         * feel like one editable surface (matches Knowledge Atlas). */
        container.addEventListener("click", function (e) {
            if (state.readonly) return;
            if (e.target.closest(".dtm-tag")) return;
            var input = container.querySelector("input");
            if (input) input.focus();
        });

        var api = {
            setTags: function (tags) { state.tags = (tags || []).slice(); render(); },
            getTags: function ()     { return state.tags.slice(); },
            setUUID: function (u)    { state.uuid = u || null; },
            setReadonly: function (ro)
            {
                state.readonly = !!ro;
                container.classList.toggle("is-readonly", state.readonly);
                render();
            },
            setColorFor: function (fn) { state.colorFor = fn || null; render(); },
            onChange: function (cb)   { if (typeof cb === "function") listeners.push(cb); }
        };
        render();
        return api;
    }

    /* Watch `element` for size changes and call `fn` (rAF-coalesced).
     * Used by graph apps so vis-network re-fits on display-mode-change
     * (otherwise the labels/positions baked at fullscreen size overflow
     * the canvas after exit).  Returns an `unobserve` function. */
    function observeResize(element, fn)
    {
        if (!element || typeof fn !== "function") return function () {};
        var scheduled = false;
        function trigger()
        {
            if (scheduled) return;
            scheduled = true;
            requestAnimationFrame(function () { scheduled = false; fn(); });
        }
        try
        {
            var ro = new ResizeObserver(trigger);
            ro.observe(element);
            return function () { try { ro.disconnect(); } catch (_) {} };
        }
        catch (_)
        {
            window.addEventListener("resize", trigger);
            return function () { window.removeEventListener("resize", trigger); };
        }
    }

    /* Locale-aware string comparison.  Raw `<`/`>` on lower-cased
     * strings is pure code-point order, which dumps accented letters
     * (ä > z) at the wrong end of the list.  `sensitivity: "base"`
     * folds case + accents for primary ordering; `numeric: true`
     * sorts "Item 2" before "Item 10". */
    var _collator = null;
    function _getCollator()
    {
        if (_collator) return _collator;
        try
        {
            _collator = new Intl.Collator(undefined, {
                sensitivity: "base",
                numeric:     true
            });
        }
        catch (_)
        {
            _collator = { compare: function (a, b) {
                a = String(a == null ? "" : a).toLowerCase();
                b = String(b == null ? "" : b).toLowerCase();
                return a < b ? -1 : a > b ? 1 : 0;
            } };
        }
        return _collator;
    }
    function compareStrings(a, b)
    {
        return _getCollator().compare(
            String(a == null ? "" : a),
            String(b == null ? "" : b)
        );
    }

    /* Pretty-format an ISO date as "8 May 2026" (or fall back to the
     * input when parsing fails). */
    function formatDate(iso)
    {
        if (!iso) return "";
        var d = new Date(iso);
        if (isNaN(d.getTime())) return String(iso);
        var months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
        return d.getDate() + " " + months[d.getMonth()] + " " + d.getFullYear();
    }

    /* Canonical relative time — `just now`, `3m ago`, `4h ago`, `2d ago`
     * for the last week.  Returns "" past 7 days (and for empty / bad
     * input) so `formatTimestamp` can fall back to the absolute form.
     * Internal — apps call `formatTimestamp`, not this. */
    function formatRelative(iso)
    {
        if (!iso) return "";
        var d = new Date(iso);
        if (isNaN(d.getTime())) return "";
        var diff = (Date.now() - d.getTime()) / 1000;
        if (diff < 0)         diff = 0;
        if (diff < 60)        return "just now";
        if (diff < 3600)      return Math.floor(diff / 60)    + "m ago";
        if (diff < 86400)     return Math.floor(diff / 3600)  + "h ago";
        if (diff < 86400 * 7) return Math.floor(diff / 86400) + "d ago";
        return "";
    }

    /* The everyday record-timestamp formatter: relative within the last
     * 7 days (`4h ago`), absolute beyond it (`15 Mar 2026`).  Use this
     * for any record's date in a list / row / card; pair the displayed
     * string with `title={formatDateTime(iso)}` for the exact value. */
    function formatTimestamp(iso)
    {
        return formatRelative(iso) || formatDate(iso);
    }

    /* Full absolute date + time, locale-aware — for `title=` tooltips
     * on a `formatTimestamp` string so the exact value is one hover
     * away regardless of how the short form rendered. */
    function formatDateTime(iso)
    {
        if (!iso) return "";
        var d = new Date(iso);
        if (isNaN(d.getTime())) return String(iso);
        return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
    }

    /* Compute a sensible hop colour from the shared palette using CSS
     * custom properties — avoids hard-coding hex values in app code so
     * tokens stay in CSS. */
    function hopVar(idx)
    {
        var n = Math.max(0, Math.min(4, idx | 0));
        return "var(--dtm-hop-" + n + ")";
    }

    function hopColor(idx)
    {
        var n = Math.max(0, Math.min(4, idx | 0));
        var styles = getComputedStyle(document.documentElement);
        return (styles.getPropertyValue("--dtm-hop-" + n) || "").trim();
    }

    /* Read an rgb triplet token (e.g. `--dtm-accent-rgb`) and return an
     * rgba() string at the given alpha.  Lets JS-built colour values
     * stay design-system-driven instead of inlining the literal triple
     * — heatmaps and dynamic edge tints in topic-pulse / tag-workbench
     * use this. */
    function rgba(tokenName, alpha)
    {
        var styles = getComputedStyle(document.documentElement);
        var triple = (styles.getPropertyValue(tokenName) || "").trim();
        if (!triple) return "transparent";
        var a = (typeof alpha === "number" && isFinite(alpha)) ? alpha : 1;
        return "rgba(" + triple + ", " + a + ")";
    }

    // ============================================================
    //  Relevance score bars
    // ============================================================
    //
    // Builds the `.dtm-score` component — five vertical bars, N lit
    // from the left where N = round(score · 5).  `score` is a 0..1
    // relevance / similarity value.  Returns a detached element the
    // caller appends (or serialises via `.outerHTML`); returns `null`
    // when no usable score is given so the row collapses gracefully.

    function scoreBars(score)
    {
        if (typeof score !== "number" || !isFinite(score)) return null;
        var s   = Math.max(0, Math.min(1, score));
        var lit = Math.max(0, Math.min(5, Math.round(s * 5)));
        var el  = document.createElement("div");
        el.className = "dtm-score";
        el.title = "Relevance: " + lit + " of 5";
        for (var i = 1; i <= 5; i++)
        {
            var b = document.createElement("div");
            b.className = "dtm-score__bar dtm-score__bar--" + i + (i <= lit ? " is-on" : "");
            el.appendChild(b);
        }
        return el;
    }

    // ============================================================
    //  Singletons + namespace
    // ============================================================

    // ============================================================
    //  Progress bar helper
    // ============================================================
    //
    // Tiny adapter around the `.dtm-progress` CSS component.  Apps
    // hand it the bar element + (done, total) and the helper handles
    // determinate-vs-indeterminate state, percentage clamping, and
    // setting the embedded fill's width.  Returns a controller so
    // callers don't have to re-look-up the element on every tick.

    function progressController(elOrId)
    {
        var el = (typeof elOrId === "string") ? document.getElementById(elOrId) : elOrId;
        if (!el) return null;
        var fill = el.querySelector(".dtm-progress__fill");
        if (!fill)
        {
            fill = document.createElement("div");
            fill.className = "dtm-progress__fill";
            el.appendChild(fill);
        }

        function set(done, total)
        {
            if (typeof done !== "number" || !isFinite(done) ||
                typeof total !== "number" || !isFinite(total) || total <= 0)
            {
                /* No total → indeterminate sweep. */
                el.classList.add("is-indeterminate");
                fill.style.width = "";
                el.removeAttribute("data-percent");
                return;
            }
            el.classList.remove("is-indeterminate");
            var pct = Math.max(0, Math.min(100, (done / total) * 100));
            fill.style.width = pct.toFixed(1) + "%";
            el.setAttribute("data-percent", pct.toFixed(1));
        }

        function reset()
        {
            el.classList.remove("is-indeterminate");
            fill.style.width = "0";
            el.removeAttribute("data-percent");
        }

        return { el: el, set: set, reset: reset };
    }

    // ============================================================

    var DTMCP = {
        host:         new HostBridge(),
        hotkeys:      new Hotkeys(),
        keyboardList: function (container, opts) { return new KeyboardList(container, opts); },
        toast:        toast,
        status:       status,
        confirm:      confirm,
        escapeHtml:   escapeHtml,
        stripMarkdown: stripMarkdown,
        formatDate:   formatDate,
        formatTimestamp: formatTimestamp,
        formatDateTime:  formatDateTime,
        hopVar:       hopVar,
        hopColor:     hopColor,
        rgba:         rgba,
        observeResize: observeResize,
        compareStrings: compareStrings,
        tagEditor:    tagEditor,
        parseToolResult: parseToolResult,
        progress:     progressController,
        scoreBars:    scoreBars
    };

    root.DTMCP = DTMCP;

})(window);
