// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { CacheList, defaultConfig, getCurrTime, CacheObject } from './Utils';

import { StorageCache } from './StorageCache';
import { ICache, CacheConfig, CacheItem, CacheItemOptions } from './types';
import { ConsoleLogger as Logger } from '@aws-amplify/core';

const logger = new Logger('InMemoryCache');

/**
 * Customized in-memory cache with LRU implemented
 * @member cacheObj - object which store items
 * @member cacheList - list of keys in the cache with LRU
 * @member curSizeInBytes - current size of the cache
 * @member maxPriority - max of the priority
 * @member cacheSizeLimit - the limit of cache size
 */
export class InMemoryCacheClass extends StorageCache implements ICache {
	private cacheList: CacheList[];
	private curSizeInBytes: number;
	private maxPriority: number;
	private cacheSizeLimit: number;

	/**
	 * initialize the cache
	 *
	 * @param config - the configuration of the cache
	 */
	constructor(config?: CacheConfig) {
		const cacheConfig = config
			? Object.assign({}, defaultConfig, config)
			: defaultConfig;
		super(cacheConfig);
		logger.debug('now we start!');
		this.cacheList = [];
		this.curSizeInBytes = 0;
		this.maxPriority = 5;

		this.getItem = this.getItem.bind(this);
		this.setItem = this.setItem.bind(this);
		this.removeItem = this.removeItem.bind(this);

		// initialize list for every priority
		for (let i = 0; i < this.maxPriority; i += 1) {
			this.cacheList[i] = new CacheList();
		}
	}

	/**
	 * decrease current size of the cache
	 *
	 * @param amount - the amount of the cache size which needs to be decreased
	 */
	private _decreaseCurSizeInBytes(amount: number): void {
		this.curSizeInBytes -= amount;
	}

	/**
	 * increase current size of the cache
	 *
	 * @param amount - the amount of the cache szie which need to be increased
	 */
	private _increaseCurSizeInBytes(amount: number): void {
		this.curSizeInBytes += amount;
	}

	/**
	 * check whether item is expired
	 *
	 * @param key - the key of the item
	 *
	 * @return true if the item is expired.
	 */
	private _isExpired(key: string): boolean {
		const text: string | null = CacheObject.getItem(key);
		const item: CacheItem = JSON.parse(text);
		if (getCurrTime() >= item.expires) {
			return true;
		}
		return false;
	}

	/**
	 * delete item from cache
	 *
	 * @param prefixedKey - the key of the item
	 * @param listIdx - indicates which cache list the key belongs to
	 */
	private _removeItem(prefixedKey: string, listIdx: number): void {
		// delete the key from the list
		this.cacheList[listIdx].removeItem(prefixedKey);
		// decrease the current size of the cache
		this._decreaseCurSizeInBytes(
			JSON.parse(CacheObject.getItem(prefixedKey)).byteSize
		);
		// finally remove the item from memory
		CacheObject.removeItem(prefixedKey);
	}

	/**
	 * put item into cache
	 *
	 * @param prefixedKey - the key of the item
	 * @param itemData - the value of the item
	 * @param itemSizeInBytes - the byte size of the item
	 * @param listIdx - indicates which cache list the key belongs to
	 */
	private _setItem(
		prefixedKey: string,
		item: CacheItem,
		listIdx: number
	): void {
		// insert the key into the list
		this.cacheList[listIdx].insertItem(prefixedKey);
		// increase the current size of the cache
		this._increaseCurSizeInBytes(item.byteSize);
		// finally add the item into memory
		CacheObject.setItem(prefixedKey, JSON.stringify(item));
	}

	/**
	 * see whether cache is full
	 *
	 * @param itemSize
	 *
	 * @return true if cache is full
	 */
	private _isCacheFull(itemSize: number): boolean {
		return this.curSizeInBytes + itemSize > this.config.capacityInBytes;
	}

	/**
	 * check whether the cache contains the key
	 *
	 * @param key
	 */
	private containsKey(key: string): number {
		const prefixedKey: string = this.config.keyPrefix + key;
		for (let i = 0; i < this.maxPriority; i += 1) {
			if (this.cacheList[i].containsKey(prefixedKey)) {
				return i + 1;
			}
		}
		return -1;
	}

	/**
	 * * Set item into cache. You can put number, string, boolean or object.
	 * The cache will first check whether has the same key.
	 * If it has, it will delete the old item and then put the new item in
	 * The cache will pop out items if it is full
	 * You can specify the cache item options. The cache will abort and output a warning:
	 * If the key is invalid
	 * If the size of the item exceeds itemMaxSize.
	 * If the value is undefined
	 * If incorrect cache item configuration
	 * If error happened with browser storage
	 *
	 * @param key - the key of the item
	 * @param value - the value of the item
	 * @param options - optional, the specified meta-data
	 *
	 * @throws if the item is too big which exceeds the limit of single item size
	 * @throws if the key is invalid
	 */
	public setItem(
		key: string,
		value: object | string | number | boolean,
		options?: CacheItemOptions
	): void {
		const prefixedKey: string = this.config.keyPrefix + key;
		// invalid keys
		if (
			prefixedKey === this.config.keyPrefix ||
			prefixedKey === this.cacheCurSizeKey
		) {
			logger.warn(`Invalid key: should not be empty or 'CurSize'`);
			return;
		}

		if (typeof value === 'undefined') {
			logger.warn(`The value of item should not be undefined!`);
			return;
		}

		const cacheItemOptions: CacheItemOptions = {
			priority:
				options && options.priority !== undefined
					? options.priority
					: this.config.defaultPriority,
			expires:
				options && options.expires !== undefined
					? options.expires
					: this.config.defaultTTL + getCurrTime(),
		};

		if (cacheItemOptions.priority < 1 || cacheItemOptions.priority > 5) {
			logger.warn(
				`Invalid parameter: priority due to out or range. It should be within 1 and 5.`
			);
			return;
		}

		const item: CacheItem = this.fillCacheItem(
			prefixedKey,
			value,
			cacheItemOptions
		);

		// check wether this item is too big;
		if (item.byteSize > this.config.itemMaxSize) {
			logger.warn(
				`Item with key: ${key} you are trying to put into is too big!`
			);
			return;
		}

		// if key already in the cache, then delete it.
		const presentKeyPrio: number = this.containsKey(key);
		if (presentKeyPrio !== -1) {
			this._removeItem(prefixedKey, presentKeyPrio - 1);
		}

		// pop out items in the cache when cache is full based on LRU
		// first start from lowest priority cache list
		let cacheListIdx = this.maxPriority - 1;
		while (this._isCacheFull(item.byteSize) && cacheListIdx >= 0) {
			if (!this.cacheList[cacheListIdx].isEmpty()) {
				const popedItemKey = this.cacheList[cacheListIdx].getLastItem();
				this._removeItem(popedItemKey, cacheListIdx);
			} else {
				cacheListIdx -= 1;
			}
		}

		this._setItem(prefixedKey, item, Number(item.priority) - 1);
	}

	/**
	 * Get item from cache. It will return null if item doesn’t exist or it has been expired.
	 * If you specified callback function in the options,
	 * then the function will be executed if no such item in the cache
	 * and finally put the return value into cache.
	 * Please make sure the callback function will return the value you want to put into the cache.
	 * The cache will abort output a warning:
	 * If the key is invalid
	 *
	 * @param key - the key of the item
	 * @param options - the options of callback function
	 */
	public getItem(key: string, options?: CacheItemOptions): any {
		let ret: string | null = null;
		const prefixedKey: string = this.config.keyPrefix + key;

		if (
			prefixedKey === this.config.keyPrefix ||
			prefixedKey === this.cacheCurSizeKey
		) {
			logger.warn(`Invalid key: should not be empty or 'CurSize'`);
			return null;
		}

		// check whether it's in the cachelist
		const presentKeyPrio: number = this.containsKey(key);
		if (presentKeyPrio !== -1) {
			if (this._isExpired(prefixedKey)) {
				// if expired, remove that item and return null
				this._removeItem(prefixedKey, presentKeyPrio - 1);
			} else {
				// if not expired, great, return the value and refresh it
				ret = CacheObject.getItem(prefixedKey);
				const item: CacheItem = JSON.parse(ret);
				this.cacheList[item.priority - 1].refresh(prefixedKey);
				return item.data;
			}
		}

		if (options && options.callback !== undefined) {
			const val: object | string | number | boolean = options.callback();
			if (val !== null) {
				this.setItem(key, val, options);
			}
			return val;
		}
		return null;
	}

	/**
	 * remove item from the cache
	 *
	 * @param key - the key of the item
	 */
	public removeItem(key: string): void {
		const prefixedKey: string = this.config.keyPrefix + key;

		// check if the key is in the cache
		const presentKeyPrio: number = this.containsKey(key);
		if (presentKeyPrio !== -1) {
			this._removeItem(prefixedKey, presentKeyPrio - 1);
		}
	}

	/**
	 * clear the entire cache
	 */
	public clear(): void {
		for (let i = 0; i < this.maxPriority; i += 1) {
			for (const key of this.cacheList[i].getKeys()) {
				this._removeItem(key, i);
			}
		}
	}

	/**
	 * Return all the keys in the cache.
	 */
	public getAllKeys(): string[] {
		const keys: string[] = [];
		for (let i = 0; i < this.maxPriority; i += 1) {
			for (const key of this.cacheList[i].getKeys()) {
				keys.push(key.substring(this.config.keyPrefix.length));
			}
		}

		return keys;
	}

	/**
	 * return the current size of the cache
	 *
	 * @return the current size of the cache
	 */
	public getCacheCurSize(): number {
		return this.curSizeInBytes;
	}

	/**
	 * Return a new instance of cache with customized configuration.
	 * @param config - the customized configuration
	 */
	public createInstance(config: CacheConfig): ICache {
		return new InMemoryCacheClass(config);
	}
}

export const InMemoryCache: ICache = new InMemoryCacheClass();
