import { AbstractTextureAtlas } from "./AbstractTextureAtlas.js";

class PatchRecord {
    constructor() {
        /**
         *
         * @type {AtlasPatch|null}
         */
        this.patch = null;

        /**
         * When was the patch last used
         * @type {number}
         */
        this.last_use_time = 0;
    }
}

/**
 * Adding and removing elements to atlas can be costly. It can trigger repack, it may require splatting or cleaning.
 * This abstraction allows us to bypass that most of the time by caching unused patches and just reusing them in the future
 * if they are requested again.
 *
 * The caching strategy here is a little unusual. The texture atlas has a certain size, and the cache will use as much of
 * the atlas space as it can. Meaning that if there is not much unused space - the cache will be very small
 * And if only a small part of the atlas is area is used by "live" patches, the cache can be quite large
 */
export class CachingTextureAtlas extends AbstractTextureAtlas {
    /**
     *
     * @param {AbstractTextureAtlas} atlas
     */
    constructor({
                    atlas
                }) {
        super();

        /**
         *
         * @type {PatchRecord[]}
         * @private
         */
        this.__cached_patches = [];

        /**
         *
         * @type {TextureAtlas}
         * @private
         */
        this.__atals = atlas;

    }

    /**
     * Finds an entry in the cache to evict
     * @returns {number}
     * @private
     */
    __find_eviction_target() {
        let best_date = Infinity;
        let best_node = -1;

        const records = this.__cached_patches;
        const n = records.length;

        for (let i = 0; i < n; i++) {
            const record = records[i];

            const date = record.last_use_time;

            if (date < best_date) {
                best_node = i;
                best_date = date;
            }

        }

        return best_node;
    }

    /**
     * Finds an entry in the cache that could be replaced with an incoming element
     * @private
     * @returns {number}
     * @param {number} width
     * @param {number} height
     */
    __find_replacement_target(width, height) {
        // find the smallest element in area that is least recently used in cache
        let best_area = Infinity;
        let best_date = Infinity;

        let best_node = -1;

        const records = this.__cached_patches;
        const n = records.length;
        for (let i = 0; i < n; i++) {
            const record = records[i];

            const packing = record.patch.packing;

            const w = packing.x1 - packing.x0;
            const h = packing.y1 - packing.y0;

            if (w < width || h < height) {
                // too small
                continue;
            }

            const area = w * h;

            if (area > best_area) {
                // prefer smaller patches to evict, as they are cheaper to pull back later on
                continue;
            }

            const date = record.last_use_time;

            if (date >= best_date) {
                // prefer patches that have been in cache the longest (the oldest records)
                continue;
            }

            // found a better candidate, update

            best_area = area;
            best_date = date;

            best_node = i;
        }

        return best_node;
    }

    /**
     * Will evict specified element from cache
     * @private
     * @param {number} index
     */
    __evict(index) {
        const records = this.__cached_patches;
        const record = records[index];

        records.splice(index, 1);
        this.__atals.remove(record.patch);
    }

    /**
     * Evict all cached elements, purging the cache
     * @private
     */
    __evict_all() {
        for (let i = this.__cached_patches.length - 1; i > 0; i--) {
            this.__evict(i);
        }
    }

    /**
     * Free up space in cache to accommodate certain area
     * @private
     * @param {number} width
     * @param {number} height
     * @returns {boolean} true if enough space was freed up to fit specified area, false otherwise
     */
    __evict_for(width, height) {
        if (this.__cached_patches.length <= 0) {
            // nothing to evict
            return false;
        }

        const target = this.__find_replacement_target(width, height);

        if (target !== -1) {
            // evicted just one element
            this.__evict(target);
            return true;
        }

        while (!this.__atals.can_pack(width, height)) {
            const victim = this.__find_eviction_target();

            if (victim === -1) {
                return false;
            }

            this.__evict(victim);
        }

        return true;
    }

    update() {
        try {
            this.__atals.update();
        } catch (e) {
            // failed to perform update

            if (this.__cached_patches.length > 0) {

                // has some caches patches, lets drop them
                this.__evict_all();

                // try to update again
                this.__atals.update();

            } else {
                // just re-throw
                throw e;
            }
        }
    }

    /**
     *
     * @returns {AbstractTextureAtlas}
     */
    get atlas() {
        return this.__atals;
    }

    /**
     *
     * @return {Sampler2D}
     */
    get sampler() {
        return this.__atals.sampler;
    }

    reset() {
        this.__cached_patches.splice(0, this.__cached_patches.length);
        this.__atals.reset();
    }

    /**
     *
     * @param {Sampler2D} sampler
     * @returns {number}
     * @private
     */
    __find_cache_record_index(sampler) {
        const records = this.__cached_patches;
        const n = records.length;
        for (let i = 0; i < n; i++) {
            if (records[i].patch.sampler === sampler) {
                return i;
            }
        }

        return -1;
    }

    /**
     *
     * @param {AtlasPatch} patch
     * @returns {number}
     * @private
     */
    __find_cache_record_index_by_patch(patch) {
        const records = this.__cached_patches;
        const n = records.length;
        for (let i = 0; i < n; i++) {
            if (records[i].patch === patch) {
                return i;
            }
        }

        return -1;
    }

    add(sampler, padding = 4) {
        const existing_patch_index = this.__find_cache_record_index(sampler);

        const records = this.__cached_patches;

        if (existing_patch_index !== -1) {
            // cache hit

            const record = records[existing_patch_index];

            // remove from cache
            records.splice(existing_patch_index, 1);

            // return patch
            return record.patch;
        }

        const w = sampler.width + padding * 2;
        const h = sampler.height + padding * 2;

        if (records.length > 0 && !this.__atals.can_pack(w, h)) {
            // new element will not fit, lets try to evict some cache to try and accommodate it
            // note that eviction will purge the entire cache in case that this patch doesn't fit
            this.__evict_for(w, h);
        }

        return this.__atals.add(sampler, padding);
    }

    /**
     *
     * @param {AtlasPatch} patch
     */
    remove(patch) {

        // validate presence of patch in the atlas
        if (!this.__atals.contains(patch)) {
            return false;
        }

        const index = this.__find_cache_record_index_by_patch(patch);

        if (index !== -1) {
            // already in the cache
            return false;
        }

        // push to cache, let eviction logic handle the rest
        const record = new PatchRecord();

        record.last_use_time = performance.now();
        record.patch = patch;

        this.__cached_patches.push(record);

        return true;
    }
}
