/**
 * BrowserSession — a live, persistent browser bound to one profile.
 *
 * v1 scope: single tab, open url, screenshot, get visible text, close.
 * Built on playwright-core; no bundled browser needed if system Chromium exists.
 */
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BrowserActions } from "./browser-actions.js";
import { observePage } from "./browser-observer.js";
import { BrowserProfileStore } from "./browser-profile.js";
import { BrowserSafety } from "./browser-safety.js";
import { resolveChromiumPath } from "./chromium-resolver.js";
export class BrowserActionDenied extends Error {
    constructor(reason, byUser) {
        super(byUser ? `denied by user: ${reason}` : `blocked by safety: ${reason}`);
        this.reason = reason;
        this.byUser = byUser;
        this.name = "BrowserActionDenied";
    }
}
export class BrowserSession extends EventEmitter {
    constructor() {
        super(...arguments);
        this.profiles = new BrowserProfileStore();
        this.context = null;
        this.page = null;
        this.profile = null;
        this.headless = false;
        this._actions = null;
        this._safety = new BrowserSafety();
        this._confirmAsker = null;
        this._credentials = null;
        this._lastSnapshot = null;
    }
    get isOpen() {
        return this.context !== null;
    }
    get actions() {
        if (!this._actions)
            throw new Error("Browser not launched.");
        return this._actions;
    }
    get safety() {
        return this._safety;
    }
    /** Attach a shared credential bridge so password fields can auto-fill from Keychain. */
    setCredentialBridge(b) {
        this._credentials = b;
    }
    get credentials() {
        return this._credentials;
    }
    /** Hostname of the currently active tab (empty string if no page). */
    currentHost() {
        const url = this.page?.url() ?? "";
        try {
            return new URL(url).hostname.toLowerCase();
        }
        catch {
            return "";
        }
    }
    /** Caller (interactive-mode) provides a function that shows a TUI prompt and returns the user's answer. */
    setConfirmAsker(asker) {
        this._confirmAsker = asker;
    }
    async launch(opts = {}) {
        if (this.context)
            return this.status();
        const profileName = opts.profile ?? "default";
        const profile = this.profiles.ensure(profileName);
        this.profile = profile;
        this.headless = opts.headless ?? false;
        const chromium = (await import("playwright-core")).chromium;
        const executablePath = resolveChromiumPath();
        this.context = await chromium.launchPersistentContext(profile.path, {
            headless: this.headless,
            executablePath,
            viewport: opts.viewport ?? { width: 1280, height: 800 },
            acceptDownloads: true,
            downloadsPath: this.profiles.downloadsFor(profileName),
            args: ["--disable-blink-features=AutomationControlled"],
        });
        // Use the first page if one was restored, else create one.
        const existing = this.context.pages();
        this.page = existing.length > 0 ? existing[0] : await this.context.newPage();
        this.context.on("close", () => {
            this.context = null;
            this.page = null;
            this._actions = null;
            this.emit("closed");
        });
        // Track newly opened tabs so future observe/actions can reference them.
        this.context.on("page", (p) => {
            this.emit("tab-opened", p);
        });
        this._actions = new BrowserActions(() => {
            if (!this.page)
                throw new Error("No active page.");
            return this.page;
        }, () => {
            if (!this.context)
                throw new Error("No context.");
            return this.context;
        });
        return this.status();
    }
    /** Make a specific tab (by context.pages() index) the active page. */
    async setActiveTab(id) {
        if (!this.context)
            throw new Error("Browser not launched.");
        const pages = this.context.pages();
        const target = pages[id];
        if (!target)
            throw new Error(`No tab with id ${id}`);
        this.page = target;
        await target.bringToFront();
    }
    /** Capture a compact DOM-lite snapshot for agent / LLM consumption. */
    async observe() {
        this.ensurePage();
        const snap = await observePage(this.page);
        this._lastSnapshot = snap;
        return snap;
    }
    get lastSnapshot() {
        return this._lastSnapshot;
    }
    /** Look up an element from the most recent observe() by index. */
    resolveTargetElement(target) {
        if ("index" in target && this._lastSnapshot) {
            return this._lastSnapshot.elements[target.index] ?? null;
        }
        return null;
    }
    async gate(action) {
        const ctx = { url: this.page?.url() ?? "", mode: this._safety.mode };
        const verdict = this._safety.evaluate(action, ctx);
        if (verdict.decision === "allow")
            return;
        if (verdict.decision === "deny") {
            throw new BrowserActionDenied(verdict.reason, false);
        }
        // confirm
        if (!this._confirmAsker) {
            // No UI hooked up -> default to deny (safe).
            throw new BrowserActionDenied(`${verdict.reason} (no confirmation UI)`, false);
        }
        const actionDescription = this._safety.describeAction(action);
        const allowed = await this._confirmAsker({
            actionDescription,
            reason: verdict.reason,
            url: ctx.url,
        });
        if (!allowed)
            throw new BrowserActionDenied(verdict.reason, true);
    }
    // ─── Action proxies (gated by safety) ──────────────────────────────
    async click(target, opts) {
        this.ensurePage();
        await this.gate({
            type: opts?.dblclick ? "dblclick" : "click",
            target: this.resolveTargetElement(target),
        });
        return this.actions.click(target, opts);
    }
    async type(target, text, opts) {
        this.ensurePage();
        const el = this.resolveTargetElement(target);
        const host = this.currentHost();
        // 1. Explicit credential token: {{cred:<host>}} or {{cred}} (auto-detect host).
        //    The LLM is supposed to use this for passwords; it never sees the real value.
        const credTokenMatch = text.match(/^\{\{cred(?::([^}]+))?\}\}$/);
        if (credTokenMatch) {
            if (!this._credentials)
                throw new Error("credential bridge not attached");
            const requestedHost = credTokenMatch[1]?.trim() || host;
            let handle = this._credentials.findForHost(requestedHost);
            if (!handle) {
                const res = await this._credentials.fetch({ host: requestedHost });
                if (!res.ok) {
                    throw new Error(`credential fetch failed (${res.reason}): ${res.message}`);
                }
                handle = res.handle;
            }
            const real = this._credentials.resolveFor(handle.id, host);
            if (real == null)
                throw new Error(`credential host mismatch: bound to ${handle.host}, page is ${host}`);
            await this.gate({ type: "type", target: el, text: "[REDACTED]", submit: opts?.submit });
            await this.actions.type(target, real, opts);
            return `typed credential for ${handle.host} into ${"index" in target ? `index ${target.index}` : target.selector}${opts?.submit ? " +submit" : ""}`;
        }
        // 2. Opportunistic auto-fill: if this looks like a password field and we have a
        //    bridge, try to fetch silently. If anything fails, fall back to literal text.
        if (this._credentials &&
            el?.type === "password" &&
            // Only auto-fill if the planner left the text empty or a sentinel like "PASSWORD".
            (text === "" || /^\s*password\s*$/i.test(text))) {
            let handle = this._credentials.findForHost(host);
            if (!handle) {
                const res = await this._credentials.fetch({ host });
                if (res.ok)
                    handle = res.handle;
            }
            if (handle) {
                const real = this._credentials.resolveFor(handle.id, host);
                if (real) {
                    await this.gate({ type: "type", target: el, text: "[REDACTED]", submit: opts?.submit });
                    await this.actions.type(target, real, opts);
                    return `auto-filled credential for ${host} into password field${opts?.submit ? " +submit" : ""}`;
                }
            }
            // Fall through -> caller (planner) will see an empty type result and ask_user.
        }
        await this.gate({
            type: "type",
            target: el,
            text,
            submit: opts?.submit,
        });
        return this.actions.type(target, text, opts);
    }
    async select(target, optionText) {
        this.ensurePage();
        await this.gate({
            type: "select",
            target: this.resolveTargetElement(target),
            option: optionText,
        });
        return this.actions.select(target, optionText);
    }
    async press(keys) {
        this.ensurePage();
        await this.gate({ type: "keys", keys });
        return this.actions.press(keys);
    }
    async scroll(opts) {
        this.ensurePage();
        await this.gate({ type: "scroll" });
        return this.actions.scroll(opts);
    }
    async hover(target) {
        this.ensurePage();
        await this.gate({
            type: "hover",
            target: this.resolveTargetElement(target),
        });
        return this.actions.hover(target);
    }
    async listTabs() {
        this.ensurePage();
        return this.actions.listTabs();
    }
    async openTabAndActivate(url) {
        this.ensurePage();
        const p = await this.actions.openTab(url);
        this.page = p;
        return p;
    }
    async closeTab(id) {
        this.ensurePage();
        await this.actions.closeTab(id);
        // If we closed the active tab, fall back to the first remaining page.
        const remaining = this.context.pages();
        if (!remaining.includes(this.page)) {
            this.page = remaining[0] ?? null;
        }
    }
    async switchTab(id) {
        this.ensurePage();
        const p = await this.actions.switchTab(id);
        this.page = p;
    }
    async open(url) {
        this.ensurePage();
        await this.gate({ type: "navigate", url });
        // Normalize: preserve explicit schemes (about:, data:, https:, http:, file:,
        // etc.); only prepend https:// if the user passed a bare host like "example.com".
        const normalized = /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`;
        await this.page.goto(normalized, { waitUntil: "domcontentloaded", timeout: 30_000 });
        return this.status();
    }
    async back() {
        this.ensurePage();
        await this.page.goBack({ waitUntil: "domcontentloaded" }).catch(() => { });
        return this.status();
    }
    async forward() {
        this.ensurePage();
        await this.page.goForward({ waitUntil: "domcontentloaded" }).catch(() => { });
        return this.status();
    }
    async refresh() {
        this.ensurePage();
        await this.page.reload({ waitUntil: "domcontentloaded" });
        return this.status();
    }
    /** Capture screenshot of the current tab. Returns base64 png + tmp file path. */
    async screenshot(fullPage = false) {
        this.ensurePage();
        const fileName = `avis-browser-${Date.now()}.png`;
        const filePath = join(tmpdir(), fileName);
        await this.page.screenshot({ path: filePath, fullPage });
        const buffer = readFileSync(filePath);
        return {
            path: filePath,
            data: buffer.toString("base64"),
            mimeType: "image/png",
        };
    }
    /** Return visible text of the page (simple evaluator, v1). */
    async getVisibleText() {
        this.ensurePage();
        // The arrow runs inside the page; use an inline evaluator string to avoid
        // TS dragging DOM globals into the Node build.
        const fn = `(() => {
			const text = (document.body && document.body.innerText ? document.body.innerText : "").trim();
			return text.length > 20000 ? text.slice(0, 20000) + "\\n…[truncated]" : text;
		})()`;
        return this.page.evaluate(fn);
    }
    status() {
        const profile = this.profile?.name ?? "default";
        if (!this.page) {
            return { profile, headless: this.headless, url: "", title: "", tabCount: 0 };
        }
        return {
            profile,
            headless: this.headless,
            url: this.page.url(),
            title: "" /* async - fetched separately if needed */,
            tabCount: this.context?.pages().length ?? 1,
        };
    }
    async statusWithTitle() {
        const base = this.status();
        if (!this.page)
            return base;
        try {
            base.title = await this.page.title();
        }
        catch {
            // page may be mid-navigation
        }
        return base;
    }
    async close() {
        if (!this.context)
            return;
        try {
            await this.context.close();
        }
        catch {
            // best-effort
        }
        this.context = null;
        this.page = null;
    }
    ensurePage() {
        if (!this.context || !this.page) {
            throw new Error("Browser not launched. Run '/browser open <url>' first.");
        }
    }
}
//# sourceMappingURL=browser-session.js.map