import { assert } from "../assert.js";
import { collectIteratorValueToArray } from "../collection/collectIteratorValueToArray.js";
import { HashMap } from "../collection/map/HashMap.js";
import Signal from "../events/signal/Signal.js";
import { returnOne } from "../function/returnOne.js";
import { returnZero } from "../function/returnZero.js";
import { invokeObjectEquals } from "../model/object/invokeObjectEquals.js";
import { invokeObjectHash } from "../model/object/invokeObjectHash.js";
import { CacheElement } from "./CacheElement.js";

/**
 * Hash-based cache, uses LRU (Least-Recently Used) eviction policy.
 * Make sure that keys being used are truly immutable when it comes to hash and equality calculation; otherwise cache corruption is inevitable.
 *
 * @template Key
 * @template Value
 * @extends {Map<Key,Value>}
 *
 * @example
 *  const cache = new Cache({maxWeight:2});
 *
 *  cache.put(key0, "a");
 *  cache.put(key1, "b");
 *  cache.put(key2, "c"); // cache has only enough space for 2 elements, key0 will be evicted
 *
 *  cache.get(key0); // null, was evicted
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class Cache {

    /**
     * How much "weight" can the cache hold before it starts evicting elements
     * @type {number}
     * @private
     */
    #maxWeight = Number.POSITIVE_INFINITY;

    /**
     *
     * @type {number}
     * @private
     */
    #weight = 0;

    /**
     * Note: setting {@link maxWeight} to 0 is pointless, as the cache will not be able to hold even a single element.
     *
     * @param {number} [maxWeight=Number.POSITIVE_INFINITY] capacity of the cache, measure in "weight"; The weight is computed by summing key and value weights.
     * @param {function(key:Key):number} [keyWeigher= key=>0] function to compute weight of a key
     * @param {function(value:Value):number} [valueWeigher= value=>1] function to compute weight of a value
     * @param {function(Key):number} [keyHashFunction]
     * @param {function(Key, Key):boolean} [keyEqualityFunction]
     * @param {number} [capacity] initial capacity for the cache, helps prevent early resizing
     * @constructor
     */
    constructor({
                    maxWeight = Number.POSITIVE_INFINITY,
                    keyWeigher = returnZero,
                    valueWeigher = returnOne,
                    keyHashFunction = invokeObjectHash,
                    keyEqualityFunction = invokeObjectEquals,
                    capacity
                } = {}) {

        assert.isNumber(maxWeight, 'maxWeight');
        assert.notNaN(maxWeight, 'maxWeight');
        assert.greaterThanOrEqual(maxWeight, 0, 'maxWeight < 0');

        assert.isFunction(keyWeigher, 'keyWeigher');
        assert.isFunction(valueWeigher, 'valueWeigher');
        assert.isFunction(keyHashFunction, 'keyHashFunction');
        assert.isFunction(keyEqualityFunction, 'keyEqualityFunction');

        /**
         *
         * @type {number}
         * @private
         */
        this.#maxWeight = maxWeight;


        /**
         *
         * @type {function(Key): number}
         * @private
         */
        this.keyWeigher = keyWeigher;

        /**
         *
         * @type {function(Value): number}
         * @private
         */
        this.valueWeigher = valueWeigher;

        /**
         *
         * @type {CacheElement<Key,Value>|null}
         * @private
         */
        this.__first = null;
        /**
         *
         * @type {CacheElement<Key,Value>|null}
         * @private
         */
        this.__last = null;


        /**
         *
         * @type {HashMap<Key, CacheElement<Key,Value>>}
         * @private
         */
        this.data = new HashMap({
            keyHashFunction,
            keyEqualityFunction,
            capacity
        });

        /**
         * Dispatched when an element is evicted from the cache due to capacity constraints.
         * Note that standard removal of an element will trigger as well, so {@link onRemoved} will still be dispatched as well, prior to this.
         * @type {Signal<Key,Value>}
         */
        this.onEvicted = new Signal();

        /**
         * Dispatched when an element is removed from the cache, either manually or due to eviction.
         * @type {Signal<Key,Value>}
         */
        this.onRemoved = new Signal();

        /**
         * Dispatched when an element is added to the cache.
         * @type {Signal<Key,Value>}
         */
        this.onSet = new Signal();
    }

    /**
     * Marks the element as recently used.
     * Moves the element to the end of the eviction queue
     * @param {CacheElement<Key,Value>} element
     * @private
     */
    __promote(element) {
        if (element === this.__first) {
            // already at the front
            return;
        }

        // update end-pointers
        if (element === this.__last) {
            this.__last = element.previous;
        }

        element.unlink();

        element.previous = null;

        if (this.__first !== null) {
            // shift previous head element forward
            element.next = this.__first;

            this.__first.previous = element;
        } else {
            element.next = null;
        }

        this.__first = element;
    }

    /**
     * Number of elements stored in cache
     * @returns {number}
     */
    size() {
        return this.data.size;
    }

    /**
     * @deprecated use {@link maxWeight} directly instead
     */
    setMaxWeight(value) {
        throw new Error('setMaxWeight is deprecated, use .maxWeight property instead');
    }

    /**
     * Total weight of all elements currently in the cache.
     * This is guaranteed to be less than or equal to {@link maxWeight}
     *
     * @returns {number}
     */
    get weight() {
        return this.#weight;
    }

    /**
     * Will cause evictions if the current weight is greater than what we're setting
     * @param {number} weight
     */
    set maxWeight(weight) {
        if (typeof weight !== "number" || weight < 0) {
            throw new Error(`Weight must be a non-negative number, instead was '${weight}'`);
        }

        const old_limit = this.#maxWeight;

        this.#maxWeight = weight;

        if (old_limit > weight) {

            // shrinking, let's make sure we are upholding the constraint
            this.evictUntilWeight(this.#maxWeight);

        }
    }

    get maxWeight() {
        return this.#maxWeight;
    }

    /**
     * Forces weight to be recomputed for all elements stored in the cache
     * Useful when values are mutated without re-insertion.
     * In general - prefer to treat values as immutable
     */
    recomputeWeight() {
        let result = 0;

        for (let [key, record] of this.data) {

            const weight = this.computeElementWeight(key, record.value);
            record.weight = weight;

            result += weight;
        }

        this.#weight = result;
        this.evictUntilWeight(this.#maxWeight);
    }

    /**
     * Useful when working with wrapped value types, where contents can change and affect overall weight
     * Instead of re-inserting the element, we can just update weights
     * NOTE: this method may trigger eviction
     * @param {Key} key
     * @returns {boolean} true when weight successfully updated, false if element was not found in cache
     */
    updateElementWeight(key) {
        const record = this.data.get(key);

        if (record === undefined) {
            return false;
        }

        const old_weight = record.weight;

        const new_weight = this.computeElementWeight(key, record.value);

        if (new_weight === old_weight) {
            // we're done, no change
            return true;
        }

        record.weight = new_weight;

        const delta_weight = new_weight - old_weight;

        this.#weight += delta_weight;

        if (
            this.#weight > this.#maxWeight
            && new_weight <= this.#maxWeight //make it less likely to drop entire cache
        ) {
            this.evictUntilWeight(this.#maxWeight);
        }

        return true;
    }

    /**
     * @private
     * @param {Key} key
     * @param {Value} value
     * @returns {number}
     */
    computeElementWeight(key, value) {
        const key_weight = this.keyWeigher(key);

        assert.notNaN(key_weight, 'key_weight');

        const value_weight = this.valueWeigher(value);

        assert.notNaN(value_weight, 'value_weight');

        return key_weight + value_weight;
    }

    /**
     *
     * @returns {CacheElement<Key,Value>|null}
     */
    findEvictionVictim() {
        return this.__last;
    }

    /**
     * Evicts a single element from the cache
     * @returns {boolean} true if an element was evicted, false otherwise
     */
    evictOne() {
        //find a victim
        const victim = this.findEvictionVictim();

        if (victim !== null) {
            const removed_from_hash_table = this.remove(victim.key);

            assert.ok(removed_from_hash_table, `Failed to remove key '${victim.key}', likely reasons:\n\t1. key was mutated (keys must never be mutated)\n\t2. provided hashing function is unstable\n\t3. provided equality function is inconsistent`);

            this.onEvicted.send2(victim.key, victim.value);

            return true;
        } else {
            //nothing to remove
            return false;
        }
    }

    /**
     * Drop data until the weight reduces to lower or equal to the requested weight
     * @param {number} target_weight
     */
    evictUntilWeight(target_weight) {
        assert.isNumber(target_weight, 'target_weight');
        assert.notNaN(target_weight, 'target_weight');

        const target = Math.max(target_weight, 0);

        while (this.#weight > target) {
            this.evictOne();
        }
    }

    /**
     *
     * @param {Key} key
     * @param {Value} value
     */
    put(key, value) {
        let element = this.data.get(key);

        if (element === undefined) {
            //compute weight
            const elementWeight = this.computeElementWeight(key, value);

            /**
             * It's possible that element being added is larger than cache's capacity,
             * in which case entire cache will be evicted, but there still won't be enough space
             * @type {number}
             */
            const weightTarget = this.#maxWeight - elementWeight;

            if (weightTarget < 0) {
                // Special case
                // element does not fit into the cache, attempting to insert it forcibly would result in a full flush and overflow
                return;
            }

            element = new CacheElement();

            element.key = key;
            element.value = value;

            // patch the element in
            element.next = this.__first;

            if (this.__first !== null) {
                this.__first.previous = element;
            }
            this.__first = element;

            if (this.__last === null) {
                this.__last = element;
            }

            element.weight = elementWeight;

            //evict elements until there is enough space for the element
            this.evictUntilWeight(weightTarget);

            //store element
            this.data.set(key, element);

            //update weight
            this.#weight += elementWeight;
        } else {
            // check if value is the same
            if (value === element.value) {
                // same value, no action required
            } else {
                // replace value, adjust weight
                this.#weight -= element.weight;

                const elementWeight = this.computeElementWeight(key, value);

                this.#weight += elementWeight;

                // assign new values
                element.weight = elementWeight;
                element.value = value;
            }


            // element inserted again, promote it to the front of the access queue
            this.__promote(element);
        }

        this.onSet.send2(key, value);
    }

    /**
     *
     * @param {Key} key
     * @returns {boolean}
     */
    contains(key) {
        return this.data.has(key);
    }

    /**
     *
     * @param {Key} key
     * @returns {Value|null} value, or null if element was not found
     */
    get(key) {
        const element = this.data.get(key);

        if (element === undefined) {
            return null;
        } else {
            this.__promote(element);

            return element.value;
        }
    }


    /**
     * Please note that the key will be stored inside the cache, so it must be treated as immutable
     * @param {Key} key
     * @param {function(Key):Value} compute
     * @param {*} [compute_context]
     * @return {Value}
     */
    getOrCompute(key, compute, compute_context) {
        const existing = this.get(key);

        if (existing !== null) {
            return existing;
        }

        const value = compute.call(compute_context, key);

        this.set(key, value);

        return value;
    }

    /**
     *
     * @param {CacheElement<Key,Value>} element
     * @private
     */
    __removeElement(element) {
        const value = element.value;

        // remove from the queue
        if (element === this.__first) {
            this.__first = element.next;
        }

        if (element === this.__last) {
            this.__last = element.previous;
        }

        element.unlink();

        const key = element.key;

        //remove from cache
        this.data.delete(key);

        //update weight
        this.#weight -= element.weight;
    }

    /**
     *
     * @param {Key} key
     * @returns {boolean} true if element was removed, false otherwise
     */
    remove(key) {
        const element = this.data.get(key);

        if (element === undefined) {
            //nothing to do
            return false;
        }

        this.__removeElement(element);

        //notify
        this.onRemoved.send2(key, element.value);

        return true;
    }

    /**
     * Remove without triggering {@link #onRemoved}
     * NOTE: please be sure you understand what you're doing when you use this method
     * @param {Key} key
     * @returns {boolean} true if the element was removed, false otherwise
     */
    silentRemove(key) {
        const element = this.data.get(key);

        if (element === undefined) {
            //nothing to do
            return false;
        }

        this.__removeElement(element);

        return true;
    }

    /**
     * Remove all elements from the cache.
     * Triggers {@link #onRemoved} for each removed element.
     *
     */
    clear() {
        /**
         *
         * @type {Key[]}
         */
        const keys = [];

        collectIteratorValueToArray(keys, this.data.keys());

        const n = keys.length;
        for (let i = 0; i < n; i++) {
            const key = keys[i];
            this.remove(key);
        }
    }

    /**
     * Removed all data from cache
     * NOTE: Does NOT signal via {@link removeListener}
     */
    drop() {
        this.data.clear();

        this.__first = null;
        this.__last = null;

        this.#weight = 0;
    }

    /**
     *
     * @param {function(message:string, key:Key, value:Value)} callback
     * @param {*} [thisArg]
     * @returns {boolean}
     */
    validate(callback, thisArg) {
        return this.data.verifyHashes((msg, key, entry) => {
            callback.call(thisArg, msg, key, entry.value);
        });
    }
}

/**
 * Shortcut for "Map" class spec compliance
 * @readonly
 */
Cache.prototype.set = Cache.prototype.put;

/**
 * Shortcut for "Map" class spec compliance
 * @readonly
 */
Cache.prototype.delete = Cache.prototype.remove;

/**
 * Shortcut for "Map" class spec compliance
 * @readonly
 */
Cache.prototype.has = Cache.prototype.contains;
