import { Box3, Object3D } from "three";
import { element } from "three/src/nodes/TSL.js";

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 { MarkerTrackHandler } from "../api.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 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.
 * 
 * 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
 * 
 * ## 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.
 * 
 * @category Web
 * @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()
    private 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 _scrollStart: number = 0;
    private _scrollEnd: number = 0;
    private _scrollValue: number = 0;
    private _scrollContainerHeight: number = 0;

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

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

    /** @internal */
    lateUpdate() {

        this.updateCurrentScrollValue();

        if (this._target_value >= 0) {
            if (this.damping > 0) { // 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._current_value !== this._appliedValue) 
        {
            this._appliedValue = this._current_value;

            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;

                // const height = this._rangeEndValue - this._rangeStartValue;
                // const pixelValue = this._rangeStartValue + value * height;

                // 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}]`);
                }
            }
        }
    }

    private _lastSelectorValue: string | null = null;
    private _lastSelectorElement: Element | null = null;
    /** Top y */
    private _rangeStartValue: number = 0;
    /** Bottom y */
    private _rangeEndValue: number = 0;

    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._scrollStart = rect.top + window.scrollY;
                        this._scrollEnd = rect.height - window.innerHeight;
                        this._scrollValue = -rect.top;
                        this._target_value = -rect.top / (rect.height - window.innerHeight);
                        this._rangeStartValue = rect.top + window.scrollY;
                        this._rangeEndValue = this._rangeStartValue + rect.height - window.innerHeight;
                        this._scrollContainerHeight = rect.height;
                        break;
                    }
                }
                else {
                    this._scrollStart = 0;
                    this._scrollEnd = window.document.body.scrollHeight - window.innerHeight;
                    this._scrollValue = window.scrollY;
                    this._target_value = this._scrollValue / (this._scrollEnd || 1);
                    this._rangeStartValue = 0;
                    this._rangeEndValue = document.body.scrollHeight;
                    this._scrollContainerHeight = window.innerHeight;
                }
                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) {
            // When objects are assigned they're expected to move vertically based on scroll
            if (target["needle:scrollbounds"] === undefined) {
                target["needle:scrollbounds"] = getBoundingBox(target) || null;
            }
            const bounds = target["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 scrollRegionStart = Infinity;
        let scrollRegionEnd = 0;
        markersArray.length = 0;

        // querySelectorResults.length = 0;
        let markerIndex = 0;

        // https://scroll-driven-animations.style/tools/view-timeline/ranges
        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, marker.name) as HTMLElement | null;
                    if (debug) console.debug(`ScrollMarker #${index} "${marker.name}" (${marker.time.toFixed(2)}) found`, marker.element);
                    if (!marker.element) {
                        marker.timeline = undefined;
                        if (debug || isDevEnvironment()) console.warn(`No HTML element found for ScrollMarker: ${marker.name} (index ${index})`);
                        continue;
                    }
                    else {
                        /** @ts-ignore */
                        marker.timeline = new ViewTimeline({
                            subject: marker.element,
                            axis: 'block', // https://drafts.csswg.org/scroll-animations/#scroll-notation
                        });
                    }
                }
                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);

            const top = marker.element.offsetTop;
            const height = marker.element.offsetHeight;
            const bottom = top + height;
            if (top < scrollRegionStart) {
                scrollRegionStart = top;
            }
            if (bottom > scrollRegionEnd) {
                scrollRegionEnd = bottom;
            }
        }



        const currentTop = this._scrollValue;
        const currentBottom = currentTop + this._scrollContainerHeight;

        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.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;
                }
            }
            continue;
            // if(this.context.time.frame % 10 === 0) console.log(marker.element?.className, timeline, calculateTimelinePositionNormalized(timeline!));

            // const top = marker.element.offsetTop - this._scrollContainerHeight;
            // const height = marker.element.offsetHeight + this._scrollContainerHeight;
            // const bottom = top + height;
            // let overlap = 0;

            // // TODO: if we have two marker sections where no HTML overlaps (vor example because some large section is between them) we probably want to still virtually interpolate between them slowly in that region

            // if (bottom < currentTop) {
            //     // marker is above scroll region
            //     overlap = 0;
            // }
            // else if (top > currentBottom) {
            //     // marker is below scroll region
            //     overlap = 0;
            // }
            // else {
            //     // calculate overlap in pixels
            //     const overlapTop = Math.max(top, currentTop);
            //     const overlapBottom = Math.min(bottom, currentBottom);
            //     const height = Math.max(1, currentBottom - currentTop);
            //     overlap = Math.max(0, overlapBottom - overlapTop);
            // }

            // // if(this.context.time.frame % 20 === 0) console.log(overlap)

            // if (overlap > 0) {
            //     weightsArray.push({ time: marker.time, weight: overlap });
            //     sum += overlap;
            // }
        }

        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) {
                director.time = time;
            }
            else {
                director.time = Mathf.lerp(director.time, time, this.context.time.deltaTime / this.damping);
            }

            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 weightsArray: OverlapInfo[] = [];
const markersArray: Array<ScrollMarkerModel & {
    element?: HTMLElement | null,
    timeline?: ViewTimeline,
}> = [];

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


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

const needleScrollMarkerIndexCache = new Map<number, Element | null>();
const needleScrollMarkerNameCache = new Map<string, Element | null>();
let needsScrollMarkerRefresh = true;

function tryGetElementsForSelector(index: number, name: string, _cycle: number = 0): Element | null {

    if (!needsScrollMarkerRefresh) {
        if (name?.length) {
            const element = needleScrollMarkerNameCache.get(name) || null;
            if (element) return element;
            // const isNumber = !isNaN(Number(name));
            // if (!isNumber) {
            // }
        }
        const element = needleScrollMarkerIndexCache.get(index) || null;
        const value = element?.getAttribute("data-timeline-marker");
        // if (value?.length) {
        //     if (cycle === 0) {
        //         // if the HTML marker we found by index does define a different marker name we try to find the correct HTML element by name
        //         return tryGetElementsForSelector(index, value, 1);
        //     }
        //     if (isDevEnvironment()) console.warn(`ScrollMarker name mismatch: expected "${name}", got "${value}"`);
        // }
        return element;
    }
    needsScrollMarkerRefresh = false;
    needleScrollMarkerIndexCache.clear();
    const markers = document.querySelectorAll(`[data-timeline-marker]`);
    markers.forEach((m, i) => {
        needleScrollMarkerIndexCache.set(i, m);
        const name = m.getAttribute("data-timeline-marker");
        if (name?.length) needleScrollMarkerNameCache.set(name, m);
    });
    needsScrollMarkerRefresh = false;
    return tryGetElementsForSelector(index, name);
}


// #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;
        startOffset: { unit: 'px', value: number };
        endOffset: { unit: 'px', value: number };
    }
}