/**
 * Tests for Chrome debug port binding race condition fixes (Issue #65)
 *
 * Covers:
 * 1. waitForDebugPort() fast-fail when Chrome process exits prematurely
 * 2. isProfileLocked() stale SingletonLock detection via PID validation
 * 3. ensureChrome() retry window for existing Chrome detection
 */

import { execSync } from 'child_process';
import type { ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

// Override the global mock from tests/setup.ts that replaces ChromeLauncher
jest.unmock('../../src/chrome/launcher');

import { ChromeLauncher, waitForDebugPort, DebugPortTimeoutError } from '../../src/chrome/launcher';

jest.mock('child_process', () => {
  const actual = jest.requireActual('child_process');
  return {
    ...actual,
    execSync: jest.fn(),
    spawn: jest.fn(() => ({
      unref: jest.fn(),
      pid: 12345,
      exitCode: null,
      on: jest.fn(),
      once: jest.fn(),
    })),
  };
});

jest.mock('../../src/config/global', () => ({
  getGlobalConfig: () => ({}),
}));

const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;

describe('ChromeLauncher port race condition fixes', () => {
  let launcher: ChromeLauncher;
  let consoleErrorSpy: jest.SpyInstance;

  beforeEach(() => {
    launcher = new ChromeLauncher(9222);
    consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
    mockExecSync.mockReset();
  });

  afterEach(() => {
    consoleErrorSpy.mockRestore();
  });

  describe('isProfileLocked() stale lock detection', () => {
    it('should return false for stale SingletonLock with dead PID', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-test-'));
      const lockFile = path.join(tmpDir, 'SingletonLock');

      // Create a symlink mimicking Chrome's SingletonLock format: "hostname-pid"
      // Use PID 999999999 which almost certainly doesn't exist
      fs.symlinkSync(`${os.hostname()}-999999999`, lockFile);

      const result = (launcher as any).isProfileLocked(tmpDir);

      expect(result).toBe(false);
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        expect.stringContaining('Stale lock ignored')
      );

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should return true for SingletonLock with alive PID', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-test-'));
      const lockFile = path.join(tmpDir, 'SingletonLock');

      // Use current process PID (guaranteed alive)
      fs.symlinkSync(`${os.hostname()}-${process.pid}`, lockFile);

      const result = (launcher as any).isProfileLocked(tmpDir);

      expect(result).toBe(true);
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        expect.stringContaining('Profile locked')
      );

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should return false when no lock files exist', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-test-'));

      const result = (launcher as any).isProfileLocked(tmpDir);

      expect(result).toBe(false);

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should return true for non-symlink lock files (SingletonSocket)', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-test-'));
      // SingletonSocket is a regular Unix domain socket file, not a symlink
      fs.writeFileSync(path.join(tmpDir, 'SingletonSocket'), '');

      const result = (launcher as any).isProfileLocked(tmpDir);

      expect(result).toBe(true);

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should handle symlink with unparseable PID (assume locked)', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-test-'));
      const lockFile = path.join(tmpDir, 'SingletonLock');

      // Create symlink with invalid PID format
      fs.symlinkSync('not-a-valid-format', lockFile);

      const result = (launcher as any).isProfileLocked(tmpDir);

      // NaN PID → can't validate → falls through to "locked"
      expect(result).toBe(true);

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should ignore stale SingletonLock but detect live SingletonCookie', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-test-'));

      // SingletonLock with dead PID → stale, should be skipped
      fs.symlinkSync(`${os.hostname()}-999999999`, path.join(tmpDir, 'SingletonLock'));

      // SingletonCookie is a regular file → should be detected as locked
      fs.writeFileSync(path.join(tmpDir, 'SingletonCookie'), '');

      const result = (launcher as any).isProfileLocked(tmpDir);

      expect(result).toBe(true);
      // Should log stale lock being ignored AND the cookie lock being detected
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        expect.stringContaining('Stale lock ignored')
      );
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        expect.stringContaining('SingletonCookie')
      );

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should still work on Windows with lockfile', () => {
      // Only test on non-Windows (mock platform for Windows-specific path)
      if (os.platform() === 'win32') {
        const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-test-'));
        fs.writeFileSync(path.join(tmpDir, 'lockfile'), '');

        const result = (launcher as any).isProfileLocked(tmpDir);
        expect(result).toBe(true);

        fs.rmSync(tmpDir, { recursive: true, force: true });
      }
    });
  });

  describe('Windows isProfileLocked() process verification', () => {
    // isProfileLocked accepts an optional platformOverride parameter for testing,
    // since os.platform is a non-configurable getter that cannot be mocked.

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

    it('should return true when lockfile exists on Windows', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-test-'));
      fs.writeFileSync(path.join(tmpDir, 'lockfile'), '');

      const result = (launcher as any).isProfileLocked(tmpDir, 'win32');
      expect(result).toBe(true);
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        expect.stringContaining('Profile locked')
      );

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should return true when no lockfile but wmic finds chrome.exe with profile dir', () => {
      // Use real temp dir (no lockfile inside) so fs.existsSync naturally returns false
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-win-test-'));

      mockExecSync.mockImplementation((cmd: string) => {
        if (typeof cmd === 'string' && cmd.includes('wmic')) {
          return `CommandLine\nchrome.exe --user-data-dir="${tmpDir}" --flag\n`;
        }
        throw new Error('unexpected command');
      });

      const result = (launcher as any).isProfileLocked(tmpDir, 'win32');
      expect(result).toBe(true);
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        expect.stringContaining('chrome.exe running with')
      );

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should return false when no lockfile and no chrome.exe running', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-win-test-'));

      mockExecSync.mockImplementation((cmd: string) => {
        if (typeof cmd === 'string' && cmd.includes('wmic')) {
          return 'CommandLine\n\n';
        }
        throw new Error('unexpected command');
      });

      const result = (launcher as any).isProfileLocked(tmpDir, 'win32');
      expect(result).toBe(false);

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should fall back to PowerShell when wmic fails', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-win-test-'));

      mockExecSync.mockImplementation((cmd: string) => {
        if (typeof cmd === 'string' && cmd.includes('wmic')) {
          throw new Error('wmic not found');
        }
        if (typeof cmd === 'string' && cmd.includes('powershell')) {
          return `chrome.exe --user-data-dir="${tmpDir}" --type=renderer\n`;
        }
        throw new Error('unexpected command');
      });

      const result = (launcher as any).isProfileLocked(tmpDir, 'win32');
      expect(result).toBe(true);
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        expect.stringContaining('PowerShell')
      );

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should fall back to tasklist when both wmic and PowerShell fail', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-win-test-'));

      const getRealSpy = jest.spyOn(launcher as any, 'getRealChromeProfileDir')
        .mockReturnValue(tmpDir);

      mockExecSync.mockImplementation((cmd: string) => {
        if (typeof cmd === 'string' && cmd.includes('wmic')) {
          throw new Error('wmic not found');
        }
        if (typeof cmd === 'string' && cmd.includes('powershell')) {
          throw new Error('powershell restricted');
        }
        if (typeof cmd === 'string' && cmd.includes('tasklist')) {
          return 'chrome.exe                    1234 Console                    1    150,000 K\n';
        }
        throw new Error('unexpected command');
      });

      const result = (launcher as any).isProfileLocked(tmpDir, 'win32');
      expect(result).toBe(true);
      expect(consoleErrorSpy).toHaveBeenCalledWith(
        expect.stringContaining('Profile likely locked')
      );

      getRealSpy.mockRestore();
      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should return false from tasklist fallback when profileDir is not the default', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-win-test-'));
      const defaultDir = path.join(os.tmpdir(), 'oc-win-test-default-chrome');

      const getRealSpy = jest.spyOn(launcher as any, 'getRealChromeProfileDir')
        .mockReturnValue(defaultDir);

      mockExecSync.mockImplementation((cmd: string) => {
        if (typeof cmd === 'string' && cmd.includes('wmic')) {
          throw new Error('wmic not found');
        }
        if (typeof cmd === 'string' && cmd.includes('powershell')) {
          throw new Error('powershell restricted');
        }
        if (typeof cmd === 'string' && cmd.includes('tasklist')) {
          return 'chrome.exe                    1234 Console                    1    150,000 K\n';
        }
        throw new Error('unexpected command');
      });

      const result = (launcher as any).isProfileLocked(tmpDir, 'win32');
      // Chrome is running but profileDir is not the default — can't confirm lock
      expect(result).toBe(false);

      getRealSpy.mockRestore();
      fs.rmSync(tmpDir, { recursive: true, force: true });
    });

    it('should return false when all process checks fail', () => {
      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-win-test-'));

      mockExecSync.mockImplementation(() => {
        throw new Error('command not found');
      });

      const result = (launcher as any).isProfileLocked(tmpDir, 'win32');
      expect(result).toBe(false);

      fs.rmSync(tmpDir, { recursive: true, force: true });
    });
  });

  describe('ensureChrome() retry window', () => {
    it('should use retry window (not single-shot) for existing Chrome detection', async () => {
      // This test verifies that ensureChrome uses waitForDebugPort (5s retry)
      // instead of a single checkDebugPort call.
      // Since we can't easily mock the http module here, we verify the behavior
      // indirectly: either Chrome is found (resolved) or it throws after waiting.

      const startTime = Date.now();
      let threw = false;
      try {
        await launcher.ensureChrome({ autoLaunch: false });
        // Chrome was found on the debug port — that's also valid behavior
      } catch (e: any) {
        threw = true;
        // Should throw after the 5s retry window
        const elapsed = Date.now() - startTime;
        expect(elapsed).toBeGreaterThanOrEqual(2000); // At least 2s (generous lower bound to avoid CI flakiness)
        expect(e.message).toContain('Chrome is not running');
      }
      if (!threw) {
        // Chrome was already running — verify the instance was found
        expect(launcher.isConnected()).toBe(true);
      }
    }, 10000); // 10s timeout for this test
  });

  describe('waitForDebugPort() process death fast-fail', () => {
    it('fast-fails when Chrome is signal-terminated before debug port is ready', async () => {
      const fakeProcess = {
        exitCode: null,
        signalCode: 'SIGTERM',
      } as unknown as ChildProcess;

      let thrown: unknown;
      try {
        await waitForDebugPort(1, 5000, fakeProcess);
      } catch (err) {
        thrown = err;
      }

      expect(thrown).toBeDefined();
      expect(thrown).not.toBeInstanceOf(DebugPortTimeoutError);
      expect((thrown as Error).message).toContain('code null signal SIGTERM');
      expect((thrown as Error).message).toContain('debug port 1');
    }, 10_000);
  });
});
