/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.  See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership.  The ASF licenses this file
* to you 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 Eventful from 'zrender/src/core/Eventful';
import * as eventTool from 'zrender/src/core/event';
import * as interactionMutex from './interactionMutex';
import { ZRenderType } from 'zrender/src/zrender';
import { ZRElementEvent, RoamOptionMixin, NullUndefined } from '../../util/types';
import { Bind3, isString, bind, defaults, extend, retrieve2 } from 'zrender/src/core/util';
import { makeInner } from '../../util/model';
import { retrieveZInfo } from '../../util/graphic';
import type Component from '../../model/Component';
import ExtensionAPI from '../../core/ExtensionAPI';
import { onIrrelevantElement } from './cursorHelper';
import Displayable from 'zrender/src/graphic/Displayable';


export interface RoamOption {
    zInfo: {
        // mandatory to provide z and zlevel and pointer checker criteria.
        component: Component
        z2?: number
    }
    triggerInfo: {
        roamTrigger: RoamOptionMixin['roamTrigger'] | NullUndefined
        // At present in all scenarios it can be supported.
        isInSelf: RoamPointerChecker
        // Required if clipping is supported
        isInClip?: RoamPointerChecker
    }
    api: ExtensionAPI

    zoomOnMouseWheel?: boolean | 'ctrl' | 'shift' | 'alt'
    moveOnMouseMove?: boolean | 'ctrl' | 'shift' | 'alt'
    moveOnMouseWheel?: boolean | 'ctrl' | 'shift' | 'alt'
    /**
     * If fixed the page when pan
     */
    preventDefaultMouseMove?: boolean

    /**
     * Cursor styles
     */
    cursorGrab?: string // 'grab' by default.
    cursorGrabbing?: string // 'grabbing' by default.
}
type RoamSetting = Omit<Required<RoamOption>, 'zInfo'> & {
    zInfoParsed: {
        component: Component
        z: number
        zlevel: number
        z2: number
    }
};

type RoamEventType = keyof RoamEventParams;

type RoamBehavior = 'zoomOnMouseWheel' | 'moveOnMouseMove' | 'moveOnMouseWheel';

export interface RoamEventParams {
    'zoom': {
        scale: number
        originX: number
        originY: number

        isAvailableBehavior: Bind3<typeof isAvailableBehavior, null, RoamBehavior, ZRElementEvent>
    }
    'scrollMove': {
        scrollDelta: number
        originX: number
        originY: number

        isAvailableBehavior: Bind3<typeof isAvailableBehavior, null, RoamBehavior, ZRElementEvent>
    }
    'pan': {
        dx: number
        dy: number
        oldX: number
        oldY: number
        newX: number
        newY: number

        isAvailableBehavior: Bind3<typeof isAvailableBehavior, null, RoamBehavior, ZRElementEvent>
    }
};
export type RoamEventDefinition = {
    [key in keyof RoamEventParams]: (params: RoamEventParams[key]) => void | undefined
};

type RoamPointerChecker = (e: ZRElementEvent, x: number, y: number) => boolean;

/**
 * An manager of zoom and pan(darg) hehavior.
 * But it is not responsible for updating the view, since view updates vary and can
 * not be handled in a uniform way.
 *
 * Note: regarding view updates:
 *  - Transformabe views typically use `coord/View` (e.g., geo and series.graph roaming).
 *    Some commonly used view update logic has been organized into `roamHelper.ts`.
 *  - Non-transformable views handle updates themselves, possibly involving re-layout,
 *    (e.g., treemap).
 *  - Some scenarios do not require transformation (e.g., dataZoom roaming for cartesian,
 *    brush component).
 */
class RoamController extends Eventful<RoamEventDefinition> {

    private _zr: ZRenderType;

    private _opt: Required<RoamSetting>;

    private _dragging: boolean;

    private _pinching: boolean;

    private _x: number;

    private _y: number;

    private _controlType: RoamOptionMixin['roam'];

    private _enabled: boolean;

    readonly enable: (this: this, controlType: RoamOptionMixin['roam'], opt: RoamOption) => void;

    readonly disable: () => void;


    constructor(zr: ZRenderType) {
        super();

        this._zr = zr;

        // Avoid two roamController bind the same handler
        const mousedownHandler = bind(this._mousedownHandler, this);
        const mousemoveHandler = bind(this._mousemoveHandler, this);
        const mouseupHandler = bind(this._mouseupHandler, this);
        const mousewheelHandler = bind(this._mousewheelHandler, this);
        const pinchHandler = bind(this._pinchHandler, this);

        /**
         * Notice:
         *  - only enable needed types. For example, if 'zoom'
         *    is not needed, 'zoom' should not be enabled, otherwise
         *    default mousewheel behaviour (scroll page) will be disabled.
         *  - This method is idempotent.
         */
        this.enable = function (controlType, rawOpt) {
            const zInfo = rawOpt.zInfo;
            const {z, zlevel} = retrieveZInfo(zInfo.component);
            const zInfoParsed = {
                component: zInfo.component,
                z,
                zlevel,
                // By default roam controller is the lowest z2 comparing to other elememts in a component.
                z2: retrieve2(zInfo.z2, -Infinity),
            };
            const triggerInfo = extend({}, rawOpt.triggerInfo);

            this._opt = defaults(extend({}, rawOpt), {
                zoomOnMouseWheel: true,
                moveOnMouseMove: true,
                // By default, wheel do not trigger move.
                moveOnMouseWheel: false,
                preventDefaultMouseMove: true,
                zInfoParsed,
                triggerInfo,
                cursorGrab: 'grab', // CSS cursor 'grab'
                cursorGrabbing: 'grabbing', // CSS cursor 'grabbing'
            });

            if (controlType == null) {
                controlType = true;
            }

            // A quick optimization for repeatedly calling `enable` during roaming.
            // Assert `disable` is only affected by `controlType`.
            if (!this._enabled || this._controlType !== controlType) {
                // Disable previous first
                this.disable();

                this._enabled = true;
                if (controlType === true || (controlType === 'move' || controlType === 'pan')) {
                    addRoamZrListener(zr, 'mousedown', mousedownHandler, zInfoParsed);
                    addRoamZrListener(zr, 'mousemove', mousemoveHandler, zInfoParsed);
                    addRoamZrListener(zr, 'mouseup', mouseupHandler, zInfoParsed);
                }
                if (controlType === true || (controlType === 'scale' || controlType === 'zoom')) {
                    addRoamZrListener(zr, 'mousewheel', mousewheelHandler, zInfoParsed);
                    addRoamZrListener(zr, 'pinch', pinchHandler, zInfoParsed);
                }
            }
        };

        this.disable = function () {
            if (this._enabled) {
                this._enabled = false;
                removeRoamZrListener(zr, 'mousedown', mousedownHandler);
                removeRoamZrListener(zr, 'mousemove', mousemoveHandler);
                removeRoamZrListener(zr, 'mouseup', mouseupHandler);
                removeRoamZrListener(zr, 'mousewheel', mousewheelHandler);
                removeRoamZrListener(zr, 'pinch', pinchHandler);
            }
        };
    }

    isDragging() {
        return this._dragging;
    }

    isPinching() {
        return this._pinching;
    }

    _checkPointer(e: ZRElementEvent, x: number, y: number): boolean {
        const opt = this._opt;

        const zInfoParsed = opt.zInfoParsed;
        if (onIrrelevantElement(e, opt.api, zInfoParsed.component)) {
            return false;
        };

        const triggerInfo = opt.triggerInfo;
        const roamTrigger = triggerInfo.roamTrigger;
        let inArea = false;
        if (roamTrigger === 'global') {
            inArea = true;
        }
        if (!inArea) {
            inArea = triggerInfo.isInSelf(e, x, y);
        }
        if (inArea && triggerInfo.isInClip && !triggerInfo.isInClip(e, x, y)) {
            inArea = false;
        }
        return inArea;
    }

    private _decideCursorStyle(
        e: ZRElementEvent,
        x: number,
        y: number,
        forReverse: boolean,
    ): string | NullUndefined {
        // If this cursor style decision is not strictly consistent with zrender,
        // it's fine - zr will set the cursor on the next mousemove.

        // This `grab` cursor style should take the lowest precedence. If the hovring element already
        // have a cursor, zrender will set it to be non-'default' before entering this handler.
        // (note, e.target is never silent, e.topTarget can be silent be irrelevant.)
        const target = e.target;
        if (!target && this._checkPointer(e, x, y)) {
            // To indicate users that this area is draggable, otherwise users probably cannot kwown
            // that when hovering out of the shape but still inside the bounding rect.
            return this._opt.cursorGrab;
        }
        if (forReverse) {
            return target && (target as Displayable).cursor || 'default';
        }
    }

    dispose() {
        this.disable();
    }

    private _mousedownHandler(e: ZRElementEvent) {
        if (eventTool.isMiddleOrRightButtonOnMouseUpDown(e)
            || eventConsumed(e)
        ) {
            return;
        }

        let el = e.target;
        while (el) {
            if (el.draggable) {
                return;
            }
            // check if host is draggable
            el = el.__hostTarget || el.parent;
        }

        const x = e.offsetX;
        const y = e.offsetY;

        // To determine dragging start, only by checking on mosedown, but not mousemove.
        // Mouse can be out of target when mouse moving.
        if (this._checkPointer(e, x, y)) {
            this._x = x;
            this._y = y;
            this._dragging = true;
        }
    }

    private _mousemoveHandler(e: ZRElementEvent) {
        const zr = this._zr;
        if (e.gestureEvent === 'pinch'
            || interactionMutex.isTaken(zr, 'globalPan')
            || eventConsumed(e)
        ) {
            return;
        }

        const x = e.offsetX;
        const y = e.offsetY;

        if (!this._dragging
            || !isAvailableBehavior('moveOnMouseMove', e, this._opt)
        ) {
            const cursorStyle = this._decideCursorStyle(e, x, y, false);
            if (cursorStyle) {
                zr.setCursorStyle(cursorStyle);
            }
            return;
        }

        zr.setCursorStyle(this._opt.cursorGrabbing);

        const oldX = this._x;
        const oldY = this._y;

        const dx = x - oldX;
        const dy = y - oldY;

        this._x = x;
        this._y = y;

        if (this._opt.preventDefaultMouseMove) {
            eventTool.stop(e.event);
        }
        (e as RoamControllerZREventExtend).__ecRoamConsumed = true;

        trigger(this, 'pan', 'moveOnMouseMove', e, {
            dx: dx, dy: dy, oldX: oldX, oldY: oldY, newX: x, newY: y, isAvailableBehavior: null
        });
    }

    private _mouseupHandler(e: ZRElementEvent) {
        if (eventConsumed(e)) {
            return;
        }
        const zr = this._zr;
        if (!eventTool.isMiddleOrRightButtonOnMouseUpDown(e)) {
            this._dragging = false;

            const cursorStyle = this._decideCursorStyle(e, e.offsetX, e.offsetY, true);
            if (cursorStyle) {
                zr.setCursorStyle(cursorStyle);
            }
        }
    }

    private _mousewheelHandler(e: ZRElementEvent) {
        if (eventConsumed(e)) {
            return;
        }

        const shouldZoom = isAvailableBehavior('zoomOnMouseWheel', e, this._opt);
        const shouldMove = isAvailableBehavior('moveOnMouseWheel', e, this._opt);
        const wheelDelta = e.wheelDelta;
        const absWheelDeltaDelta = Math.abs(wheelDelta);
        const originX = e.offsetX;
        const originY = e.offsetY;

        // wheelDelta maybe -0 in chrome mac.
        if (wheelDelta === 0 || (!shouldZoom && !shouldMove)) {
            return;
        }

        // If both `shouldZoom` and `shouldMove` is true, trigger
        // their event both, and the final behavior is determined
        // by event listener themselves.

        if (shouldZoom) {
            // Convenience:
            // Mac and VM Windows on Mac: scroll up: zoom out.
            // Windows: scroll up: zoom in.

            // FIXME: Should do more test in different environment.
            // wheelDelta is too complicated in difference nvironment
            // (https://developer.mozilla.org/en-US/docs/Web/Events/mousewheel),
            // although it has been normallized by zrender.
            // wheelDelta of mouse wheel is bigger than touch pad.
            const factor = absWheelDeltaDelta > 3 ? 1.4 : absWheelDeltaDelta > 1 ? 1.2 : 1.1;
            const scale = wheelDelta > 0 ? factor : 1 / factor;
            this._checkTriggerMoveZoom(this, 'zoom', 'zoomOnMouseWheel', e, {
                scale: scale, originX: originX, originY: originY, isAvailableBehavior: null
            });
        }

        if (shouldMove) {
            // FIXME: Should do more test in different environment.
            const absDelta = Math.abs(wheelDelta);
            // wheelDelta of mouse wheel is bigger than touch pad.
            const scrollDelta = (wheelDelta > 0 ? 1 : -1) * (absDelta > 3 ? 0.4 : absDelta > 1 ? 0.15 : 0.05);
            this._checkTriggerMoveZoom(this, 'scrollMove', 'moveOnMouseWheel', e, {
                scrollDelta: scrollDelta, originX: originX, originY: originY, isAvailableBehavior: null
            });
        }
    }

    private _pinchHandler(e: ZRElementEvent) {
        if (interactionMutex.isTaken(this._zr, 'globalPan')
            || eventConsumed(e)
        ) {
            return;
        }
        const scale = e.pinchScale > 1 ? 1.1 : 1 / 1.1;
        this._checkTriggerMoveZoom(this, 'zoom', null, e, {
            scale: scale, originX: e.pinchX, originY: e.pinchY, isAvailableBehavior: null
        });
    }

    private _checkTriggerMoveZoom<T extends 'scrollMove' | 'zoom'>(
        controller: RoamController,
        eventName: T,
        behaviorToCheck: RoamBehavior,
        e: ZRElementEvent,
        contollerEvent: RoamEventParams[T]
    ) {
        if (controller._checkPointer(e, contollerEvent.originX, contollerEvent.originY)) {
            // When mouse is out of roamController rect,
            // default befavoius should not be be disabled, otherwise
            // page sliding is disabled, contrary to expectation.
            eventTool.stop(e.event);
            (e as RoamControllerZREventExtend).__ecRoamConsumed = true;

            trigger(controller, eventName, behaviorToCheck, e, contollerEvent);
        }
    }
}

type RoamControllerZREventExtend = ZRElementEvent & {
    __ecRoamConsumed: boolean
};

function eventConsumed(e: ZRElementEvent): boolean {
    return (e as RoamControllerZREventExtend).__ecRoamConsumed;
}


type RoamControllerZREventListener = (e: ZRElementEvent) => void;
type RoamControllerZREventType = 'mousedown' | 'mousemove' | 'mouseup' | 'mousewheel' | 'pinch';
type RoamControllerListenerItem = {listener: RoamControllerZREventListener} & Pick<RoamSetting, 'zInfoParsed'>;

const innerZrStore = makeInner<{
    roam: Partial<Record<RoamControllerZREventType, RoamControllerListenerItem[]>>
    uniform: Partial<Record<RoamControllerZREventType, RoamControllerZREventListener>>
}, ZRenderType>();

function ensureZrStore(zr: ZRenderType) {
    const store = innerZrStore(zr);
    store.roam = store.roam || {};
    store.uniform = store.uniform || {};
    return store;
}

/**
 * Listeners are sorted by z2/z/zlevel in descending order.
 * This decides the precedence between different roam controllers if they are overlapped.
 *
 * [MEMO]: It's not easy to perfectly reconcile the conflicts caused by overlap.
 *  - Consider cases:
 *    - Multiple roam controllers overlapped.
 *      - Usually only the topmost can trigger roam.
 *    - Roam controllers overlap with other zr elements:
 *      - zr elements are relevant or irrelevent to the host of the roam controller. e.g., axis split line
 *        or series elements is relevant to a cartesian and should trigger roam.
 *      - zr elements is above or below the roam controller host, which affects the precedence of interaction.
 *      - zr elements may not silent only for triggering tooltip by hovering, which is available to roam;
 *        or may not silent for click, where roam is not preferable.
 *  - Approach - `addRoamZrListener+pointerChecker+onIrrelevantElement` (currently used):
 *    - Resolve the precedence between different roam controllers
 *    - But cannot prevent the handling on other zr elements that under the roam controller in z-order.
 *  - Approach - "use an invisible zr elements to receive the zr events to trigger roam":
 *    - More complicated in impl.
 *    - May cause bad cases where zr event cannot be receive due to other non-silient zr elements covering it.
 */
function addRoamZrListener(
    zr: ZRenderType,
    eventType: RoamControllerZREventType,
    listener: RoamControllerZREventListener,
    zInfoParsed: RoamSetting['zInfoParsed']
): void {
    const store = ensureZrStore(zr);
    const roam = store.roam;
    const listenerList = roam[eventType] = roam[eventType] || [];
    let idx = 0;
    for (; idx < listenerList.length; idx++) {
        const currZInfo = listenerList[idx].zInfoParsed;
        if (
            ((currZInfo.zlevel - zInfoParsed.zlevel)
                || (currZInfo.z - zInfoParsed.z)
                || (currZInfo.z2 - zInfoParsed.z2)
                // If all equals, the latter added one has a higher precedence.
            ) <= 0
        ) {
            break;
        }
    }
    listenerList.splice(idx, 0, {listener, zInfoParsed});
    ensureUniformListener(zr, eventType);
}

function removeRoamZrListener(
    zr: ZRenderType, eventType: RoamControllerZREventType, listener: RoamControllerZREventListener
): void {
    const store = ensureZrStore(zr);
    const listenerList = store.roam[eventType] || [];
    for (let idx = 0; idx < listenerList.length; idx++) {
        if (listenerList[idx].listener === listener) {
            listenerList.splice(idx, 1);
            if (!listenerList.length) {
                removeUniformListener(zr, eventType);
            }
            return;
        }
    }
}

function ensureUniformListener(zr: ZRenderType, eventType: RoamControllerZREventType): void {
    const store = ensureZrStore(zr);
    if (!store.uniform[eventType]) {
        zr.on(eventType, store.uniform[eventType] = function (event) {
            const listenerList = store.roam[eventType];
            if (listenerList) {
                for (let i = 0; i < listenerList.length; i++) {
                    listenerList[i].listener(event);
                }
            }
        });
    }
}

function removeUniformListener(zr: ZRenderType, eventType: RoamControllerZREventType): void {
    const store = ensureZrStore(zr);
    const uniform = store.uniform;
    if (uniform[eventType]) {
        zr.off(eventType, uniform[eventType]);
        uniform[eventType] = null;
    }
}


function trigger<T extends RoamEventType>(
    controller: RoamController,
    eventName: T,
    behaviorToCheck: RoamBehavior,
    e: ZRElementEvent,
    contollerEvent: RoamEventParams[T]
) {
    // Also provide behavior checker for event listener, for some case that
    // multiple components share one listener.
    contollerEvent.isAvailableBehavior = bind(isAvailableBehavior, null, behaviorToCheck, e);
    // TODO should not have type issue.
    (controller as any).trigger(eventName, contollerEvent);
}

// settings: {
//     zoomOnMouseWheel
//     moveOnMouseMove
//     moveOnMouseWheel
// }
// The value can be: true / false / 'shift' / 'ctrl' / 'alt'.
function isAvailableBehavior(
    behaviorToCheck: RoamBehavior,
    e: ZRElementEvent,
    settings: Pick<RoamOption, RoamBehavior>
) {
    const setting = settings[behaviorToCheck];
    return !behaviorToCheck || (
        setting && (!isString(setting) || e.event[setting + 'Key' as 'shiftKey' | 'ctrlKey' | 'altKey'])
    );
}

export default RoamController;