/**
 *
 * @enum {number}
 */
import {
    BufferAttribute,
    BufferGeometry,
    DynamicDrawUsage,
    InstancedBufferAttribute,
    InstancedBufferGeometry
} from "three";
import IdPool from "../../../../../core/IdPool.js";
import ObservedValue from "../../../../../core/model/ObservedValue.js";
import { itemSizeFromAttributeType } from "./itemSizeFromAttributeType.js";
import { Operation } from "./Operation.js";
import { OperationType } from "./OperationType.js";
import { optimizeCommandQueue } from "./optimizeCommandQueue.js";
import { typedArrayConstructorFromDataType } from "./typedArrayConstructorFromDataType.js";


export class ParticleGroup {
    /**
     *
     * @param {ParticleSpecification} spec
     * @param {boolean} [instanced=false]
     * @constructor
     */
    constructor(spec, instanced = false) {
        this.instanced = instanced;
        /**
         *
         * @type {ParticleSpecification}
         */
        this.spec = spec;

        this.attributes = [];

        /**
         *
         * @type {ObservedValue<BufferGeometry>}
         */
        this.geometry = new ObservedValue(null);

        /**
         *
         * @type {int}
         */
        this.size = 0;

        /**
         *
         * @type {int}
         */
        this.capacity = 100;

        this.growFactor = 1.1;
        this.shrinkFactor = 0.9;

        this.referenceIndexLookup = new Map();
        this.indexReferenceLookup = [];

        /**
         *
         * @type {Array.<Operation>}
         */
        this.commandQueue = [];

        this.referencePool = new IdPool();

        this.build();
    }

    /**
     *
     * @param {Float64Array|Float32Array|Uint32Array|Uint16Array|Uint8Array|Int32Array|Int16Array|Int8Array} array
     * @param {int} itemSize
     * @returns {InstancedBufferAttribute|BufferAttribute}
     */
    buildNewBufferAttribute(array, itemSize) {
        if (this.instanced) {
            return new InstancedBufferAttribute(array, itemSize);
        } else {
            return new BufferAttribute(array, itemSize);
        }
    }

    /**
     *
     * @returns {InstancedBufferGeometry|BufferGeometry}
     */
    buildNewGeometry() {
        if (this.instanced) {
            const result = new InstancedBufferGeometry();
            result.maxInstanceCount = this.capacity;
            return result;
        } else {
            return new BufferGeometry();
        }
    }

    build() {
        const geometry = this.buildNewGeometry();
        geometry.dynamic = true;

        const l = this.spec.attributes.length;
        for (let i = 0; i < l; i++) {

            /**
             * @type {ParticleAttribute}
             */
            const attributeSpec = this.spec.attributes[i];

            const itemSize = itemSizeFromAttributeType(attributeSpec.type);
            const TypedArrayConstructor = typedArrayConstructorFromDataType(attributeSpec.dataType);

            const newArrayLength = itemSize * this.capacity;
            const newArray = new TypedArrayConstructor(newArrayLength);

            const attribute = this.attributes[i];
            if (attribute !== undefined && attribute.count !== 0) {
                //copy old data
                newArray.set(attribute.array.subarray(0, Math.min(newArrayLength, attribute.array.length)));
            }

            const newAttribute = this.buildNewBufferAttribute(newArray, itemSize);

            newAttribute.setUsage(DynamicDrawUsage);
            newAttribute.name = attributeSpec.name;

            this.attributes[i] = newAttribute;

            geometry.setAttribute(attributeSpec.name, newAttribute);
        }

        this.geometry.set(geometry);
    }

    /**
     * attributes can not grow, this means that we need to rebuild entire geometry if we want to make that happen
     */
    setCapacity(maxSize) {
        if (this.capacity === maxSize) {
            //do nothing
            return;
        }

        if (this.size > maxSize) {
            throw new Error(`Attempted to resize capacity to ${maxSize}. Can't set capacity below current size(=${this.size}).`);
        }

        // console.log(`capacity resized from ${this.capacity} to ${maxSize}`);

        this.capacity = maxSize;
        this.build();
    }

    reset() {
        this.commandQueue = [];
        this.size = 0;

        this.referenceIndexLookup.clear();
        this.indexReferenceLookup = [];
        this.referencePool.reset();

        this.setCapacity(100);
    }

    /**
     * Flush command queue
     * @returns {boolean} true if some operations were executed, false otherwise
     */
    update() {
        this.optimizeCommandQueue();
        const numOperations = this.commandQueue.length;
        for (let i = 0; i < numOperations; i++) {
            const operation = this.commandQueue[i];
            this.executeOperation(operation);
        }
        //clear out the queue
        this.commandQueue = [];

        return numOperations > 0;
    }

    optimizeCommandQueue() {
        optimizeCommandQueue(this.commandQueue);
    }

    /**
     *
     * @param {number} id
     */
    createSpecific(id) {
        const reference = this.referencePool.getSpecific(id);

        this.commandQueue.push(new Operation(OperationType.Add, [reference]));
    }

    /**
     * NOTE: Deferred operation, required update before results can be observed
     * @returns {number} particle reference
     */
    create() {
        //reserve reference
        const reference = this.referencePool.get();

        this.commandQueue.push(new Operation(OperationType.Add, [reference]));

        return reference;
    }

    /**
     * NOTE: Deferred operation, required update before results can be observed
     */
    remove(reference) {
        this.commandQueue.push(new Operation(OperationType.Remove, [reference]));
    }

    /**
     * NOTE: this method does not take pending operations into account
     * @param reference
     */
    contains(reference) {
        return this.referenceIndexLookup.has(reference);
    }

    traverseReferences(visitor) {
        this.referenceIndexLookup.forEach(function (value, key) {
            visitor(key);
        });
    }

    /**
     * NOTE: Deferred operation, required update before results can be observed
     * @param {int} reference
     * @param {int} attributeIndex Index of attribute to be written
     * @param {Array.<number>} value
     */
    writeAttribute(reference, attributeIndex, value) {
        this.commandQueue.push(new Operation(OperationType.WriteAttribute, [reference, attributeIndex, value]));
    }

    /**
     *
     * @param {number} index
     * @param {number} attributeIndex
     * @param {Array|Float32Array|Float64Array|Uint8Array} result
     */
    readAttributeByIndex(index, attributeIndex, result) {
        const attribute = this.attributes[attributeIndex];

        const itemSize = attribute.itemSize;

        const offset = index * itemSize;

        for (let i = 0; i < itemSize; i++) {
            const element = attribute.array[offset + i];
            result[i] = element;
        }
    }

    readAttribute(reference, attributeIndex, result) {
        //dereference
        const index = this.referenceIndexLookup.get(reference);

        this.readAttributeByIndex(index, attributeIndex, result);
    }

    /**
     * Produces reference of a particle by its index
     * @param {number} index
     * @returns {number}
     */
    referenceByIndex(index) {
        return this.indexReferenceLookup[index];
    }

    createImmediate() {
        const reference = this.referencePool.get();
        this.executeOperationAdd([reference]);

        return reference;
    }

    executeOperationAdd(references) {

        const numAdded = references.length;

        for (let i = 0; i < numAdded; i++) {
            const reference = references[i];
            const index = this.size + i;

            this.referenceIndexLookup.set(reference, index);
            this.indexReferenceLookup[index] = reference;
        }

        this.grow(numAdded);
    }

    executeOperationRemove(references) {
        const numRemoved = references.length;

        const deleteIndices = [];

        for (let i = 0; i < numRemoved; i++) {
            const reference = references[i];
            const index = this.referenceIndexLookup.get(reference);

            this.referenceIndexLookup.delete(reference);

            deleteIndices.push(index);

            //release reference
            this.referencePool.release(reference);
        }

        this.deleteIndices(deleteIndices);
    }

    /**
     *
     * @param reference
     * @param attributeIndex
     * @param {number[]} value
     */
    executeOperationWriteAttribute(reference, attributeIndex, value) {

        //de-reference
        const index = this.referenceIndexLookup.get(reference);

        //bind attribute
        const attribute = this.attributes[attributeIndex];

        attribute.array.set(value, index * attribute.itemSize);

        attribute.needsUpdate = true;
        //TODO: set update range on the attribute
    }

    /**
     *
     * @param reference
     * @param attributeIndex
     * @param {number} value
     */
    executeOperationWriteAttribute_Scalar(reference, attributeIndex, value) {
        //de-reference
        const index = this.referenceIndexLookup.get(reference);

        //bind attribute
        const attribute = this.attributes[attributeIndex];

        attribute.array[index] = value;

        attribute.needsUpdate = true;
        //TODO: set update range on the attribute
    }

    /**
     *
     * @param reference
     * @param attributeIndex
     * @param x
     * @param y
     * @param z
     */
    executeOperationWriteAttribute_Vector3(reference, attributeIndex, x, y, z) {
        //de-reference
        const index = this.referenceIndexLookup.get(reference);

        //bind attribute
        const attribute = this.attributes[attributeIndex];

        const address = index * 3;

        const array = attribute.array;

        array[address] = x;
        array[address + 1] = y;
        array[address + 2] = z;

        attribute.needsUpdate = true;
        //TODO: set update range on the attribute
    }

    /**
     *
     * @param reference
     * @param attributeIndex
     * @param x
     * @param y
     * @param z
     * @param w
     */
    executeOperationWriteAttribute_Vector4(reference, attributeIndex, x, y, z, w) {
        //de-reference
        const index = this.referenceIndexLookup.get(reference);

        //bind attribute
        const attribute = this.attributes[attributeIndex];

        const address = index * 4;

        const array = attribute.array;

        array[address] = x;
        array[address + 1] = y;
        array[address + 2] = z;
        array[address + 3] = w;

        attribute.needsUpdate = true;
        //TODO: set update range on the attribute
    }

    /**
     *
     * @param {Operation} operation
     */
    executeOperation(operation) {
        const operands = operation.operands;

        const self = this;

        function _writeAttribute(operands) {
            const reference = operands[0];
            const attributeIndex = operands[1];
            const value = operands[2];

            self.executeOperationWriteAttribute(reference, attributeIndex, value);
        }

        switch (operation.operator) {
            case OperationType.Add:
                this.executeOperationAdd(operands);
                break;

            case OperationType.Remove:
                this.executeOperationRemove(operands);
                break;

            case OperationType.WriteAttribute:
                _writeAttribute(operands);
                break;

            default:
                throw new Error(`Unsupported operator: ${operation.operator}`);
        }
    }

    /**
     *
     * @param {Array.<int>} indices
     */
    deleteIndices(indices) {
        const deleteCount = indices.length;

        if (deleteCount <= 0) {
            //nothing to do
            return;
        }

        const oldSize = this.size;
        const newSize = oldSize - deleteCount;

        //sort indices
        indices.sort();

        const specAttributes = this.spec.attributes;
        const numAttributes = specAttributes.length;

        let numSwapElements = 0;
        const swaps = [];
        let swapDestination = this.size - 1;

        let i, j;

        for (i = deleteCount - 1; i >= 0; i--) {
            const victim = indices[i];

            if (swapDestination === victim) {
                swapDestination--;
            }

            if (victim >= newSize) {
                //is beyond the end of the new array and will be removed anyway, no need to swap
                continue;
            }

            swaps.push(victim, swapDestination);
            numSwapElements += 2;
        }


        for (i = 0; i < numAttributes; i++) {
            const attribute = this.attributes[i];

            const itemSize = attribute.itemSize;

            const oldArray = attribute.array;

            //do swaps in the old array
            for (j = 0; j < numSwapElements; j += 2) {
                const targetIndex = swaps[j] * itemSize;
                const sourceIndex = swaps[j + 1] * itemSize;

                oldArray.copyWithin(targetIndex, sourceIndex, sourceIndex + itemSize);
            }

            attribute.needsUpdate = true;
        }

        //update references

        for (j = 0; j < numSwapElements; j += 2) {
            const targetIndex = swaps[j];
            const sourceIndex = swaps[j + 1];

            const ref = this.indexReferenceLookup[sourceIndex];
            //move reference
            this.indexReferenceLookup[targetIndex] = ref;
            //this reference will be getting a new index
            this.referenceIndexLookup.set(ref, targetIndex);
        }

        //cut tail of index lookup
        for (i = 0; i < deleteCount; i++) {
            this.indexReferenceLookup.pop();
        }

        this.setSize(newSize);
    }

    /**
     * Swap two particles identified by index
     * @param {number} indexA
     * @param {number} indexB
     */
    swapAttributeValues(indexA, indexB) {
        const attributes = this.attributes;
        const numAttributes = attributes.length;

        let i, j;

        for (i = 0; i < numAttributes; i++) {
            const attribute = attributes[i];

            const itemSize = attribute.itemSize;

            const offsetA = indexA * itemSize;
            const offsetB = indexB * itemSize;

            const array = attribute.array;

            for (j = 0; j < itemSize; j++) {
                const addressA = offsetA + j;
                const addressB = offsetB + j;

                const elementA = array[addressA];

                array[addressA] = array[addressB];

                array[addressB] = elementA;
            }

            attribute.needsUpdate = true;
        }


    }

    /**
     *
     * @param {number} indexA
     * @param {number} indexB
     */
    swap(indexA, indexB) {
        this.swapAttributeValues(indexA, indexB);

        const refA = this.indexReferenceLookup[indexA];
        const refB = this.indexReferenceLookup[indexB];

        this.referenceIndexLookup.set(refA, indexB);
        this.referenceIndexLookup.set(refB, indexA);

        this.indexReferenceLookup[indexA] = refB;
        this.indexReferenceLookup[indexB] = refA;
    }

    /**
     *
     * @param {int} numAdded
     */
    grow(numAdded) {
        const oldSize = this.size;
        const newSize = oldSize + numAdded;

        this.setSize(newSize);
    }

    preAllocate(itemCount) {
        const newSize = this.size + itemCount;

        if (this.capacity < newSize) {
            this.updateCapacity(newSize);
        }
    }

    updateCapacity(newSize) {

        if (newSize > this.capacity) {
            //grow
            const newCapacity = Math.floor(Math.max(newSize, this.capacity * this.growFactor, this.capacity + GROW_MIN_STEP));
            this.setCapacity(newCapacity);
        } else if (newSize < this.capacity * this.shrinkFactor && newSize < this.capacity - SHRINK_THRESHOLD) {
            //shrink
            this.setCapacity(newSize);
        }

        const geometry = this.geometry.getValue();
        geometry.setDrawRange(0, newSize);
    }

    /**
     *
     * @param {int} newSize
     */
    setSize(newSize) {
        this.size = newSize;

        this.updateCapacity(newSize);
    }
}


const SHRINK_THRESHOLD = 64;
const GROW_MIN_STEP = 16;