import { AbstractCache } from '../AbstractCache';
import { Cache } from '../Cache';
import { CacheSPI } from '../CacheSPI';
import { KeyType } from '../KeyType';
import { Metrics } from '../metrics/Metrics';
import { RemovalListener } from '../RemovalListener';
import { RemovalReason } from '../RemovalReason';
import { ON_REMOVE, ON_MAINTENANCE, TRIGGER_REMOVE, MAINTENANCE } from '../symbols';

const DATA = Symbol('boundlessData');

const EVICTION_DELAY = 5000;

/**
 * Options for a boundless cache.
 */
export interface BoundlessCacheOptions<K extends KeyType, V> {
	/**
	 * Listener that triggers when a cached value is removed.
	 */
	removalListener?: RemovalListener<K, V> | undefined | null;
}

/**
 * Data as used by the boundless cache.
 */
interface BoundlessCacheData<K extends KeyType, V> {
	values: Map<K, V>;

	removalListener: RemovalListener<K, V> | null;

	evictionTimeout: any;
}

/**
 * Boundless cache.
 */
export class BoundlessCache<K extends KeyType, V> extends AbstractCache<K, V> implements Cache<K, V>, CacheSPI<K, V> {
	private [DATA]: BoundlessCacheData<K, V>;

	public [ON_REMOVE]?: RemovalListener<K, V>;
	public [ON_MAINTENANCE]?: () => void;

	public constructor(options: BoundlessCacheOptions<K, V>) {
		super();

		this[DATA] = {
			values: new Map(),

			removalListener: options.removalListener || null,

			evictionTimeout: null
		};
	}

	/**
	 * The maximum size the cache can be. Will be -1 if the cache is unbounded.
	 *
	 * @returns
	 *   maximum size, always `-1`
	 */
	public get maxSize() {
		return -1;
	}

	/**
	 * The current size of the cache.
	 *
	 * @returns
	 *   entries in the cache
	 */
	public get size() {
		return this[DATA].values.size;
	}

	/**
	 * The size of the cache weighted via the activate estimator.
	 *
	 * @returns
	 *   entries in the cache
	 */
	public get weightedSize() {
		return this.size;
	}

	/**
	 * Store a value tied to the specified key. Returns the previous value or
	 * `null` if no value currently exists for the given key.
	 *
	 * @param key -
	 *   key to store value under
	 * @param value -
	 *   value to store
	 * @returns
	 *   current value or `null`
	 */
	public set(key: K, value: V): V | null {
		const data = this[DATA];

		const old = data.values.get(key);

		// Update with the new value
		data.values.set(key, value);

		// Schedule an eviction
		if(! data.evictionTimeout) {
			data.evictionTimeout = setTimeout(() => this[MAINTENANCE](), EVICTION_DELAY);
		}

		// Return the value we replaced
		if(old !== undefined) {
			this[TRIGGER_REMOVE](key, old, RemovalReason.REPLACED);
			return old;
		} else {
			return null;
		}
	}

	/**
	 * Get the cached value for the specified key if it exists. Will return
	 * the value or `null` if no cached value exist. Updates the usage of the
	 * key.
	 *
	 * @param key -
	 *   key to get
	 * @returns
	 *   current value or `null`
	 */
	public getIfPresent(key: K): V | null {
		const data = this[DATA];
		const value = data.values.get(key);
		return value === undefined ? null : value;
	}

	/**
	 * Peek to see if a key is present without updating the usage of the
	 * key. Returns the value associated with the key or `null`  if the key
	 * is not present.
	 *
	 * In many cases `has(key)` is a better option to see if a key is present.
	 *
	 * @param key -
	 *   the key to check
	 * @returns
	 *   value associated with key or `null`
	 */
	public peek(key: K): V | null {
		const data = this[DATA];
		const value = data.values.get(key);
		return value === undefined ? null : value;
	}

	/**
	 * Delete a value in the cache. Returns the deleted value or `null` if
	 * there was no value associated with the key in the cache.
	 *
	 * @param key -
	 *   the key to delete
	 * @returns
	 *   deleted value or `null`
	 */
	public delete(key: K): V | null {
		const data = this[DATA];

		const old = data.values.get(key);
		data.values.delete(key);

		if(old !== undefined) {
			// Trigger removal events
			this[TRIGGER_REMOVE](key, old, RemovalReason.EXPLICIT);

			// Queue an eviction event if one is not set
			if(! data.evictionTimeout) {
				data.evictionTimeout = setTimeout(() => this[MAINTENANCE](), EVICTION_DELAY);
			}

			return old;
		} else {
			return null;
		}
	}

	/**
	 * Check if the given key exists in the cache.
	 *
	 * @param key -
	 *   key to check
	 * @returns
	 *   `true` if value currently exists, `false` otherwise
	 */
	public has(key: K) {
		const data = this[DATA];
		return data.values.has(key);
	}

	/**
	 * Clear all of the cached data.
	 */
	public clear() {
		const data = this[DATA];
		const oldValues = data.values;

		// Simply replace the value map new data
		data.values = new Map();

		// Trigger removal events for all of the content in the cache
		for(const [ key, value ] of oldValues.entries()) {
			this[TRIGGER_REMOVE](key, value, RemovalReason.EXPLICIT);
		}
	}

	/**
	 * Get all of the keys in the cache as an array. Can be used to iterate
	 * over all of the values in the cache, but be sure to protect against
	 * values being removed during iteration due to time-based expiration if
	 * used.
	 *
	 * @returns
	 *   snapshot of keys
	 */
	public keys() {
		this[MAINTENANCE]();
		return Array.from(this[DATA].values.keys());
	}

	/**
	 * Request clean up of the cache by removing expired entries and
	 * old data. Clean up is done automatically a short time after sets and
	 * deletes, but if your cache uses time-based expiration and has very
	 * sporadic updates it might be a good idea to call `cleanUp()` at times.
	 *
	 * A good starting point would be to call `cleanUp()` in a `setInterval`
	 * with a delay of at least a few minutes.
	 */
	public cleanUp() {
		// Simply request eviction so extra layers can handle this
		this[MAINTENANCE]();
	}

	/**
	 * Get metrics for this cache. Returns an object with the keys `hits`,
	 * `misses` and `hitRate`. For caches that do not have metrics enabled
	 * trying to access metrics will throw an error.
	 */
	public get metrics(): Metrics {
		throw new Error('Metrics are not supported by this cache');
	}

	private [TRIGGER_REMOVE](key: K, value: any, reason: RemovalReason) {
		const data = this[DATA];

		// Trigger any extended remove listeners
		const onRemove = this[ON_REMOVE];
		if(onRemove) {
			onRemove(key, value, reason);
		}

		if(data.removalListener) {
			data.removalListener(key, value as V, reason);
		}
	}

	private [MAINTENANCE]() {
		// Trigger the onEvict listener if one exists
		const onEvict = this[ON_MAINTENANCE];
		if(onEvict) {
			onEvict();
		}

		const data = this[DATA];
		if(data.evictionTimeout) {
			clearTimeout(data.evictionTimeout);
			data.evictionTimeout = null;
		}
	}
}
