/*
 * Copyright 2016 Palantir Technologies, Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import type { ClientCoordinates, CoordinateData, DragHandler } from "./dragTypes";

export class DragEvents {
    public static DOUBLE_CLICK_TIMEOUT_MSEC = 500;

    /**
     * Returns true if the event includes a modifier key that often adds the result of the drag
     * event to any existing state. For example, holding CTRL before dragging may select another
     * region in addition to an existing one, while the absence of a modifier key may clear the
     * existing selection first.
     *
     * @param event the mouse event for the drag interaction
     */
    public static isAdditive(event: MouseEvent) {
        return event.ctrlKey || event.metaKey;
    }

    private handler?: DragHandler;

    private element?: HTMLElement;

    private activationCoordinates?: ClientCoordinates;

    private doubleClickTimeoutToken?: number;

    private isActivated: boolean = false;

    private isDragging: boolean = false;

    private lastCoordinates?: ClientCoordinates;

    public attach(element: HTMLElement, handler: DragHandler) {
        this.detach();
        this.handler = handler;
        this.element = element;

        if (this.isValidDragHandler(handler)) {
            this.element.addEventListener("mousedown", this.handleMouseDown);
        }
        return this;
    }

    public detach() {
        if (this.element != null) {
            this.element.removeEventListener("mousedown", this.handleMouseDown);
            this.detachDocumentEventListeners();
        }
    }

    private isValidDragHandler(handler: DragHandler) {
        return (
            handler != null &&
            (handler.onActivate != null ||
                handler.onDragMove != null ||
                handler.onDragEnd != null ||
                handler.onClick != null ||
                handler.onDoubleClick != null)
        );
    }

    private attachDocumentEventListeners() {
        document.addEventListener("mousemove", this.handleMouseMove);
        document.addEventListener("mouseup", this.handleMouseUp);
    }

    private detachDocumentEventListeners() {
        document.removeEventListener("mousemove", this.handleMouseMove);
        document.removeEventListener("mouseup", this.handleMouseUp);
    }

    private initCoordinateData(event: MouseEvent) {
        this.activationCoordinates = [event.clientX, event.clientY];
        this.lastCoordinates = this.activationCoordinates;
    }

    private updateCoordinateData(event: MouseEvent) {
        if (this.activationCoordinates === undefined) {
            // invalid state; we should have activation by this point
            return undefined;
        }

        const currentCoordinates: [number, number] = [event.clientX, event.clientY];
        const lastCoordinates = this.lastCoordinates ?? [0, 0];
        const deltaCoordinates: [number, number] = [
            currentCoordinates[0] - lastCoordinates[0],
            currentCoordinates[1] - lastCoordinates[1],
        ];
        const offsetCoordinates: [number, number] = [
            currentCoordinates[0] - this.activationCoordinates[0],
            currentCoordinates[1] - this.activationCoordinates[1],
        ];
        const data: CoordinateData = {
            activation: this.activationCoordinates,
            current: currentCoordinates,
            delta: deltaCoordinates,
            last: lastCoordinates,
            offset: offsetCoordinates,
        };
        this.lastCoordinates = [event.clientX, event.clientY];
        return data;
    }

    private maybeAlterEventChain(event: MouseEvent) {
        if (this.handler?.preventDefault) {
            event.preventDefault();
        }
        if (this.handler?.stopPropagation) {
            event.stopPropagation();
        }
    }

    private handleMouseDown = (event: MouseEvent) => {
        this.initCoordinateData(event);

        if (this.handler != null && this.handler.onActivate != null) {
            const exitCode = this.handler.onActivate(event);
            if (exitCode === false) {
                return;
            }
        }

        this.isActivated = true;
        this.maybeAlterEventChain(event);

        // It is possible that the mouseup would not be called after the initial
        // mousedown (for example if the mouse is moved out of the window). So,
        // we preemptively detach to avoid duplicate listeners.
        this.detachDocumentEventListeners();
        this.attachDocumentEventListeners();
    };

    private handleMouseMove = (event: MouseEvent) => {
        this.maybeAlterEventChain(event);

        if (this.isActivated) {
            this.isDragging = true;
        }

        if (this.isDragging) {
            const coords = this.updateCoordinateData(event);
            if (coords !== undefined) {
                this.handler?.onDragMove?.(event, coords);
            }
        }
    };

    private handleMouseUp = (event: MouseEvent) => {
        this.maybeAlterEventChain(event);

        if (this.handler != null) {
            if (this.isDragging) {
                const coords = this.updateCoordinateData(event);
                if (coords !== undefined) {
                    this.handler?.onDragMove?.(event, coords);
                    this.handler?.onDragEnd?.(event, coords);
                }
            } else if (this.isActivated) {
                if (this.handler.onDoubleClick != null) {
                    if (this.doubleClickTimeoutToken == null) {
                        // if this the first click of a possible double-click,
                        // we delay the firing of the click event by the
                        // timeout.
                        this.doubleClickTimeoutToken = window.setTimeout(() => {
                            delete this.doubleClickTimeoutToken;
                            this.handler?.onClick?.(event);
                        }, DragEvents.DOUBLE_CLICK_TIMEOUT_MSEC);
                    } else {
                        // otherwise, this is the second click in the double-
                        // click so we cancel the single-click timeout and
                        // fire the double-click event.
                        window.clearTimeout(this.doubleClickTimeoutToken);
                        delete this.doubleClickTimeoutToken;
                        this.handler.onDoubleClick(event);
                    }
                } else if (this.handler.onClick != null) {
                    this.handler.onClick(event);
                }
            }
        }

        this.isActivated = false;
        this.isDragging = false;
        this.detachDocumentEventListeners();
    };
}
