import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
 * UI/Accept-Language locale forced on every launched Chrome. English everywhere,
 * by configuration. Keep in step with manager.ts's STEALTH_ACCEPT_LANGUAGE.
 */
const STEALTH_LANG = "en-US";
/**
 * Build the environment for the spawned Chrome.
 *
 * Timezone is forced here via the `TZ` env var rather than a CDP
 * `Emulation.setTimezoneOverride`: V8/ICU honor `TZ` in every renderer, so
 * `Date.prototype.getTimezoneOffset`, the `Date` string methods, and
 * `Intl…resolvedOptions().timeZone` ALL agree — engine-level and undetectable.
 * The CDP route is also a non-starter under extension-relay: Playwright's
 * `newCDPSession` trips `Target.attachToBrowserTarget`, which the relay can't
 * forward through `chrome.debugger`.
 */
export function buildChromeSpawnEnv(config) {
    return {
        ...process.env,
        HOME: os.homedir(),
        ...(config.browser.timezone ? { TZ: config.browser.timezone } : {}),
    };
}
/**
 * Locate Chrome, pick a free debugging port, spawn it with our flags, and
 * wait until the CDP endpoint is reachable. Throws if Chrome can't be
 * found or the CDP port doesn't come up in time.
 *
 * The handle is constructed (and its process.once("exit") cleanup is
 * registered) IMMEDIATELY after spawn — before awaiting CDP — so a Node
 * crash during the startup window still sends SIGTERM to Chrome on the
 * way out. On CDP timeout we call `handle.close()` (which awaits the
 * actual process exit) instead of a fire-and-forget SIGKILL, so the
 * caller's workspace lock release doesn't race a still-exiting profile.
 */
export async function spawnChrome(opts) {
    const chromePath = findChromeExecutable();
    if (!chromePath) {
        throw new Error("Google Chrome not found. Please install the official Google Chrome or set DP_CHROME_PATH.");
    }
    const connectionMode = opts.config.browser.connectionMode;
    const port = connectionMode === "remote-debug" ? await findFreePort() : null;
    const extensionPath = prepareBundledChromeExtensionForLaunch(opts.userDataDir, opts.config, opts.relay);
    const args = buildChromeArgs(opts.userDataDir, port, opts.config, extensionPath);
    const proc = spawn(chromePath, args, {
        stdio: ["ignore", "ignore", "pipe"],
        env: buildChromeSpawnEnv(opts.config),
        detached: false,
    });
    // Construct the handle before awaiting CDP so the exit hooks are live
    // for the full startup window, not just after CDP succeeds.
    const handle = new SpawnedChromeProcess(proc, chromePath, port, extensionPath);
    // Let the caller record the pid right now (also still inside the
    // startup window) so orphan detection survives a crash during CDP wait.
    if (proc.pid !== undefined && opts.onPid) {
        try {
            opts.onPid(proc.pid);
        }
        catch { }
    }
    try {
        if (port !== null) {
            const cdpEndpoint = await waitForCdpEndpoint(port);
            handle.setCdpEndpoint(cdpEndpoint);
        }
        return handle;
    }
    catch (err) {
        // CDP didn't come up. Use handle.close() (SIGTERM with SIGKILL
        // fallback + awaits exit) so the caller's later workspace-lock
        // release doesn't race a still-exiting Chrome profile.
        await handle.close().catch(() => { });
        throw err;
    }
}
class SpawnedChromeProcess {
    proc;
    chromePath;
    port;
    extensionPath;
    pid;
    exitListeners = [];
    nodeExitHandler = null;
    hasExited = false;
    _cdpEndpoint = null;
    constructor(proc, chromePath, port, extensionPath) {
        this.proc = proc;
        this.chromePath = chromePath;
        this.port = port;
        this.extensionPath = extensionPath;
        this.pid = proc.pid;
        // Last-resort sync cleanup: if Node exits without close() running
        // (uncaught exception, abrupt process.exit, etc.), still send SIGTERM
        // to Chrome. SIGKILL on dipclaw can't be caught — that case is covered
        // by orphan cleanup at next startup.
        if (this.pid) {
            const pid = this.pid;
            this.nodeExitHandler = () => {
                try {
                    process.kill(pid, "SIGTERM");
                }
                catch { }
            };
            process.once("exit", this.nodeExitHandler);
        }
        proc.on("exit", () => {
            this.hasExited = true;
            this.deregisterNodeExitHandler();
            for (const l of this.exitListeners) {
                try {
                    l();
                }
                catch { }
            }
        });
    }
    get cdpEndpoint() {
        if (this._cdpEndpoint === null) {
            throw new Error("ChromeProcessHandle.cdpEndpoint accessed before CDP was ready");
        }
        return this._cdpEndpoint;
    }
    /** Internal: spawnChrome sets this once waitForCdpEndpoint resolves. */
    setCdpEndpoint(endpoint) {
        this._cdpEndpoint = endpoint;
    }
    onExit(listener) {
        if (this.hasExited) {
            // Already exited — fire immediately so callers don't miss the event
            // if they subscribed after the fact.
            try {
                listener();
            }
            catch { }
            return;
        }
        this.exitListeners.push(listener);
    }
    deregisterNodeExitHandler() {
        if (this.nodeExitHandler) {
            process.removeListener("exit", this.nodeExitHandler);
            this.nodeExitHandler = null;
        }
    }
    async close() {
        this.deregisterNodeExitHandler();
        if (this.hasExited)
            return;
        const proc = this.proc;
        // Wait for Chrome to fully exit and flush data (cookies, sessions) to disk
        await new Promise((resolve) => {
            let resolved = false;
            const finish = () => {
                if (resolved)
                    return;
                resolved = true;
                resolve();
            };
            proc.on("exit", finish);
            try {
                proc.kill("SIGTERM");
            }
            catch {
                finish();
                return;
            }
            // Force kill after 5s if Chrome hangs
            setTimeout(() => {
                if (resolved)
                    return;
                try {
                    proc.kill("SIGKILL");
                }
                catch { }
                finish();
            }, 5000);
        });
    }
}
// --- Chrome executable detection ---
function findChromeExecutable() {
    const explicit = firstExistingProductionChromePath([
        process.env.DP_CHROME_PATH,
        process.env.CHROME_PATH,
        process.env.CHROME_BIN,
    ]);
    if (explicit)
        return explicit;
    const platform = process.platform;
    if (platform === "darwin") {
        const paths = [
            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        ];
        return firstExistingPath(paths);
    }
    else if (platform === "linux") {
        const paths = [
            "/usr/bin/google-chrome-stable",
            "/usr/bin/google-chrome",
            "/opt/google/chrome/chrome",
        ];
        return firstExistingPath(paths);
    }
    else if (platform === "win32") {
        const prefixes = [
            process.env["PROGRAMFILES"] || "C:\\Program Files",
            process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)",
            process.env["LOCALAPPDATA"] || "",
        ];
        const suffixes = [
            "Google\\Chrome\\Application\\chrome.exe",
        ];
        for (const prefix of prefixes) {
            if (!prefix)
                continue;
            for (const suffix of suffixes) {
                const p = path.join(prefix, suffix);
                if (fs.existsSync(p))
                    return p;
            }
        }
    }
    return null;
}
function firstExistingProductionChromePath(paths) {
    for (const p of paths) {
        if (p && fs.existsSync(p) && !isUnsupportedChromeExecutable(p))
            return p;
    }
    return null;
}
function firstExistingPath(paths) {
    for (const p of paths) {
        if (p && fs.existsSync(p))
            return p;
    }
    return null;
}
function isUnsupportedChromeExecutable(p) {
    const lower = p.toLowerCase();
    return (lower.includes("chrome for testing") ||
        lower.includes("chromium") ||
        lower.includes("microsoft edge") ||
        lower.includes("brave"));
}
// --- Chrome launch arguments ---
const ENABLE_UNPACKED_EXTENSION_FEATURE_FLAGS = [
    "DisableLoadExtensionCommandLineSwitch",
    "DisableDisableExtensionsExceptCommandLineSwitch",
].join(",");
function buildChromeArgs(userDataDir, port, config, extensionPath) {
    const args = [
        `--user-data-dir=${userDataDir}`,
        "--no-first-run",
        "--no-default-browser-check",
        "--use-mock-keychain",
        "--disable-background-networking",
        "--disable-client-side-phishing-detection",
        "--disable-component-update",
        "--disable-default-apps",
        "--disable-sync",
        "--disable-translate",
        // Force the application/UI locale to English so navigator.language and the
        // default Accept-Language are en-US regardless of the host's system locale.
        `--lang=${STEALTH_LANG}`,
        "--metrics-recording-only",
        "--no-default-browser-check",
        // Bound disk caches so workspace browser-data doesn't grow unbounded
        // over long-running sessions. Code Cache and Service Worker cache are
        // NOT controlled by these flags — handle separately if needed.
        "--disk-cache-size=104857600", // HTTP cache: 100 MB
        "--media-cache-size=52428800", // media cache: 50 MB
    ];
    if (config.browser.connectionMode === "remote-debug") {
        if (port === null) {
            throw new Error("remote-debug mode requires a CDP port");
        }
        args.unshift(`--remote-debugging-port=${port}`);
    }
    if (extensionPath) {
        // Chrome Stable disables command-line unpacked extension loading by default.
        args.push(`--disable-features=${ENABLE_UNPACKED_EXTENSION_FEATURE_FLAGS}`, `--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`);
    }
    if (config.browser.headless) {
        args.push("--headless=new");
    }
    if (config.browser.viewport) {
        const vp = config.browser.viewport;
        args.push(`--window-size=${vp.width},${vp.height}`);
    }
    else {
        args.push("--start-maximized");
    }
    // Start on a non-network page so OmniFlow can import session/proxy state
    // before user navigation. In extension-relay mode, avoid about:blank because
    // Playwright treats the initial empty document as pending first navigation
    // when attaching over CDP.
    args.push(config.browser.connectionMode === "extension-relay"
        ? "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EDipclaw%3C%2Ftitle%3E"
        : "about:blank");
    return args;
}
export function buildChromeArgsForTest(userDataDir, port, config, extensionPath) {
    return buildChromeArgs(userDataDir, port, config, extensionPath);
}
// --- Bundled Chrome extension ---
const CHROME_EXTENSION_RELATIVE_PATH = path.join("chrome-extension", "omni-flow");
const RUNTIME_EXTENSION_PARENT = "chrome-extension";
const RUNTIME_EXTENSION_BASENAME = "omni-flow";
const OMNI_FLOW_SESSION_FILENAME = "omni-flow-session.json";
const SESSION_META_FILENAME = ".session-meta.json";
export function prepareBundledChromeExtensionForLaunch(userDataDir, config, relay) {
    const sourcePath = resolveBundledChromeExtensionSourcePath();
    if (!sourcePath) {
        throw new Error("OmniFlow Chrome extension source not found; rebuild the dp-worker package");
    }
    // Key the runtime extension dir on the service-worker code hash so a changed
    // extension loads under a fresh Chrome extension ID. Chrome caches an unpacked
    // extension's service worker per ID in the persistent profile and does NOT
    // reload it on file change OR manifest version bump — so a fixed dir name would
    // keep running the previous build's service worker after a dp-worker upgrade
    // (the worker would keep the old importScripts SW and never pick up the new
    // fetch-based bootstrap, leaving session/proxy unloaded until the profile is
    // wiped). Flipping the ID only when background.js changes self-heals on upgrade
    // without wiping the profile, while keeping extension storage stable across
    // ordinary relaunches.
    const extensionParent = path.resolve(userDataDir, "..", ".dipclaw", RUNTIME_EXTENSION_PARENT);
    const runtimePath = path.join(extensionParent, `${RUNTIME_EXTENSION_BASENAME}-${hashServiceWorker(sourcePath)}`);
    pruneStaleRuntimeExtensions(extensionParent, path.basename(runtimePath));
    copyChromeExtension(sourcePath, runtimePath);
    const session = copyOmniFlowSession(config.workspace, runtimePath);
    writeChromeExtensionBootstrap(runtimePath, config, session, relay);
    return runtimePath;
}
/**
 * Short hash of the service-worker script (background.js) — the only extension
 * resource Chrome caches per extension ID across launches. A change here must
 * produce a new runtime dir name → new extension ID → fresh service worker.
 */
function hashServiceWorker(sourcePath) {
    const bg = fs.readFileSync(path.join(sourcePath, "background.js"));
    return createHash("sha256").update(bg).digest("hex").slice(0, 12);
}
/**
 * Remove previous runtime extension dirs (a stale `omni-flow` from before this
 * change, or an older `omni-flow-<hash>`) so they don't accumulate. The profile's
 * cached service worker for those old IDs is simply left unused — the launch only
 * loads `keepName`.
 */
function pruneStaleRuntimeExtensions(extensionParent, keepName) {
    let entries;
    try {
        entries = fs.readdirSync(extensionParent);
    }
    catch {
        return;
    }
    for (const name of entries) {
        if (name === keepName)
            continue;
        if (name === RUNTIME_EXTENSION_BASENAME ||
            name.startsWith(`${RUNTIME_EXTENSION_BASENAME}-`)) {
            fs.rmSync(path.join(extensionParent, name), {
                recursive: true,
                force: true,
            });
        }
    }
}
function resolveBundledChromeExtensionSourcePath() {
    const here = path.dirname(fileURLToPath(import.meta.url));
    const candidates = [
        // npm package / built dev-dist: dist/browser -> dist/chrome-extension
        path.resolve(here, "..", CHROME_EXTENSION_RELATIVE_PATH),
        // Defensive package-root layout, if assets are ever moved out of dist.
        path.resolve(here, "..", "..", CHROME_EXTENSION_RELATIVE_PATH),
        // Monorepo dev via tsx: dp-worker/src/browser -> repo/chrome-extension
        path.resolve(here, "..", "..", "..", CHROME_EXTENSION_RELATIVE_PATH),
    ];
    for (const candidate of candidates) {
        if (isUnpackedChromeExtension(candidate))
            return candidate;
    }
    return null;
}
function isUnpackedChromeExtension(dir) {
    return fs.existsSync(path.join(dir, "manifest.json"));
}
function copyChromeExtension(sourcePath, runtimePath) {
    if (path.resolve(sourcePath) === path.resolve(runtimePath))
        return;
    fs.rmSync(runtimePath, { recursive: true, force: true });
    fs.mkdirSync(path.dirname(runtimePath), { recursive: true });
    fs.cpSync(sourcePath, runtimePath, { recursive: true });
    stripRuntimeExtensionKey(runtimePath);
}
function stripRuntimeExtensionKey(runtimePath) {
    const manifestPath = path.join(runtimePath, "manifest.json");
    try {
        const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
        if (!("key" in manifest))
            return;
        delete manifest.key;
        fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
    }
    catch {
        // Leave Chrome to surface malformed manifest errors during launch.
    }
}
function writeChromeExtensionBootstrap(extensionPath, config, session, relay) {
    const bootstrap = buildChromeExtensionBootstrap(config, session, relay);
    // Write the bootstrap as JSON the extension reads via fetch(..., {cache:"no-store"}),
    // NOT as a .js loaded via importScripts. Chrome caches importScripts'd resources
    // per extension origin in the persistent profile, so a bootstrap we regenerate on
    // a later launch (e.g. after a session is uploaded) would be ignored — the service
    // worker keeps the first launch's stale config and never imports the session. A
    // no-store fetch always reads the freshly written file.
    fs.writeFileSync(path.join(extensionPath, "dipclaw-config.json"), JSON.stringify(bootstrap, null, 2) + "\n", "utf8");
}
function copyOmniFlowSession(workspace, extensionPath) {
    const src = path.join(workspace, ".dipclaw", OMNI_FLOW_SESSION_FILENAME);
    if (!fs.existsSync(src))
        return null;
    const dest = path.join(extensionPath, OMNI_FLOW_SESSION_FILENAME);
    fs.copyFileSync(src, dest);
    const meta = readSessionMetaSync(workspace);
    return {
        filename: OMNI_FLOW_SESSION_FILENAME,
        ...(meta?.content_hash ? { contentHash: meta.content_hash } : {}),
    };
}
function readSessionMetaSync(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"
            ? { content_hash: parsed.content_hash }
            : null;
    }
    catch {
        return null;
    }
}
function buildChromeExtensionBootstrap(_config, session, relay) {
    const relayConfig = relay
        ? {
            relay: {
                extensionWebSocketUrl: relay.extensionWebSocketUrl,
                token: relay.token,
                protocolVersion: relay.protocolVersion,
                autoConnect: true,
            },
        }
        : {};
    if (session) {
        return {
            source: "dipclaw",
            sessionFile: session.filename,
            ...(session.contentHash ? { expectedSessionHash: session.contentHash } : {}),
            ...relayConfig,
        };
    }
    return {
        source: "dipclaw",
        ...relayConfig,
    };
}
// --- Port & CDP helpers ---
function findFreePort() {
    return new Promise((resolve, reject) => {
        const server = net.createServer();
        server.listen(0, () => {
            const addr = server.address();
            if (addr && typeof addr === "object") {
                const port = addr.port;
                server.close(() => resolve(port));
            }
            else {
                reject(new Error("Failed to find free port"));
            }
        });
        server.on("error", reject);
    });
}
async function waitForCdpEndpoint(port, timeoutMs = 15000) {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
        try {
            const res = await fetch(`http://127.0.0.1:${port}/json/version`);
            if (res.ok) {
                const json = (await res.json());
                if (json.webSocketDebuggerUrl) {
                    return json.webSocketDebuggerUrl;
                }
            }
        }
        catch {
            // Chrome not ready yet
        }
        await new Promise((r) => setTimeout(r, 200));
    }
    throw new Error(`Chrome did not start within ${timeoutMs}ms (CDP port ${port})`);
}
//# sourceMappingURL=chrome-process.js.map