import { beforeEach, describe, expect, it, vi } from 'vitest';

const { realpathMock } = vi.hoisted(() => ({
  realpathMock: vi.fn(async (value: string) => value),
}));

// Mock dependencies before importing service
vi.mock('@main/utils/childProcess', async (importOriginal) => {
  const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
  return {
    ...actual,
    execCli: vi.fn().mockRejectedValue(new Error('execCli not configured')),
  };
});

vi.mock('fs', async (importOriginal) => {
  const actual = await importOriginal<typeof import('fs')>();
  return {
    ...actual,
    existsSync: vi.fn(() => false),
    createWriteStream: vi.fn(() => ({
      write: vi.fn(),
      end: vi.fn((cb: () => void) => cb()),
      destroy: vi.fn(),
      on: vi.fn(),
    })),
    promises: {
      ...actual.promises,
      chmod: vi.fn(),
      realpath: realpathMock,
      unlink: vi.fn(),
    },
  };
});

vi.mock('https', async (importOriginal) => {
  const actual = await importOriginal<typeof import('https')>();
  return {
    ...actual,
    default: {
      ...actual,
      get: vi.fn(),
    },
  };
});

vi.mock('http', async (importOriginal) => {
  const actual = await importOriginal<typeof import('http')>();
  return {
    ...actual,
    default: {
      ...actual,
      get: vi.fn(),
    },
  };
});

vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
  ClaudeBinaryResolver: {
    resolve: vi.fn(),
    clearCache: vi.fn(),
  },
}));

vi.mock('@main/services/team/cliFlavor', () => ({
  getConfiguredCliFlavor: vi.fn(() => 'claude'),
  getCliFlavorUiOptions: vi.fn(() => ({
    displayName: 'Claude CLI',
    supportsSelfUpdate: true,
    showVersionDetails: true,
    showBinaryPath: true,
  })),
}));

vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
  buildProviderAwareCliEnv: vi.fn(async () => ({
    env: { HOME: '/Users/tester' },
    connectionIssues: {},
  })),
}));

import {
  CliInstallerService,
  isVersionOlder,
  normalizeVersion,
} from '@main/services/infrastructure/CliInstallerService';
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor';
import { execCli } from '@main/utils/childProcess';

/**
 * Helper: allow expected console.error/warn calls in tests where service logs errors.
 * The test setup asserts no unexpected console.error/warn, so we re-spy to capture them.
 */
function allowConsoleLogs(): void {
  vi.spyOn(console, 'error').mockImplementation(() => {});
  vi.spyOn(console, 'warn').mockImplementation(() => {});
}

describe('CliInstallerService', () => {
  let service: CliInstallerService;

  beforeEach(() => {
    vi.clearAllMocks();
    realpathMock.mockReset();
    realpathMock.mockImplementation(async (value: string) => value);
    vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude');
    vi.mocked(getCliFlavorUiOptions).mockReturnValue({
      displayName: 'Claude CLI',
      supportsSelfUpdate: true,
      showVersionDetails: true,
      showBinaryPath: true,
    });
    service = new CliInstallerService();
  });

  describe('getStatus', () => {
    it('returns not installed when binary is not found', async () => {
      allowConsoleLogs();
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(null);

      const status = await service.getStatus();

      expect(status.installed).toBe(false);
      expect(status.installedVersion).toBeNull();
      expect(status.binaryPath).toBeNull();
      expect(status.updateAvailable).toBe(false);
    });

    it('includes frontend-visible providers in unavailable multimodel bootstrap status', async () => {
      allowConsoleLogs();
      vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
      vi.mocked(getCliFlavorUiOptions).mockReturnValue({
        displayName: 'agent_teams_orchestrator',
        supportsSelfUpdate: false,
        showVersionDetails: false,
        showBinaryPath: false,
      });
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(null);

      const status = await service.getStatus();
      const openCodeStatus = status.providers.find(
        (provider) => provider.providerId === 'opencode'
      );

      expect(status.providers.map((provider) => provider.providerId)).toEqual([
        'anthropic',
        'codex',
        'opencode',
      ]);
      expect(openCodeStatus).toMatchObject({
        displayName: 'OpenCode (200+ models)',
        supported: false,
        statusMessage: 'Runtime not found.',
        canLoginFromUi: false,
      });
    });

    it('does not expose hidden Gemini in frontend multimodel authentication snapshots', async () => {
      allowConsoleLogs();
      vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
      vi.mocked(getCliFlavorUiOptions).mockReturnValue({
        displayName: 'agent_teams_orchestrator',
        supportsSelfUpdate: false,
        showVersionDetails: false,
        showBinaryPath: false,
      });
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
      vi.mocked(execCli).mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' });

      const providers = [
        {
          providerId: 'anthropic',
          displayName: 'Anthropic',
          supported: true,
          authenticated: false,
          authMethod: null,
          verificationState: 'unknown',
          modelVerificationState: 'idle',
          statusMessage: null,
          models: [],
          modelAvailability: [],
          canLoginFromUi: true,
          capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
          backend: null,
        },
        {
          providerId: 'codex',
          displayName: 'Codex',
          supported: true,
          authenticated: false,
          authMethod: null,
          verificationState: 'unknown',
          modelVerificationState: 'idle',
          statusMessage: null,
          models: [],
          modelAvailability: [],
          canLoginFromUi: true,
          capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
          backend: null,
        },
        {
          providerId: 'gemini',
          displayName: 'Gemini',
          supported: true,
          authenticated: true,
          authMethod: 'gemini_api_key',
          verificationState: 'verified',
          modelVerificationState: 'idle',
          statusMessage: null,
          models: ['gemini-2.5-pro'],
          modelAvailability: [],
          canLoginFromUi: true,
          capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
          backend: { kind: 'api', label: 'Gemini API' },
        },
        {
          providerId: 'opencode',
          displayName: 'OpenCode',
          supported: true,
          authenticated: false,
          authMethod: null,
          verificationState: 'unknown',
          modelVerificationState: 'idle',
          statusMessage: null,
          models: [],
          modelAvailability: [],
          canLoginFromUi: false,
          capabilities: { teamLaunch: true, oneShot: false, extensions: undefined as never },
          backend: null,
        },
      ];
      vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation(
        async (_binaryPath, onUpdate) => {
          onUpdate?.(providers as never);
          return providers as never;
        }
      );

      const status = await service.getStatus();

      expect(status.providers.map((provider) => provider.providerId)).toEqual([
        'anthropic',
        'codex',
        'opencode',
      ]);
      expect(status.authLoggedIn).toBe(false);
      expect(status.authMethod).toBeNull();
      expect(
        service
          .getLatestStatusSnapshot()
          ?.providers.some((provider) => provider.providerId === 'gemini')
      ).toBe(false);
      expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false);
    });

    it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
      allowConsoleLogs();
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

      const status = await service.getStatus();

      expect(status.installed).toBe(false);
      expect(status.binaryPath).toBe('/usr/local/bin/claude');
      expect(status.installedVersion).toBeNull();
    });

    it('retries the version probe once before marking the runtime unhealthy', async () => {
      allowConsoleLogs();
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
      vi.mocked(execCli)
        .mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'))
        .mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
        .mockResolvedValueOnce({
          stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
          stderr: '',
        });

      const status = await service.getStatus();

      expect(status.installed).toBe(true);
      expect(status.installedVersion).toBe('2.3.4');
      expect(execCli).toHaveBeenNthCalledWith(
        1,
        '/usr/local/bin/claude',
        ['--version'],
        expect.objectContaining({ timeout: expect.any(Number) })
      );
      expect(execCli).toHaveBeenNthCalledWith(
        2,
        '/usr/local/bin/claude',
        ['--version'],
        expect.objectContaining({ timeout: expect.any(Number) })
      );
    });

    it('reuses the last healthy runtime snapshot when a later version probe fails transiently', async () => {
      allowConsoleLogs();
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
      vi.mocked(execCli)
        .mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
        .mockResolvedValueOnce({
          stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
          stderr: '',
        })
        .mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'))
        .mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'));

      const firstStatus = await service.getStatus();
      const secondStatus = await service.getStatus();

      expect(firstStatus.installed).toBe(true);
      expect(firstStatus.installedVersion).toBe('2.3.4');
      expect(secondStatus.installed).toBe(true);
      expect(secondStatus.installedVersion).toBe('2.3.4');
      expect(secondStatus.launchError).toBeNull();
    });

    it('handles spawn EINVAL when binary path contains non-ASCII by falling back', async () => {
      allowConsoleLogs();
      const fakePath = 'C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd';
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(fakePath);

      // execCli handles the EINVAL → shell fallback internally;
      // here we just verify the service delegates to execCli correctly.
      vi.mocked(execCli)
        .mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' }) // --version
        .mockResolvedValueOnce({ stdout: '{}', stderr: '' }); // auth status

      const status = await service.getStatus();
      expect(status.installed).toBe(true);
      expect(status.installedVersion).toBe('2.3.4');
      expect(execCli).toHaveBeenCalledWith(
        fakePath,
        ['--version'],
        expect.objectContaining({ timeout: expect.any(Number) })
      );
    });

    it('treats auth as logged in when JSON is embedded after stdout noise', async () => {
      allowConsoleLogs();
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
      vi.mocked(execCli)
        .mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
        .mockResolvedValueOnce({
          stdout: 'notice: something\n{"loggedIn":true,"authMethod":"oauth_token"}\n',
          stderr: '',
        });

      const status = await service.getStatus();
      expect(status.authLoggedIn).toBe(true);
      expect(status.authMethod).toBe('oauth_token');
    });

    it('falls back to the installed launcher path when --version reports unknown', async () => {
      allowConsoleLogs();
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/Users/tester/.local/bin/claude');
      vi.spyOn(service as never, 'inferInstalledCliVersionFromPath').mockResolvedValue('2.1.101');
      vi.mocked(execCli)
        .mockResolvedValueOnce({ stdout: 'unknown', stderr: '' })
        .mockResolvedValueOnce({
          stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
          stderr: '',
        });

      const status = await service.getStatus();

      expect(status.installed).toBe(true);
      expect(status.installedVersion).toBe('2.1.101');
      expect(status.authLoggedIn).toBe(true);
    });

    it('publishes probe-enriched runtime model status snapshots only for explicit verification requests', async () => {
      allowConsoleLogs();
      vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
      vi.mocked(getCliFlavorUiOptions).mockReturnValue({
        displayName: 'agent_teams_orchestrator',
        supportsSelfUpdate: false,
        showVersionDetails: false,
        showBinaryPath: false,
      });
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

      vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation(
        async (_binaryPath, onUpdate) => {
          const providers = [
            {
              providerId: 'anthropic',
              displayName: 'Anthropic',
              supported: true,
              authenticated: true,
              authMethod: 'oauth_token',
              verificationState: 'verified',
              modelVerificationState: 'idle',
              statusMessage: null,
              models: [],
              modelAvailability: [],
              canLoginFromUi: true,
              capabilities: { teamLaunch: true, oneShot: true },
              backend: null,
            },
            {
              providerId: 'codex',
              displayName: 'Codex',
              supported: true,
              authenticated: true,
              authMethod: 'oauth_token',
              verificationState: 'verified',
              modelVerificationState: 'idle',
              statusMessage: null,
              models: ['gpt-5.4', 'gpt-5.4-mini'],
              modelAvailability: [],
              canLoginFromUi: true,
              capabilities: { teamLaunch: true, oneShot: true },
              backend: {
                kind: 'openai',
                label: 'OpenAI',
                endpointLabel: 'chatgpt.com/backend-api/codex/responses',
              },
            },
            {
              providerId: 'gemini',
              displayName: 'Gemini',
              supported: false,
              authenticated: false,
              authMethod: null,
              verificationState: 'unknown',
              modelVerificationState: 'idle',
              statusMessage: null,
              models: [],
              modelAvailability: [],
              canLoginFromUi: true,
              capabilities: { teamLaunch: false, oneShot: false },
              backend: null,
            },
          ];
          onUpdate?.(providers as never);
          return providers as never;
        }
      );

      vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
        const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
        if (normalizedArgs === '--version') {
          return { stdout: '2.3.4', stderr: '' };
        }
        if (normalizedArgs.includes('--model gpt-5.4-mini')) {
          throw new Error("The 'gpt-5.4-mini' model is not supported in this Codex runtime.");
        }
        if (normalizedArgs.includes('--model gpt-5.4')) {
          return { stdout: 'PONG', stderr: '' };
        }
        throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
      });

      const mockWindow = {
        isDestroyed: () => false,
        webContents: { send: vi.fn(), isDestroyed: () => false },
      };
      service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);

      const status = await service.getStatus();
      expect(
        status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability
      ).toEqual([]);

      const verifiedProvider = await service.verifyProviderModels('codex');
      expect(verifiedProvider?.modelAvailability).toEqual(
        expect.arrayContaining([
          expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }),
          expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }),
        ])
      );
      expect(verifiedProvider?.modelAvailability).not.toEqual(
        expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
      );

      await vi.waitFor(() => {
        const latestCodexProvider = service
          .getLatestStatusSnapshot()
          ?.providers.find((provider) => provider.providerId === 'codex');

        expect(latestCodexProvider?.modelAvailability).toEqual([
          expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }),
          expect.objectContaining({
            modelId: 'gpt-5.4-mini',
            status: 'unavailable',
          }),
        ]);
      });

      expect(execCli).not.toHaveBeenCalledWith(
        '/usr/local/bin/claude',
        expect.arrayContaining(['--model', 'gpt-5.2-codex']),
        expect.anything()
      );

      const statusEvents = mockWindow.webContents.send.mock.calls
        .filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
        .map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
        .filter((event) => event.type === 'status');

      expect(statusEvents.length).toBeGreaterThan(1);
      expect(
        statusEvents.some((event) =>
          event.status?.providers?.some(
            (provider) =>
              typeof provider === 'object' &&
              provider !== null &&
              'providerId' in provider &&
              'modelAvailability' in provider &&
              (provider as { providerId?: string }).providerId === 'codex' &&
              Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
              (
                provider as { modelAvailability: Array<{ modelId?: string; status?: string }> }
              ).modelAvailability.some(
                (item) => item.modelId === 'gpt-5.4' && item.status === 'available'
              )
          )
        )
      ).toBe(true);
      expect(
        statusEvents.some((event) =>
          event.status?.providers?.some(
            (provider) =>
              typeof provider === 'object' &&
              provider !== null &&
              'providerId' in provider &&
              'modelAvailability' in provider &&
              (provider as { providerId?: string }).providerId === 'codex' &&
              Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
              (
                provider as { modelAvailability: Array<{ modelId?: string }> }
              ).modelAvailability.some((item) => item.modelId === 'gpt-5.2-codex')
          )
        )
      ).toBe(false);
    });

    it('keeps OpenCode provider verification catalog-only for explicit verify requests', async () => {
      allowConsoleLogs();
      vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
      vi.mocked(getCliFlavorUiOptions).mockReturnValue({
        displayName: 'agent_teams_orchestrator',
        supportsSelfUpdate: false,
        showVersionDetails: false,
        showBinaryPath: false,
      });
      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

      vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockResolvedValue([
        {
          providerId: 'anthropic',
          displayName: 'Anthropic',
          supported: true,
          authenticated: true,
          authMethod: 'oauth_token',
          verificationState: 'verified',
          modelVerificationState: 'idle',
          statusMessage: null,
          detailMessage: null,
          models: [],
          modelAvailability: [],
          canLoginFromUi: true,
          capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
          selectedBackendId: null,
          resolvedBackendId: null,
          availableBackends: [],
          externalRuntimeDiagnostics: [],
          backend: null,
          connection: null,
        },
        {
          providerId: 'codex',
          displayName: 'Codex',
          supported: false,
          authenticated: false,
          authMethod: null,
          verificationState: 'unknown',
          modelVerificationState: 'idle',
          statusMessage: null,
          detailMessage: null,
          models: [],
          modelAvailability: [],
          canLoginFromUi: true,
          capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
          selectedBackendId: null,
          resolvedBackendId: null,
          availableBackends: [],
          externalRuntimeDiagnostics: [],
          backend: null,
          connection: null,
        },
        {
          providerId: 'gemini',
          displayName: 'Gemini',
          supported: false,
          authenticated: false,
          authMethod: null,
          verificationState: 'unknown',
          modelVerificationState: 'idle',
          statusMessage: null,
          detailMessage: null,
          models: [],
          modelAvailability: [],
          canLoginFromUi: true,
          capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
          selectedBackendId: null,
          resolvedBackendId: null,
          availableBackends: [],
          externalRuntimeDiagnostics: [],
          backend: null,
          connection: null,
        },
        {
          providerId: 'opencode',
          displayName: 'OpenCode',
          supported: true,
          authenticated: true,
          authMethod: 'opencode_managed',
          verificationState: 'verified',
          modelVerificationState: 'idle',
          statusMessage: null,
          detailMessage: null,
          models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
          modelAvailability: [],
          canLoginFromUi: false,
          capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
          selectedBackendId: null,
          resolvedBackendId: null,
          availableBackends: [],
          externalRuntimeDiagnostics: [],
          backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
          connection: null,
        },
      ] as never);

      vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'verifyProviderStatus').mockResolvedValue({
        providerId: 'opencode',
        displayName: 'OpenCode',
        supported: true,
        authenticated: true,
        authMethod: 'opencode_managed',
        verificationState: 'verified',
        modelVerificationState: 'idle',
        statusMessage: null,
        detailMessage: null,
        models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
        modelAvailability: [],
        canLoginFromUi: false,
        capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
        selectedBackendId: null,
        resolvedBackendId: null,
        availableBackends: [],
        externalRuntimeDiagnostics: [],
        backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
        connection: null,
      } as never);

      const verifyOpenCodeModelsSpy = vi
        .spyOn(ClaudeMultimodelBridgeService.prototype, 'verifyOpenCodeModels')
        .mockResolvedValue({
          providerId: 'opencode',
          displayName: 'OpenCode',
          supported: true,
          authenticated: true,
          authMethod: 'opencode_managed',
          verificationState: 'verified',
          modelVerificationState: 'verified',
          statusMessage: null,
          detailMessage: null,
          models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
          modelAvailability: [
            {
              modelId: 'openai/gpt-5.4-mini',
              status: 'unavailable',
              reason: 'Token refresh failed: 401',
            },
            {
              modelId: 'opencode/big-pickle',
              status: 'available',
              reason: null,
            },
          ],
          canLoginFromUi: false,
          capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
          selectedBackendId: null,
          resolvedBackendId: null,
          availableBackends: [],
          externalRuntimeDiagnostics: [],
          backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
          connection: null,
        } as never);

      vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
        const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
        if (normalizedArgs === '--version') {
          return { stdout: '2.3.4', stderr: '' };
        }
        throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
      });

      const status = await service.getStatus();
      expect(
        status.providers.find((provider) => provider.providerId === 'opencode')?.modelAvailability
      ).toEqual([]);

      const verifiedProvider = await service.verifyProviderModels('opencode');

      expect(verifyOpenCodeModelsSpy).not.toHaveBeenCalled();
      expect(verifiedProvider?.modelVerificationState).toBe('idle');
      expect(verifiedProvider?.modelAvailability).toEqual([]);
    });
  });

  describe('install mutex', () => {
    it('prevents concurrent installations', async () => {
      allowConsoleLogs();

      const mockWindow = {
        isDestroyed: () => false,
        webContents: { send: vi.fn(), isDestroyed: () => false },
      };
      service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);

      // Start first install (will fail on fetch — that's fine for mutex test)
      const promise1 = service.install();
      // Start second install immediately — should get "already in progress"
      const promise2 = service.install();

      await Promise.allSettled([promise1, promise2]);

      // Second call should send "already in progress" error
      const progressCalls = mockWindow.webContents.send.mock.calls;
      const errorCalls = progressCalls.filter(
        (call: unknown[]) =>
          (call[0] as string) === 'cliInstaller:progress' &&
          (call[1] as { type: string; error?: string }).type === 'error' &&
          (call[1] as { type: string; error?: string }).error?.includes('already in progress')
      );
      expect(errorCalls.length).toBeGreaterThanOrEqual(1);
    });

    it('resets mutex after install completes (even on failure)', async () => {
      allowConsoleLogs();

      const mockWindow = {
        isDestroyed: () => false,
        webContents: { send: vi.fn(), isDestroyed: () => false },
      };
      service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);

      // First install will fail (no network mock)
      await service.install();

      // After failure, mutex should be released — second install should start checking
      mockWindow.webContents.send.mockClear();
      await service.install();

      const checkingCalls = mockWindow.webContents.send.mock.calls.filter(
        (call: unknown[]) =>
          (call[0] as string) === 'cliInstaller:progress' &&
          (call[1] as { type: string }).type === 'checking'
      );
      expect(checkingCalls.length).toBeGreaterThanOrEqual(1);
    });
  });

  describe('setMainWindow', () => {
    it('accepts null to clear window reference', () => {
      service.setMainWindow(null);
      expect(true).toBe(true);
    });

    it('accepts a BrowserWindow instance', () => {
      const mockWindow = {
        isDestroyed: () => false,
        webContents: { send: vi.fn(), isDestroyed: () => false },
      };
      service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
      expect(true).toBe(true);
    });
  });

  describe('normalizeVersion', () => {
    it('extracts semver from "claude --version" output', () => {
      expect(normalizeVersion('2.1.34 (Claude Code)\n')).toBe('2.1.34');
      expect(normalizeVersion('2.1.59 (Claude Code)')).toBe('2.1.59');
    });

    it('handles plain version strings', () => {
      expect(normalizeVersion('2.1.59')).toBe('2.1.59');
      expect(normalizeVersion('  2.1.59  ')).toBe('2.1.59');
    });

    it('strips v prefix', () => {
      expect(normalizeVersion('v2.1.59')).toBe('2.1.59');
      expect(normalizeVersion('v2.1.59\n')).toBe('2.1.59');
    });

    it('returns trimmed input when no semver found', () => {
      expect(normalizeVersion('unknown')).toBe('unknown');
      expect(normalizeVersion('  beta  ')).toBe('beta');
    });
  });

  describe('isVersionOlder', () => {
    it('returns true when installed is older', () => {
      expect(isVersionOlder('2.1.34', '2.1.59')).toBe(true);
      expect(isVersionOlder('1.0.0', '2.0.0')).toBe(true);
      expect(isVersionOlder('2.0.0', '2.1.0')).toBe(true);
      expect(isVersionOlder('2.1.0', '2.1.1')).toBe(true);
    });

    it('returns false when versions are equal', () => {
      expect(isVersionOlder('2.1.59', '2.1.59')).toBe(false);
      expect(isVersionOlder('1.0.0', '1.0.0')).toBe(false);
    });

    it('returns false when installed is newer', () => {
      expect(isVersionOlder('2.1.59', '2.1.34')).toBe(false);
      expect(isVersionOlder('3.0.0', '2.9.99')).toBe(false);
      expect(isVersionOlder('2.2.0', '2.1.59')).toBe(false);
    });

    it('handles numeric comparison correctly (not lexicographic)', () => {
      // "2.10.0" > "2.9.0" numerically (but "10" < "9" lexicographically)
      expect(isVersionOlder('2.9.0', '2.10.0')).toBe(true);
      expect(isVersionOlder('2.10.0', '2.9.0')).toBe(false);
    });

    it('handles different segment counts', () => {
      expect(isVersionOlder('2.1', '2.1.1')).toBe(true);
      expect(isVersionOlder('2.1.1', '2.1')).toBe(false);
      expect(isVersionOlder('2.1', '2.1.0')).toBe(false); // 2.1 == 2.1.0
    });
  });

  describe('getStatus timeout', () => {
    it('returns partial result when gatherStatus hangs', async () => {
      allowConsoleLogs();
      vi.useFakeTimers();

      // ClaudeBinaryResolver.resolve() never settles — simulates thread pool exhaustion
      vi.mocked(ClaudeBinaryResolver.resolve).mockReturnValue(new Promise(() => {}));

      const statusPromise = service.getStatus();

      // Advance past GET_STATUS_TIMEOUT_MS (30s)
      await vi.advanceTimersByTimeAsync(31_000);

      const status = await statusPromise;

      // Should return the default (partial) result — not hang forever
      expect(status.installed).toBe(false);
      expect(status.installedVersion).toBeNull();
      expect(status.binaryPath).toBeNull();

      vi.useRealTimers();
    });

    it('returns full result when gatherStatus completes before timeout', async () => {
      allowConsoleLogs();

      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
      vi.mocked(execCli)
        .mockResolvedValueOnce({ stdout: '2.5.0 (Claude Code)', stderr: '' })
        .mockResolvedValueOnce({
          stdout: '{"loggedIn":true,"authMethod":"api_key"}',
          stderr: '',
        });

      const status = await service.getStatus();

      expect(status.installed).toBe(true);
      expect(status.installedVersion).toBe('2.5.0');
      expect(status.authLoggedIn).toBe(true);
      expect(status.authMethod).toBe('api_key');
    });
  });

  describe('auth parallelism', () => {
    let httpsGet: ReturnType<typeof vi.fn>;

    beforeEach(async () => {
      // Reset execCli mock queue (clearAllMocks doesn't clear mockResolvedValueOnce queue)
      vi.mocked(execCli).mockReset();
      vi.mocked(execCli).mockRejectedValue(new Error('execCli not configured'));

      // Get reference to the mocked https.get for per-test control
      const httpsModule = await import('https');
      httpsGet = vi.mocked(httpsModule.default.get);
    });

    afterEach(() => {
      // Reset https.get so it doesn't leak into subsequent test groups
      httpsGet.mockReset();
      vi.useRealTimers();
    });

    it('auth is not blocked by slow GCS fetch', async () => {
      allowConsoleLogs();
      vi.useFakeTimers();

      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

      // --version resolves immediately, auth resolves immediately
      vi.mocked(execCli)
        .mockResolvedValueOnce({ stdout: '2.5.0 (Claude Code)', stderr: '' })
        .mockResolvedValueOnce({
          stdout: '{"loggedIn":true,"authMethod":"api_key"}',
          stderr: '',
        });

      // GCS never responds — simulates slow/hanging network.
      // Returns proper req-like object so httpsGetFollowRedirects doesn't crash,
      // but never fires the response callback.
      httpsGet.mockImplementation(() => ({
        setTimeout: vi.fn(),
        on: vi.fn(),
        destroy: vi.fn(),
      }));

      const statusPromise = service.getStatus();

      // Advance past GET_STATUS_TIMEOUT_MS (30s) — GCS still hanging,
      // but auth already wrote its result to `r` directly
      await vi.advanceTimersByTimeAsync(31_000);

      const status = await statusPromise;

      // Auth succeeded even though GCS is hanging
      expect(status.authLoggedIn).toBe(true);
      expect(status.authMethod).toBe('api_key');
      expect(status.installed).toBe(true);
      expect(status.installedVersion).toBe('2.5.0');
    });

    it('auth retry works when first attempt fails', async () => {
      allowConsoleLogs();
      vi.useFakeTimers();

      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

      // --version ok, auth attempt 1 fails, auth attempt 2 succeeds
      vi.mocked(execCli)
        .mockResolvedValueOnce({ stdout: '2.5.0', stderr: '' })
        .mockRejectedValueOnce(new Error('ENOENT stale lock'))
        .mockResolvedValueOnce({
          stdout: '{"loggedIn":true,"authMethod":"oauth"}',
          stderr: '',
        });

      const statusPromise = service.getStatus();

      // Advance past retry delay (1.5s) + auth timeout + outer timeout
      await vi.advanceTimersByTimeAsync(31_000);

      const status = await statusPromise;

      expect(status.authLoggedIn).toBe(true);
      expect(status.authMethod).toBe('oauth');
    });

    it('auth times out independently when both attempts hang', async () => {
      allowConsoleLogs();
      vi.useFakeTimers();

      vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

      // --version ok, auth hangs forever (never resolves)
      vi.mocked(execCli)
        .mockResolvedValueOnce({ stdout: '2.5.0', stderr: '' })
        .mockReturnValue(new Promise(() => {}));

      const statusPromise = service.getStatus();

      // Advance past AUTH_TOTAL_TIMEOUT_MS (15s) and GET_STATUS_TIMEOUT_MS (30s)
      await vi.advanceTimersByTimeAsync(31_000);

      const status = await statusPromise;

      // Auth timed out independently → stays false
      expect(status.authLoggedIn).toBe(false);
      expect(status.authMethod).toBeNull();
      // Version was populated before auth started
      expect(status.installedVersion).toBe('2.5.0');
    });
  });

  describe('sendProgress with destroyed window', () => {
    it('does not throw when window is destroyed', async () => {
      allowConsoleLogs();

      const mockWindow = {
        isDestroyed: () => true,
        webContents: { send: vi.fn(), isDestroyed: () => true },
      };
      service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);

      // install() triggers sendProgress — should not throw even with destroyed window
      await service.install();

      // send should NOT have been called because window is destroyed
      expect(mockWindow.webContents.send).not.toHaveBeenCalled();
    });
  });
});
