import BinarySearch from "../utils/BinarySearch";
import { Dimension } from "./dependencies/LayoutProvider";
import { Layout } from "./layoutmanager/LayoutManager";
/***
 * Given an offset this utility can compute visible items. Also tracks previously visible items to compute items which get hidden or visible
 * Virtual renderer uses callbacks from this utility to main recycle pool and the render stack.
 * The utility optimizes finding visible indexes by using the last visible items. However, that can be slow if scrollToOffset is explicitly called.
 * We use binary search to optimize in most cases like while finding first visible item or initial offset. In future we'll also be using BS to speed up
 * scroll to offset.
 */
export interface Range {
    start: number;
    end: number;
}

export interface WindowCorrection {
    windowShift: number;
    startCorrection: number;
    endCorrection: number;
}

export type TOnItemStatusChanged = ((all: number[], now: number[], notNow: number[]) => void);

export default class ViewabilityTracker {
    public onVisibleRowsChanged: TOnItemStatusChanged | null;
    public onEngagedRowsChanged: TOnItemStatusChanged | null;

    private _currentOffset: number;
    private _maxOffset: number;
    private _renderAheadOffset: number;
    private _visibleWindow: Range;
    private _engagedWindow: Range;
    private _relevantDim: Range;
    private _isHorizontal: boolean;
    private _windowBound: number;
    private _visibleIndexes: number[];
    private _engagedIndexes: number[];
    private _layouts: Layout[] = [];
    private _actualOffset: number;
    private _defaultCorrection: WindowCorrection;

    constructor(renderAheadOffset: number, initialOffset: number) {
        this._currentOffset = Math.max(0, initialOffset);
        this._maxOffset = 0;
        this._actualOffset = 0;
        this._renderAheadOffset = renderAheadOffset;
        this._visibleWindow = { start: 0, end: 0 };
        this._engagedWindow = { start: 0, end: 0 };

        this._isHorizontal = false;
        this._windowBound = 0;

        this._visibleIndexes = [];  //needs to be sorted
        this._engagedIndexes = [];  //needs to be sorted

        this.onVisibleRowsChanged = null;
        this.onEngagedRowsChanged = null;

        this._relevantDim = { start: 0, end: 0 };
        this._defaultCorrection = { startCorrection: 0, endCorrection: 0, windowShift: 0 };
    }

    public init(windowCorrection: WindowCorrection): void {
        this._doInitialFit(this._currentOffset, windowCorrection);
    }

    public setLayouts(layouts: Layout[], maxOffset: number): void {
        this._layouts = layouts;
        this._maxOffset = maxOffset;
    }

    public setDimensions(dimension: Dimension, isHorizontal: boolean): void {
        this._isHorizontal = isHorizontal;
        this._windowBound = isHorizontal ? dimension.width : dimension.height;
    }

    public forceRefresh(): boolean {
        const shouldForceScroll = this._actualOffset >= 0 && this._currentOffset >= (this._maxOffset - this._windowBound);
        this.forceRefreshWithOffset(this._currentOffset);
        return shouldForceScroll;
    }

    public forceRefreshWithOffset(offset: number): void {
        this._currentOffset = -1;
        this.updateOffset(offset, false, this._defaultCorrection);
    }

    public updateOffset(offset: number, isActual: boolean, windowCorrection: WindowCorrection): void {
        let correctedOffset = offset;
        if (isActual) {
            this._actualOffset = offset;
            correctedOffset = Math.min(this._maxOffset, Math.max(0,
                offset + (windowCorrection.windowShift + windowCorrection.startCorrection)));
        }

        if (this._currentOffset !== correctedOffset) {
            this._currentOffset = correctedOffset;
            this._updateTrackingWindows(offset, windowCorrection);
            let startIndex = 0;
            if (this._visibleIndexes.length > 0) {
                startIndex = this._visibleIndexes[0];
            }
            this._fitAndUpdate(startIndex);
        }
    }

    public getLastOffset(): number {
        return this._currentOffset;
    }

    public getLastActualOffset(): number {
        return this._actualOffset;
    }

    public getEngagedIndexes(): number[] {
        return this._engagedIndexes;
    }

    public findFirstLogicallyVisibleIndex(): number {
        const relevantIndex = this._findFirstVisibleIndexUsingBS(0.001);
        let result = relevantIndex;
        for (let i = relevantIndex - 1; i >= 0; i--) {
            if (this._isHorizontal) {
                if (this._layouts[relevantIndex].x !== this._layouts[i].x) {
                    break;
                } else {
                    result = i;
                }
            } else {
                if (this._layouts[relevantIndex].y !== this._layouts[i].y) {
                    break;
                } else {
                    result = i;
                }
            }
        }
        return result;
    }

    public updateRenderAheadOffset(renderAheadOffset: number): void {
        this._renderAheadOffset = Math.max(0, renderAheadOffset);
        this.forceRefreshWithOffset(this._currentOffset);
    }

    public getCurrentRenderAheadOffset(): number {
        return this._renderAheadOffset;
    }
    public setActualOffset(actualOffset: number): void {
       this._actualOffset = actualOffset;
    }

    private _findFirstVisibleIndexOptimally(): number {
        let firstVisibleIndex = 0;

        //TODO: Talha calculate this value smartly
        if (this._currentOffset > 5000) {
            firstVisibleIndex = this._findFirstVisibleIndexUsingBS();
        } else if (this._currentOffset > 0) {
            firstVisibleIndex = this._findFirstVisibleIndexLinearly();
        }
        return firstVisibleIndex;
    }

    private _fitAndUpdate(startIndex: number): void {
        const newVisibleItems: number[] = [];
        const newEngagedItems: number[] = [];
        this._fitIndexes(newVisibleItems, newEngagedItems, startIndex, true);
        this._fitIndexes(newVisibleItems, newEngagedItems, startIndex + 1, false);
        this._diffUpdateOriginalIndexesAndRaiseEvents(newVisibleItems, newEngagedItems);
    }

    private _doInitialFit(offset: number, windowCorrection: WindowCorrection): void {
        offset = Math.min(this._maxOffset, Math.max(0, offset));
        this._updateTrackingWindows(offset, windowCorrection);
        const firstVisibleIndex = this._findFirstVisibleIndexOptimally();
        this._fitAndUpdate(firstVisibleIndex);
    }

    //TODO:Talha switch to binary search and remove atleast once logic in _fitIndexes
    private _findFirstVisibleIndexLinearly(): number {
        const count = this._layouts.length;
        let itemRect = null;
        const relevantDim = { start: 0, end: 0 };

        for (let i = 0; i < count; i++) {
            itemRect = this._layouts[i];
            this._setRelevantBounds(itemRect, relevantDim);
            if (this._itemIntersectsVisibleWindow(relevantDim.start, relevantDim.end)) {
                return i;
            }
        }
        return 0;
    }

    private _findFirstVisibleIndexUsingBS(bias = 0): number {
        const count = this._layouts.length;
        return BinarySearch.findClosestHigherValueIndex(count, this._visibleWindow.start + bias, this._valueExtractorForBinarySearch);
    }

    private _valueExtractorForBinarySearch = (index: number): number => {
        const itemRect = this._layouts[index];
        this._setRelevantBounds(itemRect, this._relevantDim);
        return this._relevantDim.end;
    }

    //TODO:Talha Optimize further in later revisions, alteast once logic can be replace with a BS lookup
    private _fitIndexes(newVisibleIndexes: number[], newEngagedIndexes: number[], startIndex: number, isReverse: boolean): void {
        const count = this._layouts.length;
        const relevantDim: Range = { start: 0, end: 0 };
        let i = 0;
        let atLeastOneLocated = false;
        if (startIndex < count) {
            if (!isReverse) {
                for (i = startIndex; i < count; i++) {
                    if (this._checkIntersectionAndReport(i, false, relevantDim, newVisibleIndexes, newEngagedIndexes)) {
                        atLeastOneLocated = true;
                    } else {
                        if (atLeastOneLocated) {
                            break;
                        }
                    }
                }
            } else {
                for (i = startIndex; i >= 0; i--) {
                    if (this._checkIntersectionAndReport(i, true, relevantDim, newVisibleIndexes, newEngagedIndexes)) {
                        atLeastOneLocated = true;
                    } else {
                        if (atLeastOneLocated) {
                            break;
                        }
                    }
                }
            }
        }
    }

    private _checkIntersectionAndReport(index: number,
                                        insertOnTop: boolean,
                                        relevantDim: Range,
                                        newVisibleIndexes: number[],
                                        newEngagedIndexes: number[]): boolean {
        const itemRect = this._layouts[index];
        let isFound = false;
        this._setRelevantBounds(itemRect, relevantDim);
        if (this._itemIntersectsVisibleWindow(relevantDim.start, relevantDim.end)) {
            if (insertOnTop) {
                newVisibleIndexes.splice(0, 0, index);
                newEngagedIndexes.splice(0, 0, index);
            } else {
                newVisibleIndexes.push(index);
                newEngagedIndexes.push(index);
            }
            isFound = true;
        } else if (this._itemIntersectsEngagedWindow(relevantDim.start, relevantDim.end)) {
            //TODO: This needs to be optimized
            if (insertOnTop) {
                newEngagedIndexes.splice(0, 0, index);
            } else {
                newEngagedIndexes.push(index);

            }
            isFound = true;
        }
        return isFound;
    }

    private _setRelevantBounds(itemRect: Layout, relevantDim: Range): void {
        if (this._isHorizontal) {
            relevantDim.end = itemRect.x + itemRect.width;
            relevantDim.start = itemRect.x;
        } else {
            relevantDim.end = itemRect.y + itemRect.height;
            relevantDim.start = itemRect.y;
        }
    }

    private _isItemInBounds(window: Range, itemBound: number): boolean {
        return (window.start < itemBound && window.end > itemBound);
    }

    private _isItemBoundsBeyondWindow(window: Range, startBound: number, endBound: number): boolean {
        return (window.start >= startBound && window.end <= endBound);
    }

    private _isZeroHeightEdgeElement(window: Range, startBound: number, endBound: number): boolean {
        return startBound - endBound === 0 && (window.start === startBound || window.end === endBound);
    }

    private _itemIntersectsWindow(window: Range, startBound: number, endBound: number): boolean {
        return this._isItemInBounds(window, startBound) ||
            this._isItemInBounds(window, endBound) ||
            this._isItemBoundsBeyondWindow(window, startBound, endBound) ||
            this._isZeroHeightEdgeElement(window, startBound, endBound);
    }

    private _itemIntersectsEngagedWindow(startBound: number, endBound: number): boolean {
        return this._itemIntersectsWindow(this._engagedWindow, startBound, endBound);
    }

    private _itemIntersectsVisibleWindow(startBound: number, endBound: number): boolean {
        return this._itemIntersectsWindow(this._visibleWindow, startBound, endBound);
    }

    private _updateTrackingWindows(offset: number, correction: WindowCorrection): void {
        const startCorrection = correction.windowShift + correction.startCorrection;
        const bottomCorrection = correction.windowShift + correction.endCorrection;

        const startOffset = offset + startCorrection;
        const endOffset = (offset + this._windowBound) + bottomCorrection;

        this._engagedWindow.start = Math.max(0, startOffset - this._renderAheadOffset);
        this._engagedWindow.end = endOffset + this._renderAheadOffset;

        this._visibleWindow.start = startOffset;
        this._visibleWindow.end = endOffset;
    }

    //TODO:Talha optimize this
    private _diffUpdateOriginalIndexesAndRaiseEvents(newVisibleItems: number[], newEngagedItems: number[]): void {
        this._diffArraysAndCallFunc(newVisibleItems, this._visibleIndexes, this.onVisibleRowsChanged);
        this._diffArraysAndCallFunc(newEngagedItems, this._engagedIndexes, this.onEngagedRowsChanged);
        this._visibleIndexes = newVisibleItems;
        this._engagedIndexes = newEngagedItems;
    }

    private _diffArraysAndCallFunc(newItems: number[], oldItems: number[], func: TOnItemStatusChanged | null): void {
        if (func) {
            const now = this._calculateArrayDiff(newItems, oldItems);
            const notNow = this._calculateArrayDiff(oldItems, newItems);
            if (now.length > 0 || notNow.length > 0) {
                func([...newItems], now, notNow);
            }
        }
    }

    //TODO:Talha since arrays are sorted this can be much faster
    private _calculateArrayDiff(arr1: number[], arr2: number[]): number[] {
        const len = arr1.length;
        const diffArr = [];
        for (let i = 0; i < len; i++) {
            if (BinarySearch.findIndexOf(arr2, arr1[i]) === -1) {
                diffArr.push(arr1[i]);
            }
        }
        return diffArr;
    }
}
