/*******************************************************************************
 * Copyright (c) 2000, 2013 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/
package org.eclipse.jdt.internal.core.util;

import java.text.NumberFormat;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.List;

import org.eclipse.jdt.internal.core.JavaElement;
import org.eclipse.jdt.internal.core.util.ToStringSorter.Pair;

/**
 * The <code>LRUCache</code> is a hashtable that stores a finite number of elements.
 * When an attempt is made to add values to a full cache, the least recently used values
 * in the cache are discarded to make room for the new values as necessary.
 *
 * <p>The data structure is based on the LRU virtual memory paging scheme.
 *
 * <p>Objects can take up a variable amount of cache space by implementing
 * the <code>ILRUCacheable</code> interface.
 *
 * <p>This implementation is NOT thread-safe.  Synchronization wrappers would
 * have to be added to ensure atomic insertions and deletions from the cache.
 *
 * @see org.eclipse.jdt.internal.core.util.ILRUCacheable
 */
public class LRUCache<K, V> implements Cloneable {

	/**
	 * This type is used internally by the LRUCache to represent entries
	 * stored in the cache.
	 * It is static because it does not require a pointer to the cache
	 * which contains it.
	 *
	 * @see LRUCache
	 */
	public static class LRUCacheEntry<K, V> {

		/**
		 * Hash table key
		 */
		public K key;

		/**
		 * Hash table value (an LRUCacheEntry object)
		 */
		public V value;

		/**
		 * Time value for queue sorting
		 */
		public int timestamp;

		/**
		 * Cache footprint of this entry
		 */
		public int space;

		/**
		 * Previous entry in queue
		 */
		public LRUCacheEntry<K, V> previous;

		/**
		 * Next entry in queue
		 */
		public LRUCacheEntry<K, V> next;

		/**
		 * Creates a new instance of the receiver with the provided values
		 * for key, value, and space.
		 */
		public LRUCacheEntry (K key, V value, int space) {
			this.key = key;
			this.value = value;
			this.space = space;
		}

		/**
		 * Returns a String that represents the value of this object.
		 */
		@Override
		public String toString() {
			return "LRUCacheEntry [" + this.key + "-->" + this.value + "]"; //$NON-NLS-3$ //$NON-NLS-1$ //$NON-NLS-2$
		}
	}

	public class Stats {
		private int[] counters = new int[20];
		private long[] timestamps = new long[20];
		private int counterIndex = -1;

		private void add(int counter) {
			for (int i = 0; i <= this.counterIndex; i++) {
				if (this.counters[i] == counter)
					return;
			}
			int length = this.counters.length;
			if (++this.counterIndex == length) {
				int newLength = this.counters.length * 2;
				System.arraycopy(this.counters, 0, this.counters = new int[newLength], 0, length);
				System.arraycopy(this.timestamps, 0, this.timestamps = new long[newLength], 0, length);
			}
			this.counters[this.counterIndex] = counter;
			this.timestamps[this.counterIndex] = System.currentTimeMillis();
		}

		private String getAverageAge(long totalTime, int numberOfElements, long currentTime) {
			if (numberOfElements == 0)
				return "N/A"; //$NON-NLS-1$
			long time = totalTime / numberOfElements;
			long age = currentTime - time;
			long ageInSeconds = age/1000;
			int seconds = 0;
			int minutes = 0;
			int hours = 0;
			int days = 0;
			if (ageInSeconds > 60) {
				long ageInMin = ageInSeconds / 60;
				seconds = (int) (ageInSeconds - (60 * ageInMin));
				if (ageInMin > 60) {
					long ageInHours = ageInMin / 60;
					minutes = (int) (ageInMin - (60 * ageInHours));
					if (ageInHours > 24) {
						long ageInDays = ageInHours / 24;
						hours = (int) (ageInHours - (24 * ageInDays));
						days = (int) ageInDays;
					} else {
						hours = (int) ageInHours;
					}
				} else {
					minutes = (int) ageInMin;
				}
			} else {
				seconds = (int) ageInSeconds;
			}
			StringBuilder buffer = new StringBuilder();
			if (days > 0) {
				buffer.append(days);
				buffer.append(" days "); //$NON-NLS-1$
			}
			if (hours > 0) {
				buffer.append(hours);
				buffer.append(" hours "); //$NON-NLS-1$
			}
			if (minutes > 0) {
				buffer.append(minutes);
				buffer.append(" minutes "); //$NON-NLS-1$
			}
			buffer.append(seconds);
			buffer.append(" seconds"); //$NON-NLS-1$
			return buffer.toString();
		}

		private long getTimestamps(int counter) {
			for (int i = 0; i <= this.counterIndex; i++) {
				if (this.counters[i] >= counter)
					return this.timestamps[i];
			}
			return -1;
		}

		public synchronized String printStats() {
			int numberOfElements = LRUCache.this.currentSpace;
			if (numberOfElements == 0) {
				return "No elements in cache"; //$NON-NLS-1$
			}
			StringBuilder buffer = new StringBuilder();

			buffer.append("Number of elements in cache: "); //$NON-NLS-1$
			buffer.append(numberOfElements);

			final int numberOfGroups = 5;
			int numberOfElementsPerGroup = numberOfElements / numberOfGroups;
			buffer.append("\n("); //$NON-NLS-1$
			buffer.append(numberOfGroups);
			buffer.append(" groups of "); //$NON-NLS-1$
			buffer.append(numberOfElementsPerGroup);
			buffer.append(" elements)"); //$NON-NLS-1$
			buffer.append("\n\nAverage age:"); //$NON-NLS-1$
			int groupNumber = 1;
			int elementCounter = 0;
			LRUCacheEntry<K, V> entry = LRUCache.this.entryQueueTail;
			long currentTime = System.currentTimeMillis();
			long accumulatedTime = 0;
			while (entry != null) {
				long timeStamps = getTimestamps(entry.timestamp);
				if (timeStamps > 0) {
					accumulatedTime += timeStamps;
					elementCounter++;
				}
				if (elementCounter >= numberOfElementsPerGroup && (groupNumber < numberOfGroups)) {
					buffer.append("\nGroup "); //$NON-NLS-1$
					buffer.append(groupNumber);
					if (groupNumber == 1) {
						buffer.append(" (oldest)\t: "); //$NON-NLS-1$
					} else {
						buffer.append("\t\t: "); //$NON-NLS-1$
					}
					groupNumber++;
					buffer.append(getAverageAge(accumulatedTime, elementCounter, currentTime));
					elementCounter = 0;
					accumulatedTime = 0;
				}
				entry = entry.previous;
			}
			buffer.append("\nGroup "); //$NON-NLS-1$
			buffer.append(numberOfGroups);
			buffer.append(" (youngest)\t: "); //$NON-NLS-1$
			buffer.append(getAverageAge(accumulatedTime, elementCounter, currentTime));

			return buffer.toString();
		}

		private void removeCountersOlderThan(int counter) {
			for (int i = 0; i <= this.counterIndex; i++) {
				if (this.counters[i] >= counter) {
					if (i > 0) {
						int length = this.counterIndex-i+1;
						System.arraycopy(this.counters, i, this.counters, 0, length);
						System.arraycopy(this.timestamps, i, this.timestamps, 0, length);
						this.counterIndex = length;
					}
					return;
				}
			}
		}

		public K getOldestElement() {
			return LRUCache.this.getOldestElement();
		}

		public long getOldestTimestamps() {
			return getTimestamps(getOldestTimestampCounter());
		}

		public synchronized void snapshot() {
			removeCountersOlderThan(getOldestTimestampCounter());
			add(getNewestTimestampCounter());
		}
	}

	/**
	 * Amount of cache space used so far
	 */
	protected int currentSpace;

	/**
	 * Maximum space allowed in cache
	 */
	protected int spaceLimit;

	/**
	 * Counter for handing out sequential timestamps
	 */
	protected int timestampCounter;

	/**
	 * Hash table for fast random access to cache entries
	 */
	protected Hashtable<K, LRUCacheEntry<K, V>> entryTable;

	/**
	 * Start of queue (most recently used entry)
	 */
	protected LRUCacheEntry<K, V> entryQueue;

	/**
	 * End of queue (least recently used entry)
	 */
	protected LRUCacheEntry<K, V> entryQueueTail;

	/**
	 * Default amount of space in the cache
	 */
	protected static final int DEFAULT_SPACELIMIT = 100;

	/**
	 * Creates a new cache.  Size of cache is defined by
	 * <code>DEFAULT_SPACELIMIT</code>.
	 */
	public LRUCache() {
		this(DEFAULT_SPACELIMIT);
	}

	/**
	 * Creates a new cache.
	 * @param size Size of Cache
	 */
	public LRUCache(int size) {
		this.timestampCounter = this.currentSpace = 0;
		this.entryQueue = this.entryQueueTail = null;
		this.entryTable = new Hashtable<>(size);
		this.spaceLimit = size;
	}

	/**
	 * Returns a new cache containing the same contents.
	 *
	 * @return New copy of object.
	 */
	@Override
	public LRUCache<K, V> clone() {
		LRUCache<K, V> newCache = newInstance(this.spaceLimit);
		LRUCacheEntry<K, V> qEntry;

		/* Preserve order of entries by copying from oldest to newest */
		qEntry = this.entryQueueTail;
		while (qEntry != null) {
			newCache.privateAdd (qEntry.key, qEntry.value, qEntry.space);
			qEntry = qEntry.previous;
		}
		return newCache;
	}

	public double fillingRatio() {
		return (this.currentSpace) * 100.0 / this.spaceLimit;
	}

	/**
	 * Flushes all entries from the cache.
	 */
	public void flush() {
		this.currentSpace = 0;
		LRUCacheEntry<K, V> entry = this.entryQueueTail; // Remember last entry
		this.entryTable = new Hashtable<>();  // Clear it out
		this.entryQueue = this.entryQueueTail = null;
		while (entry != null) {  // send deletion notifications in LRU order
			entry = entry.previous;
		}
	}

	/**
	 * Flushes the given entry from the cache.  Does nothing if entry does not
	 * exist in cache.
	 *
	 * @param key Key of object to flush
	 */
	public void flush (K key) {
		LRUCacheEntry<K, V> entry;
		entry = this.entryTable.get(key);

		/* If entry does not exist, return */
		if (entry == null) return;

		privateRemoveEntry (entry, false);
	}

	/*
	 * Answers the existing key that is equals to the given key.
	 * If the key is not in the cache, returns the given key
	 */
	public K getKey(K key) {
		LRUCacheEntry<K, V> entry = this.entryTable.get(key);
		if (entry == null) {
			return key;
		}
		return entry.key;
	}

	/**
	 * Answers the value in the cache at the given key.
	 * If the value is not in the cache, returns null
	 *
	 * @param key Hash table key of object to retrieve
	 * @return Retrieved object, or null if object does not exist
	 */
	public V get(K key) {
		LRUCacheEntry<K, V> entry = this.entryTable.get(key);
		if (entry == null) {
			return null;
		}

		updateTimestamp (entry);
		return entry.value;
	}

	/**
	 * Returns the amount of space that is current used in the cache.
	 */
	public int getCurrentSpace() {
		return this.currentSpace;
	}

	/**
	 * Returns the timestamps of the most recently used element in the cache.
	 */
	public int getNewestTimestampCounter() {
		return this.entryQueue == null ? 0 : this.entryQueue.timestamp;
	}

	/**
	 * Returns the timestamps of the least recently used element in the cache.
	 */
	public int getOldestTimestampCounter() {
		return this.entryQueueTail == null ? 0 : this.entryQueueTail.timestamp;
	}

	/**
	 * Returns the lest recently used element in the cache, can return {@code null}
	 */
	public K getOldestElement() {
		return this.entryQueueTail == null ? null : this.entryQueueTail.key;
	}

	/**
	 * Returns the maximum amount of space available in the cache.
	 */
	public int getSpaceLimit() {
		return this.spaceLimit;
	}

	/**
	 * Returns an Enumeration of the keys currently in the cache.
	 */
	public Enumeration<K> keys() {
		return this.entryTable.keys();
	}

	/**
	 * Returns an enumeration that iterates over all the keys and values
	 * currently in the cache.
	 */
	public ICacheEnumeration<K, V> keysAndValues() {
		return new ICacheEnumeration<K, V>() {

			Enumeration<LRUCacheEntry<K, V>> values = LRUCache.this.entryTable.elements();
			LRUCacheEntry<K, V> entry;

			@Override
			public boolean hasMoreElements() {
				return this.values.hasMoreElements();
			}

			@Override
			public K nextElement() {
				this.entry = this.values.nextElement();
				return this.entry.key;
			}

			@Override
			public V getValue() {
				if (this.entry == null) {
					throw new java.util.NoSuchElementException();
				}
				return this.entry.value;
			}
		};
	}

	/**
	 * Ensures there is the specified amount of free space in the receiver,
	 * by removing old entries if necessary.  Returns true if the requested space was
	 * made available, false otherwise.
	 *
	 * @param space Amount of space to free up
	 */
	protected boolean makeSpace (int space) {

		int limit;

		limit = getSpaceLimit();

		/* if space is already available */
		if (this.currentSpace + space <= limit) {
			return true;
		}

		/* if entry is too big for cache */
		if (space > limit) {
			return false;
		}

		/* Free up space by removing oldest entries */
		while (this.currentSpace + space > limit && this.entryQueueTail != null) {
			privateRemoveEntry (this.entryQueueTail, false);
		}
		return true;
	}

	/**
	 * Returns a new LRUCache instance
	 */
	protected LRUCache<K, V> newInstance(int size) {
		return new LRUCache<>(size);
	}

	/**
	 * Answers the value in the cache at the given key.
	 * If the value is not in the cache, returns null
	 *
	 * This function does not modify timestamps.
	 */
	public V peek(K key) {
		LRUCacheEntry<K, V> entry = this.entryTable.get(key);
		if (entry == null) {
			return null;
		}
		return entry.value;
	}

	/**
	 * Adds an entry for the given key/value/space.
	 */
	protected void privateAdd (K key, V value, int space) {
		LRUCacheEntry<K, V> entry;
		entry = new LRUCacheEntry<>(key, value, space);
		privateAddEntry (entry, false);
	}

	/**
	 * Adds the given entry from the receiver.
	 * @param shuffle Indicates whether we are just shuffling the queue
	 * (in which case, the entry table is not modified).
	 */
	protected void privateAddEntry (LRUCacheEntry<K, V> entry, boolean shuffle) {
		if (!shuffle) {
			this.entryTable.put (entry.key, entry);
			this.currentSpace += entry.space;
		}

		entry.timestamp = this.timestampCounter++;
		entry.next = this.entryQueue;
		entry.previous = null;

		if (this.entryQueue == null) {
			/* this is the first and last entry */
			this.entryQueueTail = entry;
		} else {
			this.entryQueue.previous = entry;
		}

		this.entryQueue = entry;
	}

	/**
	 * Removes the entry from the entry queue.
	 * @param shuffle indicates whether we are just shuffling the queue
	 * (in which case, the entry table is not modified).
	 */
	protected void privateRemoveEntry (LRUCacheEntry<K, V> entry, boolean shuffle) {
		LRUCacheEntry<K, V> previous, next;

		previous = entry.previous;
		next = entry.next;

		if (!shuffle) {
			this.entryTable.remove(entry.key);
			this.currentSpace -= entry.space;
		}

		/* if this was the first entry */
		if (previous == null) {
			this.entryQueue = next;
		} else {
			previous.next = next;
		}

		/* if this was the last entry */
		if (next == null) {
			this.entryQueueTail = previous;
		} else {
			next.previous = previous;
		}
	}

	/**
	 * Sets the value in the cache at the given key. Returns the value.
	 *
	 * @param key Key of object to add.
	 * @param value Value of object to add.
	 * @return added value.
	 */
	public V put(K key, V value) {
		int newSpace, oldSpace, newTotal;
		LRUCacheEntry<K, V> entry;

		/* Check whether there's an entry in the cache */
		newSpace = spaceFor(value);
		entry = this.entryTable.get (key);

		if (entry != null) {

			/**
			 * Replace the entry in the cache if it would not overflow
			 * the cache.  Otherwise flush the entry and re-add it so as
			 * to keep cache within budget
			 */
			oldSpace = entry.space;
			newTotal = getCurrentSpace() - oldSpace + newSpace;
			if (newTotal <= getSpaceLimit()) {
				updateTimestamp (entry);
				entry.value = value;
				entry.space = newSpace;
				this.currentSpace = newTotal;
				return value;
			} else {
				privateRemoveEntry (entry, false);
			}
		}
		if (makeSpace(newSpace)) {
			privateAdd (key, value, newSpace);
		}
		return value;
	}

	/**
	 * Removes and returns the value in the cache for the given key.
	 * If the key is not in the cache, returns null.
	 *
	 * @param key Key of object to remove from cache.
	 * @return Value removed from cache.
	 */
	public V removeKey (K key) {
		LRUCacheEntry<K, V> entry = this.entryTable.get(key);
		if (entry == null) {
			return null;
		}
		V value = entry.value;
		privateRemoveEntry (entry, false);
		return value;
	}

	/**
	 * Sets the maximum amount of space that the cache can store
	 *
	 * @param limit Number of units of cache space
	 */
	public void setSpaceLimit(int limit) {
		if (limit < this.spaceLimit) {
			makeSpace(this.spaceLimit - limit);
		}
		this.spaceLimit = limit;
	}

	/**
	 * Returns the space taken by the given value.
	 */
	protected int spaceFor (V value) {
		if (value instanceof ILRUCacheable) {
			return ((ILRUCacheable) value).getCacheFootprint();
		} else {
			return 1;
		}
	}

	/**
	 * Returns a String that represents the value of this object.  This method
	 * is for debugging purposes only.
	 */
	@Override
	public String toString() {
		return
			toStringFillingRation("LRUCache") + //$NON-NLS-1$
			toStringContents();
	}

	/**
	 * Returns a String that represents the contents of this object.  This method
	 * is for debugging purposes only.
	 */
	protected String toStringContents() {
		StringBuilder result = new StringBuilder();
		ToStringSorter<K> sorter = new ToStringSorter<>(o -> o instanceof JavaElement ? ((JavaElement) o).getElementName() : o.toString());
		List<Pair<K>> sortedObjects = sorter.sort(this.entryTable.keySet());
		for (Pair<K> pair : sortedObjects) {
			String toString = pair.string;
			V value = get(pair.object);
			result.append(toString);
			result.append(" -> "); //$NON-NLS-1$
			result.append(value);
			result.append("\n"); //$NON-NLS-1$
		}
		return result.toString();
	}

	public String toStringFillingRation(String cacheName) {
		StringBuilder buffer = new StringBuilder(cacheName);
		buffer.append('[');
		buffer.append(getSpaceLimit());
		buffer.append("]: "); //$NON-NLS-1$
		buffer.append(NumberFormat.getInstance().format(fillingRatio()));
		buffer.append("% full"); //$NON-NLS-1$
		return buffer.toString();
	}

	/**
	 * Updates the timestamp for the given entry, ensuring that the queue is
	 * kept in correct order.  The entry must exist
	 */
	protected void updateTimestamp (LRUCacheEntry<K, V> entry) {
		entry.timestamp = this.timestampCounter++;
		if (this.entryQueue != entry) {
			privateRemoveEntry (entry, true);
			privateAddEntry (entry, true);
		}
		return;
	}
}
