import EventEmitter from 'events';
import fetch from 'cross-fetch';
import Redis, { ReplyError } from 'ioredis';
import type { App } from '../../src';
import type { RedisLocker } from '../../src/util/locking/RedisLocker';
import type { RedisReadWriteLock, RedisResourceLock } from '../../src/util/locking/scripts/RedisLuaScripts';
import { REDIS_LUA_SCRIPTS } from '../../src/util/locking/scripts/RedisLuaScripts';
import { describeIf, getPort } from '../util/Util';
import { getDefaultVariables, getTestConfigPath, instantiateFromConfig } from './Config';
/**
 * Test the general functionality of the server using a RedisLocker with Read-Write strategy.
 */
describeIf('docker')('A server with a RedisLocker', (): void => {
  const port = getPort('RedisLocker');
  const baseUrl = `http://localhost:${port}/`;
  let app: App;
  let locker: RedisLocker;

  beforeAll(async(): Promise<void> => {
    const instances = await instantiateFromConfig(
      'urn:solid-server:test:Instances',
      getTestConfigPath('server-redis-lock.json'),
      getDefaultVariables(port, baseUrl),
    ) as Record<string, any>;
    ({ app, locker } = instances);
    await app.start();
  });

  afterAll(async(): Promise<void> => {
    await app.stop();
  });

  describe('has a locker that', (): void => {
    it('can add a file to the store, read it and delete it.', async(): Promise<void> => {
      // Create file
      const fileUrl = `${baseUrl}testfile2.txt`;
      const fileData = 'TESTFILE2';

      let response = await fetch(fileUrl, {
        method: 'PUT',
        headers: {
          'content-type': 'text/plain',
        },
        body: fileData,
      });
      expect(response.status).toBe(201);

      // Get file
      response = await fetch(fileUrl);
      expect(response.status).toBe(200);
      const body = await response.text();
      expect(body).toContain('TESTFILE2');

      // DELETE file
      response = await fetch(fileUrl, { method: 'DELETE' });
      expect(response.status).toBe(205);
      response = await fetch(fileUrl);
      expect(response.status).toBe(404);
    });

    it('can create a folder and delete it.', async(): Promise<void> => {
      const containerPath = 'secondfolder/';
      const containerUrl = `${baseUrl}${containerPath}`;
      // PUT
      let response = await fetch(containerUrl, {
        method: 'PUT',
        headers: {
          'content-type': 'text/plain',
        },
      });
      expect(response.status).toBe(201);

      // GET
      response = await fetch(containerUrl);
      expect(response.status).toBe(200);

      // DELETE
      response = await fetch(containerUrl, { method: 'DELETE' });
      expect(response.status).toBe(205);
      response = await fetch(containerUrl);
      expect(response.status).toBe(404);
    });

    it('can get a resource multiple times.', async(): Promise<void> => {
      const fileUrl = `${baseUrl}image.png`;
      const fileData = 'testtesttest';

      let response = await fetch(fileUrl, {
        method: 'PUT',
        headers: {
          'content-type': 'text/plain',
        },
        body: fileData,
      });
      expect(response.status).toBe(201);

      // GET 4 times
      for (let i = 0; i < 4; i++) {
        const res = await fetch(fileUrl);
        expect(res.status).toBe(200);
        expect(res.headers.get('content-type')).toBe('text/plain');
        const body = await res.text();
        expect(body).toContain('testtesttest');
      }

      // DELETE
      response = await fetch(fileUrl, { method: 'DELETE' });
      expect(response.status).toBe(205);
      response = await fetch(fileUrl);
      expect(response.status).toBe(404);
    });
  });

  describe('implements ResoureLocker and', (): void => {
    const identifier = { path: 'http://test.com/foo' };

    it('can acquire a resource.', async(): Promise<void> => {
      await expect(locker.acquire(identifier)).resolves.toBeUndefined();
      // Clean up lock
      await locker.release(identifier);
    });

    it('can release a resource.', async(): Promise<void> => {
      await expect(locker.acquire(identifier)).resolves.toBeUndefined();
      await expect(locker.release(identifier)).resolves.toBeUndefined();
    });

    it('can acquire different locks simultaneously.', async(): Promise<void> => {
      const lock1 = locker.acquire({ path: 'path1' });
      const lock2 = locker.acquire({ path: 'path2' });
      const lock3 = locker.acquire({ path: 'path3' });
      const release1 = locker.release({ path: 'path1' });
      const release2 = locker.release({ path: 'path2' });
      const release3 = locker.release({ path: 'path3' });

      await expect(Promise.all([ lock1, lock2, lock3 ])).resolves.toBeDefined();
      // Clean up locks
      await Promise.all([ release1, release2, release3 ]);
    });

    it('cannot acquire the same lock simultaneously.', async(): Promise<void> => {
      await expect(locker.acquire(identifier)).resolves.toBeUndefined();
      await expect(locker.acquire(identifier)).rejects
        .toThrow(/The operation did not succeed after the set maximum of tries \(\d+\)./u);
      await expect(locker.acquire(identifier)).rejects
        .toThrow(/The operation did not succeed after the set maximum of tries \(\d+\)./u);
    });
  });

  describe('implements ReadWriteLocker and', (): void => {
    const identifier = { path: 'http://test.com/foo' };

    it('can read a resource.', async(): Promise<void> => {
      const testFn = jest.fn();
      await expect(locker.withReadLock(identifier, (): any => testFn())).resolves.toBeUndefined();
      expect(testFn).toHaveBeenCalled();
    });

    it('can write a resource.', async(): Promise<void> => {
      const testFn = jest.fn();
      await expect(locker.withWriteLock(identifier, (): any => testFn())).resolves.toBeUndefined();
      expect(testFn).toHaveBeenCalled();
    });

    it('can read a resource twice again after it was unlocked.', async(): Promise<void> => {
      const testFn = jest.fn();
      await expect(locker.withReadLock(identifier, (): any => testFn())).resolves.toBeUndefined();
      expect(testFn).toHaveBeenCalledTimes(1);
      await expect(locker.withReadLock(identifier, (): any => testFn())).resolves.toBeUndefined();
      expect(testFn).toHaveBeenCalledTimes(2);
    });

    it('can acquire different readLocks simultaneously.', async(): Promise<void> => {
      const testFn = jest.fn();
      const lock1 = locker.withReadLock({ path: 'path1' }, (): any => testFn());
      const lock2 = locker.withReadLock({ path: 'path2' }, (): any => testFn());
      const lock3 = locker.withReadLock({ path: 'path3' }, (): any => testFn());

      await expect(Promise.all([ lock1, lock2, lock3 ])).resolves.toBeDefined();
    });

    it('can acquire different writeLocks simultaneously.', async(): Promise<void> => {
      const testFn = jest.fn();
      const lock1 = locker.withWriteLock({ path: 'path1' }, (): any => testFn());
      const lock2 = locker.withWriteLock({ path: 'path2' }, (): any => testFn());
      const lock3 = locker.withWriteLock({ path: 'path3' }, (): any => testFn());

      await expect(Promise.all([ lock1, lock2, lock3 ])).resolves.toBeDefined();
    });

    it('can acquire the same readLock simultaneously.', async(): Promise<void> => {
      let res = '';
      let countdown = 3;
      const releaseSignal = new EventEmitter();
      releaseSignal.on('countdown', (): void => {
        countdown -= 1;
        // Start releasing locks after 3 inits of the promises below
        if (countdown === 0) {
          [ 1, 0, 2 ].forEach((num): unknown => releaseSignal.emit(`release${num}`));
        }
      });
      const promises = [ 0, 1, 2 ].map((num): Promise<any> =>
        locker.withReadLock(identifier, async(): Promise<void> => {
          res += `l${num}`;
          await new Promise<void>((resolve): any => {
            releaseSignal.on(`release${num}`, resolve);
            releaseSignal.emit('countdown');
          });
          res += `r${num}`;
        }));

      await Promise.all(promises);
      expect(res).toBe('l0l1l2r1r0r2');
    });

    it('cannot acquire the same writeLock simultaneously.', async(): Promise<void> => {
      let res = '';

      await expect(locker.withWriteLock(identifier, async(): Promise<void> => {
        res += 'l0';
        await expect(locker.withWriteLock(identifier, (): void => {
          res += 'l1';
          res += 'r1';
        })).rejects.toThrow();
        res += 'r0';
      })).resolves.toBeUndefined();

      await expect(locker.withWriteLock(identifier, async(): Promise<void> => {
        res += 'l2';
        res += 'r2';
      })).resolves.toBeUndefined();

      expect(res).toBe('l0r0l2r2');
    });
  });

  describe('defines custom Redis lua functions', (): void => {
    let redis: Redis & RedisReadWriteLock & RedisResourceLock;

    async function clearRedis(): Promise<void> {
      const keys = await redis.keys('*');
      if (keys && keys.length > 0) {
        await redis.del(keys);
      }
    }

    beforeAll(async(): Promise<void> => {
      redis = new Redis('127.0.0.1:6379') as Redis & RedisReadWriteLock & RedisResourceLock;

      // Register lua scripts
      for (const [ name, script ] of Object.entries(REDIS_LUA_SCRIPTS)) {
        redis.defineCommand(name, { numberOfKeys: 1, lua: script });
      }

      await clearRedis();
    });

    afterAll(async(): Promise<void> => {
      await redis.quit();
    });

    beforeEach(async(): Promise<void> => {
      await clearRedis();
    });

    it('#acquireReadLock.', async(): Promise<void> => {
      const key1 = 'key1';
      const writeKey1 = `${key1}.wlock`;
      const countKey1 = `${key1}.count`;
      // Test fails
      await redis.set(writeKey1, 'locked');
      await expect(redis.acquireReadLock(key1)).resolves.toBe(0);

      // Test succeeds
      await redis.del(writeKey1);
      await expect(redis.acquireReadLock(key1)).resolves.toBe(1);
      await expect(redis.get(countKey1)).resolves.toBe('1');
      await expect(redis.acquireReadLock(key1)).resolves.toBe(1);
      await expect(redis.get(countKey1)).resolves.toBe('2');
      await expect(redis.acquireReadLock(key1)).resolves.toBe(1);
      await expect(redis.get(countKey1)).resolves.toBe('3');
    });

    it('#acquireWriteLock.', async(): Promise<void> => {
      const key1 = 'key1';
      const writeKey1 = `${key1}.wlock`;
      const countKey1 = `${key1}.count`;

      // Test fails because count > 0
      await expect(redis.incr(countKey1)).resolves.toBe(1);
      await expect(redis.acquireWriteLock(key1)).resolves.toBe(0);

      // Test fails because write lock is present
      await clearRedis();
      await redis.set(writeKey1, 'locked');
      await expect(redis.acquireWriteLock(key1)).resolves.toBe(0);

      // Test succeeds
      await clearRedis();
      await expect(redis.acquireWriteLock(key1)).resolves.toBe('OK');

      // Test fails again
      await expect(redis.acquireWriteLock(key1)).resolves.toBe(0);
    });

    it('#releaseReadLock.', async(): Promise<void> => {
      const key1 = 'key1';
      const countKey1 = `${key1}.count`;

      // Test succeeds
      await expect(redis.acquireReadLock(key1)).resolves.toBe(1);
      await expect(redis.acquireReadLock(key1)).resolves.toBe(1);
      await expect(redis.acquireReadLock(key1)).resolves.toBe(1);
      await expect(redis.get(countKey1)).resolves.toBe('3');

      await expect(redis.releaseReadLock(key1)).resolves.toBe(1);
      await expect(redis.releaseReadLock(key1)).resolves.toBe(1);
      await expect(redis.releaseReadLock(key1)).resolves.toBe(1);
      await expect(redis.get(countKey1)).resolves.toBe('0');

      // Test fails
      await expect(redis.releaseReadLock(key1)).rejects
        .toThrow(ReplyError);
      await expect(redis.releaseReadLock(key1)).rejects
        .toThrow('Error trying to release readlock when read count was 0.');
    });

    it('#releaseWriteLock.', async(): Promise<void> => {
      const key1 = 'key1';
      const writeKey1 = `${key1}.wlock`;

      // Test fails
      await expect(redis.releaseWriteLock(key1)).rejects
        .toThrow(ReplyError);
      await expect(redis.releaseWriteLock(key1)).rejects
        .toThrow('Error trying to release writelock that did not exist.');

      // Test succeeds
      await redis.acquireWriteLock(key1);
      await expect(redis.exists(writeKey1)).resolves.toBe(1);
      await expect(redis.get(writeKey1)).resolves.toBe('locked');
      await expect(redis.releaseWriteLock(key1)).resolves.toBe(1);
      await expect(redis.exists(writeKey1)).resolves.toBe(0);
    });

    it('#acquireLock.', async(): Promise<void> => {
      const key1 = 'key1';
      const lockKey1 = `${key1}.lock`;
      // Test succeeds
      await expect(redis.acquireLock(key1)).resolves.toBe('OK');
      await expect(redis.exists(lockKey1)).resolves.toBe(1);
      await expect(redis.get(lockKey1)).resolves.toBe('locked');

      // Test fails
      await expect(redis.acquireLock(key1)).resolves.toBe(0);
      await expect(redis.exists(lockKey1)).resolves.toBe(1);
      await expect(redis.get(lockKey1)).resolves.toBe('locked');

      // Test succeeds again
      await redis.releaseLock(key1);
      await expect(redis.exists(lockKey1)).resolves.toBe(0);
      await expect(redis.acquireLock(key1)).resolves.toBe('OK');
    });

    it('#releaseLock.', async(): Promise<void> => {
      const key1 = 'key1';
      const lockKey1 = `${key1}.lock`;

      // Test fails
      await expect(redis.releaseLock(key1)).rejects
        .toThrow(ReplyError);
      await expect(redis.releaseLock(key1)).rejects
        .toThrow('Error trying to release lock that did not exist.');

      // Test succeeds
      await redis.acquireLock(key1);
      await expect(redis.exists(lockKey1)).resolves.toBe(1);
      await expect(redis.get(lockKey1)).resolves.toBe('locked');
      await expect(redis.releaseLock(key1)).resolves.toBe(1);
      await expect(redis.exists(lockKey1)).resolves.toBe(0);
    });
  });
});
