import { Group, Matrix4, Mesh } from "three";
import { mergeBufferGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { array_copy } from "../../../../../core/collection/array/array_copy.js";
import { HashMap } from "../../../../../core/collection/map/HashMap.js";
import { M4_IDENTITY } from "../../../../../core/geom/3d/mat4/M4_IDENTITY.js";
import { m4_multiply } from "../../../../../core/geom/3d/mat4/m4_multiply.js";
import { StaticMaterialCache } from "../../../../asset/loaders/material/StaticMaterialCache.js";
import { computeGeometryEquality } from "../../buffered/computeGeometryEquality.js";
import { computeGeometryHash } from "../../buffered/computeGeometryHash.js";

class GeometryContext {
    constructor() {
        this.matrix = new Float32Array(M4_IDENTITY);
        this.material = null;
        this.geometry = null;
    }
}

const DEFAULT_INSTANCING_MERGE_POLYCOUNT = 1024;

/**
 *
 * @param {THREE.Object3D} object
 * @param {THREE.Object3D} ancestor_root
 * @param {Float32Array} result
 */
function compute_transform_matrix(object, ancestor_root, result) {
    /**
     *
     * @type {THREE.Object3D[]}
     */
    const path = [];

    let node = object;

    while (node !== ancestor_root) {

        if (path.indexOf(node) !== -1) {
            throw new Error('Circular dependency');
        }

        path.push(node);

        node = node.parent;

        if (node === null) {
            throw new Error('Ancestor not found');
        }

    }

    //
    path.reverse();

    result.set(M4_IDENTITY);

    for (let i = 0; i < path.length; i++) {
        const el = path[i];

        m4_multiply(result, result, el.matrix.elements);
    }

}


/**
 *
 * @param {THREE.BufferGeometry} a
 * @param {THREE.BufferGeometry} b
 * @returns {boolean}
 */
function equal_attribute_structure(a, b) {
    if (a === b) {
        return true;
    }

    const a_attribute_names = Object.keys(a.attributes);
    const b_attribute_names = Object.keys(b.attributes);


    const n = a_attribute_names.length;
    if (n !== b_attribute_names.length) {
        return false;
    }

    for (let i = 0; i < n; i++) {
        const a_key = a_attribute_names[i];

        const b_attribute_index = b_attribute_names.indexOf(a_key);

        if (b_attribute_index === -1) {
            return -1;
        }

        const a_attribute = a.attributes[a_key];
        const b_attribute = b.attributes[a_key];

        if (a_attribute.itemSize !== b_attribute.itemSize) {
            return false;
        }

        if (a_attribute.normalized !== b_attribute.normalized) {
            return false;
        }

        if (a_attribute.array.constructor !== b_attribute.array.constructor) {
            return false;
        }
    }

    return true;
}

/**
 *
 * @param {GeometryContext[]} contexts
 * @returns {GeometryContext}
 */
function merge_contexts(contexts) {

    const geometries = contexts.map(ctx => {
        const g = ctx.geometry.clone();

        const matrix4 = new Matrix4();
        matrix4.elements = ctx.matrix;

        g.applyMatrix4(matrix4);

        return g;
    });

    const merged = mergeBufferGeometries(geometries);

    const merged_context = new GeometryContext();

    merged_context.geometry = merged;
    merged_context.material = contexts[0].material;

    return merged_context;
}

/**
 *
 * @param {GeometryContext[]} input
 * @param {GeometryContext[]} output
 * @param {number} output_offset
 * @returns {number}
 */
function merge_by_material(input, output, output_offset) {
    let result_offset = output_offset;

    const undecided = input.slice();

    while (undecided.length > 0) {
        for (let i = 0; i < undecided.length; i++) {
            const ref_0 = undecided[i];

            const set_indices = [i];
            const set_contexts = [ref_0];

            for (let j = i + 1; j < undecided.length; j++) {
                const ref_1 = undecided[j];

                const geo_0 = ref_0.geometry;
                const geo_1 = ref_1.geometry;

                if (geo_0 === geo_1) {
                    const tri_count = (geo_0.getIndex().count) / 3;
                    if (tri_count > DEFAULT_INSTANCING_MERGE_POLYCOUNT) {
                        // instancing, and instances are large enough for instancing to be efficient
                        continue;
                    }
                }

                if (!equal_attribute_structure(geo_0, geo_1)) {
                    continue;
                }

                if (ref_0.material !== ref_1.material) {
                    continue;
                }

                set_indices.push(j);
                set_contexts.push(ref_1);
            }

            let out = ref_0;

            for (let j = set_indices.length - 1; j >= 0; j--) {
                undecided.splice(set_indices[j], 1);
            }

            if (set_indices.length > 1) {


                out = merge_contexts(set_contexts);

                i--;
            }

            output[result_offset++] = out;
        }
    }

    return result_offset - output_offset;

}

/**
 *
 * @param {GeometryContext} ctx
 * @returns {Mesh}
 */
function context_to_mesh(ctx) {
    const mesh = new Mesh(ctx.geometry, ctx.material);

    matrix_array_to_three_object(ctx.matrix, mesh);

    return mesh;
}

/**
 *
 * @param {number[]} matrix
 * @param {THREE.Object3D} destination
 */
function matrix_array_to_three_object(matrix, destination) {

    array_copy(matrix, 0, destination.matrix.elements, 0, 16);

    destination.matrix.decompose(destination.position, destination.quaternion, destination.scale);

    destination.rotation.setFromQuaternion(destination.quaternion);
}

/**
 *
 * @param {THREE.Object3D} source
 * @param {THREE.Object3D} destination
 */
function apply_transform_to_another_three_object(source, destination) {
    const m4 = new Matrix4();

    m4.multiplyMatrices(source.matrix, destination.matrix);

    matrix_array_to_three_object(m4.elements, destination);
}

/**
 *
 * @param {THREE.Object3D} input
 * @returns {THREE.Object3D}
 */
export function merge_geometry_hierarchy(input) {
    // material cache
    const material_cache = new StaticMaterialCache();


    const geometry_cache = new HashMap({
        keyEqualityFunction: computeGeometryEquality,
        keyHashFunction: computeGeometryHash
    });


    /**
     *
     * @param {THREE.BufferGeometry} geo
     * @return {THREE.BufferGeometry}
     */
    function dedupe_geometry(geo) {
        const existing = geometry_cache.get(geo);

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

        geometry_cache.set(geo, geo);

        return geo;
    }

    let contexts = [];

    // collect geometries
    input.traverse(o => {
        if (o.isMesh && !!(o.geometry)) {

            const ctx = new GeometryContext();

            ctx.material = material_cache.acquire(o.material);
            ctx.geometry = dedupe_geometry(o.geometry);

            compute_transform_matrix(o, input, ctx.matrix);

            contexts.push(ctx);
        }
    });

    const contexts_merge_stage_0 = [];

    merge_by_material(contexts, contexts_merge_stage_0, 0);

    // convert contexts to Meshes

    const meshes = contexts_merge_stage_0.map(context_to_mesh);

    let result_object;

    if (meshes.length === 1) {
        // only one mesh is present, return that
        result_object = meshes[0];
    } else {

        result_object = new Group();

        for (let i = 0; i < meshes.length; i++) {
            const m = meshes[i];
            result_object.add(m);
        }
    }

    // copy transform from original
    apply_transform_to_another_three_object(input, result_object);

    return result_object;
}
