import { Comms } from "../../comms";
import { Snapper } from "./Snapper";
import { getColumnCountPerScreen, isRTL, appendVirtualColumnIfNeeded } from "../../helpers/document";
import { easeInOutQuad } from "../../helpers/animation";
import { ModuleName } from "../ModuleLibrary";
import { Locator, LocatorLocations, LocatorText } from "@readium/shared";
import { rangeFromLocator } from "../../helpers/locator";
import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom";
import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
import { BaseSuspiciousActivityEvent } from "../Peripherals";

const COLUMN_SNAPPER_STYLE_ID = "readium-column-snapper-style";

export interface SuspiciousSnappingEvent extends BaseSuspiciousActivityEvent {
    type: "suspicious_snapping";
    event: null;
}

const SNAP_DURATION = 200; // Milliseconds

enum ScrollTouchState {
    END = 0,
    START = 1,
    MOVE = 2
}

/**
 * A {Snapper} for reflowable resources using a column-based layout
 */
export class ColumnSnapper extends Snapper {
    static readonly moduleName: ModuleName = "column_snapper";
    private isSnapProtectionEnabled = false;
    private resizeObserver!: ResizeObserver;
    private mutationObserver!: MutationObserver;
    private patternAnalyzer: PatternAnalyzer | null = null;
    private lastTurnTime: number = 0;
    private wnd!: ReadiumWindow;
    private comms!: Comms;
    private doc() { return this.wnd.document.scrollingElement as HTMLElement; }
    private scrollOffset() {
        // The reason we do this is because when the document is transformed (translate3d),
        // the scrollLeft value is 0 because... reasons. So we have to use the cached value
        // from this.alreadyScrollLeft instead.
        return (this.doc().scrollLeft > 0) ? this.doc().scrollLeft : this.alreadyScrollLeft;
    }

    snapOffset(offset: number) {
        const value = offset + (isRTL(this.wnd) ? -1 : 1);
        return value - (value % this.wnd.innerWidth);
    }

    reportProgress() {
        // Contrary to ScrollTop, Android slightly adds to scrollX
        // So we do not need to round it up
        const scrollX = this.wnd.scrollX;
        const scrollWidth = this.cachedScrollWidth;
        const progress = Math.max(0, Math.min(1, scrollX / scrollWidth));
        const viewportEnd = Math.max(0, Math.min(1, (scrollX + this.wnd.innerWidth) / scrollWidth));
        this.comms.send("progress", {
            start: progress,
            end: viewportEnd
        });
    }

    private shakeTimeout = 0;
    shake() {
        // - If already overscrolling (touchscreen), then shaking on top of it looks ugly
        // - If already shaking, wait until it's finished before allowing another shake
        if(this.overscroll !== 0 || this.shakeTimeout !== 0) return;
        const doc = this.doc();

        doc.classList.add((isRTL(this.wnd) ? "readium-bounce-l" : "readium-bounce-r"));
        const curScrollLeft = this.scrollOffset();
        this.shakeTimeout = this.wnd.setTimeout(() => {
            doc.classList.remove("readium-bounce-l");
            doc.classList.remove("readium-bounce-r");
            this.shakeTimeout = 0;
            this.doc().scrollLeft = curScrollLeft;
        }, 150);
    }

    private snappingCancelled = false;
    private alreadyScrollLeft = 0;
    private overscroll = 0;
    private cachedScrollWidth = 0; // We have to cache this because during overscroll (transform, or left) the width is incorrect due to browser
    
    private takeOverSnap() {
        this.snappingCancelled = true;
        this.clearTouches();
        const doc = this.doc();

        // translate3d(XXXpx, 0px, 0px) -> slice 12 -> XXXpx, 0px, 0px) -> split "px" [0] -> XXX
        this.overscroll = doc.style.transform?.length > 12 ? parseFloat(doc.style.transform.slice(12).split("px")[0]) : 0;
    }
    
    // Snaps the current offset to the page width.
    snapCurrentOffset(smooth=false, noprogress=false) {
        const startX = this.wnd.scrollX > 0 ? this.wnd.scrollX : this.alreadyScrollLeft;
        const doc = this.doc();
        const cdo = this.dragOffset();
        const columnCount = getColumnCountPerScreen(this.wnd);
        const currentOffset = Math.min(Math.max(0, startX), this.cachedScrollWidth);
        const factor = isRTL(this.wnd) ? -1 : 1;
        const hurdle =
            // The hurdle to overcome in order to change pages
            factor *
            (this.wnd.innerWidth / 3) *
            ((factor * cdo) > 0 ? 2 : 1);

        const so = this.snapOffset(currentOffset + hurdle);
        
        const direction = so > this.scrollOffset() ? "right" : "left";

        this.checkSuspiciousSnap(direction, Math.abs(so - this.scrollOffset()));
        
        if(smooth && so !== this.scrollOffset()) { // Smooth snapping
            this.snappingCancelled = false;
            const position = (start: number, end: number, elapsed: number, period: number) => {
                if (elapsed > period) {
                    return end;
                }
                return start + (end - start) * easeInOutQuad(elapsed / period);
            }
            const period = /*Math.abs(startX - (this.useTransform ? currentOffset : 0)) < 10 ? 1 : */SNAP_DURATION * columnCount; // TODO revamp
            let startTime: number;
            const step = (timestamp: number) => {
                if(this.snappingCancelled) return;

                if (!startTime) startTime = timestamp;
                const elapsed = timestamp - startTime;

                const lpos = position(this.overscroll, 0, elapsed, period);
                const spos = position(startX, so, elapsed, period);
                doc.scrollLeft = spos;
                if(this.overscroll !== 0)
                    doc.style.transform = `translate3d(${-lpos}px, 0px, 0px)`;

                if (elapsed < period)
                    this.wnd.requestAnimationFrame(step);
                else {
                    this.clearTouches();
                    doc.style.removeProperty("transform");
                    doc.scrollLeft = so;
                    if(!noprogress) this.reportProgress();
                }
            }
            this.wnd.requestAnimationFrame(step);
        } else { // Instant snapping
            doc.style.removeProperty("transform");
            this.wnd.requestAnimationFrame(() => {
                doc.scrollLeft = so;
                this.clearTouches();
                if(!noprogress) this.reportProgress();
            });
        }
    }

    // Current touch state cycler, to assist with swipe detection etc.
    private touchState: ScrollTouchState = ScrollTouchState.END;
    private startingX: number | undefined = undefined;
    private endingX: number | undefined = undefined;
    private dragOffset() { return (this.startingX ?? 0) - (this.endingX ?? 0); }
    private clearTouches() {
        this.startingX = undefined; this.endingX = undefined;
        this.overscroll = 0;
        // this.doc().style.removeProperty("will-change");
    }

    onTouchStart(e: TouchEvent) {
        e.stopPropagation();
        this.takeOverSnap();
        switch (e.touches.length) {
            case 1: // Single finger - handle it
                break;
            case 2: // Pinch - abort
                this.onTouchEnd(e);
                return;
            default: { // More fingers - abort, notify
                this.onTouchEnd(e);
                this.comms.send("tap_more", e.touches.length);
                return;
            }
        }

        // this.doc().style.willChange = "transform, scroll-position";
        this.startingX = e.touches[0].clientX;
        this.alreadyScrollLeft = this.doc().scrollLeft;
        this.touchState = ScrollTouchState.START;
    }
    private readonly onTouchStarter = this.onTouchStart.bind(this);

    onTouchEnd(_: TouchEvent) {
        if(this.touchState === ScrollTouchState.MOVE) {
            // Get the horizontal drag distance
            const dragOffset = this.dragOffset();
            const scrollOffset = this.scrollOffset();
            // this.cachedScrollWidth = this.doc().scrollWidth!;
            if(this.cachedScrollWidth <= this.wnd.innerWidth) {
                // Only a single page, meaning any swipe triggers next/prev
                this.reportProgress();
                if(dragOffset > 5) this.comms.send("no_more", undefined);
                if(dragOffset < -5) this.comms.send("no_less", undefined);
            } else if(scrollOffset < 5 && dragOffset < 5) {
                this.alreadyScrollLeft = 0;
                this.comms.send("no_less", undefined);
            } else if((this.cachedScrollWidth - scrollOffset - this.wnd.innerWidth) < 5 && dragOffset > 5) {
                this.alreadyScrollLeft = this.cachedScrollWidth;
                this.comms.send("no_more", undefined);
            }

            this.snapCurrentOffset(true);
            this.comms.send("swipe", dragOffset);
        }
        this.touchState = ScrollTouchState.END;
    }
    private readonly onTouchEnder = this.onTouchEnd.bind(this);

    private onWidthChange() {
        this.cachedScrollWidth = this.doc().scrollWidth!;
        if(this.comms.ready)
            // This function can be called while the frame is still hidden
            // so it should only be snapped if it's actually active because
            // it sends a comms message to update progress.
            this.snapCurrentOffset();
    }
    private readonly onWidthChanger = this.onWidthChange.bind(this);

    onTouchMove(e: TouchEvent) {
        if(this.touchState === ScrollTouchState.END) return;
        if(this.touchState === ScrollTouchState.START) {
            this.touchState = ScrollTouchState.MOVE;
            deselect(this.wnd);
        }
        this.endingX = e.touches[0].clientX;

        const dro = this.dragOffset();
        const newpos = this.alreadyScrollLeft + dro;
        if(newpos < 0) {
            this.overscroll = newpos;
            this.doc().style.transform = `translate3d(${-this.overscroll}px, 0px, 0px)`;
        } else if((newpos + this.wnd.innerWidth) > this.cachedScrollWidth) {
            this.overscroll = newpos;
            this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`;
        } else {
            this.overscroll = 0;
            this.doc().style.removeProperty("transform");
            this.doc().scrollLeft = this.alreadyScrollLeft + dro;
        }
    }
    private readonly onTouchMover = this.onTouchMove.bind(this);

    private enableSnapProtection() {
        if (!this.patternAnalyzer) {
            this.patternAnalyzer = new PatternAnalyzer({
                maxVelocity: this.wnd.innerWidth,  // page width
                minVariance: 0.1,   // Allow for some variation in swipe speed
                historySize: 5,      // Fewer samples needed for swipe detection
                maxConsistentScrolls: 3,  // Lower threshold for consistent scrolling
                minDirectionChanges: 0.3  // Require more direction changes
            });
            this.isSnapProtectionEnabled = true;
            this.comms?.log("Snap protection enabled");
        }
    }

    private checkSuspiciousSnap(direction: "left" | "right", distance: number) {
        if (!this.isSnapProtectionEnabled || !this.patternAnalyzer) return;

        const now = Date.now();
        const timeDelta = now - (this.lastTurnTime || now);
        this.lastTurnTime = now;

        const isSuspicious = this.patternAnalyzer.analyze(direction, distance, timeDelta);
        if (isSuspicious) {
            this.comms?.send("content_protection", {
                type: "suspicious_snapping",
                timestamp: Date.now(),
                event: null
            } as SuspiciousSnappingEvent);
        }
    }

    mount(wnd: ReadiumWindow, comms: Comms): boolean {
        this.wnd = wnd;
        this.comms = comms;
        if(!super.mount(wnd, comms)) return false;

        wnd.navigator.epubReadingSystem && (wnd.navigator.epubReadingSystem.layoutStyle = "paginated");

        // Add styling to hide the scrollbar
        const d = wnd.document.createElement("style");
        d.dataset.readium = "true";
        d.id = COLUMN_SNAPPER_STYLE_ID;
        d.textContent = `
        @keyframes readium-bounce-l-animation {
            0%, 100% {transform: translate3d(0, 0, 0);}
            50% {transform: translate3d(-50px, 0, 0);}
        }

        @keyframes readium-bounce-r-animation {
            0%, 100% {transform: translate3d(0, 0, 0);}
            50% {transform: translate3d(50px, 0, 0);}
        }

        .readium-bounce-l {
            animation: readium-bounce-l-animation 150ms ease-out 1;
        }

        .readium-bounce-r {
            animation: readium-bounce-r-animation 150ms ease-out 1;
        }

        html {
            overflow: hidden;
        }

        body {
            -ms-overflow-style: none; /* for Internet Explorer, Edge */
        }

        * {
            scrollbar-width: none; /* for Firefox */
        }
        
        body::-webkit-scrollbar {
            display: none; /* for Chrome, Safari, and Opera */
        }
        `;
        wnd.document.head.appendChild(d);

        this.resizeObserver = new ResizeObserver(() => {
            wnd.requestAnimationFrame(() => {
                wnd && appendVirtualColumnIfNeeded(wnd);
            });
            this.onWidthChange();
        });
        this.resizeObserver.observe(wnd.document.body);

        this.mutationObserver = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                // We have to check it’s not onTouchMove + snapOffset setting transforms
                if (mutation.target === this.wnd.document.documentElement) {
                    const oldValue = mutation.oldValue as string;
                    const newValue = (mutation.target as HTMLElement).getAttribute("style") as string;
                    const transformRegex = /transform\s*:\s*([^;]+)/;
                    const oldValueTransform = oldValue?.match(transformRegex);
                    const newValueTransform = newValue?.match(transformRegex);
                    if (
                        (!oldValueTransform && !newValueTransform) || 
                        (oldValueTransform && !newValueTransform) ||
                        (oldValueTransform && newValueTransform && oldValueTransform[1] !== newValueTransform[1])
                    ) {
                        wnd.requestAnimationFrame(() => {
                            wnd && appendVirtualColumnIfNeeded(wnd);
                        });
                        this.onWidthChange();
                    }
                } else {
                    wnd.requestAnimationFrame(() => this.cachedScrollWidth = this.doc().scrollWidth!);
                }
            }
        });
        wnd.frameElement && this.mutationObserver.observe(wnd.frameElement, {attributes: true, attributeFilter: ["style"]});
        this.mutationObserver.observe(wnd.document, {attributes: true, attributeFilter: ["style"]});
        // For cases the resizeObserver is not able to detect cos body is not resizing despite colCount,
        // we need to check the syle attribute on the documentElement (ReadiumCSS props)
        this.mutationObserver.observe(wnd.document.documentElement, {attributes: true, attributeFilter: ["style"]});
        
        comms.register("scroll_protection", ColumnSnapper.moduleName, (_, ack) => {
            this.enableSnapProtection();
            ack(true);
        });

        const scrollToOffset = (offset: number): boolean => {
            const oldScrollLeft = this.doc().scrollLeft;
            this.doc().scrollLeft = this.snapOffset(offset); // TODO assert if never undefined (same for rest of !)

            return oldScrollLeft !== this.doc().scrollLeft;
        }

        wnd.addEventListener("orientationchange", this.onWidthChanger);
        wnd.addEventListener("resize", this.onWidthChanger);
        wnd.requestAnimationFrame(() => this.cachedScrollWidth = this.doc().scrollWidth!);

        comms.register("go_progression", ColumnSnapper.moduleName, (data, ack) => {
            const position = data as number;
            if (position < 0 || position > 1) {
                comms.send("error", {
                    message: "go_progression must be given a position from 0.0 to 1.0"
                });
                ack(false);
                return;
            }
            this.wnd.requestAnimationFrame(() => {
                this.cachedScrollWidth = this.doc().scrollWidth!;
                const documentWidth = this.cachedScrollWidth;
                const factor = isRTL(wnd) ? -1 : 1;
                const offset = documentWidth * position * factor;
                this.doc().scrollLeft = this.snapOffset(offset);
                this.reportProgress();
                deselect(this.wnd);
                ack(true);
            });
        })

        comms.register("go_id", ColumnSnapper.moduleName, (data, ack) => {
            const element = wnd.document.getElementById(data as string);
            if(!element) {
                ack(false);
                return;
            }
            this.wnd.requestAnimationFrame(() => {
                this.doc().scrollLeft = this.snapOffset(element.getBoundingClientRect().left + wnd.scrollX);
                this.reportProgress();
                deselect(this.wnd);
                ack(true);
            });
        });

        comms.register("go_text", ColumnSnapper.moduleName, (data: unknown | unknown[], ack) => {
            let cssSelector = undefined;
            if(Array.isArray(data)) {
                if(data.length > 1)
                    // Second element is presumed to be the CSS selector
                    cssSelector = (data as unknown[])[1] as string;
                data = data[0]; // First element will always be the locator text object
            }
            const text = LocatorText.deserialize(data);
            const r = rangeFromLocator(this.wnd.document, new Locator({
                href: wnd.location.href,
                type: "text/html",
                text,
                locations: cssSelector ? new LocatorLocations({
                    otherLocations: new Map([
                        ["cssSelector", cssSelector]
                    ])
                }) : undefined
            }));
            if(!r) {
                ack(false);
                return;
            }
            this.wnd.requestAnimationFrame(() => {
                this.doc().scrollLeft = this.snapOffset(r.getBoundingClientRect().left + wnd.scrollX);
                this.reportProgress();
                deselect(this.wnd);
                ack(true);
            });
        });

        comms.register("go_end", ColumnSnapper.moduleName, (_, ack) => {
            const factor = isRTL(wnd) ? -1 : 1;
            this.wnd.requestAnimationFrame(() => {
                this.cachedScrollWidth = this.doc().scrollWidth!;
                const final = this.cachedScrollWidth * factor;
                if(this.doc().scrollLeft === final) return ack(false);
                this.doc().scrollLeft = this.snapOffset(final);
                this.reportProgress();
                deselect(this.wnd);
                ack(true);
            });
        })

        comms.register("go_start", ColumnSnapper.moduleName, (_, ack) => {
            this.wnd.requestAnimationFrame(() => {
                if(this.doc().scrollLeft === 0) return ack(false);
                this.doc().scrollLeft = 0;
                this.reportProgress();
                deselect(this.wnd);
                ack(true);
            });
        })

        comms.register("go_prev", ColumnSnapper.moduleName, (_, ack) => {
            this.wnd.requestAnimationFrame(() => {
                this.cachedScrollWidth = this.doc().scrollWidth!;
                const offset = wnd.scrollX - wnd.innerWidth;
                const minOffset = isRTL(wnd) ? - (this.cachedScrollWidth - wnd.innerWidth) : 0;
                const change = scrollToOffset(Math.max(offset, minOffset));
                if(change) {
                    this.reportProgress();
                    deselect(this.wnd);
                    this.checkSuspiciousSnap("left", this.wnd.innerWidth);
                }
                ack(change);
            });
        });

        comms.register("go_next", ColumnSnapper.moduleName, (_, ack) => {
            this.wnd.requestAnimationFrame(() => {
                this.cachedScrollWidth = this.doc().scrollWidth!;
                const offset = wnd.scrollX + wnd.innerWidth;
                const maxOffset = isRTL(wnd) ? 0 : this.cachedScrollWidth - wnd.innerWidth;
                const change = scrollToOffset(Math.min(offset, maxOffset));
                if(change) {
                    this.reportProgress();
                    deselect(this.wnd);
                    this.checkSuspiciousSnap("right", this.wnd.innerWidth);
                }
                ack(change);
            });
        });

        comms.register("unfocus", ColumnSnapper.moduleName, (_, ack) => {
            this.snappingCancelled = true;
            deselect(this.wnd);
            ack(true);
        });

        comms.register("shake", ColumnSnapper.moduleName, (_, ack) => {
            this.shake();
            ack(true);
        });

        comms.register("focus", ColumnSnapper.moduleName, (_, ack) => {
            this.wnd.requestAnimationFrame(() => {
                this.cachedScrollWidth = this.doc().scrollWidth!;
                this.snapCurrentOffset(false, true);
                this.reportProgress();
                ack(true);
            });
        });

        comms.register("first_visible_locator", ColumnSnapper.moduleName, (_, ack) => {
            const locator = findFirstVisibleLocator(wnd as ReadiumWindow, false);
            this.comms.send("first_visible_locator", locator.serialize());
            ack(true);
        });

        // Add interaction listeners
        wnd.addEventListener("touchstart", this.onTouchStarter, { passive: true });
        wnd.addEventListener("touchend", this.onTouchEnder, { passive: true });
        wnd.addEventListener("touchmove", this.onTouchMover, { passive: true });

        // Safari hack, otherwise other events won't register
        wnd.document.addEventListener("touchstart", () => {});

        comms.log("ColumnSnapper Mounted");
        return true;
    }

    unmount(wnd: ReadiumWindow, comms: Comms): boolean {
        this.snappingCancelled = true;
        comms.unregisterAll(ColumnSnapper.moduleName);
        this.resizeObserver.disconnect();
        this.mutationObserver.disconnect();

        if (this.patternAnalyzer) {
            this.patternAnalyzer.clear();
            this.patternAnalyzer = null;
            this.isSnapProtectionEnabled = false;
        }

        wnd.removeEventListener("touchstart", this.onTouchStarter);
        wnd.removeEventListener("touchend", this.onTouchEnder);
        wnd.removeEventListener("touchmove", this.onTouchMover);

        wnd.removeEventListener("orientationchange", this.onWidthChanger);
        wnd.removeEventListener("resize", this.onWidthChanger);

        wnd.document.getElementById(COLUMN_SNAPPER_STYLE_ID)?.remove();

        comms.log("ColumnSnapper Unmounted");
        return super.unmount(wnd, comms);
    }
}