import { normalizeSelector } from "./selectors.js";
import { safeSliceHead } from "../utils/safe-slice.js";
const HELPER_DEFAULT_MAX = 8_000;
export async function extractVisibleText(page, args = {}) {
    let scopeSel;
    if (args.selector) {
        const norm = normalizeSelector(args.selector);
        if (!norm.ok)
            return `Error: ${norm.error}`;
        scopeSel = norm.selector;
    }
    const text = await page.evaluate((sel) => {
        // Arrow form (not `function visibleText()` / method shorthand) is
        // intentional: tsx/esbuild wraps named functions with __name() to
        // preserve Function.name, and __name isn't defined in the page
        // context. Same reason `acceptNode` is written as a property with
        // an arrow value rather than method shorthand.
        const visibleText = (root) => {
            const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
                acceptNode: (n) => {
                    const t = (n.textContent ?? "").trim();
                    if (!t)
                        return NodeFilter.FILTER_REJECT;
                    const parent = n.parentElement;
                    if (!parent)
                        return NodeFilter.FILTER_REJECT;
                    if (parent.closest("script,style,noscript,template,svg")) {
                        return NodeFilter.FILTER_REJECT;
                    }
                    const style = window.getComputedStyle(parent);
                    if (style.display === "none" ||
                        style.visibility === "hidden" ||
                        style.opacity === "0")
                        return NodeFilter.FILTER_REJECT;
                    return NodeFilter.FILTER_ACCEPT;
                },
            });
            const out = [];
            let n = walker.nextNode();
            while (n) {
                out.push((n.textContent ?? "").trim());
                n = walker.nextNode();
            }
            return out.join("\n");
        };
        if (sel) {
            const root = document.querySelector(sel);
            if (!root)
                return null;
            return visibleText(root);
        }
        return visibleText(document.body);
    }, scopeSel);
    if (text == null) {
        return `Error: selector "${scopeSel}" not found on page.`;
    }
    return capChars(text, args.maxChars ?? HELPER_DEFAULT_MAX);
}
export async function queryByText(page, args) {
    if (!args.text || typeof args.text !== "string") {
        return "Error: queryByText requires `text`.";
    }
    const limit = args.limit ?? 20;
    const fuzzy = args.fuzzy === true;
    const results = await page.evaluate(({ needle, fuzzy, limit }) => {
        // UTF-16 boundary-safe truncation. A naive `slice(0, n)` may split a
        // surrogate pair (emoji etc.) and produce a lone surrogate, which the
        // Anthropic API later rejects as "invalid high surrogate in string".
        const __safeCut = (s, n) => {
            if (s.length <= n)
                return s;
            const a = s.charCodeAt(n - 1);
            const b = s.charCodeAt(n);
            return s.slice(0, a >= 0xd800 && a <= 0xdbff && b >= 0xdc00 && b <= 0xdfff ? n - 1 : n);
        };
        const want = fuzzy ? needle.toLowerCase() : needle;
        const out = [];
        const all = document.querySelectorAll("*");
        for (let i = 0; i < all.length && out.length < limit; i++) {
            const el = all[i];
            const text = el.innerText ?? "";
            if (!text)
                continue;
            const compare = fuzzy ? text.toLowerCase() : text;
            const match = fuzzy ? compare.includes(want) : compare === want;
            if (!match)
                continue;
            // Skip if a descendant also matches — we want the deepest match,
            // i.e. the smallest containing element.
            let descendantMatches = false;
            for (let j = 0; j < el.children.length; j++) {
                const childText = el.children[j].innerText ?? "";
                const childCmp = fuzzy ? childText.toLowerCase() : childText;
                if ((fuzzy && childCmp.includes(want)) ||
                    (!fuzzy && childCmp === want)) {
                    descendantMatches = true;
                    break;
                }
            }
            if (descendantMatches)
                continue;
            // Selector picking with the same uniqueness discipline as the
            // snapshot enricher: only emit a selector when it actually
            // resolves to ONE element on the live page. Non-unique testids
            // (repeated feed/list action buttons) are extremely common and
            // emitting them as `selector` would mislead the LLM into clicking
            // the first match.
            const testid = el.getAttribute("data-testid");
            const id = el.id;
            let selector = null;
            if (testid) {
                const sel = `[data-testid="${testid}"]`;
                if (document.querySelectorAll(sel).length === 1)
                    selector = sel;
            }
            if (!selector && id && /^[A-Za-z][\w-]*$/.test(id)) {
                if (document.querySelectorAll(`#${id}`).length === 1)
                    selector = `#${id}`;
            }
            out.push({
                tag: el.tagName.toLowerCase(),
                role: el.getAttribute("role"),
                text: text.length > 200 ? __safeCut(text, 200) + "…" : text,
                selector,
                href: el.tagName === "A" ? el.href || null : null,
            });
        }
        return out;
    }, { needle: args.text, fuzzy, limit });
    if (results.length === 0) {
        return `(no elements matched ${JSON.stringify(args.text)})`;
    }
    return JSON.stringify(results, null, 2);
}
export async function findClickableNearText(page, args) {
    if (!args.text)
        return "Error: findClickableNearText requires `text`.";
    const maxAncestors = args.maxAncestors ?? 5;
    const limit = args.limit ?? 10;
    const results = await page.evaluate(({ needle, maxAncestors, limit }) => {
        // Arrow form for the same __name reason documented in
        // extractVisibleText above — keep all inline helpers as const
        // arrows so this evaluate body survives tsx/esbuild bundling.
        // UTF-16 boundary-safe truncation; see queryByText for rationale.
        const __safeCut = (s, n) => {
            if (s.length <= n)
                return s;
            const a = s.charCodeAt(n - 1);
            const b = s.charCodeAt(n);
            return s.slice(0, a >= 0xd800 && a <= 0xdbff && b >= 0xdc00 && b <= 0xdfff ? n - 1 : n);
        };
        const isClickable = (el) => {
            const tag = el.tagName.toLowerCase();
            if (tag === "a" || tag === "button")
                return true;
            const role = el.getAttribute("role");
            if (role === "button" ||
                role === "link" ||
                role === "menuitem" ||
                role === "tab")
                return true;
            if (el.onclick != null)
                return true;
            const tabindex = el.getAttribute("tabindex");
            if (tabindex && tabindex !== "-1")
                return true;
            return false;
        };
        // Find anchors with the requested text first.
        const all = document.querySelectorAll("*");
        const anchors = [];
        for (let i = 0; i < all.length; i++) {
            const el = all[i];
            const text = el.innerText ?? "";
            if (text.includes(needle)) {
                // prefer deepest match (smallest container)
                let deeper = false;
                for (let j = 0; j < el.children.length; j++) {
                    const c = el.children[j].innerText ?? "";
                    if (c.includes(needle)) {
                        deeper = true;
                        break;
                    }
                }
                if (!deeper)
                    anchors.push(el);
            }
        }
        const candidates = [];
        const seen = new Set();
        // Same uniqueness discipline as the snapshot enricher: emit a
        // selector only when it resolves to a single element on the page.
        const pickUniqueSelector = (node) => {
            const testid = node.getAttribute("data-testid");
            const id = node.id;
            if (testid) {
                const sel = `[data-testid="${testid}"]`;
                if (document.querySelectorAll(sel).length === 1)
                    return sel;
            }
            if (id && /^[A-Za-z][\w-]*$/.test(id)) {
                if (document.querySelectorAll(`#${id}`).length === 1)
                    return `#${id}`;
            }
            return null;
        };
        const pickRoleRef = (node, text) => {
            const role = node.getAttribute("role");
            if (!role || !/^[a-z]+$/.test(role))
                return null;
            const name = text.trim().replace(/\s+/g, " ");
            if (!name || name.length > 120)
                return null;
            return `${role}|${name}`;
        };
        for (const anchor of anchors) {
            // Walk up looking for a clickable ancestor first.
            let node = anchor;
            let levels = 0;
            while (node && levels <= maxAncestors) {
                if (isClickable(node) && !seen.has(node)) {
                    seen.add(node);
                    const text = node.innerText ?? "";
                    candidates.push({
                        tag: node.tagName.toLowerCase(),
                        role: node.getAttribute("role"),
                        text: text.length > 200 ? __safeCut(text, 200) + "…" : text,
                        ref: pickRoleRef(node, text),
                        selector: pickUniqueSelector(node),
                        anchorText: __safeCut(anchor.innerText ?? "", 80),
                    });
                    break;
                }
                node = node.parentElement;
                levels++;
            }
            // Then look for clickable descendants within the anchor.
            const desc = anchor.querySelectorAll("a,button,[role=button],[role=link],[role=menuitem],[role=tab]");
            for (let i = 0; i < desc.length && candidates.length < limit; i++) {
                const node = desc[i];
                if (seen.has(node))
                    continue;
                if (!isClickable(node))
                    continue;
                seen.add(node);
                const text = node.innerText ?? "";
                candidates.push({
                    tag: node.tagName.toLowerCase(),
                    role: node.getAttribute("role"),
                    text: text.length > 200 ? __safeCut(text, 200) + "…" : text,
                    ref: pickRoleRef(node, text),
                    selector: pickUniqueSelector(node),
                    anchorText: __safeCut(anchor.innerText ?? "", 80),
                });
            }
            if (candidates.length >= limit)
                break;
        }
        return candidates.slice(0, limit);
    }, { needle: args.text, maxAncestors, limit });
    if (results.length === 0) {
        return `(no clickable element found near ${JSON.stringify(args.text)})`;
    }
    return JSON.stringify(results, null, 2);
}
export async function extractLinks(page, args = {}) {
    let scopeSel;
    if (args.selector) {
        const norm = normalizeSelector(args.selector);
        if (!norm.ok)
            return `Error: ${norm.error}`;
        scopeSel = norm.selector;
    }
    const limit = args.limit ?? 50;
    const links = await page.evaluate(({ scope, limit }) => {
        // UTF-16 boundary-safe truncation; see queryByText for rationale.
        const __safeCut = (s, n) => {
            if (s.length <= n)
                return s;
            const a = s.charCodeAt(n - 1);
            const b = s.charCodeAt(n);
            return s.slice(0, a >= 0xd800 && a <= 0xdbff && b >= 0xdc00 && b <= 0xdfff ? n - 1 : n);
        };
        const root = scope ? document.querySelector(scope) : document.body;
        if (!root)
            return null;
        const anchors = root.querySelectorAll("a[href]");
        const out = [];
        for (let i = 0; i < anchors.length && out.length < limit; i++) {
            const a = anchors[i];
            const text = (a.innerText ?? "").trim();
            if (!text && !a.getAttribute("aria-label"))
                continue;
            const testid = a.getAttribute("data-testid");
            let selector = null;
            if (testid) {
                const sel = `[data-testid="${testid}"]`;
                if (document.querySelectorAll(sel).length === 1)
                    selector = sel;
            }
            out.push({
                text: text.length > 200
                    ? __safeCut(text, 200) + "…"
                    : text || a.getAttribute("aria-label") || "",
                href: a.href,
                selector,
                role: a.getAttribute("role"),
            });
        }
        return out;
    }, { scope: scopeSel, limit });
    if (links == null) {
        return `Error: selector "${scopeSel}" not found on page.`;
    }
    if (links.length === 0) {
        return "(no visible links)";
    }
    return JSON.stringify(links, null, 2);
}
export async function extractTweetCards(page, args = {}) {
    const limit = args.limit ?? 20;
    const cards = await page.evaluate((limit) => {
        const cells = document.querySelectorAll('[data-testid="cellInnerDiv"]');
        const out = [];
        for (let i = 0; i < cells.length && out.length < limit; i++) {
            const cell = cells[i];
            // Each cell may host a post, ad, or other; only pull when an article exists.
            const article = cell.querySelector('article[role="article"]');
            if (!article)
                continue;
            const tweetTextEl = article.querySelector('[data-testid="tweetText"]');
            const userNameEl = article.querySelector('[data-testid="User-Name"]');
            const timeEl = article.querySelector("time");
            const linkEl = timeEl?.parentElement;
            const statusUrl = linkEl?.href ?? null;
            // Build a per-card scope so each action button selector resolves to
            // exactly THIS card. The status URL is the only timeline-stable
            // anchor (cell index changes as the feed scrolls), so we use
            // :has(a[href$="/status/N"]) when one is available. Fall back to
            // the cell's nth-of-type so the LLM still gets a usable handle —
            // less stable across feed updates but accurate at extraction time.
            let cardScope;
            const statusPath = statusUrl
                ? new URL(statusUrl, window.location.href).pathname
                : null;
            if (statusPath) {
                // CSS attribute selectors don't need to escape forward slashes.
                cardScope = `[data-testid="cellInnerDiv"]:has(a[href$="${statusPath}"])`;
            }
            else {
                // 1-indexed CSS nth-of-type matches our `i` counter directly.
                cardScope = `[data-testid="cellInnerDiv"]:nth-of-type(${i + 1})`;
            }
            // Action buttons typically have data-testid values like reply/repost/like.
            // Each one is non-unique on the page, so we ALWAYS scope it to the
            // owning card. Bare `[data-testid="reply"]` would point at the first
            // matching reply button regardless of which card was extracted.
            const buttons = {};
            article
                .querySelectorAll("[data-testid]")
                .forEach((b) => {
                const tid = b.getAttribute("data-testid") || "";
                if (tid === "reply" ||
                    tid === "retweet" ||
                    tid === "unretweet" ||
                    tid === "like" ||
                    tid === "unlike" ||
                    tid === "bookmark" ||
                    tid === "removeBookmark") {
                    buttons[tid] = `${cardScope} [data-testid="${tid}"]`;
                }
            });
            out.push({
                index: out.length,
                text: tweetTextEl?.textContent ?? null,
                author: userNameEl?.textContent ?? null,
                timestamp: timeEl?.getAttribute("datetime") ?? null,
                statusUrl,
                cardSelector: cardScope,
                buttons,
            });
        }
        return out;
    }, limit);
    if (cards.length === 0) {
        return "(no social post cards found — are you on a supported timeline/profile/search page?)";
    }
    return JSON.stringify(cards, null, 2);
}
/**
 * Quick syntactic sanity check for `evaluate` scripts. We compile via
 * `new Function` which throws on parse error without ever executing the
 * code. Catches the most common LLM syntax slips (missing parens, stray
 * commas, illegal identifiers) before a round-trip to the page.
 */
export function wrapEvaluateScript(script) {
    const src = script.trim();
    const inner = shouldInvokeEvaluateScript(src) ? `(${src})()` : `(${src})`;
    return `(async () => { const __r = await (${inner}); return __r === undefined ? 'ok' : __r; })()`;
}
export function preflightEvaluateScript(script) {
    if (!script || typeof script !== "string") {
        return { ok: false, message: "evaluate requires a non-empty `fn` string" };
    }
    // Wrap matches the runtime wrapping in actions.evaluate so we catch
    // parse failures specific to the same execution shape.
    const wrapped = wrapEvaluateScript(script);
    try {
        new Function(wrapped);
        return { ok: true };
    }
    catch (err) {
        return {
            ok: false,
            message: err instanceof Error ? err.message : String(err),
        };
    }
}
function shouldInvokeEvaluateScript(src) {
    if (/^(async\s+)?function\b/.test(src))
        return true;
    return isArrowFunctionLiteral(src);
}
function isArrowFunctionLiteral(src) {
    const s = src.startsWith("async ") ? src.slice(6).trimStart() : src;
    if (/^[A-Za-z_$][\w$]*\s*=>/.test(s))
        return true;
    if (!s.startsWith("("))
        return false;
    const close = findMatchingParen(s, 0);
    if (close < 0)
        return false;
    if (/^\s*=>/.test(s.slice(close + 1)))
        return true;
    if (close === s.length - 1) {
        return isArrowFunctionLiteral(s.slice(1, close).trim());
    }
    return false;
}
function findMatchingParen(src, open) {
    let depth = 0;
    for (let i = open; i < src.length; i++) {
        const ch = src[i];
        if (ch === '"' || ch === "'" || ch === "`") {
            i = skipString(src, i, ch);
            continue;
        }
        if (ch === "(")
            depth++;
        else if (ch === ")") {
            depth--;
            if (depth === 0)
                return i;
        }
    }
    return -1;
}
function skipString(src, start, quote) {
    for (let i = start + 1; i < src.length; i++) {
        if (src[i] === "\\") {
            i++;
            continue;
        }
        if (src[i] === quote)
            return i;
    }
    return src.length - 1;
}
function capChars(text, max) {
    if (text.length <= max)
        return text;
    // Use safeSliceHead so we never leave a lone surrogate at the cut point —
    // capped strings flow back through tool results into LLM request bodies.
    const cut = safeSliceHead(text, max);
    return cut + `\n[...truncated ${text.length - cut.length} chars]`;
}
//# sourceMappingURL=dom-helpers.js.map