function safeStringify(obj, maxLen = 2000) {
    try {
        const s = JSON.stringify(obj, (_k, v) => {
            if(typeof v === 'function') return `[Function:${ v.name || 'anonymous' }]`;
            if(v instanceof Node) return v.outerHTML && v.outerHTML.slice(0, 300) || String(v);
            return v;
        });
        return s && s.length > maxLen ? s.slice(0, maxLen) + '... (truncated)' : s;
    } catch(e) {
        return String(obj);
    }
}

function cleanObj(obj) {
    if(!obj) return undefined;
    const o = {}
    Object.keys(obj).forEach(k => {
        const v = obj[k];
        if(v || v === '') {
            o[k] = v;
        }
    });
    // return o;
    return Object.keys(o).length ? o : undefined;
}

// Helper function to combine modifier keys into a single integer
// shift=1, ctrl=2, alt=4
function getKeyMod(event) {
    let mod = 0;
    if(event.shiftKey) mod |= 1;
    if(event.ctrlKey) mod |= 2;
    if(event.altKey) mod |= 4;
    return mod || undefined;
}

function getClickInfo(e) {
    if(!e || !e.clientX) return undefined;
    const cr = (e.currentTarget || e.target).getBoundingClientRect()
    return {
        x: screenXtoDesctopX(e.clientX), y: Math.round(e.clientY),
        button: e.button,
        type: e.type,
        clientRect: {x: screenXtoDesctopX(cr.left), y: Math.round(cr.top), w: Math.round(cr.width), h: Math.round(cr.height)}
    }
}

/**
 * Apply modifications described by the `js` mapping to DOM elements matched by each selector.
 * @param {Object} js - Object mapping CSS selector -> modification object.
 * Modification object fields:
 *   - `class` {Array<string>} : class names to add; prefix a name with `-` to remove it.
 *   - `style` {Object<string, string|null>} : CSS property -> value; use `null` to remove the property.
 *   - `attr` {Object<string, string|null>} : attribute -> value; use `null` to remove the attribute.
 */
function alterElements(js) {
    for(const key in js) {
        const el = document.querySelectorAll(key);
        const vObj = js[key];
        // const sel1 = vObj['sel']
        el.forEach(e => {
            for(const vk in vObj) {
                const v = vObj[vk];
                if(vk === 'class') {
                    for(const c of v) {
                        if(c.startsWith("-")) {
                            const cn = c.substring(1)
                            e.classList.remove(cn);
                        } else {
                            e.classList.add(c);
                        }
                    }
                } else if(vk === 'style') {
                    for(const sn in v) {
                        const sv = v[sn];
                        if(sv === null) {
                            e.style.removeProperty(sn);
                        } else {
                            e.style[sn] = sv;
                        }
                    }
                } else if(vk === 'attr') {
                    for(const an in v) {
                        const av = v[an];
                        if(av === null) {
                            e.removeAttribute(an);
                        } else {
                            e.setAttribute(an, av);
                        }
                    }
                } else {
                    console.error("Unknown property to alter:", vk);
                }
            }
        });
    }
}

// helper: convert CSSStyleDeclaration to Map of prop -> "value|priority"
function cssStyleDeclarationToMap(sd) {
    const m = new Map();
    if(!sd) return m;
    for(let i = 0; i < sd.length; i++) {
        const p = sd[i];
        const v = sd.getPropertyValue(p);
        const pr = sd.getPropertyPriority(p) || '';
        let key = v || '';
        if(pr) key += '|' + pr;
        m.set(p, key);
    }
    return m;
}

function copyComputedStyles(src, dst, inherited) {
    const cs = window.getComputedStyle(src);
    const merged = new Map(inherited);
    for(let i = 0; i < cs.length; i++) {
        const prop = cs[i];
        const value = cs.getPropertyValue(prop);
        if(value === 'auto') continue;
        const priority = cs.getPropertyPriority(prop) || '';
        let key = value;
        if(priority) key += '|' + priority;
        // If ancestor already set the same value+priority, skip setting on this element
        if(merged.get(prop) === key) continue;
        // Apply the property and record it for descendants
        dst.style.setProperty(prop, value, priority);
        merged.set(prop, key);
    }
    // console.log("st", dst.style.length)
    const sc = src.children;
    const dc = dst.children;
    for(let i = 0; i < sc.length; i++) {
        copyComputedStyles(sc[i], dc[i], merged);
    }
}

/*
function elementHitTest(startE, x, y, stop) {
    const l = []
    // hit test up to root
    for(let e = startE; e !== stop; e = e.parentNode) {
        if(e.style["visibility"] === "hidden") // skip hidden
            break;
        const rc = e.getBoundingClientRect()
        if(!(x >= rc.left && y >= rc.top && x < rc.right && y < rc.bottom))
            break;
        if(e === startE) {
            if(e.getAttribute("disabled"))
                break;
            l.push(e);
            /!*!// exception for scrollers
            if(dt.hasClass("drag_scroll"))
                return dt;*!/
        }
    }
    return l;
}
*/

/**
 * Builds a selector string from a template and a starting element.
 * @param el
 * @param {string} template - in {} braces, use ^ to climb up the DOM, then : to select [attribute] or id
 * @returns {string} constructed selector
 */
function buildSelectorFromTemplate(el, template) {
    const evalExpr = (startEl, expr, surroundingQuote) => {
        let cur = startEl;
        expr = expr.trim();
        // split on separator
        const gtIndex = expr.indexOf(':');
        let left = gtIndex >= 0 ? expr.slice(0, gtIndex).trim() : '';
        const right = gtIndex >= 0 ? expr.slice(gtIndex + 1).trim() : '';

        // process ^ selectors (can be chained, separated by whitespace)
        if(left) {
            const tokens = left.split(/\s+/);
            for(const t of tokens) {
                if(!t) continue;
                if(t[0] !== '^') {
                    // ignore unknown token
                    continue;
                }
                const sel = t.slice(1);
                if(!cur) break;
                cur = cur.closest ? cur.closest(sel) : null;
            }
        }

        if(!cur) return '';

        if(!right) return ''; // nothing requested

        // right should be 'id' or '[attr]'
        if(right === 'id') {
            return cur.id || ''
        }
        const m = right.match(/^\[\s*([^\]]+)\s*]$/);
        if(m) {
            const attrName = m[1];
            return cur.getAttribute ? cur.getAttribute(attrName) : null
        }
        console.log("Unknown spec in template:", expr)
        return ''
    };
    // Replace all \{...\} placeholders
    return template.replace(/\{([^}]+)}/g, (fullMatch, inner, offset) => {
        // determine char immediately before the '{' to guess surrounding quote
        const beforeChar = template[offset - 1];
        const surroundingQuote = (beforeChar === "'" || beforeChar === '"') ? beforeChar : null;
        return evalExpr(el, inner, surroundingQuote);
    });
}

function initPopupFocus(popup) {
    let items
    if(popup.getAttribute('role') === 'menu') {
        items = popup.querySelectorAll('[role="menuitem"]');
        popup.setAttribute('tabindex', '-1');
        popup.focus();
    } else if(popup.getAttribute('role') === 'dialog') {
        items = popup.querySelectorAll('input, button, textarea, select, [tabindex="0"]');
    }
    if(items.length === 0) return;
    items = Array.from(items);
    const handleKeyDown = (e) => {
        // Tab key focus trap
        if(e.key === 'Tab') {
            e.preventDefault();

            const currentIndex = items.indexOf(document.activeElement);
            let nextIndex;

            if(e.shiftKey) {
                // Going backwards
                nextIndex = currentIndex - 1;
                if(nextIndex < 0) nextIndex = items.length - 1;

                // Find first non-disabled going backwards
                while(items[nextIndex].disabled || items[nextIndex].hasAttribute('disabled')) {
                    nextIndex--;
                    if(nextIndex < 0) nextIndex = items.length - 1;
                }
            } else {
                // Going forwards
                nextIndex = currentIndex + 1;
                if(nextIndex >= items.length) nextIndex = 0;

                // Find first non-disabled going forwards
                while(items[nextIndex].disabled || items[nextIndex].hasAttribute('disabled')) {
                    nextIndex++;
                    if(nextIndex >= items.length) nextIndex = 0;
                }
            }
            items[nextIndex].focus();
        } else {
            // Arrow key navigation
            const isDown = e.key === 'ArrowDown'
            if(isDown || e.key === 'ArrowUp') {
                e.preventDefault();
                let current = Array.from(items).indexOf(document.activeElement);
                if(current === -1 && !isDown) {
                    current = items.length
                }
                if(isDown) {
                    const next = (current + 1) % items.length;
                    items[next].focus();
                } else {
                    const prev = (current - 1 + items.length) % items.length;
                    items[prev].focus();
                }
            }
        }
    };
    popup.addEventListener('keydown', handleKeyDown);
}

function scrollIntoView(js) {
    const {sel, ...options} = js;
    const e = document.querySelector(sel);
    if(!e) return;
    /*{behavior: smooth ? 'smooth' : 'auto', block: 'nearest', inline: 'nearest'}*/
    e.scrollIntoView(/** @type {ScrollIntoViewOptions} */(options));
}

function downloadFile(js) {
    const url = js.url;
    const u = new URL(url, location.href);
    u.searchParams.set(SESSION_ID_HEADER, sessionId);
    window.location.href = u.toString();
    /*try {
        // Perform native navigation via anchor click so browser handles streaming download (no buffering)
        const link = document.createElement('a');
        link.href = u.toString();
        link.style.display = 'none';
        // No download attribute so browser preserves server-provided filename and content-disposition
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
    } catch(e) {
        console.error('downloadFile error:', e);
    }*/
}
