import triggerEvent from '../utilities/triggerEvent';
import imageIdToURI from '../utilities/imageIdToURI';
import eventTarget from '../eventTarget';
import Events from '../enums/Events';
import { ImageQualityStatus } from '../enums';
import fnv1aHash from '../utilities/fnv1aHash';
const ONE_GB = 1073741824;
class Cache {
    constructor() {
        this._imageCache = new Map();
        this._volumeCache = new Map();
        this._imageIdsToVolumeIdCache = new Map();
        this._geometryCache = new Map();
        this._imageCacheSize = 0;
        this._maxCacheSize = 3 * ONE_GB;
        this._geometryCacheSize = 0;
        this.setMaxCacheSize = (newMaxCacheSize) => {
            if (!newMaxCacheSize || typeof newMaxCacheSize !== 'number') {
                const errorMessage = `New max cacheSize ${this._maxCacheSize} should be defined and should be a number.`;
                throw new Error(errorMessage);
            }
            this._maxCacheSize = newMaxCacheSize;
        };
        this.isCacheable = (byteLength) => {
            const bytesAvailable = this.getBytesAvailable();
            const purgableImageBytes = Array.from(this._imageCache.values()).reduce((total, image) => {
                if (!image.sharedCacheKey) {
                    return total + image.sizeInBytes;
                }
                return total;
            }, 0);
            const availableSpaceWithoutSharedCacheKey = bytesAvailable + purgableImageBytes;
            return availableSpaceWithoutSharedCacheKey >= byteLength;
        };
        this.getMaxCacheSize = () => this._maxCacheSize;
        this.getCacheSize = () => this._imageCacheSize;
        this._decacheImage = (imageId, force = false) => {
            const cachedImage = this._imageCache.get(imageId);
            if (!cachedImage) {
                return;
            }
            if (cachedImage.sharedCacheKey && !force) {
                throw new Error('Cannot decache an image with a shared cache key. You need to manually decache the volume first.');
            }
            const { imageLoadObject } = cachedImage;
            if (imageLoadObject?.cancelFn) {
                imageLoadObject.cancelFn();
            }
            if (imageLoadObject?.decache) {
                imageLoadObject.decache();
            }
            this._imageCache.delete(imageId);
        };
        this._decacheVolume = (volumeId) => {
            const cachedVolume = this._volumeCache.get(volumeId);
            if (!cachedVolume) {
                return;
            }
            const { volumeLoadObject, volume } = cachedVolume;
            if (!volume) {
                return;
            }
            if (volume.cancelLoading) {
                volume.cancelLoading();
            }
            if (volume.imageData) {
                volume.imageData.delete();
            }
            if (volumeLoadObject.cancelFn) {
                volumeLoadObject.cancelFn();
            }
            if (volume.imageIds) {
                volume.imageIds.forEach((imageId) => {
                    const cachedImage = this._imageCache.get(imageId);
                    if (cachedImage && cachedImage.sharedCacheKey === volumeId) {
                        cachedImage.sharedCacheKey = undefined;
                    }
                });
            }
            this._volumeCache.delete(volumeId);
        };
        this.purgeCache = () => {
            const imageIterator = this._imageCache.keys();
            this.purgeVolumeCache();
            while (true) {
                const { value: imageId, done } = imageIterator.next();
                if (done) {
                    break;
                }
                this.removeImageLoadObject(imageId, { force: true });
                triggerEvent(eventTarget, Events.IMAGE_CACHE_IMAGE_REMOVED, { imageId });
            }
        };
        this.purgeVolumeCache = () => {
            const volumeIterator = this._volumeCache.keys();
            while (true) {
                const { value: volumeId, done } = volumeIterator.next();
                if (done) {
                    break;
                }
                this.removeVolumeLoadObject(volumeId);
                triggerEvent(eventTarget, Events.VOLUME_CACHE_VOLUME_REMOVED, {
                    volumeId,
                });
            }
        };
        this.getVolumeLoadObject = (volumeId) => {
            if (volumeId === undefined) {
                throw new Error('getVolumeLoadObject: volumeId must not be undefined');
            }
            const cachedVolume = this._volumeCache.get(volumeId);
            if (!cachedVolume) {
                return;
            }
            cachedVolume.timeStamp = Date.now();
            return cachedVolume.volumeLoadObject;
        };
        this.putGeometryLoadObject = (geometryId, geometryLoadObject) => {
            if (geometryId === undefined) {
                throw new Error('putGeometryLoadObject: geometryId must not be undefined');
            }
            if (geometryLoadObject.promise === undefined) {
                throw new Error('putGeometryLoadObject: geometryLoadObject.promise must not be undefined');
            }
            if (this._geometryCache.has(geometryId)) {
                throw new Error('putGeometryLoadObject: geometryId already present in geometryCache');
            }
            if (geometryLoadObject.cancelFn &&
                typeof geometryLoadObject.cancelFn !== 'function') {
                throw new Error('putGeometryLoadObject: geometryLoadObject.cancel must be a function');
            }
            const cachedGeometry = {
                loaded: false,
                geometryId,
                geometryLoadObject,
                timeStamp: Date.now(),
                sizeInBytes: 0,
            };
            this._geometryCache.set(geometryId, cachedGeometry);
            return geometryLoadObject.promise
                .then((geometry) => {
                try {
                    this._putGeometryCommon(geometryId, geometry, cachedGeometry);
                }
                catch (error) {
                    console.debug(`Error in _putGeometryCommon for geometry ${geometryId}:`, error);
                    throw error;
                }
            })
                .catch((error) => {
                console.debug(`Error caching geometry ${geometryId}:`, error);
                this._geometryCache.delete(geometryId);
                throw error;
            });
        };
        this.getGeometry = (geometryId) => {
            if (geometryId === undefined) {
                throw new Error('getGeometry: geometryId must not be undefined');
            }
            const cachedGeometry = this._geometryCache.get(geometryId);
            if (!cachedGeometry) {
                return;
            }
            cachedGeometry.timeStamp = Date.now();
            return cachedGeometry.geometry;
        };
        this.removeGeometryLoadObject = (geometryId) => {
            if (geometryId === undefined) {
                throw new Error('removeGeometryLoadObject: geometryId must not be undefined');
            }
            const cachedGeometry = this._geometryCache.get(geometryId);
            if (!cachedGeometry) {
                throw new Error('removeGeometryLoadObject: geometryId was not present in geometryCache');
            }
            this.decrementGeometryCacheSize(cachedGeometry.sizeInBytes);
            const eventDetails = {
                geometry: cachedGeometry,
                geometryId,
            };
            triggerEvent(eventTarget, Events.GEOMETRY_CACHE_GEOMETRY_REMOVED, eventDetails);
            this._decacheGeometry(geometryId);
        };
        this._decacheGeometry = (geometryId) => {
            const cachedGeometry = this._geometryCache.get(geometryId);
            if (!cachedGeometry) {
                return;
            }
            const { geometryLoadObject } = cachedGeometry;
            if (geometryLoadObject.cancelFn) {
                geometryLoadObject.cancelFn();
            }
            if (geometryLoadObject.decache) {
                geometryLoadObject.decache();
            }
            this._geometryCache.delete(geometryId);
        };
        this.incrementGeometryCacheSize = (increment) => {
            this._geometryCacheSize += increment;
        };
        this.decrementGeometryCacheSize = (decrement) => {
            this._geometryCacheSize -= decrement;
        };
        this.getImage = (imageId, minQuality = ImageQualityStatus.FAR_REPLICATE) => {
            if (imageId === undefined) {
                throw new Error('getImage: imageId must not be undefined');
            }
            const cachedImage = this._imageCache.get(imageId);
            if (!cachedImage) {
                return;
            }
            cachedImage.timeStamp = Date.now();
            if (cachedImage.image?.imageQualityStatus < minQuality) {
                return;
            }
            return cachedImage.image;
        };
        this.getVolume = (volumeId, allowPartialMatch = false) => {
            if (volumeId === undefined) {
                throw new Error('getVolume: volumeId must not be undefined');
            }
            const cachedVolume = this._volumeCache.get(volumeId);
            if (!cachedVolume) {
                return allowPartialMatch
                    ? [...this._volumeCache.values()].find((cv) => cv.volumeId.includes(volumeId))?.volume
                    : undefined;
            }
            cachedVolume.timeStamp = Date.now();
            return cachedVolume.volume;
        };
        this.getVolumes = () => {
            const cachedVolumes = Array.from(this._volumeCache.values());
            return cachedVolumes.map((cachedVolume) => cachedVolume.volume);
        };
        this.filterVolumesByReferenceId = (volumeId) => {
            const cachedVolumes = this.getVolumes();
            return cachedVolumes.filter((volume) => {
                return volume.referencedVolumeId === volumeId;
            });
        };
        this.removeImageLoadObject = (imageId, { force = false } = {}) => {
            if (imageId === undefined) {
                throw new Error('removeImageLoadObject: imageId must not be undefined');
            }
            const cachedImage = this._imageCache.get(imageId);
            if (!cachedImage) {
                throw new Error('removeImageLoadObject: imageId was not present in imageCache');
            }
            this._decacheImage(imageId, force);
            this.incrementImageCacheSize(-cachedImage.sizeInBytes);
            const eventDetails = {
                image: cachedImage,
                imageId,
            };
            triggerEvent(eventTarget, Events.IMAGE_CACHE_IMAGE_REMOVED, eventDetails);
        };
        this.removeVolumeLoadObject = (volumeId) => {
            if (volumeId === undefined) {
                throw new Error('removeVolumeLoadObject: volumeId must not be undefined');
            }
            const cachedVolume = this._volumeCache.get(volumeId);
            if (!cachedVolume) {
                throw new Error('removeVolumeLoadObject: volumeId was not present in volumeCache');
            }
            const eventDetails = {
                volume: cachedVolume,
                volumeId,
            };
            triggerEvent(eventTarget, Events.VOLUME_CACHE_VOLUME_REMOVED, eventDetails);
            this._decacheVolume(volumeId);
        };
        this.incrementImageCacheSize = (increment) => {
            this._imageCacheSize += increment;
        };
        this.decrementImageCacheSize = (decrement) => {
            this._imageCacheSize -= decrement;
        };
        this.getGeometryLoadObject = (geometryId) => {
            if (geometryId === undefined) {
                throw new Error('getGeometryLoadObject: geometryId must not be undefined');
            }
            const cachedGeometry = this._geometryCache.get(geometryId);
            if (!cachedGeometry) {
                return;
            }
            cachedGeometry.timeStamp = Date.now();
            return cachedGeometry.geometryLoadObject;
        };
    }
    generateVolumeId(imageIds) {
        const imageURIs = imageIds.map(imageIdToURI).sort();
        let combinedHash = 0x811c9dc5;
        for (const id of imageURIs) {
            const idHash = fnv1aHash(id);
            for (let i = 0; i < idHash.length; i++) {
                combinedHash ^= idHash.charCodeAt(i);
                combinedHash +=
                    (combinedHash << 1) +
                        (combinedHash << 4) +
                        (combinedHash << 7) +
                        (combinedHash << 8) +
                        (combinedHash << 24);
            }
        }
        return `volume-${(combinedHash >>> 0).toString(36)}`;
    }
    getImageIdsForVolumeId(volumeId) {
        return Array.from(this._imageIdsToVolumeIdCache.entries())
            .filter(([_, id]) => id === volumeId)
            .map(([key]) => key);
    }
    getBytesAvailable() {
        return this.getMaxCacheSize() - this.getCacheSize();
    }
    decacheIfNecessaryUntilBytesAvailable(numBytes, volumeImageIds) {
        let bytesAvailable = this.getBytesAvailable();
        if (bytesAvailable >= numBytes) {
            return bytesAvailable;
        }
        const cachedImages = Array.from(this._imageCache.values()).filter((cachedImage) => !cachedImage.sharedCacheKey);
        function compare(a, b) {
            if (a.timeStamp > b.timeStamp) {
                return 1;
            }
            if (a.timeStamp < b.timeStamp) {
                return -1;
            }
            return 0;
        }
        cachedImages.sort(compare);
        const cachedImageIds = cachedImages.map((im) => im.imageId);
        let imageIdsToPurge = cachedImageIds;
        if (volumeImageIds) {
            imageIdsToPurge = cachedImageIds.filter((id) => !volumeImageIds.includes(id));
        }
        for (const imageId of imageIdsToPurge) {
            this.removeImageLoadObject(imageId);
            triggerEvent(eventTarget, Events.IMAGE_CACHE_IMAGE_REMOVED, { imageId });
            bytesAvailable = this.getBytesAvailable();
            if (bytesAvailable >= numBytes) {
                return bytesAvailable;
            }
        }
        for (const imageId of cachedImageIds) {
            this.removeImageLoadObject(imageId);
            triggerEvent(eventTarget, Events.IMAGE_CACHE_IMAGE_REMOVED, { imageId });
            bytesAvailable = this.getBytesAvailable();
            if (bytesAvailable >= numBytes) {
                return bytesAvailable;
            }
        }
    }
    _putImageCommon(imageId, image, cachedImage) {
        if (!this._imageCache.has(imageId)) {
            console.warn('The image was purged from the cache before it completed loading.');
            return;
        }
        if (!image) {
            console.warn('Image is undefined');
            return;
        }
        if (image.sizeInBytes === undefined || Number.isNaN(image.sizeInBytes)) {
            throw new Error('_putImageCommon: image.sizeInBytes must not be undefined');
        }
        if (image.sizeInBytes.toFixed === undefined) {
            throw new Error('_putImageCommon: image.sizeInBytes is not a number');
        }
        if (!this.isCacheable(image.sizeInBytes)) {
            throw new Error(Events.CACHE_SIZE_EXCEEDED);
        }
        this.decacheIfNecessaryUntilBytesAvailable(image.sizeInBytes);
        cachedImage.loaded = true;
        cachedImage.image = image;
        cachedImage.sizeInBytes = image.sizeInBytes;
        this.incrementImageCacheSize(cachedImage.sizeInBytes);
        const eventDetails = {
            image: cachedImage,
        };
        triggerEvent(eventTarget, Events.IMAGE_CACHE_IMAGE_ADDED, eventDetails);
        cachedImage.sharedCacheKey = image.sharedCacheKey;
    }
    async putImageLoadObject(imageId, imageLoadObject) {
        if (imageId === undefined) {
            console.error('putImageLoadObject: imageId must not be undefined');
            throw new Error('putImageLoadObject: imageId must not be undefined');
        }
        if (imageLoadObject.promise === undefined) {
            console.error('putImageLoadObject: imageLoadObject.promise must not be undefined');
            throw new Error('putImageLoadObject: imageLoadObject.promise must not be undefined');
        }
        const alreadyCached = this._imageCache.get(imageId);
        if (alreadyCached?.imageLoadObject) {
            console.warn(`putImageLoadObject: imageId ${imageId} already in cache`);
            throw new Error('putImageLoadObject: imageId already in cache');
        }
        if (imageLoadObject.cancelFn &&
            typeof imageLoadObject.cancelFn !== 'function') {
            console.error('putImageLoadObject: imageLoadObject.cancel must be a function');
            throw new Error('putImageLoadObject: imageLoadObject.cancel must be a function');
        }
        const cachedImage = {
            ...alreadyCached,
            loaded: false,
            imageId,
            sharedCacheKey: undefined,
            imageLoadObject,
            timeStamp: Date.now(),
            sizeInBytes: 0,
        };
        this._imageCache.set(imageId, cachedImage);
        this._imageCache.set(imageId, cachedImage);
        return imageLoadObject.promise
            .then((image) => {
            try {
                this._putImageCommon(imageId, image, cachedImage);
            }
            catch (error) {
                console.debug(`Error in _putImageCommon for image ${imageId}:`, error);
                throw error;
            }
        })
            .catch((error) => {
            console.debug(`Error caching image ${imageId}:`, error);
            this._imageCache.delete(imageId);
            throw error;
        });
    }
    putImageSync(imageId, image) {
        if (imageId === undefined) {
            throw new Error('putImageSync: imageId must not be undefined');
        }
        if (this._imageCache.has(imageId)) {
            throw new Error('putImageSync: imageId already in cache');
        }
        const cachedImage = {
            loaded: false,
            imageId,
            sharedCacheKey: undefined,
            imageLoadObject: {
                promise: Promise.resolve(image),
            },
            timeStamp: Date.now(),
            sizeInBytes: 0,
        };
        this._imageCache.set(imageId, cachedImage);
        try {
            this._putImageCommon(imageId, image, cachedImage);
        }
        catch (error) {
            this._imageCache.delete(imageId);
            throw error;
        }
    }
    getImageLoadObject(imageId) {
        if (imageId === undefined) {
            throw new Error('getImageLoadObject: imageId must not be undefined');
        }
        const cachedImage = this._imageCache.get(imageId);
        if (!cachedImage) {
            return;
        }
        cachedImage.timeStamp = Date.now();
        return cachedImage.imageLoadObject;
    }
    isLoaded(imageId) {
        const cachedImage = this._imageCache.get(imageId);
        if (!cachedImage) {
            return false;
        }
        return cachedImage.loaded;
    }
    getVolumeContainingImageId(imageId) {
        const volumeIds = Array.from(this._volumeCache.keys());
        const imageIdToUse = imageIdToURI(imageId);
        for (const volumeId of volumeIds) {
            const cachedVolume = this._volumeCache.get(volumeId);
            if (!cachedVolume) {
                return;
            }
            const { volume } = cachedVolume;
            if (!volume.imageIds.length) {
                return;
            }
            const imageIdIndex = volume.getImageURIIndex(imageIdToUse);
            if (imageIdIndex > -1) {
                return { volume, imageIdIndex };
            }
        }
    }
    getCachedImageBasedOnImageURI(imageId) {
        const imageURIToUse = imageIdToURI(imageId);
        const cachedImageIds = Array.from(this._imageCache.keys());
        const foundImageId = cachedImageIds.find((imageId) => {
            return imageIdToURI(imageId) === imageURIToUse;
        });
        if (!foundImageId) {
            return;
        }
        return this._imageCache.get(foundImageId);
    }
    _putVolumeCommon(volumeId, volume, cachedVolume) {
        if (!this._volumeCache.get(volumeId)) {
            console.warn('The volume was purged from the cache before it completed loading.');
            return;
        }
        cachedVolume.loaded = true;
        cachedVolume.volume = volume;
        volume.imageIds?.forEach((imageId) => {
            const image = this._imageCache.get(imageId);
            if (image) {
                image.sharedCacheKey = volumeId;
            }
        });
        const eventDetails = {
            volume: cachedVolume,
        };
        triggerEvent(eventTarget, Events.VOLUME_CACHE_VOLUME_ADDED, eventDetails);
    }
    putVolumeSync(volumeId, volume) {
        if (volumeId === undefined) {
            throw new Error('putVolumeSync: volumeId must not be undefined');
        }
        if (this._volumeCache.has(volumeId)) {
            throw new Error('putVolumeSync: volumeId already in cache');
        }
        const cachedVolume = {
            loaded: false,
            volumeId,
            volumeLoadObject: {
                promise: Promise.resolve(volume),
            },
            timeStamp: Date.now(),
            sizeInBytes: 0,
        };
        this._volumeCache.set(volumeId, cachedVolume);
        try {
            this._putVolumeCommon(volumeId, volume, cachedVolume);
        }
        catch (error) {
            this._volumeCache.delete(volumeId);
            throw error;
        }
    }
    async putVolumeLoadObject(volumeId, volumeLoadObject) {
        if (volumeId === undefined) {
            throw new Error('putVolumeLoadObject: volumeId must not be undefined');
        }
        if (volumeLoadObject.promise === undefined) {
            throw new Error('putVolumeLoadObject: volumeLoadObject.promise must not be undefined');
        }
        if (this._volumeCache.has(volumeId)) {
            throw new Error(`putVolumeLoadObject: volumeId:${volumeId} already in cache`);
        }
        if (volumeLoadObject.cancelFn &&
            typeof volumeLoadObject.cancelFn !== 'function') {
            throw new Error('putVolumeLoadObject: volumeLoadObject.cancel must be a function');
        }
        const cachedVolume = {
            loaded: false,
            volumeId,
            volumeLoadObject,
            timeStamp: Date.now(),
            sizeInBytes: 0,
        };
        this._volumeCache.set(volumeId, cachedVolume);
        return volumeLoadObject.promise
            .then((volume) => {
            try {
                this._putVolumeCommon(volumeId, volume, cachedVolume);
            }
            catch (error) {
                console.error(`Error in _putVolumeCommon for volume ${volumeId}:`, error);
                this._volumeCache.delete(volumeId);
                throw error;
            }
        })
            .catch((error) => {
            this._volumeCache.delete(volumeId);
            throw error;
        });
    }
    _putGeometryCommon(geometryId, geometry, cachedGeometry) {
        if (!this._geometryCache.get(geometryId)) {
            console.warn('The geometry was purged from the cache before it completed loading.');
            return;
        }
        if (!geometry) {
            console.warn('Geometry is undefined');
            return;
        }
        if (geometry.sizeInBytes === undefined ||
            Number.isNaN(geometry.sizeInBytes)) {
            throw new Error('_putGeometryCommon: geometry.sizeInBytes must not be undefined');
        }
        if (geometry.sizeInBytes.toFixed === undefined) {
            throw new Error('_putGeometryCommon: geometry.sizeInBytes is not a number');
        }
        if (!this.isCacheable(geometry.sizeInBytes)) {
            throw new Error(Events.CACHE_SIZE_EXCEEDED);
        }
        this.decacheIfNecessaryUntilBytesAvailable(geometry.sizeInBytes);
        cachedGeometry.loaded = true;
        cachedGeometry.geometry = geometry;
        cachedGeometry.sizeInBytes = geometry.sizeInBytes;
        this.incrementGeometryCacheSize(cachedGeometry.sizeInBytes);
        const eventDetails = {
            geometry: cachedGeometry,
        };
        triggerEvent(eventTarget, Events.GEOMETRY_CACHE_GEOMETRY_ADDED, eventDetails);
    }
    putGeometrySync(geometryId, geometry) {
        if (geometryId === undefined) {
            throw new Error('putGeometrySync: geometryId must not be undefined');
        }
        if (this._geometryCache.has(geometryId)) {
            throw new Error('putGeometrySync: geometryId already in cache');
        }
        const cachedGeometry = {
            loaded: false,
            geometryId,
            geometryLoadObject: {
                promise: Promise.resolve(geometry),
            },
            timeStamp: Date.now(),
            sizeInBytes: 0,
        };
        this._geometryCache.set(geometryId, cachedGeometry);
        try {
            this._putGeometryCommon(geometryId, geometry, cachedGeometry);
        }
        catch (error) {
            this._geometryCache.delete(geometryId);
            throw error;
        }
    }
    setPartialImage(imageId, partialImage) {
        const cachedImage = this._imageCache.get(imageId);
        if (!cachedImage) {
            if (partialImage) {
                this._imageCache.set(imageId, {
                    image: partialImage,
                    imageId,
                    loaded: false,
                    timeStamp: Date.now(),
                    sizeInBytes: 0,
                });
            }
            return;
        }
        if (cachedImage.loaded) {
            cachedImage.loaded = false;
            cachedImage.imageLoadObject = null;
            this.incrementImageCacheSize(-cachedImage.sizeInBytes);
            cachedImage.sizeInBytes = 0;
            cachedImage.image = partialImage || cachedImage.image;
        }
        else {
            cachedImage.image = partialImage || cachedImage.image;
        }
    }
    getImageQuality(imageId) {
        const image = this._imageCache.get(imageId)?.image;
        return image
            ? image.imageQualityStatus || ImageQualityStatus.FULL_RESOLUTION
            : undefined;
    }
}
const cache = new Cache();
export default cache;
export { Cache, cache };
