// Port Lock Manager Tests
// Validates atomic port allocation for concurrent Docker deployments

jest.mock('proper-lockfile');
jest.mock('fs');

const fs = require('fs');
const lockfile = require('proper-lockfile');

// Setup defaults BEFORE requiring singleton (constructor calls ensureLockDirectory)
fs.existsSync.mockReturnValue(true);
fs.mkdirSync.mockReturnValue(undefined);
fs.writeFileSync.mockReturnValue(undefined);
fs.readdirSync.mockReturnValue([]);
fs.unlinkSync.mockReturnValue(undefined);
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
lockfile.check.mockResolvedValue(false);

const portLockManager = require('../src/managers/port-lock-manager');

beforeEach(() => {
  jest.clearAllMocks();
  portLockManager.activeLocks.clear();

  // Restore defaults
  fs.existsSync.mockReturnValue(true);
  fs.mkdirSync.mockReturnValue(undefined);
  fs.writeFileSync.mockReturnValue(undefined);
  fs.readdirSync.mockReturnValue([]);
  fs.unlinkSync.mockReturnValue(undefined);
  lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
  lockfile.check.mockResolvedValue(false);
});

describe('PortLockManager — concurrent deploy safety', () => {

  describe('acquirePorts', () => {
    it('rejects empty array', async () => {
      await expect(portLockManager.acquirePorts([])).rejects.toThrow('non-empty array');
    });

    it('rejects non-array', async () => {
      await expect(portLockManager.acquirePorts('8080')).rejects.toThrow('non-empty array');
    });

    it('acquires lock for a single port', async () => {
      const mockRelease = jest.fn().mockResolvedValue();
      lockfile.lock.mockResolvedValue(mockRelease);

      const lockId = await portLockManager.acquirePorts(['8080']);
      expect(lockId).toMatch(/^lock-/);
      expect(lockfile.lock).toHaveBeenCalledTimes(1);
    });

    it('acquires locks for multiple ports in sorted order (deadlock prevention)', async () => {
      const callOrder = [];
      lockfile.lock.mockImplementation((filePath) => {
        callOrder.push(filePath);
        return Promise.resolve(jest.fn().mockResolvedValue());
      });

      await portLockManager.acquirePorts(['9090', '3001', '8080']);

      // Ports sorted numerically: 3001, 8080, 9090
      expect(callOrder[0]).toContain('port-3001.lock');
      expect(callOrder[1]).toContain('port-8080.lock');
      expect(callOrder[2]).toContain('port-9090.lock');
    });

    it('deduplicates ports', async () => {
      await portLockManager.acquirePorts(['8080', '8080', '8080']);
      expect(lockfile.lock).toHaveBeenCalledTimes(1);
    });

    it('creates lock file for new ports', async () => {
      fs.existsSync.mockReturnValue(false);
      await portLockManager.acquirePorts(['7878']);
      expect(fs.writeFileSync).toHaveBeenCalledWith(
        expect.stringContaining('port-7878.lock'),
        expect.stringContaining('"port"')
      );
    });

    it('stores lock in activeLocks map', async () => {
      const lockId = await portLockManager.acquirePorts(['8080']);
      const status = portLockManager.getStatus();
      expect(status.activeLocks).toBe(1);
      expect(status.locks[0].lockId).toBe(lockId);
      expect(status.locks[0].ports).toEqual(['8080']);
    });

    it('rolls back on partial failure — releases acquired locks', async () => {
      const released = [];
      let callCount = 0;
      lockfile.lock.mockImplementation(() => {
        callCount++;
        if (callCount === 2) return Promise.reject(new Error('Port in use'));
        const release = jest.fn().mockImplementation(() => {
          released.push(callCount);
          return Promise.resolve();
        });
        return Promise.resolve(release);
      });

      await expect(portLockManager.acquirePorts(['3001', '8080']))
        .rejects.toThrow('Failed to acquire port locks');

      // First lock should have been released during rollback
      expect(released.length).toBe(1);
    });
  });

  describe('releasePorts', () => {
    it('releases all locks for a lock ID', async () => {
      const mockRelease = jest.fn().mockResolvedValue();
      lockfile.lock.mockResolvedValue(mockRelease);

      const lockId = await portLockManager.acquirePorts(['8080', '9090']);
      await portLockManager.releasePorts(lockId);

      expect(mockRelease).toHaveBeenCalledTimes(2);
      expect(portLockManager.getStatus().activeLocks).toBe(0);
    });

    it('handles already-released lock ID gracefully', async () => {
      // Should not throw
      await portLockManager.releasePorts('nonexistent-lock-id');
    });

    it('continues releasing remaining locks if one fails', async () => {
      const releases = [
        jest.fn().mockRejectedValue(new Error('release error')),
        jest.fn().mockResolvedValue(),
      ];
      let callIdx = 0;
      lockfile.lock.mockImplementation(() => {
        return Promise.resolve(releases[callIdx++]);
      });

      const lockId = await portLockManager.acquirePorts(['3001', '8080']);
      await portLockManager.releasePorts(lockId);

      // Both should have been called despite first failure
      expect(releases[0]).toHaveBeenCalled();
      expect(releases[1]).toHaveBeenCalled();
      expect(portLockManager.getStatus().activeLocks).toBe(0);
    });
  });

  describe('isPortLocked', () => {
    it('returns false when lock file does not exist', async () => {
      fs.existsSync.mockReturnValue(false);
      const result = await portLockManager.isPortLocked('8080');
      expect(result).toBe(false);
    });

    it('returns true when port is actively locked', async () => {
      fs.existsSync.mockReturnValue(true);
      lockfile.check.mockResolvedValue(true);
      const result = await portLockManager.isPortLocked('8080');
      expect(result).toBe(true);
    });

    it('returns false when port lock is stale', async () => {
      fs.existsSync.mockReturnValue(true);
      lockfile.check.mockResolvedValue(false);
      const result = await portLockManager.isPortLocked('8080');
      expect(result).toBe(false);
    });

    it('returns false on check error (fail-open for deployments)', async () => {
      fs.existsSync.mockReturnValue(true);
      lockfile.check.mockRejectedValue(new Error('check error'));
      const result = await portLockManager.isPortLocked('8080');
      expect(result).toBe(false);
    });
  });

  describe('getStatus', () => {
    it('returns empty state when no locks active', () => {
      const status = portLockManager.getStatus();
      expect(status.activeLocks).toBe(0);
      expect(status.locks).toEqual([]);
      expect(status.lockDirectory).toContain('.port-locks');
    });

    it('includes age and timestamp for active locks', async () => {
      await portLockManager.acquirePorts(['8080']);
      const status = portLockManager.getStatus();
      expect(status.activeLocks).toBe(1);
      expect(status.locks[0].age).toBeGreaterThanOrEqual(0);
      expect(status.locks[0].timestamp).toBeDefined();
    });
  });

  describe('cleanupStaleLocks', () => {
    it('removes stale lock files (not actively locked)', async () => {
      fs.readdirSync.mockReturnValue(['port-8080.lock', 'port-9090.lock']);
      lockfile.check.mockResolvedValue(false); // not locked = stale

      await portLockManager.cleanupStaleLocks();

      expect(fs.unlinkSync).toHaveBeenCalledTimes(2);
    });

    it('skips actively locked files', async () => {
      fs.readdirSync.mockReturnValue(['port-8080.lock']);
      lockfile.check.mockResolvedValue(true); // actively locked

      await portLockManager.cleanupStaleLocks();

      expect(fs.unlinkSync).not.toHaveBeenCalled();
    });

    it('skips non-.lock files', async () => {
      fs.readdirSync.mockReturnValue(['readme.txt', 'port-8080.lock']);
      lockfile.check.mockResolvedValue(false);

      await portLockManager.cleanupStaleLocks();

      expect(fs.unlinkSync).toHaveBeenCalledTimes(1);
    });

    it('handles ENOENT errors gracefully', async () => {
      fs.readdirSync.mockReturnValue(['port-8080.lock']);
      const enoent = new Error('ENOENT');
      enoent.code = 'ENOENT';
      lockfile.check.mockRejectedValue(enoent);

      // Should not throw
      await portLockManager.cleanupStaleLocks();
      expect(fs.unlinkSync).not.toHaveBeenCalled();
    });
  });

  describe('DashCaddy deployment scenarios', () => {
    it('Radarr deploy: locks host port 7878', async () => {
      await portLockManager.acquirePorts(['7878']);
      expect(lockfile.lock).toHaveBeenCalledWith(
        expect.stringContaining('port-7878.lock'),
        expect.any(Object)
      );
    });

    it('Plex deploy: locks multiple ports (32400, 1900, 8324, 32469)', async () => {
      const plexPorts = ['32400', '1900', '8324', '32469'];
      await portLockManager.acquirePorts(plexPorts);
      expect(lockfile.lock).toHaveBeenCalledTimes(4);
    });

    it('concurrent deploys: second deploy gets separate lock ID', async () => {
      const release1 = jest.fn().mockResolvedValue();
      const release2 = jest.fn().mockResolvedValue();
      lockfile.lock.mockResolvedValueOnce(release1).mockResolvedValueOnce(release2);

      const lockId1 = await portLockManager.acquirePorts(['8080']);
      const lockId2 = await portLockManager.acquirePorts(['9090']);

      expect(lockId1).not.toBe(lockId2);
      expect(portLockManager.getStatus().activeLocks).toBe(2);
    });

    it('deploy cleanup: release after container start', async () => {
      const lockId = await portLockManager.acquirePorts(['7878']);
      expect(portLockManager.getStatus().activeLocks).toBe(1);

      // Simulate container started successfully
      await portLockManager.releasePorts(lockId);
      expect(portLockManager.getStatus().activeLocks).toBe(0);
    });
  });
});
