// Update Manager Tests
// Validates Docker image update detection, scheduling, rollback, and auto-update

const mockDockerInstance = {
  listContainers: jest.fn(),
  getContainer: jest.fn(),
  getImage: jest.fn(),
  pull: jest.fn(),
  createContainer: jest.fn(),
  modem: { followProgress: jest.fn() }
};

jest.mock('dockerode', () => jest.fn(() => mockDockerInstance));
jest.mock('fs');
jest.mock('https');

const fs = require('fs');
const https = require('https');

// Setup defaults BEFORE requiring singleton
fs.existsSync.mockReturnValue(false);
fs.readFileSync.mockReturnValue('{}');
fs.writeFileSync.mockReturnValue(undefined);

const updateManager = require('../src/managers/update-manager');

// Helper to create a fake https request that responds with a given statusCode/headers/body
function mockHttpsResponse({ statusCode = 200, headers = {}, body = '' } = {}) {
  return (options, cb) => {
    const res = {
      statusCode,
      headers,
      on: jest.fn((event, handler) => {
        if (event === 'data' && body) handler(Buffer.from(body));
        if (event === 'end') handler();
      })
    };
    if (cb) setImmediate(() => cb(res));
    return {
      on: jest.fn(),
      end: jest.fn(),
      setTimeout: jest.fn(),
      destroy: jest.fn()
    };
  };
}

beforeEach(() => {
  jest.clearAllMocks();
  jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });

  fs.existsSync.mockReturnValue(false);
  fs.readFileSync.mockReturnValue('{}');
  fs.writeFileSync.mockReturnValue(undefined);

  // Reset docker mocks
  mockDockerInstance.listContainers.mockReset();
  mockDockerInstance.getContainer.mockReset();
  mockDockerInstance.getImage.mockReset();
  mockDockerInstance.pull.mockReset();
  mockDockerInstance.createContainer.mockReset();
  mockDockerInstance.modem.followProgress.mockReset();

  // Reset internal state
  updateManager.history = [];
  updateManager.config = { autoUpdate: {} };
  updateManager.availableUpdates.clear();
  updateManager.checking = false;
  if (updateManager.checkInterval) {
    clearInterval(updateManager.checkInterval);
    updateManager.checkInterval = null;
  }
  if (updateManager.autoUpdateInterval) {
    clearInterval(updateManager.autoUpdateInterval);
    updateManager.autoUpdateInterval = null;
  }
});

afterEach(() => {
  updateManager.checking = false;
  if (updateManager.checkInterval) { clearInterval(updateManager.checkInterval); updateManager.checkInterval = null; }
  if (updateManager.autoUpdateInterval) { clearInterval(updateManager.autoUpdateInterval); updateManager.autoUpdateInterval = null; }
  jest.useRealTimers();
});

describe('UpdateManager — Docker image update lifecycle', () => {

  describe('start/stop lifecycle', () => {
    it('starts update checking', () => {
      updateManager.start();
      expect(updateManager.checking).toBe(true);
    });

    it('ignores double start', () => {
      updateManager.start();
      updateManager.start();
      expect(updateManager.checking).toBe(true);
    });

    it('stops checking and clears intervals', () => {
      updateManager.start();
      updateManager.stop();
      expect(updateManager.checking).toBe(false);
      expect(updateManager.checkInterval).toBeNull();
      expect(updateManager.autoUpdateInterval).toBeNull();
    });

    it('ignores stop when not checking', () => {
      updateManager.stop();
      expect(updateManager.checking).toBe(false);
    });
  });

  describe('extractTag', () => {
    it('extracts tag from image:tag', () => {
      expect(updateManager.extractTag('nginx:1.25')).toBe('1.25');
    });

    it('returns latest for untagged images', () => {
      expect(updateManager.extractTag('nginx')).toBe('latest');
    });

    it('handles LinuxServer images', () => {
      expect(updateManager.extractTag('lscr.io/linuxserver/plex:latest')).toBe('latest');
    });
  });

  describe('parseAuthHeader', () => {
    it('parses Docker Hub Bearer auth header', () => {
      const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
      const result = updateManager.parseAuthHeader(header);
      expect(result).toContain('https://auth.docker.io/token');
      expect(result).toContain('service=registry.docker.io');
      expect(result).toContain('scope=repository');
    });

    it('returns null for missing header', () => {
      expect(updateManager.parseAuthHeader(null)).toBeNull();
    });

    it('returns null for non-Bearer header', () => {
      expect(updateManager.parseAuthHeader('Basic realm="test"')).toBeNull();
    });
  });

  describe('extractPorts', () => {
    it('extracts host port mappings from inspect data', () => {
      const inspect = {
        NetworkSettings: {
          Ports: {
            '32400/tcp': [{ HostIp: '0.0.0.0', HostPort: '32400' }],
            '1900/udp': [{ HostIp: '0.0.0.0', HostPort: '1900' }]
          }
        }
      };

      const ports = updateManager.extractPorts(inspect);
      expect(ports).toHaveLength(2);
      expect(ports[0]).toEqual({ containerPort: '32400', hostPort: '32400', protocol: 'tcp' });
      expect(ports[1]).toEqual({ containerPort: '1900', hostPort: '1900', protocol: 'udp' });
    });

    it('returns empty for no port bindings', () => {
      const inspect = { NetworkSettings: { Ports: { '8080/tcp': null } } };
      expect(updateManager.extractPorts(inspect)).toEqual([]);
    });

    it('returns empty for missing NetworkSettings', () => {
      expect(updateManager.extractPorts({})).toEqual([]);
    });
  });

  describe('history management', () => {
    it('addToHistory appends and saves', () => {
      updateManager.addToHistory({ id: 'update-1', status: 'success' });
      expect(updateManager.history).toHaveLength(1);
      expect(fs.writeFileSync).toHaveBeenCalled();
    });

    it('caps history at 100 entries', () => {
      for (let i = 0; i < 110; i++) {
        updateManager.addToHistory({ id: `update-${i}` });
      }
      expect(updateManager.history).toHaveLength(100);
    });

    it('getHistory returns newest first', () => {
      updateManager.addToHistory({ id: 'old' });
      updateManager.addToHistory({ id: 'new' });
      const history = updateManager.getHistory();
      expect(history[0].id).toBe('new');
    });

    it('getHistory respects limit', () => {
      for (let i = 0; i < 10; i++) {
        updateManager.addToHistory({ id: `update-${i}` });
      }
      expect(updateManager.getHistory(3)).toHaveLength(3);
    });
  });

  describe('getAvailableUpdates', () => {
    it('returns empty array when no updates', () => {
      expect(updateManager.getAvailableUpdates()).toEqual([]);
    });

    it('returns list of available updates', () => {
      updateManager.availableUpdates.set('abc123', {
        containerId: 'abc123',
        containerName: 'plex',
        imageName: 'linuxserver/plex:latest'
      });
      const updates = updateManager.getAvailableUpdates();
      expect(updates).toHaveLength(1);
      expect(updates[0].containerName).toBe('plex');
    });
  });

  describe('scheduleUpdate', () => {
    it('schedules future update', () => {
      const futureTime = new Date(Date.now() + 60000).toISOString();
      updateManager.scheduleUpdate('abc123', futureTime);
      // No error thrown, timer set
    });

    it('rejects past time', () => {
      const pastTime = new Date(Date.now() - 60000).toISOString();
      expect(() => updateManager.scheduleUpdate('abc123', pastTime))
        .toThrow('must be in the future');
    });
  });

  describe('configureAutoUpdate', () => {
    it('stores auto-update config', () => {
      updateManager.configureAutoUpdate('abc123', {
        schedule: 'daily',
        maintenanceWindow: '02:00-04:00',
        autoRollback: true
      });

      const config = updateManager.getAutoUpdateConfig();
      expect(config['abc123'].enabled).toBe(true);
      expect(config['abc123'].schedule).toBe('daily');
      expect(config['abc123'].maintenanceWindow).toBe('02:00-04:00');
      expect(fs.writeFileSync).toHaveBeenCalled();
    });

    it('defaults schedule to weekly', () => {
      updateManager.configureAutoUpdate('abc123', {});
      expect(updateManager.getAutoUpdateConfig()['abc123'].schedule).toBe('weekly');
    });
  });

  describe('persistence (loadConfig/saveConfig)', () => {
    it('loadConfig returns saved config', () => {
      const saved = { autoUpdate: { abc123: { enabled: true, schedule: 'daily' } } };
      fs.existsSync.mockReturnValue(true);
      fs.readFileSync.mockReturnValue(JSON.stringify(saved));
      const config = updateManager.loadConfig();
      expect(config.autoUpdate.abc123.schedule).toBe('daily');
    });

    it('loadConfig returns defaults on missing file', () => {
      fs.existsSync.mockReturnValue(false);
      const config = updateManager.loadConfig();
      expect(config.autoUpdate).toEqual({});
    });

    it('loadConfig returns defaults on error', () => {
      fs.existsSync.mockReturnValue(true);
      fs.readFileSync.mockImplementation(() => { throw new Error('read error'); });
      const config = updateManager.loadConfig();
      expect(config.autoUpdate).toEqual({});
    });

    it('loadHistory returns empty array on missing file', () => {
      fs.existsSync.mockReturnValue(false);
      expect(updateManager.loadHistory()).toEqual([]);
    });

    it('saveConfig writes JSON', () => {
      updateManager.config = { autoUpdate: { test: { enabled: true } } };
      updateManager.saveConfig();
      expect(fs.writeFileSync).toHaveBeenCalledWith(
        expect.any(String),
        expect.stringContaining('test')
      );
    });

    it('saveConfig handles write error', () => {
      fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
      updateManager.saveConfig(); // should not throw
    });

    it('saveHistory handles write error', () => {
      fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
      updateManager.saveHistory(); // should not throw
    });
  });

  describe('formatChangelog', () => {
    it('formats repo description and tags', () => {
      const repoInfo = { description: 'Plex Media Server', pull_count: 1000000 };
      const tags = [
        { name: 'latest', last_pushed: '2026-04-01T00:00:00Z' },
        { name: '1.40', last_pushed: '2026-03-15T00:00:00Z' }
      ];

      const result = updateManager.formatChangelog(repoInfo, tags, 'latest');
      expect(result).toContain('Plex Media Server');
      expect(result).toContain('Recent tags');
      expect(result).toContain('latest');
      expect(result).toContain('1,000,000');
    });

    it('returns fallback for empty data', () => {
      const result = updateManager.formatChangelog(null, [], 'latest');
      expect(result).toBe('No changelog available');
    });
  });

  describe('verifyContainer', () => {
    it('returns true when container is running (no health check)', async () => {
      const container = {
        inspect: jest.fn().mockResolvedValue({
          State: { Running: true }
        })
      };
      const result = await updateManager.verifyContainer(container, 5000);
      expect(result).toBe(true);
    });

    it('returns true when container is healthy', async () => {
      const container = {
        inspect: jest.fn().mockResolvedValue({
          State: { Running: true, Health: { Status: 'healthy' } }
        })
      };
      const result = await updateManager.verifyContainer(container, 5000);
      expect(result).toBe(true);
    });

    it('throws on inspect error', async () => {
      const container = {
        inspect: jest.fn().mockRejectedValue(new Error('gone'))
      };
      await expect(updateManager.verifyContainer(container, 1000))
        .rejects.toThrow('Container verification failed');
    });
  });

  describe('checkForUpdates', () => {
    it('detects updates and stores them in availableUpdates', async () => {
      mockDockerInstance.listContainers.mockResolvedValue([
        { Id: 'abc123', Names: ['/plex'] }
      ]);
      mockDockerInstance.getContainer.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({
          Config: { Image: 'lscr.io/linuxserver/plex:latest' },
          Image: 'sha256:current12345678'
        })
      });
      // Custom registry returns null (not Docker Hub)
      jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:newdigest98765');

      await updateManager.checkForUpdates();

      expect(updateManager.availableUpdates.has('abc123')).toBe(true);
      const update = updateManager.availableUpdates.get('abc123');
      expect(update.containerName).toBe('plex');
      expect(update.imageName).toBe('lscr.io/linuxserver/plex:latest');

      updateManager.getLatestImageDigest.mockRestore();
    });

    it('removes from availableUpdates when no update needed', async () => {
      mockDockerInstance.listContainers.mockResolvedValue([
        { Id: 'abc123', Names: ['/plex'] }
      ]);
      mockDockerInstance.getContainer.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({
          Config: { Image: 'plex:latest' },
          Image: 'sha256:samedigest'
        })
      });
      jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:samedigest');

      // Pre-populate with stale update
      updateManager.availableUpdates.set('abc123', { stale: true });

      await updateManager.checkForUpdates();

      expect(updateManager.availableUpdates.has('abc123')).toBe(false);

      updateManager.getLatestImageDigest.mockRestore();
    });

    it('handles error during container inspect gracefully', async () => {
      mockDockerInstance.listContainers.mockResolvedValue([
        { Id: 'broken', Names: ['/broken'] }
      ]);
      mockDockerInstance.getContainer.mockReturnValue({
        inspect: jest.fn().mockRejectedValue(new Error('inspect failed'))
      });

      // Should not throw
      await expect(updateManager.checkForUpdates()).resolves.not.toThrow();
    });

    it('handles error during list containers gracefully', async () => {
      mockDockerInstance.listContainers.mockRejectedValue(new Error('docker down'));
      // Should not throw
      await expect(updateManager.checkForUpdates()).resolves.not.toThrow();
    });

    it('emits update-available event when new update detected', async () => {
      mockDockerInstance.listContainers.mockResolvedValue([
        { Id: 'newupdate', Names: ['/radarr'] }
      ]);
      mockDockerInstance.getContainer.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({
          Config: { Image: 'linuxserver/radarr:latest' },
          Image: 'sha256:oldradarr'
        })
      });
      jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:newradarr');

      const events = [];
      updateManager.on('update-available', e => events.push(e));

      await updateManager.checkForUpdates();

      expect(events).toHaveLength(1);
      expect(events[0].containerName).toBe('radarr');

      updateManager.removeAllListeners();
      updateManager.getLatestImageDigest.mockRestore();
    });
  });

  describe('getLatestImageDigest', () => {
    it('routes Docker Hub library images to getDockerHubDigest', async () => {
      jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:digest123');
      const result = await updateManager.getLatestImageDigest('nginx:latest');
      expect(result).toBe('sha256:digest123');
      expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('nginx', 'latest');
      updateManager.getDockerHubDigest.mockRestore();
    });

    it('routes Docker Hub user images to getDockerHubDigest', async () => {
      jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:abc');
      const result = await updateManager.getLatestImageDigest('linuxserver/plex:latest');
      expect(result).toBe('sha256:abc');
      expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('linuxserver/plex', 'latest');
      updateManager.getDockerHubDigest.mockRestore();
    });

    it('returns null for custom registries', async () => {
      const result = await updateManager.getLatestImageDigest('lscr.io/linuxserver/plex:latest');
      expect(result).toBeNull();
    });

    it('returns null on error', async () => {
      jest.spyOn(updateManager, 'getDockerHubDigest').mockRejectedValue(new Error('boom'));
      const result = await updateManager.getLatestImageDigest('nginx:latest');
      expect(result).toBeNull();
      updateManager.getDockerHubDigest.mockRestore();
    });

    it('defaults tag to latest when not specified', async () => {
      jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:xyz');
      await updateManager.getLatestImageDigest('redis');
      expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('redis', 'latest');
      updateManager.getDockerHubDigest.mockRestore();
    });
  });

  describe('getDockerHubDigest (https)', () => {
    it('returns digest from successful response', async () => {
      https.request.mockImplementation((options, cb) => {
        setImmediate(() => cb({
          statusCode: 200,
          headers: { 'docker-content-digest': 'sha256:fromregistry' },
          on: jest.fn()
        }));
        return { on: jest.fn(), end: jest.fn() };
      });

      const result = await updateManager.getDockerHubDigest('nginx', 'latest');
      expect(result).toBe('sha256:fromregistry');
    });

    it('handles 401 with no auth header by rejecting', async () => {
      https.request.mockImplementation((options, cb) => {
        setImmediate(() => cb({
          statusCode: 401,
          headers: {},
          on: jest.fn()
        }));
        return { on: jest.fn(), end: jest.fn() };
      });

      await expect(updateManager.getDockerHubDigest('nginx', 'latest')).rejects.toThrow();
    });

    it('rejects on https request error', async () => {
      https.request.mockImplementation(() => {
        const req = { on: jest.fn(), end: jest.fn() };
        // Trigger error event asynchronously
        setImmediate(() => {
          const errorHandler = req.on.mock.calls.find(c => c[0] === 'error');
          if (errorHandler) errorHandler[1](new Error('connection refused'));
        });
        return req;
      });

      await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
        .rejects.toThrow('connection refused');
    });

    it('normalizes library/ prefix for official images', async () => {
      let capturedPath;
      https.request.mockImplementation((options, cb) => {
        capturedPath = options.path;
        setImmediate(() => cb({
          statusCode: 200,
          headers: { 'docker-content-digest': 'sha256:digest' },
          on: jest.fn()
        }));
        return { on: jest.fn(), end: jest.fn() };
      });

      await updateManager.getDockerHubDigest('nginx', 'latest');
      expect(capturedPath).toBe('/v2/library/nginx/manifests/latest');
    });
  });

  describe('pullImage', () => {
    it('pulls image and resolves with output', async () => {
      mockDockerInstance.pull.mockImplementation((imageName, cb) => {
        cb(null, { stream: 'fake' });
      });
      mockDockerInstance.modem.followProgress.mockImplementation((stream, cb) => {
        cb(null, [{ status: 'Downloaded' }]);
      });

      const result = await updateManager.pullImage('nginx:latest');
      expect(result).toEqual([{ status: 'Downloaded' }]);
    });

    it('rejects on pull error', async () => {
      mockDockerInstance.pull.mockImplementation((imageName, cb) => {
        cb(new Error('image not found'));
      });

      await expect(updateManager.pullImage('bogus:latest')).rejects.toThrow('image not found');
    });

    it('rejects on followProgress error', async () => {
      mockDockerInstance.pull.mockImplementation((imageName, cb) => {
        cb(null, { stream: 'fake' });
      });
      mockDockerInstance.modem.followProgress.mockImplementation((stream, cb) => {
        cb(new Error('progress error'));
      });

      await expect(updateManager.pullImage('nginx:latest')).rejects.toThrow('progress error');
    });
  });

  describe('verifyContainer with timeout', () => {
    it('waits for container to start running', async () => {
      let inspectCount = 0;
      const container = {
        inspect: jest.fn().mockImplementation(() => {
          inspectCount++;
          if (inspectCount < 2) return Promise.resolve({ State: { Running: false } });
          return Promise.resolve({ State: { Running: true } });
        })
      };
      jest.useRealTimers();
      const result = await updateManager.verifyContainer(container, 5000);
      expect(result).toBe(true);
      expect(inspectCount).toBeGreaterThanOrEqual(2);
      jest.useFakeTimers();
    });

    it('throws on timeout when container never starts', async () => {
      const container = {
        inspect: jest.fn().mockResolvedValue({ State: { Running: false } })
      };
      jest.useRealTimers();
      await expect(updateManager.verifyContainer(container, 100))
        .rejects.toThrow();
      jest.useFakeTimers();
    });
  });

  describe('rollbackUpdate', () => {
    it('rolls back container from last successful backup', async () => {
      const backup = {
        containerId: 'old-id',
        containerName: 'plex',
        imageName: 'plex:latest',
        config: { Image: 'plex:latest', Env: [] },
        hostConfig: { Binds: [] }
      };

      updateManager.history.push({
        containerId: 'old-id',
        status: 'success',
        backup,
        timestamp: new Date().toISOString()
      });

      mockDockerInstance.getContainer.mockReturnValue({
        stop: jest.fn().mockResolvedValue(),
        remove: jest.fn().mockResolvedValue()
      });
      mockDockerInstance.createContainer.mockResolvedValue({
        start: jest.fn().mockResolvedValue()
      });

      const events = [];
      updateManager.on('rollback-complete', e => events.push(e));

      const result = await updateManager.rollbackUpdate('old-id');
      expect(result).toBe(true);
      expect(events).toHaveLength(1);
      expect(events[0].containerName).toBe('plex');

      updateManager.removeAllListeners();
    });

    it('throws when no backup exists', async () => {
      await expect(updateManager.rollbackUpdate('no-backup-id'))
        .rejects.toThrow('No backup found for rollback');
    });

    it('continues rollback even if old container removal fails', async () => {
      const backup = {
        containerName: 'plex',
        imageName: 'plex:latest',
        config: { Image: 'plex:latest' },
        hostConfig: {}
      };

      updateManager.history.push({
        containerId: 'gone-id',
        status: 'success',
        backup,
        timestamp: new Date().toISOString()
      });

      mockDockerInstance.getContainer.mockReturnValue({
        stop: jest.fn().mockRejectedValue(new Error('not found')),
        remove: jest.fn().mockRejectedValue(new Error('not found'))
      });
      mockDockerInstance.createContainer.mockResolvedValue({
        start: jest.fn().mockResolvedValue()
      });

      const result = await updateManager.rollbackUpdate('gone-id');
      expect(result).toBe(true);
    });
  });

  describe('updateContainer', () => {
    it('completes full update pipeline successfully', async () => {
      const inspectData = {
        Config: { Image: 'plex:latest', Env: ['TZ=UTC'] },
        HostConfig: { Binds: [] },
        NetworkSettings: { Ports: {} },
        Image: 'sha256:oldid',
        Name: '/plex'
      };

      mockDockerInstance.getContainer.mockReturnValue({
        inspect: jest.fn().mockResolvedValue(inspectData),
        stop: jest.fn().mockResolvedValue(),
        remove: jest.fn().mockResolvedValue()
      });
      mockDockerInstance.getImage.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({ RepoDigests: ['plex@sha256:olddigest'] }),
        remove: jest.fn().mockResolvedValue()
      });

      const newContainer = {
        id: 'new-container-id',
        inspect: jest.fn().mockResolvedValue({
          State: { Running: true },
          Image: 'sha256:newid',
          NetworkSettings: { Ports: {} }
        }),
        start: jest.fn().mockResolvedValue()
      };
      mockDockerInstance.createContainer.mockResolvedValue(newContainer);

      // Mock pullImage and verifyContainerExtended to skip their internals
      jest.spyOn(updateManager, 'pullImage').mockResolvedValue([{ status: 'Downloaded' }]);
      jest.spyOn(updateManager, 'verifyContainerExtended').mockResolvedValue(true);

      const events = [];
      updateManager.on('update-start', e => events.push({ type: 'start', ...e }));
      updateManager.on('update-complete', e => events.push({ type: 'complete', ...e }));

      const result = await updateManager.updateContainer('container-id');

      expect(result.status).toBe('success');
      expect(result.containerName).toBe('plex');
      expect(events).toHaveLength(2);

      updateManager.removeAllListeners();
      updateManager.pullImage.mockRestore();
      updateManager.verifyContainerExtended.mockRestore();
    });

    it('attempts rollback on failure', async () => {
      mockDockerInstance.getContainer.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({
          Config: { Image: 'plex:latest' },
          HostConfig: {},
          NetworkSettings: {},
          Image: 'sha256:old',
          Name: '/plex'
        }),
        stop: jest.fn().mockResolvedValue(),
        remove: jest.fn().mockResolvedValue()
      });
      mockDockerInstance.getImage.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({ RepoDigests: [] })
      });

      jest.spyOn(updateManager, 'pullImage').mockRejectedValue(new Error('pull failed'));
      jest.spyOn(updateManager, 'rollbackUpdate').mockResolvedValue(true);

      await expect(updateManager.updateContainer('fail-id')).rejects.toThrow('pull failed');
      expect(updateManager.rollbackUpdate).toHaveBeenCalledWith('fail-id');
      expect(updateManager.history[0].status).toBe('failed');

      updateManager.pullImage.mockRestore();
      updateManager.rollbackUpdate.mockRestore();
    });

    it('skips rollback when autoRollback is false', async () => {
      mockDockerInstance.getContainer.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({
          Config: { Image: 'plex:latest' },
          HostConfig: {},
          NetworkSettings: {},
          Image: 'sha256:old',
          Name: '/plex'
        }),
        stop: jest.fn().mockResolvedValue(),
        remove: jest.fn().mockResolvedValue()
      });
      mockDockerInstance.getImage.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({ RepoDigests: [] })
      });

      jest.spyOn(updateManager, 'pullImage').mockRejectedValue(new Error('pull failed'));
      jest.spyOn(updateManager, 'rollbackUpdate').mockResolvedValue(true);

      await expect(updateManager.updateContainer('id', { autoRollback: false }))
        .rejects.toThrow('pull failed');

      expect(updateManager.rollbackUpdate).not.toHaveBeenCalled();

      updateManager.pullImage.mockRestore();
      updateManager.rollbackUpdate.mockRestore();
    });
  });

  describe('fetchDockerHubRepo / fetchDockerHubTags', () => {
    it('fetchDockerHubRepo returns parsed response on 200', async () => {
      https.request.mockImplementation((options, cb) => {
        setImmediate(() => cb({
          statusCode: 200,
          headers: {},
          on: jest.fn((event, handler) => {
            if (event === 'data') handler(Buffer.from(JSON.stringify({
              description: 'Plex Media Server',
              pull_count: 1000000,
              star_count: 500
            })));
            if (event === 'end') handler();
          })
        }));
        return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
      });

      const result = await updateManager.fetchDockerHubRepo('linuxserver/plex', false);
      expect(result.description).toBe('Plex Media Server');
      expect(result.pull_count).toBe(1000000);
    });

    it('fetchDockerHubRepo returns null on 404', async () => {
      https.request.mockImplementation((options, cb) => {
        setImmediate(() => cb({
          statusCode: 404,
          headers: {},
          on: jest.fn((event, handler) => {
            if (event === 'end') handler();
          })
        }));
        return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
      });

      const result = await updateManager.fetchDockerHubRepo('nonexistent', false);
      expect(result).toBeNull();
    });

    it('fetchDockerHubRepo returns null on https error', async () => {
      https.request.mockImplementation(() => {
        const req = { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
        setImmediate(() => {
          const errCall = req.on.mock.calls.find(c => c[0] === 'error');
          if (errCall) errCall[1](new Error('connection refused'));
        });
        return req;
      });

      const result = await updateManager.fetchDockerHubRepo('nginx', true);
      expect(result).toBeNull();
    });

    it('fetchDockerHubTags returns results array on 200', async () => {
      https.request.mockImplementation((options, cb) => {
        setImmediate(() => cb({
          statusCode: 200,
          headers: {},
          on: jest.fn((event, handler) => {
            if (event === 'data') handler(Buffer.from(JSON.stringify({
              results: [
                { name: 'latest', last_pushed: '2026-04-01T00:00:00Z' },
                { name: '1.40', last_pushed: '2026-03-15T00:00:00Z' }
              ]
            })));
            if (event === 'end') handler();
          })
        }));
        return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
      });

      const result = await updateManager.fetchDockerHubTags('linuxserver/plex', false);
      expect(result).toHaveLength(2);
      expect(result[0].name).toBe('latest');
    });

    it('fetchDockerHubTags returns empty on non-200', async () => {
      https.request.mockImplementation((options, cb) => {
        setImmediate(() => cb({
          statusCode: 500,
          headers: {},
          on: jest.fn((event, handler) => {
            if (event === 'end') handler();
          })
        }));
        return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
      });

      const result = await updateManager.fetchDockerHubTags('nginx', true);
      expect(result).toEqual([]);
    });
  });

  describe('getChangelog', () => {
    it('returns changelog with repo info and tags', async () => {
      jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({
        description: 'Nginx web server',
        pull_count: 5000000,
        star_count: 1000,
        last_updated: '2026-04-01T00:00:00Z'
      });
      jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([
        { name: 'latest', last_pushed: '2026-04-01T00:00:00Z', digest: 'sha256:abcdef123456789', full_size: 1024 },
        { name: '1.25', last_pushed: '2026-03-15T00:00:00Z', digest: 'sha256:fedcba987654321', full_size: 2048 }
      ]);

      const result = await updateManager.getChangelog('nginx:latest');

      expect(result.imageName).toBe('nginx:latest');
      expect(result.currentTag).toBe('latest');
      expect(result.repository.description).toBe('Nginx web server');
      expect(result.repository.pullCount).toBe(5000000);
      expect(result.tags).toHaveLength(2);
      expect(result.urls.dockerHub).toContain('hub.docker.com');
      expect(result.changelog).toContain('Nginx web server');

      updateManager.fetchDockerHubRepo.mockRestore();
      updateManager.fetchDockerHubTags.mockRestore();
    });

    it('handles user images (namespace/repo format)', async () => {
      jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({
        description: 'Plex',
        pull_count: 1000,
        star_count: 50
      });
      jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([]);

      const result = await updateManager.getChangelog('linuxserver/plex:latest');

      expect(result.repository.name).toBe('linuxserver/plex');
      expect(result.urls.dockerHub).toContain('linuxserver/plex');

      updateManager.fetchDockerHubRepo.mockRestore();
      updateManager.fetchDockerHubTags.mockRestore();
    });

    it('returns fallback on fetch error', async () => {
      jest.spyOn(updateManager, 'fetchDockerHubRepo').mockRejectedValue(new Error('network'));

      const result = await updateManager.getChangelog('nginx:latest');

      expect(result.imageName).toBe('nginx:latest');
      expect(result.error).toBe('network');
      expect(result.changelog).toContain('Unable to fetch');

      updateManager.fetchDockerHubRepo.mockRestore();
    });

    it('defaults tag to latest when not specified', async () => {
      jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({});
      jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([]);

      const result = await updateManager.getChangelog('redis');
      expect(result.currentTag).toBe('latest');

      updateManager.fetchDockerHubRepo.mockRestore();
      updateManager.fetchDockerHubTags.mockRestore();
    });
  });

  describe('runAutoUpdates', () => {
    it('skips containers not enabled', async () => {
      updateManager.config.autoUpdate = {
        'cont-1': { enabled: false, schedule: 'daily' }
      };
      jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});

      await updateManager.runAutoUpdates();

      expect(updateManager.updateContainer).not.toHaveBeenCalled();
      updateManager.updateContainer.mockRestore();
    });

    it('skips outside maintenance window', async () => {
      // Force time to be 10am (outside 2-4am default window)
      jest.setSystemTime(new Date('2026-04-06T10:00:00'));
      updateManager.config.autoUpdate = {
        'cont-1': { enabled: true, schedule: 'daily' }
      };
      updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
      jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});

      await updateManager.runAutoUpdates();

      expect(updateManager.updateContainer).not.toHaveBeenCalled();
      updateManager.updateContainer.mockRestore();
    });

    it('skips containers without available updates', async () => {
      jest.setSystemTime(new Date('2026-04-06T03:00:00')); // Inside default 2-4am window
      updateManager.config.autoUpdate = {
        'cont-1': { enabled: true, schedule: 'daily' }
      };
      // No update in availableUpdates
      jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});

      await updateManager.runAutoUpdates();

      expect(updateManager.updateContainer).not.toHaveBeenCalled();
      updateManager.updateContainer.mockRestore();
    });

    it('runs update when in custom maintenance window', async () => {
      jest.setSystemTime(new Date('2026-04-06T03:00:00'));
      updateManager.config.autoUpdate = {
        'cont-1': { enabled: true, schedule: 'daily', maintenanceWindow: '02:00-05:00', autoRollback: true }
      };
      updateManager.availableUpdates.set('cont-1', { containerName: 'plex' });
      jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({ status: 'success' });

      const events = [];
      updateManager.on('auto-update-complete', e => events.push(e));

      await updateManager.runAutoUpdates();

      expect(updateManager.updateContainer).toHaveBeenCalledWith('cont-1', expect.any(Object));
      expect(events).toHaveLength(1);

      updateManager.removeAllListeners();
      updateManager.updateContainer.mockRestore();
    });

    it('emits auto-update-failed on update error', async () => {
      jest.setSystemTime(new Date('2026-04-06T03:00:00'));
      updateManager.config.autoUpdate = {
        'cont-1': { enabled: true, schedule: 'daily', maintenanceWindow: '02:00-05:00' }
      };
      updateManager.availableUpdates.set('cont-1', { containerName: 'plex' });
      jest.spyOn(updateManager, 'updateContainer').mockRejectedValue(new Error('update failed'));

      const events = [];
      updateManager.on('auto-update-failed', e => events.push(e));

      await updateManager.runAutoUpdates();

      expect(events).toHaveLength(1);
      expect(events[0].error).toBe('update failed');

      updateManager.removeAllListeners();
      updateManager.updateContainer.mockRestore();
    });

    it('skips weekly schedule when not Sunday', async () => {
      // Monday April 6 2026
      jest.setSystemTime(new Date('2026-04-06T03:00:00'));
      updateManager.config.autoUpdate = {
        'cont-1': { enabled: true, schedule: 'weekly', maintenanceWindow: '02:00-05:00' }
      };
      updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
      jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});

      await updateManager.runAutoUpdates();

      expect(updateManager.updateContainer).not.toHaveBeenCalled();
      updateManager.updateContainer.mockRestore();
    });

    it('skips when already ran today', async () => {
      jest.setSystemTime(new Date('2026-04-06T03:00:00'));
      updateManager.config.autoUpdate = {
        'cont-1': {
          enabled: true,
          schedule: 'daily',
          maintenanceWindow: '02:00-05:00',
          lastAutoUpdate: new Date('2026-04-06T02:30:00').toISOString()
        }
      };
      updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
      jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});

      await updateManager.runAutoUpdates();

      expect(updateManager.updateContainer).not.toHaveBeenCalled();
      updateManager.updateContainer.mockRestore();
    });
  });

  describe('DashCaddy scenarios', () => {
    it('Radarr auto-update: weekly at 3AM', () => {
      updateManager.configureAutoUpdate('radarr-id', {
        schedule: 'weekly',
        maintenanceWindow: '02:00-05:00',
        autoRollback: true
      });

      const config = updateManager.getAutoUpdateConfig()['radarr-id'];
      expect(config.schedule).toBe('weekly');
      expect(config.maintenanceWindow).toBe('02:00-05:00');
      expect(config.autoRollback).toBe(true);
    });

    it('tracks update for Plex container', () => {
      updateManager.availableUpdates.set('plex-id', {
        containerId: 'plex-id',
        containerName: 'plex',
        imageName: 'lscr.io/linuxserver/plex:latest',
        currentDigest: 'sha256:abc12',
        latestDigest: 'sha256:def34',
        detectedAt: new Date().toISOString()
      });

      const updates = updateManager.getAvailableUpdates();
      expect(updates[0].imageName).toBe('lscr.io/linuxserver/plex:latest');
    });
  });
});
