import { MeshDepthMaterial, RGBADepthPacking } from "three";
import { DDSLoader } from "three/examples/jsm/loaders/DDSLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { HashSet } from "../../../core/collection/set/HashSet.js";
import { AABB3 } from "../../../core/geom/3d/aabb/AABB3.js";
import { AnimationOptimizer } from "../../ecs/animation/AnimationOptimizer.js";
import { Transform } from "../../ecs/transform/Transform.js";
import { traverseThreeObject } from "../../graphics/ecs/highlight/renderer/traverseThreeObject.js";
import { computeGeometryEquality } from "../../graphics/geometry/buffered/computeGeometryEquality.js";
import { computeGeometryHash } from "../../graphics/geometry/buffered/computeGeometryHash.js";
import { ensureGeometryBoundingSphere } from "../../graphics/geometry/buffered/ensureGeometryBoundingSphere.js";
import {
    computeSkinnedMeshBoundingVolumes
} from "../../graphics/geometry/skining/computeSkinnedMeshBoundingVolumes.js";
import { cloneObject3D } from "../../graphics/three/cloneObject3D.js";
import { three_computeObjectBoundingBox } from "../../graphics/three/three_computeObjectBoundingBox.js";
import { prepareMaterial, prepareObject } from "../../graphics/three/ThreeFactory.js";
import { ensureGeometryBoundingBox } from "../../graphics/util/ensureGeometryBoundingBox.js";
import { Asset } from "../Asset.js";
import { CrossOriginKind } from "../CORS/CrossOriginKind.js";
import { AssetLoader } from "./AssetLoader.js";
import { async_traverse_three_object } from "./async_traverse_three_object.js";
import { computeObjectBoundingSphere } from "./gltf/computeObjectBoundingSphere.js";
import GLTFTextureDDSExtension from "./gltf/extensions/MSFT_texture_dds.js";
import { isMesh } from "./gltf/isMesh.js";
import { computeTextureEquality } from "./material/computeTextureEquality.js";
import { computeTextureHash } from "./material/computeTextureHash.js";
import { StaticMaterialCache } from "./material/StaticMaterialCache.js";
import { TextureAttachmentsByMaterialType } from "./material/TextureAttachmensByMaterialType.js";

const animationOptimizer = new AnimationOptimizer();
const materialCache = StaticMaterialCache.Global;

/**
 *
 * @param {THREE.Material} material
 * @param {HashSet<Texture>} texture_cache
 */
function replace_material_textures_from_cache(material, texture_cache) {
    const attachments = TextureAttachmentsByMaterialType[material.type];

    if (attachments === undefined) {
        // unsupported material
        return;
    }

    const attachment_count = attachments.length;

    for (let i = 0; i < attachment_count; i++) {
        const attachment = attachments[i];

        const texture = attachment.read(material);
        if (texture !== undefined && texture !== null) {
            const cached = texture_cache.ensure(texture);

            if (cached !== texture) {
                // got an existing cached texture, write that instead
                attachment.write(material, cached);
            }
        }
    }
}

/**
 * Transfer any transform on the root down to the children instead
 * @param {Object3D} root
 * @param {Transform} result
 * @returns {boolean} whether or not any transform was carried out
 */
function transferRootTransform(root, result) {
    if (
        root.position.x === 0 && root.position.y === 0 && root.position.z === 0
        && root.rotation.x === 0 && root.rotation.y === 0 && root.rotation.z === 0
        && root.scale.x === 1 && root.scale.y === 1 && root.scale.z === 1
    ) {
        // no transform to apply
        return false;
    }

    // record transformation matrix on the root
    root.updateMatrix();
    result.fromMatrix(root.matrix.elements);

    return true;
}

/**
 *
 * @param {Material} material
 */
function configureTextures(material) {

    //patch textures
    const materialType = material.type;

    /**
     *
     * @type {TextureAttachment[]}
     */
    const attachments = TextureAttachmentsByMaterialType[materialType];


    if (attachments !== undefined) {
        const n = attachments.length;

        for (let i = 0; i < n; i++) {
            const attachment = attachments[i];

            const texture = attachment.read(material);

            if (texture === null || texture === undefined) {
                continue;
            }

            //enable anisotropic filtering
            texture.anisotropy = 8;
        }

    }
}

/**
 * Path to be supplied to draco decoder
 * @returns {string}
 */
function buildDracoDecoderPath() {

    let pathname = window.location.pathname;
    if (pathname.length > 0 && pathname.endsWith('/')) {
        pathname = pathname.slice(0, -1);
    }
    return `${pathname}/libs/draco/`;
}

export class GLTFAssetLoader extends AssetLoader {

    constructor() {
        super();


        this.__dracoLoader = new DRACOLoader();
        this.__dracoLoader.setWorkerLimit(1);

        // set default path
        this.__dracoLoader.setDecoderPath(buildDracoDecoderPath());

        const loader = new GLTFLoader();

        const ddsLoader = new DDSLoader();

        loader.register(p => new GLTFTextureDDSExtension(p, ddsLoader));
        loader.setDRACOLoader(this.__dracoLoader);

        this.loader = loader;

        /**
         * Asset optimization flag
         * Whether or not geometry de-duping should be done
         * @type {boolean}
         * @private
         */
        this.__enable_geometry_cache = false;

        /**
         * Asset optimization flag
         * Whether or not texture de-duping should be done
         * @type {boolean}
         * @private
         */
        this.__enable_texture_cache = false;

        /**
         *
         * @type {HashSet<THREE.BufferGeometry>}
         * @private
         */
        this.__geometry_cache = new HashSet({
            keyHashFunction: computeGeometryHash,
            keyEqualityFunction: computeGeometryEquality,
            capacity: 1024
        });


        /**
         *
         * @type {HashSet<Texture>}
         * @private
         */
        this.__texture_cache = new HashSet({
            keyHashFunction: computeTextureHash,
            keyEqualityFunction: computeTextureEquality,
            capacity: 2048
        });
    }

    /**
     *
     * @param {string} p
     */
    setDracoDecoderPath(p) {
        this.__dracoLoader.setDecoderPath(p);
    }

    /**
     *
     * @param {boolean} v
     */
    set is_micron_enabled(v) {
        console.warn('Deprecated. Handled by the MicronRenderPlugin. Remove usage, no further action required');
    }

    /**
     *
     * @returns {boolean}
     */
    get is_micron_enabled() {
        throw new Error('Deprecated. Handled by the MicronRenderPlugin. Remove usage, no further action required. To check for presence of micron, check plugins instead.');
    }

    async link(assetManager, engine) {
        await super.link(assetManager, engine);

        this.loader.setCrossOrigin(assetManager.crossOriginConfig.kind);
        if (assetManager.crossOriginConfig.kind === CrossOriginKind.UseCredentials) {
            this.loader.setWithCredentials(true);
        }
    }

    /**
     *
     * @param {THREE.Mesh|THREE.Object3D} object
     * @private
     */
    __prepareObjectMaterial(object) {
        if (!isMesh(object)) {
            return;
        }

        /**
         *
         * @type {GraphicsEngine}
         */
        const graphics = this.context.graphics;

        /**
         *
         * @type {MaterialManager}
         */
        const materialManager = graphics.getMaterialManager();

        prepareMaterial(object.material);

        configureTextures(object.material);

        //re-write material with a cached one if possible to reduce draw calls and texture unit usage
        object.material = materialManager.obtain(object.material).getValue();


        //if material uses alpha testing, we need a custom depth material for shadows to look right
        if (object.material.alphaTest !== 0) {

            const depthMaterial = new MeshDepthMaterial({
                depthPacking: RGBADepthPacking,
                map: object.material.map,
                alphaTest: object.material.alphaTest
            });

            if (this.__enable_texture_cache) {
                object.customDepthMaterial = materialCache.acquire(depthMaterial);
            }
        }
    }


    load(scope, path, success, failure, progress) {
        const loader = this.loader;
        const texture_cache = this.__texture_cache;


        /**
         *
         * @param {THREE.Mesh|THREE.SkinnedMesh} mesh
         */
        const processMesh = async (mesh) => {
            let geometry = mesh.geometry;

            if (geometry === undefined) {
                throw new Error(`No geometry found`);
            }

            if (this.__enable_geometry_cache) {
                // replace geometry
                geometry = this.__geometry_cache.ensure(geometry);
                mesh.geometry = geometry;
            }

            if (this.__enable_texture_cache) {
                // replace textures from cache
                replace_material_textures_from_cache(mesh.material, texture_cache);
            }

            const isSkinned = mesh.isSkinnedMesh === true;


            if (isSkinned) {
                // this used to be done inside SkinnedMesh constructor in thee.js prior to r99
                mesh.normalizeSkinWeights();

                //compute bounding box and sphere for the mesh, do it using the skeleton data
                computeSkinnedMeshBoundingVolumes(mesh);
            } else {

                ensureGeometryBoundingBox(geometry);
                ensureGeometryBoundingSphere(geometry);
            }

        }


        loader.load(path, async (gltf) => {
            const scene = gltf.scene;

            scene.updateMatrixWorld();

            /**
             * {Array.<THREE.Object3D>}
             */
            const children = scene.children;

            if (children.length === 0) {
                failure("Scene is empty");
                return;
            }


            let root = scene;

            if (children.length === 1 && isMesh(children[0])) {
                // scene only contains a single mesh, use that directly
                root = children[0];
            }

            traverseThreeObject(root, this.__prepareObjectMaterial, this);

            const root_transform = new Transform();

            // extract root transform
            const has_root_transform = transferRootTransform(root, root_transform);

            // clear transform on the root element
            root.position.set(0, 0, 0);
            root.rotation.set(0, 0, 0);
            root.scale.set(1, 1, 1);

            /**
             *
             * @param {THREE.Object3D} o
             * @returns {Promise<void>}
             */
            async function process_object(o) {
                o.updateMatrix();

                prepareObject(o);

                if (isMesh(o)) {
                    processMesh(o);
                }
            }


            await async_traverse_three_object(root, process_object);


            // compute object bounding sphere
            root.boundingSphere = computeObjectBoundingSphere(root);

            // compute bounding box
            const boundingBox = new AABB3(0, 0, 0, 0, 0, 0);
            three_computeObjectBoundingBox(root, boundingBox);


            /**
             *
             * @returns {THREE.Object3D}
             */
            function assetFactory() {
                const result = cloneObject3D(root);

                result.castShadow = true;
                result.receiveShadow = false;

                if (asset.animations !== undefined) {
                    //animations are present
                    result.animations = asset.animations;
                }

                // Copy bounding sphere
                result.boundingSphere = root.boundingSphere;

                return result;
            }

            const byteSize = 1;

            const asset = new Asset(assetFactory, byteSize);
            asset.boundingBox = boundingBox;
            asset.has_root_transform = has_root_transform;
            asset.root_transform = root_transform;

            // remember GLTF metadata
            asset.gltf = gltf;

            if (gltf.animations !== undefined) {
                /**
                 *
                 * @type {AnimationClip[]}
                 */
                const animations = gltf.animations;

                //validate and optimize animations
                const animation_count = animations.length;
                for (let i = 0; i < animation_count; i++) {
                    const animation = animations[i];
                    if (animation.validate()) {
                        animationOptimizer.optimize(animation);
                    }
                }

                asset.animations = animations;
            }

            success(asset);
        }, function (xhr) {
            //dispatch progress callback
            progress(xhr.loaded, xhr.total);
        }, failure);
    }
}
