/**
 * ProcessManager Tests
 *
 * Tests for the process lifecycle manager.
 * Mocks execa to avoid spawning real processes.
 */

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { ProcessRegistry } from './registry.js';
import { ProcessManager } from './manager.js';
import type { EventBus, ProcessSpawnedEvent, ProcessStoppedEvent, ProcessCrashedEvent } from '../events/index.js';

// Mock execa module
vi.mock('execa', () => {
  return {
    execa: vi.fn(),
  };
});

// Import execa mock after mocking
import { execa } from 'execa';
const mockExeca = vi.mocked(execa);

describe('ProcessManager', () => {
  let registry: ProcessRegistry;
  let manager: ProcessManager;
  let mockSubprocess: {
    pid: number;
    on: ReturnType<typeof vi.fn>;
    kill: ReturnType<typeof vi.fn>;
    unref: ReturnType<typeof vi.fn>;
    catch: ReturnType<typeof vi.fn>;
    then: ReturnType<typeof vi.fn>;
  };
  let exitHandler: ((code: number | null, signal: string | null) => void) | null;

  beforeEach(() => {
    registry = new ProcessRegistry();
    manager = new ProcessManager(registry);
    exitHandler = null;

    // Create mock subprocess
    mockSubprocess = {
      pid: 12345,
      on: vi.fn((event: string, handler: (code: number | null, signal: string | null) => void) => {
        if (event === 'exit') {
          exitHandler = handler;
        }
      }),
      kill: vi.fn(),
      unref: vi.fn(),
      catch: vi.fn().mockReturnThis(),
      then: vi.fn().mockReturnThis(),
    };

    // Setup execa mock to return subprocess
    mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType<typeof execa>);
  });

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

  describe('spawn()', () => {
    it('should create process and register it', async () => {
      const info = await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: ['server.js'],
      });

      expect(info.id).toBe('proc-1');
      expect(info.pid).toBe(12345);
      expect(info.command).toBe('node');
      expect(info.args).toEqual(['server.js']);
      expect(info.status).toBe('running');
      expect(info.startedAt).toBeInstanceOf(Date);

      // Verify registered in registry
      expect(registry.get('proc-1')).toBe(info);
    });

    it('should call execa with correct arguments', async () => {
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: ['app.js', '--port', '3000'],
        cwd: '/app',
        env: { NODE_ENV: 'production' },
      });

      expect(mockExeca).toHaveBeenCalledWith('node', ['app.js', '--port', '3000'], {
        cwd: '/app',
        env: expect.objectContaining({ NODE_ENV: 'production' }),
        detached: true,
        stdio: 'ignore',
      });
    });

    it('should throw if process ID already running', async () => {
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      await expect(
        manager.spawn({
          id: 'proc-1',
          command: 'node',
          args: [],
        })
      ).rejects.toThrow("Process with id 'proc-1' is already running");
    });

    it('should throw if PID is undefined', async () => {
      mockSubprocess.pid = undefined as unknown as number;

      await expect(
        manager.spawn({
          id: 'proc-1',
          command: 'node',
          args: [],
        })
      ).rejects.toThrow("Failed to get PID for process 'proc-1'");
    });

    it('should set up exit handler that updates registry on normal exit', async () => {
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Simulate normal exit
      exitHandler?.(0, null);

      expect(registry.get('proc-1')?.status).toBe('stopped');
    });

    it('should mark as crashed on non-zero exit', async () => {
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Simulate crash (non-zero exit)
      exitHandler?.(1, null);

      expect(registry.get('proc-1')?.status).toBe('crashed');
    });
  });

  describe('stop()', () => {
    it('should terminate process', async () => {
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Make subprocess.then resolve immediately to simulate exit
      mockSubprocess.then.mockImplementation((cb: () => void) => {
        cb();
        return mockSubprocess;
      });

      await manager.stop('proc-1');

      expect(mockSubprocess.kill).toHaveBeenCalledWith('SIGTERM');
    });

    it('should throw if process not found', async () => {
      await expect(manager.stop('non-existent')).rejects.toThrow(
        "Process with id 'non-existent' not found"
      );
    });

    it('should not throw for already stopped process', async () => {
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Simulate process already stopped via exit handler
      exitHandler?.(0, null);

      // Should not throw
      await expect(manager.stop('proc-1')).resolves.toBeUndefined();
    });
  });

  describe('stopAll()', () => {
    it('should stop all running processes', async () => {
      // Create multiple mock subprocesses
      const mockSubprocess1 = { ...mockSubprocess, pid: 111 };
      const mockSubprocess2 = { ...mockSubprocess, pid: 222 };
      const mockSubprocess3 = { ...mockSubprocess, pid: 333 };

      let callCount = 0;
      mockExeca.mockImplementation(() => {
        callCount++;
        const sub = [mockSubprocess1, mockSubprocess2, mockSubprocess3][callCount - 1];
        sub.then = vi.fn().mockImplementation((cb: () => void) => {
          cb();
          return sub;
        });
        return sub as unknown as ReturnType<typeof execa>;
      });

      await manager.spawn({ id: 'proc-1', command: 'node', args: [] });
      await manager.spawn({ id: 'proc-2', command: 'node', args: [] });
      await manager.spawn({ id: 'proc-3', command: 'node', args: [] });

      await manager.stopAll();

      expect(mockSubprocess1.kill).toHaveBeenCalledWith('SIGTERM');
      expect(mockSubprocess2.kill).toHaveBeenCalledWith('SIGTERM');
      expect(mockSubprocess3.kill).toHaveBeenCalledWith('SIGTERM');
    });
  });

  describe('restart()', () => {
    it('should stop and respawn process', async () => {
      const mockSubprocess2 = { ...mockSubprocess, pid: 54321 };

      // First spawn returns original subprocess
      mockExeca.mockReturnValueOnce(mockSubprocess as unknown as ReturnType<typeof execa>);

      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: ['server.js'],
      });

      // Make stop work
      mockSubprocess.then.mockImplementation((cb: () => void) => {
        cb();
        return mockSubprocess;
      });

      // Second spawn (after restart) returns new subprocess
      mockExeca.mockReturnValueOnce(mockSubprocess2 as unknown as ReturnType<typeof execa>);

      const newInfo = await manager.restart('proc-1');

      expect(newInfo.id).toBe('proc-1');
      expect(newInfo.pid).toBe(54321);
      expect(newInfo.status).toBe('running');
    });

    it('should throw if process not found', async () => {
      await expect(manager.restart('non-existent')).rejects.toThrow(
        "Process with id 'non-existent' not found"
      );
    });
  });

  describe('isRunning()', () => {
    it('should return true for running process', async () => {
      // Mock process.kill to not throw (process exists)
      const originalKill = process.kill;
      vi.spyOn(process, 'kill').mockImplementation(() => true);

      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      expect(manager.isRunning('proc-1')).toBe(true);

      process.kill = originalKill;
    });

    it('should return false for non-existent process', () => {
      expect(manager.isRunning('non-existent')).toBe(false);
    });

    it('should return false and update status if process is dead', async () => {
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Mock process.kill to throw (process dead)
      vi.spyOn(process, 'kill').mockImplementation(() => {
        throw new Error('ESRCH');
      });

      expect(manager.isRunning('proc-1')).toBe(false);
      expect(registry.get('proc-1')?.status).toBe('crashed');
    });

    it('should return false for stopped process', async () => {
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Simulate exit
      exitHandler?.(0, null);

      expect(manager.isRunning('proc-1')).toBe(false);
    });
  });

  describe('event emission', () => {
    let mockEventBus: EventBus;
    let managerWithBus: ProcessManager;

    beforeEach(() => {
      mockEventBus = {
        emit: vi.fn(),
        on: vi.fn(),
        off: vi.fn(),
        once: vi.fn(),
      };
      managerWithBus = new ProcessManager(registry, mockEventBus);
    });

    it('should emit ProcessSpawned event on spawn', async () => {
      await managerWithBus.spawn({
        id: 'proc-1',
        command: 'node',
        args: ['server.js'],
      });

      expect(mockEventBus.emit).toHaveBeenCalledWith(
        expect.objectContaining({
          type: 'process:spawned',
          timestamp: expect.any(Date),
          payload: {
            processId: 'proc-1',
            pid: 12345,
            command: 'node',
          },
        })
      );
    });

    it('should emit ProcessStopped event on normal exit', async () => {
      await managerWithBus.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Clear spawn event
      vi.mocked(mockEventBus.emit).mockClear();

      // Simulate normal exit (code 0)
      exitHandler?.(0, null);

      expect(mockEventBus.emit).toHaveBeenCalledWith(
        expect.objectContaining({
          type: 'process:stopped',
          timestamp: expect.any(Date),
          payload: {
            processId: 'proc-1',
            pid: 12345,
            exitCode: 0,
          },
        })
      );
    });

    it('should emit ProcessCrashed event on non-zero exit', async () => {
      await managerWithBus.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Clear spawn event
      vi.mocked(mockEventBus.emit).mockClear();

      // Simulate crash (non-zero exit with signal)
      exitHandler?.(1, 'SIGTERM');

      expect(mockEventBus.emit).toHaveBeenCalledWith(
        expect.objectContaining({
          type: 'process:crashed',
          timestamp: expect.any(Date),
          payload: {
            processId: 'proc-1',
            pid: 12345,
            exitCode: 1,
            signal: 'SIGTERM',
          },
        })
      );
    });

    it('should not emit events if eventBus is not provided', async () => {
      // Use manager without event bus
      await manager.spawn({
        id: 'proc-1',
        command: 'node',
        args: [],
      });

      // Simulate exit
      exitHandler?.(0, null);

      // No errors should occur (events just don't get emitted)
      expect(mockEventBus.emit).not.toHaveBeenCalled();
    });
  });
});
