import { IS_IPAD } from '@blocksuite/global/env';
import { nextTick, Vec } from '@blocksuite/global/utils';

import type { UIEventDispatcher } from '../dispatcher.js';

import { UIEventState, UIEventStateContext } from '../base.js';
import {
  DndEventState,
  MultiPointerEventState,
  PointerEventState,
} from '../state/index.js';
import { EventScopeSourceType, EventSourceState } from '../state/source.js';
import { isFarEnough } from '../utils.js';

type PointerId = typeof PointerEvent.prototype.pointerId;

function createContext(
  event: Event,
  state: PointerEventState | MultiPointerEventState
) {
  return UIEventStateContext.from(
    new UIEventState(event),
    new EventSourceState({
      event,
      sourceType: EventScopeSourceType.Target,
    }),
    state
  );
}

const POLL_INTERVAL = 1000;

abstract class PointerControllerBase {
  constructor(
    protected _dispatcher: UIEventDispatcher,
    protected _getRect: () => DOMRect
  ) {}

  abstract listen(): void;
}

class PointerEventForward extends PointerControllerBase {
  private _down = (event: PointerEvent) => {
    const { pointerId } = event;

    const pointerState = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: -Infinity,
      startY: -Infinity,
      last: null,
    });
    this._startStates.set(pointerId, pointerState);
    this._lastStates.set(pointerId, pointerState);
    this._dispatcher.run('pointerDown', createContext(event, pointerState));
  };

  private _lastStates = new Map<PointerId, PointerEventState>();

  private _move = (event: PointerEvent) => {
    const { pointerId } = event;

    const start = this._startStates.get(pointerId) ?? null;
    const last = this._lastStates.get(pointerId) ?? null;

    const state = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: start?.x ?? -Infinity,
      startY: start?.y ?? -Infinity,
      last,
    });
    this._lastStates.set(pointerId, state);

    this._dispatcher.run('pointerMove', createContext(event, state));
  };

  private _startStates = new Map<PointerId, PointerEventState>();

  private _upOrOut = (up: boolean) => (event: PointerEvent) => {
    const { pointerId } = event;

    const start = this._startStates.get(pointerId) ?? null;
    const last = this._lastStates.get(pointerId) ?? null;

    const state = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: start?.x ?? -Infinity,
      startY: start?.y ?? -Infinity,
      last,
    });

    this._startStates.delete(pointerId);
    this._lastStates.delete(pointerId);

    this._dispatcher.run(
      up ? 'pointerUp' : 'pointerOut',
      createContext(event, state)
    );
  };

  listen() {
    const { host, disposables } = this._dispatcher;
    disposables.addFromEvent(host, 'pointerdown', this._down);
    disposables.addFromEvent(host, 'pointermove', this._move);
    disposables.addFromEvent(host, 'pointerup', this._upOrOut(true));
    disposables.addFromEvent(host, 'pointerout', this._upOrOut(false));
  }
}

class ClickController extends PointerControllerBase {
  private _down = (event: PointerEvent) => {
    // disable for secondary pointer
    if (event.isPrimary === false) return;

    if (
      this._downPointerState &&
      event.pointerId === this._downPointerState.raw.pointerId &&
      event.timeStamp - this._downPointerState.raw.timeStamp < 500 &&
      !isFarEnough(event, this._downPointerState.raw)
    ) {
      this._pointerDownCount++;
    } else {
      this._pointerDownCount = 1;
    }

    this._downPointerState = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: -Infinity,
      startY: -Infinity,
      last: null,
    });
  };

  private _downPointerState: PointerEventState | null = null;

  private _pointerDownCount = 0;

  private _up = (event: PointerEvent) => {
    if (!this._downPointerState) return;

    if (isFarEnough(this._downPointerState.raw, event)) {
      this._pointerDownCount = 0;
      this._downPointerState = null;
      return;
    }

    const state = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: -Infinity,
      startY: -Infinity,
      last: null,
    });
    const context = createContext(event, state);

    const run = () => {
      this._dispatcher.run('click', context);
      if (this._pointerDownCount === 2) {
        this._dispatcher.run('doubleClick', context);
      }
      if (this._pointerDownCount === 3) {
        this._dispatcher.run('tripleClick', context);
      }
    };

    run();
  };

  listen() {
    const { host, disposables } = this._dispatcher;

    disposables.addFromEvent(host, 'pointerdown', this._down);
    disposables.addFromEvent(host, 'pointerup', this._up);
  }
}

class DragController extends PointerControllerBase {
  private _down = (event: PointerEvent) => {
    if (this._nativeDragging) return;

    if (!event.isPrimary) {
      if (this._dragging && this._lastPointerState) {
        this._up(this._lastPointerState.raw);
      }
      this._reset();
      return;
    }

    const pointerState = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: -Infinity,
      startY: -Infinity,
      last: null,
    });
    this._startPointerState = pointerState;

    this._dispatcher.disposables.addFromEvent(
      document,
      'pointermove',
      this._move
    );
    this._dispatcher.disposables.addFromEvent(document, 'pointerup', this._up);
  };

  private _dragging = false;

  private _lastPointerState: PointerEventState | null = null;

  private _move = (event: PointerEvent) => {
    if (
      this._startPointerState === null ||
      this._startPointerState.raw.pointerId !== event.pointerId
    )
      return;

    const start = this._startPointerState;
    const last = this._lastPointerState ?? start;

    const state = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: start.x,
      startY: start.y,
      last,
    });

    this._lastPointerState = state;

    if (
      !this._nativeDragging &&
      !this._dragging &&
      isFarEnough(event, this._startPointerState.raw)
    ) {
      this._dragging = true;
      this._dispatcher.run('dragStart', createContext(event, start));
    }

    if (this._dragging) {
      this._dispatcher.run('dragMove', createContext(event, state));
    }
  };

  private _nativeDragEnd = (event: DragEvent) => {
    this._nativeDragging = false;
    const dndEventState = new DndEventState({ event });
    this._dispatcher.run(
      'nativeDragEnd',
      this._createContext(event, dndEventState)
    );
  };

  private _nativeDragging = false;

  private _nativeDragMove = (event: DragEvent) => {
    const dndEventState = new DndEventState({ event });
    this._dispatcher.run(
      'nativeDragMove',
      this._createContext(event, dndEventState)
    );
  };

  private _nativeDragStart = (event: DragEvent) => {
    this._reset();
    this._nativeDragging = true;
    const dndEventState = new DndEventState({ event });
    this._dispatcher.run(
      'nativeDragStart',
      this._createContext(event, dndEventState)
    );
  };

  private _nativeDrop = (event: DragEvent) => {
    this._reset();
    this._nativeDragging = false;
    const dndEventState = new DndEventState({ event });
    this._dispatcher.run(
      'nativeDrop',
      this._createContext(event, dndEventState)
    );
  };

  private _reset = () => {
    this._dragging = false;
    this._startPointerState = null;
    this._lastPointerState = null;

    document.removeEventListener('pointermove', this._move);
    document.removeEventListener('pointerup', this._up);
  };

  private _startPointerState: PointerEventState | null = null;

  private _up = (event: PointerEvent) => {
    if (
      !this._startPointerState ||
      this._startPointerState.raw.pointerId !== event.pointerId
    )
      return;

    const start = this._startPointerState;
    const last = this._lastPointerState;

    const state = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: start.x,
      startY: start.y,
      last,
    });

    if (this._dragging) {
      this._dispatcher.run('dragEnd', createContext(event, state));
    }

    this._reset();
  };

  // https://mikepk.com/2020/10/iOS-safari-scribble-bug/
  private _applyScribblePatch() {
    if (!IS_IPAD) return;

    const { host, disposables } = this._dispatcher;
    disposables.addFromEvent(host, 'touchmove', (event: TouchEvent) => {
      if (
        this._dragging &&
        this._startPointerState &&
        this._startPointerState.raw.pointerType === 'pen'
      ) {
        event.preventDefault();
      }
    });
  }

  private _createContext(event: Event, dndState: DndEventState) {
    return UIEventStateContext.from(
      new UIEventState(event),
      new EventSourceState({
        event,
        sourceType: EventScopeSourceType.Target,
      }),
      dndState
    );
  }

  listen() {
    const { host, disposables } = this._dispatcher;
    disposables.addFromEvent(host, 'pointerdown', this._down);
    this._applyScribblePatch();

    disposables.addFromEvent(host, 'dragstart', this._nativeDragStart);
    disposables.addFromEvent(host, 'dragend', this._nativeDragEnd);
    disposables.addFromEvent(host, 'drag', this._nativeDragMove);
    disposables.addFromEvent(host, 'drop', this._nativeDrop);
  }
}

abstract class DualDragControllerBase extends PointerControllerBase {
  private _down = (event: PointerEvent) => {
    // Another pointer down
    if (
      this._startPointerStates.primary !== null &&
      this._startPointerStates.secondary !== null
    ) {
      this._reset();
    }

    if (this._startPointerStates.primary === null && !event.isPrimary) {
      return;
    }

    const state = new PointerEventState({
      event,
      rect: this._getRect(),
      startX: -Infinity,
      startY: -Infinity,
      last: null,
    });

    if (event.isPrimary) {
      this._startPointerStates.primary = state;
    } else {
      this._startPointerStates.secondary = state;
    }
  };

  private _lastPointerStates: {
    primary: PointerEventState | null;
    secondary: PointerEventState | null;
  } = {
    primary: null,
    secondary: null,
  };

  private _move = (event: PointerEvent) => {
    if (
      this._startPointerStates.primary === null ||
      this._startPointerStates.secondary === null
    ) {
      return;
    }

    const { isPrimary } = event;
    const startPrimaryState = this._startPointerStates.primary;
    let lastPrimaryState = this._lastPointerStates.primary;

    const startSecondaryState = this._startPointerStates.secondary;
    let lastSecondaryState = this._lastPointerStates.secondary;

    if (isPrimary) {
      lastPrimaryState = new PointerEventState({
        event,
        rect: this._getRect(),
        startX: startPrimaryState.x,
        startY: startPrimaryState.y,
        last: lastPrimaryState,
      });
    } else {
      lastSecondaryState = new PointerEventState({
        event,
        rect: this._getRect(),
        startX: startSecondaryState.x,
        startY: startSecondaryState.y,
        last: lastSecondaryState,
      });
    }

    const multiPointerState = new MultiPointerEventState(event, [
      lastPrimaryState ?? startPrimaryState,
      lastSecondaryState ?? startSecondaryState,
    ]);

    this._handleMove(event, multiPointerState);

    this._lastPointerStates = {
      primary: lastPrimaryState,
      secondary: lastSecondaryState,
    };
  };

  private _reset = () => {
    this._startPointerStates = {
      primary: null,
      secondary: null,
    };
    this._lastPointerStates = {
      primary: null,
      secondary: null,
    };
  };

  private _startPointerStates: {
    primary: PointerEventState | null;
    secondary: PointerEventState | null;
  } = {
    primary: null,
    secondary: null,
  };

  private _upOrOut = (event: PointerEvent) => {
    const { pointerId } = event;
    if (
      pointerId === this._startPointerStates.primary?.raw.pointerId ||
      pointerId === this._startPointerStates.secondary?.raw.pointerId
    ) {
      this._reset();
    }
  };

  abstract _handleMove(
    event: PointerEvent,
    state: MultiPointerEventState
  ): void;

  override listen(): void {
    const { host, disposables } = this._dispatcher;
    disposables.addFromEvent(host, 'pointerdown', this._down);
    disposables.addFromEvent(host, 'pointermove', this._move);
    disposables.addFromEvent(host, 'pointerup', this._upOrOut);
    disposables.addFromEvent(host, 'pointerout', this._upOrOut);
  }
}

class PinchController extends DualDragControllerBase {
  override _handleMove(event: PointerEvent, state: MultiPointerEventState) {
    if (event.pointerType !== 'touch') return;

    const deltaFirstPointer = state.pointers[0].delta;
    const deltaSecondPointer = state.pointers[1].delta;

    const deltaFirstPointerVec = Vec.toVec(deltaFirstPointer);
    const deltaSecondPointerVec = Vec.toVec(deltaSecondPointer);

    const deltaFirstPointerValue = Vec.len(deltaFirstPointerVec);
    const deltaSecondPointerValue = Vec.len(deltaSecondPointerVec);

    const deltaDotProduct = Vec.dpr(
      deltaFirstPointerVec,
      deltaSecondPointerVec
    );

    const deltaValueThreshold = 0.1;

    // the changes of distance between two pointers is not far enough
    if (
      !isFarEnough(deltaFirstPointer, deltaSecondPointer) ||
      deltaDotProduct > 0 ||
      deltaFirstPointerValue < deltaValueThreshold ||
      deltaSecondPointerValue < deltaValueThreshold
    )
      return;

    this._dispatcher.run('pinch', createContext(event, state));
  }
}

class PanController extends DualDragControllerBase {
  override _handleMove(event: PointerEvent, state: MultiPointerEventState) {
    if (event.pointerType !== 'touch') return;

    const deltaFirstPointer = state.pointers[0].delta;
    const deltaSecondPointer = state.pointers[1].delta;

    const deltaDotProduct = Vec.dpr(
      Vec.toVec(deltaFirstPointer),
      Vec.toVec(deltaSecondPointer)
    );

    // the center move distance is not far enough
    if (
      !isFarEnough(deltaFirstPointer, deltaSecondPointer) &&
      deltaDotProduct < 0
    )
      return;

    this._dispatcher.run('pan', createContext(event, state));
  }
}

export class PointerControl {
  private _cachedRect: DOMRect | null = null;

  private _getRect = () => {
    if (this._cachedRect === null) {
      this._updateRect();
    }
    return this._cachedRect as DOMRect;
  };

  // XXX: polling is used instead of MutationObserver
  // due to potential performance issues
  private _pollingInterval: number | null = null;

  private controllers: PointerControllerBase[];

  constructor(private _dispatcher: UIEventDispatcher) {
    this.controllers = [
      new PointerEventForward(_dispatcher, this._getRect),
      new ClickController(_dispatcher, this._getRect),
      new DragController(_dispatcher, this._getRect),
      new PanController(_dispatcher, this._getRect),
      new PinchController(_dispatcher, this._getRect),
    ];
  }

  private _startPolling() {
    const poll = () => {
      nextTick()
        .then(() => this._updateRect())
        .catch(console.error);
    };
    this._pollingInterval = window.setInterval(poll, POLL_INTERVAL);
    poll();
  }

  protected _updateRect() {
    if (!this._dispatcher.host) return;
    this._cachedRect = this._dispatcher.host.getBoundingClientRect();
  }

  dispose() {
    if (this._pollingInterval !== null) {
      clearInterval(this._pollingInterval);
      this._pollingInterval = null;
    }
  }

  listen() {
    this._startPolling();
    this.controllers.forEach(controller => controller.listen());
  }
}
