import { afterEach, describe, it, expect, vi } from 'vitest';
import { BrowserBridge, generateStealthJs } from './browser/index.js';
import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
import { withTimeoutMs } from './runtime.js';
import { __test__ as cdpTest } from './browser/cdp.js';
import { classifyBrowserError } from './browser/errors.js';
import * as daemonTransport from './browser/daemon-transport.js';
import * as daemonLifecycle from './browser/daemon-lifecycle.js';

afterEach(() => {
  vi.restoreAllMocks();
});

describe('browser helpers', () => {
  it('extracts tab entries from string snapshots', () => {
    const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');

    expect(entries).toEqual([
      { index: 0, identity: 'https://example.com' },
      { index: 1, identity: 'Chrome Extension' },
    ]);
  });

  it('extracts tab entries from MCP markdown format', () => {
    const entries = extractTabEntries(
      '- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)'
    );

    expect(entries).toEqual([
      { index: 0, identity: '(current) [Playwright MCP extension](chrome-extension://abc/connect.html)' },
      { index: 1, identity: '[知乎 - 首页](https://www.zhihu.com/)' },
    ]);
  });

  it('closes only tabs that were opened during the session', () => {
    const tabsToClose = diffTabIndexes(
      ['https://example.com', 'Chrome Extension'],
      [
        { index: 0, identity: 'https://example.com' },
        { index: 1, identity: 'Chrome Extension' },
        { index: 2, identity: 'https://target.example/page' },
        { index: 3, identity: 'chrome-extension://bridge' },
      ],
    );

    expect(tabsToClose).toEqual([3, 2]);
  });

  it('keeps only the tail of stderr buffers', () => {
    expect(appendLimited('12345', '67890', 8)).toBe('34567890');
  });

  it('times out slow promises', async () => {
    await expect(withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
  });

  it('classifies browser errors with correct kind and retry advice', () => {
    // CDP target navigation — page-level settle retry
    const nav = classifyBrowserError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'));
    expect(nav.kind).toBe('target-navigation');
    expect(nav.delayMs).toBe(200);

    // Extension transient — daemon-client retry only, NOT page-level
    const ext = classifyBrowserError(new Error('Extension disconnected'));
    expect(ext.kind).toBe('extension-transient');
    expect(ext.delayMs).toBe(1500);

    // Non-transient errors — not retryable
    expect(classifyBrowserError(new Error('malformed exec payload')).kind).toBe('non-retryable');
    expect(classifyBrowserError(new Error('Permission denied')).kind).toBe('non-retryable');
  });

  it('prefers the real Electron app target over DevTools and blank pages', () => {
    const target = cdpTest.selectCDPTarget([
      {
        type: 'page',
        title: 'DevTools - localhost:9224',
        url: 'devtools://devtools/bundled/inspector.html',
        webSocketDebuggerUrl: 'ws://127.0.0.1:9224/devtools',
      },
      {
        type: 'page',
        title: '',
        url: 'about:blank',
        webSocketDebuggerUrl: 'ws://127.0.0.1:9224/blank',
      },
      {
        type: 'app',
        title: 'Antigravity',
        url: 'http://localhost:3000/',
        webSocketDebuggerUrl: 'ws://127.0.0.1:9224/app',
      },
    ]);

    expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9224/app');
  });

  it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
    vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');

    const target = cdpTest.selectCDPTarget([
      {
        type: 'app',
        title: 'Cursor',
        url: 'http://localhost:3000/cursor',
        webSocketDebuggerUrl: 'ws://127.0.0.1:9226/cursor',
      },
      {
        type: 'app',
        title: 'OpenAI Codex',
        url: 'http://localhost:3000/codex',
        webSocketDebuggerUrl: 'ws://127.0.0.1:9226/codex',
      },
    ]);

    expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9226/codex');
  });
});

describe('BrowserBridge state', () => {
  it('transitions to closed after close()', async () => {
    const bridge = new BrowserBridge();

    expect(bridge.state).toBe('idle');

    await bridge.close();

    expect(bridge.state).toBe('closed');
  });

  it('rejects connect() after the session has been closed', async () => {
    const bridge = new BrowserBridge();
    await bridge.close();

    await expect(bridge.connect()).rejects.toThrow('Session is closed');
  });

  it('rejects connect() while already connecting', async () => {
    const bridge = new BrowserBridge();
    (bridge as unknown as { _state: string })._state = 'connecting';

    await expect(bridge.connect()).rejects.toThrow('Already connecting');
  });

  it('rejects connect() while closing', async () => {
    const bridge = new BrowserBridge();
    (bridge as unknown as { _state: string })._state = 'closing';

    await expect(bridge.connect()).rejects.toThrow('Session is closing');
  });

  it('fails fast when daemon is running but extension is disconnected (same version)', async () => {
    const { PKG_VERSION } = await import('./version.js');
    vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
      state: 'no-extension',
      status: {
        ok: true,
        pid: 999999,
        uptime: 0,
        daemonVersion: PKG_VERSION,
        extensionConnected: false,
        pending: 0,
        memoryMB: 0,
        port: 0,
      },
    });

    const bridge = new BrowserBridge();

    await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
  });

  it('attempts stale daemon replacement when daemonVersion is missing', async () => {
    vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
      state: 'no-extension',
      status: {
        ok: true,
        pid: 999999,
        uptime: 0,
        extensionConnected: false,
        pending: 0,
        memoryMB: 0,
        port: 0,
      },
    });
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'requestDaemonShutdown').mockResolvedValue(false);
    // Keep the SIGKILL fallback's poll short — neither pid 999999 nor the test
    // daemon exists, so we'd otherwise spin for 2s on every test in the block.
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop').mockResolvedValue(false);

    const bridge = new BrowserBridge();

    await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
  });

  it('attempts stale daemon replacement when daemonVersion mismatches', async () => {
    vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
      state: 'no-extension',
      status: {
        ok: true,
        pid: 999999,
        uptime: 0,
        daemonVersion: '0.0.1',
        extensionConnected: false,
        pending: 0,
        memoryMB: 0,
        port: 0,
      },
    });
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'requestDaemonShutdown').mockResolvedValue(false);
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop').mockResolvedValue(false);

    const bridge = new BrowserBridge();

    await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
  });

  it('attempts stale daemon replacement even when extension is connected', async () => {
    vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
      state: 'ready',
      status: {
        ok: true,
        pid: 999999,
        uptime: 0,
        daemonVersion: '0.0.1',
        extensionConnected: true,
        pending: 0,
        memoryMB: 0,
        port: 0,
      },
    });
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'requestDaemonShutdown').mockResolvedValue(false);
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop').mockResolvedValue(false);

    const bridge = new BrowserBridge();

    await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
  });

  it('falls back to SIGKILL when stale daemon refuses graceful shutdown', async () => {
    vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
      state: 'no-extension',
      status: {
        ok: true,
        pid: 99999,
        uptime: 0,
        daemonVersion: '0.0.1',
        extensionConnected: false,
        pending: 0,
        memoryMB: 0,
        port: 0,
      },
    });
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'requestDaemonShutdown').mockResolvedValue(false);
    // Graceful shutdown short-circuits to false (requestDaemonShutdown -> false).
    // After SIGKILL the port is released, so the second waitForDaemonStop returns true.
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop').mockResolvedValue(true);
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'spawnDaemonProcess').mockReturnValue(null as unknown as ReturnType<typeof daemonLifecycle.spawnDaemonProcess>);
    const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);

    const bridge = new BrowserBridge();

    // We expect the stale-daemon path to succeed and then continue into the
    // no-extension wait (which times out with `timeout: 0.1`), producing the
    // extension-not-connected error rather than the stale-daemon error.
    await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
    expect(killSpy).toHaveBeenCalledWith(99999, 'SIGKILL');
  });

  it('reports stale daemon error when SIGKILL fails to release the port', async () => {
    vi.spyOn(daemonTransport, 'getDaemonHealth').mockResolvedValue({
      state: 'no-extension',
      status: {
        ok: true,
        pid: 99999,
        uptime: 0,
        daemonVersion: '0.0.1',
        extensionConnected: false,
        pending: 0,
        memoryMB: 0,
        port: 0,
      },
    });
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'requestDaemonShutdown').mockResolvedValue(false);
    // Graceful + SIGKILL both fail to release the port.
    vi.spyOn(daemonLifecycle.daemonLifecycleHooks, 'waitForDaemonStop').mockResolvedValue(false);
    vi.spyOn(process, 'kill').mockImplementation(() => {
      throw Object.assign(new Error('EPERM'), { code: 'EPERM' });
    });

    const bridge = new BrowserBridge();

    await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Stale daemon could not be replaced');
  });
});

describe('stealth anti-detection', () => {
  it('generates non-empty JS string', () => {
    const js = generateStealthJs();
    expect(typeof js).toBe('string');
    expect(js.length).toBeGreaterThan(100);
  });

  it('contains all 7 anti-detection patches', () => {
    const js = generateStealthJs();
    // 1. webdriver
    expect(js).toContain('navigator');
    expect(js).toContain('webdriver');
    // 2. chrome stub
    expect(js).toContain('window.chrome');
    // 3. plugins
    expect(js).toContain('plugins');
    expect(js).toContain('PDF Viewer');
    // 4. languages
    expect(js).toContain('languages');
    // 5. permissions
    expect(js).toContain('Permissions');
    expect(js).toContain('notifications');
    // 6. automation artifacts (dynamic cdc_ scan)
    expect(js).toContain('__playwright');
    expect(js).toContain('__puppeteer');
    expect(js).toContain('getOwnPropertyNames');
    expect(js).toContain('cdc_');
    // 7. CDP stack trace cleanup
    expect(js).toContain('Error.prototype');
    expect(js).toContain('puppeteer_evaluation_script');
    expect(js).toContain('getOwnPropertyDescriptor');
  });

  it('includes guard flag to prevent double-injection', () => {
    const js = generateStealthJs();
    // Guard uses a non-enumerable property on a built-in prototype
    expect(js).toContain("EventTarget.prototype");
    // Guard should check early and return 'skipped'
    expect(js).toContain("return 'skipped'");
    // Normal path returns 'applied'
    expect(js).toContain("return 'applied'");
  });

  it('generates syntactically valid JS', () => {
    const js = generateStealthJs();
    // Should not throw when parsed
    expect(() => new Function(js)).not.toThrow();
  });
});
