import { AnimationAction, AnimationClip, AnimationMixer, AxesHelper, Euler, KeyframeTrack, LoopOnce, Object3D, Quaternion, Vector3 } from "three";

import { isDevEnvironment } from "../engine/debug/index.js";
import { AnimationUtils } from "../engine/engine_animation.js";
import { Mathf } from "../engine/engine_math.js";
import { InstantiateIdProvider } from "../engine/engine_networking_instantiate.js";
import { assign, SerializationContext, TypeSerializer } from "../engine/engine_serialization_core.js";
import { Context } from "../engine/engine_setup.js";
import { isAnimationAction } from "../engine/engine_three_utils.js";
import { TypeStore } from "../engine/engine_typestore.js";
import { deepClone, getParam } from "../engine/engine_utils.js";
import type { AnimatorControllerModel, Condition, State, Transition } from "../engine/extensions/NEEDLE_animator_controller_model.js";
import { AnimatorConditionMode, AnimatorControllerParameterType, AnimatorStateInfo, createMotion, StateMachineBehaviour } from "../engine/extensions/NEEDLE_animator_controller_model.js";
import type { Animator } from "./Animator.js";

const debug = getParam("debuganimatorcontroller");
const debugRootMotion = getParam("debugrootmotion");

/**
 * Generates a hash code for a string
 * @param str - The string to hash
 * @returns A numeric hash value
 */
function stringToHash(str): number {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
        const char = str.charCodeAt(i);
        hash = ((hash << 5) - hash) + char;
        hash = hash & hash;
    }
    return hash;
}

/**
 * Configuration options for creating an AnimatorController
 */
declare type CreateAnimatorControllerOptions = {
    /** Should each animation state loop */
    looping?: boolean,
    /** Set to false to disable generating transitions between animation clips */
    autoTransition?: boolean,
    /** Duration in seconds for transitions between states */
    transitionDuration?: number,
}

/** 
 * Controls the playback of animations using a state machine architecture.
 * 
 * The AnimatorController manages animation states, transitions between states,
 * and parameters that affect those transitions. It is used by the {@link Animator}
 * component to control animation behavior on 3D models.
 * 
 * Use the static method {@link AnimatorController.createFromClips} to create
 * an animator controller from a set of animation clips.
 * 
 * @category Animation and Sequencing
 * @group Utilities
 */
export class AnimatorController {

    /**
     * Creates an AnimatorController from a set of animation clips.
     * Each clip becomes a state in the controller's state machine.
     * 
     * @param clips - The animation clips to use for creating states
     * @param options - Configuration options for the controller including looping behavior and transitions
     * @returns A new AnimatorController instance
     */
    static createFromClips(clips: AnimationClip[], options: CreateAnimatorControllerOptions = { looping: false, autoTransition: true, transitionDuration: 0 }): AnimatorController {
        const states: State[] = [];
        for (let i = 0; i < clips.length; i++) {
            const clip = clips[i];
            const transitions: Transition[] = [];

            if (options.autoTransition !== false) {
                const dur = options.transitionDuration ?? 0;
                const normalizedDuration = dur / clip.duration;
                // automatically transition to self by default
                let nextState = i;
                if (options.autoTransition === undefined || options.autoTransition === true) {
                    nextState = (i + 1) % clips.length;
                }
                transitions.push({
                    exitTime: 1 - normalizedDuration,
                    offset: 0,
                    duration: dur,
                    hasExitTime: true,
                    destinationState: nextState,
                    conditions: [],
                })
            }

            const state: State = {
                name: clip.name,
                hash: i, // by using the index it's easy for users to call play(2) to play the clip at index 2
                motion: {
                    name: clip.name,
                    clip: clip,
                    isLooping: options?.looping ?? false,
                },
                transitions: transitions,
                behaviours: []
            }
            states.push(state);
        }
        const model: AnimatorControllerModel = {
            name: "AnimatorController",
            guid: new InstantiateIdProvider(Date.now()).generateUUID(),
            parameters: [],
            layers: [{
                name: "Base Layer",
                stateMachine: {
                    defaultState: 0,
                    states: states
                }
            }]
        }
        const controller = new AnimatorController(model);
        return controller;
    }

    /**
     * Plays an animation state by name or hash.
     * 
     * @param name - The name or hash identifier of the state to play
     * @param layerIndex - The layer index (defaults to 0)
     * @param normalizedTime - The normalized time to start the animation from (0-1)
     * @param durationInSec - Transition duration in seconds
     */
    play(name: string | number, layerIndex: number = -1, normalizedTime: number = Number.NEGATIVE_INFINITY, durationInSec: number = 0) {
        if (layerIndex < 0) layerIndex = 0;
        else if (layerIndex >= this.model.layers.length) {
            console.warn("invalid layer");
            return;
        }
        const layer = this.model.layers[layerIndex];
        const sm = layer.stateMachine;
        for (const state of sm.states) {
            if (state.name === name || state.hash === name) {
                if (debug)
                    console.log("transition to ", state);
                this.transitionTo(state, durationInSec, normalizedTime);
                return;
            }
        }
        console.warn("Could not find " + name + " to play");
    }

    /**
     * Resets the controller to its initial state.
     */
    reset() {
        this.setStartTransition();
    }

    /**
     * Sets a boolean parameter value by name or hash.
     * 
     * @param name - The name or hash identifier of the parameter
     * @param value - The boolean value to set
     */
    setBool(name: string | number, value: boolean) {
        const key = typeof name === "string" ? "name" : "hash";
        return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = value);
    }

    /**
     * Gets a boolean parameter value by name or hash.
     * 
     * @param name - The name or hash identifier of the parameter
     * @returns The boolean value of the parameter, or false if not found
     */
    getBool(name: string | number): boolean {
        const key = typeof name === "string" ? "name" : "hash";
        return this.model?.parameters?.find(p => p[key] === name)?.value as boolean ?? false;
    }

    /**
     * Sets a float parameter value by name or hash.
     * 
     * @param name - The name or hash identifier of the parameter
     * @param val - The float value to set
     * @returns True if the parameter was found and set, false otherwise
     */
    setFloat(name: string | number, val: number) {
        const key = typeof name === "string" ? "name" : "hash";
        const filtered = this.model?.parameters?.filter(p => p[key] === name);
        filtered.forEach(p => p.value = val);
        return filtered?.length > 0;
    }

    /**
     * Gets a float parameter value by name or hash.
     * 
     * @param name - The name or hash identifier of the parameter
     * @returns The float value of the parameter, or 0 if not found
     */
    getFloat(name: string | number): number {
        const key = typeof name === "string" ? "name" : "hash";
        return this.model?.parameters?.find(p => p[key] === name)?.value as number ?? 0;
    }

    /**
     * Sets an integer parameter value by name or hash.
     * 
     * @param name - The name or hash identifier of the parameter
     * @param val - The integer value to set
     */
    setInteger(name: string | number, val: number) {
        const key = typeof name === "string" ? "name" : "hash";
        return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = val);
    }

    /**
     * Gets an integer parameter value by name or hash.
     * 
     * @param name - The name or hash identifier of the parameter
     * @returns The integer value of the parameter, or 0 if not found
     */
    getInteger(name: string | number): number {
        const key = typeof name === "string" ? "name" : "hash";
        return this.model?.parameters?.find(p => p[key] === name)?.value as number ?? 0;
    }

    /**
     * Sets a trigger parameter to active (true).
     * Trigger parameters are automatically reset after they are consumed by a transition.
     * 
     * @param name - The name or hash identifier of the trigger parameter
     */
    setTrigger(name: string | number) {
        if (debug)
            console.log("SET TRIGGER", name);
        const key = typeof name === "string" ? "name" : "hash";
        return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = true);
    }

    /**
     * Resets a trigger parameter to inactive (false).
     * 
     * @param name - The name or hash identifier of the trigger parameter
     */
    resetTrigger(name: string | number) {
        const key = typeof name === "string" ? "name" : "hash";
        return this.model?.parameters?.filter(p => p[key] === name).forEach(p => p.value = false);
    }

    /**
     * Gets the current state of a trigger parameter.
     * 
     * @param name - The name or hash identifier of the trigger parameter
     * @returns The boolean state of the trigger, or false if not found
     */
    getTrigger(name: string | number): boolean {
        const key = typeof name === "string" ? "name" : "hash";
        return this.model?.parameters?.find(p => p[key] === name)?.value as boolean ?? false;
    }

    /**
     * Checks if the controller is currently in a transition between states.
     * 
     * @returns True if a transition is in progress, false otherwise
     */
    isInTransition(): boolean {
        return this._activeStates.length > 1;
    }

    /** Set the speed of the animator controller. Larger values will make the animation play faster. */
    setSpeed(speed: number) {
        this._speed = speed;
    }
    private _speed: number = 1;


    /**
     * Finds an animation state by name or hash.
     * @deprecated Use findState instead
     * 
     * @param name - The name or hash identifier of the state to find
     * @returns The found state or null if not found
     */
    FindState(name: string | number | undefined | null): State | null { return this.findState(name); }

    /**
     * Finds an animation state by name or hash.
     * 
     * @param name - The name or hash identifier of the state to find
     * @returns The found state or null if not found
     */
    findState(name: string | number | undefined | null): State | null {
        if (!name) return null;
        if (Array.isArray(this.model.layers)) {
            for (const layer of this.model.layers) {
                for (const state of layer.stateMachine.states) {
                    if (state.name === name || state.hash == name) return state;
                }
            }
        }
        return null;
    }

    /**
     * Gets information about the current playing animation state.
     * 
     * @returns An AnimatorStateInfo object with data about the current state, or null if no state is active
     */
    getCurrentStateInfo() {
        if (!this._activeState) return null;
        const action = this._activeState.motion.action;
        if (!action) return null;
        const dur = this._activeState.motion.clip!.duration;
        const normalizedTime = dur <= 0 ? 0 : Math.abs(action.time / dur);
        return new AnimatorStateInfo(this._activeState, normalizedTime, dur, this._speed);
    }

    /**
     * Gets the animation action currently playing.
     * 
     * @returns The current animation action, or null if no action is playing
     */
    get currentAction(): AnimationAction | null {
        if (!this._activeState) return null;
        const action = this._activeState.motion.action;
        if (!action) return null;
        return action;
    }

    // addState(state: State, layerIndex: number = 0) {
    //     if (!this.model) throw new Error("AnimatorController model is missing");
    //     if (layerIndex < 0 || layerIndex >= this.model.layers.length) {
    //         throw new Error(`Invalid layer index: ${layerIndex}`);
    //     }
    //     const layer = this.model.layers[layerIndex];
    //     if (!layer.stateMachine) {
    //         layer.stateMachine = { states: [], defaultState: 0 };
    //     }
    //     if (!layer.stateMachine.states) {
    //         layer.stateMachine.states = [];
    //     }
    //     if (state.hash === undefined) {
    //         state.hash = stringToHash(state.name || "state" + layer.stateMachine.states.length);
    //     }

    // }


    /**
     * The normalized time (0-1) to start playing the first state at.
     * This affects the initial state when the animator is first enabled.
     */
    normalizedStartOffset: number = 0;

    /**
     * The Animator component this controller is bound to.
     */
    animator?: Animator;

    /**
     * The data model describing the animation states and transitions.
     */
    model: AnimatorControllerModel;

    /**
     * Gets the engine context from the bound animator.
     */
    get context(): Context | undefined | null { return this.animator?.context; }

    /**
     * Gets the animation mixer used by this controller.
     */
    get mixer() {
        return this._mixer;
    }

    /**
     * Cleans up resources used by this controller.
     * Stops all animations and unregisters the mixer from the animation system.
     */
    dispose() {
        this._mixer.stopAllAction();
        if (this.animator) {
            this._mixer.uncacheRoot(this.animator.gameObject);
            for (const action of this._activeStates) {
                if (action.motion.clip)
                    this.mixer.uncacheAction(action.motion.clip, this.animator.gameObject);
            }
        }
        this.context?.animations.unregisterAnimationMixer(this._mixer);
    }

    // applyRootMotion(obj: Object3D) {
    //     // this.internalApplyRootMotion(obj);
    // }

    /**
     * Binds this controller to an animator component.
     * Creates a new animation mixer and sets up animation actions.
     * 
     * @param animator - The animator to bind this controller to
     */
    bind(animator: Animator) {
        if (!animator) console.error("AnimatorController.bind: animator is null");
        else if (this.animator !== animator) {
            if (this._mixer) {
                this._mixer.stopAllAction();
                this.context?.animations.unregisterAnimationMixer(this._mixer);
            }
            this.animator = animator;
            this._mixer = new AnimationMixer(this.animator.gameObject);
            this.context?.animations.registerAnimationMixer(this._mixer);
            this.createActions(this.animator);
        }
    }

    /**
     * Creates a deep copy of this controller.
     * Clones the model data but does not copy runtime state.
     * 
     * @returns A new AnimatorController instance with the same configuration
     */
    clone() {
        if (typeof this.model === "string") {
            console.warn("AnimatorController has not been resolved, can not create model from string", this.model);
            return null;
        }
        if (debug) console.warn("AnimatorController clone()", this.model);
        // clone runtime controller but dont clone clip or action
        const clonedModel = deepClone(this.model, (_owner, _key, _value) => {
            if (_value === null || _value === undefined) return true;
            // dont clone three Objects
            if (_value.type === "Object3D" || _value.isObject3D === true) return false;
            // dont clone AnimationAction
            if (isAnimationAction(_value)) { //.constructor.name === "AnimationAction") {
                // console.log(_value);
                return false;
            }
            // dont clone AnimationClip
            if (_value["tracks"] !== undefined) return false;
            // when assigned __concreteInstance during serialization
            if (_value instanceof AnimatorController) return false;
            return true;
        }) as AnimatorControllerModel;
        console.assert(clonedModel !== this.model);
        const controller = new AnimatorController(clonedModel);
        return controller;
    }

    /**
     * Updates the controller's state machine and animations.
     * Called each frame by the animator component.
     * 
     * @param weight - The weight to apply to the animations (for blending)
     */
    update(weight: number) {
        if (!this.animator) return;
        this.evaluateTransitions();
        this.updateActiveStates(weight);
        // We want to update the animation mixer even if there is no active state (e.g. in cases where an empty animator controller is assigned and the timeline runs)
        // if (!this._activeState) return;
        const dt = this.animator.context.time.deltaTime;
        if (this.animator.applyRootMotion) {
            this.rootMotionHandler?.onBeforeUpdate(weight);
        }
        this._mixer.update(dt);
        if (this.animator.applyRootMotion) {
            this.rootMotionHandler?.onAfterUpdate(weight);
        }
    }


    private _mixer!: AnimationMixer;
    private _activeState?: State;

    /**
     * Gets the currently active animation state.
     * 
     * @returns The active state or undefined if no state is active
     */
    get activeState(): State | undefined { return this._activeState; }

    constructor(model: AnimatorControllerModel) {
        this.model = model;
        if (debug) console.log(this);
    }

    private _activeStates: State[] = [];

    private updateActiveStates(weight: number) {
        for (let i = 0; i < this._activeStates.length; i++) {
            const state = this._activeStates[i];
            const motion = state.motion;
            if (!motion.action) {
                this._activeStates.splice(i, 1);
                i--;
            }
            else {
                const action = motion.action;
                action.weight = weight;
                // console.log(action.getClip().name, action.getEffectiveWeight(), action.isScheduled());
                if ((action.getEffectiveWeight() <= 0 && !action.isRunning())) {
                    if (debug)
                        console.debug("REMOVE", state.name, action.getEffectiveWeight(), action.isRunning(), action.isScheduled())
                    this._activeStates.splice(i, 1);
                    i--;
                }
            }
        }
    }

    private setStartTransition() {
        if (this.model.layers.length > 1 && (debug || isDevEnvironment())) {
            console.warn("Multiple layers are not supported yet " + this.animator?.name);
        }
        for (const layer of this.model.layers) {
            const sm = layer.stateMachine;
            if (sm.defaultState === undefined) {
                if (debug)
                    console.warn("AnimatorController default state is undefined, will assign state 0 as default", layer);
                sm.defaultState = 0;
            }
            const start = sm.states[sm.defaultState];
            this.transitionTo(start, 0, this.normalizedStartOffset);
            break;
        }
    }

    private evaluateTransitions() {
        let didEnterStateThisFrame = false;
        if (!this._activeState) {
            this.setStartTransition();
            if (!this._activeState) return;
            didEnterStateThisFrame = true;
        }

        const state = this._activeState;
        const action = state.motion.action;
        let index = 0;
        for (const transition of state.transitions) {
            ++index;
            // transition without exit time and without condition that transition to itself are ignored
            if (!transition.hasExitTime && transition.conditions.length <= 0) {
                // if (this._activeState && this.getState(transition.destinationState, currentLayer)?.hash === this._activeState.hash)
                continue;
            }

            let allConditionsAreMet = true;
            for (const cond of transition.conditions) {
                if (!this.evaluateCondition(cond)) {
                    allConditionsAreMet = false;
                    break;
                }
            }
            if (!allConditionsAreMet) continue;

            if (debug && allConditionsAreMet) {
                // console.log("All conditions are met", transition);
            }

            if (action) {
                const dur = state.motion.clip!.duration;
                const normalizedTime = dur <= 0 ? 1 : Math.abs(action.time / dur);
                let exitTime = transition.exitTime;

                // When the animation is playing backwards we need to check exit time inverted
                if (action.timeScale < 0) {
                    exitTime = 1 - exitTime;
                }

                let makeTransition = false;
                if (transition.hasExitTime) {
                    if (action.timeScale > 0) makeTransition = normalizedTime >= transition.exitTime;
                    // When the animation is playing backwards we need to check exit time inverted
                    else if (action.timeScale < 0) makeTransition = 1 - normalizedTime >= transition.exitTime;
                }
                else {
                    makeTransition = true;
                }

                if (makeTransition) {
                    // disable triggers for this transition
                    for (const cond of transition.conditions) {
                        const param = this.model.parameters.find(p => p.name === cond.parameter);
                        if (param?.type === AnimatorControllerParameterType.Trigger && param.value) {
                            param.value = false;
                        }
                    }

                    // if (transition.hasExitTime && transition.exitTime >= .9999) 
                    action.clampWhenFinished = true;
                    // else action.clampWhenFinished = false;
                    if (debug) {
                        const targetState = this.getState(transition.destinationState, 0);
                        console.log(`Transition to ${transition.destinationState} / ${targetState?.name}`, transition, "\nTimescale: " + action.timeScale, "\nNormalized time: " + normalizedTime.toFixed(3), "\nExit Time: " + exitTime, transition.hasExitTime);
                        // console.log(action.time, transition.exitTime);
                    }
                    this.transitionTo(transition.destinationState, transition.duration, transition.offset);
                    // use the first transition that matches all conditions and make the transition as soon as in range
                    return;
                }
            }
            else {
                this.transitionTo(transition.destinationState, transition.duration, transition.offset);
                return;
            }
            // if none of the transitions can be made continue searching for another transition meeting the conditions
        }

        // action.time += this.context.time.deltaTime
        // console.log(action?.time, action?.getEffectiveWeight())

        // update timescale
        if (action) {
            this.setTimescale(action, state);
        }

        let didTriggerLooping = false;
        if (state.motion.isLooping && action) {
            // we dont use the three loop state here because it prevents the transition check above
            // it is easier if we re-trigger loop here. 
            // We also can easily add the cycle offset settings from unity later
            if (action.time >= action.getClip().duration) {
                didTriggerLooping = true;
                action.reset();
                action.time = 0;
                action.play();
            }
            else if (action.time <= 0 && action.timeScale < 0) {
                didTriggerLooping = true;
                action.reset();
                action.time = action.getClip().duration;
                action.play();
            }
        }

        // call update state behaviours:
        if (!didTriggerLooping && state && !didEnterStateThisFrame && action && this.animator) {
            if (state.behaviours) {
                const duration = action?.getClip().duration;
                const normalizedTime = action.time / duration;
                const info = new AnimatorStateInfo(this._activeState, normalizedTime, duration, this._speed)
                for (const beh of state.behaviours) {
                    if (beh.instance) {
                        beh.instance.onStateUpdate?.call(beh.instance, this.animator, info, 0);
                    }
                }
            }
        }

    }

    private setTimescale(action: AnimationAction, state: State) {
        let speedFactor = state.speed ?? 1;
        if (state.speedParameter)
            speedFactor *= this.getFloat(state.speedParameter);
        if (speedFactor !== undefined) {
            action.timeScale = speedFactor * this._speed;
        }
    }

    private getState(state: State | number, layerIndex: number): State | null {
        if (typeof state === "number") {
            if (state == -1) {
                state = this.model.layers[layerIndex].stateMachine.defaultState; // exit state -> entry state
                if (state === undefined) {
                    if (debug)
                        console.warn("AnimatorController default state is undefined: ", this.model, "Layer: " + layerIndex);
                    state = 0;
                }
            }
            state = this.model.layers[layerIndex].stateMachine.states[state];
        }
        return state;
    }

    /**
     * These actions have been active previously but not faded out because we entered a state that has no real animation - no duration. In which case we hold the previously active actions until they are faded out.
     */
    private readonly _heldActions: AnimationAction[] = [];
    private releaseHeldActions(duration: number) {
        for (const prev of this._heldActions) {
            prev.fadeOut(duration);
        }
        this._heldActions.length = 0;
    }

    private transitionTo(state: State | number, durationInSec: number, offsetNormalized: number) {

        if (!this.animator) return;

        const layerIndex = 0;

        state = this.getState(state, layerIndex) as State;

        if (!state?.motion || !state.motion.clip || !(state.motion.clip instanceof AnimationClip)) {
            // if(debug) console.warn("State has no clip or motion", state);
            return;
        }

        const isSelf = this._activeState === state;
        if (isSelf) {
            const motion = state.motion;
            if (!motion.action_loopback && motion.clip) {
                // uncache action immediately resets the applied animation which breaks the root motion 
                // this happens if we have a transition to self and the clip is not cached yet
                const previousMatrix = this.rootMotionHandler ? this.animator.gameObject.matrix.clone() : null;
                this._mixer.uncacheAction(motion.clip, this.animator.gameObject);
                if (previousMatrix)
                    previousMatrix.decompose(this.animator.gameObject.position, this.animator.gameObject.quaternion, this.animator.gameObject.scale);
                motion.action_loopback = this.createAction(motion.clip);
            }
        }

        // call exit state behaviours
        if (this._activeState?.behaviours && this._activeState.motion.action) {
            const duration = this._activeState?.motion.clip!.duration;
            const normalizedTime = this._activeState.motion.action.time / duration;
            const info = new AnimatorStateInfo(this._activeState, normalizedTime, duration, this._speed);
            for (const beh of this._activeState.behaviours) {
                beh.instance?.onStateExit?.call(beh.instance, this.animator, info, layerIndex);
            }
        }

        const prevAction = this._activeState?.motion.action;


        if (isSelf) {
            state.motion.action = state.motion.action_loopback;
            state.motion.action_loopback = prevAction;
        }
        const prev = this._activeState;
        this._activeState = state;
        const action = state.motion?.action;

        const clip = state.motion.clip;


        if (clip?.duration <= 0 && clip.tracks.length <= 0) {
            // if the new state doesn't have a valid clip / no tracks we don't fadeout the previous action and instead hold the previous action.
            if (prevAction) {
                this._heldActions.push(prevAction);
            }
        }
        else if (prevAction) {
            prevAction!.fadeOut(durationInSec);
            this.releaseHeldActions(durationInSec);
        }

        if (action) {
            offsetNormalized = Math.max(0, Math.min(1, offsetNormalized));
            if (state.cycleOffsetParameter) {
                let val = this.getFloat(state.cycleOffsetParameter);
                if (typeof val === "number") {
                    if (val < 0) val += 1;
                    offsetNormalized += val;
                    offsetNormalized %= 1;
                }
                else if (debug) console.warn("AnimatorController cycle offset parameter is not a number", state.cycleOffsetParameter);
            }
            else if (typeof state.cycleOffset === "number") {
                offsetNormalized += state.cycleOffset
                offsetNormalized %= 1;
            }
            if (action.isRunning())
                action.stop();
            action.reset();
            action.enabled = true;
            this.setTimescale(action, state);
            const duration = state.motion.clip!.duration;
            // if we are looping to the same state we don't want to offset the current start time
            action.time = isSelf ? 0 : offsetNormalized * duration;
            if (action.timeScale < 0) action.time = duration - action.time;
            action.clampWhenFinished = true;
            action.setLoop(LoopOnce, 0);
            if (durationInSec > 0)
                action.fadeIn(durationInSec);
            else action.weight = 1;
            action.play();

            window.requestAnimationFrame(() => AnimationUtils.testIfRootCanAnimate(action));

            if (this.rootMotionHandler) {
                this.rootMotionHandler.onStart(action);
            }

            if (!this._activeStates.includes(state))
                this._activeStates.push(state);

            // call enter state behaviours
            if (this._activeState.behaviours) {
                const info = new AnimatorStateInfo(state, offsetNormalized, duration, this._speed);
                for (const beh of this._activeState.behaviours) {
                    beh.instance?.onStateEnter?.call(beh.instance, this.animator, info, layerIndex);
                }
            }
        }
        else if (debug) {
            if (!state["__warned_no_motion"]) {
                state["__warned_no_motion"] = true;
                console.warn("No action", state.motion, this);
            }
        }

        if (debug)
            console.log("TRANSITION FROM " + prev?.name + " TO " + state.name, durationInSec, prevAction, action, action?.getEffectiveTimeScale(), action?.getEffectiveWeight(), action?.isRunning(), action?.isScheduled(), action?.paused);
    }

    private createAction(clip: AnimationClip) {

        // uncache clip causes issues when multiple states use the same clip
        // this._mixer.uncacheClip(clip);
        // instead only uncache the action when one already exists to make sure
        // we get unique actions per state
        const existing = this._mixer.existingAction(clip);
        if (existing) this._mixer.uncacheAction(clip, this.animator?.gameObject);

        if (this.animator?.applyRootMotion) {
            if (!this.rootMotionHandler) {
                this.rootMotionHandler = new RootMotionHandler(this);
            }
            // TODO: find root bone properly
            const root = this.animator.gameObject;
            return this.rootMotionHandler.createClip(this._mixer, root, clip);
        }
        else {
            const action = this._mixer.clipAction(clip);
            return action;
        }
    }

    private evaluateCondition(cond: Condition): boolean {
        const param = this.model.parameters.find(p => p.name === cond.parameter);
        if (!param) return false;
        // console.log(param.name, param.value);
        switch (cond.mode) {
            case AnimatorConditionMode.If:
                return param.value === true;
            case AnimatorConditionMode.IfNot:
                return param.value === false;
            case AnimatorConditionMode.Greater:
                return param.value as number > cond.threshold;
            case AnimatorConditionMode.Less:
                return param.value as number < cond.threshold;
            case AnimatorConditionMode.Equals:
                return param.value === cond.threshold;
            case AnimatorConditionMode.NotEqual:
                return param.value !== cond.threshold;
        }
        return false;
    }

    private createActions(_animator: Animator) {
        if (debug) console.log("AnimatorController createActions", this.model);
        for (const layer of this.model.layers) {
            const sm = layer.stateMachine;
            for (let index = 0; index < sm.states.length; index++) {
                const state = sm.states[index];

                // ensure we have a transitions array
                if (!state.transitions) {
                    state.transitions = [];
                }
                for (const t of state.transitions) {
                    // can happen if conditions are empty in blender - the exporter seems to skip empty arrays
                    if (!t.conditions) t.conditions = [];
                }

                // ensure we have a motion even if none was exported
                if (!state.motion) {
                    if (debug) console.warn("No motion", state);
                    state.motion = createMotion(state.name);
                    // console.warn("Missing motion", "AnimatorController: " + this.model.name, state);
                    // sm.states.splice(index, 1);
                    // index -= 1;
                    // continue;
                }
                // the clips array contains which animator has which animationclip
                if (this.animator && state.motion.clips) {
                    // TODO: we have to compare by name because on instantiate we clone objects but not the node object
                    const mapping = state.motion.clips?.find(e => e.node.name === this.animator?.gameObject?.name);
                    if (!mapping) {
                        if (debug || isDevEnvironment()) {
                            console.warn("Could not find clip for animator \"" + this.animator?.gameObject?.name + "\"", state.motion.clips.map(c => c.node.name));
                        }
                    }
                    else
                        state.motion.clip = mapping.clip;
                }

                // ensure we have a clip to blend to
                if (!state.motion.clip) {
                    if (debug) console.warn("No clip assigned to state", state);
                    const clip = new AnimationClip(undefined, undefined, []);
                    state.motion.clip = clip;
                }

                if (state.motion?.clip) {
                    const clip = state.motion.clip;
                    if (clip instanceof AnimationClip) {
                        const action = this.createAction(clip);
                        state.motion.action = action;
                    }
                    else {
                        if (debug || isDevEnvironment()) console.warn("No valid animationclip assigned", state);
                    }
                }

                // create state machine behaviours
                if (state.behaviours && Array.isArray(state.behaviours)) {
                    for (const behaviour of state.behaviours) {
                        if (!behaviour?.typeName) continue;
                        const type = TypeStore.get(behaviour.typeName);
                        if (type) {
                            const instance: StateMachineBehaviour = new type() as StateMachineBehaviour;
                            if (instance.isStateMachineBehaviour) {
                                instance._context = this.context ?? undefined;
                                assign(instance, behaviour.properties);
                                behaviour.instance = instance;
                            }
                            if (debug) console.log("Created animator controller behaviour", state.name, behaviour.typeName, behaviour.properties, instance);
                        }
                        else {
                            if (debug || isDevEnvironment()) console.warn("Could not find AnimatorBehaviour type: " + behaviour.typeName);
                        }
                    }
                }
            }
        }
    }

    /**
     * Yields all animation actions managed by this controller.
     * Iterates through all states in all layers and returns their actions.
     */
    *enumerateActions() {
        if (!this.model.layers) return;
        for (const layer of this.model.layers) {
            const sm = layer.stateMachine;
            for (let index = 0; index < sm.states.length; index++) {
                const state = sm.states[index];
                if (state?.motion) {
                    if (state.motion.action)
                        yield state.motion.action;
                    if (state.motion.action_loopback)
                        yield state.motion.action_loopback;
                }
            }
        }
    }


    // https://docs.unity3d.com/Manual/RootMotion.html
    private rootMotionHandler?: RootMotionHandler;


    // private findRootBone(obj: Object3D): Object3D | null {
    //     if (this.animationRoot) return this.animationRoot;
    //     if (obj.type === "Bone") {
    //         this.animationRoot = obj as Bone;
    //         return this.animationRoot;
    //     }
    //     if (obj.children) {
    //         for (const ch of obj.children) {
    //             const res = this.findRootBone(ch);
    //             if (res) return res;
    //         }
    //     }
    //     return null;
    // }
}

/**
 * Wraps a KeyframeTrack to allow custom evaluation of animation values.
 * Used internally to modify animation behavior without changing the original data.
 */
class TrackEvaluationWrapper {

    track?: KeyframeTrack;
    createdInterpolant?: any;
    originalEvaluate?: Function;
    private customEvaluate?: (time: number) => any;

    constructor(track: KeyframeTrack, evaluate: (time: number, value: any) => any) {
        this.track = track;
        const t = track as any;
        const createOriginalInterpolator = t.createInterpolant.bind(track);
        t.createInterpolant = () => {
            t.createInterpolant = createOriginalInterpolator;
            this.createdInterpolant = createOriginalInterpolator();
            this.originalEvaluate = this.createdInterpolant.evaluate.bind(this.createdInterpolant);
            this.customEvaluate = time => {
                if (!this.originalEvaluate) return;
                const res = this.originalEvaluate(time);
                return evaluate(time, res);
            };
            this.createdInterpolant.evaluate = this.customEvaluate;
            return this.createdInterpolant;
        }
    };


    dispose() {
        if (this.createdInterpolant && this.originalEvaluate) {
            this.createdInterpolant.evaluate = this.originalEvaluate;
        }
        this.track = undefined;
        this.createdInterpolant = null;
        this.originalEvaluate = undefined;
        this.customEvaluate = undefined;
    }
}

/**
 * Handles root motion extraction from animation tracks.
 * Captures movement from animations and applies it to the root object.
 */
class RootMotionAction {

    private static lastObjPosition: { [key: string]: Vector3 } = {};
    private static lastObjRotation: { [key: string]: Quaternion } = {};

    // we remove the first keyframe rotation from the space rotation when updating
    private static firstKeyframeRotation: { [key: string]: Quaternion } = {};
    // this is used to rotate the space on clip end / start (so the transform direction is correct)
    private static spaceRotation: { [key: string]: Quaternion } = {};
    private static effectiveSpaceRotation: { [key: string]: Quaternion } = {};
    private static clipOffsetRotation: { [key: string]: Quaternion } = {};


    set action(val: AnimationAction) {
        this._action = val;
    }
    get action() {
        return this._action;
    }

    get cacheId() {
        return this.root.uuid;
    }

    private _action!: AnimationAction;

    private root: Object3D;
    private clip: AnimationClip;
    private positionWrapper: TrackEvaluationWrapper | null = null;
    private rotationWrapper: TrackEvaluationWrapper | null = null;
    private context: Context;

    positionChange: Vector3 = new Vector3();
    rotationChange: Quaternion = new Quaternion();

    constructor(context: Context, root: Object3D, clip: AnimationClip, positionTrack: KeyframeTrack | null, rotationTrack: KeyframeTrack | null) {
        // console.log(this, positionTrack, rotationTrack);
        this.context = context;
        this.root = root;
        this.clip = clip;

        if (!RootMotionAction.firstKeyframeRotation[this.cacheId])
            RootMotionAction.firstKeyframeRotation[this.cacheId] = new Quaternion();
        if (rotationTrack) {
            const values = rotationTrack.values;
            RootMotionAction.firstKeyframeRotation[this.cacheId]
                .set(values[0], values[1], values[2], values[3])
        }

        if (!RootMotionAction.spaceRotation[this.cacheId])
            RootMotionAction.spaceRotation[this.cacheId] = new Quaternion();

        if (!RootMotionAction.effectiveSpaceRotation[this.cacheId])
            RootMotionAction.effectiveSpaceRotation[this.cacheId] = new Quaternion();

        RootMotionAction.clipOffsetRotation[this.cacheId] = new Quaternion();
        if (rotationTrack) {
            RootMotionAction.clipOffsetRotation[this.cacheId]
                .set(rotationTrack.values[0], rotationTrack.values[1], rotationTrack.values[2], rotationTrack.values[3])
                .invert();
        }

        this.handlePosition(clip, positionTrack);
        this.handleRotation(clip, rotationTrack);
    }

    onStart(action: AnimationAction) {
        if (action.getClip() !== this.clip) return;

        if (!RootMotionAction.lastObjRotation[this.cacheId]) {
            RootMotionAction.lastObjRotation[this.cacheId] = this.root.quaternion.clone()
        }
        const lastRotation = RootMotionAction.lastObjRotation[this.cacheId];
        // const firstKeyframe = RootMotionAction.firstKeyframeRotation[this.this.cacheId];
        // lastRotation.invert().premultiply(firstKeyframe).invert();
        RootMotionAction.spaceRotation[this.cacheId].copy(lastRotation);

        if (debugRootMotion) {
            const euler = new Euler().setFromQuaternion(lastRotation);
            console.log("START", this.clip.name, Mathf.toDegrees(euler.y), this.root.position.z);
        }
    }

    private getClipRotationOffset() {
        return RootMotionAction.clipOffsetRotation[this.cacheId];
    }

    private _prevTime = 0;

    private handlePosition(_clip: AnimationClip, track: KeyframeTrack | null) {
        if (track) {

            const root = this.root;
            if (debugRootMotion)
                root.add(new AxesHelper());

            if (!RootMotionAction.lastObjPosition[this.cacheId])
                RootMotionAction.lastObjPosition[this.cacheId] = this.root.position.clone();
            const valuesDiff = new Vector3();
            const valuesPrev = new Vector3();
            // const rotation = new Quaternion();
            this.positionWrapper = new TrackEvaluationWrapper(track, (time, value: Float64Array) => {

                const weight = this.action.getEffectiveWeight();


                // reset for testing
                if (debugRootMotion) {
                    if (root.position.length() > 8)
                        root.position.set(0, root.position.y, 0);
                }

                if (time > this._prevTime) {
                    valuesDiff.set(value[0], value[1], value[2]);
                    valuesDiff.sub(valuesPrev);
                    valuesDiff.multiplyScalar(weight);
                    valuesDiff.applyQuaternion(this.getClipRotationOffset());

                    // RootMotionAction.effectiveSpaceRotation[id].slerp(RootMotionAction.spaceRotation[id], weight);
                    valuesDiff.applyQuaternion(root.quaternion);
                    this.positionChange.copy(valuesDiff);

                    // this.root.position.add(valuesDiff);
                }

                valuesPrev.fromArray(value);

                this._prevTime = time;
                value[0] = 0;
                value[1] = 0;
                value[2] = 0;
                return value;

            });
        }
    }

    private static identityQuaternion = new Quaternion();

    private handleRotation(clip: AnimationClip, track: KeyframeTrack | null) {
        if (track) {
            if (debugRootMotion) {
                const arr = track.values;
                const firstKeyframe = new Euler().setFromQuaternion(new Quaternion(arr[0], arr[1], arr[2], arr[3]));
                console.log(clip.name, track.name, "FIRST ROTATION IN TRACK", Mathf.toDegrees(firstKeyframe.y));
                const i = track.values.length - 4;
                const lastKeyframe = new Quaternion().set(arr[i], arr[i + 1], arr[i + 2], arr[i + 3]);
                const euler = new Euler().setFromQuaternion(lastKeyframe);
                console.log(clip.name, track.name, "LAST ROTATION IN TRACK", Mathf.toDegrees(euler.y));
            }

            // if (!RootMotionAction.lastObjRotation[root.uuid]) RootMotionAction.lastObjRotation[root.uuid] = new Quaternion();
            // const temp = new Quaternion();
            let prevTime: number = 0;
            const valuesPrev = new Quaternion();
            const valuesDiff = new Quaternion();
            // const summedRot = new Quaternion();
            this.rotationWrapper = new TrackEvaluationWrapper(track, (time, value: Float64Array) => {
                // root.quaternion.copy(RootMotionAction.lastObjRotation[root.uuid]);
                if (time > prevTime) {
                    valuesDiff.set(value[0], value[1], value[2], value[3]);
                    valuesPrev.invert();
                    valuesDiff.multiply(valuesPrev);
                    // if(weight < .99) valuesDiff.slerp(RootMotionAction.identityQuaternion, 1 - weight);
                    this.rotationChange.copy(valuesDiff);
                    // root.quaternion.multiply(valuesDiff);
                }
                // else
                //     root.quaternion.multiply(this.getClipRotationOffset());

                // RootMotionAction.lastObjRotation[root.uuid].copy(root.quaternion);
                valuesPrev.fromArray(value);
                prevTime = time;
                value[0] = 0;
                value[1] = 0;
                value[2] = 0;
                value[3] = 1;
                return value;
            });
        }
    }

    // private lastPos: Vector3 = new Vector3();

    onBeforeUpdate(_weight: number) {
        this.positionChange.set(0, 0, 0);
        this.rotationChange.set(0, 0, 0, 1);
    }

    onAfterUpdate(weight: number): boolean {
        if (!this.action) return false;
        weight *= this.action.getEffectiveWeight();
        if (weight <= 0) return false;
        this.positionChange.multiplyScalar(weight);
        this.rotationChange.slerp(RootMotionAction.identityQuaternion, 1 - weight);
        return true;
    }
}

/**
 * Manages root motion for a character.
 * Extracts motion from animation tracks and applies it to the character's transform.
 */
class RootMotionHandler {

    private controller: AnimatorController;
    private handler: RootMotionAction[] = [];
    private root!: Object3D;


    private basePosition: Vector3 = new Vector3();
    private baseQuaternion: Quaternion = new Quaternion();
    private baseRotation: Euler = new Euler();

    constructor(controller: AnimatorController) {
        this.controller = controller;
    }

    createClip(mixer: AnimationMixer, root: Object3D, clip: AnimationClip): AnimationAction {
        this.root = root;
        let rootName = "";
        if (root && "name" in root) {
            rootName = root.name;
        }
        const positionTrack = this.findRootTrack(clip, ".position");
        const rotationTrack = this.findRootTrack(clip, ".quaternion");
        const handler = new RootMotionAction(this.controller.context!, root, clip, positionTrack, rotationTrack);
        this.handler.push(handler);

        // it is important we do this after the handler is created
        // otherwise we can not hook into threejs interpolators
        const action = mixer.clipAction(clip);
        handler.action = action;
        return action;
    }


    onStart(action: AnimationAction) {
        for (const handler of this.handler) {
            handler.onStart(action);
        }

    }

    onBeforeUpdate(weight: number) {
        // capture the position of the object
        this.basePosition.copy(this.root.position);
        this.baseQuaternion.copy(this.root.quaternion);

        for (const hand of this.handler)
            hand.onBeforeUpdate(weight);
    }

    private summedPosition: Vector3 = new Vector3();
    private summedRotation: Quaternion = new Quaternion();

    onAfterUpdate(weight: number) {
        if (weight <= 0) return;
        // TODO: blend weight properly with root motion (when using timeline blending with animator)

        // apply the accumulated changes
        this.root.position.copy(this.basePosition);
        this.root.quaternion.copy(this.baseQuaternion);

        this.summedPosition.set(0, 0, 0);
        this.summedRotation.set(0, 0, 0, 1);
        for (const entry of this.handler) {
            if (entry.onAfterUpdate(weight)) {
                this.summedPosition.add(entry.positionChange);
                this.summedRotation.multiply(entry.rotationChange);
            }
        }
        this.root.position.add(this.summedPosition);
        this.root.quaternion.multiply(this.summedRotation);
        // RootMotionAction.lastObjRotation[this.root.uuid].copy(this.root.quaternion);
    }

    private findRootTrack(clip: AnimationClip, name: string) {
        const tracks = clip.tracks;
        if (!tracks) return null;
        for (const track of tracks) {
            if (track.name.endsWith(name)) {
                return track;
            }
        }
        return null;
    }
}

/**
 * Serialization handler for AnimatorController instances.
 * Handles conversion between serialized data and runtime objects.
 */
class AnimatorControllerSerializator extends TypeSerializer {
    onSerialize(_: any, _context: SerializationContext) {

    }
    onDeserialize(data: AnimatorControllerModel & { __type?: string }, context: SerializationContext) {
        if (context.type === AnimatorController && data?.__type === "AnimatorController")
            return new AnimatorController(data);
        return undefined;
    }
}
new AnimatorControllerSerializator(AnimatorController);