import { mat4 } from "gl-matrix";
import {
    ClampToEdgeWrapping,
    DataTexture,
    DataTexture3D,
    Frustum,
    LinearFilter,
    LinearMipMapLinearFilter,
    NearestFilter,
    RGBAFormat,
    RGBAIntegerFormat,
    UnsignedByteType,
    UnsignedShortType
} from "three";
import { assert } from "../../../../core/assert.js";
import { computeBinaryDataTypeByPrecision } from "../../../../core/binary/type/computeBinaryDataTypeByPrecision.js";
import {
    DataType2TypedArrayConstructorMapping
} from "../../../../core/binary/type/DataType2TypedArrayConstructorMapping.js";
import { BinaryUint32BVH } from "../../../../core/bvh2/binary/2/BinaryUint32BVH.js";
import { BVH } from "../../../../core/bvh2/bvh3/BVH.js";
import {
    bvh_query_user_data_overlaps_frustum
} from "../../../../core/bvh2/bvh3/query/bvh_query_user_data_overlaps_frustum.js";
import { array_copy } from "../../../../core/collection/array/array_copy.js";
import { array_sort_quick } from "../../../../core/collection/array/array_sort_quick.js";
import { array_swap_one } from "../../../../core/collection/array/array_swap_one.js";
import { read_cluster_frustum_corners } from "../../../../core/geom/3d/frustum/read_cluster_frustum_corners.js";
import { read_three_planes_to_array } from "../../../../core/geom/3d/frustum/read_three_planes_to_array.js";
import { slice_frustum_linear_to_points } from "../../../../core/geom/3d/frustum/slice_frustum_linear_to_points.js";
import { v3_morton_encode_transformed } from "../../../../core/geom/3d/morton/v3_morton_encode_transformed.js";
import { v3_distance } from "../../../../core/geom/vec3/v3_distance.js";
import Vector3 from "../../../../core/geom/Vector3.js";
import { NumericType } from "../../../../core/math/NumericType.js";
import { frustum_from_camera } from "../../ecs/camera/frustum_from_camera.js";
import { CachingTextureAtlas } from "../../texture/atlas/CachingTextureAtlas.js";
import { ReferencedTextureAtlas } from "../../texture/atlas/ReferencedTextureAtlas.js";
import { TextureAtlas } from "../../texture/atlas/TextureAtlas.js";
import {
    computeThreeTextureInternalFormatFromDataType
} from "../../texture/computeThreeTextureInternalFormatFromDataType.js";
import { computeThreeTextureTypeFromDataType } from "../../texture/computeThreeTextureTypeFromDataType.js";
import { writeSample2DDataToDataTexture } from "../../texture/sampler/writeSampler2DDataToDataTexture.js";
import { TextureBackedMemoryRegion } from "../../texture/TextureBackedMemoryRegion.js";
import { IncrementalDeltaSet } from "../visibility/IncrementalDeltaSet.js";
import { assign_cluster, LOOKUP_CACHE, scratch_corners, scratch_frustum_planes } from "./assign_cluster.js";
import { compute_cluster_planes_from_points } from "./cluster/compute_cluster_planes_from_points.js";
import { read_plane_pair } from "./cluster/read_plane_pair.js";
import { computeFrustumCorners } from "./computeFrustumCorners.js";
import { LightRenderMetadata } from "./LightRenderMetadata.js";
import { Decal } from "./model/Decal.js";
import { point_light_inside_volume } from "./query/point_light_inside_volume.js";


/**
 * How many bits are used to encode light type
 * @type {number}
 */
const LIGHT_ENCODING_TYPE_BIT_COUNT = 2;

/**
 *
 * @param {AbstractLight|PointLight|Decal} light
 * @returns {number}
 */
function encode_light_type(light) {
    if (light.isPointLight === true) {
        return 0;
    } else if (light.isDecal === true) {
        return 3;
    } else {
        throw new Error('Unsupported light type');
    }
}

/**
 *
 * @param {number} address
 * @param {AbstractLight} light
 * @returns {number}
 */
function encode_light_descriptor(address, light) {
    const light_type = encode_light_type(light);
    return light_type | (address << 2);
}

/**
 *
 * @param {IncrementalDeltaSet<LightRenderMetadata>} data_set
 * @param {BinaryUint32BVH} bvh
 */
function build_light_data_bvh(data_set, bvh) {
    const elements = data_set.elements;
    const element_count = data_set.size;

    bvh.setLeafCount(element_count);
    bvh.initialize_structure();

    for (let i = 0; i < element_count; i++) {

        /**
         *
         * @type {LightRenderMetadata}
         */
        const datum = elements[i];

        const bb = datum.bvh_leaf.bounds;

        const payload = encode_light_descriptor(datum.address, datum.light);

        bvh.setLeafData(
            i, payload,
            bb[0], bb[1], bb[2],
            bb[3], bb[4], bb[5],
        );
    }

    // vbvh.sort_morton(this.__projection_matrix);
    // vbvh.sort_bubble_sah();

    bvh.build();
}

/**
 *
 * @param {LightRenderMetadata} a
 * @param {LightRenderMetadata} b
 * @returns {number}
 */
function compareLRMByLightId(a, b) {
    return b.light.id - a.light.id;
}

export class LightManager {

    /**
     * @readonly
     * @type {BVH}
     */
    #light_data_bvh = new BVH();

    /**
     * Mapping from a light/decal object ID internal metadata
     * @type {Map<number,LightRenderMetadata>}
     */
    #metadata_map = new Map();


    constructor() {

        /**
         * Number of cluster slices along each dimension
         * @type {Vector3}
         * @private
         */
        this.__tiles_resolution = new Vector3(32, 16, 8);

        this.__cluster_texture_precision = 8;
        this.__cluster_texture_needs_rebuild = false;

        /**
         * Texel layout:
         *  - R: offset into lookup texture for lights
         *  - G: light count
         *  - B: offset into lookup texture for decals
         *  - A: decal count
         * @type {DataTexture3D}
         * @private
         */
        this.__cluster_texture = new DataTexture3D(
            new Uint16Array(4),
            1,
            1,
            1,
        );

        this.__cluster_texture.flipY = false;
        this.__cluster_texture.generateMipmaps = false;
        this.__cluster_texture.unpackAlignment = 2;

        this.__cluster_texture.magFilter = NearestFilter;
        this.__cluster_texture.minFilter = NearestFilter;

        this.__cluster_texture.type = UnsignedShortType;
        this.__cluster_texture.format = RGBAIntegerFormat;
        this.__cluster_texture.internalFormat = "RGBA16UI";

        /**
         *
         * @type {TextureBackedMemoryRegion}
         * @readonly
         * @private
         */
        this.__lookup_data = new TextureBackedMemoryRegion();
        this.__lookup_data.type = NumericType.Uint;
        this.__lookup_data.channel_count = 1;
        this.__lookup_data.precision = 8;
        this.__lookup_data.resize(1);

        /**
         *
         * @type {TextureBackedMemoryRegion}
         * @readonly
         * @private
         */
        this.__light_data = new TextureBackedMemoryRegion();
        this.__light_data.type = NumericType.Float;
        this.__light_data.precision = 32;
        this.__light_data.channel_count = 4;
        this.__light_data.resize(8);

        /**
         * @type {DataTexture}
         * @readonly
         * @private
         */
        this.__decal_atlas_texture = new DataTexture(new Uint8Array(4), 1, 1, RGBAFormat);
        this.__decal_atlas_texture.type = UnsignedByteType;
        this.__decal_atlas_texture.flipY = false;
        this.__decal_atlas_texture.wrapS = ClampToEdgeWrapping;
        this.__decal_atlas_texture.wrapT = ClampToEdgeWrapping;
        this.__decal_atlas_texture.minFilter = LinearMipMapLinearFilter;
        this.__decal_atlas_texture.magFilter = LinearFilter;
        this.__decal_atlas_texture.generateMipmaps = true;
        this.__decal_atlas_texture.unpackAlignment = 4;
        this.__decal_atlas_texture.anisotropy = 8;

        /**
         *
         * @type {AbstractTextureAtlas}
         * @readonly
         * @private
         */
        this.__decal_atlas = new CachingTextureAtlas({
            atlas: new TextureAtlas(512)
        });

        /**
         *
         * @type {ReferencedTextureAtlas}
         * @private
         */
        this.__decal_patch_references = new ReferencedTextureAtlas(this.__decal_atlas);

        /**
         *
         * @type {Map<Decal, Reference<AtlasPatch>>}
         * @private
         */
        this.__decal_references = new Map();

        /**
         *
         * @type {IncrementalDeltaSet<LightRenderMetadata>}
         * @readonly
         * @private
         */
        this.__visible_lights = new IncrementalDeltaSet(compareLRMByLightId);
        /**
         *
         * @type {IncrementalDeltaSet<LightRenderMetadata>}
         * @readonly
         * @private
         */
        this.__visible_decals = new IncrementalDeltaSet(compareLRMByLightId);

        /**
         *
         * @type {LightRenderMetadata[]}
         * @private
         */
        this.__sorted_visible_lights = [];

        /**
         *
         * @type {BinaryUint32BVH}
         * @private
         */
        this.__visible_bvh_lights = new BinaryUint32BVH();
        /**
         *
         * @type {BinaryUint32BVH}
         * @private
         */
        this.__visible_bvh_decals = new BinaryUint32BVH();

        /**
         *
         * @type {Frustum}
         * @private
         */
        this.__view_frustum = new Frustum();

        /**
         * Corner points of the view frustum
         * @type {Float32Array}
         * @private
         */
        this.__view_frustum_points = new Float32Array(24);

        /**
         *
         * @type {Float32Array|mat4|number[]}
         * @private
         */
        this.__projection_matrix = new Float32Array(16);


        /**
         * Accelerated data structure with pre-computed plane normals for clusters in each dimensions. Order: X, Y, Z
         * @type {Float32Array}
         * @private
         */
        this.__cluster_planes = new Float32Array(1);

        /**
         * Accelerated data structure with pre-computed corner coordinates of each cluster's frustum
         * @type {Float32Array}
         * @private
         */
        this.__cluster_frustum_points = new Float32Array(1);

        this.setTileMapResolution(32, 16, 16);

        this.__visible_lights.onAdded.add(this.__handle_visible_light_added, this);
        this.__visible_lights.onRemoved.add(this.__handle_visible_light_removed, this);

        this.__visible_decals.onAdded.add(this.__handle_visible_decal_added, this);
        this.__visible_decals.onRemoved.add(this.__handle_visible_decal_removed, this);

        /**
         * Data needs to be re-written into the data texture
         * Usually set when source lights change
         * @type {boolean}
         * @private
         */
        this.__light_data_needs_update = true;

        /**
         *
         * @type {boolean}
         * @private
         */
        this.__visible_bvh_needs_update = true;

        // window.light_manager = this; // DEBUG
    }

    requestDataUpdate() {
        this.__light_data_needs_update = true;
    }

    /**
     * Please set this to false if you have a lot of overlapping decals in the scene
     * Overlapping decals can provide filtering artifacts due to incorrect mipmap level detection by WebGL
     * see article: https://0fps.net/2013/07/09/texture-atlases-wrapping-and-mip-mapping/
     * NOTE: the technique mentioned in the article above is not implemented, as it would require significant increase in number of texture fetches
     * @param {boolean} v
     */
    set decal_filtering_enabled(v) {
        const t = this.__decal_atlas_texture;

        if (t.generateMipmaps === v) {
            return;
        }

        t.generateMipmaps = v;
    }

    /**
     *
     * @return {boolean}
     */
    get decal_filtering_enabled() {
        return this.__decal_atlas_texture.generateMipmaps;
    }

    __update_decal_atlas_texture() {
        const sampler = this.__decal_atlas.sampler;

        if (sampler.version !== this.__decal_atlas_texture.version) {
            writeSample2DDataToDataTexture(sampler, this.__decal_atlas_texture);

            this.__decal_atlas_texture.version = sampler.version;
        }
    }

    /**
     * Sometimes light lookup table can get quite large, to make sure we can store these addresses in the cluster texture,
     * we may need to switch to a larger data type, such as Uint8->Uint16 or Uint16->Uint32
     * @param {number} bit_count How many bits should be addressable from the cluster content
     * @returns {boolean} true if texture was rebuilt (underlying type change), false otherwise
     * @private
     */
    __set_cluster_addressable_bit_range(bit_count) {
        const rounded_value = Math.ceil(bit_count);

        this.__cluster_texture_precision = rounded_value;
        const dataType = computeBinaryDataTypeByPrecision(NumericType.Uint, rounded_value);

        const threeTextureType = computeThreeTextureTypeFromDataType(dataType);

        const texture = this.__cluster_texture;
        if (threeTextureType !== texture.type) {
            this.__cluster_texture_needs_rebuild = true;

            return true;
        } else {
            return false;
        }
    }

    __update_cluster_texture() {
        if (this.__cluster_texture_needs_rebuild) {
            this.__build_cluster_texture();
        }
    }

    __build_cluster_texture() {
        const dataType = computeBinaryDataTypeByPrecision(NumericType.Uint, this.__cluster_texture_precision);

        const threeTextureType = computeThreeTextureTypeFromDataType(dataType);

        const texture = this.__cluster_texture;

        const channelCount = 4;

        texture.dispose();

        const image = texture.image;

        const resolution = this.__tiles_resolution;

        image.width = resolution.x;
        image.height = resolution.y;
        image.depth = resolution.z;

        texture.type = threeTextureType;
        texture.internalFormat = computeThreeTextureInternalFormatFromDataType(dataType, channelCount);

        const ArrayConstructor = DataType2TypedArrayConstructorMapping[dataType];

        image.data = new ArrayConstructor(resolution.x * resolution.y * resolution.z * channelCount);

        texture.needsUpdate = true;

        this.__cluster_texture_needs_rebuild = false;
    }

    /**
     *
     * @param {number} size
     * @returns {boolean}
     * @private
     */
    __ensure_lookup_size(size) {
        return this.__lookup_data.resize(size);
    }

    /**
     *
     * @returns {DataTexture}
     */
    getTextureLookup() {
        return this.__lookup_data.getTexture();
    }

    /**
     *
     * @returns {DataTexture}
     */
    getTextureData() {
        return this.__light_data.getTexture();
    }

    /**
     * @returns {DataTexture3D}
     */
    getTextureClusters() {
        return this.__cluster_texture;
    }

    /**
     * @returns {DataTexture}
     */
    getTextureDecalAtlas() {
        return this.__decal_atlas_texture;
    }

    /**
     * NOTE: do not modify the value
     * @returns {Readonly<Vector3>}
     */
    getResolution() {
        return this.__tiles_resolution;
    }

    /**
     *
     * @private
     */
    __handle_light_dimensions_change() {
        // internal BVH needs to be rebuilt
        this.__visible_bvh_needs_update = true;
        // when dimensions change, typically data will need to be re-written to the GPU, things like light positions or projection matrix
        this.__light_data_needs_update = true;
    }

    /**
     *
     * @param {LightRenderMetadata} data
     * @private
     */
    __handle_visible_decal_added(data) {
        this.__light_data_needs_update = true;

        /**
         *
         * @type {Decal}
         */
        const light = data.light;

        light.onDimensionChanged(this.__handle_light_dimensions_change, this);

        const ref = this.__decal_patch_references.acquire(light.texture_diffuse);

        const patch = ref.getValue();
        const patch_uv = patch.uv;

        patch_uv.position.onChanged.add(light.handleUvPositionChange, light);
        patch_uv.size.onChanged.add(light.handleUvSizeChange, light);

        light.uv[0] = patch_uv.position.x;
        light.uv[1] = patch_uv.position.y;
        light.uv[2] = patch_uv.size.x;
        light.uv[3] = patch_uv.size.y;

        this.__decal_references.set(light, ref);
    }

    /**
     *
     * @param {LightRenderMetadata} data
     * @private
     */
    __handle_visible_decal_removed(data) {
        this.__light_data_needs_update = true;

        /**
         *
         * @type {Decal}
         */
        const light = data.light;

        light.offDimensionChanged(this.__handle_light_dimensions_change, this);

        const ref = this.__decal_references.get(light);

        if (ref === undefined) {
            // This can occur when decal changes while being in the visible list, messing with the IncrementalDeltaSet's compare order
            console.warn(`Decal reference not found: ${light}`);
            return;
        }

        const patch = ref.getValue();
        const patch_uv = patch.uv;

        // unsubscribe
        patch_uv.position.onChanged.remove(light.handleUvPositionChange, light);
        patch_uv.size.onChanged.remove(light.handleUvSizeChange, light);


        ref.release();

        this.__decal_references.delete(light);
    }

    /**
     *
     * @param {LightRenderMetadata} data
     * @private
     */
    __handle_visible_light_added(data) {
        this.__light_data_needs_update = true;

        const light = data.light;

        light.onDimensionChanged(this.__handle_light_dimensions_change, this);
    }

    /**
     *
     * @param {LightRenderMetadata} data
     * @private
     */
    __handle_visible_light_removed(data) {
        this.__light_data_needs_update = true;

        const light = data.light;
        light.offDimensionChanged(this.__handle_light_dimensions_change, this);
    }

    /**
     *
     * @param {LightRenderMetadata} light
     * @returns {number}
     * @protected
     */
    __sort_visible_light_score(light) {
        const center = scratch_corners;

        light.light.getCenter(center)

        const x = center[0];
        const y = center[1];
        const z = center[2];

        return v3_morton_encode_transformed(x, y, z, this.__projection_matrix);
    }

    /**
     * DEBUG method
     * @param {LightRenderMetadata} lights
     * @param {number} count
     * @returns {number} lower = better sorting score
     * @private
     */
    __assess_sorting_score(lights, count) {

        let r = 0;

        const center_a = [];
        const center_b = [];

        for (let i = 0; i < count; i++) {
            const a = lights[i];

            const score_a = this.__sort_visible_light_score(a);
            a.light.getCenter(center_a);

            for (let j = i + 1; j < count; j++) {
                const b = lights[j];

                const score_b = this.__sort_visible_light_score(b);
                b.light.getCenter(center_b);

                //
                const score_distance = Math.abs(score_a - score_b);
                const position_distance = v3_distance(
                    center_a[0], center_a[1], center_a[2],
                    center_b[0], center_b[1], center_b[2],
                );

                r += score_distance / position_distance;
            }
        }


        return r / (count * (count - 1) * 0.5);
    }

    /**
     * Sort lights for better data locality
     * @private
     */
    __sort_visible_light() {
        const sorted_lights = this.__sorted_visible_lights;

        const visible_light_set = this.__visible_lights;
        const visible_light_count = visible_light_set.size;

        const visible_decal_set = this.__visible_decals;
        const visible_decal_count = visible_decal_set.size;

        const expected_sorted_size = visible_light_count + visible_decal_count;

        if (expected_sorted_size > visible_light_count) {
            // crop array if it's too big
            sorted_lights.splice(visible_light_count, expected_sorted_size - visible_light_count)
        }

        array_copy(visible_light_set.elements, 0, sorted_lights, 0, visible_light_count);
        array_copy(visible_decal_set.elements, 0, sorted_lights, visible_light_count, visible_decal_count);

        array_sort_quick(sorted_lights, this.__sort_visible_light_score, this, 0, expected_sorted_size - 1);
    }

    __update_visible_bvh() {
        this.__visible_bvh_needs_update = false;

        build_light_data_bvh(
            this.__visible_lights,
            this.__visible_bvh_lights
        );

        build_light_data_bvh(
            this.__visible_decals,
            this.__visible_bvh_decals
        );
    }


    /**
     *
     * @param {Camera} camera
     * @private
     */
    __build_view_frustum(camera) {
        frustum_from_camera(camera, this.__view_frustum, false);

        array_swap_one(this.__view_frustum.planes, 4, 5);

        this.__build_view_frustum_points();
    }

    __build_visible_light_list() {

        /**
         *
         * @type {IncrementalDeltaSet<LightRenderMetadata>}
         */
        const visible_lights = this.__visible_lights;

        visible_lights.initializeUpdate();

        const visible_decals = this.__visible_decals;

        visible_decals.initializeUpdate();

        const nodes = [];

        read_three_planes_to_array(this.__view_frustum.planes, scratch_frustum_planes)

        /*
        Search is done in 2 phases:
        1) broad phase using bounding boxes
        2) granular phase where we use object-specific shape to check against frustum
         */

        const broad_match_count = bvh_query_user_data_overlaps_frustum(nodes, 0, this.#light_data_bvh, scratch_frustum_planes);

        for (let i = 0; i < broad_match_count; i++) {

            const light_id = nodes[i];

            /**
             *
             * @type {LightRenderMetadata}
             */
            const light_data = this.#metadata_map.get(light_id);

            /**
             *
             * @type {PointLight|Decal}
             */
            const light = light_data.light;

            if (light.isDecal === true) {


                // decals go into a separate bucket
                visible_decals.push(light_data);

            } else if (light.isPointLight === true) {
                // perform granular check

                const light_position = light.position;

                const light_x = light_position.x;
                const light_y = light_position.y;
                const light_z = light_position.z;

                const radius = light.radius.getValue();

                if (!point_light_inside_volume(light_x, light_y, light_z, radius, this.__view_frustum_points, scratch_frustum_planes)) {
                    // outside of view frustum
                    continue;
                }


                // register as visible
                visible_lights.push(light_data);
            }

        }

        visible_lights.finalizeUpdate();

        visible_decals.finalizeUpdate();
    }

    __write_light_data_texture() {

        /**
         *
         * @type {LightRenderMetadata[]}
         */
        const visible_lights = this.__sorted_visible_lights;

        // write light data into texture
        const visible_light_count = visible_lights.length;

        // compute amount of space required for the lights
        let address = 0;
        for (let i = 0; i < visible_light_count; i++) {
            const datum = visible_lights[i];

            /**
             *
             * @type {PointLight}
             */
            const light = datum.light;

            address += light.ENCODED_SLOT_COUNT;
        }

        const expected_light_data_size = address;


        const light_data = this.__light_data;
        light_data.resize(Math.ceil(address / 4));

        light_data.update();

        // update required lookup precision
        this.__lookup_data.precision = Math.max(
            0,
            Math.ceil(Math.log2(light_data.size))
        ) + LIGHT_ENCODING_TYPE_BIT_COUNT;

        const tx_light = light_data.getTexture();

        /**
         *
         * @type {Float32Array}
         */
        const tx_light_data = tx_light.image.data;

        address = 0;

        for (let i = 0; i < visible_light_count; i++) {
            const datum = visible_lights[i];

            assert.lessThan(address, tx_light_data.length, "overflow");

            /**
             *
             * @type {PointLight}
             */
            const light = datum.light;

            datum.address = address >> 2;

            const written_slots = light.toArray(tx_light_data, address);

            address += written_slots;

            // align address to 4 slot boundary
            if ((address & 3) !== 0) {
                // not on 4 slot boundary
                address = ((address >> 2) + 1) << 2;
            }
        }

        if (address !== expected_light_data_size) {
            throw new Error(`Expected light data size is ${expected_light_data_size}, actual written data is size is ${address}`);
        }

        tx_light.needsUpdate = true;

        this.__visible_bvh_needs_update = true;
    }

    /**
     * Perform cluster assignment where each cluster is filled with overlapping lights
     * @private
     */
    __assign_lights_to_clusters() {

        const tiles_resolution = this.__tiles_resolution;

        const tr_z = tiles_resolution.z;
        const tr_y = tiles_resolution.y;
        const tr_x = tiles_resolution.x;

        const tr_x_1 = tr_x + 1;
        const tr_y_1 = tr_y + 1;

        const tr_xy_1 = tr_x_1 * tr_y_1;

        /**
         *
         * @type {Float32Array}
         */
        const cluster_planes = this.__cluster_planes;

        const bvh_lights = this.__visible_bvh_lights;
        const bvh_decals = this.__visible_bvh_decals;


        const cluster_planes_x_offset = 0;
        const cluster_planes_y_offset = tr_x_1 * 4;
        const cluster_planes_z_offset = cluster_planes_y_offset + tr_y_1 * 4;

        /**
         *
         * @type {number[]|Float32Array}
         */
        const light_source_data = this.__light_data.getTexture().image.data;

        const tile_slice_size = tr_x * tr_y;

        const light_cluster_texture = this.__cluster_texture;
        const tile_texture_data = light_cluster_texture.image.data;

        const light_lookup = this.__lookup_data;
        light_lookup.update();
        const light_lookup_texture = light_lookup.getTexture();
        const light_lookup_texture_data = light_lookup_texture.image.data;

        const cluster_frustum_points = this.__cluster_frustum_points;

        let lookup_address_offset = 0;
        let i_x_0, i_y_0, i_z_0;

        // clear cache
        LOOKUP_CACHE.fill(0xFFFFFFFF);

        // console.log('Assignment Start');

        for (i_z_0 = 0; i_z_0 < tr_z; i_z_0++) {

            // construct z planes

            const slice_offset = i_z_0 * tile_slice_size;

            const z_plane_index_offset_0 = cluster_planes_z_offset + i_z_0 * 4;

            read_plane_pair(cluster_planes, z_plane_index_offset_0, scratch_frustum_planes, 16);

            for (i_y_0 = 0; i_y_0 < tr_y; i_y_0++) {

                // construct y planes

                const y_plane_index_offset_0 = cluster_planes_y_offset + i_y_0 * 4;

                read_plane_pair(cluster_planes, y_plane_index_offset_0, scratch_frustum_planes, 8);

                for (i_x_0 = 0; i_x_0 < tr_x; i_x_0++) {

                    // construct x planes

                    const x_plane_index_offset_0 = cluster_planes_x_offset + i_x_0 * 4;

                    read_plane_pair(cluster_planes, x_plane_index_offset_0, scratch_frustum_planes, 0);

                    read_cluster_frustum_corners(scratch_corners, i_z_0, tr_xy_1, i_y_0, tr_x_1, i_x_0, cluster_frustum_points);

                    const tile_index = slice_offset + (i_y_0 * tr_x) + i_x_0;

                    const tile_data_offset = tile_index * 4;

                    //assign lights
                    lookup_address_offset += assign_cluster(
                        tile_data_offset,
                        bvh_lights,
                        lookup_address_offset,
                        light_lookup_texture_data,
                        tile_texture_data,
                        light_source_data
                    );

                    // assign decals
                    const decal_count = assign_cluster(
                        tile_data_offset + 2,
                        bvh_decals,
                        lookup_address_offset,
                        light_lookup_texture_data,
                        tile_texture_data,
                        light_source_data
                    );

                    /*
                    TODO fix decal sorting artifacts

                    if (decal_count > 1) { // only sort when there are 2 or more, sorting 1 object is pointless
                        // decals need to be sorted for correct blending result
                        sort_decal_data(
                            light_lookup_texture_data,
                            light_source_data,
                            lookup_address_offset,
                            decal_count
                        );
                    }

                     */

                    lookup_address_offset += decal_count;
                }
            }
        }

        light_cluster_texture.needsUpdate = true;
        light_lookup_texture.needsUpdate = true;

        // Post-fact lookup texture resize, this frame will be wrong, but next one should be ok
        const current_lookup_size = this.__lookup_data.size;

        const lookup_memory_resized = this.__ensure_lookup_size(lookup_address_offset);

        const cluster_resized = this.__set_cluster_addressable_bit_range(Math.max(0, Math.log2(lookup_address_offset)));

        if (
            (lookup_memory_resized && current_lookup_size < lookup_address_offset)
            || cluster_resized
        ) {
            // overflow, we ended up trying to write more lights into some clusters than what they could contain
            this.__update_cluster_texture();

            // re-do assignment
            this.__assign_lights_to_clusters();
        }

        // console.log('Unique assignment count: ', assignment_count, ', Hash reuse:', hash_reuse_count);
    }

    /**
     *
     * @private
     */
    __build_cluster_frustum_planes() {
        const tiles_resolution = this.__tiles_resolution;

        const tr_z = tiles_resolution.z;
        const tr_y = tiles_resolution.y;
        const tr_x = tiles_resolution.x;

        const destination = this.__cluster_planes;

        compute_cluster_planes_from_points(destination, this.__cluster_frustum_points, tr_x, tr_y, tr_z);
    }

    /**
     *
     * @private
     */
    __build_view_frustum_points() {

        const view_frustum = this.__view_frustum;

        const points = this.__view_frustum_points;

        computeFrustumCorners(points, view_frustum.planes);
    }

    /**
     *
     * @private
     */
    __build_cluster_frustum_points() {
        const tiles_resolution = this.__tiles_resolution;

        const tr_z = tiles_resolution.z;
        const tr_y = tiles_resolution.y;
        const tr_x = tiles_resolution.x;

        const view_frustum_points = this.__view_frustum_points;
        const points_data = this.__cluster_frustum_points;

        slice_frustum_linear_to_points(view_frustum_points, tr_x, tr_y, tr_z, points_data);
    }

    /**
     * Build light tile texture
     * @param {Camera|THREE.PerspectiveCamera} camera
     */
    buildTiles(camera) {
        // camera.updateProjectionMatrix();
        // camera.updateMatrix();
        // camera.updateMatrixWorld(true);

        //update world inverse matrix
        camera.matrixWorldInverse.copy(camera.matrixWorld);
        camera.matrixWorldInverse.invert();

        // this.__projection_matrix.set(camera.matrixWorldInverse.elements);
        mat4.multiply(this.__projection_matrix, camera.projectionMatrix.elements, camera.matrixWorldInverse.elements);

        // console.time('__build_view_frustum');
        this.__build_view_frustum(camera);
        // console.timeEnd('__build_view_frustum');


        // console.time('__build_visible_light_list');
        this.__build_visible_light_list();
        // console.timeEnd('__build_visible_light_list');

        // update decal atlas
        this.__decal_atlas.update();
        this.__update_decal_atlas_texture();

        // console.time('__write_light_data_texture');
        if (this.__light_data_needs_update) {
            // sort light for better search and query locality
            this.__sort_visible_light();

            this.__write_light_data_texture();

            // clear flag
            this.__light_data_needs_update = false;
        }
        // console.timeEnd('__write_light_data_texture');

        if (this.__visible_bvh_needs_update) {
            this.__update_visible_bvh();
        }

        this.__build_cluster_frustum_points();
        // console.time('__build_cluster_frustum_planes');
        this.__build_cluster_frustum_planes();
        // console.timeEnd('__build_cluster_frustum_planes');

        // console.time('__assign_lights_to_clusters');
        this.__assign_lights_to_clusters();
        // console.timeEnd('__assign_lights_to_clusters');
    }

    dispose() {
        this.__cluster_texture.dispose();
        this.__light_data.dispose();
        this.__lookup_data.dispose();
        this.__decal_atlas_texture.dispose();
    }

    /**
     * Set resolution of the cluster texture, higher resolution will result in less load on the GPU, but will take up more RAM to represent and more time to build the clusters each frame
     * @param {number} x
     * @param {number} y
     * @param {number} z
     */
    setTileMapResolution(x, y, z) {
        assert.isNumber(x, 'x');
        assert.isNonNegativeInteger(x, 'x');
        assert.isFinite(x, 'x');
        assert.greaterThan(x, 0, 'x must be > 0');

        assert.isNumber(y, 'y');
        assert.isNonNegativeInteger(y, 'y');
        assert.isFinite(y, 'y');
        assert.greaterThan(y, 0, 'y must be > 0');

        assert.isNumber(z, 'z');
        assert.isNonNegativeInteger(z, 'z');
        assert.isFinite(z, 'z');
        assert.greaterThan(z, 0, 'z must be > 0');

        const r = this.__tiles_resolution;

        if (x === r.x && y === r.y && z === r.z) {
            // special case, no change
            return;
        }

        r.set(x, y, z);

        this.__cluster_texture_needs_rebuild = true;
        this.__update_cluster_texture();

        const cluster_planes_size = ((x + 1) + (y + 1) + (z + 1)) * 4;
        const cluster_frustum_points_size = (x + 1) * (y + 1) * (z + 1) * 3;

        const buffer = new ArrayBuffer((cluster_planes_size + cluster_frustum_points_size) * 4);

        this.__cluster_planes = new Float32Array(
            buffer, 0,
            cluster_planes_size
        );
        this.__cluster_frustum_points = new Float32Array(
            buffer, cluster_planes_size * 4,
            cluster_frustum_points_size
        );
    }

    /**
     *
     * @param {AbstractLight} light
     * @returns {boolean}
     */
    hasLight(light) {
        return this.#metadata_map.has(light.id);
    }

    /**
     *
     * @param {AbstractLight} light
     */
    addLight(light) {
        const lightData = new LightRenderMetadata(light);

        lightData.link(this.#light_data_bvh);

        this.#metadata_map.set(light.id, lightData);
    }

    /**
     *
     * @param {AbstractLight} light
     * @returns {boolean}
     */
    removeLight(light) {

        const light_id = light.id;

        const data = this.#metadata_map.get(light_id);

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

        data.unlink();

        this.#metadata_map.delete(light_id);

        return true;
    }
}
