/**
 * Browser Manager
 *
 * Manages Puppeteer browser lifecycle: Chrome executable resolution,
 * launch args, pooled browser acquisition/release.
 */

import type { Browser, PuppeteerNode } from "puppeteer-core";
import { execSync } from "child_process";
import { existsSync, readdirSync } from "fs";
import { join } from "path";
import { homedir, totalmem } from "os";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";

let _puppeteer: PuppeteerNode | undefined;

async function getPuppeteer(): Promise<PuppeteerNode> {
  if (_puppeteer) return _puppeteer;
  try {
    const mod = await import("puppeteer" as string);
    _puppeteer = mod.default;
  } catch {
    const mod = await import("puppeteer-core");
    _puppeteer = mod.default;
  }
  if (!_puppeteer) throw new Error("Neither puppeteer nor puppeteer-core found");
  return _puppeteer;
}

// "beginframe" = atomic compositor control via HeadlessExperimental.beginFrame (Linux only)
// "screenshot" = renderSeek + Page.captureScreenshot (all platforms)
export type CaptureMode = "beginframe" | "screenshot";

export interface AcquiredBrowser {
  browser: Browser;
  captureMode: CaptureMode;
}

/**
 * Resolve chrome-headless-shell binary for deterministic BeginFrame rendering.
 * Checks config.chromePath, then PRODUCER_HEADLESS_SHELL_PATH env var,
 * then scans Puppeteer's managed cache at ~/.cache/puppeteer/chrome-headless-shell/.
 */
export function resolveHeadlessShellPath(
  config?: Partial<Pick<EngineConfig, "chromePath">>,
): string | undefined {
  if (config?.chromePath) {
    return config.chromePath;
  }
  if (process.env.PRODUCER_HEADLESS_SHELL_PATH) {
    return process.env.PRODUCER_HEADLESS_SHELL_PATH;
  }
  const baseDir = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
  if (!existsSync(baseDir)) return undefined;
  try {
    const versions = readdirSync(baseDir).sort().reverse(); // newest first
    for (const version of versions) {
      const candidates = [
        join(baseDir, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
        join(baseDir, version, "chrome-headless-shell-mac-arm64", "chrome-headless-shell"),
        join(baseDir, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
        join(baseDir, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
      ];
      for (const binary of candidates) {
        if (existsSync(binary)) return binary;
      }
    }
  } catch {
    // ignore
  }
  return undefined;
}

let pooledBrowser: Browser | null = null;
let pooledBrowserRefCount = 0;
let pooledCaptureMode: CaptureMode = "screenshot";
let _pooledBrowserLaunchPromise: Promise<AcquiredBrowser> | null = null;

// Preserve the producer-era export so re-export shims keep the same public API.
export const ENABLE_BROWSER_POOL = DEFAULT_CONFIG.enableBrowserPool;

// Flags only meaningful when Chrome's compositor is driven by
// HeadlessExperimental.beginFrame. If we fall back to screenshot mode they
// must be stripped — `--enable-begin-frame-control` in particular makes the
// compositor wait for frames we'll never send, producing blank screenshots.
const BEGINFRAME_ONLY_FLAGS = new Set([
  "--deterministic-mode",
  "--enable-begin-frame-control",
  "--disable-new-content-rendering-timeout",
  "--run-all-compositor-stages-before-draw",
  "--disable-threaded-animation",
  "--disable-threaded-scrolling",
  "--disable-checker-imaging",
  "--disable-image-animation-resync",
  "--enable-surface-synchronization",
]);

function stripBeginFrameFlags(args: string[]): string[] {
  return args.filter((a) => !BEGINFRAME_ONLY_FLAGS.has(a));
}

/**
 * Probe whether the browser still speaks HeadlessExperimental.beginFrame.
 *
 * Recent chrome-headless-shell builds (observed on 147) expose the domain
 * well enough that HeadlessExperimental.enable succeeds but drop the
 * beginFrame method itself — the capture loop then dies on first frame with
 * `'HeadlessExperimental.beginFrame' wasn't found`. So we probe BOTH: enable
 * + one cheap beginFrame raced against a 2s timeout. In beginframe-control
 * mode the command completes as soon as the compositor acks, so a real
 * supported browser returns well under the timeout.
 *
 * Any failure (method missing, timeout, protocol error) is treated as
 * unsupported. Real errors after launch would surface in the warmup loop and
 * fall out through the caller's try/catch.
 */
async function probeBeginFrameSupport(browser: Browser): Promise<boolean> {
  let page;
  try {
    page = await browser.newPage();
    const client = await page.createCDPSession();
    await client.send("HeadlessExperimental.enable");
    const beginFrame = client.send("HeadlessExperimental.beginFrame", {
      frameTimeTicks: 0,
      interval: 33,
      noDisplayUpdates: true,
    });
    const timeout = new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error("beginFrame probe timeout")), 2000),
    );
    await Promise.race([beginFrame, timeout]);
    await client.detach().catch(() => {});
    return true;
  } catch {
    return false;
  } finally {
    await page?.close().catch(() => {});
  }
}

/**
 * Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`.
 *
 * Caching the Promise (rather than the resolved value) deduplicates concurrent
 * callers — the parallel coordinator runs N workers via `Promise.all`, so a
 * `--workers 4` render against a no-GPU host would otherwise fire 4
 * simultaneous probe Chromes. The first call assigns the Promise and every
 * other concurrent caller awaits the same one, paying the ~240 ms probe cost
 * exactly once per process lifetime.
 *
 * Exported for tests; production callers go through `resolveBrowserGpuMode`.
 */
let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined;

/** Test-only: reset the cached probe result. */
export function _resetAutoBrowserGpuModeCacheForTests(): void {
  _autoBrowserGpuModeCache = undefined;
}

/**
 * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
 *
 * For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"`
 * it launches a tiny Chrome with the platform's hardware GPU args, runs a
 * one-shot WebGL availability probe, and falls back to `"software"` if
 * hardware-mode WebGL is unavailable. The Promise is cached for the process
 * lifetime, so concurrent callers (parallel workers) share the same probe.
 *
 * Any failure (Chrome launch error, navigation timeout, missing canvas API,
 * etc.) is treated as a `"software"` fallback. The render path with
 * SwiftShader always works, so a misclassification toward software is the
 * safe failure mode; misclassifying toward hardware would error on the real
 * render.
 */
export function resolveBrowserGpuMode(
  mode: EngineConfig["browserGpuMode"],
  options: {
    chromePath?: string;
    browserTimeout?: number;
    platform?: NodeJS.Platform;
  } = {},
): Promise<"software" | "hardware"> {
  if (mode !== "auto") return Promise.resolve(mode);
  if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache;

  _autoBrowserGpuModeCache = (async () => {
    const platform = options.platform ?? process.platform;
    const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
    const executablePath = options.chromePath ?? resolveHeadlessShellPath({});

    const probeArgs = [
      "--no-sandbox",
      "--disable-setuid-sandbox",
      "--disable-dev-shm-usage",
      "--enable-webgl",
      "--ignore-gpu-blocklist",
      ...getBrowserGpuArgs("hardware", platform),
    ];

    const ppt = await getPuppeteer().catch(() => null);
    if (!ppt) {
      logResolvedBrowserGpuMode("software", "puppeteer unavailable");
      return "software" as const;
    }

    let probeBrowser: Browser | undefined;
    try {
      probeBrowser = await ppt.launch({
        headless: true,
        args: probeArgs,
        defaultViewport: { width: 64, height: 64 },
        executablePath,
        timeout: browserTimeout,
      });
      const page = await probeBrowser.newPage();
      const hasWebGL = await page.evaluate(() => {
        try {
          const c = document.createElement("canvas");
          const gl =
            c.getContext("webgl") ||
            (c.getContext("experimental-webgl") as RenderingContext | null);
          return gl !== null;
        } catch {
          return false;
        }
      });
      const resolved = hasWebGL ? ("hardware" as const) : ("software" as const);
      logResolvedBrowserGpuMode(resolved, hasWebGL ? "WebGL probe succeeded" : "WebGL unavailable");
      return resolved;
    } catch (err) {
      logResolvedBrowserGpuMode(
        "software",
        `probe failed (${err instanceof Error ? err.message : String(err)})`,
      );
      return "software" as const;
    } finally {
      await probeBrowser?.close().catch(() => {});
    }
  })();

  return _autoBrowserGpuModeCache;
}

/**
 * Single observability surface for the auto-detect outcome. Logged exactly
 * once per process (the probe runs once); without this line, a regression
 * to "always software even with a GPU present" would be invisible in
 * production. Goes to stderr to stay out of stdout pipelines.
 */
function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void {
  console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`);
}

/**
 * Resolve the capture mode the caller expects, WITHOUT launching a browser.
 * Used to validate pool compatibility before returning a cached instance.
 */
function resolveRequestedCaptureMode(
  config?: Partial<Pick<EngineConfig, "chromePath" | "forceScreenshot">>,
): CaptureMode {
  const headlessShell = resolveHeadlessShellPath(config);
  // BeginFrame requires chrome-headless-shell AND Linux — crashes on
  // macOS/Windows (crbug.com/40656275).
  const isLinux = process.platform === "linux";
  const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
  if (headlessShell && isLinux && !forceScreenshot) return "beginframe";
  return "screenshot";
}

export async function acquireBrowser(
  chromeArgs: string[],
  config?: Partial<
    Pick<
      EngineConfig,
      "browserTimeout" | "protocolTimeout" | "enableBrowserPool" | "chromePath" | "forceScreenshot"
    >
  >,
): Promise<AcquiredBrowser> {
  const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;

  if (enablePool && pooledBrowser) {
    if (!pooledBrowser.connected) {
      pooledBrowser = null;
      pooledBrowserRefCount = 0;
      _pooledBrowserLaunchPromise = null;
    } else {
      // Validate mode compatibility: a caller that needs screenshot mode
      // (forceScreenshot, alpha output, BeginFrame timeout retry) must not
      // receive a beginframe browser — the BeginFrame-only flags make the
      // compositor wait for frames the screenshot path never sends.
      const requestedMode = resolveRequestedCaptureMode(config);
      if (pooledCaptureMode === requestedMode) {
        pooledBrowserRefCount += 1;
        return { browser: pooledBrowser, captureMode: pooledCaptureMode };
      }
      // Mode mismatch — skip pool, launch a dedicated browser for this caller.
      // Don't evict the pooled browser: other sessions may still hold refs.
    }
  }

  // Dedup concurrent launches: when the pool is enabled and multiple callers
  // (e.g. parallel workers via Promise.all) race into acquireBrowser before
  // the first launch completes, they would all see pooledBrowser === null and
  // each spawn a separate Chrome. Cache the in-flight launch Promise so the
  // second+ callers await the same one instead of launching again.
  if (enablePool && _pooledBrowserLaunchPromise) {
    const result = await _pooledBrowserLaunchPromise;
    const requestedMode = resolveRequestedCaptureMode(config);
    if (result.captureMode === requestedMode) {
      pooledBrowserRefCount += 1;
      return result;
    }
    // Mode mismatch with pending launch — launch a dedicated browser.
  }

  const launchPromise = launchBrowser(chromeArgs, config);

  if (enablePool && !pooledBrowser && !_pooledBrowserLaunchPromise) {
    _pooledBrowserLaunchPromise = launchPromise;
    try {
      const result = await launchPromise;
      pooledBrowser = result.browser;
      pooledBrowserRefCount = 1;
      pooledCaptureMode = result.captureMode;
      return result;
    } finally {
      _pooledBrowserLaunchPromise = null;
    }
  }

  return launchPromise;
}

async function launchBrowser(
  chromeArgs: string[],
  config?: Partial<
    Pick<EngineConfig, "browserTimeout" | "protocolTimeout" | "chromePath" | "forceScreenshot">
  >,
): Promise<AcquiredBrowser> {
  // Config chromePath overrides env var / auto-detection.
  const headlessShell = resolveHeadlessShellPath(config);

  // BeginFrame requires chrome-headless-shell AND Linux (crashes on
  // macOS/Windows — crbug.com/40656275).
  const isLinux = process.platform === "linux";
  const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
  let captureMode: CaptureMode;
  let executablePath: string | undefined;

  if (headlessShell && isLinux && !forceScreenshot) {
    captureMode = "beginframe";
    executablePath = headlessShell;
  } else {
    // Screenshot mode with renderSeek: works on all platforms.
    captureMode = "screenshot";
    executablePath = headlessShell ?? undefined;
  }

  const ppt = await getPuppeteer();
  const browserTimeout = config?.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
  const protocolTimeout = config?.protocolTimeout ?? DEFAULT_CONFIG.protocolTimeout;
  let browser = await ppt.launch({
    headless: true,
    args: chromeArgs,
    defaultViewport: null,
    executablePath,
    timeout: browserTimeout,
    protocolTimeout,
  });

  if (captureMode === "beginframe") {
    const supported = await probeBeginFrameSupport(browser).catch(() => true);
    if (!supported) {
      await browser.close().catch(() => {});
      console.warn(
        "[BrowserManager] HeadlessExperimental.beginFrame unavailable in this Chromium build; falling back to screenshot mode.",
      );
      captureMode = "screenshot";
      browser = await ppt.launch({
        headless: true,
        args: stripBeginFrameFlags(chromeArgs),
        defaultViewport: null,
        executablePath,
        timeout: browserTimeout,
        protocolTimeout,
      });
    }
  }

  return { browser, captureMode };
}

export async function releaseBrowser(
  browser: Browser,
  config?: Partial<Pick<EngineConfig, "enableBrowserPool">>,
): Promise<void> {
  const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
  if (!enablePool) {
    await browser.close().catch(() => {});
    return;
  }
  if (pooledBrowser && pooledBrowser === browser) {
    pooledBrowserRefCount = Math.max(0, pooledBrowserRefCount - 1);
    if (pooledBrowserRefCount === 0) {
      await browser.close().catch(() => {});
      pooledBrowser = null;
      _pooledBrowserLaunchPromise = null;
    }
    return;
  }
  await browser.close().catch(() => {});
}

export function forceReleaseBrowser(browser: Browser): void {
  if (pooledBrowser && pooledBrowser === browser) {
    // If other sessions still hold refs, just drop ours — don't kill the
    // shared Chrome out from under them. The browser will be cleaned up when
    // the last session releases or drainBrowserPool is called.
    if (pooledBrowserRefCount > 1) {
      pooledBrowserRefCount -= 1;
      return;
    }
    pooledBrowserRefCount = 0;
    pooledBrowser = null;
    _pooledBrowserLaunchPromise = null;
  }
  const proc = (
    browser as unknown as {
      process?: () => { kill: (signal?: NodeJS.Signals) => boolean; killed?: boolean } | null;
    }
  ).process?.();
  if (proc && !proc.killed) {
    try {
      proc.kill("SIGKILL");
    } catch {
      // Best-effort cleanup.
    }
  }
  try {
    browser.disconnect();
  } catch {
    // Best-effort cleanup.
  }
}

/**
 * Forcefully close the pooled browser if one exists, regardless of refCount.
 * Used for explicit cleanup at process exit or between independent render jobs
 * that should not share browser state.
 */
export async function drainBrowserPool(): Promise<void> {
  // Await any in-flight launch first — otherwise the launch resolves after we
  // drain and produces a browser that nobody references (orphan).
  const pending = _pooledBrowserLaunchPromise;
  _pooledBrowserLaunchPromise = null;
  if (pending) {
    await pending.then((r) => r.browser.close()).catch(() => {});
  }
  if (pooledBrowser) {
    const browser = pooledBrowser;
    pooledBrowser = null;
    pooledBrowserRefCount = 0;
    await browser.close().catch(() => {});
  }
}

/** Test-only: reset all pool state. */
export function _resetBrowserPoolForTests(): void {
  pooledBrowser = null;
  pooledBrowserRefCount = 0;
  pooledCaptureMode = "screenshot";
  _pooledBrowserLaunchPromise = null;
}

/** Test-only: inject a mock PuppeteerNode so tests bypass the dynamic import. */
export function _setPuppeteerForTests(mock: PuppeteerNode | undefined): void {
  _puppeteer = mock;
}

function getTotalMemMb(): number {
  return Math.floor(totalmem() / (1024 * 1024));
}

let _cachedVramMb: number | null = null;

function probeNvidiaVramMb(): number | null {
  if (_cachedVramMb !== null) return _cachedVramMb;
  try {
    // Synchronous, runs once per process (cached). ~50ms on typical systems.
    const out = execSync("nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits", {
      timeout: 3000,
      encoding: "utf-8",
      stdio: ["pipe", "pipe", "pipe"],
    }).trim();
    const mb = parseInt(out.split("\n")[0] ?? "", 10);
    if (Number.isFinite(mb) && mb > 0) {
      _cachedVramMb = mb;
      return mb;
    }
  } catch {
    // nvidia-smi not available or no NVIDIA GPU
  }
  return null;
}

function getGpuMemBudgetMb(): number {
  const vram = probeNvidiaVramMb();
  if (vram) return Math.min(vram, 16384);

  const total = getTotalMemMb();
  if (total < 4096) return 512;
  if (total < 8192) return 1024;
  return Math.min(Math.floor(total / 2), 16384);
}

function getLowMemoryFlags(): string[] {
  const total = getTotalMemMb();
  if (total >= 8192) return [];
  const heapMb = total < 4096 ? 256 : 512;
  return [`--js-flags=--max-old-space-size=${heapMb}`];
}

export interface BuildChromeArgsOptions {
  width: number;
  height: number;
  captureMode?: CaptureMode;
  platform?: NodeJS.Platform;
}

const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
const WEBGPU_FLAG = "--enable-unsafe-webgpu";

export function buildChromeArgs(
  options: BuildChromeArgsOptions,
  config?: Partial<Pick<EngineConfig, "browserGpuMode" | "disableGpu" | "chromePath">>,
): string[] {
  const platform = options.platform ?? process.platform;
  const gpuDisabled = config?.disableGpu ?? DEFAULT_CONFIG.disableGpu;
  const browserGpuMode = gpuDisabled
    ? "software"
    : (config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode);
  // Chrome flags tuned for headless rendering performance. The set below is a
  // fairly standard "headless-for-capture" configuration — similar profiles
  // appear in Puppeteer's defaults, Playwright, Remotion, and Chrome's own
  // headless-shell guidance.
  const chromeArgs = [
    "--no-sandbox",
    "--disable-setuid-sandbox",
    "--disable-dev-shm-usage",
    CANVAS_DRAW_ELEMENT_FEATURE_FLAG,
    "--enable-webgl",
    "--ignore-gpu-blocklist",
    ...getBrowserGpuArgs(browserGpuMode, platform),
    "--font-render-hinting=none",
    "--force-color-profile=srgb",
    `--window-size=${options.width},${options.height}`,
    // Prevent Chrome from throttling background tabs/timers — critical when the
    // page is offscreen during headless capture
    "--disable-background-timer-throttling",
    "--disable-backgrounding-occluded-windows",
    "--disable-renderer-backgrounding",
    "--disable-background-media-suspend",
    // Reduce overhead from unused Chrome features
    "--disable-breakpad",
    "--disable-component-extensions-with-background-pages",
    "--disable-default-apps",
    "--disable-extensions",
    "--disable-hang-monitor",
    "--disable-ipc-flooding-protection",
    "--disable-popup-blocking",
    "--disable-sync",
    "--disable-component-update",
    "--disable-domain-reliability",
    "--disable-print-preview",
    "--no-pings",
    "--no-zygote",
    // Memory — scale GPU budget to available system RAM
    `--force-gpu-mem-available-mb=${getGpuMemBudgetMb()}`,
    "--disk-cache-size=268435456",
    ...getLowMemoryFlags(),
    // Disable features that add overhead
    "--disable-features=AudioServiceOutOfProcess,IsolateOrigins,site-per-process,Translate,BackForwardCache,IntensiveWakeUpThrottling",
  ];

  if (browserGpuMode !== "software") {
    chromeArgs.push(WEBGPU_FLAG);
  }

  // BeginFrame flags — only when using chrome-headless-shell on Linux
  if (options.captureMode !== "screenshot") {
    chromeArgs.push(
      "--deterministic-mode",
      "--enable-begin-frame-control",
      "--disable-new-content-rendering-timeout",
      "--run-all-compositor-stages-before-draw",
      "--disable-threaded-animation",
      "--disable-threaded-scrolling",
      "--disable-checker-imaging",
      "--disable-image-animation-resync",
      "--enable-surface-synchronization",
    );
  }

  if (gpuDisabled) {
    chromeArgs.push("--disable-gpu");
  }
  return chromeArgs;
}

function getBrowserGpuArgs(
  mode: EngineConfig["browserGpuMode"],
  platform: NodeJS.Platform,
): string[] {
  if (mode === "software") {
    // Chrome 120+ deprecated implicit SwiftShader fallback; the explicit
    // path (--use-angle=swiftshader) keeps working but Chrome emits a
    // deprecation warning unless --enable-unsafe-swiftshader is also set.
    // Despite the name, this is exactly the behaviour Chrome had before;
    // the flag exists to make CPU rasterisation an explicit opt-in rather
    // than an implicit fallback for end users on the open web.
    return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"];
  }

  if (mode === "auto") {
    // Should not reach here — `resolveBrowserGpuMode` collapses "auto" to
    // "software" or "hardware" before args are built. Be defensive: software
    // is the always-safe fallback.
    return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"];
  }

  switch (platform) {
    case "darwin":
      return ["--use-gl=angle", "--use-angle=metal", "--enable-gpu-rasterization"];
    case "win32":
      return ["--use-gl=angle", "--use-angle=d3d11", "--enable-gpu-rasterization"];
    case "linux":
      return ["--use-gl=egl", "--enable-gpu-rasterization"];
    default:
      return ["--enable-gpu-rasterization"];
  }
}
