import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InstanceService, InvalidStateError } from '../src/services/instance.service.js';
import { NotFoundError } from '../src/services/mcp-server.service.js';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
import type { McpInstance } from '@prisma/client';

function mockInstanceRepo(): IMcpInstanceRepository {
  return {
    findAll: vi.fn(async () => []),
    findById: vi.fn(async () => null),
    findByContainerId: vi.fn(async () => null),
    create: vi.fn(async (data) => ({
      id: 'inst-1',
      serverId: data.serverId,
      containerId: data.containerId ?? null,
      status: data.status ?? 'STOPPED',
      port: data.port ?? null,
      metadata: data.metadata ?? {},
      version: 1,
      createdAt: new Date(),
      updatedAt: new Date(),
    })),
    updateStatus: vi.fn(async (id, status, fields) => ({
      id,
      serverId: 'srv-1',
      containerId: fields?.containerId ?? 'ctr-abc',
      status,
      port: fields?.port ?? null,
      metadata: fields?.metadata ?? {},
      version: 2,
      createdAt: new Date(),
      updatedAt: new Date(),
    })),
    delete: vi.fn(async () => {}),
  };
}

function mockServerRepo(): IMcpServerRepository {
  return {
    findAll: vi.fn(async () => []),
    findById: vi.fn(async () => null),
    findByName: vi.fn(async () => null),
    create: vi.fn(async () => ({} as never)),
    update: vi.fn(async () => ({} as never)),
    delete: vi.fn(async () => {}),
  };
}

function mockOrchestrator(): McpOrchestrator {
  return {
    ping: vi.fn(async () => true),
    pullImage: vi.fn(async () => {}),
    createContainer: vi.fn(async (spec) => ({
      containerId: 'ctr-abc123',
      name: spec.name,
      state: 'running' as const,
      port: 3000,
      createdAt: new Date(),
    })),
    stopContainer: vi.fn(async () => {}),
    removeContainer: vi.fn(async () => {}),
    inspectContainer: vi.fn(async () => ({
      containerId: 'ctr-abc123',
      name: 'test',
      state: 'running' as const,
      createdAt: new Date(),
    })),
    getContainerLogs: vi.fn(async () => ({ stdout: 'log output', stderr: '' })),
  };
}

function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) {
  return {
    id: overrides.id ?? 'srv-1',
    name: overrides.name ?? 'slack',
    dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest',
    packageName: null,
    transport: overrides.transport ?? 'STDIO',
    description: '',
    repositoryUrl: null,
    externalUrl: overrides.externalUrl ?? null,
    command: overrides.command ?? null,
    containerPort: overrides.containerPort ?? null,
    replicas: overrides.replicas ?? 1,
    env: [],
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
  };
}

function makeInstance(overrides: Partial<McpInstance> = {}): McpInstance {
  return {
    id: 'inst-1',
    serverId: 'srv-1',
    containerId: overrides.containerId ?? 'ctr-abc',
    status: overrides.status ?? 'RUNNING',
    port: overrides.port ?? 3000,
    metadata: overrides.metadata ?? {},
    version: 1,
    createdAt: overrides.createdAt ?? new Date(),
    updatedAt: new Date(),
    ...overrides,
  } as McpInstance;
}

describe('InstanceService', () => {
  let instanceRepo: ReturnType<typeof mockInstanceRepo>;
  let serverRepo: ReturnType<typeof mockServerRepo>;
  let orchestrator: ReturnType<typeof mockOrchestrator>;
  let service: InstanceService;

  beforeEach(() => {
    instanceRepo = mockInstanceRepo();
    serverRepo = mockServerRepo();
    orchestrator = mockOrchestrator();
    service = new InstanceService(instanceRepo, serverRepo, orchestrator);
  });

  describe('list', () => {
    it('lists all instances', async () => {
      const result = await service.list();
      expect(instanceRepo.findAll).toHaveBeenCalledWith(undefined);
      expect(result).toEqual([]);
    });

    it('filters by serverId', async () => {
      await service.list('srv-1');
      expect(instanceRepo.findAll).toHaveBeenCalledWith('srv-1');
    });
  });

  describe('getById', () => {
    it('throws NotFoundError when not found', async () => {
      await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
    });

    it('returns instance when found', async () => {
      vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ id: 'inst-1' }));
      const result = await service.getById('inst-1');
      expect(result.id).toBe('inst-1');
    });
  });

  describe('reconcile', () => {
    it('starts instances when below desired replicas', async () => {
      vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 }));
      vi.mocked(instanceRepo.findAll).mockResolvedValue([]);

      await service.reconcile('srv-1');

      // Should create 2 instances
      expect(instanceRepo.create).toHaveBeenCalledTimes(2);
    });

    it('does nothing when at desired replicas', async () => {
      vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 }));
      vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance({ status: 'RUNNING' })]);

      await service.reconcile('srv-1');

      expect(instanceRepo.create).not.toHaveBeenCalled();
      expect(instanceRepo.delete).not.toHaveBeenCalled();
    });

    it('removes excess instances when above desired replicas', async () => {
      vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 }));
      vi.mocked(instanceRepo.findAll).mockResolvedValue([
        makeInstance({ id: 'inst-old', createdAt: new Date('2025-01-01') }),
        makeInstance({ id: 'inst-new', createdAt: new Date('2025-06-01') }),
      ]);

      await service.reconcile('srv-1');

      // Should remove the oldest one
      expect(orchestrator.stopContainer).toHaveBeenCalledTimes(1);
      expect(instanceRepo.delete).toHaveBeenCalledWith('inst-old');
    });

    it('creates external instances without Docker', async () => {
      vi.mocked(serverRepo.findById).mockResolvedValue(
        makeServer({ replicas: 1, externalUrl: 'http://localhost:8086/mcp', dockerImage: null }),
      );
      vi.mocked(instanceRepo.findAll).mockResolvedValue([]);

      await service.reconcile('srv-1');

      expect(instanceRepo.create).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'RUNNING', metadata: expect.objectContaining({ external: true }) }),
      );
      expect(orchestrator.createContainer).not.toHaveBeenCalled();
    });

    it('handles replicas: 0 by removing all instances', async () => {
      vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 0 }));
      vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance()]);

      await service.reconcile('srv-1');

      expect(instanceRepo.delete).toHaveBeenCalledTimes(1);
    });

    it('throws NotFoundError for unknown server', async () => {
      await expect(service.reconcile('missing')).rejects.toThrow(NotFoundError);
    });
  });

  describe('remove', () => {
    it('stops container and deletes DB record', async () => {
      vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));

      const result = await service.remove('inst-1');

      expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
      expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
      expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
      expect(result.serverId).toBe('srv-1');
    });

    it('deletes DB record for external instance (no container)', async () => {
      vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));

      await service.remove('inst-1');

      expect(orchestrator.stopContainer).not.toHaveBeenCalled();
      expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
    });

    it('deletes DB record even if container is already gone', async () => {
      vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
      vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));

      await service.remove('inst-1');

      expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
    });
  });

  describe('removeAllForServer', () => {
    it('stops all containers for a server', async () => {
      vi.mocked(instanceRepo.findAll).mockResolvedValue([
        makeInstance({ id: 'inst-1', containerId: 'ctr-1' }),
        makeInstance({ id: 'inst-2', containerId: 'ctr-2' }),
      ]);

      await service.removeAllForServer('srv-1');

      expect(orchestrator.stopContainer).toHaveBeenCalledTimes(2);
      expect(orchestrator.removeContainer).toHaveBeenCalledTimes(2);
    });

    it('skips external instances with no container', async () => {
      vi.mocked(instanceRepo.findAll).mockResolvedValue([
        makeInstance({ id: 'inst-1', containerId: null }),
      ]);

      await service.removeAllForServer('srv-1');

      expect(orchestrator.stopContainer).not.toHaveBeenCalled();
    });
  });

  describe('inspect', () => {
    it('returns container info', async () => {
      vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));

      const result = await service.inspect('inst-1');
      expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc');
      expect(result.containerId).toBe('ctr-abc123');
    });

    it('throws InvalidStateError when no container', async () => {
      vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));

      await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError);
    });
  });

  describe('getLogs', () => {
    it('returns empty logs for instance without container', async () => {
      vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));

      const result = await service.getLogs('inst-1');
      expect(result).toEqual({ stdout: '', stderr: '' });
    });

    it('returns container logs', async () => {
      vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));

      const result = await service.getLogs('inst-1', { tail: 50 });

      expect(orchestrator.getContainerLogs).toHaveBeenCalledWith('ctr-abc', { tail: 50 });
      expect(result.stdout).toBe('log output');
    });
  });

  describe('reconcileAll', () => {
    it('creates missing instances for servers with replicas > 0', async () => {
      const server = makeServer({ id: 'srv-1', name: 'grafana', replicas: 1 });
      vi.mocked(serverRepo.findAll).mockResolvedValue([server]);
      vi.mocked(serverRepo.findById).mockResolvedValue(server);
      // No instances exist
      vi.mocked(instanceRepo.findAll).mockResolvedValue([]);

      const result = await service.reconcileAll();

      expect(result.reconciled).toBe(1);
      expect(result.errors).toHaveLength(0);
      expect(instanceRepo.create).toHaveBeenCalled();
    });

    it('skips servers with replicas = 0', async () => {
      const server = makeServer({ id: 'srv-1', replicas: 0 });
      vi.mocked(serverRepo.findAll).mockResolvedValue([server]);
      vi.mocked(instanceRepo.findAll).mockResolvedValue([]);

      const result = await service.reconcileAll();

      expect(result.reconciled).toBe(0);
      expect(instanceRepo.create).not.toHaveBeenCalled();
    });

    it('does not create instances when already at desired count', async () => {
      const server = makeServer({ id: 'srv-1', replicas: 1 });
      vi.mocked(serverRepo.findAll).mockResolvedValue([server]);
      vi.mocked(instanceRepo.findAll).mockResolvedValue([
        makeInstance({ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING' }),
      ]);

      const result = await service.reconcileAll();

      expect(result.reconciled).toBe(0);
      expect(instanceRepo.create).not.toHaveBeenCalled();
    });

    it('retries ERROR instances in-place when their backoff has elapsed (no delete, no new row)', async () => {
      const server = makeServer({ id: 'srv-1', replicas: 1 });
      vi.mocked(serverRepo.findAll).mockResolvedValue([server]);
      vi.mocked(serverRepo.findById).mockResolvedValue(server);
      // ERROR instance with no nextRetryAt → retry is due immediately.
      vi.mocked(instanceRepo.findAll).mockResolvedValue([
        makeInstance({ id: 'inst-dead', serverId: 'srv-1', status: 'ERROR', containerId: 'ctr-dead' }),
      ]);

      const result = await service.reconcileAll();

      // Retry-in-place semantics: don't delete the row, don't create a
      // replacement. attemptCount needs to live on the same row so the
      // backoff schedule can actually escalate.
      expect(instanceRepo.delete).not.toHaveBeenCalled();
      expect(instanceRepo.create).not.toHaveBeenCalled();
      // retryInstance flips the row STARTING before attemptStart runs.
      expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-dead', 'STARTING', expect.anything());
      expect(result.reconciled).toBe(1);
    });

    it('leaves ERROR instances alone while their nextRetryAt is in the future', async () => {
      const server = makeServer({ id: 'srv-1', replicas: 1 });
      vi.mocked(serverRepo.findAll).mockResolvedValue([server]);
      vi.mocked(serverRepo.findById).mockResolvedValue(server);
      const futureRetry = new Date(Date.now() + 60_000).toISOString();
      vi.mocked(instanceRepo.findAll).mockResolvedValue([
        makeInstance({
          id: 'inst-waiting',
          serverId: 'srv-1',
          status: 'ERROR',
          metadata: { nextRetryAt: futureRetry, attemptCount: 2 },
        }),
      ]);

      const result = await service.reconcileAll();

      // Within the backoff window the reconciler must not delete the row,
      // not retry it, and not spawn a replacement (counting it against
      // the replica budget is what prevents tight create-fail-create churn).
      expect(instanceRepo.delete).not.toHaveBeenCalled();
      expect(instanceRepo.create).not.toHaveBeenCalled();
      expect(orchestrator.createContainer).not.toHaveBeenCalled();
      expect(result.reconciled).toBe(0);
    });

    it('escalates the backoff: attemptCount + nextRetryAt persist on retry failures', async () => {
      const server = makeServer({ id: 'srv-1', replicas: 1 });
      vi.mocked(serverRepo.findAll).mockResolvedValue([server]);
      vi.mocked(serverRepo.findById).mockResolvedValue(server);

      // Fail container creation so attemptStart goes down the markInstanceError path.
      vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('boom'));

      // Existing ERROR instance with attemptCount=2 (so the next failure
      // produces attemptCount=3, still inside the fast-retry window).
      vi.mocked(instanceRepo.findAll).mockResolvedValue([
        makeInstance({
          id: 'inst-1',
          serverId: 'srv-1',
          status: 'ERROR',
          metadata: { error: 'previous failure', attemptCount: 2, nextRetryAt: new Date(Date.now() - 1000).toISOString() },
        }),
      ]);
      // retryInstance refreshes via findById; let it return the same row.
      vi.mocked(instanceRepo.findById).mockImplementation(async () => makeInstance({
        id: 'inst-1',
        serverId: 'srv-1',
        status: 'STARTING',
        metadata: { error: 'previous failure', attemptCount: 2, nextRetryAt: new Date(Date.now() - 1000).toISOString() },
      }));

      await service.reconcileAll();

      // Look at the last updateStatus call — it should be the ERROR transition
      // with attemptCount bumped to 3.
      const errorCalls = vi.mocked(instanceRepo.updateStatus).mock.calls.filter(
        (c) => c[1] === 'ERROR',
      );
      expect(errorCalls.length).toBeGreaterThan(0);
      const lastErrorCall = errorCalls[errorCalls.length - 1]!;
      const meta = (lastErrorCall[2] as { metadata?: Record<string, unknown> } | undefined)?.metadata;
      expect(meta).toBeDefined();
      expect((meta as Record<string, unknown>)['attemptCount']).toBe(3);
      expect((meta as Record<string, unknown>)['nextRetryAt']).toBeTypeOf('string');
      // Reason should reference the boom we threw.
      expect(String((meta as Record<string, unknown>)['error'])).toContain('boom');
    });

    it('reconciles multiple servers independently', async () => {
      const srv1 = makeServer({ id: 'srv-1', name: 'grafana', replicas: 1, dockerImage: 'grafana:latest' });
      const srv2 = makeServer({ id: 'srv-2', name: 'node-red', replicas: 1, dockerImage: 'nodered:latest' });
      vi.mocked(serverRepo.findAll).mockResolvedValue([srv1, srv2]);
      vi.mocked(serverRepo.findById).mockImplementation(async (id) => {
        if (id === 'srv-1') return srv1;
        if (id === 'srv-2') return srv2;
        return null;
      });
      // srv-1 has a running instance, srv-2 has none
      vi.mocked(instanceRepo.findAll).mockImplementation(async (serverId) => {
        if (serverId === 'srv-1') return [makeInstance({ serverId: 'srv-1', status: 'RUNNING' })];
        return [];
      });

      const result = await service.reconcileAll();

      // Only srv-2 needed reconciliation
      expect(result.reconciled).toBe(1);
    });

    it('collects errors without stopping other servers', async () => {
      const srv1 = makeServer({ id: 'srv-1', name: 'broken', replicas: 1 });
      const srv2 = makeServer({ id: 'srv-2', name: 'healthy', replicas: 1, dockerImage: 'img:latest' });
      vi.mocked(serverRepo.findAll).mockResolvedValue([srv1, srv2]);
      vi.mocked(serverRepo.findById).mockImplementation(async (id) => {
        if (id === 'srv-2') return srv2;
        return null; // srv-1 can't be found → will error
      });
      vi.mocked(instanceRepo.findAll).mockResolvedValue([]);

      const result = await service.reconcileAll();

      // srv-1 errored, srv-2 reconciled
      expect(result.errors).toHaveLength(1);
      expect(result.errors[0]).toContain('broken');
      expect(result.reconciled).toBe(1);
    });
  });
});
