// For firefox ViewTimeline support
import "scroll-timeline-polyfill/dist/scroll-timeline.js";

import { Box3, Object3D } from "three";

import { isDevEnvironment } from "../../engine/debug/debug.js";
import { Mathf } from "../../engine/engine_math.js";
import { serializable } from "../../engine/engine_serialization.js";
import { getBoundingBox, setVisibleInCustomShadowRendering } from "../../engine/engine_three_utils.js";
import { getParam } from "../../engine/engine_utils.js";
import { Animation } from "../Animation.js";
import { Animator } from "../Animator.js";
import { AudioSource } from "../AudioSource.js";
import { Behaviour } from "../Component.js";
import { EventList } from "../EventList.js";
import { Light } from "../Light.js";
import { SplineWalker } from "../splines/SplineWalker.js";
import { PlayableDirector } from "../timeline/PlayableDirector.js";
import { ScrollMarkerModel } from "../timeline/TimelineModels.js";

const debug = getParam("debugscroll");

type ScrollFollowEvent = {
    /** Event type */
    type: "change",
    /** Current scroll value */
    value: number,
    /** ScrollFollow component that raised the event */
    component: ScrollFollow,
    /** Call to prevent invocation of default (e.g. updating targets) */
    preventDefault: () => void,
    defaultPrevented: boolean,
}

/**
 * The [ScrollFollow](https://engine.needle.tools/docs/api/ScrollFollow) component allows you to link the scroll position of the page (or a specific element) to one or more target objects.  
 
 * This can be used to create scroll-based animations, audio playback, or other effects. For example you can link the scroll position to a timeline (PlayableDirector) to create scroll-based storytelling effects or to an Animator component to change the animation state based on scroll.
 * 
 * ![](https://cloud.needle.tools/-/media/SYuH-vXxO4Jf30oU1HhjKQ.gif)  
 * ![](https://cloud.needle.tools/-/media/RplmU_j7-xb8XHXkOzc9PA.gif)  
 * 
 * Assign {@link target} objects to the component to have them updated based on the current scroll position (check the 'target' property for supported types).
 * 
 * @link Example at https://scrollytelling-2-z23hmxby7c6x-u30ld.needle.run/
 * @link Template at https://github.com/needle-engine/scrollytelling-template
 * @link [Scrollytelling Bike Demo](https://scrollytelling-bike-z23hmxb2gnu5a.needle.run/)   
 * 
 * ## How to use with an Animator
 * 1. Create an Animator component and set up a float parameter named "scroll".
 * 2. Create transitions between animation states based on the "scroll" parameter (e.g. from 0 to 1).
 * 3. Add a ScrollFollow component to the same GameObject or another GameObject in the scene.
 * 4. Assign the Animator component to the ScrollFollow's target property.
 * 
 * ## How to use with a PlayableDirector (timeline)
 * 1. Create a PlayableDirector component and set up a timeline asset.
 * 2. Add a ScrollFollow component to the same GameObject or another GameObject in the scene.
 * 3. Assign the PlayableDirector component to the ScrollFollow's target property.
 * 4. The timeline will now scrub based on the scroll position of the page.
 * 5. (Optional) Add ScrollMarker markers to your HTML to define specific points in the timeline that correspond to elements on the page. For example:
 *   ```html
 *   <div data-timeline-marker="0.0">Start of Timeline</div>
 *   <div data-timeline-marker="0.5">Middle of Timeline</div>
 *   <div data-timeline-marker="1.0">End of Timeline</div>
 * ```
 * 
 * @summary Links scroll position to target objects
 * @category Web
 * @category Interaction
 * @group Components
 * @component
 */
export class ScrollFollow extends Behaviour {

    /**
     * Target object(s) to follow the scroll position of the page.
     * 
     * Supported target types:
     * - PlayableDirector (timeline), the scroll position will be mapped to the timeline time
     * - Animator, the scroll position will be set to a float parameter named "scroll"
     * - Animation, the scroll position will be mapped to the animation time
     * - AudioSource, the scroll position will be mapped to the audio time
     * - SplineWalker, the scroll position will be mapped to the position01 property
     * - Light, the scroll position will be mapped to the intensity property
     * - Object3D, the object will move vertically based on the scroll position
     * - Any object with a `scroll` property (number or function)
     */
    @serializable([Behaviour, Object3D])
    target: object[] | object | null = null;

    /**
     * Damping for the movement, set to 0 for instant movement
     * @default 0
     */
    @serializable()
    damping: number = 0;

    /**
     * If true, the scroll value will be inverted (e.g. scrolling down will result in a value of 0)
     * @default false
     */
    @serializable()
    invert: boolean = false;


    /** 
     * **Experimental - might change in future updates**  
     * If set, the scroll position will be read from the specified element instead of the window.  
     * Use a CSS selector to specify the element, e.g. `#my-scrollable-div` or `.scroll-container`.
     * @default null
     */
    @serializable()
    htmlSelector: string | null = null;

    @serializable()
    mode: "window" = "window";

    /**
     * Event fired when the scroll position changes
     */
    @serializable(EventList)
    changed: EventList<ScrollFollowEvent> = new EventList<ScrollFollowEvent>();

    /**
     * Current scroll value in "pages" (0 = top of page, 1 = bottom of page)
     */
    get currentValue() {
        return this._current_value;
    }

    private _current_value: number = 0;
    private _target_value: number = 0;
    private _appliedValue: number = -1;
    private _needsUpdate = false;
    private _firstUpdate = false;

    awake() {
        this._firstUpdate = true;
    }


    /** @internal */
    onEnable() {
        window.addEventListener("wheel", this.updateCurrentScrollValue, { passive: true });
        this._appliedValue = -1;
        this._needsUpdate = true;
    }

    /** @internal */
    onDisable() {
        window.removeEventListener("wheel", this.updateCurrentScrollValue);
    }

    /** @internal */
    lateUpdate() {

        this.updateCurrentScrollValue();

        if (this._target_value >= 0) {
            if (this.damping > 0 && !this._firstUpdate) { // apply damping
                this._current_value = Mathf.lerp(this._current_value, this._target_value, this.context.time.deltaTime / this.damping);
                if (Math.abs(this._current_value - this._target_value) < 0.001) {
                    this._current_value = this._target_value;
                }
            }
            else {
                this._current_value = this._target_value;
            }
        }

        if (this._needsUpdate || this._current_value !== this._appliedValue) 
        {
            this._appliedValue = this._current_value;
            this._needsUpdate = false;

            let defaultPrevented = false;
            if (this.changed.listenerCount > 0) {
                // fire change event
                const event: ScrollFollowEvent = {
                    type: "change",
                    value: this._current_value,
                    component: this,
                    preventDefault: () => { event.defaultPrevented = true; },
                    defaultPrevented: false,
                };
                this.changed.invoke(event);
                defaultPrevented = event.defaultPrevented;
            }

            // if not prevented apply scroll
            if (!defaultPrevented) {

                const value = this.invert ? 1 - this._current_value : this._current_value;

                // apply scroll to target(s)
                if (Array.isArray(this.target)) {
                    this.target.forEach(t => t && this.applyScroll(t, value));
                }
                else if (this.target) {
                    this.applyScroll(this.target, value);
                }

                if (debug && this.context.time.frame % 30 === 0) {
                    console.debug(`[ScrollFollow] ${this._current_value.toFixed(5)} — ${(this._target_value * 100).toFixed(0)}%, targets [${Array.isArray(this.target) ? this.target.length : 1}]`);
                }
            }

            this._firstUpdate = false;
        }
    }

    private _lastSelectorValue: string | null = null;
    private _lastSelectorElement: Element | null = null;

    private updateCurrentScrollValue = () => {

        switch (this.mode) {
            case "window":
                if (this.htmlSelector?.length) {
                    if (this.htmlSelector !== this._lastSelectorValue) {
                        this._lastSelectorElement = document.querySelector(this.htmlSelector);
                        this._lastSelectorValue = this.htmlSelector;
                    }
                    if (this._lastSelectorElement) {
                        const rect = this._lastSelectorElement.getBoundingClientRect();
                        this._target_value = -rect.top / (rect.height - window.innerHeight);
                        break;
                    }
                }
                else {
                    if (window.document.body.scrollHeight <= window.innerHeight) {
                        // If the page is not scrollable we can still increment the scroll value to allow triggering timelines etc.
                    }
                    else {
                        const diff = window.document.body.scrollHeight - window.innerHeight;
                        this._target_value = window.scrollY / (diff || 1);
                    }
                }

                break;
        }

        if (isNaN(this._target_value) || !isFinite(this._target_value)) this._target_value = -1;
    }


    private applyScroll(target: object, value: number) {

        if (!target) return;

        if (target instanceof PlayableDirector) {
            this.handleTimelineTarget(target, value);
            if (target.isPlaying) target.pause();
            target.evaluate();
        }
        else if (target instanceof Animator) {
            target.setFloat("scroll", value);
        }
        else if (target instanceof Animation) {
            target.time = value * target.duration;
        }
        else if (target instanceof AudioSource) {
            if (!target.duration) return;
            target.time = value * target.duration;
        }
        else if (target instanceof SplineWalker) {
            target.position01 = value;
        }
        else if (target instanceof Light) {
            target.intensity = value;
        }
        else if (target instanceof Object3D) {
            const t = target as any;
            // When objects are assigned they're expected to move vertically based on scroll
            if (t["needle:scrollbounds"] === undefined) {
                t["needle:scrollbounds"] = getBoundingBox(target) || null;
            }
            const bounds = t["needle:scrollbounds"] as Box3;
            if (bounds) {
                // TODO: remap position to use upper screen edge and lower edge instead of center
                target.position.y = -bounds.min.y - value * (bounds.max.y - bounds.min.y);
            }
        }
        else if ("scroll" in target) {
            if (typeof target.scroll === "number") {
                target.scroll = value;
            }
            else if (typeof target.scroll === "function") {
                target.scroll(value);
            }
        }
    }



    private handleTimelineTarget(director: PlayableDirector, value: number) {

        const duration = director.duration;
        let markersArray = timelineMarkerArrays.get(director);

        // Create markers array
        if (!markersArray) {
            markersArray = [];
            timelineMarkerArrays.set(director, markersArray);

            let markerIndex = 0;

            for (const marker of director.foreachMarker<ScrollMarkerModel & { element?: HTMLElement | null, needsUpdate?: boolean, timeline?: ViewTimeline }>("ScrollMarker")) {

                const index = markerIndex++;

                // Get marker elements from DOM
                if ((marker.element === undefined || marker.needsUpdate === true || /** element is not in DOM anymore? */ (marker.element && !marker.element?.parentNode))) {
                    marker.needsUpdate = false;
                    try {
                        // TODO: with this it's currently not possible to remap markers from HTML. For example if I have two sections and I want to now use the marker["center"] multiple times to stay at that marker for a longer time
                        marker.element = tryGetElementsForSelector(index) as HTMLElement | null;
                        if (debug) console.debug(`ScrollMarker #${index} (${marker.time.toFixed(2)}) found`, marker.element);
                        if (!marker.element) {
                            if (debug || isDevEnvironment()) console.warn(`No HTML element found for ScrollMarker: ${marker.name} (index ${index})`);
                            continue;
                        }
                    }
                    catch (error) {
                        marker.element = null;
                        console.error("ScrollMarker selector is not valid: " + marker.name + "\n", error);
                    }
                }

                // skip markers without element (e.g. if the selector didn't return any element)
                if (!marker.element) continue;

                markersArray.push(marker);
            }

            // If the timeline has no markers defined we can use timeline-marker elements in the DOM. These must define times then
            if (markersArray.length <= 0) {
                const markers = document.querySelectorAll(`[data-timeline-marker]`);
                markers.forEach((element) => {
                    const value = element.getAttribute("data-timeline-marker");
                    const time = parseFloat(value || ("NaN"));
                    if (!isNaN(time)) {
                        markersArray!.push({
                            time,
                            element: element as HTMLElement,
                        });
                    }
                    else if (isDevEnvironment() || debug) {
                        console.warn("[ScrollFollow] data-timeline-marker attribute is not a valid number. Supported are numbers only (e.g. <div data-timeline-marker=\"0.5\">)");
                    }
                });
            }

            // Init ViewTimeline for markers
            for (const marker of markersArray) {
                if (marker.element) {
                    // https://scroll-driven-animations.style/tools/view-timeline/ranges
                    /** @ts-ignore */
                    marker.timeline = new ViewTimeline({
                        subject: marker.element,
                        axis: 'block', // https://drafts.csswg.org/scroll-animations/#scroll-notation
                    });
                }
            }
        }


        weightsArray.length = 0;
        let sum = 0;
        const oneFrameTime = 1 / 60;

        // We keep a separate count here in case there are some markers that could not be resolved so point to *invalid* elements - the timeline should fallback to 0-1 scroll behaviour then
        let markerCount = 0;
        for (let i = 0; i < markersArray.length; i++) {
            const marker = markersArray[i];
            if (!marker.element) continue;
            const nextMarker = markersArray[i + 1];

            const nextTime = nextMarker
                ? (nextMarker.time - oneFrameTime)
                : duration;

            markerCount += 1;

            const timeline = marker.timeline;
            if (timeline) {
                const time01 = calculateTimelinePositionNormalized(timeline);
                // remap 0-1 to 0 - 1 - 0 (full weight at center)
                const weight = 1 - Math.abs(time01 - 0.5) * 2;
                const name = `marker${i}`;
                if (time01 > 0 && time01 <= 1) {
                    const lerpTime = marker.time + (nextTime - marker.time) * time01;
                    weightsArray.push({ name, time: lerpTime, weight: weight });
                    sum += weight;
                }
                // Before the first marker is reached
                else if (i === 0 && time01 <= 0) {
                    weightsArray.push({ name, time: 0, weight: 1 });
                    sum += 1;
                }
                // After the last marker is reached
                else if (i === markersArray.length - 1 && time01 >= 1) {
                    weightsArray.push({ name, time: duration, weight: 1 });
                    sum += 1;
                }
            }
        }

        if (weightsArray.length <= 0 && markerCount <= 0) {
            director.time = value * duration;
        }
        else if (weightsArray.length > 0) {
            // normalize and calculate weighted time
            let time = weightsArray[0].time; // fallback to first time
            if (weightsArray.length > 1) {
                for (const entry of weightsArray) {
                    const weight = entry.weight / Math.max(0.00001, sum);
                    // console.log(weight.toFixed(2))
                    // lerp time based on weight
                    const diff = Math.abs(entry.time - time);
                    time += diff * weight;
                }
            }
            if (this.damping <= 0 || this._firstUpdate) {
                director.time = time;
            }
            else {
                director.time = Mathf.lerp(director.time, time, this.context.time.deltaTime / this.damping);
            }

            const delta = Math.abs(director.time - time);
            if (delta > .001) { // if the time is > 1/100th of a second off we need another update
                this._needsUpdate = true;
            }

            if (debug && this.context.time.frame % 30 === 0) {
                console.log(`[ScrollFollow ] Timeline ${director.name}: ${time.toFixed(3)}`, weightsArray.map(w => `[${w.name} ${(w.weight * 100).toFixed(0)}%]`).join(", "));
            }
        }
    }

}


const timelineMarkerArrays: WeakMap<PlayableDirector,
    Array<{
        time: number,
        element?: HTMLElement | null | undefined,
        timeline?: ViewTimeline,
    }>
> = new WeakMap();


type OverlapInfo = {
    name: string,
    /** Marker time */
    time: number,
    /** Overlap in pixels */
    weight: number,
}

const weightsArray: OverlapInfo[] = [];


// type SelectorCache = {
//     /** The selector used to query the *elements */
//     selector: string,
//     elements: Element[] | null,
//     usedElementCount: number,
// }
// const querySelectorResults: Array<SelectorCache> = [];

const needleScrollMarkerCache = new Array<Element>();
let needsScrollMarkerRefresh = true;

function tryGetElementsForSelector(index: number): Element | null {

    if (!needsScrollMarkerRefresh) {
        const element = needleScrollMarkerCache[index] || null;
        return element;
    }
    needsScrollMarkerRefresh = false;
    needleScrollMarkerCache.length = 0;
    const markers = document.querySelectorAll(`[data-timeline-marker]`);
    markers.forEach((m, i) => {
        needleScrollMarkerCache[i] = m;
    });
    needsScrollMarkerRefresh = false;
    return tryGetElementsForSelector(index);
}


// #region ScrollTimeline

function calculateTimelinePositionNormalized(timeline: ViewTimeline) {
    if (!timeline.source) return 0;
    const currentTime = timeline.currentTime;
    const duration = timeline.duration;
    let durationValue = 1;
    if (duration.unit === "seconds") {
        durationValue = duration.value;
    }
    else if (duration.unit === "percent") {
        durationValue = duration.value;
    }
    const t01 = currentTime.unit === "seconds" ? (currentTime.value / durationValue) : (currentTime.value / 100);
    return t01;
}


declare global {
    interface ViewTimeline {
        axis: 'block' | 'inline';
        currentTime: { unit: 'seconds' | 'percent', value: number };
        duration: { unit: 'seconds' | 'percent', value: number };
        source: Element | null;
    }
}