/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net

This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License version 3.0 as published by the Free Software Foundation;

This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301 USA
*/

// JS bridge for the CRAFTER_GRAPHICS_WINDOW_DOM build of Crafter.Graphics.
// Populates `window.crafter_webbuild_env` (same global as Crafter.CppDOM
// used) with the env imports the .wasm declares. Crafter.Build's
// runtime.js merges this object into the WebAssembly import object as
// the `env` module before instantiation.
//
// Three groups of imports:
//   1. DOM ops + 23 element-scoped listener kinds (port of CppDOM env.js,
//      plus createElement / getBody for the new element-creation API).
//   2. Window-level event hookup — DOM events delivered to a global
//      Window object on the C++ side. Set up when the C++ Window ctor
//      calls domAttachWindow().
//   3. requestAnimationFrame driver — domStartFrameLoop / domStopFrameLoop.

const __decoder = new TextDecoder();
const __encoder = new TextEncoder();

// jsmemory: integer cookie → live DOM element. Counter is monotone;
// 32-bit wrap is unlikely over a single page's lifetime but if it
// ever matters we'd switch to a free-list.
let __cookieCounter = 0;
const __jsmemory = new Map();
const __listenerHandlers = new Map();   // `${ptr}-${id}-${kind}` → JS handler

// Window-level (document / window) listeners installed by domAttachWindow.
// Indexed by event kind; each entry is the JS handler we'd remove if the
// user ever destroyed the Window. V1 only ever has one Window so we don't
// support tear-down; the listeners live for the page's lifetime.
const __windowListeners = {};
let __windowAttachedHandle = 0;  // cookie of the C++ Window for export dispatch
let __frameLoopActive = false;

function __wasm() { return window.crafter_webbuild_wasi.instance.exports; }
function __memBuf() { return window.crafter_webbuild_wasi.instance.exports.memory.buffer; }

function __readUtf8(ptr, len) {
    return __decoder.decode(new Uint8Array(__memBuf(), ptr, len));
}
function __writeUtf8(str) {
    const encoded = __encoder.encode(str + '\0');
    const ptr = __wasm().WasmAlloc(encoded.length);
    new Uint8Array(__memBuf(), ptr, encoded.length).set(encoded);
    return ptr;
}
function __storeElement(el) {
    __jsmemory.set(++__cookieCounter, el);
    return __cookieCounter;
}

// ─── DOM lookup / creation ────────────────────────────────────────────

function getElementById(idPtr, idLen) {
    try {
        const id = __readUtf8(idPtr, idLen);
        const el = document.getElementById(id);
        if (!el) {
            console.error(`Crafter.Dom: getElementById("${id}") → null`);
            return 0;
        }
        return __storeElement(el);
    } catch (err) {
        console.error(err);
        return 0;
    }
}

function createElement(parentCookie, tagPtr, tagLen, idPtr, idLen) {
    try {
        const tag = __readUtf8(tagPtr, tagLen);
        const el = document.createElement(tag);
        if (idLen > 0) {
            el.id = __readUtf8(idPtr, idLen);
        }
        const parent = __jsmemory.get(parentCookie);
        if (parent) {
            parent.appendChild(el);
        } else {
            // parent cookie 0 / not-found → append to body so the element
            // still ends up in the DOM (matches CreateInBody semantics
            // when the user passes a moved-from / default-constructed
            // HtmlElementPtr).
            document.body.appendChild(el);
        }
        return __storeElement(el);
    } catch (err) {
        console.error(err);
        return 0;
    }
}

function getBody() {
    return __storeElement(document.body);
}

function freeJs(cookie) {
    __jsmemory.delete(cookie);
}

// ─── DOM mutations ────────────────────────────────────────────────────

function setInnerHTML(cookie, htmlPtr, htmlLen) {
    const el = __jsmemory.get(cookie);
    if (el) el.innerHTML = __readUtf8(htmlPtr, htmlLen);
}
function setStyle(cookie, stylePtr, styleLen) {
    const el = __jsmemory.get(cookie);
    if (el) el.style.cssText = __readUtf8(stylePtr, styleLen);
}
function setProperty(cookie, propPtr, propLen, valPtr, valLen) {
    const el = __jsmemory.get(cookie);
    if (el) el.style.setProperty(__readUtf8(propPtr, propLen), __readUtf8(valPtr, valLen));
}
function addClass(cookie, namePtr, nameLen) {
    const el = __jsmemory.get(cookie);
    if (el) el.classList.add(__readUtf8(namePtr, nameLen));
}
function removeClass(cookie, namePtr, nameLen) {
    const el = __jsmemory.get(cookie);
    if (el) el.classList.remove(__readUtf8(namePtr, nameLen));
}
function toggleClass(cookie, namePtr, nameLen) {
    const el = __jsmemory.get(cookie);
    if (el) el.classList.toggle(__readUtf8(namePtr, nameLen));
}
function hasClass(cookie, namePtr, nameLen) {
    const el = __jsmemory.get(cookie);
    if (!el) return false;
    return el.classList.contains(__readUtf8(namePtr, nameLen));
}
function deleteElement(cookie) {
    const el = __jsmemory.get(cookie);
    if (el && el.parentNode) el.parentNode.removeChild(el);
}
function getValue(cookie) {
    const el = __jsmemory.get(cookie);
    if (!el || el.value === undefined) return 0;
    return __writeUtf8(el.value || "");
}
function setValue(cookie, valPtr, valLen) {
    const el = __jsmemory.get(cookie);
    if (el) el.value = __readUtf8(valPtr, valLen);
}

// ─── Element-scoped event listeners ───────────────────────────────────
//
// Each Add*Listener factory generates an addEventListener call whose
// handler marshals the event fields through to a specific C++ export.
// We key the handler in __listenerHandlers by `${cookie}-${id}-${kind}`
// so removeEventListener can re-find it. C++-side handler id counters
// are per-kind, so a per-kind suffix is what makes the keys unique.

// devicePixelRatio scaling factor. dom-webgpu.js sets window.crafter_dpr
// during its canvas sync so this side and the GPU side agree on a single
// physical-pixel coordinate space. Fallback to the live DPR if no GPU
// bridge ran (pure-CppDOM apps); ultimately fallback to 1 so non-HiDPI
// browsers behave as before.
function __dpr() {
    return window.crafter_dpr || window.devicePixelRatio || 1;
}

function __makeMouseListenerPair(kind, eventName, exportName) {
    return {
        add(cookie, id) {
            const el = __jsmemory.get(cookie);
            if (!el) return;
            const handler = (event) => {
                const s = __dpr();
                __wasm()[exportName](id,
                    event.clientX * s, event.clientY * s,
                    event.screenX * s, event.screenY * s,
                    event.button, event.buttons,
                    event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
            };
            __listenerHandlers.set(`${cookie}-${id}-${kind}`, handler);
            el.addEventListener(eventName, handler);
        },
        remove(cookie, id) {
            const el = __jsmemory.get(cookie);
            const key = `${cookie}-${id}-${kind}`;
            const handler = __listenerHandlers.get(key);
            if (el && handler) el.removeEventListener(eventName, handler);
            __listenerHandlers.delete(key);
        }
    };
}
function __makeKeyListenerPair(kind, eventName, exportName) {
    return {
        add(cookie, id) {
            const el = __jsmemory.get(cookie);
            if (!el) return;
            const handler = (event) => {
                const keyPtr = __writeUtf8(event.key || "");
                __wasm()[exportName](id, keyPtr, event.keyCode,
                    event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
                __wasm().WasmFree(keyPtr);
            };
            __listenerHandlers.set(`${cookie}-${id}-${kind}`, handler);
            el.addEventListener(eventName, handler);
        },
        remove(cookie, id) {
            const el = __jsmemory.get(cookie);
            const key = `${cookie}-${id}-${kind}`;
            const handler = __listenerHandlers.get(key);
            if (el && handler) el.removeEventListener(eventName, handler);
            __listenerHandlers.delete(key);
        }
    };
}

const __mouse_Click       = __makeMouseListenerPair("click",       "click",       "ExecuteClickHandler");
const __mouse_MouseOver   = __makeMouseListenerPair("mouseover",   "mouseover",   "ExecuteMouseOverHandler");
const __mouse_MouseOut    = __makeMouseListenerPair("mouseout",    "mouseout",    "ExecuteMouseOutHandler");
const __mouse_MouseMove   = __makeMouseListenerPair("mousemove",   "mousemove",   "ExecuteMouseMoveHandler");
const __mouse_MouseDown   = __makeMouseListenerPair("mousedown",   "mousedown",   "ExecuteMouseDownHandler");
const __mouse_MouseUp     = __makeMouseListenerPair("mouseup",     "mouseup",     "ExecuteMouseUpHandler");
const __mouse_ContextMenu = __makeMouseListenerPair("contextmenu", "contextmenu", "ExecuteContextMenuHandler");
const __mouse_DragStart   = __makeMouseListenerPair("dragstart",   "dragstart",   "ExecuteDragStartHandler");
const __mouse_DragEnd     = __makeMouseListenerPair("dragend",     "dragend",     "ExecuteDragEndHandler");
const __mouse_Drop        = __makeMouseListenerPair("drop",        "drop",        "ExecuteDropHandler");
const __mouse_DragOver    = __makeMouseListenerPair("dragover",    "dragover",    "ExecuteDragOverHandler");
const __mouse_DragEnter   = __makeMouseListenerPair("dragenter",   "dragenter",   "ExecuteDragEnterHandler");
const __mouse_DragLeave   = __makeMouseListenerPair("dragleave",   "dragleave",   "ExecuteDragLeaveHandler");
const __key_KeyDown       = __makeKeyListenerPair("keydown",       "keydown",     "ExecuteKeyDownHandler");
const __key_KeyUp         = __makeKeyListenerPair("keyup",         "keyup",       "ExecuteKeyUpHandler");
const __key_KeyPress      = __makeKeyListenerPair("keypress",      "keypress",    "ExecuteKeyPressHandler");

function __makeFocusListenerPair(kind, eventName, exportName) {
    return {
        add(cookie, id) {
            const el = __jsmemory.get(cookie);
            if (!el) return;
            const handler = (event) => {
                const tCookie = event.target          ? __storeElement(event.target)          : 0;
                const rCookie = event.relatedTarget   ? __storeElement(event.relatedTarget)   : 0;
                __wasm()[exportName](id, tCookie, rCookie);
            };
            __listenerHandlers.set(`${cookie}-${id}-${kind}`, handler);
            el.addEventListener(eventName, handler);
        },
        remove(cookie, id) {
            const el = __jsmemory.get(cookie);
            const key = `${cookie}-${id}-${kind}`;
            const handler = __listenerHandlers.get(key);
            if (el && handler) el.removeEventListener(eventName, handler);
            __listenerHandlers.delete(key);
        }
    };
}
const __focusPair = __makeFocusListenerPair("focus", "focus", "ExecuteFocusHandler");
const __blurPair  = __makeFocusListenerPair("blur",  "blur",  "ExecuteBlurHandler");

// Change / Input / Submit / Resize / Scroll have bespoke marshaling.
const __changePair = {
    add(cookie, id) {
        const el = __jsmemory.get(cookie); if (!el) return;
        const handler = (event) => {
            const p = __writeUtf8(event.target.value || "");
            __wasm().ExecuteChangeHandler(id, p);
            __wasm().WasmFree(p);
        };
        __listenerHandlers.set(`${cookie}-${id}-change`, handler);
        el.addEventListener("change", handler);
    },
    remove(cookie, id) {
        const el = __jsmemory.get(cookie);
        const key = `${cookie}-${id}-change`;
        const h = __listenerHandlers.get(key);
        if (el && h) el.removeEventListener("change", h);
        __listenerHandlers.delete(key);
    }
};
const __inputPair = {
    add(cookie, id) {
        const el = __jsmemory.get(cookie); if (!el) return;
        const handler = (event) => {
            const text = event.data || (event.target && event.target.value) || "";
            const p = __writeUtf8(text);
            __wasm().ExecuteInputHandler(id, p, event.inputType === 'insertCompositionText');
            __wasm().WasmFree(p);
        };
        __listenerHandlers.set(`${cookie}-${id}-input`, handler);
        el.addEventListener("input", handler);
    },
    remove(cookie, id) {
        const el = __jsmemory.get(cookie);
        const key = `${cookie}-${id}-input`;
        const h = __listenerHandlers.get(key);
        if (el && h) el.removeEventListener("input", h);
        __listenerHandlers.delete(key);
    }
};
const __submitPair = {
    add(cookie, id) {
        const el = __jsmemory.get(cookie); if (!el) return;
        const handler = (event) => { event.preventDefault(); __wasm().ExecuteSubmitHandler(id); };
        __listenerHandlers.set(`${cookie}-${id}-submit`, handler);
        el.addEventListener("submit", handler);
    },
    remove(cookie, id) {
        const el = __jsmemory.get(cookie);
        const key = `${cookie}-${id}-submit`;
        const h = __listenerHandlers.get(key);
        if (el && h) el.removeEventListener("submit", h);
        __listenerHandlers.delete(key);
    }
};
const __resizePair = {
    // Resize is window-global in CppDOM. Mirror that: attach to `window`
    // regardless of which element the C++ caller passed.
    add(cookie, id) {
        const handler = () => {
            const s = __dpr();
            __wasm().ExecuteResizeHandler(id, window.innerWidth * s, window.innerHeight * s);
        };
        __listenerHandlers.set(`${cookie}-${id}-resize`, handler);
        window.addEventListener("resize", handler);
    },
    remove(cookie, id) {
        const key = `${cookie}-${id}-resize`;
        const h = __listenerHandlers.get(key);
        if (h) window.removeEventListener("resize", h);
        __listenerHandlers.delete(key);
    }
};
const __scrollPair = {
    add(cookie, id) {
        const handler = () => __wasm().ExecuteScrollHandler(id, window.scrollX, window.scrollY);
        __listenerHandlers.set(`${cookie}-${id}-scroll`, handler);
        window.addEventListener("scroll", handler);
    },
    remove(cookie, id) {
        const key = `${cookie}-${id}-scroll`;
        const h = __listenerHandlers.get(key);
        if (h) window.removeEventListener("scroll", h);
        __listenerHandlers.delete(key);
    }
};
const __wheelPair = {
    add(cookie, id) {
        const el = __jsmemory.get(cookie); if (!el) return;
        const handler = (event) => {
            const s = __dpr();
            __wasm().ExecuteWheelHandler(id,
                event.deltaX, event.deltaY, event.deltaZ, event.deltaMode,
                event.clientX * s, event.clientY * s, event.screenX * s, event.screenY * s,
                event.button, event.buttons,
                event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
        };
        __listenerHandlers.set(`${cookie}-${id}-wheel`, handler);
        el.addEventListener("wheel", handler);
    },
    remove(cookie, id) {
        const el = __jsmemory.get(cookie);
        const key = `${cookie}-${id}-wheel`;
        const h = __listenerHandlers.get(key);
        if (el && h) el.removeEventListener("wheel", h);
        __listenerHandlers.delete(key);
    }
};

// ─── Window-level event hookup ────────────────────────────────────────
//
// Called by the C++ Window ctor (`domAttachWindow`). Installs document /
// window listeners that route into Window exports (`__crafterDom_*`).
// Only one Window per page in V1 — multiple Attach calls overwrite the
// previous attachment.

function domAttachWindow(windowHandle) {
    __windowAttachedHandle = windowHandle;

    const fire = (name, args) => {
        const fn = __wasm()[name];
        if (fn) fn(__windowAttachedHandle, ...args);
    };

    // Synthetic absolute position for pointer-lock mode. While the
    // pointer is locked, browsers fire mousemove events with movementX/Y
    // deltas instead of meaningful clientX/Y, and the cursor is hidden +
    // captured by the canvas (no window-edge clamp). We accumulate the
    // deltas into a synthetic position and feed *that* to the C++ side,
    // so the existing `currentMousePos - lastMousePos` delta computation
    // keeps working unchanged. Initialised to the cursor position the
    // moment lock is acquired.
    let __ptrLockSyntheticX = 0;
    let __ptrLockSyntheticY = 0;
    const __isPointerLocked = () =>
        document.pointerLockElement !== null &&
        document.pointerLockElement !== undefined;

    // pointermove (not mousemove) so we can pull sub-frame events out of
    // `getCoalescedEvents()`. Browsers normally collapse multiple raw
    // mouse events between paint frames into a single event you'd see
    // via `mousemove`; PointerEvent.getCoalescedEvents() returns the raw
    // pre-coalesced list. Summing those gives a higher-resolution delta
    // per frame than the single coalesced movementX/Y. PointerEvent also
    // delivers fractional movementX from high-precision mice on Chromium.
    __windowListeners.mousemove = (e) => {
        const s = __dpr();
        const locked = __isPointerLocked();
        if (locked) {
            // Accumulate over every sub-frame event the browser had
            // queued up. `getCoalescedEvents` is the spec-correct way
            // to access raw input between rAF ticks. Some browsers
            // return an empty list — fall back to the top-level event.
            let dx = 0, dy = 0;
            const sub = (typeof e.getCoalescedEvents === "function")
                ? e.getCoalescedEvents() : null;
            if (sub && sub.length > 0) {
                for (let i = 0; i < sub.length; i++) {
                    dx += sub[i].movementX;
                    dy += sub[i].movementY;
                }
            } else {
                dx = e.movementX;
                dy = e.movementY;
            }
            // No DPR scaling in pointer-lock: position is synthetic and
            // there's no UI hit-test using it. DPR-scaling here only
            // rounds finer movements up to multiples of `dpr`, which is
            // pure quantization loss for aim controls.
            __ptrLockSyntheticX += dx;
            __ptrLockSyntheticY += dy;
            fire("__crafterDom_mouseMove",
                 [__ptrLockSyntheticX, __ptrLockSyntheticY]);
        } else {
            fire("__crafterDom_mouseMove", [e.clientX * s, e.clientY * s]);
        }
    };
    __windowListeners.mousedown = (e) => {
        // Right-click holds engage pointer lock — typical FPS-camera
        // convention. Acquiring on any click (the previous policy) made
        // menus annoying: clicking a button hid the cursor mid-flow. Now
        // the cursor stays free for clicks/menus until the user holds
        // RMB to actively look around. Browsers require lock requests
        // from user gestures, which mousedown satisfies.
        if (e.button === 2 && !__isPointerLocked()) {
            const target = document.body;
            if (target && target.requestPointerLock) {
                target.requestPointerLock();
                // Seed the synthetic position from the click point so
                // there's no jump when the lock starts producing deltas.
                __ptrLockSyntheticX = e.clientX;
                __ptrLockSyntheticY = e.clientY;
            }
        }
        fire("__crafterDom_mouseDown", [e.button]);
    };
    __windowListeners.mouseup = (e) => {
        // Release lock on RMB up — cursor reappears at the seed point
        // for clicks/menus until the next RMB hold.
        if (e.button === 2 && __isPointerLocked()) {
            document.exitPointerLock();
        }
        fire("__crafterDom_mouseUp", [e.button]);
    };
    // Window::onMouseScroll speaks whole wheel detents (+1 = wheel down),
    // the same unit the Wayland and Win32 backends deliver. WheelEvent
    // deltas arrive in browser-specific units, so normalize per deltaMode
    // (Chromium: ~100px per detent in pixel mode; Firefox: 3 lines in line
    // mode) and carry the sub-detent remainder so smooth-scroll trackpads
    // add up instead of each event truncating to zero.
    let __wheelDetentRemainder = 0;
    __windowListeners.wheel = (e) => {
        const perDetent = e.deltaMode === 1 ? 3 : e.deltaMode === 2 ? 1 : 100;
        __wheelDetentRemainder += e.deltaY / perDetent;
        const whole = Math.trunc(__wheelDetentRemainder);
        if (whole !== 0) {
            __wheelDetentRemainder -= whole;
            fire("__crafterDom_wheel", [whole]);
        }
    };
    __windowListeners.contextmenu = (e) => { e.preventDefault(); };
    __windowListeners.pointerlockchange = () => {
        // Reset the synthetic accumulator when lock is released so the
        // next acquisition starts cleanly. The C++ side will see one
        // small jump back to the real cursor position on release.
        if (!__isPointerLocked()) {
            __ptrLockSyntheticX = 0;
            __ptrLockSyntheticY = 0;
        }
    };

    // Keyboard events go through the document so they fire even when no
    // input element is focused. event.code is the layout-independent
    // physical key identifier — matches what the C++ Keys table hashes.
    __windowListeners.keydown = (e) => {
        const codePtr = __writeUtf8(e.code || "");
        const keyPtr  = __writeUtf8(e.key  || "");
        fire("__crafterDom_keyDown", [codePtr, e.code.length, keyPtr, e.key.length, e.repeat]);
        __wasm().WasmFree(codePtr);
        __wasm().WasmFree(keyPtr);
    };
    __windowListeners.keyup = (e) => {
        const codePtr = __writeUtf8(e.code || "");
        fire("__crafterDom_keyUp", [codePtr, e.code.length]);
        __wasm().WasmFree(codePtr);
    };

    __windowListeners.resize = () => {
        const s = __dpr();
        fire("__crafterDom_resize", [window.innerWidth * s, window.innerHeight * s]);
    };
    __windowListeners.beforeunload = () => fire("__crafterDom_close", []);

    // pointermove (not mousemove) so the handler receives PointerEvents
    // and can use getCoalescedEvents() to recover sub-frame motion. The
    // handler's variable name stays "mousemove" — it's the same JS object,
    // just bound to a different event type.
    document.addEventListener("pointermove", __windowListeners.mousemove);
    document.addEventListener("mousedown",   __windowListeners.mousedown);
    document.addEventListener("mouseup",     __windowListeners.mouseup);
    document.addEventListener("wheel",       __windowListeners.wheel);
    document.addEventListener("contextmenu", __windowListeners.contextmenu);
    document.addEventListener("keydown",     __windowListeners.keydown);
    document.addEventListener("keyup",       __windowListeners.keyup);
    document.addEventListener("pointerlockchange", __windowListeners.pointerlockchange);
    window  .addEventListener("resize",      __windowListeners.resize);
    window  .addEventListener("beforeunload",__windowListeners.beforeunload);
}

function domSetTitle(titlePtr, titleLen) {
    document.title = __readUtf8(titlePtr, titleLen);
}

function domGetInnerWidth()  { return Math.round(window.innerWidth  * __dpr()); }
function domGetInnerHeight() { return Math.round(window.innerHeight * __dpr()); }

// ─── requestAnimationFrame loop ───────────────────────────────────────

function __frameTick() {
    if (!__frameLoopActive) return;
    const fn = __wasm().__crafterDom_frame;
    if (fn) fn(__windowAttachedHandle);
    window.requestAnimationFrame(__frameTick);
}

function domStartFrameLoop() {
    if (__frameLoopActive) return;
    __frameLoopActive = true;
    window.requestAnimationFrame(__frameTick);
}

function domStopFrameLoop() {
    __frameLoopActive = false;
}

// ─── Clipboard ────────────────────────────────────────────────────────
//
// Read path is necessarily a latched buffer: navigator.clipboard.readText
// is async (Promise) but the C++ Clipboard::GetText signature is
// synchronous to match the Wayland / Win32 backends. Two feeders keep
// the cache fresh:
//
//   1. A capture-phase `paste` listener on `window` reads
//      event.clipboardData.getData('text/plain') synchronously — this
//      covers the standard Ctrl+V flow, where the paste event fires
//      in the same task as the C++ key handler that will call GetText.
//   2. Each GetText call also kicks off a navigator.clipboard.readText
//      in the background. The promise resolves in a later task; the
//      first call usually still returns the latched paste value (or
//      nullopt), subsequent calls see the readText result.

let __clipboardCache = null;

window.addEventListener("paste", (event) => {
    try {
        const t = event.clipboardData &&
                  event.clipboardData.getData("text/plain");
        if (typeof t === "string") __clipboardCache = t;
    } catch (err) {
        // Some browsers reject clipboardData access inside non-input
        // targets; the async readText fallback still has a chance.
    }
}, true);

function clipboardSetText(strPtr, strLen) {
    try {
        navigator.clipboard.writeText(__readUtf8(strPtr, strLen));
        return true;
    } catch (err) {
        console.error("Crafter.Clipboard.SetText failed:", err);
        return false;
    }
}

function clipboardGetText() {
    // Best-effort async refresh. Permission/focus rejections are
    // expected in non-activated contexts — swallow them so we don't
    // spam the console on every paste check.
    if (navigator.clipboard && navigator.clipboard.readText) {
        navigator.clipboard.readText()
            .then((t) => { if (typeof t === "string") __clipboardCache = t; })
            .catch(() => {});
    }
    if (__clipboardCache === null) return 0;
    return __writeUtf8(__clipboardCache);
}

// ─── History / routing ────────────────────────────────────────────────

function pushState(dataPtr, dataLen, titlePtr, titleLen, urlPtr, urlLen) {
    const dataStr  = __readUtf8(dataPtr,  dataLen);
    const titleStr = __readUtf8(titlePtr, titleLen);
    const urlStr   = __readUtf8(urlPtr,   urlLen);
    let parsed;
    try { parsed = JSON.parse(dataStr); } catch { parsed = null; }
    window.history.pushState(parsed, titleStr, urlStr);
}
function addPopStateListener(id) {
    const handler = () => __wasm().ExecutePopStateHandler(id);
    __listenerHandlers.set(`popstate-${id}`, handler);
    window.addEventListener("popstate", handler);
}
function removePopStateListener(id) {
    const h = __listenerHandlers.get(`popstate-${id}`);
    if (h) window.removeEventListener("popstate", h);
    __listenerHandlers.delete(`popstate-${id}`);
}
function getPathName() {
    return __writeUtf8(window.location.pathname);
}

// ─── Gamepad polling helper ───────────────────────────────────────────
//
// V1: a minimal polling shim used by the C++ Gamepad backend. Returns
// the navigator.getGamepads() result reshaped into a flat layout the
// C++ side can read out via getter calls. Hot-plug events are exposed
// as a flag the C++ side polls during Tick.

let __gamepadConnectedFlag = false;
let __gamepadDisconnectedFlag = false;
window.addEventListener("gamepadconnected",    () => { __gamepadConnectedFlag = true;  });
window.addEventListener("gamepaddisconnected", () => { __gamepadDisconnectedFlag = true; });
function gamepadPollConnected()    { const v = __gamepadConnectedFlag;    __gamepadConnectedFlag    = false; return v; }
function gamepadPollDisconnected() { const v = __gamepadDisconnectedFlag; __gamepadDisconnectedFlag = false; return v; }
function gamepadCount() {
    return (navigator.getGamepads ? navigator.getGamepads() : []).filter(g => g).length;
}
function gamepadGetButton(idx, buttonIdx) {
    const pads = navigator.getGamepads ? navigator.getGamepads() : [];
    const p = pads.filter(g => g)[idx];
    return p && p.buttons[buttonIdx] ? (p.buttons[buttonIdx].pressed ? 1 : 0) : 0;
}
function gamepadGetAxis(idx, axisIdx) {
    const pads = navigator.getGamepads ? navigator.getGamepads() : [];
    const p = pads.filter(g => g)[idx];
    return p && typeof p.axes[axisIdx] === "number" ? p.axes[axisIdx] : 0;
}

// ─── Export env object ────────────────────────────────────────────────

if (!window.crafter_webbuild_env) {
    window.crafter_webbuild_env = {};
}
Object.assign(window.crafter_webbuild_env, {
    // DOM lookup / creation / mutation
    freeJs, getElementById, createElement, getBody,
    setInnerHTML, setStyle, setProperty,
    addClass, removeClass, toggleClass, hasClass,
    deleteElement, getValue, setValue,

    // Element listeners — pair add/remove
    addClickListener:        __mouse_Click.add,        removeClickListener:        __mouse_Click.remove,
    addMouseOverListener:    __mouse_MouseOver.add,    removeMouseOverListener:    __mouse_MouseOver.remove,
    addMouseOutListener:     __mouse_MouseOut.add,     removeMouseOutListener:     __mouse_MouseOut.remove,
    addMouseMoveListener:    __mouse_MouseMove.add,    removeMouseMoveListener:    __mouse_MouseMove.remove,
    addMouseDownListener:    __mouse_MouseDown.add,    removeMouseDownListener:    __mouse_MouseDown.remove,
    addMouseUpListener:      __mouse_MouseUp.add,      removeMouseUpListener:      __mouse_MouseUp.remove,
    addContextMenuListener:  __mouse_ContextMenu.add,  removeContextMenuListener:  __mouse_ContextMenu.remove,
    addDragStartListener:    __mouse_DragStart.add,    removeDragStartListener:    __mouse_DragStart.remove,
    addDragEndListener:      __mouse_DragEnd.add,      removeDragEndListener:      __mouse_DragEnd.remove,
    addDropListener:         __mouse_Drop.add,         removeDropListener:         __mouse_Drop.remove,
    addDragOverListener:     __mouse_DragOver.add,     removeDragOverListener:     __mouse_DragOver.remove,
    addDragEnterListener:    __mouse_DragEnter.add,    removeDragEnterListener:    __mouse_DragEnter.remove,
    addDragLeaveListener:    __mouse_DragLeave.add,    removeDragLeaveListener:    __mouse_DragLeave.remove,
    addKeyDownListener:      __key_KeyDown.add,        removeKeyDownListener:      __key_KeyDown.remove,
    addKeyUpListener:        __key_KeyUp.add,          removeKeyUpListener:        __key_KeyUp.remove,
    addKeyPressListener:     __key_KeyPress.add,       removeKeyPressListener:     __key_KeyPress.remove,
    addFocusListener:        __focusPair.add,          removeFocusListener:        __focusPair.remove,
    addBlurListener:         __blurPair.add,           removeBlurListener:         __blurPair.remove,
    addChangeListener:       __changePair.add,         removeChangeListener:       __changePair.remove,
    addInputListener:        __inputPair.add,          removeInputListener:        __inputPair.remove,
    addSubmitListener:       __submitPair.add,         removeSubmitListener:       __submitPair.remove,
    addResizeListener:       __resizePair.add,         removeResizeListener:       __resizePair.remove,
    addScrollListener:       __scrollPair.add,         removeScrollListener:       __scrollPair.remove,
    addWheelListener:        __wheelPair.add,          removeWheelListener:        __wheelPair.remove,

    // Window / lifecycle
    domAttachWindow, domSetTitle,
    domGetInnerWidth, domGetInnerHeight,
    domStartFrameLoop, domStopFrameLoop,

    // Clipboard
    clipboardSetText, clipboardGetText,

    // History
    pushState, addPopStateListener, removePopStateListener, getPathName,

    // Gamepad
    gamepadPollConnected, gamepadPollDisconnected,
    gamepadCount, gamepadGetButton, gamepadGetAxis,
});
