import { chromium, } from "playwright-core";
import path from "node:path";
import fs from "node:fs";
import { resetRefs } from "./snapshot.js";
import { acquireWorkspaceLock, } from "./workspace-lock.js";
import { spawnChrome } from "./chrome-process.js";
import { startExtensionRelay, } from "./extension-relay.js";
import { launchCloakBrowserContext } from "./cloakbrowser-backend.js";
const SESSION_META_FILENAME = ".session-meta.json";
const OMNI_FLOW_READY_TIMEOUT_MS = 20_000;
/**
 * Accept-Language header forced on every controlled page so the HTTP header
 * matches the engine locale (set via Chrome's `--lang=en-US` launch flag in
 * chrome-process.ts). Timezone and locale themselves are handled at launch
 * (TZ env + --lang), NOT here — the CDP `Emulation.*` route does not survive the
 * extension-relay transport (newCDPSession trips Target.attachToBrowserTarget).
 */
const STEALTH_ACCEPT_LANGUAGE = "en-US,en;q=0.9";
export class BrowserManager {
    browser = null;
    context = null;
    chromeProcess = null;
    relay = null;
    /**
     * Materialized OmniFlow runtime extension dir for the current launch. Set by
     * the chrome backend; finishLaunch / waitForOmniFlowReady read it instead of
     * reaching into `chromeProcess`. Null in cloakbrowser mode.
     */
    extensionPath = null;
    config;
    workspaceLock = null;
    launchedAtMs = null;
    /**
     * Wall-clock of the last browser-touching call (launch, page access,
     * resetTabs). Used by the idle-close watchdog to decide when to tear
     * Chrome down while keeping workspace ownership. Null while Chrome is
     * not launched.
     */
    lastActivityMs = null;
    /**
     * In-flight setup promises keyed by Page. Both the `context.on("page")`
     * listener and explicit callers (resetTabs, future openTab fixes) route
     * through `setupPage`; this map ensures they share one promise so an
     * explicit `await setupPage(p)` actually waits for the listener-spawned
     * work to finish, instead of starting a redundant second setup that
     * would attach duplicate Fetch listeners and CDP sessions.
     */
    setupPromises = new WeakMap();
    constructor(config) {
        this.config = config;
    }
    /**
     * Claim workspace ownership and clean up orphan Chrome from a previous
     * run. Idempotent. Lets startup fail-fast on workspace conflicts without
     * paying the cost of actually spawning Chrome — Chrome itself is launched
     * lazily by getActivePage/newPage when a browser action is needed, and
     * may also be torn down + relaunched by the idle-close watchdog while
     * this workspace lock stays held.
     */
    /**
     * Per-backend Chromium profile directory.
     *
     * The chrome (real Google Chrome + CDP) and cloakbrowser (patched stealth
     * Chromium 145) backends must NOT share a user-data-dir. A Chromium profile's
     * GPU/shader caches, state LevelDBs, and "Last Version" gate are keyed to the
     * exact engine build that wrote them; pointing a *different* build at it
     * triggers a downgrade/migration path that the weaker, --no-sandbox
     * cloakbrowser mac binary crashes on (SIGTRAP ~1s after launch). Switching
     * backends on the same workspace is exactly that cross-engine collision —
     * hence "切一下就崩,不切正常". Separate dirs keep each engine's profile
     * self-consistent. Login state is unaffected: the cloakbrowser backend
     * re-injects养号 cookies from the session blob on every launch.
     */
    profileDir() {
        const dir = this.config.browser.backend === "cloakbrowser"
            ? "browser-data-cloak"
            : "browser-data";
        return path.join(this.config.workspace, dir);
    }
    async prepareWorkspace() {
        if (this.workspaceLock)
            return;
        const userDataDir = this.profileDir();
        fs.mkdirSync(userDataDir, { recursive: true });
        // Refuse to start if another live dipclaw already owns this workspace,
        // otherwise (no lock or stale lock) take ownership. Only persist the
        // handle after acquire succeeds, otherwise a failed acquire could leave
        // this.workspaceLock pointing at someone else's lock and a later close()
        // would unlink it.
        const handle = await acquireWorkspaceLock(userDataDir);
        try {
            // Always run orphan Chrome cleanup after acquiring the lock, not just
            // on the stale-lock path. Covers: upgrade from a version without the
            // lock file, manual `rm .dipclaw.lock`, or a crash mid-recovery — in
            // all of which the user-data-dir could still be held by a previous
            // run's Chrome and a later spawn would fail.
            await handle.killOrphanChrome(userDataDir);
        }
        catch (err) {
            handle.release();
            throw err;
        }
        this.workspaceLock = handle;
    }
    async launch() {
        if (this.context)
            return;
        await this.prepareWorkspace();
        const userDataDir = this.profileDir();
        try {
            await this.spawnAndConnect(userDataDir);
        }
        catch (err) {
            // Partial init failed (Chrome not found, CDP timeout, connect failure,
            // etc.). Release everything we acquired so a caller that catches and
            // retries — or the next startup — isn't blocked by our own live
            // ownerPid in the lock file.
            await this.close().catch(() => { });
            throw err;
        }
    }
    async spawnAndConnect(userDataDir) {
        if (this.config.browser.backend === "cloakbrowser") {
            await this.launchWithCloakBrowser(userDataDir);
        }
        else {
            await this.launchWithChrome(userDataDir);
        }
        await this.finishLaunch();
    }
    /**
     * Default backend: spawn a real Google Chrome and attach over CDP — via the
     * extension relay (default) or legacy remote-debug. OmniFlow runs in full mode
     * (proxy + timezone + fingerprint + session import).
     */
    async launchWithChrome(userDataDir) {
        const relay = this.config.browser.connectionMode === "extension-relay"
            ? await startExtensionRelay()
            : null;
        this.relay = relay;
        // Write pid via onPid callback so it lands on disk synchronously after
        // spawn — BEFORE awaiting CDP. Otherwise a crash or CDP timeout during
        // startup would leave a Chrome we can't trace on the next launch.
        this.chromeProcess = await spawnChrome({
            userDataDir,
            config: this.config,
            relay: relay
                ? {
                    extensionWebSocketUrl: relay.extensionEndpoint(),
                    token: relay.token,
                    protocolVersion: relay.protocolVersion,
                }
                : undefined,
            onPid: (pid) => {
                this.workspaceLock?.writePid(pid);
            },
        });
        this.extensionPath = this.chromeProcess.extensionPath;
        // If Chrome exits on its own (user closed window, crash, or our own
        // SIGTERM in close()), clear all browser-related state so the next
        // launch() runs full setup instead of returning early via the
        // `if (this.context)` guard, and so a later close() doesn't try to
        // wait for an exit event that already fired.
        //
        // The workspace lock file is intentionally NOT cleaned here: dipclaw
        // still owns this workspace even when Chrome dies. Lock release lives
        // in close().
        this.chromeProcess.onExit(() => {
            this.chromeProcess = null;
            const oldRelay = this.relay;
            const oldBrowser = this.browser;
            this.relay = null;
            this.browser = null;
            this.context = null;
            this.launchedAtMs = null;
            this.lastActivityMs = null;
            // Best-effort tear-down of the now-disconnected Playwright handle.
            // Fire-and-forget so this listener stays synchronous.
            oldBrowser?.close().catch(() => { });
            oldRelay?.close().catch(() => { });
            this.workspaceLock?.clearPid();
        });
        if (relay) {
            await relay.waitForExtensionReady(this.config.browser.relay?.connectTimeoutMs ?? OMNI_FLOW_READY_TIMEOUT_MS);
            this.browser = await chromium.connectOverCDP(relay.cdpEndpoint());
        }
        else {
            // Connect Playwright via Chrome's native remote-debug CDP endpoint.
            this.browser = await chromium.connectOverCDP(this.chromeProcess.cdpEndpoint);
        }
        this.context = this.browser.contexts()[0] ?? (await this.browser.newContext());
    }
    /**
     * CloakBrowser backend: launch the patched stealth Chromium as a persistent
     * context. No separate spawn handle (`chromeProcess` stays null) and no CDP
     * Browser handle (`browser` stays null) — the persistent context IS the
     * surface we drive. Fully extension-free: proxy/timezone come from the
     * session blob via CloakBrowser flags, and养号 cookies are injected with
     * Playwright's native `addCookies` inside launchCloakBrowserContext (so
     * there's no OmniFlow extension and no "extension present" fingerprint).
     */
    async launchWithCloakBrowser(userDataDir) {
        this.extensionPath = null;
        this.context = await launchCloakBrowserContext({
            userDataDir,
            config: this.config,
        });
        this.browser = null;
        // Persistent context closes when the underlying browser dies. Mirror the
        // chrome path's onExit teardown so a crashed/closed CloakBrowser resets
        // state for the next launch() and clears the idle/uptime clocks.
        this.context.on("close", () => {
            this.context = null;
            this.browser = null;
            this.launchedAtMs = null;
            this.lastActivityMs = null;
            this.workspaceLock?.clearPid();
        });
    }
    /** Shared post-launch setup for both backends. */
    async finishLaunch() {
        if (!this.context) {
            throw new Error("browser launch produced no context");
        }
        // Wait for OmniFlow to import the session — only in chrome remote-debug mode.
        // Relay mode handles readiness via relay.waitForExtensionReady() in
        // launchWithChrome (and CDP Emulation doesn't survive the relay transport);
        // the cloakbrowser backend is extension-free, so there's nothing to wait for.
        if (this.extensionPath && !this.relay) {
            await this.waitForOmniFlowReady();
        }
        // Set up page-context compatibility shims for all pages.
        for (const page of this.context.pages()) {
            await this.setupPage(page);
        }
        // The listener is fire-and-forget: emit() doesn't await listener
        // promises. We swallow rejection here to avoid unhandledRejection
        // for pages that are never accessed via getActivePage/newPage (e.g.
        // popups, target=_blank, CDP-driven targets). Explicit callers still
        // see errors — they await the same WeakMap-cached promise, and a
        // .catch on the chain does not change that promise's reject state.
        this.context.on("page", (page) => {
            this.setupPage(page).catch(() => { });
        });
        const proxyInfo = this.config.browser.proxy
            ? `${this.config.browser.proxy.protocol}://${this.config.browser.proxy.host}:${this.config.browser.proxy.port}`
            : "none";
        const tzInfo = this.config.browser.timezone || "system";
        const extensionInfo = this.extensionPath ?? "none";
        if (this.chromeProcess) {
            const connectionInfo = this.config.browser.connectionMode === "extension-relay"
                ? "extension-relay"
                : `remote-debug:${this.chromeProcess.port}`;
            console.log(`🌐 Browser launched (Chrome @ ${this.chromeProcess.chromePath}, profile: ${this.config.name}, connection: ${connectionInfo}, proxy/timezone source: OmniFlow session (configured proxy: ${proxyInfo}, timezone: ${tzInfo}), extension: ${extensionInfo})`);
        }
        else {
            // CloakBrowser backend logs its own proxy/timezone/cookie summary inside
            // launchCloakBrowserContext (sourced from the养号 session blob). It loads
            // no extension, so nothing more to report here.
            console.log(`🌐 Browser launched (CloakBrowser stealth Chromium, profile: ${this.config.name}, extension: none [cookies via Playwright])`);
        }
        this.launchedAtMs = Date.now();
        this.lastActivityMs = this.launchedAtMs;
    }
    async getActivePage() {
        return this.withExtensionRelayReconnect(async () => {
            if (!this.context) {
                await this.launch();
            }
            this.lastActivityMs = Date.now();
            const pages = this.context.pages();
            if (pages.length === 0) {
                const p = await this.context.newPage();
                await this.setupPage(p);
                return p;
            }
            // Returning an existing page: setup was already initiated when that
            // page was created (either by launch's explicit loop or the context
            // "page" listener). Joining the in-flight promise keeps the contract
            // "every page returned from this manager is fully set up".
            const p = pages[pages.length - 1];
            await this.setupPage(p);
            return p;
        });
    }
    async getPages() {
        if (!this.context) {
            await this.launch();
        }
        this.lastActivityMs = Date.now();
        // Intentionally NOT awaiting setupPage here: callers (listTabs,
        // closeTab, focusTab) only read title/url or detach pages — none of
        // which require the page compatibility shim to be live. Action-shaped
        // accessors (getActivePage / newPage) await; inspection-shaped accessors
        // don't, to keep them cheap.
        return this.context.pages();
    }
    async newPage() {
        return this.withExtensionRelayReconnect(async () => {
            if (!this.context) {
                await this.launch();
            }
            this.lastActivityMs = Date.now();
            const p = await this.context.newPage();
            // Join the listener-spawned setup before returning, so callers that
            // immediately navigate (openTab, etc.) don't race proxy/timezone init.
            await this.setupPage(p);
            return p;
        });
    }
    async withExtensionRelayReconnect(operation) {
        try {
            return await operation();
        }
        catch (err) {
            if (!this.isRecoverableExtensionRelayDisconnect(err)) {
                throw err;
            }
            console.warn("  Extension relay disconnected; restarting Chrome profile and retrying...");
            await this.closeKeepLock();
            await this.launch();
            return await operation();
        }
    }
    isRecoverableExtensionRelayDisconnect(err) {
        if (this.config.browser.connectionMode !== "extension-relay")
            return false;
        const message = err instanceof Error ? err.message : String(err);
        return message.includes("extension relay socket is not connected");
    }
    /**
     * Close every open tab and leave a single fresh about:blank.
     *
     * Each Chrome tab runs in its own renderer process; closing the tab is
     * what releases that renderer's anonymous memory (V8 heap, decoded
     * images, DOM). Long-running scheduled tasks accumulate this growth
     * over hours, so we call this at the start of every scheduled task to
     * return renderers to a clean slate.
     *
     * Order matters: open the fresh page first, then close the old ones.
     * If we did it the other way around, Chrome could see "zero pages" for
     * a moment and decide to exit (it doesn't always, but the behavior is
     * version-dependent — opening first sidesteps the question).
     *
     * Safe to call when no tabs are open: returns immediately.
     */
    async resetTabs() {
        if (!this.context)
            return;
        this.lastActivityMs = Date.now();
        const before = this.context.pages();
        if (before.length === 0)
            return;
        const fresh = await this.context.newPage();
        // The context "page" listener has already kicked off setupPage(fresh)
        // synchronously. Awaiting here joins that in-flight promise so the
        // caller's next navigate isn't racing partial setup.
        await this.setupPage(fresh);
        let closed = 0;
        for (const p of before) {
            if (p === fresh)
                continue;
            try {
                await p.close();
                closed++;
            }
            catch {
                // Page was already closing or detached — count it as gone either way.
            }
        }
        // Element refs from the closed pages no longer point at anything.
        resetRefs();
        if (closed > 0) {
            console.log(`🧹 Released ${closed} tab(s); renderers freed.`);
        }
    }
    /**
     * Tear down Chrome + Playwright handle while retaining workspace
     * ownership. Used by the idle-close watchdog: a later getActivePage()
     * re-spawns Chrome in-place without re-claiming the lock or rescanning
     * for orphans (this process is still the owner).
     *
     * Safe to call when Chrome was never launched: returns immediately.
     * Page references the caller may still hold become detached and any
     * subsequent operation on them will throw — element refs (`[ref=eN]`)
     * are also invalidated.
     */
    async closeKeepLock() {
        if (this.browser) {
            // chrome+CDP backend: closing the CDP Browser handle disconnects us;
            // the spawned Chrome is torn down via chromeProcess.close() below.
            const browser = this.browser;
            this.browser = null;
            this.context = null;
            this.launchedAtMs = null;
            this.lastActivityMs = null;
            await settleOrTimeout(browser.close().catch(() => { }), 3000);
        }
        else if (this.context) {
            // cloakbrowser backend: the persistent context owns the browser process,
            // so closing it shuts CloakBrowser down (and flushes cookies to disk).
            const context = this.context;
            this.context = null;
            this.launchedAtMs = null;
            this.lastActivityMs = null;
            await settleOrTimeout(context.close().catch(() => { }), 3000);
        }
        this.extensionPath = null;
        if (this.chromeProcess) {
            // Wait for Chrome to fully exit and flush data (cookies, sessions) to disk.
            // The handle's close() handles SIGTERM/SIGKILL waterfall and the
            // process-exit-handler cleanup; the onExit listener wired in
            // spawnAndConnect will null out chromeProcess as the process dies.
            await this.chromeProcess.close();
            this.chromeProcess = null;
        }
        if (this.relay) {
            const relay = this.relay;
            this.relay = null;
            await relay.close().catch(() => { });
        }
        // Element refs from the now-dead renderer no longer point at anything.
        // Calling here (not just in resetTabs) so an idle-close while refs
        // are cached in memory doesn't leave stale entries usable by a later
        // chat turn before its first navigation.
        resetRefs();
    }
    async close() {
        await this.closeKeepLock();
        // Release workspace ownership last. The handle clears the pid file
        // before unlinking the lock so a concurrent dipclaw startup sees the
        // lock until both Chrome is gone and pid record is cleaned up.
        if (this.workspaceLock) {
            this.workspaceLock.release();
            this.workspaceLock = null;
        }
    }
    async restart() {
        await this.close();
        await this.launch();
    }
    getUptimeMs() {
        return this.launchedAtMs ? Date.now() - this.launchedAtMs : null;
    }
    /**
     * Ms since the last browser-touching operation, or null if Chrome is
     * not currently launched. Used by the idle-close watchdog.
     */
    getIdleMs() {
        return this.lastActivityMs ? Date.now() - this.lastActivityMs : null;
    }
    /**
     * Idempotent per-page setup entry point.
     *
     * The context "page" event listener fires synchronously on emit, but its
     * setup work is async and
     * not awaited by the emitter. Without coordination, an explicit caller
     * that awaits `newPage()` and then navigates can race against that
     * listener-spawned work.
     *
     * We coalesce by Page: the first call (whichever wins the race) starts
     * the real setup and stashes the Promise; subsequent calls return the
     * same Promise, so awaiting it produces correct happens-before.
     */
    setupPage(page) {
        let p = this.setupPromises.get(page);
        if (!p) {
            p = this.doSetupPage(page);
            this.setupPromises.set(page, p);
        }
        return p;
    }
    async doSetupPage(page) {
        // Keep the Accept-Language header in step with the engine locale (forced to
        // en-US at launch via --lang). Timezone/locale themselves are set at launch
        // (TZ env + --lang), so there are no per-page CDP emulation calls here.
        await this.applyAcceptLanguage(page);
        // Dev runs go through tsx/esbuild, which can preserve function names by
        // rewriting local page-context helpers as __name(fn, "helper"). Those
        // serialized helpers execute in Chrome, not Node, so provide the tiny
        // identity shim they expect. tsc production output does not need it, but
        // the shim is harmless and keeps dev/prod behavior aligned.
        await page.addInitScript(() => {
            const g = globalThis;
            if (typeof g.__name !== "function") {
                Object.defineProperty(g, "__name", {
                    value: (fn) => fn,
                    configurable: true,
                });
            }
        });
        await page
            .evaluate(() => {
            const g = globalThis;
            if (typeof g.__name !== "function") {
                Object.defineProperty(g, "__name", {
                    value: (fn) => fn,
                    configurable: true,
                });
            }
        })
            .catch(() => { });
    }
    /**
     * Force the Accept-Language header to match the engine locale (en-US, set via
     * Chrome's --lang launch flag). Uses the high-level Playwright API, which
     * rides the page's existing CDP session and works over both connection modes
     * (confirmed over extension-relay via Network.setExtraHTTPHeaders). Guarded so
     * a transport that rejects it can't break page setup.
     */
    async applyAcceptLanguage(page) {
        await page
            .setExtraHTTPHeaders({ "Accept-Language": STEALTH_ACCEPT_LANGUAGE })
            .catch(() => { });
    }
    async waitForOmniFlowReady() {
        if (!this.context)
            return;
        if (!this.extensionPath) {
            throw new Error("OmniFlow Chrome extension is required but was not found in the dp-worker package");
        }
        const expectedHash = readExpectedSessionHash(this.config.workspace);
        const deadline = Date.now() + OMNI_FLOW_READY_TIMEOUT_MS;
        let worker = findOmniFlowServiceWorker(this.context);
        while (Date.now() < deadline) {
            if (!worker) {
                worker = findOmniFlowServiceWorker(this.context);
            }
            if (!worker) {
                const remaining = Math.max(1, deadline - Date.now());
                worker = await this.context
                    .waitForEvent("serviceworker", {
                    timeout: Math.min(1000, remaining),
                })
                    .then((w) => (isOmniFlowServiceWorker(w) ? w : null))
                    .catch(() => null);
            }
            if (worker) {
                try {
                    const ready = await readOmniFlowReadyState(worker);
                    if (ready?.status === "error") {
                        throw new Error(`OmniFlow extension failed to import session: ${ready.error ?? "unknown error"}`);
                    }
                    if (ready?.status === "ready" &&
                        (!expectedHash || ready.sessionHash === expectedHash)) {
                        const suffix = ready.sessionHash
                            ? ` (session ${ready.sessionHash.slice(0, 12)}…)`
                            : "";
                        console.log(`🧩 OmniFlow extension ready${suffix}.`);
                        return;
                    }
                }
                catch (err) {
                    if (err instanceof Error &&
                        err.message.startsWith("OmniFlow extension failed")) {
                        throw err;
                    }
                    worker = null;
                }
            }
            await delay(200);
        }
        throw new Error(expectedHash
            ? `OmniFlow extension did not report session ${expectedHash} ready within ${OMNI_FLOW_READY_TIMEOUT_MS}ms`
            : `OmniFlow extension did not report ready within ${OMNI_FLOW_READY_TIMEOUT_MS}ms`);
    }
    isLaunched() {
        return this.context !== null;
    }
}
function settleOrTimeout(promise, timeoutMs) {
    return new Promise((resolve) => {
        const timer = setTimeout(resolve, timeoutMs);
        promise
            .then((value) => {
            clearTimeout(timer);
            resolve(value);
        })
            .catch(() => {
            clearTimeout(timer);
            resolve();
        });
    });
}
function readExpectedSessionHash(workspace) {
    const p = path.join(workspace, SESSION_META_FILENAME);
    if (!fs.existsSync(p))
        return null;
    try {
        const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
        return typeof parsed.content_hash === "string" ? parsed.content_hash : null;
    }
    catch {
        return null;
    }
}
function findOmniFlowServiceWorker(context) {
    return context.serviceWorkers().find(isOmniFlowServiceWorker) ?? null;
}
function isOmniFlowServiceWorker(worker) {
    const url = worker.url();
    return url.startsWith("chrome-extension://") && url.endsWith("/background.js");
}
async function readOmniFlowReadyState(worker) {
    return worker.evaluate(async () => {
        const g = globalThis;
        if (g.OMNI_FLOW_READY_STATE)
            return g.OMNI_FLOW_READY_STATE;
        const stored = await g.chrome?.storage?.local?.get(["dipclawReady"]);
        return stored?.dipclawReady ?? null;
    });
}
function delay(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
//# sourceMappingURL=manager.js.map