/**
 * MultiProviderAgentManager Tests
 *
 * Unit tests for the MultiProviderAgentManager adapter.
 * Mocks child_process.spawn since we can't spawn real Claude CLI in tests.
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MultiProviderAgentManager } from './manager.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import { EventEmitterBus } from '../events/index.js';
import type { DomainEvent } from '../events/index.js';

// Mock child_process.spawn and execFile
vi.mock('node:child_process', () => ({
  spawn: vi.fn(),
  execFile: vi.fn((_cmd: string, _args: string[], _opts: unknown, cb?: Function) => {
    if (cb) cb(null, '', '');
  }),
}));

// Import spawn to get the mock
import { spawn } from 'node:child_process';
const mockSpawn = vi.mocked(spawn);

// Mock SimpleGitWorktreeManager so spawn doesn't need a real git repo
vi.mock('../git/manager.js', () => {
  return {
    SimpleGitWorktreeManager: class MockWorktreeManager {
      create = vi.fn().mockResolvedValue({ id: 'workspace', path: '/tmp/test-workspace/agent-workdirs/gastown/workspace', branch: 'agent/gastown' });
      get = vi.fn().mockResolvedValue(null);
      list = vi.fn().mockResolvedValue([]);
      remove = vi.fn().mockResolvedValue(undefined);
    },
  };
});

// Mock fs operations for file-based output
vi.mock('node:fs', async () => {
  const actual = await vi.importActual('node:fs');
  // Create a mock write stream
  const mockWriteStream = {
    write: vi.fn(),
    end: vi.fn(),
    on: vi.fn(),
  };
  return {
    ...actual,
    openSync: vi.fn().mockReturnValue(99),
    closeSync: vi.fn(),
    mkdirSync: vi.fn(),
    writeFileSync: vi.fn(),
    createWriteStream: vi.fn().mockReturnValue(mockWriteStream),
    existsSync: vi.fn().mockReturnValue(true), // Default to true for our new validation
  };
});

vi.mock('node:fs/promises', async () => {
  const actual = await vi.importActual('node:fs/promises');
  return {
    ...actual,
    readFile: vi.fn().mockResolvedValue(''),
    readdir: vi.fn().mockRejectedValue(new Error('ENOENT')),
    rm: vi.fn().mockResolvedValue(undefined),
    writeFile: vi.fn().mockResolvedValue(undefined),
    mkdir: vi.fn().mockResolvedValue(undefined),
  };
});

// Mock FileTailer to avoid actual file watching
vi.mock('./file-tailer.js', () => ({
  FileTailer: class MockFileTailer {
    start = vi.fn().mockResolvedValue(undefined);
    stop = vi.fn().mockResolvedValue(undefined);
    isStopped = false;
  },
}));

import type { ChildProcess } from 'node:child_process';

/**
 * Create a mock ChildProcess for detached spawning.
 * The process is spawned detached and unreferenced.
 */
function createMockChildProcess(options?: {
  pid?: number;
}) {
  const { pid = 123 } = options ?? {};

  // Create a minimal mock that satisfies the actual usage in spawnDetached
  const childProcess = {
    pid,
    unref: vi.fn(),
    on: vi.fn().mockReturnThis(),
    kill: vi.fn(),
  } as unknown as ChildProcess;

  return childProcess;
}

describe('MultiProviderAgentManager', () => {
  let manager: MultiProviderAgentManager;
  let mockRepository: AgentRepository;
  let mockProjectRepository: ProjectRepository;
  let eventBus: EventEmitterBus;
  let capturedEvents: DomainEvent[];

  const mockAgent = {
    id: 'agent-123',
    name: 'gastown',
    taskId: 'task-456',
    initiativeId: null as string | null,
    sessionId: 'session-789',
    worktreeId: 'gastown',
    status: 'idle' as const,
    mode: 'execute' as const,
    provider: 'claude',
    accountId: null as string | null,
    pid: null as number | null,
    outputFilePath: null as string | null,
    result: null as string | null,
    pendingQuestions: null as string | null,
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  beforeEach(() => {
    vi.clearAllMocks();
    capturedEvents = [];

    mockRepository = {
      create: vi.fn().mockResolvedValue(mockAgent),
      findById: vi.fn().mockResolvedValue(mockAgent),
      findByName: vi.fn().mockResolvedValue(null), // No duplicate by default
      findByTaskId: vi.fn().mockResolvedValue(mockAgent),
      findBySessionId: vi.fn().mockResolvedValue(mockAgent),
      findAll: vi.fn().mockResolvedValue([mockAgent]),
      findByStatus: vi.fn().mockResolvedValue([mockAgent]),
      update: vi.fn().mockResolvedValue(mockAgent),
      delete: vi.fn().mockResolvedValue(undefined),
    };

    mockProjectRepository = {
      create: vi.fn(),
      findById: vi.fn(),
      findByName: vi.fn(),
      findAll: vi.fn().mockResolvedValue([]),
      update: vi.fn(),
      delete: vi.fn(),
      addProjectToInitiative: vi.fn(),
      removeProjectFromInitiative: vi.fn(),
      findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
      setInitiativeProjects: vi.fn(),
    };

    eventBus = new EventEmitterBus();
    // Subscribe to all agent events
    eventBus.on('agent:spawned', (e) => capturedEvents.push(e));
    eventBus.on('agent:stopped', (e) => capturedEvents.push(e));
    eventBus.on('agent:crashed', (e) => capturedEvents.push(e));
    eventBus.on('agent:resumed', (e) => capturedEvents.push(e));
    eventBus.on('agent:waiting', (e) => capturedEvents.push(e));

    manager = new MultiProviderAgentManager(
      mockRepository,
      '/tmp/test-workspace',
      mockProjectRepository,
      undefined,
      eventBus
    );
  });

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

  describe('spawn', () => {
    it('creates agent record with provided name', async () => {
      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      const result = await manager.spawn({
        name: 'gastown',
        taskId: 'task-456',
        prompt: 'Test task',
      });

      expect(mockRepository.create).toHaveBeenCalledWith(
        expect.objectContaining({ name: 'gastown' })
      );
      expect(result.name).toBe('gastown');
    });

    it('rejects duplicate agent names', async () => {
      mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent);

      await expect(
        manager.spawn({
          name: 'gastown',
          taskId: 'task-456',
          prompt: 'Test',
        })
      ).rejects.toThrow("Agent with name 'gastown' already exists");
    });

    it('emits AgentSpawned event with name', async () => {
      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      await manager.spawn({
        name: 'gastown',
        taskId: 'task-456',
        prompt: 'Test',
      });

      const spawnedEvent = capturedEvents.find(
        (e) => e.type === 'agent:spawned'
      );
      expect(spawnedEvent).toBeDefined();
      expect(
        (spawnedEvent as { payload: { name: string } }).payload.name
      ).toBe('gastown');
    });

    it('writes diagnostic files for workdir verification', async () => {
      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      // Mock fs/promises writeFile to capture diagnostic file writing
      const { writeFile } = await import('node:fs/promises');
      const mockWriteFile = vi.mocked(writeFile);

      // The existsSync is already mocked globally to return true

      await manager.spawn({
        name: 'gastown',
        taskId: 'task-456',
        prompt: 'Test task',
      });

      // Verify diagnostic file was written
      const diagnosticCalls = mockWriteFile.mock.calls.filter(call =>
        call[0].toString().includes('spawn-diagnostic.json')
      );
      expect(diagnosticCalls).toHaveLength(1);

      // Parse the diagnostic data to verify structure
      const diagnosticCall = diagnosticCalls[0];
      const diagnosticData = JSON.parse(diagnosticCall[1] as string);

      expect(diagnosticData).toMatchObject({
        agentId: expect.any(String),
        alias: 'gastown',
        intendedCwd: expect.stringContaining('/agent-workdirs/gastown/workspace'),
        worktreeId: 'gastown',
        provider: 'claude',
        command: expect.any(String),
        args: expect.any(Array),
        env: expect.any(Object),
        cwdExistsAtSpawn: true,
        initiativeId: null,
        customCwdProvided: false,
        accountId: null,
        timestamp: expect.any(String),
      });
    });

    it('uses custom cwd if provided', async () => {
      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      await manager.spawn({
        name: 'chinatown',
        taskId: 'task-789',
        prompt: 'Test task',
        cwd: '/custom/path',
      });

      // Verify spawn was called with custom cwd
      expect(mockSpawn).toHaveBeenCalledWith(
        'claude',
        expect.arrayContaining(['-p', expect.stringContaining('Test task'), '--output-format', 'stream-json']),
        expect.objectContaining({ cwd: '/custom/path' })
      );
    });
  });

  describe('stop', () => {
    it('stops running agent and updates status', async () => {
      // When we call stop, it looks up the agent by ID
      // The repository mock returns mockAgent which has id 'agent-123'
      await manager.stop(mockAgent.id);

      expect(mockRepository.update).toHaveBeenCalledWith(
        mockAgent.id,
        { status: 'stopped', pendingQuestions: null }
      );
    });

    it('kills detached process if running', async () => {
      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      // Spawn returns immediately since process is detached
      const spawned = await manager.spawn({
        name: 'gastown',
        taskId: 'task-456',
        prompt: 'Test',
      });

      // Now stop using the returned agent ID
      await manager.stop(spawned.id);

      // Verify status was updated (process.kill is called internally, not on the child object)
      expect(mockRepository.update).toHaveBeenCalledWith(
        spawned.id,
        { status: 'stopped', pendingQuestions: null }
      );
    });

    it('throws if agent not found', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue(null);

      await expect(manager.stop('nonexistent')).rejects.toThrow(
        "Agent 'nonexistent' not found"
      );
    });

    it('emits AgentStopped event with user_requested reason', async () => {
      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      const spawned = await manager.spawn({
        name: 'gastown',
        taskId: 'task-456',
        prompt: 'Test',
      });
      await manager.stop(spawned.id);

      const stoppedEvent = capturedEvents.find(
        (e) => e.type === 'agent:stopped'
      );
      expect(stoppedEvent).toBeDefined();
      expect(
        (stoppedEvent as { payload: { reason: string } }).payload.reason
      ).toBe('user_requested');
    });
  });

  describe('list', () => {
    it('returns all agents with names', async () => {
      const agents = await manager.list();

      expect(agents).toHaveLength(1);
      expect(agents[0].name).toBe('gastown');
    });
  });

  describe('get', () => {
    it('finds agent by id', async () => {
      const agent = await manager.get('agent-123');

      expect(mockRepository.findById).toHaveBeenCalledWith('agent-123');
      expect(agent?.id).toBe('agent-123');
    });

    it('returns null if agent not found', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue(null);

      const agent = await manager.get('nonexistent');

      expect(agent).toBeNull();
    });
  });

  describe('getByName', () => {
    it('finds agent by name', async () => {
      mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent);

      const agent = await manager.getByName('gastown');

      expect(mockRepository.findByName).toHaveBeenCalledWith('gastown');
      expect(agent?.name).toBe('gastown');
    });

    it('returns null if agent not found', async () => {
      mockRepository.findByName = vi.fn().mockResolvedValue(null);

      const agent = await manager.getByName('nonexistent');

      expect(agent).toBeNull();
    });
  });

  describe('resume', () => {
    it('resumes agent waiting for input with answers map', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue({
        ...mockAgent,
        status: 'waiting_for_input',
      });

      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      await manager.resume(mockAgent.id, { q1: 'Answer one', q2: 'Answer two' });

      // Verify spawn was called with resume args
      expect(mockSpawn).toHaveBeenCalledWith(
        'claude',
        expect.arrayContaining([
          '--resume',
          'session-789',
          '--output-format',
          'stream-json',
        ]),
        expect.any(Object)
      );
    });

    it('rejects if agent not waiting for input', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue({
        ...mockAgent,
        status: 'running',
      });

      await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow(
        'not waiting for input'
      );
    });

    it('rejects if agent has no session', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue({
        ...mockAgent,
        status: 'waiting_for_input',
        sessionId: null,
      });

      await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow(
        'has no session to resume'
      );
    });

    it('emits AgentResumed event', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue({
        ...mockAgent,
        status: 'waiting_for_input',
      });

      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      await manager.resume(mockAgent.id, { q1: 'User answer' });

      const resumedEvent = capturedEvents.find(
        (e) => e.type === 'agent:resumed'
      );
      expect(resumedEvent).toBeDefined();
      expect(
        (resumedEvent as { payload: { sessionId: string } }).payload.sessionId
      ).toBe('session-789');
    });
  });

  describe('sendUserMessage', () => {
    it('resumes errand agent in waiting_for_input status', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue({
        ...mockAgent,
        status: 'waiting_for_input',
      });

      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      await expect(manager.sendUserMessage(mockAgent.id, 'my answer')).resolves.not.toThrow();
    });

    it('rejects if agent is stopped', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue({
        ...mockAgent,
        status: 'stopped',
      });

      await expect(manager.sendUserMessage(mockAgent.id, 'message')).rejects.toThrow(
        'Agent is not running'
      );
    });
  });

  describe('getResult', () => {
    it('returns null when agent has no result', async () => {
      const result = await manager.getResult('agent-123');

      expect(result).toBeNull();
    });
  });

  describe('delete', () => {
    it('deletes agent and clears active state', async () => {
      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      // Spawn an agent first
      const spawned = await manager.spawn({
        name: 'gastown',
        taskId: 'task-456',
        prompt: 'Test',
      });

      // Delete the agent
      await manager.delete(spawned.id);

      // Verify DB record was deleted
      expect(mockRepository.delete).toHaveBeenCalledWith(spawned.id);
    });

    it('emits agent:deleted event', async () => {
      const mockChild = createMockChildProcess();
      mockSpawn.mockReturnValue(mockChild);

      eventBus.on('agent:deleted', (e) => capturedEvents.push(e));

      const spawned = await manager.spawn({
        name: 'gastown',
        taskId: 'task-456',
        prompt: 'Test',
      });

      await manager.delete(spawned.id);

      const deletedEvent = capturedEvents.find(
        (e) => e.type === 'agent:deleted'
      );
      expect(deletedEvent).toBeDefined();
      expect(
        (deletedEvent as { payload: { name: string } }).payload.name
      ).toBe('gastown');
    });

    it('throws if agent not found', async () => {
      mockRepository.findById = vi.fn().mockResolvedValue(null);

      await expect(manager.delete('nonexistent')).rejects.toThrow(
        "Agent 'nonexistent' not found"
      );
    });

    it('handles missing workdir gracefully', async () => {
      // Agent exists in DB but has no active state and workdir doesn't exist
      // The delete should succeed (best-effort cleanup)
      await manager.delete(mockAgent.id);

      expect(mockRepository.delete).toHaveBeenCalledWith(mockAgent.id);
    });
  });
});
