/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { logger } from "matrix-js-sdk/src/logger";

interface CacheItem<K, V> {
    key: K;
    value: V;
    /** Next item in the list */
    next: CacheItem<K, V> | null;
    /** Previous item in the list */
    prev: CacheItem<K, V> | null;
}

/**
 * Least Recently Used cache.
 * Can be initialised with a capacity and drops the least recently used items.
 * This cache should be error robust: Cache miss on error.
 *
 * Implemented via a key lookup map and a double linked list:
 *             head              tail
 *              a next → b next → c → next null
 *  null ← prev a ← prev b ← prev c
 *
 * @template K - Type of the key used to look up the values inside the cache
 * @template V - Type of the values inside the cache
 */
export class LruCache<K, V> {
    /** Head of the list. */
    private head: CacheItem<K, V> | null = null;
    /** Tail of the list */
    private tail: CacheItem<K, V> | null = null;
    /** Key lookup map */
    private map: Map<K, CacheItem<K, V>>;

    /**
     * @param capacity - Cache capcity.
     * @throws {Error} - Raises an error if the cache capacity is less than 1.
     */
    public constructor(private capacity: number) {
        if (this.capacity < 1) {
            throw new Error("Cache capacity must be at least 1");
        }

        this.map = new Map();
    }

    /**
     * Whether the cache contains an item under this key.
     * Marks the item as most recently used.
     *
     * @param key - Key of the item
     * @returns true: item in cache, else false
     */
    public has(key: K): boolean {
        try {
            return this.getItem(key) !== undefined;
        } catch (e) {
            // Should not happen but makes it more robust to the unknown.
            this.onError(e);
            return false;
        }
    }

    /**
     * Returns an item from the cache.
     * Marks the item as most recently used.
     *
     * @param key - Key of the item
     * @returns The value if found, else undefined
     */
    public get(key: K): V | undefined {
        try {
            return this.getItem(key)?.value;
        } catch (e) {
            // Should not happen but makes it more robust to the unknown.
            this.onError(e);
            return undefined;
        }
    }

    /**
     * Adds an item to the cache.
     * A newly added item will be the set as the most recently used.
     *
     * @param key - Key of the item
     * @param value - Item value
     */
    public set(key: K, value: V): void {
        try {
            this.safeSet(key, value);
        } catch (e) {
            // Should not happen but makes it more robust to the unknown.
            this.onError(e);
        }
    }

    /**
     * Deletes an item from the cache.
     *
     * @param key - Key of the item to be removed
     */
    public delete(key: K): void {
        const item = this.map.get(key);

        // Unknown item.
        if (!item) return;

        try {
            this.removeItemFromList(item);
            this.map.delete(key);
        } catch (e) {
            // Should not happen but makes it more robust to the unknown.
            this.onError(e);
        }
    }

    /**
     * Clears the cache.
     */
    public clear(): void {
        this.map = new Map();
        this.head = null;
        this.tail = null;
    }

    /**
     * Returns an iterator over the cached values.
     * @yields each value in the cache
     */
    public *values(): IterableIterator<V> {
        for (const item of this.map.values()) {
            yield item.value;
        }
    }

    private safeSet(key: K, value: V): void {
        const item = this.getItem(key);

        if (item) {
            // The item is already stored under this key. Update the value.
            item.value = value;
            return;
        }

        const newItem: CacheItem<K, V> = {
            key,
            value,
            next: null,
            prev: null,
        };

        if (this.head) {
            // Put item in front of the list.
            this.head.prev = newItem;
            newItem.next = this.head;
        }

        this.setHeadTail(newItem);

        // Store item in lookup map.
        this.map.set(key, newItem);

        if (this.tail && this.map.size > this.capacity) {
            // Map size exceeded cache capcity. Drop tail item.
            this.delete(this.tail.key);
        }
    }

    private onError(e: unknown): void {
        logger.warn("LruCache error", e);
        this.clear();
    }

    private getItem(key: K): CacheItem<K, V> | undefined {
        const item = this.map.get(key);

        // Not in cache.
        if (!item) return undefined;

        // Item is already at the head of the list.
        // No update required.
        if (item === this.head) return item;

        this.removeItemFromList(item);

        // Put item to the front.

        if (this.head) {
            this.head.prev = item;
        }

        item.prev = null;
        item.next = this.head;

        this.setHeadTail(item);

        return item;
    }

    private setHeadTail(item: CacheItem<K, V>): void {
        if (item.prev === null) {
            // Item has no previous item → head
            this.head = item;
        }

        if (item.next === null) {
            // Item has no next item → tail
            this.tail = item;
        }
    }

    private removeItemFromList(item: CacheItem<K, V>): void {
        if (item === this.head) {
            this.head = item.next;
        }

        if (item === this.tail) {
            this.tail = item.prev;
        }

        if (item.prev) {
            item.prev.next = item.next;
        }

        if (item.next) {
            item.next.prev = item.prev;
        }
    }
}
