/*
 * https://github.com/morethanwords/tweb
 * Copyright (C) 2019-2021 Eduard Kuzmenko
 * https://github.com/morethanwords/tweb/blob/master/LICENSE
 */

// * zoom part from WebZ
// * https://github.com/Ajaxy/telegram-tt/blob/069f4f5b2f2c7c22529ccced876842e7f9cb81f4/src/util/captureEvents.ts

import cancelEvent from '../helpers/dom/cancelEvent';
import IS_TOUCH_SUPPORTED from '../environment/touchSupport';
import safeAssign from '../helpers/object/safeAssign';
import contextMenuController from '../helpers/contextMenuController';
import {Middleware} from '../helpers/middleware';
import ListenerSetter, {Listener, ListenerOptions} from '../helpers/listenerSetter';
import {attachContextMenuListener} from '../helpers/dom/attachContextMenuListener';
import pause from '../helpers/schedulers/pause';
import deferredPromise from '../helpers/cancellablePromise';
import clamp from '../helpers/number/clamp';
import debounce from '../helpers/schedulers/debounce';
import {logger} from '../lib/logger';
import isSwipingBackSafari from '../helpers/dom/isSwipingBackSafari';
import windowSize from '../helpers/windowSize';

type E = {
  clientX: number,
  clientY: number,
  target: EventTarget,
  button?: number,
  type?: string
};

type EE = E | (Exclude<E, 'clientX' | 'clientY'> & {
  touches: E[]
});

export const getEvent = (e: EE) => {
  return 'touches' in e ? e.touches[0] : e;
};

function getDistance(a: Touch, b?: Touch) {
  if(!b) return 0;
  return Math.hypot((b.pageX - a.pageX), (b.pageY - a.pageY));
}

function getTouchCenter(a: Touch, b: Touch) {
  return {
    x: (a.pageX + b.pageX) / 2,
    y: (a.pageY + b.pageY) / 2
  };
}

const attachGlobalListenerTo = document;

let RESET_GLOBAL = false;
contextMenuController.addEventListener('toggle', (visible) => {
  RESET_GLOBAL = visible;
});

export type SwipeHandlerOptions = {
  element: SwipeHandler['element'],
  onSwipe: SwipeHandler['onSwipe'],
  verifyTouchTarget?: SwipeHandler['verifyTouchTarget'],
  onFirstSwipe?: SwipeHandler['onFirstSwipe'],
  onReset?: SwipeHandler['onReset'],
  onStart?: SwipeHandler['onStart'],
  onZoom?: SwipeHandler['onZoom'],
  onDrag?: SwipeHandler['onDrag'],
  onDoubleClick?: SwipeHandler['onDoubleClick'],
  cursor?: SwipeHandler['cursor'],
  cancelEvent?: SwipeHandler['cancelEvent'],
  listenerOptions?: SwipeHandler['listenerOptions'],
  setCursorTo?: HTMLElement,
  middleware?: Middleware,
  withDelay?: boolean,
  minZoom?: number,
  maxZoom?: number
};

const TOUCH_MOVE_OPTIONS: ListenerOptions = {passive: false};
const MOUSE_MOVE_OPTIONS: ListenerOptions = false as any;
const WHEEL_OPTIONS: ListenerOptions = {capture: true, passive: false};

export type ZoomDetails = {
  zoom?: number;
  zoomFactor?: number;
  zoomAdd?: number;
  initialCenterX: number;
  initialCenterY: number;
  dragOffsetX: number;
  dragOffsetY: number;
  currentCenterX: number;
  currentCenterY: number;
};

export default class SwipeHandler {
  private element: HTMLElement;
  private onSwipe: (xDiff: number, yDiff: number, e: EE, cancelDrag?: (x: boolean, y: boolean) => void) => boolean | void;
  private verifyTouchTarget: (evt: EE) => boolean | Promise<boolean>;
  private onFirstSwipe: (e: EE) => void;
  private onReset: (e?: Event) => void;
  private onStart: () => void;
  private onZoom: (details: ZoomDetails) => void;
  private onDrag: (e: EE, captureEvent: E, details: {dragOffsetX: number, dragOffsetY: number}, cancelDrag: (x: boolean, y: boolean) => void) => void;
  private onDoubleClick: (details: {centerX: number, centerY: number}) => void;
  private cursor: 'grabbing' | 'move' | 'row-resize' | 'col-resize' | 'nesw-resize' | 'nwse-resize' | 'ne-resize' | 'se-resize' | 'sw-resize' | 'nw-resize' | 'n-resize' | 'e-resize' | 's-resize' | 'w-resize' | '';
  private cancelEvent: boolean;
  private listenerOptions: ListenerOptions;
  private setCursorTo: HTMLElement;

  private isMouseDown: boolean;
  private tempId: number;

  private hadMove: boolean;
  private eventUp: E;
  private xDown: number;
  private yDown: number;
  private xAdded: number;
  private yAdded: number;

  private withDelay: boolean;
  private listenerSetter: ListenerSetter;

  private initialDistance: number;
  private initialTouchCenter: ReturnType<typeof getTouchCenter>;
  private initialDragOffset: {x: number, y: number};
  private isDragCanceled: {x: boolean, y: boolean};
  private wheelZoom: number;
  private releaseWheelDrag: ReturnType<typeof debounce<(e: Event) => void>>;
  private releaseWheelZoom: ReturnType<typeof debounce<(e: Event) => void>>;

  private log: ReturnType<typeof logger>;

  constructor(options: SwipeHandlerOptions) {
    safeAssign(this, options);

    this.log = logger('SWIPE-HANDLER');
    this.cursor ??= 'grabbing';
    this.cancelEvent ??= true;
    // this.listenerOptions ??= false as any;
    this.listenerOptions ??= TOUCH_MOVE_OPTIONS;

    this.setCursorTo ??= this.element;
    this.listenerSetter = new ListenerSetter();
    this.setListeners();

    this.resetValues();
    this.tempId = 0;

    options.middleware?.onDestroy(() => {
      this.reset();
      this.removeListeners();
    });

    this.releaseWheelDrag = debounce(this.reset, 150, false);
    this.releaseWheelZoom = debounce(this.reset, 150, false);
  }

  public setListeners() {
    if(!IS_TOUCH_SUPPORTED) {
      // @ts-ignore
      this.listenerSetter.add(this.element)('mousedown', this.handleStart, this.listenerOptions);
      this.listenerSetter.add(attachGlobalListenerTo)('mouseup', this.reset);

      if(this.onZoom || this.onDoubleClick) {
        this.listenerSetter.add(this.element)('wheel', this.handleWheel, WHEEL_OPTIONS);
      }
    } else {
      if(this.withDelay) {
        attachContextMenuListener({
          element: this.element,
          callback: (e) => {
            cancelEvent(e);
            // @ts-ignore
            this.handleStart(e);
          },
          listenerSetter: this.listenerSetter,
          listenerOptions: this.listenerOptions
        });
      } else {
        // @ts-ignore
        this.listenerSetter.add(this.element)('touchstart', this.handleStart, this.listenerOptions);
      }

      if(this.onDoubleClick) {
        this.listenerSetter.add(this.element)('dblclick', (e) => {
          this.onDoubleClick({centerX: e.pageX, centerY: e.pageY});
        });
      }

      this.listenerSetter.add(attachGlobalListenerTo)('touchend', this.reset);
    }
  }

  public removeListeners() {
    this.log('remove listeners');
    this.reset();
    this.listenerSetter.removeAll();
  }

  public setCursor(cursor: SwipeHandler['cursor'] = '') {
    this.cursor = cursor;

    if(!IS_TOUCH_SUPPORTED && this.hadMove) {
      this.setCursorTo.style.setProperty('cursor', this.cursor, 'important');
    }
  }

  public add(x: number, y: number) {
    this.xAdded = x;
    this.yAdded = y;
    this.handleMove({
      clientX: this.eventUp.clientX,
      clientY: this.eventUp.clientY,
      target: this.eventUp.target
    });
  }

  protected resetValues() {
    ++this.tempId;
    this.hadMove = false;
    this.xAdded = this.yAdded = 0;
    this.xDown =
      this.yDown =
      this.eventUp =
      this.isMouseDown =
      undefined;

    if(this.onZoom) {
      this.initialDistance = 0;
      this.initialTouchCenter = {
        x: windowSize.width / 2,
        y: windowSize.height / 2
      };
      this.initialDragOffset = {x: 0, y: 0};
      this.isDragCanceled = {x: false, y: false};
      this.wheelZoom = 1;
    }
  }

  public reset = (e?: Event) => {
    this.log('reset');
    /* if(e) {
      cancelEvent(e);
    } */

    if(IS_TOUCH_SUPPORTED) {
      this.listenerSetter.removeManual(attachGlobalListenerTo, 'touchmove', this.handleMove, TOUCH_MOVE_OPTIONS);
    } else {
      this.listenerSetter.removeManual(attachGlobalListenerTo, 'mousemove', this.handleMove, MOUSE_MOVE_OPTIONS);
      this.setCursorTo.style.cursor = '';
    }

    if(this.hadMove) {
      this.onReset?.(e);
    }

    this.releaseWheelDrag?.clearTimeout();
    this.releaseWheelZoom?.clearTimeout();

    this.resetValues();
  };

  protected setHadMove(_e: EE) {
    if(!this.hadMove) {
      this.log('had move');
      this.hadMove = true;
      this.setCursorTo.style.setProperty('cursor', this.cursor, 'important');
      this.onFirstSwipe?.(_e);
    }
  }

  protected dispatchOnSwipe(...args: Parameters<SwipeHandlerOptions['onSwipe']>) {
    const onSwipeResult = this.onSwipe(...args);
    if(onSwipeResult !== undefined && onSwipeResult) {
      this.reset();
    }
  }

  protected handleStart = async(_e: EE) => {
    this.log('start');

    if(this.isMouseDown) {
      const touches = (_e as any as TouchEvent).touches;
      if(touches?.length === 2) {
        this.initialDistance = getDistance(touches[0], touches[1]);
        this.initialTouchCenter = getTouchCenter(touches[0], touches[1]);
      }

      return;
    }

    const e = getEvent(_e);
    if(![0, 1].includes(Math.max(0, e.button ?? 0))) {
      return;
    }

    if(e.button === 1) {
      cancelEvent(_e as any);
    }

    if(isSwipingBackSafari(_e as any)) {
      return;
    }

    const tempId = ++this.tempId;

    const verifyResult = this.verifyTouchTarget?.(_e);
    if(verifyResult !== undefined) {
      let result: any;
      if(verifyResult instanceof Promise) {
        // const tempId = this.tempId;
        result = await verifyResult;

        if(this.tempId !== tempId) {
          return;
        }
      } else {
        result = verifyResult;
      }

      if(!result) {
        return this.reset();
      }
    }

    this.isMouseDown = true;

    if(this.withDelay && !IS_TOUCH_SUPPORTED) {
      const options = {...MOUSE_MOVE_OPTIONS, once: true};
      const deferred = deferredPromise<void>();
      const cb = () => deferred.resolve();
      const listener = this.listenerSetter.add(attachGlobalListenerTo)('mousemove', cb, options) as any as Listener;

      await Promise.race([
        pause(300),
        deferred
      ]);

      deferred.resolve();
      this.listenerSetter.remove(listener);

      if(this.tempId !== tempId) {
        return;
      }
    }

    this.xDown = e.clientX;
    this.yDown = e.clientY;
    this.eventUp = e;

    if(IS_TOUCH_SUPPORTED) {
      // @ts-ignore
      this.listenerSetter.add(attachGlobalListenerTo)('touchmove', this.handleMove, TOUCH_MOVE_OPTIONS);
    } else {
      // @ts-ignore
      this.listenerSetter.add(attachGlobalListenerTo)('mousemove', this.handleMove, MOUSE_MOVE_OPTIONS);
    }

    if(this.onStart) {
      this.onStart();

      // have to initiate move instantly
      this.hadMove = true;
      this.handleMove(e);
    }
  };

  protected handleMove = (_e: EE) => {
    if(this.xDown === undefined || this.yDown === undefined || RESET_GLOBAL) {
      this.reset();
      return;
    }

    if(this.cancelEvent) {
      cancelEvent(_e as any);
    }

    if(this.releaseWheelDrag?.isDebounced() || this.releaseWheelZoom?.isDebounced()) {
      return;
    }

    this.log('move');

    const e = this.eventUp = getEvent(_e);
    const xUp = e.clientX;
    const yUp = e.clientY;

    const xDiff = xUp - this.xDown + this.xAdded;
    const yDiff = yUp - this.yDown + this.yAdded;

    if(!this.hadMove) {
      if(!xDiff && !yDiff) {
        return;
      }

      this.setHadMove(_e);
    }

    const touches = (_e as any as TouchEvent).touches;
    if(this.onZoom && this.initialDistance > 0 && touches.length === 2) {
      const endDistance = getDistance(touches[0], touches[1]);
      const touchCenter = getTouchCenter(touches[0], touches[1]);
      const dragOffsetX = touchCenter.x - this.initialTouchCenter.x;
      const dragOffsetY = touchCenter.y - this.initialTouchCenter.y;
      const zoomFactor = endDistance / this.initialDistance;
      const details: ZoomDetails = {
        zoomFactor,
        initialCenterX: this.initialTouchCenter.x,
        initialCenterY: this.initialTouchCenter.y,
        dragOffsetX,
        dragOffsetY,
        currentCenterX: touchCenter.x,
        currentCenterY: touchCenter.y
      };

      this.onZoom(details);
    }

    this.dispatchOnSwipe(xDiff, yDiff, _e);
  };

  protected handleWheel = (e: WheelEvent) => {
    if(!this.hadMove && this.verifyTouchTarget) {
      const result = this.verifyTouchTarget(e);
      if(result !== undefined && !result) {
        this.reset(e);
        return;
      }
    }

    cancelEvent(e);

    this.log('wheel');

    if(this.onDoubleClick && Object.is(e.deltaX, -0) && Object.is(e.deltaY, -0) && e.ctrlKey) {
      this.onWheelCapture(e);
      this.onDoubleClick({centerX: e.pageX, centerY: e.pageY});
      this.reset();
      return;
    }

    const metaKeyPressed = e.metaKey || e.ctrlKey || e.shiftKey;
    if(metaKeyPressed) {
      // * fix zooming while dragging is in inertia
      if(this.releaseWheelDrag?.isDebounced()) {
        this.reset();
      }

      this.onWheelZoom(e);
    } else {
      this.handleWheelDrag(e);
    }
  };

  protected handleWheelDrag = (e: WheelEvent) => {
    this.log('wheel drag');

    this.onWheelCapture(e);
    // Ignore wheel inertia if drag is canceled in this direction
    if(!this.isDragCanceled.x || Math.sign(this.initialDragOffset.x) === Math.sign(e.deltaX)) {
      this.initialDragOffset.x -= e.deltaX;
    }
    if(!this.isDragCanceled.y || Math.sign(this.initialDragOffset.y) === Math.sign(e.deltaY)) {
      this.initialDragOffset.y -= e.deltaY;
    }
    const {x, y} = this.initialDragOffset;
    this.releaseWheelDrag(e);
    this.dispatchOnSwipe(x, y, e, (dx, dy) => {
      this.isDragCanceled = {x: dx, y: dy};
    });
  };

  protected onWheelCapture = (e: WheelEvent) => {
    if(this.hadMove) return;
    this.log('wheel capture');
    this.handleStart(e);
    this.setHadMove(e);
    this.initialTouchCenter = {x: e.x, y: e.y};
  };

  protected onWheelZoom = (e: WheelEvent) => {
    if(!this.onZoom) return;
    this.log('wheel zoom');
    this.onWheelCapture(e);
    const dragOffsetX = e.x - this.initialTouchCenter.x;
    const dragOffsetY = e.y - this.initialTouchCenter.y;
    const delta = clamp(e.deltaY, -25, 25);
    this.wheelZoom -= delta * 0.01;
    const details: ZoomDetails = {
      zoomAdd: this.wheelZoom - 1,
      initialCenterX: this.initialTouchCenter.x,
      initialCenterY: this.initialTouchCenter.y,
      dragOffsetX,
      dragOffsetY,
      currentCenterX: e.x,
      currentCenterY: e.y
    };
    this.onZoom(details);
    this.releaseWheelZoom(e);
  }
}
