import { AnimationClip, Object3D } from "three";
import type { Light, Material, PerspectiveCamera } from "three";

import type { Animator } from "../Animator.js";
import type { AudioSource } from "../AudioSource.js";
import { GameObject } from "../Component.js";
import { InstantiateIdProvider } from "../../engine/engine_networking_instantiate.js";
import { EventList } from "../EventList.js";
import { isTrackDescriptor, resolveToClip, track as trackFn, type TrackDescriptor, type TrackOptions, type AnimationKeyframe, type Tween, type Vec3Value, type QuatValue, type EulerValue, type ColorValue } from "../AnimationBuilder.js";

/** Keyframe array or tween shorthand */
type KF<V> = AnimationKeyframe<V>[] | Tween<V>;
import { SignalAsset, SignalReceiver, SignalReceiverEvent } from "./SignalAsset.js";
import type { PlayableDirector } from "./PlayableDirector.js";
import { ClipExtrapolation, TrackType } from "./TimelineModels.js";
import type { TimelineAssetModel, TrackModel, ClipModel, AnimationClipModel, AudioClipModel, ControlClipModel, MarkerModel, SignalMarkerModel, TrackOffset } from "./TimelineModels.js";
import { MarkerType } from "./TimelineModels.js";

/**
 * Options for an animation clip in the timeline builder
 */
export declare type AnimationClipOptions = {
    /** Start time of the clip in seconds. If omitted, placed after the previous clip on this track. */
    start?: number;
    /** Duration of the clip in seconds. Defaults to the animation clip duration. */
    duration?: number;
    /** Playback speed multiplier (default: 1) */
    speed?: number;
    /** Whether the animation should loop within the clip (default: false) */
    loop?: boolean;
    /** Ease-in duration in seconds (default: 0) */
    easeIn?: number;
    /** Ease-out duration in seconds (default: 0) */
    easeOut?: number;
    /** Offset into the source animation clip in seconds (default: 0) */
    clipIn?: number;
    /** Whether to remove the start offset of the animation (default: false) */
    removeStartOffset?: boolean;
    /** Pre-extrapolation mode (default: None) */
    preExtrapolation?: ClipExtrapolation;
    /** Post-extrapolation mode (default: None) */
    postExtrapolation?: ClipExtrapolation;
    /** Play the clip in reverse */
    reversed?: boolean;
}

/**
 * Options for an audio clip in the timeline builder
 */
export declare type AudioClipOptions = {
    /** Start time of the clip in seconds. If omitted, placed after the previous clip on this track. */
    start?: number;
    /** Duration of the clip in seconds (required for audio since we can't infer it) */
    duration: number;
    /** Playback speed multiplier (default: 1) */
    speed?: number;
    /** Volume multiplier for this clip (default: 1) */
    volume?: number;
    /** Whether the audio should loop within the clip (default: false) */
    loop?: boolean;
    /** Ease-in duration in seconds (default: 0) */
    easeIn?: number;
    /** Ease-out duration in seconds (default: 0) */
    easeOut?: number;
}

/**
 * Options for an activation clip in the timeline builder
 */
export declare type ActivationClipOptions = {
    /** Start time of the clip in seconds. If omitted, placed after the previous clip on this track. */
    start?: number;
    /** Duration of the clip in seconds (required) */
    duration: number;
    /** Ease-in duration in seconds (default: 0) */
    easeIn?: number;
    /** Ease-out duration in seconds (default: 0) */
    easeOut?: number;
}

/**
 * Options for a control clip in the timeline builder
 */
export declare type ControlClipOptions = {
    /** Start time of the clip in seconds. If omitted, placed after the previous clip on this track. */
    start?: number;
    /** Duration of the clip in seconds (required) */
    duration: number;
    /** Whether to control the activation of the source object (default: true) */
    controlActivation?: boolean;
    /** Whether to update a nested PlayableDirector on the source object (default: true) */
    updateDirector?: boolean;
}

/**
 * Options for a signal marker in the timeline builder
 */
export declare type SignalMarkerOptions = {
    /** Whether the signal should fire if the playback starts past its time (default: false) */
    retroActive?: boolean;
    /** Whether the signal should only fire once (default: false) */
    emitOnce?: boolean;
}

// Internal types for track building
type BuilderTrack = {
    name: string;
    type: TrackType;
    muted: boolean;
    outputs: Array<null | object>;
    clips: ClipModel[];
    markers: MarkerModel[];
    volume?: number;
    trackOffset?: TrackOffset;
    cursor: number; // current time position for auto-advancing
    inlineTracks: TrackDescriptor[]; // accumulated by .track() calls, committed at boundaries
};

type PendingSignal = {
    trackIndex: number;
    guid: string;
    callback: Function;
};


// ============================================================
// Track builder interfaces — typed views per track type
// ============================================================

/**
 * Shared methods available on all track builders and the TimelineBuilder entry point.
 * Provides track creation, build, and install methods.
 *
 * @category Animation and Sequencing
 */
export interface TimelineBuilderBase {
    /** Adds an animation track. Chain `.clip()` or `.track()` to add content. */
    animationTrack(name: string, binding?: Animator | Object3D | null): AnimationTrackBuilder;
    /** Adds an audio track. Chain `.clip()` to add audio clips. */
    audioTrack(name: string, binding?: AudioSource | Object3D | null, volume?: number): AudioTrackBuilder;
    /** Adds an activation track. Chain `.clip()` to define activation windows. */
    activationTrack(name: string, binding?: Object3D | null): ActivationTrackBuilder;
    /** Adds a control track. Chain `.clip()` to control nested objects/timelines. */
    controlTrack(name: string): ControlTrackBuilder;
    /** Adds a signal track. Chain `.signal()` or `.marker()` to add events. */
    signalTrack(name: string, binding?: SignalReceiver | Object3D | null): SignalTrackBuilder;
    /** Adds a marker track. Chain `.marker()` to add markers. */
    markerTrack(name: string): MarkerTrackBuilder;
    /** Builds and returns the {@link TimelineAssetModel}. */
    build(): TimelineAssetModel;
    /** Builds the timeline, assigns it to the director, and wires up signal callbacks. */
    install(director: PlayableDirector): TimelineAssetModel;
}

/**
 * Builder for animation tracks.
 * Provides `.clip()` for pre-built AnimationClips and `.track()` for inline animation definition.
 *
 * @category Animation and Sequencing
 */
export interface AnimationTrackBuilder extends TimelineBuilderBase {
    /** Adds a pre-built AnimationClip */
    clip(asset: AnimationClip, options?: AnimationClipOptions): AnimationTrackBuilder;
    /** Adds a clip from a single {@link TrackDescriptor} */
    clip(descriptor: TrackDescriptor, options?: AnimationClipOptions): AnimationTrackBuilder;
    /** Adds a clip from multiple {@link TrackDescriptor}s */
    clip(descriptors: TrackDescriptor[], options?: AnimationClipOptions): AnimationTrackBuilder;
    /** Adds an animation track for an Object3D's position or scale */
    track(target: Object3D, property: "position" | "scale", keyframes: KF<Vec3Value>, options?: TrackOptions): AnimationTrackBuilder;
    /** Adds an animation track for an Object3D's quaternion */
    track(target: Object3D, property: "quaternion", keyframes: KF<QuatValue>, options?: TrackOptions): AnimationTrackBuilder;
    /** Adds an animation track for an Object3D's rotation (Euler, converted to quaternion) */
    track(target: Object3D, property: "rotation", keyframes: KF<EulerValue>, options?: TrackOptions): AnimationTrackBuilder;
    /** Adds an animation track for an Object3D's visibility */
    track(target: Object3D, property: "visible", keyframes: KF<boolean>, options?: TrackOptions): AnimationTrackBuilder;
    /** Adds an animation track for a material's numeric property */
    track(target: Material, property: "opacity" | "roughness" | "metalness" | "alphaTest" | "emissiveIntensity" | "envMapIntensity" | "bumpScale" | "displacementScale" | "displacementBias", keyframes: KF<number>, options?: TrackOptions): AnimationTrackBuilder;
    /** Adds an animation track for a material's color property */
    track(target: Material, property: "color" | "emissive", keyframes: KF<ColorValue>, options?: TrackOptions): AnimationTrackBuilder;
    /** Adds an animation track for a light's numeric property */
    track(target: Light, property: "intensity" | "distance" | "angle" | "penumbra" | "decay", keyframes: KF<number>, options?: TrackOptions): AnimationTrackBuilder;
    /** Adds an animation track for a light's color */
    track(target: Light, property: "color", keyframes: KF<ColorValue>, options?: TrackOptions): AnimationTrackBuilder;
    /** Adds an animation track for a camera's numeric property */
    track(target: PerspectiveCamera, property: "fov" | "near" | "far" | "zoom", keyframes: KF<number>, options?: TrackOptions): AnimationTrackBuilder;
    /** Mutes this track so it is skipped during playback */
    muted(muted?: boolean): AnimationTrackBuilder;
}

/**
 * Builder for audio tracks. Provides `.clip()` for adding audio clips by URL.
 * @category Animation and Sequencing
 */
export interface AudioTrackBuilder extends TimelineBuilderBase {
    /** Adds an audio clip by URL */
    clip(url: string, options: AudioClipOptions): AudioTrackBuilder;
    /** Mutes this track so it is skipped during playback */
    muted(muted?: boolean): AudioTrackBuilder;
}

/**
 * Builder for activation tracks. Provides `.clip()` for defining activation windows.
 * @category Animation and Sequencing
 */
export interface ActivationTrackBuilder extends TimelineBuilderBase {
    /** Adds an activation clip that shows/hides the bound object */
    clip(options: ActivationClipOptions): ActivationTrackBuilder;
    /** Mutes this track so it is skipped during playback */
    muted(muted?: boolean): ActivationTrackBuilder;
}

/**
 * Builder for control tracks. Provides `.clip()` for controlling nested objects/timelines.
 * @category Animation and Sequencing
 */
export interface ControlTrackBuilder extends TimelineBuilderBase {
    /** Adds a control clip for a source object */
    clip(sourceObject: Object3D, options: ControlClipOptions): ControlTrackBuilder;
    /** Mutes this track so it is skipped during playback */
    muted(muted?: boolean): ControlTrackBuilder;
}

/**
 * Builder for signal tracks. Provides `.signal()` for callback-based signals and `.marker()` for asset-based markers.
 * @category Animation and Sequencing
 */
export interface SignalTrackBuilder extends TimelineBuilderBase {
    /** Adds a signal with a callback that fires at the given time */
    signal(time: number, callback: Function, options?: SignalMarkerOptions): SignalTrackBuilder;
    /** Adds a signal marker referencing a signal asset by guid */
    marker(time: number, asset: string, options?: SignalMarkerOptions): SignalTrackBuilder;
    /** Mutes this track so it is skipped during playback */
    muted(muted?: boolean): SignalTrackBuilder;
}

/**
 * Builder for marker tracks. Provides `.marker()` for adding markers.
 * @category Animation and Sequencing
 */
export interface MarkerTrackBuilder extends TimelineBuilderBase {
    /** Adds a marker referencing a signal asset by guid */
    marker(time: number, asset: string, options?: SignalMarkerOptions): MarkerTrackBuilder;
    /** Mutes this track so it is skipped during playback */
    muted(muted?: boolean): MarkerTrackBuilder;
}


/**
 * A fluent builder for creating timeline assets ({@link TimelineAssetModel}) from code.
 *
 * Use {@link TimelineBuilder.create} to start building a timeline.
 *
 * @example Using build() for timelines without signal callbacks
 * ```ts
 * const timeline = TimelineBuilder.create("MySequence")
 *     .animationTrack("Character", animator)
 *         .clip(walkClip, { duration: 2, easeIn: 0.3 })
 *         .clip(runClip, { duration: 3, easeIn: 0.5, easeOut: 0.5 })
 *     .activationTrack("FX", particleObject)
 *         .clip({ start: 1, duration: 2 })
 *     .audioTrack("Music", audioSource)
 *         .clip("music.mp3", { start: 0, duration: 5, volume: 0.8 })
 *     .build();
 *
 * director.playableAsset = timeline;
 * director.play();
 * ```
 *
 * @example With inline tracks (no pre-built clips needed)
 * ```ts
 * TimelineBuilder.create("DoorSequence")
 *     .animationTrack("Door", door)
 *         .track(door, "position", { from: [0, 0, 0], to: [2, 0, 0], duration: 1 })
 *         .track(light, "intensity", { from: 0, to: 5, duration: 1 })
 *     .signalTrack("Events")
 *         .signal(0.5, () => playSound("creak"))
 *     .install(director);
 *
 * director.play();
 * ```
 *
 * @example Using install() with signal callbacks
 * ```ts
 * TimelineBuilder.create("WithSignals")
 *     .animationTrack("Character", animator)
 *         .clip(walkClip, { duration: 2 })
 *     .signalTrack("Events")
 *         .signal(1.0, () => console.log("1 second!"))
 *         .signal(2.0, () => spawnParticles())
 *     .install(director);
 *
 * director.play();
 * ```
 *
 * @category Animation and Sequencing
 * @group Utilities
 */
export class TimelineBuilder {
    private _name: string;
    private _tracks: BuilderTrack[] = [];
    private _currentTrack: BuilderTrack | null = null;
    private _pendingSignals: PendingSignal[] = [];
    private _idProvider: InstantiateIdProvider;

    private constructor(name: string, seed?: number) {
        this._name = name;
        this._idProvider = new InstantiateIdProvider(seed ?? Date.now());
    }

    /**
     * Creates a new TimelineBuilder instance.
     * @param name - Name for the timeline asset
     * @param seed - Optional numeric seed for deterministic guid generation. Defaults to `Date.now()`.
     */
    static create(name?: string, seed?: number): TimelineBuilderBase {
        return new TimelineBuilder(name ?? "Timeline", seed);
    }

    // #region Track creation

    /**
     * Adds an animation track. Chain `.clip()` calls to add pre-built clips,
     * or chain `.track()` calls to define animation data inline:
     *
     * @example With pre-built AnimationClip
     * ```ts
     * .animationTrack("Character", animator)
     *     .clip(walkClip, { duration: 2, easeIn: 0.3 })
     *     .clip(runClip, { duration: 3 })
     * ```
     *
     * @example With inline tracks
     * ```ts
     * .animationTrack("Door", door)
     *     .track(door, "position", { from: [0, 0, 0], to: [2, 0, 0], duration: 1 })
     *     .track(light, "intensity", { from: 0, to: 5, duration: 1 })
     * ```
     *
     * @param name - Display name for the track
     * @param binding - The Animator or Object3D to animate
     */
    animationTrack(name: string, binding?: Animator | Object3D | null): AnimationTrackBuilder {
        this.commitInlineTracks();
        this._currentTrack = this.pushTrack(name, TrackType.Animation, binding ?? null);
        return this as unknown as AnimationTrackBuilder;
    }

    /**
     * Adds an audio track. Subsequent `.clip()` calls add audio clips to this track.
     * @param name - Display name for the track
     * @param binding - The AudioSource to play audio on (optional)
     * @param volume - Track volume multiplier (default: 1)
     */
    audioTrack(name: string, binding?: AudioSource | Object3D | null, volume?: number): AudioTrackBuilder {
        this.commitInlineTracks();
        this._currentTrack = this.pushTrack(name, TrackType.Audio, binding ?? null);
        this._currentTrack.volume = volume;
        return this as unknown as AudioTrackBuilder;
    }

    /**
     * Adds an activation track. Subsequent `.clip()` calls define when the bound object is active.
     * @param name - Display name for the track
     * @param binding - The Object3D to show/hide
     */
    activationTrack(name: string, binding?: Object3D | null): ActivationTrackBuilder {
        this.commitInlineTracks();
        this._currentTrack = this.pushTrack(name, TrackType.Activation, binding ?? null);
        return this as unknown as ActivationTrackBuilder;
    }

    /**
     * Adds a control track. Subsequent `.clip()` calls control nested timelines or objects.
     * @param name - Display name for the track
     */
    controlTrack(name: string): ControlTrackBuilder {
        this.commitInlineTracks();
        this._currentTrack = this.pushTrack(name, TrackType.Control, null);
        return this as unknown as ControlTrackBuilder;
    }

    /**
     * Adds a signal track. Use `.signal()` or `.marker()` to add signal markers.
     * @param name - Display name for the track
     * @param binding - The SignalReceiver component (optional — if using `.signal()` with callbacks, one is created automatically by {@link install})
     */
    signalTrack(name: string, binding?: SignalReceiver | Object3D | null): SignalTrackBuilder {
        this.commitInlineTracks();
        this._currentTrack = this.pushTrack(name, TrackType.Signal, binding ?? null);
        return this as unknown as SignalTrackBuilder;
    }

    /**
     * Adds a marker track. Use `.marker()` to add markers.
     * @param name - Display name for the track
     */
    markerTrack(name: string): MarkerTrackBuilder {
        this.commitInlineTracks();
        this._currentTrack = this.pushTrack(name, TrackType.Marker, null);
        return this as unknown as MarkerTrackBuilder;
    }

    // #endregion

    // #region Clip and marker methods

    /**
     * Adds a clip to the current track. The clip type must match the track type.
     *
     * - On an **animation track**: pass an `AnimationClip`, a {@link TrackDescriptor}, or a `TrackDescriptor[]` — and optional {@link AnimationClipOptions}
     * - On an **audio track**: pass a clip URL (string) and {@link AudioClipOptions}
     * - On an **activation track**: pass {@link ActivationClipOptions}
     * - On a **control track**: pass an Object3D and {@link ControlClipOptions}
     */
    clip(asset: AnimationClip, options?: AnimationClipOptions): this;
    clip(descriptor: TrackDescriptor, options?: AnimationClipOptions): this;
    clip(descriptors: TrackDescriptor[], options?: AnimationClipOptions): this;
    clip(url: string, options: AudioClipOptions): this;
    clip(options: ActivationClipOptions): this;
    clip(sourceObject: Object3D, options: ControlClipOptions): this;
    clip(assetOrOptions: AnimationClip | TrackDescriptor | TrackDescriptor[] | string | Object3D | ActivationClipOptions, options?: AnimationClipOptions | AudioClipOptions | ControlClipOptions): this {
        if (!this._currentTrack) throw new Error("TimelineBuilder: .clip() must be called after a track method (e.g. .animationTrack())");
        this.commitInlineTracks();

        const track = this._currentTrack;

        switch (track.type) {
            case TrackType.Animation: {
                // Resolve TrackDescriptor(s) to AnimationClip if needed
                let animClip: AnimationClip;
                if (assetOrOptions instanceof AnimationClip) {
                    animClip = assetOrOptions;
                }
                else {
                    const descriptors = Array.isArray(assetOrOptions) ? assetOrOptions : [assetOrOptions as TrackDescriptor];
                    // Use the track's binding as root for resolution
                    const binding = track.outputs[0];
                    const root = binding instanceof Object3D ? binding
                        : (binding != null && "gameObject" in binding) ? (binding as any).gameObject as Object3D
                        : undefined;
                    animClip = resolveToClip(descriptors, root);
                }

                const opts = (options ?? {}) as AnimationClipOptions;
                const duration = opts.duration ?? animClip.duration;
                const start = opts.start ?? track.cursor;
                const end = start + duration;

                const clipModel: ClipModel = {
                    start,
                    end,
                    duration,
                    timeScale: opts.speed ?? 1,
                    clipIn: opts.clipIn ?? 0,
                    easeInDuration: opts.easeIn ?? 0,
                    easeOutDuration: opts.easeOut ?? 0,
                    preExtrapolationMode: opts.preExtrapolation ?? ClipExtrapolation.None,
                    postExtrapolationMode: opts.postExtrapolation ?? ClipExtrapolation.None,
                    reversed: opts.reversed,
                    asset: {
                        clip: animClip,
                        loop: opts.loop ?? false,
                        duration: animClip.duration,
                        removeStartOffset: opts.removeStartOffset ?? false,
                    } satisfies AnimationClipModel,
                };
                track.clips.push(clipModel);
                track.cursor = end;
                break;
            }

            case TrackType.Audio: {
                const url = assetOrOptions as string;
                const opts = (options ?? {}) as AudioClipOptions;
                const duration = opts.duration;
                const start = opts.start ?? track.cursor;
                const end = start + duration;

                const clipModel: ClipModel = {
                    start,
                    end,
                    duration,
                    timeScale: opts.speed ?? 1,
                    clipIn: 0,
                    easeInDuration: opts.easeIn ?? 0,
                    easeOutDuration: opts.easeOut ?? 0,
                    preExtrapolationMode: ClipExtrapolation.None,
                    postExtrapolationMode: ClipExtrapolation.None,
                    asset: {
                        clip: url,
                        loop: opts.loop ?? false,
                        volume: opts.volume ?? 1,
                    } satisfies AudioClipModel,
                };
                track.clips.push(clipModel);
                track.cursor = end;
                break;
            }

            case TrackType.Activation: {
                const opts = assetOrOptions as ActivationClipOptions;
                const start = opts.start ?? track.cursor;
                const end = start + opts.duration;

                const clipModel: ClipModel = {
                    start,
                    end,
                    duration: opts.duration,
                    timeScale: 1,
                    clipIn: 0,
                    easeInDuration: opts.easeIn ?? 0,
                    easeOutDuration: opts.easeOut ?? 0,
                    preExtrapolationMode: ClipExtrapolation.None,
                    postExtrapolationMode: ClipExtrapolation.None,
                    asset: {},
                };
                track.clips.push(clipModel);
                track.cursor = end;
                break;
            }

            case TrackType.Control: {
                const sourceObject = assetOrOptions as Object3D;
                const opts = (options ?? {}) as ControlClipOptions;
                const start = opts.start ?? track.cursor;
                const duration = opts.duration;
                const end = start + duration;

                const clipModel: ClipModel = {
                    start,
                    end,
                    duration,
                    timeScale: 1,
                    clipIn: 0,
                    easeInDuration: 0,
                    easeOutDuration: 0,
                    preExtrapolationMode: ClipExtrapolation.None,
                    postExtrapolationMode: ClipExtrapolation.None,
                    asset: {
                        sourceObject,
                        controlActivation: opts.controlActivation ?? true,
                        updateDirector: opts.updateDirector ?? true,
                    } satisfies ControlClipModel,
                };
                track.clips.push(clipModel);
                track.cursor = end;
                break;
            }

            default:
                throw new Error(`TimelineBuilder: .clip() is not supported on track type "${track.type}"`);
        }

        return this;
    }

    /**
     * Adds a signal marker to the current signal or marker track.
     * @param time - Time in seconds when the signal fires
     * @param asset - The signal asset identifier (guid string)
     * @param options - Optional marker configuration
     */
    marker(time: number, asset: string, options?: SignalMarkerOptions): this {
        if (!this._currentTrack) throw new Error("TimelineBuilder: .marker() must be called after a track method");
        if (this._currentTrack.type !== TrackType.Signal && this._currentTrack.type !== TrackType.Marker) {
            throw new Error(`TimelineBuilder: .marker() is only supported on signal and marker tracks, not "${this._currentTrack.type}"`);
        }

        const marker: SignalMarkerModel = {
            type: MarkerType.Signal,
            time,
            retroActive: options?.retroActive ?? false,
            emitOnce: options?.emitOnce ?? false,
            asset,
        };
        this._currentTrack.markers.push(marker);

        // Update cursor past the marker
        if (time > this._currentTrack.cursor) {
            this._currentTrack.cursor = time;
        }

        return this;
    }

    /**
     * Adds a signal with a callback to the current signal track.
     * This is a convenience method that automatically generates a signal asset guid,
     * adds the marker, and registers the callback so that {@link install} can wire up
     * the `SignalReceiver` on the director's GameObject.
     *
     * @param time - Time in seconds when the signal fires
     * @param callback - The function to invoke when the signal fires
     * @param options - Optional marker configuration
     *
     * @example
     * ```ts
     * const timeline = TimelineBuilder.create("Sequence")
     *     .signalTrack("Events")
     *         .signal(1.0, () => console.log("1 second reached!"))
     *         .signal(3.5, () => console.log("halfway!"), { emitOnce: true })
     *     .install(director);
     * ```
     */
    signal(time: number, callback: Function, options?: SignalMarkerOptions): this {
        if (!this._currentTrack) throw new Error("TimelineBuilder: .signal() must be called after a track method");
        if (this._currentTrack.type !== TrackType.Signal && this._currentTrack.type !== TrackType.Marker) {
            throw new Error(`TimelineBuilder: .signal() is only supported on signal and marker tracks, not "${this._currentTrack.type}"`);
        }

        const guid = this._idProvider.generateUUID();
        const trackIndex = this._tracks.indexOf(this._currentTrack);

        // Add the marker with the generated guid
        const marker: SignalMarkerModel = {
            type: MarkerType.Signal,
            time,
            retroActive: options?.retroActive ?? false,
            emitOnce: options?.emitOnce ?? false,
            asset: guid,
        };
        this._currentTrack.markers.push(marker);

        // Store the pending signal for wiring during install()
        this._pendingSignals.push({ trackIndex, guid, callback });

        if (time > this._currentTrack.cursor) {
            this._currentTrack.cursor = time;
        }

        return this;
    }

    /**
     * Mutes the current track so it is skipped during playback.
     */
    muted(muted: boolean = true): this {
        if (!this._currentTrack) throw new Error("TimelineBuilder: .muted() must be called after a track method");
        this._currentTrack.muted = muted;
        return this;
    }

    // --- Object3D ---
    /** Adds an animation track descriptor for an Object3D's position or scale to the current animation track */
    track(target: Object3D, property: "position" | "scale", keyframes: KF<Vec3Value>, options?: TrackOptions): this;
    /** Adds an animation track descriptor for an Object3D's quaternion to the current animation track */
    track(target: Object3D, property: "quaternion", keyframes: KF<QuatValue>, options?: TrackOptions): this;
    /** Adds an animation track descriptor for an Object3D's rotation (Euler, converted to quaternion) to the current animation track */
    track(target: Object3D, property: "rotation", keyframes: KF<EulerValue>, options?: TrackOptions): this;
    /** Adds an animation track descriptor for an Object3D's visibility to the current animation track */
    track(target: Object3D, property: "visible", keyframes: KF<boolean>, options?: TrackOptions): this;
    // --- Material ---
    /** Adds an animation track descriptor for a material's numeric property to the current animation track */
    track(target: Material, property: "opacity" | "roughness" | "metalness" | "alphaTest" | "emissiveIntensity" | "envMapIntensity" | "bumpScale" | "displacementScale" | "displacementBias", keyframes: KF<number>, options?: TrackOptions): this;
    /** Adds an animation track descriptor for a material's color property to the current animation track */
    track(target: Material, property: "color" | "emissive", keyframes: KF<ColorValue>, options?: TrackOptions): this;
    // --- Light ---
    /** Adds an animation track descriptor for a light's numeric property to the current animation track */
    track(target: Light, property: "intensity" | "distance" | "angle" | "penumbra" | "decay", keyframes: KF<number>, options?: TrackOptions): this;
    /** Adds an animation track descriptor for a light's color to the current animation track */
    track(target: Light, property: "color", keyframes: KF<ColorValue>, options?: TrackOptions): this;
    // --- Camera ---
    /** Adds an animation track descriptor for a camera's numeric property to the current animation track */
    track(target: PerspectiveCamera, property: "fov" | "near" | "far" | "zoom", keyframes: KF<number>, options?: TrackOptions): this;
    /**
     * Adds an animation track descriptor to the current animation track.
     * Multiple `.track()` calls accumulate into a single animation clip that is
     * committed when the next `.clip()`, track method, or `.build()`/`.install()` is called.
     *
     * Must be called after `.animationTrack()`.
     *
     * @param target - The object whose type determines valid properties and value types
     * @param property - The property to animate
     * @param keyframes - Keyframe array or {@link Tween} shorthand
     * @param options - Optional {@link TrackOptions} with a `root` for named targeting
     */
    track(target: object, property: string, keyframes: KF<any>, options?: TrackOptions): this {
        if (!this._currentTrack) throw new Error("TimelineBuilder: .track() must be called after .animationTrack()");
        if (this._currentTrack.type !== TrackType.Animation) throw new Error("TimelineBuilder: .track() is only supported on animation tracks");
        this._currentTrack.inlineTracks.push(trackFn(target as Object3D, property as "position", keyframes, options));
        return this;
    }

    // #endregion

    /**
     * Builds and returns the {@link TimelineAssetModel}.
     * Assign the result to `PlayableDirector.playableAsset` to play it.
     *
     * If you used `.signal()` with callbacks, use {@link install} instead — it calls `build()`
     * internally and also wires up the SignalReceiver on the director's GameObject.
     */
    build(): TimelineAssetModel {
        this.commitInlineTracks();
        const tracks: TrackModel[] = this._tracks.map(t => {
            const track: TrackModel = {
                name: t.name,
                type: t.type,
                muted: t.muted,
                outputs: t.outputs,
            };
            if (t.clips.length > 0) track.clips = t.clips;
            if (t.markers.length > 0) track.markers = t.markers;
            if (t.volume !== undefined) track.volume = t.volume;
            if (t.trackOffset !== undefined) track.trackOffset = t.trackOffset;
            return track;
        });

        return {
            name: this._name,
            tracks,
        };
    }

    /**
     * Builds the timeline asset, assigns it to the director, and wires up any
     * `.signal()` callbacks by creating/configuring a {@link SignalReceiver} on the
     * director's GameObject.
     *
     * @param director - The PlayableDirector to install the timeline on
     * @returns The built TimelineAssetModel (also assigned to `director.playableAsset`)
     *
     * @example
     * ```ts
     * TimelineBuilder.create("MyTimeline")
     *     .animationTrack("Anim", animator)
     *         .clip(walkClip, { duration: 2 })
     *     .signalTrack("Events")
     *         .signal(1.0, () => console.log("signal fired!"))
     *     .install(director);
     *
     * director.play();
     * ```
     */
    install(director: PlayableDirector): TimelineAssetModel {
        const asset = this.build();

        // Wire up signal callbacks
        if (this._pendingSignals.length > 0) {
            const obj = director.gameObject;
            let receiver = GameObject.getComponent(obj, SignalReceiver);
            if (!receiver) {
                receiver = GameObject.addComponent(obj, SignalReceiver);
            }
            if (!receiver.events) {
                receiver.events = [];
            }

            for (const pending of this._pendingSignals) {
                const signalAsset = new SignalAsset();
                signalAsset.guid = pending.guid;

                const evt = new SignalReceiverEvent();
                evt.signal = signalAsset;
                evt.reaction = new EventList([pending.callback]);
                receiver.events.push(evt);

                // Wire the receiver as the output binding for the signal track
                const track = asset.tracks[pending.trackIndex];
                if (track && !track.outputs.includes(receiver)) {
                    track.outputs.push(receiver);
                }
            }
        }

        director.playableAsset = asset;
        return asset;
    }

    // #region Private helpers

    private pushTrack(name: string, type: TrackType, binding: object | null): BuilderTrack {
        const track: BuilderTrack = {
            name,
            type,
            muted: false,
            outputs: binding ? [binding] : [],
            clips: [],
            markers: [],
            cursor: 0,
            inlineTracks: [],
        };
        this._tracks.push(track);
        return track;
    }

    /** Commits any pending `.track()` descriptors on the current animation track into a clip */
    private commitInlineTracks(): void {
        if (!this._currentTrack || this._currentTrack.inlineTracks.length === 0) return;

        const t = this._currentTrack;
        const binding = t.outputs[0];
        const root = binding instanceof Object3D ? binding
            : (binding != null && "gameObject" in binding) ? (binding as any).gameObject as Object3D
            : undefined;
        const animClip = resolveToClip(t.inlineTracks, root);

        const start = t.cursor;
        const duration = animClip.duration;

        const clipModel: ClipModel = {
            start,
            end: start + duration,
            duration,
            timeScale: 1,
            clipIn: 0,
            easeInDuration: 0,
            easeOutDuration: 0,
            preExtrapolationMode: ClipExtrapolation.None,
            postExtrapolationMode: ClipExtrapolation.None,
            asset: {
                clip: animClip,
                loop: false,
                duration: animClip.duration,
                removeStartOffset: false,
            } satisfies AnimationClipModel,
        };
        t.clips.push(clipModel);
        t.cursor = start + duration;
        t.inlineTracks = [];
    }

    // #endregion
}
