import { normalizeSelector } from "./selectors.js";
import { takeSnapshot, getElementRef, getPreviousElementRef, } from "./snapshot.js";
const DESTRUCTIVE_NAME_RE = /\b(post|tweet|send|submit|confirm|delete|remove|buy|sell|transfer|pay|publish|reply|repost|retweet|cancel\s+order|approve)\b/i;
/**
 * "Destructive" = a click that submits/posts/sends/deletes, where landing
 * on the wrong button would do real damage. Recovery for these is restricted
 * to high-confidence paths (stableSelector + role+exact-name); fuzzy name
 * matches are refused so we never auto-click a "Post" lookalike.
 */
export function looksDestructive(meta) {
    if (!meta)
        return false;
    return DESTRUCTIVE_NAME_RE.test(meta.name);
}
/**
 * Top-level resolver. Handles all four input shapes and unifies error
 * formatting. Most action methods call this with only one of {ref, selector}
 * set; role/name/text are exposed for callers that want Playwright's native
 * accessibility-tree matching without a snapshot first.
 */
export async function resolveLocator(page, input, opts = {}) {
    if (input.ref != null) {
        return locateRefWithRecovery(page, input.ref, opts);
    }
    if (input.selector != null) {
        const r = resolveSelector(page, input.selector, opts.selectorParam ?? "selector");
        if (!r.ok)
            return { ok: false, message: r.error };
        return {
            ok: true,
            locator: r.locator,
            label: r.selector,
            selectorUsed: r.selector,
        };
    }
    if (input.role) {
        if (input.name != null) {
            const loc = page.getByRole(input.role, {
                name: input.name,
                exact: true,
            });
            return {
                ok: true,
                locator: loc.first(),
                label: `${input.role}: "${input.name}"`,
            };
        }
        const loc = page.getByRole(input.role);
        return {
            ok: true,
            locator: loc.first(),
            label: `${input.role}`,
        };
    }
    if (input.text != null) {
        return {
            ok: true,
            locator: page.getByText(input.text).first(),
            label: `text: "${input.text}"`,
        };
    }
    return {
        ok: false,
        message: "resolveLocator requires one of {ref, selector, role, text}.",
    };
}
/* -------------------------------------------------------------------------- */
/* Selector path                                                              */
/* -------------------------------------------------------------------------- */
export function resolveSelector(page, raw, paramName = "selector") {
    const norm = normalizeSelector(raw);
    if (!norm.ok) {
        return {
            ok: false,
            error: `Error: ${paramName} ${norm.error}. ` +
                `Original: ${JSON.stringify(norm.original)}. ` +
                `Tip: pass plain CSS like [data-testid="..."], no extra escaping.`,
        };
    }
    return {
        ok: true,
        locator: page.locator(norm.selector).first(),
        selector: norm.selector,
        ...(norm.note ? { note: norm.note } : {}),
    };
}
/* -------------------------------------------------------------------------- */
/* Stale-ref auto-recovery                                                    */
/* -------------------------------------------------------------------------- */
export function refNotFoundMessage(ref, label = "element", selectorParam = "selector") {
    const base = `Error: ${label} reference '${ref}' not found or stale. ` +
        `Refs are invalidated by the next snapshot, navigation, tab switch, ` +
        `dialog open/close, and React re-renders on focus ` +
        `(common for Lexical/ProseMirror-style rich editors). ` +
        `Take a fresh snapshot to get new refs`;
    if (selectorParam === null) {
        return `${base}.`;
    }
    return (`${base}, or pass ${selectorParam}='<css>' ` +
        `(e.g. [data-testid="..."]) for stable elements.`);
}
/**
 * Resolve a ref to a Locator, with one bounded auto-recovery pass when the
 * underlying DOM node is stale. Recovery order:
 *   1. stableSelector (data-testid / unique id / unique [name|aria-label|placeholder])
 *   2. role + exact accessible name (re-snapshotted)
 *   3. role + fuzzy accessible name (skipped for destructive actions)
 *
 * Recovery only fires when there's exactly one high-confidence candidate.
 * Ambiguous matches return the candidate list so the LLM can disambiguate.
 */
export async function locateRefWithRecovery(page, ref, opts = {}) {
    const labelHint = opts.label ?? "element";
    const selectorParam = opts.selectorParam ?? "selector";
    const allowRecovery = opts.allowRecovery !== false;
    // Role-ref format: "role|name" (emitted by snapshots with refs:"role").
    // Resolves via Playwright's accessibility tree on each call rather than
    // through the eN cache — safer across rerenders, but only act-safe when
    // the role+name pair is unique on the page.
    const roleSep = ref.indexOf("|");
    if (roleSep > 0 && !ref.startsWith("e")) {
        const role = ref.slice(0, roleSep);
        const name = ref.slice(roleSep + 1);
        const loc = page.getByRole(role, {
            name,
            exact: true,
        });
        try {
            const cnt = await loc.count();
            if (cnt === 1) {
                return {
                    ok: true,
                    locator: loc.first(),
                    label: `${role}: "${name}"`,
                };
            }
            if (cnt === 0) {
                return {
                    ok: false,
                    message: `Error: role-ref ${JSON.stringify(ref)} did not match any element. ` +
                        `Take a fresh snapshot — the role+name pair may have changed.`,
                };
            }
            return {
                ok: false,
                message: `Error: role-ref ${JSON.stringify(ref)} matched ${cnt} elements. ` +
                    `Use a more specific ref (snapshot with refs="aria") or a CSS selector.`,
            };
        }
        catch (err) {
            return {
                ok: false,
                message: `Error resolving role-ref ${JSON.stringify(ref)}: ${err instanceof Error ? err.message : String(err)}`,
            };
        }
    }
    // Three lookup tiers, all fed into the same recovery machinery:
    //   1. currentRefs     → live: try aria-ref first, fall back to recovery
    //   2. previousRefs    → ref invalidated by a newer snapshot but we still
    //                        know its metadata; skip the live lookup (it would
    //                        point at the wrong DOM) and go straight to recovery
    //   3. neither         → return ref-not-found
    const liveMeta = getElementRef(ref);
    const histMeta = liveMeta ? undefined : getPreviousElementRef(ref);
    const meta = liveMeta ?? histMeta;
    if (!meta) {
        return {
            ok: false,
            message: refNotFoundMessage(ref, labelHint, selectorParam),
        };
    }
    // Live path: only valid for refs in the CURRENT snapshot map. Refs from
    // the previous snapshot are stale by definition — their aria-ref entries
    // belong to an old AX tree and would resolve to the wrong node.
    if (liveMeta) {
        const live = page.locator(`aria-ref=${ref}`);
        try {
            if ((await live.count()) > 0) {
                return {
                    ok: true,
                    locator: live,
                    label: `${meta.role}: "${meta.name}"`,
                    refUsed: ref,
                    selectorUsed: meta.stableSelector,
                };
            }
        }
        catch {
            // fall through to recovery
        }
    }
    if (!allowRecovery) {
        return {
            ok: false,
            message: refNotFoundMessage(ref, labelHint, selectorParam),
        };
    }
    // Recovery 1: stable selector still resolves uniquely.
    if (meta.stableSelector) {
        try {
            const cnt = await page.locator(meta.stableSelector).count();
            if (cnt === 1) {
                const loc = page.locator(meta.stableSelector).first();
                return {
                    ok: true,
                    locator: loc,
                    label: `${meta.role}: "${meta.name}" (recovered via stableSelector)`,
                    refUsed: ref,
                    selectorUsed: meta.stableSelector,
                    recoveredVia: "stableSelector",
                };
            }
        }
        catch {
            // selector parse error or other — fall through.
        }
    }
    // Re-snapshot to get fresh refs, then look for a same-role+name match.
    let fresh;
    try {
        const snap = await takeSnapshot(page, { mode: "interactive" });
        fresh = snap.elementRefs;
    }
    catch {
        return {
            ok: false,
            message: refNotFoundMessage(ref, labelHint, selectorParam) +
                ` (recovery snapshot failed)`,
        };
    }
    // Recovery 2: role + exact name.
    const exact = [];
    for (const [r, m] of fresh) {
        if (m.role === meta.role && m.name === meta.name) {
            exact.push({ ref: r, meta: m });
        }
    }
    if (exact.length === 1) {
        const newRef = exact[0].ref;
        const newMeta = exact[0].meta;
        return {
            ok: true,
            locator: page.locator(`aria-ref=${newRef}`),
            label: `${newMeta.role}: "${newMeta.name}" (recovered via role+exact-name, ref ${ref}→${newRef})`,
            refUsed: newRef,
            selectorUsed: newMeta.stableSelector,
            recoveredVia: "roleExactName",
        };
    }
    // Recovery 3: role + fuzzy name (skipped for destructive actions).
    if (!opts.destructive) {
        const wanted = meta.name.toLowerCase().trim();
        if (wanted) {
            const fuzzy = [];
            for (const [r, m] of fresh) {
                if (m.role !== meta.role)
                    continue;
                const cand = m.name.toLowerCase().trim();
                if (cand === wanted)
                    continue;
                if (cand.includes(wanted) || wanted.includes(cand)) {
                    fuzzy.push({ ref: r, meta: m });
                }
            }
            if (fuzzy.length === 1) {
                const newRef = fuzzy[0].ref;
                const newMeta = fuzzy[0].meta;
                return {
                    ok: true,
                    locator: page.locator(`aria-ref=${newRef}`),
                    label: `${newMeta.role}: "${newMeta.name}" (recovered via role+fuzzy-name, ref ${ref}→${newRef})`,
                    refUsed: newRef,
                    selectorUsed: newMeta.stableSelector,
                    recoveredVia: "roleFuzzyName",
                };
            }
        }
    }
    // No high-confidence recovery — return candidates so the LLM can choose.
    const allCandidates = [];
    for (const [r, m] of fresh) {
        if (m.role === meta.role)
            allCandidates.push({ ref: r, meta: m });
    }
    const top = allCandidates.slice(0, 8);
    const candidatesText = top.length
        ? top
            .map((c) => `  - ${c.meta.role} "${c.meta.name}" [ref=${c.ref}]${c.meta.stableSelector ? ` [selector=${c.meta.stableSelector}]` : ""}`)
            .join("\n")
        : "  (no same-role candidates in fresh snapshot)";
    const destructiveNote = opts.destructive
        ? `\nNote: this action is destructive — fuzzy-name auto-recovery was skipped to avoid clicking the wrong submit/post/delete button.`
        : "";
    return {
        ok: false,
        message: refNotFoundMessage(ref, labelHint, selectorParam) +
            `\nFresh snapshot has these ${meta.role}s — pick one and retry:\n` +
            candidatesText +
            destructiveNote,
    };
}
//# sourceMappingURL=locator-resolver.js.map