import Container from 'typedi';
import { CacheService } from '@/services/cache.service';
import type { MemoryCache } from 'cache-manager';
import type { RedisCache } from 'cache-manager-ioredis-yet';
import config from '@/config';

const cacheService = Container.get(CacheService);

function setDefaultConfig() {
	config.set('executions.mode', 'regular');
	config.set('cache.enabled', true);
	config.set('cache.backend', 'memory');
	config.set('cache.memory.maxSize', 1 * 1024 * 1024);
}

interface TestObject {
	test: string;
	test2: number;
	test3?: TestObject & { test4: TestObject };
}

const testObject: TestObject = {
	test: 'test',
	test2: 123,
	test3: {
		test: 'test3',
		test2: 123,
		test4: {
			test: 'test4',
			test2: 123,
		},
	},
};

describe('cacheService', () => {
	beforeAll(async () => {
		jest.mock('ioredis', () => {
			const Redis = require('ioredis-mock');
			if (typeof Redis === 'object') {
				// the first mock is an ioredis shim because ioredis-mock depends on it
				// https://github.com/stipsan/ioredis-mock/blob/master/src/index.js#L101-L111
				return {
					Command: { _transformer: { argument: {}, reply: {} } },
				};
			}
			// second mock for our code
			return function (...args: any) {
				return new Redis(args);
			};
		});
	});

	beforeEach(async () => {
		setDefaultConfig();
		await Container.get(CacheService).destroy();
	});

	test('should create a memory cache by default', async () => {
		await cacheService.init();
		await expect(cacheService.getCache()).resolves.toBeDefined();
		const candidate = (await cacheService.getCache()) as MemoryCache;
		// type guard to check that a MemoryCache is returned and not a RedisCache (which does not have a size property)
		expect(candidate.store.size).toBeDefined();
	});

	test('should cache and retrieve a value', async () => {
		await cacheService.init();
		await expect(cacheService.getCache()).resolves.toBeDefined();
		await cacheService.set('testString', 'test');
		await cacheService.set('testNumber1', 123);

		await expect(cacheService.get('testString')).resolves.toBe('test');
		expect(typeof (await cacheService.get('testString'))).toBe('string');
		await expect(cacheService.get('testNumber1')).resolves.toBe(123);
		expect(typeof (await cacheService.get('testNumber1'))).toBe('number');
	});

	test('should honour ttl values', async () => {
		await cacheService.set('testString', 'test', 10);
		await cacheService.set('testNumber1', 123, 1000);

		const store = (await cacheService.getCache())?.store;

		expect(store).toBeDefined();

		await expect(store!.ttl('testString')).resolves.toBeLessThanOrEqual(100);
		await expect(store!.ttl('testNumber1')).resolves.toBeLessThanOrEqual(1000);
	});

	test('should set and remove values', async () => {
		await cacheService.set('testString', 'test');
		await expect(cacheService.get('testString')).resolves.toBe('test');
		await cacheService.delete('testString');
		await expect(cacheService.get('testString')).resolves.toBeUndefined();
	});

	test('should calculate maxSize', async () => {
		config.set('cache.memory.maxSize', 16);
		await cacheService.destroy();

		// 16 bytes because stringify wraps the string in quotes, so 2 bytes for the quotes
		await cacheService.set('testString', 'withoutUnicode');
		await expect(cacheService.get('testString')).resolves.toBe('withoutUnicode');

		await cacheService.destroy();

		// should not fit!
		await cacheService.set('testString', 'withUnicodeԱԲԳ');
		await expect(cacheService.get('testString')).resolves.toBeUndefined();
	});

	test('should set and get complex objects', async () => {
		await cacheService.set('testObject', testObject);
		await expect(cacheService.get('testObject')).resolves.toMatchObject(testObject);
	});

	test('should set and get multiple values', async () => {
		await cacheService.destroy();
		expect(cacheService.isRedisCache()).toBe(false);

		await cacheService.setMany([
			['testString', 'test'],
			['testString2', 'test2'],
		]);
		await cacheService.setMany([
			['testNumber1', 123],
			['testNumber2', 456],
		]);
		await expect(cacheService.getMany(['testString', 'testString2'])).resolves.toStrictEqual([
			'test',
			'test2',
		]);
		await expect(cacheService.getMany(['testNumber1', 'testNumber2'])).resolves.toStrictEqual([
			123, 456,
		]);
	});

	test('should create a redis in queue mode', async () => {
		config.set('cache.backend', 'auto');
		config.set('executions.mode', 'queue');
		await cacheService.destroy();
		await cacheService.init();

		const cache = await cacheService.getCache();
		await expect(cacheService.getCache()).resolves.toBeDefined();
		const candidate = (await cacheService.getCache()) as RedisCache;
		expect(candidate.store.client).toBeDefined();
	});

	test('should create a redis cache if asked', async () => {
		config.set('cache.backend', 'redis');
		config.set('executions.mode', 'queue');
		await cacheService.destroy();
		await cacheService.init();

		const cache = await cacheService.getCache();
		await expect(cacheService.getCache()).resolves.toBeDefined();
		const candidate = (await cacheService.getCache()) as RedisCache;
		expect(candidate.store.client).toBeDefined();
	});

	test('should get/set/delete redis cache', async () => {
		config.set('cache.backend', 'redis');
		config.set('executions.mode', 'queue');
		await cacheService.destroy();
		await cacheService.init();

		await cacheService.set('testObject', testObject);
		await expect(cacheService.get('testObject')).resolves.toMatchObject(testObject);
		await cacheService.delete('testObject');
		await expect(cacheService.get('testObject')).resolves.toBeUndefined();
	});

	// NOTE: mset and mget are not supported by ioredis-mock
	// test('should set and get multiple values with redis', async () => {
	// });

	test('should return fallback value if key is not set', async () => {
		await cacheService.reset();
		await expect(cacheService.get('testString')).resolves.toBeUndefined();
		await expect(
			cacheService.get('testString', {
				fallbackValue: 'fallback',
			}),
		).resolves.toBe('fallback');
	});

	test('should call refreshFunction if key is not set', async () => {
		await cacheService.reset();
		await expect(cacheService.get('testString')).resolves.toBeUndefined();
		await expect(
			cacheService.get('testString', {
				refreshFunction: async () => 'refreshed',
				fallbackValue: 'this should not be returned',
			}),
		).resolves.toBe('refreshed');
	});

	test('should transparently handle disabled cache', async () => {
		await cacheService.disable();
		await expect(cacheService.get('testString')).resolves.toBeUndefined();
		await cacheService.set('testString', 'whatever');
		await expect(cacheService.get('testString')).resolves.toBeUndefined();
		await expect(
			cacheService.get('testString', {
				fallbackValue: 'fallback',
			}),
		).resolves.toBe('fallback');
		await expect(
			cacheService.get('testString', {
				refreshFunction: async () => 'refreshed',
				fallbackValue: 'this should not be returned',
			}),
		).resolves.toBe('refreshed');
	});

	test('should set and get partial results', async () => {
		await cacheService.setMany([
			['testNumber1', 123],
			['testNumber2', 456],
		]);
		await expect(cacheService.getMany(['testNumber1', 'testNumber2'])).resolves.toStrictEqual([
			123, 456,
		]);
		await expect(cacheService.getMany(['testNumber3', 'testNumber2'])).resolves.toStrictEqual([
			undefined,
			456,
		]);
	});

	test('should getMany and fix partial results and set single key', async () => {
		await cacheService.setMany([
			['testNumber1', 123],
			['testNumber2', 456],
		]);
		await expect(
			cacheService.getMany(['testNumber1', 'testNumber2', 'testNumber3']),
		).resolves.toStrictEqual([123, 456, undefined]);
		await expect(cacheService.get('testNumber3')).resolves.toBeUndefined();
		await expect(
			cacheService.getMany(['testNumber1', 'testNumber2', 'testNumber3'], {
				async refreshFunctionEach(key) {
					return key === 'testNumber3' ? 789 : undefined;
				},
			}),
		).resolves.toStrictEqual([123, 456, 789]);
		await expect(cacheService.get('testNumber3')).resolves.toBe(789);
	});

	test('should getMany and set all keys', async () => {
		await cacheService.setMany([
			['testNumber1', 123],
			['testNumber2', 456],
		]);
		await expect(
			cacheService.getMany(['testNumber1', 'testNumber2', 'testNumber3']),
		).resolves.toStrictEqual([123, 456, undefined]);
		await expect(cacheService.get('testNumber3')).resolves.toBeUndefined();
		await expect(
			cacheService.getMany(['testNumber1', 'testNumber2', 'testNumber3'], {
				async refreshFunctionMany(keys) {
					return [111, 222, 333];
				},
			}),
		).resolves.toStrictEqual([111, 222, 333]);
		await expect(cacheService.get('testNumber1')).resolves.toBe(111);
		await expect(cacheService.get('testNumber2')).resolves.toBe(222);
		await expect(cacheService.get('testNumber3')).resolves.toBe(333);
	});

	test('should set and get multiple values with fallbackValue', async () => {
		await cacheService.disable();
		await cacheService.setMany([
			['testNumber1', 123],
			['testNumber2', 456],
		]);
		await expect(cacheService.getMany(['testNumber1', 'testNumber2'])).resolves.toStrictEqual([
			undefined,
			undefined,
		]);
		await expect(
			cacheService.getMany(['testNumber1', 'testNumber2'], {
				fallbackValues: [123, 456],
			}),
		).resolves.toStrictEqual([123, 456]);
		await expect(
			cacheService.getMany(['testNumber1', 'testNumber2'], {
				refreshFunctionMany: async () => [123, 456],
				fallbackValues: [0, 1],
			}),
		).resolves.toStrictEqual([123, 456]);
	});

	test('should deal with unicode keys', async () => {
		const key = '? > ":< ! withUnicodeԱԲԳ';
		await cacheService.set(key, 'test');
		await expect(cacheService.get(key)).resolves.toBe('test');
		await cacheService.delete(key);
		await expect(cacheService.get(key)).resolves.toBeUndefined();
	});

	test('should deal with unicode keys in redis', async () => {
		config.set('cache.backend', 'redis');
		config.set('executions.mode', 'queue');
		await cacheService.destroy();
		await cacheService.init();
		const key = '? > ":< ! withUnicodeԱԲԳ';

		expect(((await cacheService.getCache()) as RedisCache).store.client).toBeDefined();

		await cacheService.set(key, 'test');
		await expect(cacheService.get(key)).resolves.toBe('test');
		await cacheService.delete(key);
		await expect(cacheService.get(key)).resolves.toBeUndefined();
	});

	test('should not cache null or undefined values', async () => {
		await cacheService.set('nullValue', null);
		await cacheService.set('undefValue', undefined);
		await cacheService.set('normalValue', 'test');

		await expect(cacheService.get('normalValue')).resolves.toBe('test');
		await expect(cacheService.get('undefValue')).resolves.toBeUndefined();
		await expect(cacheService.get('nullValue')).resolves.toBeUndefined();
	});

	test('should handle setting empty keys', async () => {
		await cacheService.set('', null);
		await expect(cacheService.get('')).resolves.toBeUndefined();
		await cacheService.setMany([
			['', 'something'],
			['', 'something'],
		]);
		await expect(cacheService.getMany([''])).resolves.toStrictEqual([undefined]);
		await cacheService.setMany([]);
		await expect(cacheService.getMany([])).resolves.toStrictEqual([]);
	});

	test('should handle setting empty keys (redis)', async () => {
		config.set('cache.backend', 'redis');
		config.set('executions.mode', 'queue');
		await cacheService.destroy();
		await cacheService.init();

		await cacheService.set('', null);
		await expect(cacheService.get('')).resolves.toBeUndefined();
		await cacheService.setMany([
			['', 'something'],
			['', 'something'],
		]);
		await expect(cacheService.getMany([''])).resolves.toStrictEqual([undefined]);
		await cacheService.setMany([]);
		await expect(cacheService.getMany([])).resolves.toStrictEqual([]);
	});
});
