import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Service } from '../services/types.js';

// --- I/O mocks. Hoisted so vi.mock factories below can see them. ---------------
const execMock = vi.hoisted(() => vi.fn());
vi.mock('child_process', () => ({ exec: execMock }));

const createDeploymentMock = vi.hoisted(() => vi.fn());
const updateDeploymentMock = vi.hoisted(() => vi.fn());
vi.mock('./repository.js', () => ({
  createDeployment: createDeploymentMock,
  updateDeployment: updateDeploymentMock,
}));

const getServiceByIdMock = vi.hoisted(() => vi.fn());
const updateServiceMock = vi.hoisted(() => vi.fn());
vi.mock('../services/repository.js', () => ({
  getServiceById: getServiceByIdMock,
  updateService: updateServiceMock,
}));

vi.mock('../../lib/config.js', () => ({
  config: {},
  productId: 'devops-internal',
}));

const { triggerDeployment } = await import('./orchestrator.js');

function makeService(overrides?: Partial<Service>): Service {
  return {
    id: 'svc-1',
    name: 'Test Service',
    scriptPath: 'deploy.sh',
    healthUrl: 'https://example.com/health',
    repoPath: '../repo',
    status: 'up',
    version: '1.0.0',
    productId: 'devops-internal',
    ...overrides,
  };
}

// promisify(exec) calls exec(cmd, options, cb(err, { stdout, stderr })). Drive
// the callback synchronously off the mock so the deferred script work resolves
// before our awaited assertion.
function setExec(handler: () => { error?: Error & { stdout?: string; stderr?: string }; stdout?: string; stderr?: string }) {
  execMock.mockImplementation(
    (
      _cmd: string,
      _opts: unknown,
      cb: (err: (Error & { stdout?: string; stderr?: string }) | null, result?: { stdout: string; stderr: string }) => void,
    ) => {
      const res = handler();
      if (res.error) cb(res.error);
      else cb(null, { stdout: res.stdout ?? '', stderr: res.stderr ?? '' });
    },
  );
}

describe('triggerDeployment', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    createDeploymentMock.mockImplementation(async (data) => ({ id: 'dep-1', ...data }));
    updateDeploymentMock.mockResolvedValue({});
    getServiceByIdMock.mockImplementation(async (id) => makeService({ id, version: '0.9.0' }));
    updateServiceMock.mockResolvedValue({});
  });

  it('creates a pending deployment record and returns its id immediately', async () => {
    setExec(() => ({ stdout: 'deployed v1.2.3', stderr: '' }));
    const id = await triggerDeployment(makeService(), 'tester@bytelyst');
    expect(id).toBe('dep-1');
    expect(createDeploymentMock).toHaveBeenCalledWith({
      serviceId: 'svc-1',
      version: 'pending',
      triggeredBy: 'tester@bytelyst',
      productId: 'devops-internal',
    });
  });

  // Wait for the post-trigger async work to flush. We can't await the inner
  // promise directly (orchestrator deliberately fire-and-forgets), so we yield
  // ticks until updateDeployment is observed.
  async function flushBackground(): Promise<void> {
    for (let i = 0; i < 50; i++) {
      if (updateDeploymentMock.mock.calls.length > 0) return;
      await Promise.resolve();
    }
  }

  it('marks the deployment success and updates the service version on a clean run', async () => {
    setExec(() => ({ stdout: 'release version: 2.5.1\n', stderr: '' }));
    await triggerDeployment(makeService(), 'tester');
    await flushBackground();

    const finalCall = updateDeploymentMock.mock.calls.at(-1)![1];
    expect(finalCall.status).toBe('success');
    expect(finalCall.version).toBe('2.5.1');
    expect(typeof finalCall.completedAt).toBe('string');

    // Service is moved to 'up' with the extracted version.
    expect(updateServiceMock).toHaveBeenCalledWith(
      'svc-1',
      expect.objectContaining({ status: 'up', version: '2.5.1' }),
    );
  });

  it('falls back to version "unknown" when the script logs no recognizable version', async () => {
    setExec(() => ({ stdout: 'all good, no numbers here', stderr: '' }));
    await triggerDeployment(makeService(), 'tester');
    await flushBackground();

    const finalCall = updateDeploymentMock.mock.calls.at(-1)![1];
    expect(finalCall.status).toBe('success');
    expect(finalCall.version).toBe('unknown');
  });

  it('marks the deployment failed and the service down when the script throws', async () => {
    const err = Object.assign(new Error('exit 1'), { stdout: 'partial', stderr: 'boom' });
    setExec(() => ({ error: err }));
    await triggerDeployment(makeService(), 'tester');
    await flushBackground();

    const finalCall = updateDeploymentMock.mock.calls.at(-1)![1];
    expect(finalCall.status).toBe('failed');
    expect(finalCall.logs).toContain('ERROR: exit 1');
    expect(finalCall.logs).toContain('STDERR:\nboom');
    expect(finalCall).not.toHaveProperty('version');

    expect(updateServiceMock).toHaveBeenCalledWith('svc-1', { status: 'down' });
  });

  it('does not crash when getServiceById returns null in the success path', async () => {
    getServiceByIdMock.mockResolvedValue(null);
    setExec(() => ({ stdout: 'version: 1.0.0', stderr: '' }));
    await triggerDeployment(makeService(), 'tester');
    await flushBackground();

    expect(updateServiceMock).not.toHaveBeenCalled();
    const finalCall = updateDeploymentMock.mock.calls.at(-1)![1];
    expect(finalCall.status).toBe('success');
  });
});
