import { normalizeSelector } from "./selectors.js";
import { getElementRef } from "./snapshot.js";
const DEFAULT_TIMEOUT_MS = 8_000;
/**
 * Set of supported `expect` field names. Used to warn about typos so a
 * misspelled key (`selectorVisble`, `urlContain`, etc.) doesn't silently
 * pass as "(no expectations)" — that would be worse than not verifying
 * at all because the LLM moves on assuming success.
 */
const KNOWN_EXPECT_FIELDS = new Set([
    "text",
    "textGone",
    "selector",
    "selectorVisible",
    "selectorGone",
    "selectorHidden",
    "urlContains",
    "urlMatches",
    "inputValue",
    "timeoutMs",
]);
/**
 * Run all expectation fields against the page. Returns a structured result
 * the caller can stitch into a tool result string. Never throws — every
 * failure mode is captured as a `details` entry.
 */
export async function runExpectation(page, expect) {
    // Detect unknown / misspelled fields and fail loudly. Returning ok:true
    // here would let typos masquerade as "verified" which is exactly the
    // failure mode we're trying to avoid.
    const unknown = Object.keys(expect).filter((k) => !KNOWN_EXPECT_FIELDS.has(k));
    if (unknown.length > 0) {
        return {
            ok: false,
            summary: `unknown expect field(s): ${unknown.join(", ")}. Supported: ${[...KNOWN_EXPECT_FIELDS].join(", ")}`,
            details: unknown.map((k) => ({
                field: k,
                ok: false,
                message: "unknown field",
            })),
        };
    }
    const fields = collectFields(expect);
    if (fields.length === 0) {
        return {
            ok: true,
            summary: "(no expectations)",
            details: [],
        };
    }
    const timeoutMs = expect.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    const deadline = Date.now() + timeoutMs;
    const results = [];
    // Each field gets the *remaining* budget — order is the order the LLM
    // wrote them, which lets it cheaply express "wait for URL change THEN
    // verify text". Earlier fields keep most of the budget; later fields
    // get whatever's left.
    for (const field of fields) {
        const remaining = Math.max(deadline - Date.now(), 250);
        const result = await runOne(page, field, remaining);
        results.push(result);
        if (!result.ok) {
            // Short-circuit: an expectation block is pass-all, fail-any. Reporting
            // every subsequent field's timeout would just be noise.
            break;
        }
    }
    const allOk = results.every((r) => r.ok);
    const summary = allOk
        ? `All ${results.length} expectation(s) passed`
        : results
            .filter((r) => !r.ok)
            .map((r) => `${r.field}: ${r.message ?? "failed"}`)
            .join("; ");
    return { ok: allOk, summary, details: results };
}
function collectFields(expect) {
    const out = [];
    if (expect.text != null) {
        const needle = expect.text;
        out.push({
            name: "text",
            run: async (page, timeoutMs) => {
                try {
                    await page
                        .getByText(needle)
                        .first()
                        .waitFor({ state: "visible", timeout: timeoutMs });
                    return { ok: true };
                }
                catch {
                    return { ok: false, message: `text not found: ${JSON.stringify(needle)}` };
                }
            },
        });
    }
    if (expect.textGone != null) {
        const needle = expect.textGone;
        out.push({
            name: "textGone",
            run: async (page, timeoutMs) => {
                try {
                    await page
                        .getByText(needle)
                        .first()
                        .waitFor({ state: "hidden", timeout: timeoutMs });
                    return { ok: true };
                }
                catch {
                    return { ok: false, message: `text still present: ${JSON.stringify(needle)}` };
                }
            },
        });
    }
    // selector / selectorVisible — both wait for the element to be visible.
    // We accept either field name so prompts and code stay forgiving; if the
    // caller sets both, the one written first in the if-chain wins (selector
    // takes precedence because it's the older name).
    const visibleRaw = expect.selector ?? expect.selectorVisible;
    if (visibleRaw != null) {
        const fieldName = expect.selector != null ? "selector" : "selectorVisible";
        out.push({
            name: fieldName,
            run: async (page, timeoutMs) => {
                const norm = normalizeSelector(visibleRaw);
                if (!norm.ok)
                    return { ok: false, message: norm.error };
                try {
                    await page
                        .locator(norm.selector)
                        .first()
                        .waitFor({ state: "visible", timeout: timeoutMs });
                    return { ok: true };
                }
                catch {
                    return { ok: false, message: `selector not visible: ${norm.selector}` };
                }
            },
        });
    }
    // selectorGone / selectorHidden — alias pair, same Playwright "hidden" wait.
    const goneRaw = expect.selectorGone ?? expect.selectorHidden;
    if (goneRaw != null) {
        const fieldName = expect.selectorGone != null ? "selectorGone" : "selectorHidden";
        out.push({
            name: fieldName,
            run: async (page, timeoutMs) => {
                const norm = normalizeSelector(goneRaw);
                if (!norm.ok)
                    return { ok: false, message: norm.error };
                const result = await waitForNoVisibleMatches(page.locator(norm.selector), timeoutMs);
                return result.ok
                    ? { ok: true }
                    : {
                        ok: false,
                        message: `selector still visible: ${norm.selector} (${result.visibleCount}/${result.totalCount} visible)`,
                    };
            },
        });
    }
    if (expect.urlContains != null) {
        const sub = expect.urlContains;
        out.push({
            name: "urlContains",
            run: async (page, timeoutMs) => {
                const ok = await pollUntil(timeoutMs, async () => page.url().includes(sub));
                return ok
                    ? { ok: true }
                    : {
                        ok: false,
                        message: `url=${page.url()} did not contain ${JSON.stringify(sub)}`,
                    };
            },
        });
    }
    if (expect.urlMatches != null) {
        const pat = expect.urlMatches;
        let re;
        try {
            re = new RegExp(pat);
        }
        catch (err) {
            out.push({
                name: "urlMatches",
                run: async () => ({
                    ok: false,
                    message: `invalid regex: ${err instanceof Error ? err.message : String(err)}`,
                }),
            });
            return out;
        }
        out.push({
            name: "urlMatches",
            run: async (page, timeoutMs) => {
                const ok = await pollUntil(timeoutMs, async () => re.test(page.url()));
                return ok
                    ? { ok: true }
                    : { ok: false, message: `url=${page.url()} did not match ${pat}` };
            },
        });
    }
    if (expect.inputValue != null) {
        const iv = expect.inputValue;
        out.push({
            name: "inputValue",
            run: async (page, timeoutMs) => verifyInputValue(page, iv, timeoutMs),
        });
    }
    return out;
}
async function runOne(page, field, timeoutMs) {
    try {
        const r = await field.run(page, timeoutMs);
        return { field: field.name, ...r };
    }
    catch (err) {
        return {
            field: field.name,
            ok: false,
            message: err instanceof Error ? err.message : String(err),
        };
    }
}
/**
 * Poll a synchronous predicate (wrapped async for symmetry with the rest
 * of the API) until it returns true or the budget runs out. 100ms cadence
 * — fast enough to feel snappy, slow enough not to hammer the page.
 */
async function pollUntil(timeoutMs, pred) {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
        if (await pred())
            return true;
        await new Promise((r) => setTimeout(r, 100));
    }
    return pred();
}
export async function waitForNoVisibleMatches(locator, timeoutMs) {
    let last = { visibleCount: 0, totalCount: 0 };
    const ok = await pollUntil(timeoutMs, async () => {
        last = await countVisibleMatches(locator);
        return last.visibleCount === 0;
    });
    if (!ok) {
        last = await countVisibleMatches(locator).catch(() => last);
    }
    return { ok, ...last };
}
async function countVisibleMatches(locator) {
    const totalCount = await locator.count();
    let visibleCount = 0;
    for (let i = 0; i < totalCount; i++) {
        try {
            if (await locator.nth(i).isVisible())
                visibleCount++;
        }
        catch {
            // Detached while polling means this match is no longer visibly blocking.
        }
    }
    return { visibleCount, totalCount };
}
async function verifyInputValue(page, iv, timeoutMs) {
    if (iv.contains == null && iv.equals == null) {
        return { ok: false, message: "inputValue requires `contains` or `equals`" };
    }
    let locator;
    let label;
    if (iv.ref) {
        if (!getElementRef(iv.ref)) {
            return { ok: false, message: `inputValue ref '${iv.ref}' not in current snapshot` };
        }
        locator = page.locator(`aria-ref=${iv.ref}`);
        label = iv.ref;
    }
    else if (iv.selector) {
        const norm = normalizeSelector(iv.selector);
        if (!norm.ok)
            return { ok: false, message: norm.error };
        locator = page.locator(norm.selector).first();
        label = norm.selector;
    }
    else {
        return { ok: false, message: "inputValue requires `ref` or `selector`" };
    }
    const deadline = Date.now() + timeoutMs;
    let lastValue = "";
    while (Date.now() < deadline) {
        try {
            const v = await readEditableValue(locator);
            lastValue = v;
            if (iv.equals != null) {
                // Rich text editors often expose an "empty" contenteditable as "\n"
                // through innerText after submit.
                // Treat whitespace-only readback as empty when the expected value is
                // explicitly "", otherwise a successful post can look like a failed
                // click verification.
                if (v === iv.equals || (iv.equals === "" && v.trim() === "")) {
                    return { ok: true };
                }
            }
            if (iv.contains != null && v.includes(iv.contains))
                return { ok: true };
        }
        catch {
            // node may not be ready yet — keep polling.
        }
        await new Promise((r) => setTimeout(r, 150));
    }
    const expected = iv.equals != null
        ? `equals ${JSON.stringify(iv.equals)}`
        : `contains ${JSON.stringify(iv.contains)}`;
    return {
        ok: false,
        message: `${label} value ${JSON.stringify(lastValue)} did not satisfy ${expected}`,
    };
}
/**
 * Read text from the resolved node regardless of its DOM type:
 * - <input>/<textarea>: .value
 * - [contenteditable]/aria-textbox: .innerText
 * - anything else: .textContent (rare, but better than throwing)
 */
async function readEditableValue(locator) {
    return locator.first().evaluate((el) => {
        if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
            return el.value;
        }
        const he = el;
        if (he.isContentEditable || el.getAttribute("role") === "textbox") {
            return he.innerText ?? "";
        }
        return he.innerText ?? he.textContent ?? "";
    });
}
//# sourceMappingURL=expect.js.map