// AI Helper MAIN world page tools installer v1.0
// Installed once per tab via chrome.scripting.executeScript({world:'MAIN', files: [...]})
// by background.js when the first jz-page-tool-call message arrives.
//
// Provides window.__aiPageTools — 21 tools:
//   DOM (14):       getPageSummary, getDomOutline, searchElements, inspectElement,
//                   getComputedStyles, getMatchingCssRules, getUniqueSelector,
//                   getElementContext, testSelector, runJsProbe, previewCss, previewJs,
//                   observeDomChanges, recordDomTimeline
//   Interaction (6): simulateClick, simulateHover, simulateScroll, simulateKeypress,
//                    simulateType, simulateFormFill
//   Vision (1):      getElementRectForScreenshot (helper for content.js + background crop)
//
// All tools return { data, meta, suggestions, warnings? } — JSON-serializable.
// Tools never throw — errors are returned as { data: null, meta: { error }, warnings: [...] }.

(function installPageTools() {
  if (window.__aiPageToolsInstalled) return; // idempotent
  window.__aiPageToolsInstalled = true;
  window.__aiPageToolsVersion = '1.0';

  // ────────────────────────────────────────────────────────────────────────────
  // CONSTANTS
  // ────────────────────────────────────────────────────────────────────────────

  const UI_FILTER_SELECTORS = [
    '.jz-pane',
    '#jz-ai-helper',
    '.jz-actions-bar',
    '#justzix-floating-btn',
    '#jz-ai-style-panel',
    '.jz-ai-font-menu',
  ];

  const DEFAULT_COMPUTED_PROPS = [
    'display', 'position', 'top', 'right', 'bottom', 'left',
    'visibility', 'opacity', 'z-index', 'overflow',
    'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
    'margin', 'padding', 'border',
    'background-color', 'background-image',
    'color', 'font-family', 'font-size', 'font-weight', 'line-height',
    'text-align', 'text-decoration',
    'transform', 'transition', 'cursor',
  ];

  const LANDMARK_TAGS = ['header', 'nav', 'main', 'aside', 'footer'];
  const LANDMARK_ROLES = ['banner', 'navigation', 'main', 'complementary', 'contentinfo', 'search', 'form', 'region'];

  // Patterns that indicate mutation / side-effect APIs — used by runJsProbe readOnly check.
  const MUTATION_PATTERN = /\.appendChild|\.removeChild|\.replaceChild|\.insertBefore|\.innerHTML\s*=|\.outerHTML\s*=|\.insertAdjacentHTML|\.setAttribute|\.removeAttribute|\.classList\.(add|remove|toggle|replace)|document\.cookie\s*=|localStorage\.|sessionStorage\.|indexedDB|fetch\s*\(|XMLHttpRequest|navigator\.sendBeacon|navigator\.clipboard|history\.(push|replace)State|window\.open\s*\(|location\s*=|location\.(assign|replace)|location\.href\s*=/;

  // ────────────────────────────────────────────────────────────────────────────
  // HELPERS
  // ────────────────────────────────────────────────────────────────────────────

  function cssEscape(s) {
    if (typeof s !== 'string') return '';
    if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s);
    return s.replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\' + ch; });
  }

  function attrEscape(s) {
    return String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  }

  function truncate(text, max) {
    if (text == null) return '';
    const s = String(text);
    if (s.length <= max) return s;
    return s.slice(0, Math.max(0, max - 1)) + '…';
  }

  // v3.2.55 — diacritics-insensitive fold for text matching. NFD decomposes most
  // accented letters (ó/ś/ż/ć/ń/ą/ę/ź → base + combining mark, stripped here), but
  // Polish ł/Ł are precomposed and do NOT decompose under NFD — handle explicitly.
  // Lets "naglowek" match "nagłówek" and an un-accented query match accented text.
  function foldText(s) {
    return String(s == null ? '' : s)
      .replace(/[łŁ]/g, 'l')
      .normalize('NFD').replace(/[̀-ͯ]/g, '')
      .toLowerCase();
  }

  // v3.2.55 — accessible-name + visible text haystack for the `text` filter.
  // Matching only textContent missed elements identified by aria-label / alt /
  // title / placeholder (common for icons, images, inputs, landmarks).
  function textHaystack(el) {
    return foldText([
      el.textContent || '',
      el.getAttribute && el.getAttribute('aria-label') || '',
      el.getAttribute && el.getAttribute('alt') || '',
      el.getAttribute && el.getAttribute('title') || '',
      el.getAttribute && el.getAttribute('placeholder') || '',
    ].join(' '));
  }

  function ok(data, meta, suggestions, warnings) {
    const out = { data: data, meta: meta || {}, suggestions: suggestions || [] };
    if (warnings && warnings.length) out.warnings = warnings;
    return out;
  }

  function fail(message, extra) {
    return {
      data: null,
      meta: Object.assign({ error: message }, extra || {}),
      suggestions: [],
      warnings: [message],
    };
  }

  // v3.2.75 — wykrycie Trusted Types / CSP blokującego eval stringa jako JS
  // (np. play.google.com). new Function(code) tam rzuca — chcemy oddać agentowi
  // czytelny komunikat, żeby zmienił strategię, a nie traktował to jak błąd kodu.
  function isTrustedTypesError(e) {
    const m = (e && e.message ? String(e.message) : String(e || '')).toLowerCase();
    return m.indexOf('trusted type') >= 0 || m.indexOf('trustedtypes') >= 0 ||
           (m.indexOf('csp') >= 0 && m.indexOf('eval') >= 0);
  }
  const TT_BLOCKED_MSG = 'This page blocks running custom JavaScript injected as a string (Trusted Types / CSP). run_js_probe and preview_js will NOT work here. Switch strategy: use a CSS-only approach (preview_css / set_temp_pane_code type:css), or tell the user this page forbids custom JS so a JS-based solution is not possible — do not silently ship an inferior workaround.';

  function safeRun(fn) {
    try { return fn(); }
    catch (e) {
      return fail(e && e.message ? String(e.message) : String(e),
        { stack: e && e.stack ? truncate(String(e.stack), 500) : null });
    }
  }

  async function safeRunAsync(fn) {
    try { return await fn(); }
    catch (e) {
      return fail(e && e.message ? String(e.message) : String(e),
        { stack: e && e.stack ? truncate(String(e.stack), 500) : null });
    }
  }

  function isVisible(el) {
    if (!el || el.nodeType !== 1) return false;
    if (!el.isConnected) return false;
    if (!el.getBoundingClientRect) return false;
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) return false;
    const cs = window.getComputedStyle(el);
    if (cs.display === 'none') return false;
    if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
    if (parseFloat(cs.opacity) === 0) return false;
    return true;
  }

  function isOurUI(node) {
    if (!node || node.nodeType !== 1) return false;
    for (const sel of UI_FILTER_SELECTORS) {
      try { if (node.closest && node.closest(sel)) return true; } catch (_) {}
    }
    return false;
  }

  function getRect(el) {
    if (!el || !el.getBoundingClientRect) return null;
    const r = el.getBoundingClientRect();
    return {
      x: Math.round(r.x),
      y: Math.round(r.y),
      width: Math.round(r.width),
      height: Math.round(r.height),
    };
  }

  function isDynamicId(id) {
    if (!id) return true;
    if (id.length > 64) return true;
    if (/^\d+$/.test(id)) return true;
    if (/^:r\w+:$/i.test(id)) return true;                   // React-like :r0:
    if (/^[a-z]+-?\d{2,}$/i.test(id)) return true;            // tab-123, react-1, etc.
    if (/^[a-f0-9]{6,}$/i.test(id)) return true;              // hex blob
    if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(id)) return true; // uuid
    if (/[a-zA-Z]{2,}_[a-z0-9]{6,}$/i.test(id)) return true;  // name_xxxxxx hash suffix
    return false;
  }

  function isDynamicClass(cls) {
    if (!cls || cls.length > 80) return true;
    if (/^(css|jsx|sc|emotion|styled)-[a-z0-9]{3,}$/i.test(cls)) return true; // css-in-js
    if (/[a-zA-Z]+_[a-zA-Z]+__[a-zA-Z0-9]{4,}$/.test(cls)) return true;       // css-modules
    if (cls.length >= 10 && /^[a-zA-Z0-9]+$/.test(cls) && /\d/.test(cls) && /[a-z]/i.test(cls)) {
      const distinct = new Set(cls.toLowerCase()).size;
      if (distinct >= 5) return true;                                          // random hash-y
    }
    return false;
  }

  function safeAttributes(el) {
    const out = {};
    if (!el || !el.attributes) return out;
    for (const attr of el.attributes) {
      const n = attr.name;
      if (
        n === 'id' || n === 'name' || n === 'role' || n === 'type' ||
        n === 'href' || n === 'src' || n === 'alt' || n === 'title' ||
        n === 'placeholder' || n === 'value' ||
        n.startsWith('data-') || n.startsWith('aria-')
      ) {
        out[n] = truncate(attr.value, 200);
      }
    }
    return out;
  }

  // v3.2.48 — ALL attributes (everything inside the opening tag), each value
  // capped to 200 chars. Returns { attrs, truncatedNames }. For get_dom_outline.
  function allAttributes(el) {
    const attrs = {};
    const truncatedNames = [];
    if (!el || !el.attributes) return { attrs: attrs, truncatedNames: truncatedNames };
    for (const attr of el.attributes) {
      const v = attr.value == null ? '' : String(attr.value);
      if (v.length > 200) truncatedNames.push(attr.name);
      attrs[attr.name] = truncate(v, 200);
    }
    return { attrs: attrs, truncatedNames: truncatedNames };
  }

  // v3.2.48 — tag classification for response shaping. Container tags hold
  // structure (no useful own text); leaf-ish tags carry meaningful content.
  // Heuristic override: anything with >20 descendants is treated as a container
  // regardless of tag name.
  var CONTAINER_TAGS = {
    DIV: 1, SECTION: 1, ARTICLE: 1, MAIN: 1, BODY: 1, HTML: 1, UL: 1, OL: 1,
    NAV: 1, HEADER: 1, FOOTER: 1, ASIDE: 1, FORM: 1, FIGURE: 1, TABLE: 1,
    TR: 1, TBODY: 1, THEAD: 1, FIELDSET: 1, DL: 1, PICTURE: 1,
  };
  function isContainerEl(el) {
    if (!el || el.nodeType !== 1) return false;
    if (el.getElementsByTagName('*').length > 20) return true;
    return !!CONTAINER_TAGS[el.tagName];
  }
  // Count descendants by tag name across the WHOLE subtree (cheap single call).
  function descendantTagCounts(el) {
    const counts = {};
    if (!el || !el.getElementsByTagName) return counts;
    const all = el.getElementsByTagName('*');
    for (let i = 0; i < all.length; i++) {
      const t = all[i].tagName.toLowerCase();
      counts[t] = (counts[t] || 0) + 1;
    }
    return counts;
  }
  // Max nesting depth below an element (0 = no element children).
  function subtreeDepth(el) {
    if (!el || !el.children || !el.children.length) return 0;
    let max = 0;
    for (const c of el.children) {
      const d = subtreeDepth(c);
      if (d > max) max = d;
    }
    return max + 1;
  }

  function shortCssPath(el, maxDepth) {
    maxDepth = maxDepth || 5;
    if (!el || el.nodeType !== 1) return '';
    const path = [];
    let cur = el;
    let depth = 0;
    while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < maxDepth) {
      let part = cur.tagName.toLowerCase();
      if (cur.id && !isDynamicId(cur.id)) {
        path.unshift('#' + cssEscape(cur.id));
        break;
      }
      const semantic = Array.from(cur.classList || []).filter(function (c) { return !isDynamicClass(c); }).slice(0, 2);
      if (semantic.length) {
        part += '.' + semantic.map(cssEscape).join('.');
      } else {
        const parent = cur.parentElement;
        if (parent) {
          const sameTag = Array.from(parent.children).filter(function (c) { return c.tagName === cur.tagName; });
          if (sameTag.length > 1) {
            const idx = sameTag.indexOf(cur) + 1;
            part += ':nth-of-type(' + idx + ')';
          }
        }
      }
      path.unshift(part);
      if (cur.tagName === 'BODY') break;
      cur = cur.parentElement;
      depth++;
    }
    return path.join(' > ');
  }

  function buildStructuralPath(el, maxDepth) {
    maxDepth = maxDepth || 10;
    if (!el || el.nodeType !== 1) return '';
    const parts = [];
    let cur = el;
    let depth = 0;
    while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < maxDepth) {
      let part = cur.tagName.toLowerCase();
      if (cur.id && !isDynamicId(cur.id)) {
        parts.unshift('#' + cssEscape(cur.id));
        break;
      }
      const parent = cur.parentElement;
      if (parent) {
        const sameTag = Array.from(parent.children).filter(function (c) { return c.tagName === cur.tagName; });
        if (sameTag.length > 1) {
          const idx = sameTag.indexOf(cur) + 1;
          part += ':nth-of-type(' + idx + ')';
        }
      }
      parts.unshift(part);
      if (cur.tagName === 'BODY') break;
      cur = cur.parentElement;
      depth++;
    }
    return parts.join(' > ');
  }

  function getUniqueSelectorInternal(el) {
    if (!el || el.nodeType !== 1) return null;
    if (el === document.body) return 'body';
    if (el === document.documentElement) return 'html';

    // 1. test attributes
    for (const attr of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
      const v = el.getAttribute && el.getAttribute(attr);
      if (v) {
        const sel = '[' + attr + '="' + attrEscape(v) + '"]';
        try { if (document.querySelectorAll(sel).length === 1) return sel; } catch (_) {}
      }
    }

    // 2. stable id
    if (el.id && !isDynamicId(el.id)) {
      const sel = '#' + cssEscape(el.id);
      try { if (document.querySelectorAll(sel).length === 1) return sel; } catch (_) {}
    }

    // 3. role + aria-label
    const role = el.getAttribute && el.getAttribute('role');
    const ariaLabel = el.getAttribute && el.getAttribute('aria-label');
    if (role && ariaLabel) {
      const sel = '[role="' + attrEscape(role) + '"][aria-label="' + attrEscape(ariaLabel) + '"]';
      try { if (document.querySelectorAll(sel).length === 1) return sel; } catch (_) {}
    }

    // 4. semantic classes
    if (el.classList && el.classList.length) {
      const semantic = Array.from(el.classList).filter(function (c) { return !isDynamicClass(c); });
      if (semantic.length) {
        const sel = el.tagName.toLowerCase() + '.' + semantic.map(cssEscape).join('.');
        try { if (document.querySelectorAll(sel).length === 1) return sel; } catch (_) {}
      }
    }

    // 5. structural fallback
    return buildStructuralPath(el);
  }

  function computeSpecificity(selector) {
    if (!selector || typeof selector !== 'string') return [0, 0, 0];
    if (selector.indexOf(',') >= 0) {
      let max = [0, 0, 0];
      for (const part of selector.split(',')) {
        const cur = computeSpecificity(part.trim());
        if (cur[0] * 10000 + cur[1] * 100 + cur[2] > max[0] * 10000 + max[1] * 100 + max[2]) max = cur;
      }
      return max;
    }
    let s = selector;
    const pe = (s.match(/::[a-zA-Z-]+/g) || []).length;
    s = s.replace(/::[a-zA-Z-]+/g, ' ');
    const ids = (s.match(/#[\w-]+/g) || []).length;
    s = s.replace(/#[\w-]+/g, ' ');
    const classes = (s.match(/\.[\w-]+/g) || []).length;
    s = s.replace(/\.[\w-]+/g, ' ');
    const attrs = (s.match(/\[[^\]]*\]/g) || []).length;
    s = s.replace(/\[[^\]]*\]/g, ' ');
    const pcMatches = s.match(/:[a-zA-Z-]+(\([^)]*\))?/g) || [];
    let pc = 0;
    for (const m of pcMatches) {
      if (/^:where\b/.test(m)) continue; // :where() has 0 specificity per spec
      pc++;
    }
    s = s.replace(/:[a-zA-Z-]+(\([^)]*\))?/g, ' ');
    const tags = (s.match(/[a-zA-Z][\w-]*/g) || []).length;
    return [ids, classes + attrs + pc, tags + pe];
  }

  function specificityCompare(a, b) {
    for (let i = 0; i < Math.max(a.length, b.length); i++) {
      const av = a[i] || 0, bv = b[i] || 0;
      if (av !== bv) return bv - av;
    }
    return 0;
  }

  function safeQuery(selector) {
    if (!selector || typeof selector !== 'string') return null;
    try { return document.querySelector(selector); } catch (_) { return null; }
  }

  function getDirectText(el) {
    if (!el) return '';
    let txt = '';
    for (const node of el.childNodes) {
      if (node.nodeType === 3) txt += node.textContent;
    }
    return txt.trim();
  }

  function serialize(val, depth) {
    depth = depth || 0;
    if (depth > 4) return '[depth-limit]';
    if (val == null) return val;
    const t = typeof val;
    if (t === 'string') return truncate(val, 1000);
    if (t === 'number') return Number.isFinite(val) ? val : String(val);
    if (t === 'boolean') return val;
    if (t === 'function') return '[Function]';
    if (t === 'symbol') return val.toString();
    if (t === 'bigint') return val.toString();
    if (val instanceof Error) {
      return { __error: true, name: val.name, message: val.message, stack: truncate(val.stack || '', 500) };
    }
    if (val instanceof Element) {
      return { __node: val.nodeName, selector: getUniqueSelectorInternal(val) };
    }
    if (val instanceof Node) {
      return { __node: val.nodeName, text: truncate(val.textContent || '', 80) };
    }
    if (val instanceof Date) return val.toISOString();
    if (val instanceof RegExp) return val.toString();
    if (Array.isArray(val)) {
      if (val.length > 50) {
        return val.slice(0, 50).map(function (v) { return serialize(v, depth + 1); })
          .concat(['[truncated ' + (val.length - 50) + ' items]']);
      }
      return val.map(function (v) { return serialize(v, depth + 1); });
    }
    if (t === 'object') {
      try {
        const out = {};
        let count = 0;
        for (const k of Object.keys(val)) {
          if (count++ >= 30) { out.__truncated = true; break; }
          out[k] = serialize(val[k], depth + 1);
        }
        return out;
      } catch (_) { return '[unserializable]'; }
    }
    return String(val);
  }

  function summarizeRemovedNode(n) {
    if (!n || n.nodeType !== 1) return null;
    const tag = n.tagName.toLowerCase();
    let key = tag;
    if (n.id) key += '#' + n.id;
    else if (n.className && typeof n.className === 'string') {
      const first = n.className.trim().split(/\s+/)[0];
      if (first) key += '.' + first;
    }
    return { selector: key, tag: tag };
  }

  // ────────────────────────────────────────────────────────────────────────────
  // TOOLS
  // ────────────────────────────────────────────────────────────────────────────

  const tools = {

    // ─── DOM: 1. getPageSummary ────────────────────────────────────────────────
    getPageSummary(args) {
      return safeRun(function () {
        const headings = [];
        const hElems = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
        for (let i = 0; i < hElems.length && headings.length < 20; i++) {
          const h = hElems[i];
          headings.push({
            level: parseInt(h.tagName.substring(1), 10),
            text: truncate((h.textContent || '').trim(), 100),
            path: shortCssPath(h),
          });
        }
        const landmarks = [];
        for (const tag of LANDMARK_TAGS) {
          if (document.querySelector(tag)) landmarks.push(tag);
        }
        for (const role of LANDMARK_ROLES) {
          if (document.querySelector('[role="' + role + '"]') && landmarks.indexOf(role) < 0) landmarks.push(role);
        }
        const data = {
          url: location.href,
          title: document.title || '',
          lang: document.documentElement.lang || '',
          viewport: { w: window.innerWidth, h: window.innerHeight },
          bodyClasses: document.body ? Array.from(document.body.classList) : [],
          landmarks: landmarks,
          headings: headings,
          formsCount: document.querySelectorAll('form').length,
          buttonsCount: document.querySelectorAll('button, [role="button"]').length,
          linksCount: document.querySelectorAll('a[href]').length,
          scriptsCount: document.querySelectorAll('script').length,
          stylesheetsCount: document.styleSheets.length,
        };
        return ok(data, {}, [
          'Use getDomOutline for structure',
          'Use searchElements for specific elements',
        ]);
      });
    },

    // ─── DOM: 2. getDomOutline ─────────────────────────────────────────────────
    getDomOutline(args) {
      return safeRun(function () {
        const a = args || {};
        // v3.2.48 — root is MANDATORY. Without scope the outline floods the
        // context. If unsure where to start, call get_page_summary first
        // (it lists landmarks: header, main, nav, footer).
        const rootSel = a.root;
        if (!rootSel) {
          return fail(
            'get_dom_outline requires a `root` selector (e.g. "main", "#header", ".content"). ' +
            'Call get_page_summary first to discover landmarks, then outline a specific scope.',
            { hint: 'root is mandatory — pick a container to scope the structure' }
          );
        }
        // v3.2.48 — hard depth cap 3, default 2. No text (structure only).
        const maxDepth = Math.max(1, Math.min(3, a.maxDepth != null ? a.maxDepth : 2));
        const includeHidden = !!a.includeHidden;
        const CAP = 100;

        const root = safeQuery(rootSel);
        if (!root) return fail('Root element not found: ' + rootSel, { root: rootSel });

        // Build a structural node — NO text content, only shape metadata.
        function buildNode(el) {
          const node = {
            tag: el.tagName.toLowerCase(),
            selector: getUniqueSelectorInternal(el),
          };
          const aa = allAttributes(el);
          if (Object.keys(aa.attrs).length) node.attributes = aa.attrs;
          if (aa.truncatedNames.length) node.attrTruncated = aa.truncatedNames;
          if (el.children.length) node.childrenCount = el.children.length;
          const dl = subtreeDepth(el);
          if (dl > 0) node.descendantLevels = dl;
          const dtags = descendantTagCounts(el);
          if (Object.keys(dtags).length) node.descendantTags = dtags;
          const tl = (el.textContent || '').trim().length;
          if (tl) node.textLength = tl;
          return node;
        }

        // v3.2.48 — BFS level-by-level. Add a whole level only if it fits the
        // node cap; never emit a partial level (cleaner for the model). Stop at
        // maxDepth (levels of children below root).
        const rootNode = buildNode(root);
        let totalNodes = 1;
        let currentLevel = [{ el: root, node: rootNode }];
        let depth = 0;
        let truncatedAtLevel = null;
        let skippedLevelSize = 0;
        while (depth < maxDepth) {
          const candidates = [];
          for (const item of currentLevel) {
            for (const c of item.el.children) {
              if (isOurUI(c)) continue;
              if (!includeHidden && !isVisible(c)) continue;
              candidates.push({ parent: item.node, el: c });
            }
          }
          if (!candidates.length) break;
          if (totalNodes + candidates.length > CAP) {
            truncatedAtLevel = depth + 1;
            skippedLevelSize = candidates.length;
            break; // do not emit a partial level
          }
          const nextLevel = [];
          for (const cand of candidates) {
            const n = buildNode(cand.el);
            if (!cand.parent.children) cand.parent.children = [];
            cand.parent.children.push(n);
            nextLevel.push({ el: cand.el, node: n });
            totalNodes++;
          }
          currentLevel = nextLevel;
          depth++;
        }

        const deepestDescendant = subtreeDepth(root);
        const meta = {
          root: rootSel,
          maxDepthRequested: maxDepth,
          maxDepthReached: depth,
          totalNodes: totalNodes,
          deepestDescendant: deepestDescendant,
        };
        const suggestions = [];
        if (truncatedAtLevel != null) {
          meta.truncatedAtLevel = truncatedAtLevel;
          meta.skippedLevelSize = skippedLevelSize;
          suggestions.push('Level ' + truncatedAtLevel + ' had ' + skippedLevelSize + ' nodes — exceeded the ' + CAP + '-node cap and was dropped. Drill into a specific child via root:<selector> to go deeper.');
        } else if (deepestDescendant > maxDepth) {
          suggestions.push('Subtree is ' + deepestDescendant + ' levels deep (showed ' + depth + '). Re-run with a deeper root if you need more. descendantTags shows what is below each node.');
        }
        suggestions.push('Use inspect_element on a specific selector for full detail (text, computed styles).');
        return ok([rootNode], meta, suggestions);
      });
    },

    // ─── DOM: 3. searchElements ────────────────────────────────────────────────
    searchElements(args) {
      return safeRun(function () {
        const a = args || {};
        const text = a.text != null ? foldText(a.text) : null;
        const textExact = !!a.textExact;
        const selector = a.selector || null;
        const tag = a.tag ? String(a.tag).toUpperCase() : null;
        const role = a.role || null;
        const attrs = (a.attributes && typeof a.attributes === 'object' && Object.keys(a.attributes).length) ? a.attributes : null;
        const visibleOnly = a.visibleOnly !== false;
        const inViewport = !!a.inViewport;
        // v3.2.48 — hard cap 10, default 5. Plus offset for pagination.
        const limit = Math.max(1, Math.min(10, a.limit || 5));
        const offset = Math.max(0, a.offset || 0);
        const rootSel = a.root || null;

        // v3.2.48 — require at least one SPECIFIC filter. A bare wildcard
        // selector (or none) with no text/role/attributes scans the whole DOM
        // and floods the context. selector counts as specific only if it has an
        // id (#), class (.) or attribute ([...]). tag alone is NOT enough.
        const selectorIsSpecific = !!selector && /[#.\[]/.test(selector);
        const hasSpecificFilter = !!text || selectorIsSpecific || !!role || !!attrs;
        if (!hasSpecificFilter) {
          return fail(
            'search_elements needs at least one specific filter: text, a selector with an id/class/attribute (e.g. "#hero", ".banner", "[data-id]"), role, or an attributes map. ' +
            'Generic selectors like "main *", "div", "*" are not allowed alone — narrow the scope, or use count_elements to size a broad query first.',
            {
              hint: 'add text:"..." OR selector:"#id"/".class"/"[attr]" OR role:"..." OR attributes:{...}; combine with root:"#header" to scope',
            }
          );
        }

        // Scope: search within `root` if given, else whole document.
        let scopeEl = document;
        if (rootSel) {
          scopeEl = safeQuery(rootSel);
          if (!scopeEl) return fail('Root element not found: ' + rootSel, { root: rootSel });
        }
        const querySel = selector || '*';
        let all;
        try { all = Array.from(scopeEl.querySelectorAll(querySel)); }
        catch (e) { return fail('Invalid selector: ' + querySel); }

        const vw = window.innerWidth, vh = window.innerHeight;
        const matched = [];
        let totalMatched = 0;
        for (const el of all) {
          if (isOurUI(el)) continue;
          if (tag && el.tagName !== tag) continue;
          if (role && (el.getAttribute('role') || '') !== role) continue;
          if (attrs) {
            let pass = true;
            for (const k of Object.keys(attrs)) {
              const v = el.getAttribute(k);
              if (v !== String(attrs[k])) { pass = false; break; }
            }
            if (!pass) continue;
          }
          if (text) {
            if (textExact) {
              if (foldText((el.textContent || '').trim()) !== text) continue;
            } else if (textHaystack(el).indexOf(text) < 0) continue;
          }
          if (visibleOnly && !isVisible(el)) continue;
          if (inViewport) {
            const r = el.getBoundingClientRect();
            if (r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw) continue;
          }
          totalMatched++;
          // Pagination window: [offset, offset+limit)
          if (totalMatched > offset && matched.length < limit) matched.push(el);
        }

        // v3.2.48 — per-tag response shape. Containers get structural summary
        // (childCount, descendantCount, descendantTags, textLength, firstWords)
        // instead of a misleading 100-char text slice. Leaf-ish keep text.
        const items = matched.map(function (el) {
          const base = {
            selector: getUniqueSelectorInternal(el),
            tag: el.tagName.toLowerCase(),
            classes: Array.from(el.classList || []),
            attributes: safeAttributes(el),
            rect: getRect(el),
          };
          if (isContainerEl(el)) {
            const txt = (el.textContent || '').trim();
            base.childCount = el.children.length;
            base.descendantCount = el.getElementsByTagName('*').length;
            base.textLength = txt.length;
            if (txt) base.firstWords = truncate(txt.replace(/\s+/g, ' '), 50);
          } else {
            base.text = truncate((el.textContent || '').trim(), 100);
          }
          return base;
        });

        const hasMore = totalMatched > offset + items.length;
        const suggestions = [];
        if (!items.length) {
          if (text) {
            suggestions.push('No text match (matching is diacritics-insensitive and also scans aria-label/alt/title/placeholder). The page may use different wording — try a shorter or native-language keyword, or identify the element by role (e.g. "banner"/"navigation"), a class/id selector, or an attributes map instead of text.');
          } else {
            suggestions.push('No matches in this window — relax filters, set visibleOnly:false, or check the selector.');
          }
        } else {
          suggestions.push('Use inspect_element for full detail on a single match.');
          if (hasMore) suggestions.push('More matches exist — call again with offset:' + (offset + items.length) + ' if needed (usually 5-10 samples are enough to find a pattern).');
        }
        return ok(items, {
          count: items.length,
          totalMatched: totalMatched,
          offset: offset,
          hasMore: hasMore,
          nextOffset: hasMore ? (offset + items.length) : null,
        }, suggestions);
      });
    },

    // v3.2.48 — lightweight sanity check. Same filters as searchElements but
    // permissive (no specific-filter requirement) and returns ONLY a count —
    // no per-element payload. Use before a broad searchElements to size it.
    countElements(args) {
      return safeRun(function () {
        const a = args || {};
        const text = a.text != null ? foldText(a.text) : null;
        const selector = a.selector || '*';
        const tag = a.tag ? String(a.tag).toUpperCase() : null;
        const role = a.role || null;
        const attrs = (a.attributes && typeof a.attributes === 'object' && Object.keys(a.attributes).length) ? a.attributes : null;
        const visibleOnly = a.visibleOnly !== false;
        const rootSel = a.root || null;
        const CAP = 5000;

        let scopeEl = document;
        if (rootSel) {
          scopeEl = safeQuery(rootSel);
          if (!scopeEl) return fail('Root element not found: ' + rootSel, { root: rootSel });
        }
        let all;
        try { all = Array.from(scopeEl.querySelectorAll(selector)); }
        catch (e) { return fail('Invalid selector: ' + selector); }

        let count = 0;
        for (const el of all) {
          if (isOurUI(el)) continue;
          if (tag && el.tagName !== tag) continue;
          if (role && (el.getAttribute('role') || '') !== role) continue;
          if (attrs) {
            let pass = true;
            for (const k of Object.keys(attrs)) {
              if (el.getAttribute(k) !== String(attrs[k])) { pass = false; break; }
            }
            if (!pass) continue;
          }
          if (text && textHaystack(el).indexOf(text) < 0) continue;
          if (visibleOnly && !isVisible(el)) continue;
          count++;
          if (count >= CAP) break;
        }
        const overflow = count >= CAP;
        const suggestions = count > 50
          ? ['Too many matches (' + count + (overflow ? '+' : '') + ') — narrow with text/role/a specific selector before search_elements, or scope with root.']
          : (count === 0 ? ['Zero matches — the filter is too narrow or the selector is wrong.'] : ['Reasonable count — search_elements will return useful samples.']);
        return ok({ count: count, hasOverflow: overflow }, {}, suggestions);
      });
    },

    // ─── DOM: 4. inspectElement ────────────────────────────────────────────────
    inspectElement(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const el = safeQuery(a.selector);
        if (!el) return ok(null, { found: false }, ['Element not found', 'Use testSelector to validate the selector']);

        const includeStyles = a.includeStyles !== false;
        const includeChildren = a.includeChildren !== false;
        const childrenLimit = Math.max(0, Math.min(50, a.childrenLimit != null ? a.childrenLimit : 10));

        const parent = el.parentElement;
        const data = {
          selector: getUniqueSelectorInternal(el),
          tag: el.tagName.toLowerCase(),
          id: el.id || null,
          classes: Array.from(el.classList || []),
          attributes: safeAttributes(el),
          text: truncate((el.textContent || '').trim(), 200),
          htmlPreview: truncate(el.outerHTML || '', 500),
          parent: parent ? { selector: getUniqueSelectorInternal(parent), tag: parent.tagName.toLowerCase() } : null,
          children: [],
          siblings: {
            prev: el.previousElementSibling
              ? { selector: getUniqueSelectorInternal(el.previousElementSibling), tag: el.previousElementSibling.tagName.toLowerCase() }
              : null,
            next: el.nextElementSibling
              ? { selector: getUniqueSelectorInternal(el.nextElementSibling), tag: el.nextElementSibling.tagName.toLowerCase() }
              : null,
          },
          rect: getRect(el),
          visible: isVisible(el),
        };

        if (includeChildren) {
          const kids = Array.from(el.children).slice(0, childrenLimit);
          data.children = kids.map(function (c) {
            return {
              selector: getUniqueSelectorInternal(c),
              tag: c.tagName.toLowerCase(),
              text: truncate((c.textContent || '').trim(), 80),
            };
          });
          if (el.children.length > childrenLimit) {
            data.childrenTruncated = true;
            data.totalChildren = el.children.length;
          }
        }

        if (includeStyles) {
          const cs = window.getComputedStyle(el);
          const styles = {};
          for (const prop of DEFAULT_COMPUTED_PROPS) styles[prop] = cs.getPropertyValue(prop);
          data.computedStyles = styles;
        }

        // CSS rule count (matches without enumerating)
        let rulesCount = 0;
        try {
          for (const sheet of document.styleSheets) {
            let rules;
            try { rules = sheet.cssRules || sheet.rules; } catch (_) { continue; }
            if (!rules) continue;
            const walk = function (r) {
              if (r.type === 1 && r.selectorText) {
                try { if (el.matches(r.selectorText)) rulesCount++; } catch (_) {}
              } else if (r.cssRules) {
                for (const sub of r.cssRules) walk(sub);
              }
            };
            for (const r of rules) walk(r);
          }
        } catch (_) {}
        data.cssRulesCount = rulesCount;

        return ok(data, { found: true }, [
          'Use getMatchingCssRules for CSS rule sources',
          'Use getElementContext for nearby context',
        ]);
      });
    },

    // ─── DOM: 5. getComputedStyles ─────────────────────────────────────────────
    getComputedStyles(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const el = safeQuery(a.selector);
        if (!el) return fail('Element not found');
        const requested = Array.isArray(a.properties) && a.properties.length ? a.properties : DEFAULT_COMPUTED_PROPS;
        const cs = window.getComputedStyle(el);
        const out = {};
        let returnedCount = 0;
        for (const p of requested) {
          if (typeof p !== 'string') continue;
          const v = cs.getPropertyValue(p);
          if (v !== '') { out[p] = v; returnedCount++; }
        }
        return ok(out, {
          requestedCount: requested.length,
          returnedCount: returnedCount,
          defaultsUsed: !(Array.isArray(a.properties) && a.properties.length),
        });
      });
    },

    // ─── DOM: 6. getMatchingCssRules ───────────────────────────────────────────
    getMatchingCssRules(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const el = safeQuery(a.selector);
        if (!el) return fail('Element not found');

        // v3.2.48 — optional property filter. Without it, each rule returns only
        // propertyNames (no values) to keep payload small — call again with
        // `properties` to get values for what you actually care about.
        const propFilter = (Array.isArray(a.properties) && a.properties.length)
          ? a.properties.map(function (p) { return String(p).toLowerCase(); })
          : null;
        const limit = Math.max(1, Math.min(20, a.limit || 10));
        const offset = Math.max(0, a.offset || 0);
        const includeInline = a.includeInline !== false;

        // Read declarations from a CSSStyleDeclaration → { prop: value }, value
        // capped at 200 chars, data: URLs collapsed (they can be KB-sized).
        function readDecls(style) {
          const decls = {};
          if (!style) return decls;
          for (let i = 0; i < style.length; i++) {
            const prop = style[i];
            let val = style.getPropertyValue(prop);
            if (/url\(\s*['"]?data:/i.test(val)) val = '[data-url, ' + val.length + 'B]';
            else if (val.length > 200) val = truncate(val, 200);
            decls[prop] = val;
          }
          return decls;
        }
        // Shape a matched rule for output: with propFilter → declarations of the
        // matched props + otherProperties names; without → propertyNames only.
        function shapeRule(selectorText, source, specificity, fullDecls, inline) {
          const allProps = Object.keys(fullDecls);
          const r = { selector: selectorText, source: source, specificity: specificity };
          if (inline) r.inline = true;
          if (propFilter) {
            const matchedDecls = {};
            const other = [];
            allProps.forEach(function (p) {
              if (propFilter.indexOf(p.toLowerCase()) >= 0) matchedDecls[p] = fullDecls[p];
              else other.push(p);
            });
            r.declarations = matchedDecls;
            if (other.length) r.otherProperties = other;
            r._matchedFilter = Object.keys(matchedDecls).length > 0;
          } else {
            r.propertyNames = allProps;
          }
          return r;
        }

        const matches = [];
        let totalScanned = 0;
        let accessibleSheets = 0;
        let blockedByCors = 0;

        function visitRule(rule, sourceLabel) {
          totalScanned++;
          if (rule.type === 1 && rule.selectorText) {
            try {
              if (el.matches(rule.selectorText)) {
                const fullDecls = readDecls(rule.style);
                matches.push(shapeRule(rule.selectorText, sourceLabel, computeSpecificity(rule.selectorText), fullDecls, false));
              }
            } catch (_) {}
          } else if (rule.cssRules) {
            for (const sub of rule.cssRules) visitRule(sub, sourceLabel);
          }
        }

        for (const sheet of document.styleSheets) {
          let rules;
          try { rules = sheet.cssRules || sheet.rules; }
          catch (_) { blockedByCors++; continue; }
          if (!rules) continue;
          accessibleSheets++;
          const label = sheet.href || (sheet.ownerNode && sheet.ownerNode.id ? '#' + sheet.ownerNode.id : '<inline>');
          for (const r of rules) visitRule(r, label);
        }

        // Inline style attribute (highest specificity) — always with values.
        if (includeInline && el.style && el.style.length) {
          const fullDecls = readDecls(el.style);
          matches.unshift(shapeRule('style="…"', '<inline-attr>', [1000, 0, 0], fullDecls, true));
        }

        // When filtering by property, drop rules that touch none of them.
        let relevant = matches;
        if (propFilter) relevant = matches.filter(function (r) { return r.inline || r._matchedFilter; });
        relevant.forEach(function (r) { delete r._matchedFilter; });

        relevant.sort(function (x, y) { return specificityCompare(x.specificity, y.specificity); });

        const totalMatched = relevant.length;
        const out = relevant.slice(offset, offset + limit);
        const hasMore = totalMatched > offset + out.length;

        const suggestions = [];
        if (!propFilter && out.length) {
          suggestions.push('Showing property NAMES only. Call again with properties:["color","background"] to get values + which rule sets them.');
        }
        if (blockedByCors > 0) {
          suggestions.push(blockedByCors + ' stylesheet(s) blocked by CORS — rules from those (often CDN) are invisible; the live result may differ.');
        }
        suggestions.push('For final computed values (after cascade + inheritance) use get_computed_styles.');
        if (hasMore) suggestions.push('More rules exist — call again with offset:' + (offset + out.length) + '.');

        return ok(out, {
          totalMatched: totalMatched,
          count: out.length,
          offset: offset,
          hasMore: hasMore,
          nextOffset: hasMore ? (offset + out.length) : null,
          totalRulesScanned: totalScanned,
          accessibleSheets: accessibleSheets,
          blockedByCors: blockedByCors,
          filteredByProperties: propFilter || null,
        }, suggestions);
      });
    },

    // ─── DOM: 7. getUniqueSelector ─────────────────────────────────────────────
    getUniqueSelector(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const el = safeQuery(a.selector);
        if (!el) return fail('Element not found');

        const warnings = [];
        const alternatives = [];
        let preferred = null;
        let stabilityScore = 0.5;
        let strategy = 'structural';

        for (const attr of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
          const v = el.getAttribute(attr);
          if (v) {
            const sel = '[' + attr + '="' + attrEscape(v) + '"]';
            try {
              if (document.querySelectorAll(sel).length === 1) {
                if (!preferred) { preferred = sel; stabilityScore = 0.95; strategy = 'data-attr'; }
                else if (alternatives.indexOf(sel) < 0) alternatives.push(sel);
              }
            } catch (_) {}
          }
        }

        if (el.id) {
          if (!isDynamicId(el.id)) {
            const sel = '#' + cssEscape(el.id);
            try {
              if (document.querySelectorAll(sel).length === 1) {
                if (!preferred) { preferred = sel; stabilityScore = 0.9; strategy = 'id'; }
                else if (alternatives.indexOf(sel) < 0) alternatives.push(sel);
              }
            } catch (_) {}
          } else {
            warnings.push('Selector uses dynamic id: ' + el.id);
          }
        }

        const role = el.getAttribute('role');
        const ariaLabel = el.getAttribute('aria-label');
        if (role && ariaLabel) {
          const sel = '[role="' + attrEscape(role) + '"][aria-label="' + attrEscape(ariaLabel) + '"]';
          try {
            if (document.querySelectorAll(sel).length === 1) {
              if (!preferred) { preferred = sel; stabilityScore = 0.85; strategy = 'role'; }
              else if (alternatives.indexOf(sel) < 0) alternatives.push(sel);
            }
          } catch (_) {}
        }

        if (el.classList && el.classList.length) {
          const semantic = Array.from(el.classList).filter(function (c) { return !isDynamicClass(c); });
          const dynamic = Array.from(el.classList).filter(function (c) { return isDynamicClass(c); });
          if (dynamic.length) warnings.push('Selector uses dynamic class: ' + dynamic.slice(0, 3).join(', '));
          if (semantic.length) {
            const sel = el.tagName.toLowerCase() + '.' + semantic.map(cssEscape).join('.');
            try {
              if (document.querySelectorAll(sel).length === 1) {
                if (!preferred) { preferred = sel; stabilityScore = 0.7; strategy = 'class'; }
                else if (alternatives.indexOf(sel) < 0) alternatives.push(sel);
              }
            } catch (_) {}
          }
        }

        const structural = buildStructuralPath(el);
        if (!preferred) { preferred = structural; stabilityScore = 0.5; strategy = 'structural'; }
        else if (preferred !== structural && alternatives.indexOf(structural) < 0) alternatives.push(structural);

        if (strategy === 'structural') {
          if (/:nth-of-type/.test(preferred)) warnings.push('Selector relies on nth-of-type — may break on DOM reordering');
          warnings.push('No stable identifier — selector may break on DOM changes');
        }

        return ok({
          preferred: preferred,
          alternatives: alternatives.slice(0, 5),
          stabilityScore: stabilityScore,
          warnings: warnings,
        }, { strategy: strategy });
      });
    },

    // ─── DOM: 8. getElementContext ─────────────────────────────────────────────
    getElementContext(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const el = safeQuery(a.selector);
        if (!el) return fail('Element not found');

        // v3.2.68 — nearest "component" wrapper. Reliable signals only:
        //  - data-component / data-cmp attribute
        //  - a hyphenated component-ish class (product-card, list-item, …)
        // We deliberately do NOT match bare words like "row"/"container" (Bootstrap
        // layout classes) so we return real components, not layout wrappers.
        const hyphenPattern = /-(card|item|row|tile|product|modal|panel|widget|block|cell|entry|listing|article|post|wrapper|container)\b/i;
        const isComponentEl = function (node) {
          if (node.getAttribute && (node.getAttribute('data-component') || node.getAttribute('data-cmp'))) return true;
          const cls = Array.from(node.classList || []);
          return cls.some(function (c) { return hyphenPattern.test(c); });
        };
        let nearestComponent = null;
        let cur = el.parentElement;
        while (cur && cur !== document.body) {
          if (isComponentEl(cur)) { nearestComponent = getUniqueSelectorInternal(cur); break; }
          cur = cur.parentElement;
        }

        let section = null;
        const sectionEl = el.closest('section, article, main > div, main');
        if (sectionEl && sectionEl !== el) section = getUniqueSelectorInternal(sectionEl);

        const form = el.closest('form');

        // v3.2.68 — siblings as ONE linked list: { selector, tag, text, sameTag }.
        // Previously nearbyText (texts only) and relatedElements (same-tag selectors
        // only) were two unlinked arrays — you could not map a sibling's text to its
        // selector. Now each sibling is one record (up to 8); sameTag marks peers of
        // the same element type (e.g. other product cards) for iteration.
        const siblings = [];
        const parent = el.parentElement;
        if (parent) {
          for (const sib of parent.children) {
            if (sib === el) continue;
            siblings.push({
              selector: getUniqueSelectorInternal(sib),
              tag: sib.tagName.toLowerCase(),
              text: truncate((sib.textContent || '').trim(), 50),
              sameTag: sib.tagName === el.tagName,
            });
            if (siblings.length >= 8) break;
          }
        }

        return ok({
          nearestComponent: nearestComponent,
          section: section,
          form: form ? getUniqueSelectorInternal(form) : null,
          siblings: siblings,
        }, {}, [
          'siblings[].sameTag=true marks peers of the same type (e.g. other product cards)',
          'Use nearestComponent as a scope for search_elements / get_dom_outline',
        ]);
      });
    },

    // ─── DOM: 9. testSelector ──────────────────────────────────────────────────
    testSelector(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const visibleOnly = !!a.visibleOnly;
        let all;
        try { all = Array.from(document.querySelectorAll(a.selector)); }
        catch (e) { return fail('Invalid selector: ' + (e && e.message ? e.message : e)); }
        const visible = all.filter(isVisible);
        // v3.2.66 — visibleOnly (consistency with search_elements/count_elements):
        // when true, examples are sampled from visible elements. count + visibleCount
        // are always reported so the "total vs visible" signal is never lost.
        const sampleFrom = visibleOnly ? visible : all;
        const examples = sampleFrom.slice(0, 3).map(function (el) {
          return {
            text: truncate((el.textContent || '').trim(), 50),
            path: shortCssPath(el),
          };
        });
        const suggestions = [];
        if (all.length === 0) suggestions.push('Selector matches nothing — verify it exists on this page');
        else if (all.length !== visible.length) suggestions.push('Some elements are hidden — check display/visibility');
        return ok({
          selector: a.selector,
          count: all.length,
          visibleCount: visible.length,
          examples: examples,
        }, {}, suggestions);
      });
    },

    // ─── DOM: 10. runJsProbe ───────────────────────────────────────────────────
    async runJsProbe(args) {
      return await safeRunAsync(async function () {
        const a = args || {};
        if (!a.code || typeof a.code !== 'string') return fail('code is required');
        const code = a.code;
        const readOnly = !!a.readOnly;
        const timeout = Math.max(50, Math.min(30000, a.timeout || 2000));

        const warnings = [];
        if (readOnly) {
          const m = code.match(MUTATION_PATTERN);
          if (m) {
            return {
              data: null,
              meta: { blocked: true, matchedPattern: m[0] },
              suggestions: ['Code contains mutation patterns. Set readOnly:false to allow, or rewrite as pure read.'],
              warnings: ['Blocked in readOnly mode: ' + m[0]],
            };
          }
        }

        let runner;
        try { runner = new Function('return (async () => { return (' + code + '); })();'); }
        catch (_) {
          try { runner = new Function('return (async () => { ' + code + ' })();'); }
          catch (e) {
            if (isTrustedTypesError(e)) return fail(TT_BLOCKED_MSG, { trustedTypes: true });
            return fail('Syntax error: ' + (e && e.message ? e.message : e));
          }
        }

        const start = performance.now();
        const timeoutPromise = new Promise(function (_, rej) {
          setTimeout(function () { rej(new Error('Timeout exceeded ' + timeout + 'ms')); }, timeout);
        });

        let result;
        try {
          result = await Promise.race([runner(), timeoutPromise]);
        } catch (e) {
          const msg = e && e.message ? String(e.message) : String(e);
          warnings.push(msg);
          return {
            data: null,
            meta: { elapsedMs: Math.round(performance.now() - start), error: msg },
            suggestions: ['For statement-only code, last expression is not auto-returned — add explicit return'],
            warnings: warnings,
          };
        }

        return ok(serialize(result), { elapsedMs: Math.round(performance.now() - start) }, [], warnings);
      });
    },

    // ─── DOM: 11. previewCss ───────────────────────────────────────────────────
    previewCss(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.css || typeof a.css !== 'string') return fail('css is required');
        const styleId = 'jz-preview-' + Math.random().toString(36).slice(2, 10);
        const style = document.createElement('style');
        style.id = styleId;
        style.setAttribute('data-jz-preview', '1');
        style.textContent = a.css;
        document.head.appendChild(style);

        let ruleCount = 0;
        let affectedElements = 0;
        const warnings = [];
        try {
          const sheet = style.sheet;
          if (sheet && sheet.cssRules) {
            const walk = function (rule) {
              if (rule.type === 1 && rule.selectorText) {
                ruleCount++;
                try { affectedElements += document.querySelectorAll(rule.selectorText).length; }
                catch (_) { warnings.push('Could not query selector: ' + rule.selectorText); }
              } else if (rule.cssRules) {
                for (const sub of rule.cssRules) walk(sub);
              }
            };
            for (const rule of sheet.cssRules) walk(rule);
          }
        } catch (e) { warnings.push('Parse error: ' + e.message); }

        return ok({
          applied: true,
          affectedElements: affectedElements,
          ruleCount: ruleCount,
        }, { styleId: styleId }, [
          'Use testSelector first to ensure selector matches',
          'Style persists until page reload — re-run previewCss to layer new styles',
        ], warnings);
      });
    },

    // ─── DOM: 12. previewJs ────────────────────────────────────────────────────
    async previewJs(args) {
      return await safeRunAsync(async function () {
        const a = args || {};
        if (!a.code || typeof a.code !== 'string') return fail('code is required');
        const timeout = Math.max(50, Math.min(10000, a.timeout || 1000));

        const consoleLogs = [];
        const errors = [];
        const origConsole = {};
        const levels = ['log', 'warn', 'error', 'info', 'debug'];
        for (const lvl of levels) {
          origConsole[lvl] = console[lvl];
          console[lvl] = function () {
            const args = Array.prototype.slice.call(arguments);
            consoleLogs.push({ level: lvl, args: args.map(function (v) { return serialize(v); }) });
            try { origConsole[lvl].apply(console, args); } catch (_) {}
          };
        }

        const added = new Map();
        const removed = new Map();
        const attrChanged = [];
        const observer = new MutationObserver(function (records) {
          for (const r of records) {
            if (r.target && isOurUI(r.target)) continue;
            if (r.type === 'childList') {
              for (const n of r.addedNodes) {
                if (n.nodeType !== 1 || isOurUI(n)) continue;
                const sel = getUniqueSelectorInternal(n);
                if (sel) added.set(sel, true);
              }
              for (const n of r.removedNodes) {
                if (n.nodeType !== 1) continue;
                const sum = summarizeRemovedNode(n);
                if (sum) removed.set(sum.selector, sum.tag);
              }
            } else if (r.type === 'attributes') {
              if (attrChanged.length >= 50) continue;
              attrChanged.push({
                target: getUniqueSelectorInternal(r.target),
                attr: r.attributeName,
                old: r.oldValue,
                new: r.target.getAttribute ? r.target.getAttribute(r.attributeName) : null,
              });
            }
          }
        });
        observer.observe(document.documentElement, {
          childList: true, attributes: true, subtree: true, attributeOldValue: true,
        });

        const start = performance.now();
        let returnValue;
        let success = true;
        let ttBlocked = false;
        try {
          let runner;
          try { runner = new Function('return (async () => { return (' + a.code + '); })();'); }
          catch (_) { runner = new Function('return (async () => { ' + a.code + ' })();'); }
          const timeoutPromise = new Promise(function (_, rej) {
            setTimeout(function () { rej(new Error('Timeout exceeded ' + timeout + 'ms')); }, timeout);
          });
          returnValue = await Promise.race([runner(), timeoutPromise]);
        } catch (e) {
          success = false;
          ttBlocked = isTrustedTypesError(e);
          errors.push({
            message: e && e.message ? String(e.message) : String(e),
            stack: truncate(e && e.stack || '', 500),
          });
        }

        // Allow microtasks + initial macrotask to settle before reading mutations
        await new Promise(function (r) { setTimeout(r, 100); });

        observer.disconnect();
        for (const lvl of levels) console[lvl] = origConsole[lvl];

        const data = {
          success: success,
          returnValue: success ? serialize(returnValue) : null,
          consoleLogs: consoleLogs,
          errors: errors,
          domMutations: {
            added: Array.from(added.keys()).slice(0, 50),
            removed: Array.from(removed.keys()).slice(0, 50),
            attrChanged: attrChanged,
          },
        };
        return ok(data, { elapsedMs: Math.round(performance.now() - start), trustedTypes: ttBlocked || undefined },
          ttBlocked
            ? [TT_BLOCKED_MSG]
            : ['No rollback — side effects persist. Reload page to undo.']);
      });
    },

    // ─── DOM: 13. observeDomChanges ────────────────────────────────────────────
    async observeDomChanges(args) {
      return await safeRunAsync(async function () {
        const a = args || {};
        const action = a.action || 'click';
        const timeout = Math.max(50, Math.min(30000, a.timeout || 1000));

        if (action === 'click' && !a.selector) return fail('selector is required for click action');
        if (action === 'eval' && !a.code) return fail('code is required for eval action');
        if (action !== 'click' && action !== 'eval') return fail('Unknown action: ' + action);

        const addedMap = new Map();
        const removedMap = new Map();
        const changes = [];

        const observer = new MutationObserver(function (records) {
          for (const r of records) {
            if (r.target && isOurUI(r.target)) continue;
            if (r.type === 'childList') {
              for (const n of r.addedNodes) {
                if (n.nodeType !== 1 || isOurUI(n)) continue;
                const sel = getUniqueSelectorInternal(n);
                if (sel) addedMap.set(sel, {
                  selector: sel,
                  tag: n.tagName.toLowerCase(),
                  text: truncate((n.textContent || '').trim(), 50),
                });
              }
              for (const n of r.removedNodes) {
                if (n.nodeType !== 1) continue;
                const sum = summarizeRemovedNode(n);
                if (sum) removedMap.set(sum.selector, sum);
              }
            } else if (r.type === 'attributes') {
              if (changes.length >= 50) continue;
              changes.push({
                selector: getUniqueSelectorInternal(r.target),
                attribute: r.attributeName,
                before: r.oldValue,
                after: r.target.getAttribute ? r.target.getAttribute(r.attributeName) : null,
              });
            }
          }
        });
        observer.observe(document.documentElement, {
          childList: true, attributes: true, subtree: true, attributeOldValue: true,
        });

        const start = performance.now();
        const warnings = [];
        try {
          if (action === 'click') {
            const el = safeQuery(a.selector);
            if (!el) { observer.disconnect(); return fail('Element not found'); }
            const ev = new MouseEvent('click', { bubbles: true, cancelable: true, view: window });
            el.dispatchEvent(ev);
          } else if (action === 'eval') {
            try { new Function(a.code)(); }
            catch (e) { warnings.push('Eval error: ' + (e && e.message ? e.message : e)); }
          }
        } catch (e) {
          warnings.push('Action error: ' + (e && e.message ? e.message : e));
        }

        await new Promise(function (r) { setTimeout(r, timeout); });
        observer.disconnect();

        // Dedup: if same key appears in added and removed → no-op
        for (const k of Array.from(removedMap.keys())) {
          if (addedMap.has(k)) { addedMap.delete(k); removedMap.delete(k); }
        }

        const data = {
          added: Array.from(addedMap.values()).slice(0, 50),
          removed: Array.from(removedMap.values()).slice(0, 50),
          changed: changes.slice(0, 50),
        };
        return ok(data, {
          elapsedMs: Math.round(performance.now() - start),
          mutationCount: data.added.length + data.removed.length + data.changed.length,
        }, [], warnings);
      });
    },

    // ─── DOM: 14. recordDomTimeline ────────────────────────────────────────────
    async recordDomTimeline(args) {
      return await safeRunAsync(async function () {
        const a = args || {};
        // v3.2.48 — duration max 10s (was 30), throttle default 200ms (was 50),
        // maxEvents 50 (was 500), optional root scope, value caps 100ch.
        const duration = Math.max(50, Math.min(10000, a.duration || 1000));
        const throttle = Math.max(0, Math.min(5000, a.throttle != null ? a.throttle : 200));
        const filter = a.filter || 'all';
        const rootSel = a.root || null;
        const maxEvents = 50;

        let observeTarget = document.documentElement;
        if (rootSel) {
          const r = safeQuery(rootSel);
          if (!r) return fail('Root element not found: ' + rootSel, { root: rootSel });
          observeTarget = r;
        }

        const start = Date.now();
        const events = [];
        let throttledCount = 0;
        let droppedCount = 0;
        const lastByKey = new Map();
        const eventTypes = { added: 0, removed: 0, attrChanged: 0, textChanged: 0 };
        const targetCounts = {};

        function pushEvent(t, type, target, details) {
          eventTypes[type] = (eventTypes[type] || 0) + 1;
          targetCounts[target] = (targetCounts[target] || 0) + 1;
          if (events.length >= maxEvents) { droppedCount++; return; }
          const key = type + '::' + target + (details && details.attr ? '::' + details.attr : '');
          if (throttle > 0 && lastByKey.has(key) && (t - lastByKey.get(key)) < throttle) {
            throttledCount++;
            return;
          }
          lastByKey.set(key, t);
          events.push({ t: t, type: type, target: target, details: details || {} });
        }

        const observer = new MutationObserver(function (records) {
          for (const r of records) {
            if (r.target && isOurUI(r.target)) continue;
            const t = Date.now() - start;
            if (r.type === 'childList') {
              if (filter !== 'attrOnly') {
                for (const n of r.addedNodes) {
                  if (n.nodeType !== 1 || isOurUI(n)) continue;
                  pushEvent(t, 'added', getUniqueSelectorInternal(n) || n.tagName.toLowerCase(), { tag: n.tagName.toLowerCase() });
                }
              }
              if (filter !== 'attrOnly' && filter !== 'addOnly') {
                for (const n of r.removedNodes) {
                  if (n.nodeType !== 1) continue;
                  const sum = summarizeRemovedNode(n);
                  if (sum) pushEvent(t, 'removed', sum.selector, { tag: sum.tag });
                }
              }
            } else if (r.type === 'attributes') {
              if (filter === 'addOnly') continue;
              pushEvent(t, 'attrChanged', getUniqueSelectorInternal(r.target), {
                attr: r.attributeName,
                old: truncate(r.oldValue == null ? '' : String(r.oldValue), 100),
                new: truncate(r.target.getAttribute ? (r.target.getAttribute(r.attributeName) || '') : '', 100),
              });
            } else if (r.type === 'characterData') {
              if (filter === 'addOnly' || filter === 'attrOnly') continue;
              const parent = r.target.parentElement;
              if (!parent || isOurUI(parent)) continue;
              pushEvent(t, 'textChanged', getUniqueSelectorInternal(parent), {
                text: truncate(r.target.textContent || '', 100),
              });
            }
          }
        });
        observer.observe(observeTarget, {
          childList: true, attributes: true, subtree: true,
          attributeOldValue: true, characterData: true,
        });

        await new Promise(function (r) { setTimeout(r, duration); });
        observer.disconnect();

        events.sort(function (a, b) { return a.t - b.t; });

        // Top-5 most-touched targets + a "rest" tally, so the agent sees the
        // pattern without reading every event.
        const sortedTargets = Object.keys(targetCounts).sort(function (x, y) { return targetCounts[y] - targetCounts[x]; });
        const targetSummary = {};
        let restTotal = 0;
        sortedTargets.forEach(function (sel, idx) {
          if (idx < 5) targetSummary[sel] = targetCounts[sel];
          else restTotal += targetCounts[sel];
        });
        if (restTotal) targetSummary['(' + (sortedTargets.length - 5) + ' more)'] = restTotal;

        const totalObserved = eventTypes.added + eventTypes.removed + eventTypes.attrChanged + eventTypes.textChanged;
        const suggestions = [];
        if (!totalObserved) suggestions.push('0 events in ' + duration + 'ms — the page was idle. Trigger an action first (simulate_click) then record.');
        else suggestions.push('eventTypes + targetSummary show the pattern; the events array is a sample (max ' + maxEvents + ').');
        if (droppedCount) suggestions.push(droppedCount + ' events past the ' + maxEvents + '-event sample cap — narrow with root, filter, or shorter duration.');

        return ok(events, {
          startTime: start,
          endTime: Date.now(),
          durationMs: duration,
          eventCount: events.length,
          totalObserved: totalObserved,
          eventTypes: eventTypes,
          targetSummary: targetSummary,
          throttledCount: throttledCount,
          droppedCount: droppedCount,
          root: rootSel || null,
        }, suggestions);
      });
    },

    // ─── VISION: 15. getElementRectForScreenshot ──────────────────────────────
    getElementRectForScreenshot(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const el = safeQuery(a.selector);
        if (!el) return ok(null, { found: false }, ['Element not found']);
        const padding = Math.max(0, Math.min(200, a.padding || 0));
        const r = el.getBoundingClientRect();
        const data = {
          rect: {
            x: Math.max(0, Math.round(r.x - padding)),
            y: Math.max(0, Math.round(r.y - padding)),
            width: Math.round(r.width + padding * 2),
            height: Math.round(r.height + padding * 2),
          },
          scrollX: window.scrollX,
          scrollY: window.scrollY,
          devicePixelRatio: window.devicePixelRatio || 1,
          viewport: { w: window.innerWidth, h: window.innerHeight },
        };
        return ok(data, { found: true });
      });
    },

    // ─── INTERACTION: 16. simulateClick ────────────────────────────────────────
    simulateClick(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const el = safeQuery(a.selector);
        if (!el) return ok({ dispatched: false, target: a.selector }, {}, [], ['Element not found']);
        const mod = a.modifier;
        const init = {
          bubbles: true,
          cancelable: true,
          view: window,
          button: 0,
          ctrlKey: mod === 'ctrl',
          shiftKey: mod === 'shift',
          altKey: mod === 'alt',
          metaKey: mod === 'meta',
        };
        const ev = new MouseEvent('click', init);
        const acceptedByDefault = el.dispatchEvent(ev);
        return ok({
          dispatched: true,
          target: getUniqueSelectorInternal(el),
        }, {
          defaultPrevented: !acceptedByDefault,
          isTrusted: false,
        }, [], ['Synthetic event (isTrusted: false). Some apps reject these.']);
      });
    },

    // ─── INTERACTION: 17. simulateHover ────────────────────────────────────────
    simulateHover(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        const el = safeQuery(a.selector);
        if (!el) return ok({ dispatched: false, target: a.selector }, {}, [], ['Element not found']);
        const baseInit = { bubbles: true, cancelable: true, view: window };
        el.dispatchEvent(new MouseEvent('mouseenter', Object.assign({}, baseInit, { bubbles: false })));
        el.dispatchEvent(new MouseEvent('mouseover', baseInit));
        el.dispatchEvent(new MouseEvent('mousemove', baseInit));
        return ok({ dispatched: true, target: getUniqueSelectorInternal(el) },
          { isTrusted: false }, [], ['Synthetic events (isTrusted: false)']);
      });
    },

    // ─── INTERACTION: 18. simulateScroll ──────────────────────────────────────
    simulateScroll(args) {
      return safeRun(function () {
        const a = args || {};
        const smooth = a.smooth !== false;
        if (a.selector) {
          const el = safeQuery(a.selector);
          if (!el) return ok({ scrolled: false, position: { x: window.scrollX, y: window.scrollY } },
            {}, [], ['Element not found']);
          el.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center', inline: 'nearest' });
          return ok({ scrolled: true, position: { x: window.scrollX, y: window.scrollY } });
        }
        const x = typeof a.x === 'number' ? a.x : window.scrollX;
        const y = typeof a.y === 'number' ? a.y : window.scrollY;
        window.scrollTo({ left: x, top: y, behavior: smooth ? 'smooth' : 'auto' });
        return ok({ scrolled: true, position: { x: x, y: y } });
      });
    },

    // ─── INTERACTION: 19. simulateKeypress ────────────────────────────────────
    simulateKeypress(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.key) return fail('key is required');
        let target;
        if (a.selector) {
          target = safeQuery(a.selector);
          if (!target) return ok({ dispatched: false }, {}, [], ['Target element not found']);
          if (target.focus) { try { target.focus(); } catch (_) {} }
        } else {
          target = document.activeElement || document.body;
        }
        const mods = (Array.isArray(a.modifiers) ? a.modifiers : []).map(function (m) { return String(m).toLowerCase(); });
        const init = {
          key: a.key,
          code: a.key.length === 1 ? 'Key' + a.key.toUpperCase() : a.key,
          bubbles: true,
          cancelable: true,
          ctrlKey: mods.indexOf('ctrl') >= 0,
          shiftKey: mods.indexOf('shift') >= 0,
          altKey: mods.indexOf('alt') >= 0,
          metaKey: mods.indexOf('meta') >= 0,
        };
        target.dispatchEvent(new KeyboardEvent('keydown', init));
        if (a.key.length === 1 && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
          try {
            const proto = target.tagName === 'INPUT' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
            const desc = Object.getOwnPropertyDescriptor(proto, 'value');
            const newVal = (target.value || '') + a.key;
            if (desc && desc.set) desc.set.call(target, newVal); else target.value = newVal;
            target.dispatchEvent(new InputEvent('input', { bubbles: true, data: a.key, inputType: 'insertText' }));
          } catch (_) {}
        }
        target.dispatchEvent(new KeyboardEvent('keyup', init));
        return ok({
          dispatched: true,
          target: target === document.body ? 'body' : getUniqueSelectorInternal(target),
          key: a.key,
          modifiers: mods,
        }, { isTrusted: false });
      });
    },

    // ─── INTERACTION: 20. simulateType ────────────────────────────────────────
    simulateType(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.selector) return fail('selector is required');
        if (typeof a.text !== 'string') return fail('text is required');
        const el = safeQuery(a.selector);
        if (!el) return ok({ typed: false }, {}, [], ['Element not found']);
        const clear = a.clear !== false;
        const warnings = ['Synthetic input (isTrusted: false)'];
        const tag = el.tagName;

        try {
          if (tag === 'INPUT' || tag === 'TEXTAREA') {
            const proto = tag === 'INPUT' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
            const desc = Object.getOwnPropertyDescriptor(proto, 'value');
            const setter = desc && desc.set;
            if (clear) {
              if (setter) setter.call(el, ''); else el.value = '';
            }
            if (setter) setter.call(el, a.text); else el.value = a.text;
            el.dispatchEvent(new InputEvent('input', { bubbles: true, data: a.text, inputType: 'insertText' }));
            el.dispatchEvent(new Event('change', { bubbles: true }));
            return ok({ typed: true, value: el.value, target: getUniqueSelectorInternal(el) }, {}, [], warnings);
          }
          if (el.isContentEditable) {
            if (clear) el.textContent = '';
            el.textContent = (clear ? '' : el.textContent || '') + a.text;
            el.dispatchEvent(new InputEvent('input', { bubbles: true }));
            return ok({ typed: true, value: el.textContent, target: getUniqueSelectorInternal(el) }, {}, [], warnings);
          }
          return ok({ typed: false, target: getUniqueSelectorInternal(el) }, {}, [],
            ['Element is not an input/textarea/contenteditable']);
        } catch (e) {
          return fail('Type error: ' + (e && e.message ? e.message : e));
        }
      });
    },

    // ─── INTERACTION: 21. simulateFormFill ────────────────────────────────────
    simulateFormFill(args) {
      return safeRun(function () {
        const a = args || {};
        if (!a.formSelector) return fail('formSelector is required');
        if (!a.values || typeof a.values !== 'object') return fail('values object is required');
        const form = safeQuery(a.formSelector);
        if (!form) return fail('Form not found');

        const filled = [];
        const skipped = [];

        for (const name of Object.keys(a.values)) {
          const value = a.values[name];
          let field;
          try { field = form.querySelector('[name="' + attrEscape(name) + '"]'); }
          catch (_) { field = null; }
          if (!field) { skipped.push({ name: name, reason: 'not found' }); continue; }

          try {
            const tag = field.tagName;
            const type = (field.type || '').toLowerCase();

            if (type === 'checkbox' || type === 'radio') {
              if (type === 'radio') {
                // For radios, value selects the radio with matching value
                const group = form.querySelectorAll('[name="' + attrEscape(name) + '"]');
                let matched = false;
                for (const r of group) {
                  if (String(r.value) === String(value)) {
                    r.checked = true;
                    r.dispatchEvent(new Event('change', { bubbles: true }));
                    matched = true;
                  }
                }
                if (matched) filled.push({ name: name, value: String(value) });
                else skipped.push({ name: name, reason: 'no radio with value: ' + value });
              } else {
                field.checked = !!value;
                field.dispatchEvent(new Event('change', { bubbles: true }));
                filled.push({ name: name, value: !!value });
              }
            } else if (tag === 'SELECT') {
              field.value = String(value);
              field.dispatchEvent(new Event('change', { bubbles: true }));
              filled.push({ name: name, value: field.value });
            } else if (tag === 'INPUT' || tag === 'TEXTAREA') {
              const proto = tag === 'INPUT' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
              const desc = Object.getOwnPropertyDescriptor(proto, 'value');
              if (desc && desc.set) desc.set.call(field, String(value));
              else field.value = String(value);
              field.dispatchEvent(new InputEvent('input', { bubbles: true }));
              field.dispatchEvent(new Event('change', { bubbles: true }));
              filled.push({ name: name, value: String(value) });
            } else {
              skipped.push({ name: name, reason: 'unsupported field type: ' + tag });
            }
          } catch (e) {
            skipped.push({ name: name, reason: 'error: ' + (e && e.message ? e.message : e) });
          }
        }

        return ok({ filled: filled, skipped: skipped }, { formSelector: a.formSelector });
      });
    },
  };

  window.__aiPageTools = tools;
})();
