import { CubeCamera, Object3D, Scene, WebGLCubeRenderTarget } from 'three';

import { isDevEnvironment } from "./debug/index.js";
import * as constants from "./engine_constants.js";
import { ContextRegistry } from "./engine_context_registry.js";
import { NEEDLE_ENGINE_FEATURE_FLAGS } from './engine_feature_flags.js';
import { isActiveSelf } from './engine_gameobject.js';
import { safeInvoke } from "./engine_generic_utils.js";
import type { IComponent, IContext } from './engine_types.js';
import { getParam } from './engine_utils.js';
import type { INeedleXRSessionEventReceiver } from "./engine_xr.js";

const debug = getParam("debugnewscripts");
const debugHierarchy = getParam("debughierarchy");

// if some other script adds new scripts in onEnable or awake 
// the original array should be cleared before processing it
// so we use this copy buffer
const new_scripts_buffer: any[] = [];

/** @internal */
export function hasNewScripts() {
    return new_scripts_buffer.length > 0;
}

/** 
 * This method is called by the engine to process new scripts that were added to the scene.  
 * It will call the registering method on the script, then the awake method and finally the onEnable method.
 * @internal
 */
export function processNewScripts(context: IContext) {
    if (debug)
        console.log("Register new components", context.new_scripts.length, [...context.new_scripts], context.alias ? ("element: " + context.alias) : context["hash"], context);


    if (context.new_scripts_pre_setup_callbacks.length > 0) {
        for (const cb of context.new_scripts_pre_setup_callbacks) {
            if (!cb) continue;
            cb();
        }
        context.new_scripts_pre_setup_callbacks.length = 0;
    }

    if (context.new_scripts.length <= 0) return;

    // TODO: update all the code from above to use this logic
    // basically code gen should add the scripts to new scripts
    // and this code below should go into some util method
    new_scripts_buffer.length = 0;
    if (context.new_scripts.length > 0) {
        new_scripts_buffer.push(...context.new_scripts);
    }
    context.new_scripts.length = 0;

    // Check valid scripts and add all valid to the scripts array
    for (let i = 0; i < new_scripts_buffer.length; i++) {
        try {
            const script: IComponent = new_scripts_buffer[i];
            if (script.isComponent !== true) {
                if (isDevEnvironment() || debug)
                    console.error("Registered script is not a Needle Engine component. \nThe script will be ignored. Please make sure your component extends \"Behaviour\" imported from \"@needle-tools/engine\"\n", script);
                new_scripts_buffer.splice(i, 1);
                i--;
                continue;
            }
            if (script.destroyed) continue;
            if (!script.gameObject) {
                console.warn("Component can not be initialized: no GameObject assigned.\nDid you add and remove a component in the same frame?");
                new_scripts_buffer.splice(i, 1);
                i--;
                continue;
            }
            script.context = context;
            updateActiveInHierarchyWithoutEventCall(script.gameObject);
            addScriptToArrays(script, context);
        }
        catch (err) {
            console.error(err);
            removeScriptFromContext(new_scripts_buffer[i], context);
            new_scripts_buffer.splice(i, 1);
            i--;
        }
    }

    // Awake
    for (let i = 0; i < new_scripts_buffer.length; i++) {
        try {
            const script: IComponent = new_scripts_buffer[i];
            if (script.destroyed) {
                removeScriptFromContext(new_scripts_buffer[i], context);
                new_scripts_buffer.splice(i, 1);
                i--; continue;
            }
            if (script.registering) {
                try {
                    script.registering();
                }
                catch (err) { console.error(err); }
            }
            // console.log(script, script.gameObject)
            // TODO: we should not call awake on components with inactive gameobjects
            if (script.__internalAwake !== undefined) {
                if (!script.gameObject) {
                    console.error("Calling awake for a component without a GameObject", script, script.gameObject);
                }
                updateActiveInHierarchyWithoutEventCall(script.gameObject);
                if (script.activeAndEnabled)
                    safeInvoke(script.__internalAwake.bind(script));

                // registerPrewarmObject(script.gameObject, context);
            }
        }
        catch (err) {
            console.error(err);
            removeScriptFromContext(new_scripts_buffer[i], context);
            new_scripts_buffer.splice(i, 1);
            i--;
        }
    }

    // OnEnable
    for (let i = 0; i < new_scripts_buffer.length; i++) {
        try {
            const script: IComponent = new_scripts_buffer[i];
            if (script.destroyed) continue;
            // console.log(script, script.enabled, script.activeAndEnabled);
            if (script.enabled === false) continue;
            updateActiveInHierarchyWithoutEventCall(script.gameObject);
            if (script.activeAndEnabled === false) continue;
            if (script.__internalEnable !== undefined) {
                script.enabled = true;
                safeInvoke(script.__internalEnable.bind(script));
            }
        }
        catch (err) {
            console.error(err);
            removeScriptFromContext(new_scripts_buffer[i], context);
            new_scripts_buffer.splice(i, 1);
            i--;
        }
    }

    // Enqueue Start
    for (let i = 0; i < new_scripts_buffer.length; i++) {
        try {
            const script = new_scripts_buffer[i];
            if (script.destroyed) continue;
            if (!script.gameObject) continue;
            context.new_script_start.push(script);
        }
        catch (err) {
            console.error(err);
            removeScriptFromContext(new_scripts_buffer[i], context);
            new_scripts_buffer.splice(i, 1);
            i--;
        }
    }

    // for (const script of new_scripts_buffer) {
    //     if (script.destroyed) continue;
    //     context.scripts.push(script);
    // }
    new_scripts_buffer.length = 0;

    // if(new_scripts_post_setup_callbacks.length > 0) console.log(new_scripts_post_setup_callbacks);
    for (const cb of context.new_scripts_post_setup_callbacks) {
        if (cb)
            cb();
    }
    context.new_scripts_post_setup_callbacks.length = 0;
}

/** @internal */
export function processRemoveFromScene(script: IComponent) {
    if (!script) return;
    script.__internalDisable(true);
    removeScriptFromContext(script, script.context);
}

/** @internal */
export function processStart(context: IContext, object?: Object3D) {
    // Call start on scripts
    for (let i = 0; i < context.new_script_start.length; i++) {
        try {
            const script = context.new_script_start[i];
            if (object !== undefined && script.gameObject !== object) continue;
            if (script.destroyed) continue;
            if (script.activeAndEnabled === false) {
                continue;
            }
            // keep them in queue until script has started
            // call awake if the script was inactive before
            safeInvoke(script.__internalAwake.bind(script));
            if (script.enabled) {
                safeInvoke(script.__internalEnable.bind(script));
                // now call start
                safeInvoke(script.__internalStart.bind(script));
                context.new_script_start.splice(i, 1);
                i--;
            }
        }
        catch (err) {
            console.error(err);
            removeScriptFromContext(context.new_script_start[i], context);
            context.new_script_start.splice(i, 1);
            i--;
        }
    }
}


/** @internal */
export function addScriptToArrays(script: any, context: IContext) {
    // TODO: not sure if this is ideal - maybe we should add a map if we have many scripts?
    const index = context.scripts.indexOf(script);
    if (index !== -1) return;
    context.scripts.push(script);
    if (script.earlyUpdate) context.scripts_earlyUpdate.push(script);
    if (script.update) context.scripts_update.push(script);
    if (script.lateUpdate) context.scripts_lateUpdate.push(script);
    if (script.onBeforeRender) context.scripts_onBeforeRender.push(script);
    if (script.onAfterRender) context.scripts_onAfterRender.push(script);
    if (script.onPausedChanged) context.scripts_pausedChanged.push(script);
    if (isNeedleXRSessionEventReceiver(script, null)) context.new_scripts_xr.push(script);
    // do we want to check if a XR session is active before adding scripts here?
    if (isNeedleXRSessionEventReceiver(script, "immersive-vr")) context.scripts_immersive_vr.push(script);
    if (isNeedleXRSessionEventReceiver(script, "immersive-ar")) context.scripts_immersive_ar.push(script);
}

/** @internal */
export function removeScriptFromContext(script: any, context: IContext) {
    removeFromArray(script, context.new_scripts);
    removeFromArray(script, context.new_script_start);
    removeFromArray(script, context.scripts);
    removeFromArray(script, context.scripts_earlyUpdate);
    removeFromArray(script, context.scripts_update);
    removeFromArray(script, context.scripts_lateUpdate);
    removeFromArray(script, context.scripts_onBeforeRender);
    removeFromArray(script, context.scripts_onAfterRender);
    removeFromArray(script, context.scripts_pausedChanged);
    removeFromArray(script, context.new_scripts_xr);
    removeFromArray(script, context.scripts_immersive_vr);
    removeFromArray(script, context.scripts_immersive_ar);
    context.stopAllCoroutinesFrom(script);
}

function removeFromArray(script: any, array: any[]) {
    const index = array.indexOf(script);
    if (index >= 0) array.splice(index, 1);
}

/** @internal */
export function isNeedleXRSessionEventReceiver(script: any, mode: XRSessionMode | null): script is INeedleXRSessionEventReceiver {
    if (script) {
        const i = script as Partial<INeedleXRSessionEventReceiver>;
        if (i.onBeforeXR ||
            i.onEnterXR ||
            i.onUpdateXR ||
            i.onLeaveXR ||
            i.onXRControllerAdded ||
            i.onXRControllerRemoved
        ) {
            if (mode != null) {
                if (i.supportsXR?.(mode) === false) return false;
            }
            return true;
        }
    }
    return false;
}




// #region activeInHierarchy

let needsUpdate = true;
export function markHierarchyDirty() {
    needsUpdate = true;
}

/** @internal */
export function updateIsActive(obj?: Object3D, force: boolean = false) {

    if (NEEDLE_ENGINE_FEATURE_FLAGS.experimentalSmartHierarchyUpdate) {

        if (!force) {
            if (!needsUpdate) return;
        }
        needsUpdate = false;
    }

    if (!obj) obj = ContextRegistry.Current.scene;
    if (!obj) {
        console.trace("Invalid call - no current context.");
        return;
    }
    const activeSelf = isActiveSelf(obj);
    const wasSuccessful = updateIsActiveInHierarchyRecursiveRuntime(obj, activeSelf, true);
    if (!wasSuccessful) {
        if (debug || isDevEnvironment()) {
            console.error("Error updating hierarchy\nDo you have circular references in your project? <a target=\"_blank\" href=\"https://docs.needle.tools/circular-reference\"> Click here for more information.", obj)
        }
        else
            console.error("Failed to update active state in hierarchy of \"" + obj.name + "\"", obj);
        console.warn(" ↑ this error might be caused by circular references. Please make sure you don't have files with circular references (e.g. one GLB 1 is loading GLB 2 which is then loading GLB 1 again).")

    }
}

function updateIsActiveInHierarchyRecursiveRuntime(go: Object3D, activeInHierarchy: boolean, allowEventCall: boolean, level: number = 0): boolean {
    if (level > 1000) {
        console.warn("Hierarchy is too deep (> 1000 level) - will abort updating active state");
        return false;
    }

    const activeSelf = isActiveSelf(go);
    if (activeInHierarchy) {
        activeInHierarchy = activeSelf;
        // IF we update activeInHierarchy within a disabled hierarchy we need to check the parent
        if (activeInHierarchy && go.parent && level === 0) {
            const parent = go.parent;
            activeInHierarchy = parent[constants.activeInHierarchyFieldName];
            if (activeInHierarchy === undefined) {
                // TODO: come up with a better solution for this. When we are in a r3f hierarchy (externally managed) and the parent flag is undefined we set it to true if the parent is NOT a scene. This activates the object by default. We should probably walk up the stack and check if we can find either the root Scene or any object that is disabled and use that to set the activeInHierarchy flag.
                if (parent instanceof Scene) {
                }
                else {
                    activeInHierarchy = true;
                }
            }
        }
    }

    const prevActive = go[constants.activeInHierarchyFieldName];
    const changed = prevActive !== activeInHierarchy;

    // only raise events here if we didnt call enable etc already
    if (changed) {
        go[constants.activeInHierarchyFieldName] = activeInHierarchy;

        if (debugHierarchy)
            console.warn("ACTIVE CHANGE", { name: go.name, activeSelf, visible: go.visible, activeInHierarchy, changed, go });
        if (allowEventCall) {
            const components = go.userData?.components;
            if (components) {
                for (let ci = components.length - 1, cl = -1; ci > cl; ci--) {
                    const comp = components[ci];
                    if (activeInHierarchy) {
                        if (comp?.enabled) {
                            try { comp.__internalAwake(); }
                            catch (err) { console.error(err); }
                            if (comp.enabled) {
                                comp.__internalEnable();
                            }
                        }
                    }
                    else if(comp) {
                        if (comp["__didAwake"] && comp.enabled) {
                            comp["__didEnable"] = false;
                            comp.onDisable();
                        }
                    }
                }
            }
        }
    }
    const children = go.children;
    if (children) {
        // When this node is inactive and hasn't changed, skip children that are already
        // marked inactive. Only recurse into children that still have a stale active=true
        // (e.g. after reparenting into this inactive subtree).
        if (!changed && !activeInHierarchy) {
            let success = true;
            for (let i = 0, l = children.length; i < l; i++) {
                const ch = children[i];
                if (ch[constants.activeInHierarchyFieldName] !== false) {
                    if (updateIsActiveInHierarchyRecursiveRuntime(ch, false, allowEventCall, level + 1) === false)
                        success = false;
                }
            }
            return success;
        }
        let success = true;
        for (let i = 0, l = children.length; i < l; i++) {
            if (updateIsActiveInHierarchyRecursiveRuntime(children[i], activeInHierarchy, allowEventCall, level + 1) === false)
                success = false;
        }
        return success;
    }
    return true;
}




/** @internal */
export function updateActiveInHierarchyWithoutEventCall(go: Object3D) {
    if (!go) {
        console.error("GO is null");
        return;
    }
    let activeInHierarchy = true;
    let foundScene = false;
    let current: Object3D | null = go;
    while (current) {
        if (current.type === "Scene") foundScene = true;
        if (!isActiveSelf(current)) {
            activeInHierarchy = false;
            break;
        }
        current = current.parent;
    }
    go[constants.activeInHierarchyFieldName] = activeInHierarchy && foundScene;
}




// #region prewarm

const prewarmList: Map<IContext, Object3D[]> = new Map();
const $prewarmedFlag = Symbol("prewarmFlag");
const $waitingForPrewarm = Symbol("waitingForPrewarm");
const debugPrewarm = getParam("debugprewarm");

/** @internal */
export function registerPrewarmObject(obj: Object3D, context: IContext) {
    if (!obj) return;
    // allow objects to be marked as prewarmed in which case we dont need to register them again
    if (obj[$prewarmedFlag] === true) return;
    if (obj[$waitingForPrewarm] === true) return;
    if (!prewarmList.has(context)) {
        prewarmList.set(context, []);
    }
    obj[$waitingForPrewarm] = true;
    const list = prewarmList.get(context);
    list!.push(obj);
    if (debugPrewarm) console.debug("register prewarm", obj.name);
}

let prewarmTarget: WebGLCubeRenderTarget | null = null;
let prewarmCamera: CubeCamera | null = null;

/** @internal called by the engine to remove scroll or animation hiccup when objects are rendered/compiled for the first time */
export function runPrewarm(context: IContext) {
    if (!context) return;
    const list = prewarmList.get(context);
    if (!list?.length) return;

    const cam = context.mainCamera;
    if (cam) {
        if (debugPrewarm) console.log("prewarm", list.length, "objects", [...list]);
        const renderer = context.renderer;
        if (renderer.compile) {
            const scene = context.scene;
            renderer.compile(scene, cam!)
            prewarmTarget ??= new WebGLCubeRenderTarget(64)
            prewarmCamera ??= new CubeCamera(0.001, 9999999, prewarmTarget);
            prewarmCamera.update(renderer, scene);
            for (const obj of list) {
                obj[$prewarmedFlag] = true;
                obj[$waitingForPrewarm] = false;
            }
            list.length = 0;
            if (debugPrewarm) console.log("prewarm done");
        }
    }
}

/** @internal */
export function clearPrewarmList(context: IContext) {
    const list = prewarmList.get(context);
    if (list) {
        for (const obj of list) {
            obj[$waitingForPrewarm] = false;
        }
        list.length = 0;
    }
    prewarmList.delete(context);
}