import { Directive, Input, ElementRef, NgZone, OnInit, OnDestroy } from '@angular/core';

/**
 * @hidden
 */
@Directive({
    selector: '[igxScrollInertia]',
    standalone: true
})
export class IgxScrollInertiaDirective implements OnInit, OnDestroy {

    @Input()
    public IgxScrollInertiaDirection: string;

    @Input()
    public IgxScrollInertiaScrollContainer: any;

    @Input()
    public wheelStep = 50;

    @Input()
    public inertiaStep = 1.5;

    @Input()
    public smoothingStep = 1.5;

    @Input()
    public smoothingDuration = 0.5;

    @Input()
    public swipeToleranceX = 20;

    @Input()
    public inertiaDeltaY = 3;

    @Input()
    public inertiaDeltaX = 2;

    @Input()
    public inertiaDuration = 0.5;

    private _touchInertiaAnimID;
    private _startX;
    private _startY;
    private _touchStartX;
    private _touchStartY;
    private _lastTouchEnd;
    private _lastTouchX;
    private _lastTouchY;
    private _savedSpeedsX = [];
    private _savedSpeedsY;
    private _totalMovedX;
    private _offsetRecorded;
    private _offsetDirection;
    private _lastMovedX;
    private _lastMovedY;
    private _nextX;
    private _nextY;
    private parentElement;
    private baseDeltaMultiplier = 1 / 120;
    private firefoxDeltaMultiplier = 1 / 30;

    constructor(private element: ElementRef, private _zone: NgZone) { }

    public ngOnInit(): void {
        this._zone.runOutsideAngular(() => {
            this.parentElement = this.element.nativeElement.parentElement || this.element.nativeElement.parentNode;
            if (!this.parentElement) {
                return;
            }
            const targetElem = this.parentElement;
            targetElem.addEventListener('wheel', this.onWheel.bind(this));
            targetElem.addEventListener('touchstart', this.onTouchStart.bind(this));
            targetElem.addEventListener('touchmove', this.onTouchMove.bind(this));
            targetElem.addEventListener('touchend', this.onTouchEnd.bind(this));
        });
    }

    public ngOnDestroy() {
        this._zone.runOutsideAngular(() => {
            const targetElem = this.parentElement;
            if (!targetElem) {
                return;
            }
            targetElem.removeEventListener('wheel', this.onWheel);
            targetElem.removeEventListener('touchstart', this.onTouchStart);
            targetElem.removeEventListener('touchmove', this.onTouchMove);
            targetElem.removeEventListener('touchend', this.onTouchEnd);
        });
    }

    /**
     * @hidden
     * Function that is called when scrolling with the mouse wheel or using touchpad
     */
    protected onWheel(evt) {
        // if no scrollbar return
        if (!this.IgxScrollInertiaScrollContainer) {
            return;
        }
        // if ctrl key is pressed and the user want to zoom in/out the page
        if (evt.ctrlKey) {
            return;
        }
        let scrollDeltaX;
        let scrollDeltaY;
        const scrollStep = this.wheelStep;
        const minWheelStep = 1 / this.wheelStep;
        const smoothing = this.smoothingDuration !== 0;

        this._startX = this.IgxScrollInertiaScrollContainer.scrollLeft;
        this._startY = this.IgxScrollInertiaScrollContainer.scrollTop;

        if (evt.wheelDeltaX) {
            /* Option supported on Chrome, Safari, Opera.
            /* 120 is default for mousewheel on these browsers. Other values are for trackpads */
            scrollDeltaX = -evt.wheelDeltaX * this.baseDeltaMultiplier;

            if (-minWheelStep < scrollDeltaX && scrollDeltaX < minWheelStep) {
                scrollDeltaX = Math.sign(scrollDeltaX) * minWheelStep;
            }
        } else if (evt.deltaX) {
            /* For other browsers that don't provide wheelDelta, use the deltaY to determine direction and pass default values. */
            const deltaScaledX = evt.deltaX * (evt.deltaMode === 0 ? this.firefoxDeltaMultiplier : 1);
            scrollDeltaX = this.calcAxisCoords(deltaScaledX, -1, 1);
        }

        /** Get delta for the Y axis */
        if (evt.wheelDeltaY) {
            /* Option supported on Chrome, Safari, Opera.
            /* 120 is default for mousewheel on these browsers. Other values are for trackpads */
            scrollDeltaY = -evt.wheelDeltaY * this.baseDeltaMultiplier;

            if (-minWheelStep < scrollDeltaY && scrollDeltaY < minWheelStep) {
                scrollDeltaY = Math.sign(scrollDeltaY) * minWheelStep;
            }
        } else if (evt.deltaY) {
            /* For other browsers that don't provide wheelDelta, use the deltaY to determine direction and pass default values. */
            const deltaScaledY = evt.deltaY * (evt.deltaMode === 0 ? this.firefoxDeltaMultiplier : 1);
            scrollDeltaY = this.calcAxisCoords(deltaScaledY, -1, 1);
        }

        if (evt.composedPath && this.didChildScroll(evt, scrollDeltaX, scrollDeltaY)) {
            return;
        }

        if (scrollDeltaX && this.IgxScrollInertiaDirection === 'horizontal') {
            const nextLeft = this._startX + scrollDeltaX * scrollStep;
            if (!smoothing) {
                this._scrollToX(nextLeft);
            } else {
                this._smoothWheelScroll(scrollDeltaX);
            }
            const maxScrollLeft = parseInt(this.IgxScrollInertiaScrollContainer.children[0].style.width, 10);
            if (0 < nextLeft && nextLeft < maxScrollLeft) {
                // Prevent navigating through pages when scrolling on Mac
                evt.preventDefault();
            }
        } else if (evt.shiftKey && scrollDeltaY && this.IgxScrollInertiaDirection === 'horizontal') {
            if (!smoothing) {
                const step = this._startX + scrollDeltaY * scrollStep;
                this._scrollToX(step);
            } else {
                this._smoothWheelScroll(scrollDeltaY);
            }
        } else if (!evt.shiftKey && scrollDeltaY && this.IgxScrollInertiaDirection === 'vertical') {
            const nextTop = this._startY + scrollDeltaY * scrollStep;
            if (!smoothing) {
                this._scrollToY(nextTop);
            } else {
                this._smoothWheelScroll(scrollDeltaY);
            }
            this.preventParentScroll(evt, true, nextTop);
        }
    }

    /**
     * @hidden
     * When there is still room to scroll up/down prevent the parent elements from scrolling too.
     */
    protected preventParentScroll(evt, preventDefault, nextTop = 0) {
        const curScrollTop = nextTop === 0 ? this.IgxScrollInertiaScrollContainer.scrollTop : nextTop;
        const maxScrollTop = this.IgxScrollInertiaScrollContainer.children[0].scrollHeight -
            this.IgxScrollInertiaScrollContainer.offsetHeight;
        if (0 < curScrollTop && curScrollTop < maxScrollTop) {
            if (preventDefault) {
                evt.preventDefault();
            }
            if (evt.stopPropagation) {
                evt.stopPropagation();
            }
        }
    }

    /**
     * @hidden
     * Checks if the wheel event would have scrolled an element under the display container
     * in DOM tree so that it can correctly be ignored until that element can no longer be scrolled.
     */
    protected didChildScroll(evt, scrollDeltaX, scrollDeltaY): boolean {
        const path = evt.composedPath();
        let i = 0;
        while (i < path.length && path[i].localName !== 'igx-display-container') {
            const e = path[i++];
            if (e.scrollHeight > e.clientHeight) {
                const overflowY = window.getComputedStyle(e)['overflow-y'];
                if (overflowY === 'auto' || overflowY === 'scroll') {
                    if (scrollDeltaY > 0 && e.scrollHeight - Math.abs(Math.round(e.scrollTop)) !== e.clientHeight) {
                        return true;
                    }
                    if (scrollDeltaY < 0 && e.scrollTop !== 0) {
                        return true;
                    }
                }
            }
            if (e.scrollWidth > e.clientWidth) {
                const overflowX = window.getComputedStyle(e)['overflow-x'];
                if (overflowX === 'auto' || overflowX === 'scroll') {
                    if (scrollDeltaX > 0 && e.scrollWidth - Math.abs(Math.round(e.scrollLeft)) !== e.clientWidth) {
                        return true;
                    }
                    if (scrollDeltaX < 0 && e.scrollLeft !== 0) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

    /**
     * @hidden
     * Function that is called the first moment we start interacting with the content on a touch device
     */
    protected onTouchStart(event) {
        if (!this.IgxScrollInertiaScrollContainer) {
            return false;
        }

        // stops any current ongoing inertia
        cancelAnimationFrame(this._touchInertiaAnimID);

        const touch = event.touches[0];

        this._startX = this.IgxScrollInertiaScrollContainer.scrollLeft;

        this._startY = this.IgxScrollInertiaScrollContainer.scrollTop;

        this._touchStartX = touch.pageX;
        this._touchStartY = touch.pageY;

        this._lastTouchEnd = new Date().getTime();
        this._lastTouchX = touch.pageX;
        this._lastTouchY = touch.pageY;
        this._savedSpeedsX = [];
        this._savedSpeedsY = [];

        // Vars regarding swipe offset
        this._totalMovedX = 0;
        this._offsetRecorded = false;
        this._offsetDirection = 0;

        if (this.IgxScrollInertiaDirection === 'vertical') {
            this.preventParentScroll(event, false);
        }
    }

    /**
     * @hidden
     * Function that is called when we need to scroll the content based on touch interactions
     */
    protected onTouchMove(event) {
        if (!this.IgxScrollInertiaScrollContainer) {
            return;
        }

        const touch = event.touches[0];
        const destX = this._startX + (this._touchStartX - touch.pageX) * Math.sign(this.inertiaStep);
        const destY = this._startY + (this._touchStartY - touch.pageY) * Math.sign(this.inertiaStep);

        /* Handle complex touchmoves when swipe stops but the toch doesn't end and then a swipe is initiated again */
        /* **********************************************************/


        const timeFromLastTouch = (new Date().getTime()) - this._lastTouchEnd;
        if (timeFromLastTouch !== 0 && timeFromLastTouch < 100) {
            const speedX = (this._lastTouchX - touch.pageX) / timeFromLastTouch;
            const speedY = (this._lastTouchY - touch.pageY) / timeFromLastTouch;

            // Save the last 5 speeds between two touchmoves on X axis
            if (this._savedSpeedsX.length < 5) {
                this._savedSpeedsX.push(speedX);
            } else {
                this._savedSpeedsX.shift();
                this._savedSpeedsX.push(speedX);
            }

            // Save the last 5 speeds between two touchmoves on Y axis
            if (this._savedSpeedsY.length < 5) {
                this._savedSpeedsY.push(speedY);
            } else {
                this._savedSpeedsY.shift();
                this._savedSpeedsY.push(speedY);
            }
        }
        this._lastTouchEnd = new Date().getTime();
        this._lastMovedX = this._lastTouchX - touch.pageX;
        this._lastMovedY = this._lastTouchY - touch.pageY;
        this._lastTouchX = touch.pageX;
        this._lastTouchY = touch.pageY;

        this._totalMovedX += this._lastMovedX;

        /*	Do not scroll using touch untill out of the swipeToleranceX bounds */
        if (Math.abs(this._totalMovedX) < this.swipeToleranceX && !this._offsetRecorded) {
            this._scrollTo(this._startX, destY);
        } else {
            /*	Record the direction the first time we are out of the swipeToleranceX bounds.
            *	That way we know which direction we apply the offset so it doesn't hickup when moving out of the swipeToleranceX bounds */
            if (!this._offsetRecorded) {
                this._offsetDirection = Math.sign(destX - this._startX);
                this._offsetRecorded = true;
            }

            /*	Scroll with offset ammout of swipeToleranceX in the direction we have exited the bounds and
            don't change it after that ever until touchend and again touchstart */
            this._scrollTo(destX - this._offsetDirection * this.swipeToleranceX, destY);
        }

        // On Safari preventing the touchmove would prevent default page scroll behaviour even if there is the element doesn't have overflow
        if (this.IgxScrollInertiaDirection === 'vertical') {
            this.preventParentScroll(event, true);
        }
    }

    protected onTouchEnd(event) {
        let speedX = 0;
        let speedY = 0;

        // savedSpeedsX and savedSpeedsY have same length
        for (let i = 0; i < this._savedSpeedsX.length; i++) {
            speedX += this._savedSpeedsX[i];
            speedY += this._savedSpeedsY[i];
        }
        speedX = this._savedSpeedsX.length ? speedX / this._savedSpeedsX.length : 0;
        speedY = this._savedSpeedsX.length ? speedY / this._savedSpeedsY.length : 0;

        // Use the lastMovedX and lastMovedY to determine if the swipe stops without lifting the finger so we don't start inertia
        if ((Math.abs(speedX) > 0.1 || Math.abs(speedY) > 0.1) &&
            (Math.abs(this._lastMovedX) > 2 || Math.abs(this._lastMovedY) > 2)) {
            this._inertiaInit(speedX, speedY);
        }
        if (this.IgxScrollInertiaDirection === 'vertical') {
            this.preventParentScroll(event, false);
        }
    }

    protected _smoothWheelScroll(delta) {
        this._nextY = this.IgxScrollInertiaScrollContainer.scrollTop;
        this._nextX = this.IgxScrollInertiaScrollContainer.scrollLeft;
        let x = -1;
        let wheelInertialAnimation = null;
        const inertiaWheelStep = () => {
            if (x > 1) {
                cancelAnimationFrame(wheelInertialAnimation);
                return;
            }
            const nextScroll = ((-3 * x * x + 3) * delta * 2) * this.smoothingStep;
            if (this.IgxScrollInertiaDirection === 'vertical') {
                this._nextY += nextScroll;
                this._scrollToY(this._nextY);
            } else {
                this._nextX += nextScroll;
                this._scrollToX(this._nextX);
            }
            //continue the inertia
            x += 0.08 * (1 / this.smoothingDuration);
            wheelInertialAnimation = requestAnimationFrame(inertiaWheelStep);
        };
        wheelInertialAnimation = requestAnimationFrame(inertiaWheelStep);
    }

    protected _inertiaInit(speedX, speedY) {
        const stepModifer = this.inertiaStep;
        const inertiaDuration = this.inertiaDuration;
        let x = 0;
        this._nextX = this.IgxScrollInertiaScrollContainer.scrollLeft;
        this._nextY = this.IgxScrollInertiaScrollContainer.scrollTop;

        // Sets timeout until executing next movement iteration of the inertia
        const inertiaStep = () => {
            if (x > 6) {
                cancelAnimationFrame(this._touchInertiaAnimID);
                return;
            }

            if (Math.abs(speedX) > Math.abs(speedY)) {
                x += 0.05 / (1 * inertiaDuration);
            } else {
                x += 0.05 / (1 * inertiaDuration);
            }

            if (x <= 1) {
                // We use constant quation to determine the offset without speed falloff befor x reaches 1
                if (Math.abs(speedY) <= Math.abs(speedX) * this.inertiaDeltaY) {
                    this._nextX += 1 * speedX * 15 * stepModifer;
                }
                if (Math.abs(speedY) >= Math.abs(speedX) * this.inertiaDeltaX) {
                    this._nextY += 1 * speedY * 15 * stepModifer;
                }
            } else {
                // We use the quation "y = 2 / (x + 0.55) - 0.3" to determine the offset
                if (Math.abs(speedY) <= Math.abs(speedX) * this.inertiaDeltaY) {
                    this._nextX += Math.abs(2 / (x + 0.55) - 0.3) * speedX * 15 * stepModifer;
                }
                if (Math.abs(speedY) >= Math.abs(speedX) * this.inertiaDeltaX) {
                    this._nextY += Math.abs(2 / (x + 0.55) - 0.3) * speedY * 15 * stepModifer;
                }
            }

            // If we have mixed environment we use the default behaviour. i.e. touchscreen + mouse
            this._scrollTo(this._nextX, this._nextY);

            this._touchInertiaAnimID = requestAnimationFrame(inertiaStep);
        };

        // Start inertia and continue it recursively
        this._touchInertiaAnimID = requestAnimationFrame(inertiaStep);
    }

    private calcAxisCoords(target, min, max) {
        if (target === undefined || target < min) {
            target = min;
        } else if (target > max) {
            target = max;
        }

        return target;
    }

    private _scrollTo(destX, destY) {
        // TODO Trigger scrolling event?
        const scrolledX = this._scrollToX(destX);
        const scrolledY = this._scrollToY(destY);

        return { x: scrolledX, y: scrolledY };
    }
    private _scrollToX(dest) {
        this.IgxScrollInertiaScrollContainer.scrollLeft = dest;
    }
    private _scrollToY(dest) {
        this.IgxScrollInertiaScrollContainer.scrollTop = dest;
    }
}

/**
 * @hidden
 */


