/**
 * Smoke tests for config-drift-detector.js
 * Verifies the ConfigDriftDetector class detects drift across all categories,
 * exposes polling control, extracts container ports, and dispatches
 * drift notifications.
 */

const EventEmitter = require('events');
const { ConfigDriftDetector } = require('../src/managers/config-drift-detector');

function makeContainer(overrides = {}) {
  return {
    Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
    Names: ['/dashcaddy-test'],
    Image: 'nginx:latest',
    State: 'running',
    Status: 'Up 5 minutes',
    Ports: [],
    Labels: {},
    ...overrides,
  };
}

function makeDetector(overrides = {}) {
  const servicesStateManager = {
    read: jest.fn().mockResolvedValue([]),
    update: jest.fn().mockImplementation(async (updater) => {
      const data = await servicesStateManager.read();
      const list = Array.isArray(data) ? data : (data?.services || []);
      const next = updater(list);
      return next;
    }),
    ...(overrides.servicesStateManager || {}),
  };

  const docker = {
    client: {
      listContainers: jest.fn().mockResolvedValue([]),
      ...(overrides.dockerClient || {}),
    },
  };

  const notification = {
    send: jest.fn().mockResolvedValue({ success: true }),
    ...(overrides.notification || {}),
  };

  const ctx = {
    docker,
    servicesStateManager,
    notification,
    log: {
      info: jest.fn(),
      error: jest.fn(),
      warn: jest.fn(),
      debug: jest.fn(),
    },
    logError: jest.fn(),
  };

  const detector = new ConfigDriftDetector(ctx);
  return { detector, ctx, docker, servicesStateManager, notification };
}

describe('ConfigDriftDetector', () => {
  describe('constructor', () => {
    test('extends EventEmitter and stores ctx dependencies', () => {
      const { detector, ctx } = makeDetector();
      expect(detector).toBeInstanceOf(EventEmitter);
      expect(detector.ctx).toBe(ctx);
      expect(detector.docker).toBe(ctx.docker);
      expect(detector.servicesStateManager).toBe(ctx.servicesStateManager);
      expect(detector.notification).toBe(ctx.notification);
      expect(detector.lastReport).toBeNull();
      expect(detector.isPolling()).toBe(false);
    });
  });

  describe('detect()', () => {
    test('returns a clean report when services and containers are empty', async () => {
      const { detector } = makeDetector();
      const report = await detector.detect();
      expect(report).toHaveProperty('checkedAt');
      expect(report.missingContainers).toEqual([]);
      expect(report.unknownContainers).toEqual([]);
      expect(report.portMismatch).toEqual([]);
      expect(report.stateMismatch).toEqual([]);
      expect(report.staleRecords).toEqual([]);
      expect(report.hasDrift).toBe(false);
    });

    test('flags missing containers when service containerId is not in Docker', async () => {
      const services = [{
        id: 'svc-1',
        name: 'svc-1',
        containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef0000000000000000',
      }];
      const { detector, servicesStateManager, docker } = makeDetector();
      servicesStateManager.read.mockResolvedValue(services);
      docker.client.listContainers.mockResolvedValue([]);

      const report = await detector.detect();
      expect(report.staleRecords).toHaveLength(1);
      expect(report.staleRecords[0].serviceId).toBe('svc-1');
      expect(report.hasDrift).toBe(true);
    });

    test('flags port mismatches between service config and container', async () => {
      const services = [{
        id: 'svc-1',
        name: 'svc-1',
        port: 8080,
        containerId: 'abcdef012345',
      }];
      const containers = [makeContainer({
        Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
        Ports: [{ PublicPort: 9090, PrivatePort: 80, Type: 'tcp' }],
      })];

      const { detector, servicesStateManager, docker } = makeDetector();
      servicesStateManager.read.mockResolvedValue(services);
      docker.client.listContainers.mockResolvedValue(containers);

      const report = await detector.detect();
      expect(report.portMismatch).toHaveLength(1);
      expect(report.portMismatch[0].configuredPort).toBe(8080);
      expect(report.portMismatch[0].actualPorts).toEqual([9090]);
    });

    test('flags state mismatch when service is not running', async () => {
      const services = [{
        id: 'svc-1',
        name: 'svc-1',
        containerId: 'abcdef012345',
      }];
      const containers = [makeContainer({ State: 'exited', Status: 'Exited (1) 5 minutes ago' })];

      const { detector, servicesStateManager, docker } = makeDetector();
      servicesStateManager.read.mockResolvedValue(services);
      docker.client.listContainers.mockResolvedValue(containers);

      const report = await detector.detect();
      expect(report.missingContainers).toHaveLength(1);
      expect(report.stateMismatch).toHaveLength(1);
      expect(report.stateMismatch[0].actualState).toBe('exited');
    });

    test('flags unknown managed containers not in services.json', async () => {
      const containers = [makeContainer({
        Labels: { 'sami.managed': 'true', 'sami.app': 'whoami' },
      })];

      const { detector, docker, servicesStateManager } = makeDetector();
      docker.client.listContainers.mockResolvedValue(containers);
      servicesStateManager.read.mockResolvedValue([]);

      const report = await detector.detect();
      expect(report.unknownContainers).toHaveLength(1);
      expect(report.unknownContainers[0].name).toBe('dashcaddy-test');
      expect(report.unknownContainers[0].app).toBe('whoami');
    });

    test('emits drift-detected and sends notification when drift exists', async () => {
      const services = [{
        id: 'svc-1',
        name: 'svc-1',
        containerId: 'missingcontainer00',
      }];
      const { detector, servicesStateManager, docker, notification } = makeDetector();
      servicesStateManager.read.mockResolvedValue(services);
      docker.client.listContainers.mockResolvedValue([]);

      const onDrift = jest.fn();
      detector.on('drift-detected', onDrift);
      await detector.detect();

      expect(onDrift).toHaveBeenCalledTimes(1);
      expect(notification.send).toHaveBeenCalledTimes(1);
      expect(notification.send.mock.calls[0][0]).toBe('drift-detected');
      const payload = notification.send.mock.calls[0][1];
      expect(payload.text).toMatch(/drift/i);
      expect(payload.report).toBeDefined();
    });

    test('caches the report on the instance', async () => {
      const { detector } = makeDetector();
      const report = await detector.detect();
      expect(detector.lastReport).toBe(report);
    });

    test('handles services as a wrapper object with .services field', async () => {
      const { detector, servicesStateManager } = makeDetector();
      servicesStateManager.read.mockResolvedValue({ services: [] });
      const report = await detector.detect();
      expect(report).toBeDefined();
      expect(report.hasDrift).toBe(false);
    });

    test('tolerates Docker listContainers failure (logs and continues)', async () => {
      const { detector, docker, ctx } = makeDetector();
      docker.client.listContainers.mockRejectedValue(new Error('docker daemon down'));
      const report = await detector.detect();
      expect(report).toBeDefined();
      expect(report.hasDrift).toBe(false);
      expect(ctx.log.error).toHaveBeenCalled();
    });
  });

  describe('autoFix()', () => {
    test('removes stale records via servicesStateManager.update', async () => {
      const services = [
        { id: 'svc-good', name: 'svc-good', containerId: 'liveid0000000000000000000000000000' },
        { id: 'svc-stale', name: 'svc-stale', containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef00000000' },
      ];
      const containers = [makeContainer({
        Id: 'liveid0000000000000000000000000000000000000000000000000000000000',
      })];

      const { detector, servicesStateManager, docker } = makeDetector();
      servicesStateManager.read.mockResolvedValue(services);
      servicesStateManager.update.mockImplementation(async (updater) => {
        const next = updater(services);
        return next;
      });
      docker.client.listContainers.mockResolvedValue(containers);

      const result = await detector.autoFix();
      expect(result.staleRemoved).toBe(1);
      expect(result.unknownFlagged).toBe(0);
      expect(servicesStateManager.update).toHaveBeenCalledTimes(1);
    });
  });

  describe('polling', () => {
    afterEach(() => {
      jest.useRealTimers();
    });

    test('startPolling/stopPolling toggles isPolling', () => {
      const { detector } = makeDetector();
      expect(detector.isPolling()).toBe(false);
      detector.startPolling(60000);
      expect(detector.isPolling()).toBe(true);
      detector.stopPolling();
      expect(detector.isPolling()).toBe(false);
    });

    test('startPolling clears any existing timer before starting a new one', () => {
      const { detector } = makeDetector();
      detector.startPolling(60000);
      const firstTimer = detector._pollTimer;
      detector.startPolling(120000);
      expect(detector._pollTimer).not.toBe(firstTimer);
      detector.stopPolling();
    });

    test('stopPolling is a safe no-op when not started', () => {
      const { detector } = makeDetector();
      expect(() => detector.stopPolling()).not.toThrow();
      expect(detector.isPolling()).toBe(false);
    });

    test('runs detect on the polling interval', async () => {
      jest.useFakeTimers();
      const { detector } = makeDetector();
      const detectSpy = jest.spyOn(detector, 'detect').mockResolvedValue({
        checkedAt: new Date().toISOString(),
        missingContainers: [],
        unknownContainers: [],
        portMismatch: [],
        stateMismatch: [],
        staleRecords: [],
        hasDrift: false,
      });

      detector.startPolling(1000);
      jest.advanceTimersByTime(3500);
      // 3 intervals should have fired (1000, 2000, 3000)
      expect(detectSpy.mock.calls.length).toBeGreaterThanOrEqual(3);
      detector.stopPolling();
      detectSpy.mockRestore();
    });
  });

  describe('_extractContainerPorts', () => {
    test('returns mapped public ports', () => {
      const { detector } = makeDetector();
      const ports = detector._extractContainerPorts({
        Ports: [
          { PublicPort: 8080, PrivatePort: 80, Type: 'tcp' },
          { PublicPort: 8443, PrivatePort: 443, Type: 'tcp' },
          { PrivatePort: 53, Type: 'udp' }, // No PublicPort → not exposed
        ],
      });
      expect(ports).toEqual([8080, 8443]);
    });

    test('returns [] when container has no Ports field', () => {
      const { detector } = makeDetector();
      expect(detector._extractContainerPorts({})).toEqual([]);
      expect(detector._extractContainerPorts({ Ports: null })).toEqual([]);
    });
  });

  describe('_sendDriftNotification', () => {
    test('returns early when no notification manager is present', async () => {
      const { detector } = makeDetector({ notification: null });
      // Replace the field with null/undefined to simulate missing
      detector.notification = null;
      const result = await detector._sendDriftNotification({ hasDrift: true });
      expect(result.success).toBe(false);
      expect(result.reason).toMatch(/no-notification-manager/i);
    });

    test('formats message with one line per drift category', async () => {
      const { detector, notification } = makeDetector();
      const report = {
        missingContainers: [{ name: 'app-a' }],
        unknownContainers: [{ name: 'app-b' }],
        portMismatch: [{ name: 'app-c' }],
        stateMismatch: [],
        staleRecords: [{ name: 'app-d' }],
        hasDrift: true,
      };
      await detector._sendDriftNotification(report);
      expect(notification.send).toHaveBeenCalledTimes(1);
      const payload = notification.send.mock.calls[0][1];
      expect(payload.text).toMatch(/Missing containers: app-a/);
      expect(payload.text).toMatch(/Unknown managed containers: app-b/);
      expect(payload.text).toMatch(/Port mismatches: app-c/);
      expect(payload.text).toMatch(/Stale records: app-d/);
      expect(payload.report).toBe(report);
    });
  });
});
