import { normalizeSelector } from "./selectors.js";
import { safeSliceHead, safeSliceTail } from "../utils/safe-slice.js";
import { collectPageOutline } from "./page-outline.js";
/**
 * ARIA roles that represent interactive/actionable elements.
 * Used by "interactive" mode to filter the snapshot.
 */
const INTERACTIVE_ROLES = new Set([
    "link",
    "button",
    "textbox",
    "combobox",
    "searchbox",
    "checkbox",
    "radio",
    "switch",
    "slider",
    "spinbutton",
    "menuitem",
    "menuitemcheckbox",
    "menuitemradio",
    "option",
    "tab",
    "treeitem",
    "select",
    "input",
    "textarea",
    "a",
]);
let currentRefs = new Map();
/**
 * Metadata from the *previous* snapshot. Kept alive across snapshot calls
 * so stale-ref recovery (locator-resolver) can still look up the original
 * role/name/stableSelector when the LLM acts on a ref it grabbed before
 * the most recent snapshot — by far the most common stale-ref scenario
 * in production logs.
 *
 * Cleared by `resetRefs()` (navigation, tab switch, dialog) because those
 * change the page enough that old metadata can't be trusted.
 */
let previousRefs = new Map();
/**
 * Wipe all ref metadata. Called when the page identity changes
 * (navigation, tab switch, page close) — both the live and historical
 * caches refer to a page we no longer have.
 */
export function resetRefs() {
    currentRefs = new Map();
    previousRefs = new Map();
}
/**
 * Rotate the current ref map into the historical slot. Called by
 * `takeSnapshot` before populating fresh refs, so an action that uses
 * a ref from the prior snapshot can still recover via stableSelector
 * or role+name fallback.
 *
 * NOT called on navigation — `resetRefs` is the right entry point for that.
 */
function rotateRefs() {
    previousRefs = currentRefs;
    currentRefs = new Map();
}
export function getElementRef(ref) {
    return currentRefs.get(ref);
}
/**
 * Look up a ref's metadata in the previous-snapshot cache only. Used by
 * stale-ref recovery to locate the role/name/stableSelector of a ref the
 * LLM is acting on AFTER taking a fresh snapshot that invalidated it.
 *
 * The recovery code will NOT use this metadata to re-query via aria-ref —
 * that would point at the wrong DOM. It uses the stable attributes
 * (data-testid, etc.) and role+name pair to find the equivalent element
 * in the current snapshot.
 */
export function getPreviousElementRef(ref) {
    return previousRefs.get(ref);
}
export function getAllRefs() {
    return currentRefs;
}
export const FULL_DEFAULT_MAX_CHARS = 10_000;
export const FULL_HARD_MAX_CHARS = 16_000;
export const WIDE_FULL_HARD_MAX_CHARS = 10_000;
export const INTERACTIVE_DEFAULT_MAX_CHARS = 8_000;
export const INTERACTIVE_HARD_MAX_CHARS = 12_000;
export const EFFICIENT_MAX_CHARS = 8_000;
export const EFFICIENT_MAX_DEPTH = 6;
const BROAD_SELECTOR_PATTERNS = [
    /^html$/i,
    /^body$/i,
    /^document$/i,
    /^:root$/i,
    /^\*$/,
    /^main$/i,
    /^main\[\s*role\s*=\s*["']?main["']?\s*\]$/i,
    /^\[\s*role\s*=\s*["']?main["']?\s*\]$/i,
    /^#react-root$/i,
];
function normalizePositiveMaxChars(value) {
    if (value === undefined || !Number.isFinite(value) || value <= 0) {
        return undefined;
    }
    return Math.floor(value);
}
function isBroadFullSelector(selector) {
    if (!selector)
        return true;
    const normalized = selector.trim();
    return BROAD_SELECTOR_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function resolveSnapshotLimit(opts, mode) {
    const requested = normalizePositiveMaxChars(opts.maxChars);
    if (mode === "efficient") {
        return {
            limit: EFFICIENT_MAX_CHARS,
            notice: requested !== undefined && requested > EFFICIENT_MAX_CHARS
                ? `[snapshot limit: requested maxChars=${requested} capped to ${EFFICIENT_MAX_CHARS} for efficient mode]`
                : undefined,
        };
    }
    if (mode === "interactive") {
        const base = requested ?? INTERACTIVE_DEFAULT_MAX_CHARS;
        const limit = Math.min(base, INTERACTIVE_HARD_MAX_CHARS);
        return {
            limit,
            notice: requested !== undefined && requested > limit
                ? `[snapshot limit: requested maxChars=${requested} capped to ${limit} for interactive mode]`
                : undefined,
        };
    }
    const broad = isBroadFullSelector(opts.selector);
    const hard = broad ? WIDE_FULL_HARD_MAX_CHARS : FULL_HARD_MAX_CHARS;
    const base = requested ?? FULL_DEFAULT_MAX_CHARS;
    const limit = Math.min(base, hard);
    return {
        limit,
        notice: requested !== undefined && requested > limit
            ? `[snapshot limit: requested maxChars=${requested} capped to ${limit} for ${broad ? "wide full snapshot" : "full mode"}]`
            : undefined,
    };
}
/**
 * Suffix appended when a snapshot is truncated. Includes an actionable hint —
 * without it the LLM tends to re-snapshot at the same wide scope and waste
 * another 30k chars on the next turn.
 */
const SNAPSHOT_TRUNCATED_HINT = '\n[...TRUNCATED - snapshot too large. Next time pass selector="<css>" to scope, mode="outline" for a page map, mode="efficient" for compact actionable refs, or mode="interactive" for clickables only.]';
const MINIMAL_TRUNCATED_HINT = "\n[...TRUNCATED - snapshot too large]";
const ERROR_VALUE_MAX_CHARS = 240;
export function formatCappedSnapshotText(body, opts) {
    const limit = Math.max(0, Math.floor(opts.limit));
    if (limit === 0)
        return "";
    const prefix = opts.prefix ? `${opts.prefix.trimEnd()}\n\n` : "";
    const notice = opts.notice ? `${opts.notice}\n` : "";
    const full = `${prefix}${notice}${body}`;
    if (full.length <= limit)
        return full;
    const requestedHint = opts.truncatedHint ?? SNAPSHOT_TRUNCATED_HINT;
    const hint = requestedHint.length <= limit
        ? requestedHint
        : MINIMAL_TRUNCATED_HINT.length <= limit
            ? MINIMAL_TRUNCATED_HINT
            : safeSliceHead(MINIMAL_TRUNCATED_HINT, limit);
    let keptPrefix = prefix;
    const reserved = notice.length + hint.length;
    if (reserved > limit) {
        return `${safeSliceHead(notice, Math.max(0, limit - hint.length))}${hint}`;
    }
    const prefixAndBodyBudget = Math.max(0, limit - reserved);
    if (keptPrefix.length > prefixAndBodyBudget) {
        keptPrefix = safeSliceHead(keptPrefix, prefixAndBodyBudget);
    }
    const bodyBudget = Math.max(0, limit - keptPrefix.length - notice.length - hint.length);
    const cappedBody = opts.preserveTail
        ? formatHeadTailSnapshotBody(body, bodyBudget, hint)
        : `${safeSliceHead(body, bodyBudget)}${hint}`;
    return `${keptPrefix}${notice}${cappedBody}`;
}
function formatHeadTailSnapshotBody(body, bodyBudget, hint) {
    if (bodyBudget <= 0)
        return hint;
    if (body.length <= bodyBudget)
        return `${body}${hint}`;
    const tailBudget = Math.floor(bodyBudget * 0.45);
    const tail = safeSliceTail(body, tailBudget);
    const headBudget = Math.max(0, bodyBudget - tail.length);
    return `${safeSliceHead(body, headBudget)}${hint}${tail}`;
}
function formatErrorValue(value) {
    if (value.length <= ERROR_VALUE_MAX_CHARS)
        return value;
    const head = safeSliceHead(value, ERROR_VALUE_MAX_CHARS);
    return `${head}...[TRUNCATED ${value.length} chars]`;
}
/**
 * Take a snapshot of the page accessibility tree.
 *
 * Accepts either a SnapshotMode string (backward compat) or SnapshotOptions object.
 */
export async function takeSnapshot(page, modeOrOptions, maxCharsOverride) {
    // Normalize arguments
    let opts;
    if (typeof modeOrOptions === "string") {
        opts = { mode: modeOrOptions, maxChars: maxCharsOverride };
    }
    else {
        opts = modeOrOptions ?? {};
    }
    const mode = opts.mode ?? "full";
    const filterMode = mode === "efficient" ? "interactive" : mode;
    const effectiveDepth = mode === "efficient" && opts.depth === undefined
        ? EFFICIENT_MAX_DEPTH
        : opts.depth;
    const effectiveCompact = mode === "efficient" ? true : opts.compact;
    // Rotate (not reset) — keep the previous snapshot's metadata for the
    // stale-ref recovery path. Navigation/tab-switch still call resetRefs
    // separately, which wipes both maps because the old metadata refers to
    // a page that's gone.
    rotateRefs();
    if (mode === "outline") {
        const text = await collectPageOutline(page, {
            selector: opts.selector,
            maxChars: opts.maxChars,
        });
        return { text, elementRefs: currentRefs };
    }
    const { limit, notice } = resolveSnapshotLimit(opts, mode);
    const formatResultText = (body) => formatCappedSnapshotText(body, { limit, notice });
    // Take snapshot — scoped to selector if provided. The Playwright API for
    // "aria" mode varies by version; we feature-detect rather than relying on
    // a specific signature.
    const fmt = opts.snapshotFormat ?? "ai";
    let snapshot;
    if (opts.selector) {
        const norm = normalizeSelector(opts.selector);
        if (!norm.ok) {
            const error = formatErrorValue(norm.error ?? "invalid selector");
            const original = JSON.stringify(formatErrorValue(norm.original));
            return {
                text: formatResultText(`Error: selector ${error}. Original: ${original}.`),
                elementRefs: currentRefs,
            };
        }
        try {
            // ariaSnapshot accepts {mode: "ai"} on current Playwright; older
            // versions return the verbose tree by default. Either way, this
            // path is used only when the caller scopes by selector.
            const loc = page.locator(norm.selector).first();
            if (fmt === "ai") {
                snapshot = await loc.ariaSnapshot({ mode: "ai" });
            }
            else {
                snapshot = await loc.ariaSnapshot();
            }
        }
        catch {
            return {
                text: formatResultText(`Error: selector ${JSON.stringify(formatErrorValue(norm.selector))} not found on page.`),
                elementRefs: currentRefs,
            };
        }
    }
    else {
        if (fmt === "ai") {
            snapshot = await page.ariaSnapshot({ mode: "ai" });
        }
        else {
            snapshot = await page.ariaSnapshot();
        }
    }
    // Apply depth filter
    if (effectiveDepth != null && effectiveDepth >= 0) {
        snapshot = truncateByDepth(snapshot, effectiveDepth);
    }
    // Apply interactive filter
    let filteredSnapshot;
    if (filterMode === "interactive") {
        filteredSnapshot = filterInteractiveLines(snapshot);
    }
    else {
        filteredSnapshot = snapshot;
    }
    // Apply compact filter
    if (effectiveCompact) {
        filteredSnapshot = compactSnapshot(filteredSnapshot);
    }
    // Apply char limit (pre-enrichment pass — enrichment annotations are
    // typically <100 bytes per ref so we re-cap after).
    if (filteredSnapshot.length > limit) {
        filteredSnapshot = formatCappedSnapshotText(filteredSnapshot, { limit });
    }
    // Parse refs from the (now possibly truncated) snapshot text — only refs
    // that survived filtering get into currentRefs.
    parseRefs(filteredSnapshot);
    // Enrich each visible ref with stable selector / editable / value hints.
    // Skipped on error so a single bad node doesn't kill the whole snapshot.
    if (opts.enrich !== false && currentRefs.size > 0) {
        try {
            await enrichRefs(page);
            filteredSnapshot = annotateSnapshotLines(filteredSnapshot, opts.refs ?? "aria");
            // Re-cap in case annotations grew the text past the limit.
            if (filteredSnapshot.length > limit) {
                filteredSnapshot = formatCappedSnapshotText(filteredSnapshot, {
                    limit,
                    truncatedHint: MINIMAL_TRUNCATED_HINT,
                });
            }
        }
        catch {
            // best-effort; raw snapshot still works without enrichment.
        }
    }
    if (notice) {
        filteredSnapshot = formatCappedSnapshotText(filteredSnapshot, {
            limit,
            notice,
        });
    }
    return { text: filteredSnapshot, elementRefs: currentRefs };
}
/**
 * For every ref in currentRefs, query the underlying DOM node via
 * `aria-ref=eN` and pull stable attributes + editability + value. Runs all
 * refs in parallel so the round-trip cost is roughly one CDP latency
 * regardless of ref count.
 *
 * Stable selector picking happens inside the page context — `document.
 * querySelectorAll` is cheap there, and uniqueness is the deciding factor
 * we cannot evaluate from outside.
 */
async function enrichRefs(page) {
    const refs = Array.from(currentRefs.keys());
    await Promise.all(refs.map(async (ref) => {
        const existing = currentRefs.get(ref);
        if (!existing)
            return;
        try {
            const data = await page
                .locator(`aria-ref=${ref}`)
                .first()
                .evaluate(extractRefMetadataInPage);
            if (!data)
                return;
            currentRefs.set(ref, { ...existing, ...data });
        }
        catch {
            // Node went away between snapshot and enrichment — leave the basic ref.
        }
    }));
}
/**
 * Runs in the page context. Pulls stable attributes off `el` and returns the
 * subset that's worth carrying back. Picks `stableSelector` based on
 * uniqueness in the live DOM — testid first, then id/name/aria-label/
 * placeholder if they resolve uniquely.
 *
 * Defined as a named function so it serializes correctly when passed to
 * Playwright's `evaluate`.
 */
function extractRefMetadataInPage(el) {
    if (!el || !(el instanceof Element))
        return null;
    const tag = el.tagName.toLowerCase();
    const testid = el.getAttribute("data-testid") ?? undefined;
    const id = el.id || undefined;
    const name = el.getAttribute("name") ?? undefined;
    const ariaLabel = el.getAttribute("aria-label") ?? undefined;
    const placeholder = el.getAttribute("placeholder") ?? undefined;
    const type = el.getAttribute("type") ?? undefined;
    const href = tag === "a" ? el.getAttribute("href") ?? undefined : undefined;
    const editable = tag === "input" ||
        tag === "textarea" ||
        el.isContentEditable === true ||
        el.getAttribute("role") === "textbox";
    let value;
    if (el instanceof HTMLInputElement ||
        el instanceof HTMLTextAreaElement) {
        value = el.value ?? undefined;
        if (value && value.length > 200) {
            // UTF-16 boundary-safe cut: don't split a surrogate pair (emoji/CJK
            // extension) at index 199/200 — the lone surrogate would later land
            // in a JSON payload to the LLM API and fail strict parsing.
            let end = 200;
            const prev = value.charCodeAt(end - 1);
            const curr = value.charCodeAt(end);
            if (prev >= 0xd800 &&
                prev <= 0xdbff &&
                curr >= 0xdc00 &&
                curr <= 0xdfff) {
                end = 199;
            }
            value = value.slice(0, end) + "…";
        }
    }
    // Arrow form (not `function attrSel()`) is intentional: tsx/esbuild
    // wraps named function declarations with __name() to preserve
    // Function.name, and __name isn't defined in the page context.
    // Serialized via extractRefMetadataInPage.toString() → Playwright
    // evaluate, so the inner-function form would throw ReferenceError
    // on every snapshot enrichment.
    const attrSel = (attr, val) => {
        const escaped = val.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
        return `[${attr}="${escaped}"]`;
    };
    const isUnique = (sel) => {
        try {
            return document.querySelectorAll(sel).length === 1;
        }
        catch {
            return false;
        }
    };
    let stableSelector;
    // testidSelectorCount surfaces non-uniqueness to the LLM via the
    // annotated snapshot output. On repeated feed/list items, many cards have
    // the same action-button testids; those were previously emitted as
    // stableSelector and would auto-click the first matching button regardless
    // of intent.
    let testidSelectorCount;
    if (testid) {
        const sel = attrSel("data-testid", testid);
        const count = document.querySelectorAll(sel).length;
        if (count === 1) {
            stableSelector = sel;
        }
        else {
            testidSelectorCount = count;
        }
    }
    if (!stableSelector) {
        if (id && /^[A-Za-z][\w-]*$/.test(id) && isUnique(`#${id}`)) {
            stableSelector = `#${id}`;
        }
        else if (name && isUnique(attrSel("name", name))) {
            stableSelector = attrSel("name", name);
        }
        else if (ariaLabel && isUnique(attrSel("aria-label", ariaLabel))) {
            stableSelector = attrSel("aria-label", ariaLabel);
        }
        else if (placeholder && isUnique(attrSel("placeholder", placeholder))) {
            stableSelector = attrSel("placeholder", placeholder);
        }
    }
    return {
        tag,
        stableSelector,
        editable,
        value,
        href,
        ...(testidSelectorCount !== undefined ? { testidSelectorCount } : {}),
        attrs: {
            ...(testid !== undefined ? { testid } : {}),
            ...(id !== undefined ? { id } : {}),
            ...(name !== undefined ? { name } : {}),
            ...(ariaLabel !== undefined ? { ariaLabel } : {}),
            ...(placeholder !== undefined ? { placeholder } : {}),
            ...(type !== undefined ? { type } : {}),
        },
    };
}
/**
 * Append compact metadata annotations to each `[ref=eN]` line in the
 * filtered snapshot text. Format chosen so it parses with the same regex
 * as before (refs are still recoverable) and so a quick read tells the LLM
 * which selector to prefer.
 */
function annotateSnapshotLines(snapshot, refStyle) {
    return snapshot
        .split("\n")
        .map((line) => {
        const m = line.match(/\[ref=(e\d+)\]/);
        if (!m)
            return line;
        const meta = currentRefs.get(m[1]);
        if (!meta)
            return line;
        const parts = [];
        if (meta.stableSelector)
            parts.push(`[selector=${meta.stableSelector}]`);
        else if (meta.testidSelectorCount !== undefined && meta.attrs?.testid) {
            // Non-unique testid: surface the ambiguity so the LLM doesn't
            // copy the testid into a click ref expecting it to land on this
            // specific element. Common on repeated social feeds.
            parts.push(`[testid="${meta.attrs.testid}" non-unique x${meta.testidSelectorCount}]`);
        }
        if (meta.editable)
            parts.push("[editable]");
        if (meta.value && meta.value.length > 0)
            parts.push(`[value=${JSON.stringify(meta.value)}]`);
        if (refStyle === "role") {
            // Encode role|name so the resolver can re-resolve via getByRole.
            parts.push(`[role-ref="${meta.role}|${meta.name.replace(/"/g, '\\"')}"]`);
        }
        if (parts.length === 0)
            return line;
        return line + " " + parts.join(" ");
    })
        .join("\n");
}
/**
 * Truncate snapshot lines deeper than maxDepth.
 * Playwright AI snapshot uses 2-space indentation per level.
 */
function truncateByDepth(snapshot, maxDepth) {
    const lines = snapshot.split("\n");
    const kept = [];
    for (const line of lines) {
        const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
        const level = Math.floor(indent / 2);
        if (level <= maxDepth) {
            kept.push(line);
        }
    }
    return kept.join("\n");
}
/**
 * Strip consecutive blank lines and trailing whitespace per line.
 */
function compactSnapshot(text) {
    return text
        .split("\n")
        .map((line) => line.trimEnd())
        .filter((line, i, arr) => {
        // Remove consecutive blank lines
        if (line === "" && i > 0 && arr[i - 1] === "")
            return false;
        return true;
    })
        .join("\n");
}
/**
 * Filter snapshot lines to only keep interactive elements.
 *
 * Keeps lines that:
 * - Contain a [ref=eN] tag (Playwright assigns refs to interactive elements)
 * - OR have an interactive ARIA role keyword
 *
 * Also preserves headings for structural context (they're cheap and help LLM orient).
 */
function filterInteractiveLines(snapshot) {
    const lines = snapshot.split("\n");
    const kept = [];
    for (const line of lines) {
        const trimmed = line.trimStart();
        // Always keep lines with element refs — they are interactive by definition
        if (/\[ref=e\d+\]/.test(trimmed)) {
            kept.push(line);
            continue;
        }
        // Keep headings for structural context (low cost, high orientation value)
        if (/^-\s*heading\s/.test(trimmed)) {
            kept.push(line);
            continue;
        }
        // Check if the line starts with "- role" where role is interactive
        const roleMatch = trimmed.match(/^-\s+(\w+)\s/);
        if (roleMatch && INTERACTIVE_ROLES.has(roleMatch[1].toLowerCase())) {
            kept.push(line);
            continue;
        }
    }
    return kept.join("\n");
}
/**
 * Parse element refs from snapshot text into the currentRefs map.
 *
 * Three passes, richest first. Each pass uses `currentRefs.has` as a
 * "don't overwrite" guard so a later, looser pattern can never replace
 * a name we already captured.
 *
 * Pass C (role-only) exists because Playwright's ai-mode emits refs on
 * structural nodes that have no accessible name — `- generic [ref=e1]:`,
 * `- link [ref=e5]:` when a site wraps several visual elements in a single
 * link with empty aria-name. Without this pass those refs appear in
 * the snapshot text the LLM reads but never enter currentRefs, so any
 * action against them returns "ref not found or stale" *before*
 * stale-ref recovery has any metadata to work with. Exported so unit
 * tests can drive it directly without spinning up a browser.
 */
export function parseRefs(snapshot) {
    let match;
    // Pass A — bulleted role with quoted name and trailing ref. Allow any
    // number of intermediate [attr] blocks (e.g. [level=1], [disabled],
    // [checked]) between the name and the [ref=...] marker so attributed
    // nodes don't fall through to the no-name pass and lose their name.
    const namedBullet = /-\s+(\w+)\s+"([^"]*)"(?:\s+\[(?!ref=)[^\]]+\])*\s+\[ref=(e\d+)\]/g;
    while ((match = namedBullet.exec(snapshot)) !== null) {
        if (!currentRefs.has(match[3])) {
            currentRefs.set(match[3], {
                ref: match[3],
                role: match[1],
                name: match[2],
            });
        }
    }
    // Pass B — legacy "[ref=eN] role \"name\"" form (ref before role). Kept
    // for any callers that emit refs in this order.
    const prefixedRef = /\[ref=(e\d+)\]\s*(\w+)\s*"([^"]*)"/g;
    while ((match = prefixedRef.exec(snapshot)) !== null) {
        if (!currentRefs.has(match[1])) {
            currentRefs.set(match[1], {
                ref: match[1],
                role: match[2],
                name: match[3],
            });
        }
    }
    // Pass C — bulleted role WITHOUT a quoted name. Same intermediate-attr
    // tolerance as Pass A. Missing this case is what caused four straight
    // hover failures in the BTC-search task: the LLM picked refs on
    // `- link [ref=eN]:` username wrappers and got back "ref not found".
    const roleOnlyBullet = /-\s+(\w+)(?:\s+\[(?!ref=)[^\]]+\])*\s+\[ref=(e\d+)\]/g;
    while ((match = roleOnlyBullet.exec(snapshot)) !== null) {
        if (!currentRefs.has(match[2])) {
            currentRefs.set(match[2], {
                ref: match[2],
                role: match[1],
                name: "",
            });
        }
    }
}
//# sourceMappingURL=snapshot.js.map