import { AnimationMixer, Object3D, Quaternion, Vector3 } from 'three';

import { isDevEnvironment } from '../../engine/debug/index.js';
import { FrameEvent } from '../../engine/engine_context.js';
import { isLocalNetwork } from '../../engine/engine_networking_utils.js';
import { serializable } from '../../engine/engine_serialization.js';
import type { GuidsMap } from '../../engine/engine_types.js';
import { deepClone, delay, getParam } from '../../engine/engine_utils.js';
import { Animator } from '../Animator.js';
import { AudioListener } from '../AudioListener.js';
import { AudioSource } from '../AudioSource.js';
import { Behaviour, GameObject } from '../Component.js';
import { SignalReceiver } from './SignalAsset.js';
import * as Models from "./TimelineModels.js";
import * as Tracks from "./TimelineTracks.js";

const debug = getParam("debugtimeline");

/**
 * Controls how the {@link PlayableDirector} behaves when playback reaches the end.
 * @see {@link PlayableDirector.extrapolationMode}
 */
export enum DirectorWrapMode {
    /** Hold the last frame when playback reaches the end of the timeline. */
    Hold = 0,
    /** Loop back to the start and continue playing indefinitely. */
    Loop = 1,
    /** Stop playback when the end is reached. The timeline will not loop. */
    None = 2,
}


/** How the clip handles time outside its start and end range. */
export enum ClipExtrapolation {
    /** No extrapolation is applied. */
    None = 0,
    /** Hold the time at the end value of the clip. */
    Hold = 1,
    /** Repeat time values outside the start/end range. */
    Loop = 2,
    /** Repeat time values outside the start/end range, reversing direction at each loop */
    PingPong = 3,
    /** Time values are passed in without modification, extending beyond the clips range */
    Continue = 4
};

/** @internal */
export type CreateTrackFunction = (director: PlayableDirector, track: Models.TrackModel) => Tracks.TrackHandler | undefined | null;

/**
 * PlayableDirector is the main component for controlling timelines in Needle Engine.  
 * It orchestrates playback of TimelineAssets containing animation, audio, signal,  
 * control, and activation tracks.    
 * 
 * ![](https://cloud.needle.tools/-/media/CkJal5dIBwFe6erA-MmiGw.webp)  
 * *Screenshot: Timeline in Unity*
 *
 * **Supported track types:**  
 * - Animation tracks - animate objects using AnimationClips
 * - Audio tracks - play synchronized audio
 * - Activation tracks - show/hide objects at specific times
 * - Signal tracks - trigger events at specific points
 * - Control tracks - control nested timelines or prefab instances
 * - Marker tracks - add metadata and navigation points  
 * 
 * [![](https://cloud.needle.tools/-/media/HFudFwl8J8D-Kt_VPu7pRw.gif)](https://engine.needle.tools/samples/bike-scrollytelling-responsive-3d)
 * 
 * [![](https://cloud.needle.tools/-/media/xJ1rI3STbZRnOoWJrSzqlQ.gif)](https://app.songsofcultures.com/?scene=little-brother)
 *
 * **Playback control:**    
 * Use `play()`, `pause()`, `stop()` for basic control.  
 * Set `time` directly and call `evaluate()` for scrubbing.  
 * Adjust `speed` for playback rate and `weight` for blending.  
 *
 * @example Basic timeline playback
 * ```ts
 * const director = myObject.getComponent(PlayableDirector);
 * director.play();
 * // Jump to specific time
 * director.time = 2.5;
 * director.evaluate();
 * ```
 *
 * @example Control playback speed
 * ```ts
 * director.speed = 0.5; // Half speed
 * director.speed = -1;  // Reverse playback
 * ```
 *
 * - Example: https://engine.needle.tools/samples-uploads/product-flyover/
 *
 * @summary Controls and plays TimelineAssets
 * @category Animation and Sequencing
 * @group Components
 * @see {@link Animator} for playing individual AnimationClips
 * @see {@link AudioSource} for standalone audio playback
 * @see {@link SignalReceiver} for handling timeline signals
 * @link https://engine.needle.tools/samples/?overlay=samples&tag=animation
 * @link https://app.songsofcultures.com/  
 * @link https://engine.needle.tools/docs/blender/animation.html Blender timeline and animation export
 */
export class PlayableDirector extends Behaviour {

    private static createTrackFunctions: { [key: string]: CreateTrackFunction } = {};

    /**
     * Register a function to create a track handler for a custom track type.
     * This allows you to extend the timeline system with your own track types and handlers.
     */
    static registerCreateTrack(type: string, fn: CreateTrackFunction) {
        this.createTrackFunctions[type] = fn;
    }

    /**
     * The timeline asset containing tracks, clips, and markers that this director will play.
     * Assign a timeline asset exported from Unity or Blender to enable playback.
     */
    playableAsset?: Models.TimelineAssetModel;

    /**
     * When true, the timeline starts playing automatically when the component awakens.
     * Set to false to control playback manually via `play()`.
     * @default false
     */
    @serializable()
    playOnAwake?: boolean;

    /**
     * Determines how the timeline behaves when it reaches the end of its duration.
     * @default DirectorWrapMode.Loop
     */
    @serializable()
    extrapolationMode: DirectorWrapMode = DirectorWrapMode.Loop;

    /** Returns true if the timeline is currently playing (not paused or stopped). */
    get isPlaying(): boolean { return this._isPlaying; }
    /** Returns true if the timeline is currently paused. */
    get isPaused(): boolean { return this._isPaused; }
    /**
     * The current playback time in seconds. Set this and call `evaluate()` to scrub.
     * @example Scrub to a specific time
     * ```ts
     * director.time = 5.0;
     * director.evaluate();
     * ```
     */
    get time(): number { return this._time; }
    set time(value: number) {
        if (typeof value === "number" && !Number.isNaN(value))
            this._time = value;
        else if (debug || isLocalNetwork()) {
            console.error("INVALID TIMELINE.TIME VALUE", value, this.name)
        };
    }
    /** The total duration of the timeline in seconds (read from the longest track/clip). */
    get duration(): number { return this._duration; }
    set duration(value: number) { this._duration = value; }
    /**
     * The blend weight of the timeline (0-1). Use values below 1 to blend
     * timeline animations with other animations like those from an Animator.
     */
    get weight(): number { return this._weight; };
    set weight(value: number) { this._weight = value; }
    /**
     * The playback speed multiplier. Set to negative values for reverse playback.
     * @example Reverse playback at double speed
     * ```ts
     * director.speed = -2;
     * ```
     */
    get speed(): number { return this._speed; }
    set speed(value: number) { this._speed = value; }

    /**
     * When true, `play()` will wait for audio tracks to load and for user interaction  
     * before starting playback. Web browsers require user interaction (click/tap) before  
     * allowing audio to play - this ensures audio is synchronized with the timeline.  
     * Set to false if you need immediate visual playback and can tolerate audio delay.  
     * @default true
     */
    waitForAudio: boolean = true;

    private _visibilityChangeEvt?: any;
    private _clonedPlayableAsset: boolean = false;
    private _speed: number = 1;

    /** @internal */
    awake(): void {
        if (debug) console.log(`[Timeline] Awake '${this.name}'`, this);

        this.rebuildGraph();

        if (!this.isValid() && (debug || isDevEnvironment())) {
            if (debug) {
                console.warn("PlayableDirector is not valid", "Asset?", this.playableAsset, "Tracks:", this.playableAsset?.tracks, "IsArray?", Array.isArray(this.playableAsset?.tracks), this);
            }
            else if (!this.playableAsset?.tracks?.length) {
                console.warn("PlayableDirector has no tracks");
            }
            else {
                console.warn("PlayableDirector is not valid");
            }
        }
    }

    /** @internal */
    onEnable() {
        if (debug) console.log("[Timeline] OnEnable", this.name, this.playOnAwake);

        for (const track of this._audioTracks) {
            track.onEnable?.();
        }
        for (const track of this._customTracks) {
            track.onEnable?.();
        }
        for (const track of this._animationTracks) {
            track.onEnable?.();
        }
        if (this.playOnAwake) {
            this.play();
        }

        if (!this._visibilityChangeEvt) this._visibilityChangeEvt = () => {
            switch (document.visibilityState) {
                case "hidden":
                    this.setAudioTracksAllowPlaying(false);
                    break;
                case "visible":
                    this.setAudioTracksAllowPlaying(true);
                    break;
            }
        }
        window.addEventListener('visibilitychange', this._visibilityChangeEvt);
    }

    /** @internal */
    onDisable(): void {
        if (debug) console.log("[Timeline] OnDisable", this.name);

        this.stop();
        for (const track of this._audioTracks) {
            track.onDisable?.();
        }
        for (const track of this._customTracks) {
            track.onDisable?.();
        }
        for (const track of this._animationTracks) {
            track.onDisable?.();
        }
        if (this._visibilityChangeEvt)
            window.removeEventListener('visibilitychange', this._visibilityChangeEvt);
    }

    /** @internal */
    onDestroy(): void {
        for (const tracks of this._allTracks) {
            for (const track of tracks)
                track.onDestroy?.();
        }
    }

    /** @internal */
    rebuildGraph() {
        if (!this.isValid()) return;
        this.resolveBindings();
        this.updateTimelineDuration();
        this.setupAndCreateTrackHandlers();
    }

    /**
     * Play the timeline from the current time.  
     * If the timeline is already playing this method does nothing.
     */
    async play() {
        if (!this.isValid()) return;
        const pauseChanged = this._isPaused == true;
        this._isPaused = false;
        if (this._isPlaying) return;
        this._isPlaying = true;
        if (pauseChanged) this.invokePauseChangedMethodsOnTracks();
        if (this.waitForAudio) {
            // Make sure audio tracks have loaded at the current time
            const promises: Array<Promise<any>> = [];
            for (const track of this._audioTracks) {
                const promise = track.loadAudio(this._time, 1, 0);
                if (promise)
                    promises.push(promise);
            }
            if (promises.length > 0) {
                await Promise.all(promises);
                if (!this._isPlaying) return;
            }
            while (this._audioTracks.length > 0 && this._isPlaying && !AudioSource.userInteractionRegistered && this.waitForAudio)
                await delay(200);
        }
        this.invokeStateChangedMethodsOnTracks();
        // Update timeline in LateUpdate to give other scripts time to react to the updated state  
        // e.g. if we animate OrbitControls look at target we want those changes to be applied in onBeforeRender  
        // if we use onBeforeRender here it will be called *after* the regular onBeforeRender events  
        // which is too late
        this._internalUpdateRoutine = this.startCoroutine(this.internalUpdate(), FrameEvent.LateUpdate);
    }

    /**
     * Pause the timeline.
     */
    pause() {
        if (!this.isValid()) return;
        this._isPlaying = false;
        if (this._isPaused) return;
        this._isPaused = true;
        this.internalEvaluate();
        this.invokePauseChangedMethodsOnTracks();
        this.invokeStateChangedMethodsOnTracks();
    }

    /**
     * Stop the timeline.
     */
    stop() {
        this._isStopping = true;
        for (const track of this._audioTracks) track.stop();
        const pauseChanged = this._isPaused == true;
        const wasPlaying = this._isPlaying;
        if (this._isPlaying) {
            this._time = 0;
            this._isPlaying = false;
            this._isPaused = false;
            this.internalEvaluate();
            if (pauseChanged) this.invokePauseChangedMethodsOnTracks();
        }
        this._isPlaying = false;
        this._isPaused = false;
        if (pauseChanged && !wasPlaying) this.invokePauseChangedMethodsOnTracks();
        if (wasPlaying) this.invokeStateChangedMethodsOnTracks();
        if (this._internalUpdateRoutine)
            this.stopCoroutine(this._internalUpdateRoutine);
        this._internalUpdateRoutine = null;
        this._isStopping = false;
    }

    /**
     * Evaluate the timeline at the current time. This is useful when you want to manually update the timeline e.g. when the timeline is paused and you set `time` to a new value.
     */
    evaluate() {
        this.internalEvaluate(true);
    }

    /**
     * @returns true if the timeline is valid and has tracks
     */
    isValid() {
        return this.playableAsset && this.playableAsset.tracks && Array.isArray(this.playableAsset.tracks);
    }

    /** Iterates over all tracks of the timeline
     * @returns all tracks of the timeline
     */
    *forEachTrack() {
        for (const tracks of this._allTracks) {
            for (const track of tracks)
                yield track;
        }
    }

    /**
     * @returns all animation tracks of the timeline
     */
    get animationTracks() {
        return this._animationTracks;
    }

    /**
     * @returns all audio tracks of the timeline
     */
    get audioTracks(): Tracks.AudioTrackHandler[] {
        return this._audioTracks;
    }

    /**
     * @returns all signal tracks of the timeline
     */
    get signalTracks(): Tracks.SignalTrackHandler[] {
        return this._signalTracks;
    }

    /**
     * @returns all marker tracks of the timeline
     */
    get markerTracks(): Tracks.MarkerTrackHandler[] {
        return this._markerTracks;
    }

    /**
     * Iterates over all markers of the timeline, optionally filtering by type
     * 
     * @example
     * ```ts
     * // Iterate over all ScrollMarkers in the timeline
     * for (const marker of director.foreachMarker<{selector:string}>("ScrollMarker")) {
     *   console.log(marker.time, marker.selector);
     * }
     * ```
     * 
     */
    *foreachMarker<T extends Record<string, any>>(type: string | null = null): Generator<(T & Models.MarkerModel)> {
        for (const track of this._markerTracks) {
            for (const marker of track.foreachMarker<T>(type)) {
                yield marker as T & Models.MarkerModel;
            }
        }
    }


    private _guidsMap?: GuidsMap;
    /** @internal */
    resolveGuids(map: GuidsMap) {
        this._guidsMap = map;
    }

    // INTERNALS

    private _isPlaying: boolean = false;
    private _internalUpdateRoutine: any;
    private _isPaused: boolean = false;
    /** internal, true during the time stop() is being processed */
    private _isStopping: boolean = false;
    private _time: number = 0;
    private _duration: number = 0;
    private _weight: number = 1;
    private readonly _animationTracks: Array<Tracks.AnimationTrackHandler> = [];
    private readonly _audioTracks: Array<Tracks.AudioTrackHandler> = [];
    private readonly _signalTracks: Array<Tracks.SignalTrackHandler> = [];
    private readonly _markerTracks: Array<Tracks.MarkerTrackHandler> = [];
    private readonly _controlTracks: Array<Tracks.ControlTrackHandler> = [];
    private readonly _customTracks: Array<Tracks.TrackHandler> = [];

    private readonly _tracksArray: Array<Array<Tracks.TrackHandler>> = [];
    private get _allTracks(): Array<Array<Tracks.TrackHandler>> {
        this._tracksArray.length = 0;
        this._tracksArray.push(this._animationTracks);
        this._tracksArray.push(this._audioTracks);
        this._tracksArray.push(this._signalTracks);
        this._tracksArray.push(this._markerTracks);
        this._tracksArray.push(this._controlTracks);
        this._tracksArray.push(this._customTracks);
        return this._tracksArray;
    }

    /** should be called after evaluate if the director was playing */
    private invokePauseChangedMethodsOnTracks() {
        for (const track of this.forEachTrack()) {
            track.onPauseChanged?.call(track);
        }
    }
    private invokeStateChangedMethodsOnTracks() {
        for (const track of this.forEachTrack()) {
            track.onStateChanged?.call(track, this._isPlaying);
        }
    }

    private * internalUpdate() {
        while (this._isPlaying && this.activeAndEnabled) {
            if (!this._isPaused && this._isPlaying) {
                this._time += this.context.time.deltaTime * this.speed;
                this.internalEvaluate();
            }
            // for (let i = 0; i < 5; i++)
            yield;
        }
    }

    /**
     * PlayableDirector lifecycle should always call this instead of "evaluate"
     * @param called_by_user If true the evaluation is called by the user (e.g. via evaluate())
     */
    private internalEvaluate(called_by_user: boolean = false) {
        // when the timeline is called by a user via evaluate() we want to keep updating activation tracks
        // because "isPlaying" might be false but the director is still active. See NE-3737

        if (!this.isValid()) return;

        let t = this._time;
        switch (this.extrapolationMode) {
            case DirectorWrapMode.Hold:
                if (this._speed > 0)
                    t = Math.min(t, this._duration);
                else if (this._speed < 0)
                    t = Math.max(t, 0);
                this._time = t;
                break;
            case DirectorWrapMode.Loop:
                t %= this._duration;
                this._time = t;
                break;
            case DirectorWrapMode.None:
                if (t > this._duration) {
                    this.stop();
                    return;
                }
                break;
        }

        const time = this._time;

        for (const track of this.playableAsset!.tracks) {
            if (track.muted) continue;
            switch (track.type) {
                case Models.TrackType.Activation:
                    // when the timeline is being disabled or stopped 
                    // then we want to leave objects active state as they were
                    // see NE-3241
                    // TODO: support all "post-playback-state" settings an activation track has, this is just "Leave as is"
                    if (!called_by_user && !this._isPlaying) continue;

                    for (let i = 0; i < track.outputs.length; i++) {
                        const binding = track.outputs[i];
                        if (typeof binding === "object") {
                            let isActive: boolean = false;
                            if (track.clips) {
                                for (const clip of track.clips) {
                                    if (clip.start <= time && time <= clip.end) {
                                        isActive = true;
                                    }
                                }
                            }
                            const obj = binding as Object3D;
                            if (obj.visible !== undefined) {
                                if (obj.visible !== isActive) {
                                    obj.visible = isActive;
                                    if (debug)
                                        console.warn(this.name, "set ActivationTrack-" + i, obj.name, isActive, time);
                                }
                            }
                        }
                    }
                    break;

            }
        }

        for (const track of this._allTracks) {
            for (const handler of track) {
                // When timeline reaches the end "stop()" is called which is evaluating with time 0
                // We don't want to re-evaluate the animation then in case the timeline is blended with the Animator
                // e.g then the timeline animation at time 0 is 100% applied on top of the animator animation

                if (this._isStopping && handler instanceof Tracks.AnimationTrackHandler) {
                    continue;
                }
                handler.evaluate(time);
            }
        }
    }

    private resolveBindings() {
        if (!this._clonedPlayableAsset) {
            this._clonedPlayableAsset = true;
            this.playableAsset = deepClone(this.playableAsset);
        }

        if (!this.playableAsset || !this.playableAsset.tracks) return;


        // if the director has a parent we assume it is part of the current scene
        // if not (e.g. when loaded via adressable but not yet added to any scene)
        // we can only resolve objects that are children
        const root = this.findRoot(this.gameObject);

        for (const track of this.playableAsset.tracks) {
            for (let i = track.outputs.length - 1; i >= 0; i--) {
                let binding = track.outputs[i];
                if (typeof binding === "string") {
                    if (this._guidsMap && this._guidsMap[binding])
                        binding = this._guidsMap[binding];
                    const obj = GameObject.findByGuid(binding, root);
                    if (obj === null || typeof obj !== "object") {
                        // if the binding is missing remove it to avoid unnecessary loops
                        track.outputs.splice(i, 1);
                        console.warn("Failed to resolve binding", binding, track.name, track.type);
                    }
                    else {
                        if (debug)
                            console.log("Resolved binding", binding, "to", obj);
                        track.outputs[i] = obj;
                    }
                }
                else if (binding === null) {
                    track.outputs.splice(i, 1);
                    if (PlayableDirector.createTrackFunctions[track.type]) {
                        // if a custom track doesnt have a binding its ok
                        continue;
                    }
                    // if the binding is missing remove it to avoid unnecessary loops
                    if (track.type !== Models.TrackType.Audio && track.type !== Models.TrackType.Control && track.type !== Models.TrackType.Marker && track.type !== Models.TrackType.Signal)
                        console.warn("Missing binding", binding, track.name, track.type, this.name, this.playableAsset.name);
                }
            }
            if (track.type === Models.TrackType.Control) {
                if (track.clips) {
                    for (let i = 0; i < track.clips.length; i++) {
                        const clip = track.clips[i];
                        let binding = clip.asset.sourceObject;
                        if (typeof binding === "string") {
                            if (this._guidsMap && this._guidsMap[binding])
                                binding = this._guidsMap[binding];
                            const obj = GameObject.findByGuid(binding, root);
                            if (obj === null || typeof obj !== "object") {
                                console.warn("Failed to resolve sourceObject binding", binding, track.name, clip);
                            }
                            else {
                                if (debug)
                                    console.log("Resolved binding", binding, "to", obj);
                                clip.asset.sourceObject = obj;
                            }
                        }
                    }
                }
            }
        }
    }

    private findRoot(current: Object3D): Object3D {
        if (current.parent)
            return this.findRoot(current.parent);
        return current;
    }

    private updateTimelineDuration() {
        this._duration = 0;
        if (!this.playableAsset || !this.playableAsset.tracks) return;
        for (const track of this.playableAsset.tracks) {
            if (track.muted === true) continue;
            if (track.clips) {
                for (const clip of track.clips) {
                    if (clip.end > this._duration) this._duration = clip.end;
                }
            }
            if (track.markers) {
                for (const marker of track.markers) {
                    if (marker.time > this._duration) this._duration = marker.time + .001;
                }
            }
        }
        // console.log("timeline duration", this._duration, this.playableAsset);
    }

    private setupAndCreateTrackHandlers() {
        this._animationTracks.length = 0;
        this._audioTracks.length = 0;
        this._signalTracks.length = 0;

        if (!this.playableAsset) return;
        let audioListener: AudioListener | null = GameObject.findObjectOfType(AudioListener, this.context);
        for (const track of this.playableAsset!.tracks) {
            const type = track.type;
            const registered = PlayableDirector.createTrackFunctions[type];
            if (registered !== null && registered !== undefined) {
                const res = registered(this, track) as Tracks.TrackHandler;
                if (typeof res.evaluate === "function") {
                    res.director = this;
                    res.track = track;
                    this._customTracks.push(res);
                    continue;
                }
            }
            // only handle animation tracks
            if (track.type === Models.TrackType.Animation) {
                if (!track.clips || track.clips.length <= 0) {
                    if (debug) console.warn("Animation track has no clips", track);
                    continue;
                }
                // loop outputs / bindings, they should contain animator references
                for (let i = track.outputs.length - 1; i >= 0; i--) {
                    let binding = track.outputs[i] as Animator;
                    if (binding instanceof Object3D) {
                        const anim = GameObject.getOrAddComponent(binding, Animator);
                        if (anim) binding = anim;
                    }
                    const animationClips = binding?.gameObject?.animations;
                    if (animationClips) {
                        const handler = new Tracks.AnimationTrackHandler();
                        handler.trackOffset = track.trackOffset;
                        handler.director = this;
                        handler.track = track;
                        for (let i = 0; i < track.clips.length; i++) {
                            const clipModel = track.clips[i];
                            const animModel = clipModel.asset as Models.AnimationClipModel;
                            if (!animModel) {
                                console.error(`Timeline ${this.name}: clip #${i} on track \"${track.name}\" has no animation data`);
                                continue;
                            }
                            // console.log(clipModel, track);
                            const targetObjectId = animModel.clip;
                            let clip: any = targetObjectId;
                            if (typeof clip === "string" || typeof clip === "number") {
                                clip = animationClips.find(c => c.name === targetObjectId);
                            }
                            if (debug) console.log(animModel, targetObjectId, "→", clip)
                            if (!clip) {
                                console.warn("Could not find animationClip for model", clipModel, track.name, this.name, this.playableAsset?.name, animationClips, binding);
                                continue;
                            }
                            // Try to share the mixer with the animator
                            if (binding instanceof Animator && binding.runtimeAnimatorController) {
                                if (!binding.__internalDidAwakeAndStart) binding.initializeRuntimeAnimatorController();
                                // Call bind once to ensure the animator is setup and has a mixer
                                if (!binding.runtimeAnimatorController.mixer) binding.runtimeAnimatorController.bind(binding);
                                handler.mixer = binding.runtimeAnimatorController.mixer;
                            }
                            // If we can not get the mixer from the animator then create a new one
                            if (!handler.mixer) {
                                handler.mixer = new AnimationMixer(binding.gameObject);
                                this.context.animations.registerAnimationMixer(handler.mixer);
                            }
                            handler.clips.push(clip);
                            // uncache because we want to create a new action
                            // this is needed because if a clip is used multiple times in a track (or even multiple tracks)
                            // we want to avoid setting weights on the same instance for clips/objects that are not active
                            handler.mixer.uncacheAction(clip);
                            handler.createHooks(clipModel.asset as Models.AnimationClipModel, clip);
                            const clipAction = handler.mixer.clipAction(clip); // new AnimationAction(handler.mixer, clip, null, null);
                            handler.actions.push(clipAction);
                            handler.models.push(clipModel);
                        }
                        this._animationTracks.push(handler);
                    }
                }
            }
            else if (track.type === Models.TrackType.Audio) {
                if (!track.clips || track.clips.length <= 0) continue;
                const audio = new Tracks.AudioTrackHandler();
                audio.director = this;
                audio.track = track;
                audio.audioSource = track.outputs.find(o => o instanceof AudioSource) as AudioSource;

                this._audioTracks.push(audio);
                if (!audioListener) {
                    // If the scene doesnt have an AudioListener we add one to the main camera
                    audioListener = this.context.mainCameraComponent?.gameObject.addComponent(AudioListener)!;
                }
                audio.listener = audioListener.listener;
                for (let i = 0; i < track.clips.length; i++) {
                    const clipModel = track.clips[i];
                    audio.addModel(clipModel);
                }
            }
            else if (track.type === Models.TrackType.Marker) {

                if (track.markers) {
                    // For the marker track we create both a signal track handler AND a markertrack handler because a marker track can have signals and markers
                    const signalHandler: Tracks.SignalTrackHandler = new Tracks.SignalTrackHandler();
                    signalHandler.director = this;
                    signalHandler.track = track;

                    const markerHandler: Tracks.MarkerTrackHandler = new Tracks.MarkerTrackHandler();
                    markerHandler.director = this;
                    markerHandler.track = track;

                    for (const marker of track.markers) {
                        switch (marker.type) {
                            case Models.MarkerType.Signal:
                                signalHandler.models.push(marker as Models.SignalMarkerModel);
                                signalHandler.didTrigger.push(false);
                                break;
                            default:
                                markerHandler.models.push(marker);
                                break;
                        }
                    }

                    if (signalHandler !== null && signalHandler.models.length > 0) {
                        const rec = GameObject.getComponent(this.gameObject, SignalReceiver);
                        if (rec) {
                            signalHandler.receivers.push(rec);
                            this._signalTracks.push(signalHandler);
                        }
                    }

                    if (markerHandler !== null && markerHandler.models.length > 0) {
                        this._markerTracks.push(markerHandler);
                    }
                }

            }
            else if (track.type === Models.TrackType.Signal) {
                const handler = new Tracks.SignalTrackHandler();
                handler.director = this;
                handler.track = track;
                if (track.markers) {
                    for (const marker of track.markers) {
                        handler.models.push(marker as Models.SignalMarkerModel);
                        handler.didTrigger.push(false);
                    }
                }
                for (const bound of track.outputs) {
                    handler.receivers.push(bound as SignalReceiver);
                }
                this._signalTracks.push(handler);
            }
            else if (track.type === Models.TrackType.Control) {
                const handler = new Tracks.ControlTrackHandler();
                handler.director = this;
                handler.track = track;
                if (track.clips) {
                    for (const clip of track.clips) {
                        handler.models.push(clip);
                    }
                }
                handler.resolveSourceObjects(this.context);
                this._controlTracks.push(handler);
            }
        }
    }

    private setAudioTracksAllowPlaying(allow: boolean) {
        for (const track of this._audioTracks) {
            track.onAllowAudioChanged(allow);
        }
    }



    /** Experimental support for overriding timeline animation data (position or rotation) */
    readonly animationCallbackReceivers: ITimelineAnimationCallbacks[] = [];
    /** Experimental: Receive callbacks for timeline animation. Allows modification of final value */
    registerAnimationCallback(receiver: ITimelineAnimationCallbacks) { this.animationCallbackReceivers.push(receiver); }
    /** Experimental: Unregister callbacks for timeline animation. Allows modification of final value */
    unregisterAnimationCallback(receiver: ITimelineAnimationCallbacks) {
        const index = this.animationCallbackReceivers.indexOf(receiver);
        if (index === -1) return;
        this.animationCallbackReceivers.splice(index, 1);
    }
}

/**
 * Interface for receiving callbacks during timeline animation evaluation.  
 * Allows modification of position/rotation values before they are applied.  
 *
 * **Registration:**
 * ```ts
 * director.registerAnimationCallback(this);
 * // Later: director.unregisterAnimationCallback(this);
 * ```
 *
 * @experimental This interface may change in future versions
 * @see {@link PlayableDirector.registerAnimationCallback}
 */
export interface ITimelineAnimationCallbacks {
    /**
    * @param director The director that is playing the timeline
    * @param target The target object that is being animated
    * @param time The current time of the timeline
    * @param rotation The evaluated rotation of the target object at the current time
     */
    onTimelineRotation?(director: PlayableDirector, target: Object3D, time: number, rotation: Quaternion);
    /**
    * @param director The director that is playing the timeline
    * @param target The target object that is being animated
    * @param time The current time of the timeline
    * @param position The evaluated position of the target object at the current time
     */
    onTimelinePosition?(director: PlayableDirector, target: Object3D, time: number, position: Vector3);
}
