import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { EventBus, DomainEvent } from '../events/types.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import { PREVIEW_LABELS, COMPOSE_PROJECT_PREFIX } from './types.js';

// Mock all external dependencies before imports
vi.mock('./docker-client.js', () => ({
  isDockerAvailable: vi.fn(),
  composeUp: vi.fn(),
  composeDown: vi.fn(),
  execInContainer: vi.fn(),
  composePs: vi.fn(),
  listPreviewProjects: vi.fn(),
  getContainerLabels: vi.fn(),
  ensureDockerNetwork: vi.fn(),
  removeDockerNetwork: vi.fn(),
  dockerNetworkExists: vi.fn(),
}));

vi.mock('./config-reader.js', () => ({
  discoverConfig: vi.fn(),
}));

vi.mock('./port-allocator.js', () => ({
  allocatePort: vi.fn(),
}));

vi.mock('./health-checker.js', () => ({
  waitForHealthy: vi.fn(),
}));

vi.mock('./worktree.js', () => ({
  createPreviewWorktree: vi.fn().mockResolvedValue(undefined),
  removePreviewWorktree: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('simple-git', () => ({
  simpleGit: vi.fn(() => ({
    fetch: vi.fn().mockResolvedValue(undefined),
  })),
}));

// Mock gateway to prevent it from consuming docker-client mock values
const mockGatewayInstance = {
  ensureGateway: vi.fn().mockResolvedValue(9100),
  updateRoutes: vi.fn().mockResolvedValue(undefined),
  stopGateway: vi.fn().mockResolvedValue(undefined),
  isRunning: vi.fn().mockResolvedValue(false),
  getPort: vi.fn().mockResolvedValue(null),
};
vi.mock('./gateway.js', () => {
  const MockGatewayManager = function() {
    return mockGatewayInstance;
  };
  return {
    GatewayManager: MockGatewayManager,
    generateGatewayCaddyfile: vi.fn().mockReturnValue(''),
  };
});

vi.mock('node:fs/promises', () => ({
  mkdir: vi.fn().mockResolvedValue(undefined),
  writeFile: vi.fn().mockResolvedValue(undefined),
  rm: vi.fn().mockResolvedValue(undefined),
}));

vi.mock('nanoid', () => ({
  customAlphabet: vi.fn(() => vi.fn(() => 'abc123test')),
}));

import { PreviewManager } from './manager.js';
import {
  isDockerAvailable,
  composeUp,
  composeDown,
  execInContainer,
  composePs,
  listPreviewProjects,
  getContainerLabels,
} from './docker-client.js';
import { discoverConfig } from './config-reader.js';
import { allocatePort } from './port-allocator.js';
import { waitForHealthy } from './health-checker.js';
import { createPreviewWorktree, removePreviewWorktree } from './worktree.js';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import type { PreviewConfig } from './types.js';

const mockIsDockerAvailable = vi.mocked(isDockerAvailable);
const mockComposeUp = vi.mocked(composeUp);
const mockComposeDown = vi.mocked(composeDown);
const mockComposePs = vi.mocked(composePs);
const mockListPreviewProjects = vi.mocked(listPreviewProjects);
const mockGetContainerLabels = vi.mocked(getContainerLabels);
const mockExecInContainer = vi.mocked(execInContainer);
const mockDiscoverConfig = vi.mocked(discoverConfig);
const mockAllocatePort = vi.mocked(allocatePort);
const mockWaitForHealthy = vi.mocked(waitForHealthy);
const mockCreatePreviewWorktree = vi.mocked(createPreviewWorktree);
const mockMkdir = vi.mocked(mkdir);
const mockWriteFile = vi.mocked(writeFile);
const mockRm = vi.mocked(rm);

// Collect emitted events
function createMockEventBus(): EventBus & { emitted: DomainEvent[] } {
  const emitted: DomainEvent[] = [];
  return {
    emitted,
    emit: vi.fn((event: DomainEvent) => {
      emitted.push(event);
    }),
    on: vi.fn(),
    off: vi.fn(),
    once: vi.fn(),
  };
}

function createMockProjectRepo(project = {
  id: 'proj-1',
  name: 'test-project',
  url: 'https://github.com/test/repo.git',
  defaultBranch: 'main',
  createdAt: new Date(),
  updatedAt: new Date(),
}): ProjectRepository {
  return {
    findById: vi.fn().mockResolvedValue(project),
    findByName: vi.fn(),
    findByUrl: vi.fn(),
    findAll: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
    setInitiativeProjects: vi.fn(),
    getInitiativeProjects: vi.fn(),
    findProjectsByInitiativeId: vi.fn().mockResolvedValue([project]),
  } as unknown as ProjectRepository;
}

function createMockPhaseRepo(): PhaseRepository {
  return {
    findById: vi.fn(),
    findByInitiativeId: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
    getNextNumber: vi.fn(),
    findByNumber: vi.fn(),
  } as unknown as PhaseRepository;
}

function createMockInitiativeRepo(): InitiativeRepository {
  return {
    findById: vi.fn(),
    findAll: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
    findByStatus: vi.fn(),
  } as unknown as InitiativeRepository;
}

const WORKSPACE_ROOT = '/tmp/test-workspace';

const SIMPLE_CONFIG: PreviewConfig = {
  version: 1,
  services: {
    app: {
      name: 'app',
      build: '.',
      port: 3000,
      healthcheck: { path: '/health' },
    },
  },
};

describe('PreviewManager', () => {
  let manager: PreviewManager;
  let eventBus: EventBus & { emitted: DomainEvent[] };
  let projectRepo: ProjectRepository;
  let phaseRepo: PhaseRepository;
  let initiativeRepo: InitiativeRepository;

  beforeEach(() => {
    vi.clearAllMocks();
    // Reset gateway mock to defaults after clearAllMocks wipes implementations
    mockGatewayInstance.ensureGateway.mockResolvedValue(9100);
    mockGatewayInstance.updateRoutes.mockResolvedValue(undefined);
    mockGatewayInstance.stopGateway.mockResolvedValue(undefined);
    mockGatewayInstance.isRunning.mockResolvedValue(false);
    mockGatewayInstance.getPort.mockResolvedValue(null);

    eventBus = createMockEventBus();
    projectRepo = createMockProjectRepo();
    phaseRepo = createMockPhaseRepo();
    initiativeRepo = createMockInitiativeRepo();
    manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo);
  });

  describe('start', () => {
    it('completes the full start lifecycle with gateway architecture', async () => {
      mockIsDockerAvailable.mockResolvedValue(true);
      mockComposeUp.mockResolvedValue(undefined);
      mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'healthy' }]);
      mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
      mockCreatePreviewWorktree.mockResolvedValue(undefined);
      mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]);

      const result = await manager.start({
        initiativeId: 'init-1',
        projectId: 'proj-1',
        branch: 'feature-x',
      });

      // Verify returned status uses gateway fields
      expect(result.id).toBe('abc123test');
      expect(result.projectName).toBe('cw-preview-abc123test');
      expect(result.initiativeId).toBe('init-1');
      expect(result.projectId).toBe('proj-1');
      expect(result.branch).toBe('feature-x');
      expect(result.gatewayPort).toBe(9100);
      expect(result.url).toBe('http://abc123test.localhost:9100/');
      expect(result.mode).toBe('preview');
      expect(result.status).toBe('running');

      // Verify worktree was created for preview mode
      expect(mockCreatePreviewWorktree).toHaveBeenCalledOnce();

      // Verify events: building then ready
      const buildingEvent = eventBus.emitted.find((e) => e.type === 'preview:building');
      const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready');
      expect(buildingEvent).toBeDefined();
      expect(readyEvent).toBeDefined();
      expect((readyEvent!.payload as Record<string, unknown>).url).toBe(
        'http://abc123test.localhost:9100/',
      );
    });

    it('includes phaseId when provided', async () => {
      mockIsDockerAvailable.mockResolvedValue(true);
      mockComposeUp.mockResolvedValue(undefined);
      mockComposePs.mockResolvedValue([]);
      mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
      mockCreatePreviewWorktree.mockResolvedValue(undefined);
      mockWaitForHealthy.mockResolvedValue([]);

      const result = await manager.start({
        initiativeId: 'init-1',
        phaseId: 'phase-1',
        projectId: 'proj-1',
        branch: 'feature-x',
      });

      expect(result.phaseId).toBe('phase-1');
    });

    it('throws when Docker is not available', async () => {
      mockIsDockerAvailable.mockResolvedValue(false);

      await expect(
        manager.start({
          initiativeId: 'init-1',
          projectId: 'proj-1',
          branch: 'main',
        }),
      ).rejects.toThrow('Docker is not available');

      // No events should be emitted
      expect(eventBus.emitted).toHaveLength(0);
    });

    it('throws when project is not found', async () => {
      mockIsDockerAvailable.mockResolvedValue(true);
      projectRepo = createMockProjectRepo();
      (projectRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(null);
      manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo);

      await expect(
        manager.start({
          initiativeId: 'init-1',
          projectId: 'nonexistent',
          branch: 'main',
        }),
      ).rejects.toThrow("Project 'nonexistent' not found");
    });

    it('emits preview:failed and cleans up when compose up fails', async () => {
      mockIsDockerAvailable.mockResolvedValue(true);
      mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
      mockCreatePreviewWorktree.mockResolvedValue(undefined);
      mockComposeUp.mockRejectedValue(new Error('build error: Dockerfile not found'));
      mockComposeDown.mockResolvedValue(undefined);

      await expect(
        manager.start({
          initiativeId: 'init-1',
          projectId: 'proj-1',
          branch: 'main',
        }),
      ).rejects.toThrow('Preview build failed');

      // Events: building, then failed
      const failedEvent = eventBus.emitted.find((e) => e.type === 'preview:failed');
      expect(failedEvent).toBeDefined();
    });

    it('runs seed commands after health check and before preview:ready', async () => {
      const seededConfig: PreviewConfig = {
        version: 1,
        services: {
          app: {
            name: 'app',
            build: '.',
            port: 3000,
            seed: ['npm run db:migrate', 'npm run db:seed'],
          },
        },
      };

      mockIsDockerAvailable.mockResolvedValue(true);
      mockComposeUp.mockResolvedValue(undefined);
      mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'healthy' }]);
      mockDiscoverConfig.mockResolvedValue(seededConfig);
      mockCreatePreviewWorktree.mockResolvedValue(undefined);
      mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]);
      mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '' });

      const result = await manager.start({
        initiativeId: 'init-1',
        projectId: 'proj-1',
        branch: 'feature-x',
      });

      expect(result.status).toBe('running');
      expect(mockExecInContainer).toHaveBeenCalledTimes(2);
      expect(mockExecInContainer).toHaveBeenCalledWith('cw-preview-abc123test', 'app', 'npm run db:migrate');
      expect(mockExecInContainer).toHaveBeenCalledWith('cw-preview-abc123test', 'app', 'npm run db:seed');

      const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready');
      expect(readyEvent).toBeDefined();
    });

    it('emits preview:failed and cleans up when seed command fails', async () => {
      const seededConfig: PreviewConfig = {
        version: 1,
        services: {
          app: {
            name: 'app',
            build: '.',
            port: 3000,
            seed: ['npm run db:migrate'],
          },
        },
      };

      mockIsDockerAvailable.mockResolvedValue(true);
      mockComposeUp.mockResolvedValue(undefined);
      mockDiscoverConfig.mockResolvedValue(seededConfig);
      mockCreatePreviewWorktree.mockResolvedValue(undefined);
      mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]);
      mockExecInContainer.mockRejectedValue(new Error('migration failed: relation already exists'));
      mockComposeDown.mockResolvedValue(undefined);

      await expect(
        manager.start({
          initiativeId: 'init-1',
          projectId: 'proj-1',
          branch: 'main',
        }),
      ).rejects.toThrow('Preview build failed');

      const failedEvent = eventBus.emitted.find((e) => e.type === 'preview:failed');
      expect(failedEvent).toBeDefined();
      expect(mockComposeDown).toHaveBeenCalled();
    });

    it('succeeds when no healthcheck endpoints are configured', async () => {
      const noHealthConfig: PreviewConfig = {
        version: 1,
        services: { app: { name: 'app', build: '.', port: 3000 } },
      };

      mockIsDockerAvailable.mockResolvedValue(true);
      mockComposeUp.mockResolvedValue(undefined);
      mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]);
      mockDiscoverConfig.mockResolvedValue(noHealthConfig);
      mockCreatePreviewWorktree.mockResolvedValue(undefined);
      mockWaitForHealthy.mockResolvedValue([]);

      const result = await manager.start({
        initiativeId: 'init-1',
        projectId: 'proj-1',
        branch: 'main',
      });

      expect(result.status).toBe('running');
      const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready');
      expect(readyEvent).toBeDefined();
    });
  });

  describe('stop', () => {
    it('stops compose, cleans up artifacts, and emits preview:stopped', async () => {
      mockGetContainerLabels.mockResolvedValue({
        [PREVIEW_LABELS.preview]: 'true',
        [PREVIEW_LABELS.initiativeId]: 'init-1',
        [PREVIEW_LABELS.mode]: 'preview',
        [PREVIEW_LABELS.projectId]: 'proj-1',
      });
      mockComposeDown.mockResolvedValue(undefined);

      await manager.stop('abc123test');

      expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-abc123test');
      expect(mockRm).toHaveBeenCalledWith(
        `${WORKSPACE_ROOT}/.cw-previews/abc123test`,
        { recursive: true, force: true },
      );

      const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped');
      expect(stoppedEvent).toBeDefined();
      expect((stoppedEvent!.payload as Record<string, unknown>).previewId).toBe('abc123test');
      expect((stoppedEvent!.payload as Record<string, unknown>).initiativeId).toBe('init-1');
    });

    it('emits empty initiativeId when labels are missing', async () => {
      mockGetContainerLabels.mockResolvedValue({});
      mockComposeDown.mockResolvedValue(undefined);

      await manager.stop('xyz');

      const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped');
      expect(stoppedEvent).toBeDefined();
      expect((stoppedEvent!.payload as Record<string, unknown>).initiativeId).toBe('');
    });
  });

  describe('list', () => {
    it('returns all active previews reconstructed from Docker state', async () => {
      mockListPreviewProjects.mockResolvedValue([
        { Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '/tmp/compose.yml' },
        { Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '/tmp/compose2.yml' },
      ]);

      mockGetContainerLabels
        .mockResolvedValueOnce({
          [PREVIEW_LABELS.preview]: 'true',
          [PREVIEW_LABELS.initiativeId]: 'init-1',
          [PREVIEW_LABELS.projectId]: 'proj-1',
          [PREVIEW_LABELS.branch]: 'feat-a',
          [PREVIEW_LABELS.port]: '9100',
          [PREVIEW_LABELS.previewId]: 'aaa',
          [PREVIEW_LABELS.mode]: 'preview',
        })
        .mockResolvedValueOnce({
          [PREVIEW_LABELS.preview]: 'true',
          [PREVIEW_LABELS.initiativeId]: 'init-2',
          [PREVIEW_LABELS.projectId]: 'proj-2',
          [PREVIEW_LABELS.branch]: 'feat-b',
          [PREVIEW_LABELS.port]: '9100',
          [PREVIEW_LABELS.previewId]: 'bbb',
          [PREVIEW_LABELS.mode]: 'dev',
        });

      mockComposePs
        .mockResolvedValueOnce([{ name: 'app', state: 'running', health: 'healthy' }])
        .mockResolvedValueOnce([{ name: 'api', state: 'running', health: 'none' }]);

      const previews = await manager.list();

      expect(previews).toHaveLength(2);
      expect(previews[0].id).toBe('aaa');
      expect(previews[0].gatewayPort).toBe(9100);
      expect(previews[0].url).toBe('http://aaa.localhost:9100/');
      expect(previews[0].mode).toBe('preview');
      expect(previews[0].services).toHaveLength(1);
      expect(previews[1].id).toBe('bbb');
      expect(previews[1].gatewayPort).toBe(9100);
      expect(previews[1].mode).toBe('dev');
    });

    it('filters by initiativeId when provided', async () => {
      mockListPreviewProjects.mockResolvedValue([
        { Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' },
        { Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' },
      ]);

      mockGetContainerLabels
        .mockResolvedValueOnce({
          [PREVIEW_LABELS.preview]: 'true',
          [PREVIEW_LABELS.initiativeId]: 'init-1',
          [PREVIEW_LABELS.projectId]: 'proj-1',
          [PREVIEW_LABELS.branch]: 'feat-a',
          [PREVIEW_LABELS.port]: '9100',
          [PREVIEW_LABELS.previewId]: 'aaa',
        })
        .mockResolvedValueOnce({
          [PREVIEW_LABELS.preview]: 'true',
          [PREVIEW_LABELS.initiativeId]: 'init-2',
          [PREVIEW_LABELS.projectId]: 'proj-2',
          [PREVIEW_LABELS.branch]: 'feat-b',
          [PREVIEW_LABELS.port]: '9100',
          [PREVIEW_LABELS.previewId]: 'bbb',
        });

      mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]);

      const previews = await manager.list('init-1');

      expect(previews).toHaveLength(1);
      expect(previews[0].initiativeId).toBe('init-1');
    });

    it('skips gateway project from listing', async () => {
      mockListPreviewProjects.mockResolvedValue([
        { Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' },
        { Name: 'cw-preview-aaa', Status: 'running(1)', ConfigFiles: '' },
      ]);

      mockGetContainerLabels.mockResolvedValue({
        [PREVIEW_LABELS.preview]: 'true',
        [PREVIEW_LABELS.initiativeId]: 'init-1',
        [PREVIEW_LABELS.projectId]: 'proj-1',
        [PREVIEW_LABELS.branch]: 'main',
        [PREVIEW_LABELS.port]: '9100',
        [PREVIEW_LABELS.previewId]: 'aaa',
      });

      mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]);

      const previews = await manager.list();

      // Should only include actual previews, not gateway
      expect(previews).toHaveLength(1);
      expect(previews[0].id).toBe('aaa');
    });

    it('skips projects with incomplete labels', async () => {
      mockListPreviewProjects.mockResolvedValue([
        { Name: 'cw-preview-partial', Status: 'running(1)', ConfigFiles: '' },
      ]);
      mockGetContainerLabels.mockResolvedValue({
        [PREVIEW_LABELS.preview]: 'true',
        // Missing required: initiativeId, projectId, branch
      });

      const previews = await manager.list();
      expect(previews).toHaveLength(0);
    });
  });

  describe('getStatus', () => {
    const labels = {
      [PREVIEW_LABELS.preview]: 'true',
      [PREVIEW_LABELS.initiativeId]: 'init-1',
      [PREVIEW_LABELS.projectId]: 'proj-1',
      [PREVIEW_LABELS.branch]: 'main',
      [PREVIEW_LABELS.port]: '9100',
      [PREVIEW_LABELS.previewId]: 'abc',
      [PREVIEW_LABELS.mode]: 'preview',
    };

    it('returns running when all services are running', async () => {
      mockGetContainerLabels.mockResolvedValue(labels);
      mockComposePs.mockResolvedValue([
        { name: 'app', state: 'running', health: 'healthy' },
      ]);

      const status = await manager.getStatus('abc');

      expect(status).not.toBeNull();
      expect(status!.status).toBe('running');
      expect(status!.id).toBe('abc');
      expect(status!.gatewayPort).toBe(9100);
      expect(status!.url).toBe('http://abc.localhost:9100/');
      expect(status!.mode).toBe('preview');
    });

    it('returns failed when any service is exited', async () => {
      mockGetContainerLabels.mockResolvedValue(labels);
      mockComposePs.mockResolvedValue([
        { name: 'app', state: 'exited', health: 'none' },
      ]);

      const status = await manager.getStatus('abc');
      expect(status!.status).toBe('failed');
    });

    it('returns stopped when no services exist', async () => {
      mockGetContainerLabels.mockResolvedValue(labels);
      mockComposePs.mockResolvedValue([]);

      const status = await manager.getStatus('abc');
      expect(status!.status).toBe('stopped');
    });

    it('returns building when services are in other states', async () => {
      mockGetContainerLabels.mockResolvedValue(labels);
      mockComposePs.mockResolvedValue([
        { name: 'app', state: 'created', health: 'starting' },
      ]);

      const status = await manager.getStatus('abc');
      expect(status!.status).toBe('building');
    });

    it('returns null when preview is not found', async () => {
      mockGetContainerLabels.mockResolvedValue({}); // no cw.preview label

      const status = await manager.getStatus('nonexistent');
      expect(status).toBeNull();
    });
  });

  describe('stopAll', () => {
    it('stops all preview projects and the gateway', async () => {
      mockListPreviewProjects.mockResolvedValue([
        { Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' },
        { Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' },
        { Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' },
      ]);

      // stop() calls getContainerLabels then composeDown
      mockGetContainerLabels.mockResolvedValue({
        [PREVIEW_LABELS.initiativeId]: 'init-1',
      });
      mockComposeDown.mockResolvedValue(undefined);

      await manager.stopAll();

      // Should stop preview projects but not call stop() on gateway directly
      // (gateway is handled separately via stopGateway)
      expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-aaa');
      expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-bbb');
      // Gateway is stopped via the mocked GatewayManager.stopGateway()
      expect(mockGatewayInstance.stopGateway).toHaveBeenCalled();
    });

    it('handles empty project list gracefully', async () => {
      mockListPreviewProjects.mockResolvedValue([]);

      await manager.stopAll();

      // No preview composeDown calls, but gateway stopGateway still called
      expect(mockComposeDown).not.toHaveBeenCalled();
      expect(mockGatewayInstance.stopGateway).toHaveBeenCalled();
    });
  });
});
