import { Cache, Camera, Loader, Material, Mesh, Object3D } from "three";
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
import { USDZLoader } from 'three/examples/jsm/loaders/USDZLoader.js';

import { showBalloonMessage } from "./debug/index.js";
import { getLoader, type INeedleGltfLoader, registerLoader } from "./engine_gltf.js";
import { createBuiltinComponents, writeBuiltinComponentData } from "./engine_gltf_builtin_components.js";
// import * as object from "./engine_gltf_builtin_components.js";
import * as loaders from "./engine_loaders.js"
import { registerPrewarmObject } from "./engine_mainloop_utils.js";
import { SerializationContext } from "./engine_serialization_core.js";
import { Context } from "./engine_setup.js"
import { postprocessFBXMaterials } from "./engine_three_utils.js";
import { Model, type UIDProvider } from "./engine_types.js";
import * as utils from "./engine_utils.js";
import { tryDetermineFileTypeFromURL } from "./engine_utils_format.js"
import { invokeAfterImportPluginHooks, registerComponentExtension, registerExtensions } from "./extensions/extensions.js";
import { NEEDLE_components } from "./extensions/NEEDLE_components.js";

/** @internal */
export class NeedleLoader implements INeedleGltfLoader {
    createBuiltinComponents(context: Context, gltfId: string, gltf: any, seed: number | UIDProvider | null, extension?: NEEDLE_components | undefined) {
        return createBuiltinComponents(context, gltfId, gltf, seed, extension);
    }
    writeBuiltinComponentData(comp: any, context: SerializationContext) {
        return writeBuiltinComponentData(comp, context);
    }
    parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<Model | undefined> {
        return parseSync(context, data, path, seed);
    }
    loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: ((ProgressEvent: any) => void) | undefined): Promise<Model | undefined> {
        return loadSync(context, url, sourceId, seed, prog);
    }
}
registerLoader(NeedleLoader); // Register the loader


const printGltf = utils.getParam("printGltf") || utils.getParam("printgltf");
const downloadGltf = utils.getParam("downloadgltf");
const debugFileTypes = utils.getParam("debugfileformat");

// const loader = new GLTFLoader();
// registerExtensions(loader);

export enum GltfLoadEventType {
    BeforeLoad = 0,
    AfterLoaded = 1,
    FinishedSetup = 10,
}

export class GltfLoadEvent {
    context: Context
    loader: GLTFLoader;
    path: string;
    gltf?: GLTF;
    constructor(context: Context, path: string, loader: GLTFLoader, gltf?: GLTF) {
        this.context = context;
        this.path = path;
        this.loader = loader;
        this.gltf = gltf;
    }
}

export type GltfLoadEventCallback = (event: GltfLoadEvent) => void;

const eventListeners: { [key: string]: GltfLoadEventCallback[] } = {};

export function addGltfLoadEventListener(type: GltfLoadEventType, listener: GltfLoadEventCallback) {
    eventListeners[type] = eventListeners[type] || [];
    eventListeners[type].push(listener);
}
export function removeGltfLoadEventListener(type: GltfLoadEventType, listener: GltfLoadEventCallback) {
    if (eventListeners[type]) {
        const index = eventListeners[type].indexOf(listener);
        if (index >= 0) {
            eventListeners[type].splice(index, 1);
        }
    }
}

function invokeEvents(type: GltfLoadEventType, event: GltfLoadEvent) {
    if (eventListeners[type]) {
        for (const listener of eventListeners[type]) {
            listener(event);
        }
    }
}

async function handleLoadedGltf(context: Context, gltfId: string, gltf, seed: number | null | UIDProvider, componentsExtension) {
    if (printGltf)
        console.warn("glTF", gltfId, gltf);
    // Remove query parameters from gltfId
    if (gltfId.includes("?")) {
        gltfId = gltfId.split("?")[0];
    }
    await getLoader().createBuiltinComponents(context, gltfId, gltf, seed, componentsExtension);
}


export async function createLoader(url: string, context: Context): Promise<GLTFLoader | FBXLoader | USDZLoader | OBJLoader | null> {

    const type = await tryDetermineFileTypeFromURL(url) || "unknown";
    if (debugFileTypes) console.debug("Determined file type: " + type + " for url", url);

    switch (type) {
        case "unknown":
            {
                console.warn("Unknown file type. Assuming glTF:", url);
                const loader = new GLTFLoader();
                await registerExtensions(loader, context, url);
                return loader;
            }
        case "fbx":
            return new FBXLoader();
        case "obj":
            return new OBJLoader();
        case "usd":
        case "usda":
        case "usdz":
            console.warn(type.toUpperCase() + " files are not supported.")
            return null;
        // return new USDZLoader();
        default:
            console.warn("Unknown file type:", type);
        case "gltf":
        case "glb":
        case "vrm":
            {
                const loader = new GLTFLoader();
                await registerExtensions(loader, context, url);
                return loader;
            }
    }
}

/** Load a gltf file from a url. This is the core method used by Needle Engine to load gltf files. All known extensions are registered here.   
 * @param context The current context
 * @param data The gltf data as string or ArrayBuffer
 * @param path The path to the gltf file
 * @param seed The seed for generating unique ids
 * @returns The loaded gltf object
 */
export async function parseSync(context: Context, data: string | ArrayBuffer, path: string, seed: number | UIDProvider | null): Promise<Model | undefined> {
    if (typeof path !== "string") {
        console.warn("Parse gltf binary without path, this might lead to errors in resolving extensions. Please provide the source path of the gltf/glb file", path, typeof path);
        path = "";
    }
    if (printGltf) console.log("Parse glTF", path)
    const loader = await createLoader(path, context);
    if (!loader) {
        return undefined;
    }

    // Handle OBJ Loader
    if (loader instanceof OBJLoader) {
        if (typeof data !== "string") { data = new TextDecoder().decode(data);  }
        const res = loader.parse(data);
        return {
            animations: res.animations,
            scene: res,
            scenes: [res]
        } as GLTF;
    }
    // Handle any other loader that is not a GLTFLoader
    const isNotGLTF = !(loader instanceof GLTFLoader);
    if (isNotGLTF) {
        const res = loader.parse(data, path);
        postprocessLoadedFile(loader, res);
        return {
            animations: res.animations,
            scene: res,
            scenes: [res]
        } as GLTF;
    }

    const componentsExtension = registerComponentExtension(loader);
    return new Promise((resolve, reject) => {
        try {

            // GltfLoader expects a base path for resolving referenced assets
            // https://threejs.org/docs/#examples/en/loaders/GLTFLoader.parse
            // so we make sure that "path" is never a file path
            let gltfLoaderPath = path.split("?")[0].trimEnd();
            // This assumes that the path is a FILE path and not already a directory
            // (it does not end with "/") – see https://linear.app/needle/issue/NE-6075
            {
                // strip file from path
                const parts = gltfLoaderPath.split("/");
                // check if the last part is a /, otherwise remove it
                if (parts.length > 0 && parts[parts.length - 1] !== "")
                    parts.pop();
                gltfLoaderPath = parts.join("/");
                if (!gltfLoaderPath.endsWith("/")) gltfLoaderPath += "/";
            }
            loader.resourcePath = gltfLoaderPath;
            loaders.addDracoAndKTX2Loaders(loader, context);

            invokeEvents(GltfLoadEventType.BeforeLoad, new GltfLoadEvent(context, path, loader));
            const camera = context.mainCamera;
            loader.parse(data, "", async res => {
                invokeAfterImportPluginHooks(path, res, context);
                invokeEvents(GltfLoadEventType.AfterLoaded, new GltfLoadEvent(context, path, loader, res));
                await handleLoadedGltf(context, path, res, seed, componentsExtension);
                await compileAsync(res.scene, context, camera);
                invokeEvents(GltfLoadEventType.FinishedSetup, new GltfLoadEvent(context, path, loader, res));

                resolve(res);
                if (downloadGltf) {
                    _downloadGltf(data)
                }

            }, err => {
                console.error("Loading asset at \"" + path + "\" failed\n", err);
                resolve(undefined);
            });
        }
        catch (err) {
            console.error(err);
            reject(err);
        }
    });
}

/**
 * Load a gltf file from a url. This is the core method used by Needle Engine to load gltf files. All known extensions are registered here.  
 * @param context The current context
 * @param url The url to the gltf file
 * @param sourceId The source id of the gltf file - this is usually the url
 * @param seed The seed for generating unique ids
 * @param prog A progress callback
 * @returns The loaded gltf object
 */
export async function loadSync(context: Context, url: string, sourceId: string, seed: number | UIDProvider | null, prog?: (ProgressEvent) => void): Promise<Model | undefined> {
    // better to create new loaders every time
    // (maybe we can cache them...)
    // but due to the async nature and potentially triggering multiple loads at the same time
    // we need to make sure the extensions dont override each other
    // creating new loaders should not be expensive as well
    checkIfUserAttemptedToLoadALocalFile(url)
    const loader = await createLoader(url, context);
    if (!loader) {
        return undefined;
    }

    // Handle any loader that is not a GLTFLoader
    if (!(loader instanceof GLTFLoader)) {
        const res = await loader.loadAsync(url, prog);
        postprocessLoadedFile(loader, res);
        return {
            animations: res.animations,
            scene: res,
            scenes: [res]
        } as GLTF;
    }

    const componentsExtension = registerComponentExtension(loader);
    return new Promise((resolve, reject) => {
        try {
            loaders.addDracoAndKTX2Loaders(loader, context);
            invokeEvents(GltfLoadEventType.BeforeLoad, new GltfLoadEvent(context, url, loader));
            const camera = context.mainCamera;
            loader.load(url, async res => {
                invokeAfterImportPluginHooks(url, res, context);
                invokeEvents(GltfLoadEventType.AfterLoaded, new GltfLoadEvent(context, url, loader, res));
                await handleLoadedGltf(context, sourceId, res, seed, componentsExtension);
                await compileAsync(res.scene, context, camera);
                invokeEvents(GltfLoadEventType.FinishedSetup, new GltfLoadEvent(context, url, loader, res));
                resolve(res);
                if (downloadGltf) {
                    _downloadGltf(url)
                }
            }, evt => {
                prog?.call(loader, evt);
            }, err => {
                console.error("Loading asset at \"" + url + "\" failed\n", err);
                resolve(undefined);
            });
        }
        catch (err) {
            console.error(err);
            reject(err);
        }
    });
}

async function compileAsync(scene: Object3D, context: Context, camera?: Camera | null) {
    if (!camera) camera = context.mainCamera;
    try {
        if (camera) {
            await context.renderer.compileAsync(scene, camera, context.scene)
                .catch(err => {
                    console.warn(err.message);
                });
        }
        else
            registerPrewarmObject(scene, context);
    }
    catch (err: Error | any) {
        console.warn(err?.message || err);
    }
}

function checkIfUserAttemptedToLoadALocalFile(url: string) {
    const fullurl = new URL(url, window.location.href).href;
    if (fullurl.startsWith("file://")) {
        const msg = "Hi - it looks like you are trying to load a local file which will not work. You need to use a webserver to serve your files.\nPlease refer to the documentation on <a href=\"https://fwd.needle.tools/needle-engine/docs/local-server\">https://docs.needle.tools</a> or ask for help in our <a href=\"https://discord.needle.tools\">discord community</a>";
        showBalloonMessage(msg);
        console.warn(msg)
    }
}

function _downloadGltf(data: string | ArrayBuffer) {
    if (typeof data === "string") {
        const a = document.createElement("a") as HTMLAnchorElement;
        a.href = data;
        a.download = data.split("/").pop()!;
        a.click();
    }
    else {
        const blob = new Blob([data], { type: "application/octet-stream" });
        const url = window.URL.createObjectURL(blob);
        const a = document.createElement("a") as HTMLAnchorElement;
        a.href = url;
        a.download = "download.glb";
        a.click();
    }
}




function postprocessLoadedFile(loader: Loader, result: Object3D | GLTF) {

    if ((result as Object3D)?.isObject3D) {
        const obj = result as Object3D;

        if (loader instanceof FBXLoader || loader instanceof OBJLoader) {
            obj.traverse((child) => {
                const mesh = child as Mesh;

                // See https://github.com/needle-tools/three.js/blob/b8df3843ff123ac9dc0ed0d3ccc5b568f840c804/examples/webgl_loader_multiple.html#L377
                if (mesh?.isMesh) {
                    postprocessFBXMaterials(mesh, mesh.material as Material);
                }
            });
        }
        // else if (loader instanceof OBJLoader) {

        //     obj.traverse(_child => {

        //         // TODO: Needs testing

        //         // if (!(child instanceof Mesh)) return;

        //         // child.material = new MeshStandardMaterial();

        //     });
        // }


    }
}

