import { action, computed, makeObservable, observable, reaction } from 'mobx';
import {
  ActionMove,
  ActionMoveBase,
  ComputedQueryFull,
  FiltersMap,
  KeyedItem,
  QueryState,
  ReportBI,
  ToastConfig,
  WixPatternsContainer,
} from '@wix/bex-core';
import {
  CollectionDragEndEvent,
  DragAndDropBulkSubmit,
  DragAndDropSubmit,
} from './DragAndDrop';
import { HTMLAttributes, MutableRefObject } from 'react';
import { subtract } from '@wix/wix-style-react-incubator/dnd-kit/utilities';
import { hasExceededDistance } from './hasExceededDistance';
import { Announcements } from '@wix/wix-style-react-incubator/drag-and-drop';
import {
  ClientRect,
  DragStartEvent,
} from '@wix/wix-style-react-incubator/dnd-kit/core';
import { haveIntersection } from './rectIntersection';
import { EventEmitter } from 'events';
import { TypedEmitter } from '@wix/bex-core/events';
import { v4 as uuid } from 'uuid';
import { emptyRect, RectState } from '../../state/RectState';
import { ReorderModeState } from './ReorderModeState';

export interface DragAndDropParentState<T, F extends FiltersMap> {
  readonly reportBi: ReportBI;
  readonly dragAndDropCategories?: (keyof F)[][] | null | undefined;
  forceRenderIndexes: undefined | number[];
  readonly containerRect?: ClientRect;
  readonly _dnd?: DragAndDropState<T, F> | null;
}

export interface DragAndDropCollection<F extends FiltersMap> {
  getKeyedItem: (key: string) => KeyedItem<any> | undefined | null;
  getKeyedItemByIndex: (ind: number) => KeyedItem<any> | null;
  keyedItems: KeyedItem<any>[];
  originQuery: ComputedQueryFull<F>;
  query: QueryState<F>;
  total: number;
}

interface DragAndDropBaseBI<T> {
  onDragStart: () =>
    | ((params: { endType?: 'clicked ESC' | 'drop outside' | null }) => void)
    | undefined;
  onMoveCancelled: (move: ActionMoveBase<T>) => void;
  onAttemptDragStart: (itemId: string) => () => void;
}

export interface DragAndDropStateParams<T, F extends FiltersMap>
  extends DragAndDropBaseStateParams<T, F> {
  move: (ev: { over: DragItemOver | null }) => void;
  announcements?: Partial<Announcements>;
  bi?: DragAndDropBaseBI<T>;
}

export interface DragAndDropBaseStateParams<T, F extends FiltersMap> {
  container: WixPatternsContainer;
  collection: DragAndDropCollection<F>;
  state: DragAndDropParentState<T, F>;
}

interface DragItemActive {
  id: string | number;
  rect?: MutableRefObject<{
    initial: ClientRect | null;
    translated: ClientRect | null;
  }>;
}
export interface DragItemOver {
  id: string | number;
  rect?: ClientRect;
  disabled?: boolean;
}

export class DragAndDropState<T, F extends FiltersMap> {
  readonly state;
  readonly container;
  readonly collection;

  readonly events = new EventEmitter() as TypedEmitter<{
    dropAnimationEnd: () => unknown;
    dragStart: () => unknown;
  }>;

  status: 'start' | 'over' | 'end' | 'cancel' | null | undefined = null;
  activeId: string | null | undefined = null;
  overlayRect;
  _moveDelta: { x: number } = { x: 0 };
  overId: string | null | undefined = null;
  droppingId: string | null | undefined = null;
  droppedId: string | null | undefined = null;
  _dropAnimationId: string | null | undefined = null;
  scheduleDropEnd;
  scheduleIdle;

  move;

  onSubmit?: DragAndDropSubmit<T, F>;
  onBulkSubmit?: DragAndDropBulkSubmit<T, F>;
  onCancel?: () => Promise<boolean | null>;

  reorderModeState?: ReorderModeState<T, F> | null = null;

  readonly bi;

  _isHandleActive = false;
  _isKeyboardDragging = false;
  _moveId = '';
  _onDragEndBi?:
    | ((params: { endType?: 'clicked ESC' | 'drop outside' | null }) => void)
    | null;

  _endType: 'clicked ESC' | 'drop outside' | null = null;

  private readonly _baseAnimation = {
    duration: 250,
    easing: 'cubic-bezier(0.33, 1, 0.68, 1)',
  };

  readonly sortableAnimation = {
    ...this._baseAnimation,
  };

  readonly dropAnimation = {
    duration: 75,
    easing: 'cubic-bezier(0.33, 1, 0.68, 1)',
    sideEffects: () => {
      this._dropAnimationId = this.droppingId;
      return () => {
        this.onDropAnimationEnd();
      };
    },
  };

  readonly activationConstraint = {
    distance: 1,
  };

  readonly keyboardCodes = {
    start: ['Space', 'Enter'],
    cancel: ['Escape', 'Tab'],
    end: ['Space', 'Enter'],
    move: ['ArrowDown'],
  };

  readonly nullAnnouncements: Announcements;

  // Should be triggered before `onDragStart` for correct BI
  readonly handleEvents: Partial<HTMLAttributes<Element>> = {
    onPointerDown: action(() => {
      this._isHandleActive = true;
    }),
    onKeyDown: action((e) => {
      const { keyboardCodes } = this;
      if (keyboardCodes.start.includes(e.nativeEvent.code)) {
        this._isHandleActive = true;
        this._isKeyboardDragging = true;
        return;
      }
    }),
  };

  readonly containerEvents: Partial<HTMLAttributes<Element>> = {
    onPointerDown: action(() => {
      this._isKeyboardDragging = false;
    }),
  };

  constructor(params: DragAndDropStateParams<T, F>) {
    this.container = params.container;
    this.state = params.state;
    this.collection = params.collection;

    this.overlayRect = new RectState({ container: this.container });

    this.events.setMaxListeners(100);

    this.bi = params.bi;

    // We have an issue with pptr where `dropAnimation.sideEffects` is not being called.
    // This ensures `onDropAnimationEnd` will be called eventually, even if `dropAnimation.sideEffects` wasn't called.
    this.scheduleDropEnd = this.container.lodash.debounce(() => {
      // Do not call `onDropAnimationEnd` if _dropAnimationId was set, meaning that `dropAnimation.sideEffects` worked as expected
      if (this._dropAnimationId && this._dropAnimationId === this.droppingId) {
        return;
      }
      this.onDropAnimationEnd();
    }, this.dropAnimation.duration);

    this.scheduleIdle = this.container.lodash.debounce(
      action(() => {
        if (this.status === 'end' || this.status === 'cancel') {
          this.status = null;
          this._isKeyboardDragging = false;
          this._onDragEndBi = null;
          this._endType = null;
        }
      }),
      500,
    );

    this.move = params.move;

    this.nullAnnouncements = {
      onDragStart: () => undefined,
      onDragMove: () => undefined,
      onDragOver: () => undefined,
      onDragEnd: () => undefined,
      onDragCancel: () => undefined,
      listAttributes: () => {
        const { translate: t } = this.container;
        return {
          'aria-label': t('cairo.dragAndDrop.list-aria-label.sr'),
        };
      },
      ...(params.announcements ?? {}),
    };

    makeObservable(this, {
      activeId: observable.ref,
      overlayRect: observable.ref,
      overId: observable.ref,
      _moveDelta: observable.ref,
      status: observable.ref,
      onDragStart: action,
      init: action,
      beforeDrop: action,
      onDragEnd: action,
      onDragMove: action,
      onDragOver: action,
      onDragCancel: action,
      focusDragHandleIfDropped: action,
      active: computed,
      isActiveRectOutsideContainer: computed,
      over: computed,
      activeIndex: computed,
      isActive: computed,
      droppingId: observable.ref,
      isDropping: computed,
      _isHandleActive: observable.ref,
      reorderModeState: observable.ref,
    });
  }

  onDropAnimationEnd = action(() => {
    if (this.droppingId && this.activeId === this.droppingId) {
      this.activeId = null;
      this._isHandleActive = false;
      this.droppedId = this.droppingId;
      this.droppingId = null;
      this._dropAnimationId = null;
      this.events.emit('dropAnimationEnd');
      this._onDragEndBi?.({
        endType: this._endType,
      });
      this.scheduleIdle();
    }
  });

  get active() {
    const { activeId, collection } = this;
    return activeId != null ? collection.getKeyedItem(activeId) : null;
  }

  get over() {
    const { overId, collection } = this;
    return overId != null ? collection.getKeyedItem(overId) : null;
  }

  get isDropping() {
    return this.droppingId != null;
  }

  get activeIndex() {
    return this.active?.index;
  }

  get isActive() {
    return this.activeId != null;
  }

  _onGlobalKeydown = action((e: KeyboardEvent) => {
    const { keyboardCodes, activeId } = this;

    if (activeId == null) {
      return;
    }

    if (keyboardCodes.cancel.includes(e.code)) {
      this._endType = 'clicked ESC';
      return;
    }
  });

  onDragMove(event: {
    active: {
      id: DragStartEvent['active']['id'];
      rect?: DragStartEvent['active']['rect'];
    };
    delta: { x: number };
  }) {
    const { active } = event;
    this._moveDelta = event.delta;
    Object.assign(this.overlayRect.rect, active.rect?.current.translated);
  }

  onDragStart(event: {
    active: {
      id: DragStartEvent['active']['id'];
      rect?: DragStartEvent['active']['rect'];
    };
  }) {
    const { events } = this;
    const { active } = event;
    this.status = 'start';
    Object.assign(this.overlayRect.rect, active.rect?.current.translated);
    this.activeId = event.active.id as string;
    this.overId = this.activeId;
    this._moveId = uuid();
    this._onDragEndBi = this.bi?.onDragStart();
    events.emit('dragStart');
  }

  attachDisabledDragAttempt = (
    itemId: string,
    downEvent: MouseEvent,
    axis: 'x' | 'y' | 'all',
  ) => {
    const state = {
      onDragAttemptEnd: null as (() => void) | null,
    };

    const coordinates = {
      x: downEvent.x,
      y: downEvent.y,
    };

    const onMouseUp = () => {
      if (state.onDragAttemptEnd) {
        state.onDragAttemptEnd();
        state.onDragAttemptEnd = null;
      }

      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onMouseUp);
    };

    const onMove = this.container.lodash.throttle((moveEvent: MouseEvent) => {
      const delta = subtract({ x: moveEvent.x, y: moveEvent.y }, coordinates);

      // for detecting drag attempt use larger activation constraint (10px instead of 1px)
      if (
        process.env.NODE_ENV !== 'test' &&
        !hasExceededDistance({ delta, measurement: 10, axis })
      ) {
        return;
      }

      document.removeEventListener('mousemove', onMove);
      state.onDragAttemptEnd = this.bi?.onAttemptDragStart(itemId) ?? null;
    }, 200);

    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onMouseUp);
  };

  get isActiveRectOutsideContainer() {
    const {
      state: { containerRect },
      overlayRect: { rect: overlayRect },
    } = this;

    if (overlayRect.width == null || containerRect == null) {
      return false;
    }

    return !haveIntersection(overlayRect, containerRect);
  }

  _maybeShowErrorToast = (
    shouldCancel: Omit<ToastConfig, 'type' | 'biName'> | true,
    dragEndEvent: ActionMoveBase<T>,
  ) => {
    const { container } = this;

    if (shouldCancel && typeof shouldCancel === 'object') {
      this.bi?.onMoveCancelled?.(dragEndEvent);
      container.showToast?.({
        ...shouldCancel,
        type: 'ERROR',
        biName: 'cairo-drag-and-drop-external-logic-error',
      });
    }
  };

  async beforeDrop(event: {
    active: DragItemActive | null;
    over: DragItemOver | null;
  }): Promise<boolean> {
    this.droppingId = this.activeId;
    this.overId = event.over?.id as string;
    const { active } = event;

    if (active == null) {
      return false;
    }

    const cancelResult = await this.onCancel?.();

    if (cancelResult) {
      return true;
    }

    const rect = active.rect?.current?.translated;
    const container = this.state.containerRect;

    if (rect == null || container == null) {
      return false;
    }

    const shouldCancel = !haveIntersection(rect, container);

    if (shouldCancel) {
      this._endType = 'drop outside';
    }

    return shouldCancel;
  }

  _createDragEndEvent() {
    const { active, collection, overId } = this;
    const { query } = collection;

    if (active == null || overId == null || overId === active.id) {
      return null;
    }

    const from = collection.getKeyedItem(active.id as string);
    const over = collection.getKeyedItem(overId as string);

    if (from == null || over == null) {
      return null;
    }

    const after =
      over.index === 0
        ? null
        : over.index < from.index
        ? collection.getKeyedItemByIndex(over.index - 1)
        : over;

    return {
      moveId: this._moveId,
      from,
      over,
      after,
      query: collection.originQuery,
      filtersKeyHash: query.filtersKeyHash,
      isFromHandle: Boolean(this._isHandleActive),
    };
  }

  _createSimpleDragEndEvent(
    event: ActionMove<T>,
  ): CollectionDragEndEvent<T, F> {
    const simplifyIndexedItem = (keyedItem: KeyedItem<T>) => ({
      id: keyedItem.id,
      item: keyedItem.item,
      index: keyedItem.index,
    });

    return {
      from: simplifyIndexedItem(event.from),
      after: event.after != null ? simplifyIndexedItem(event.after) : null,
      over: simplifyIndexedItem(event.over),
      filters: event.query.filters,
    };
  }

  onDragCancel() {
    this.status = 'cancel';
    Object.assign(this.overlayRect.rect, emptyRect());
    this.droppingId = this.activeId;
    this.scheduleDropEnd();
  }

  onDragOver(event: { over: DragItemOver | null }) {
    this.status = 'over';
    this.overId = event.over?.id as string;
  }

  onDragEnd(event: { over: DragItemOver | null }) {
    this.status = 'end';
    this.overId = event.over?.id as string;
    Object.assign(this.overlayRect.rect, emptyRect());
    this.move(event);
    this.scheduleDropEnd();
  }

  get isDisabled() {
    const { dragAndDropCategories } = this.state;
    const {
      query: { hasActiveSort, filtersArray },
    } = this.collection;

    const activeFiltersNames = filtersArray
      .filter((filter) => !filter.isEmpty)
      .map((filter) => filter.name);

    if (hasActiveSort) {
      return true;
    }

    if (activeFiltersNames.length) {
      return (
        dragAndDropCategories == null ||
        !dragAndDropCategories.some(
          (combination) =>
            activeFiltersNames.length === combination.length &&
            activeFiltersNames.every((name) => combination.includes(name)),
        )
      );
    }

    if (dragAndDropCategories) {
      const hasEmptyCombination = dragAndDropCategories.some(
        (combination) => !combination.length,
      );
      return !hasEmptyCombination;
    }

    return false;
  }

  init() {
    document.addEventListener('keydown', this._onGlobalKeydown);

    const disposers = [
      reaction(
        () => this.active,
        (keyedItem) => {
          this.state.forceRenderIndexes = keyedItem
            ? [keyedItem.index]
            : undefined;
        },
        {
          fireImmediately: true,
        },
      ),
    ];

    return () => {
      document.removeEventListener('keydown', this._onGlobalKeydown);
      disposers.forEach((dispose) => dispose());
    };
  }

  focusDragHandleIfDropped(
    id: string | number,
    element: HTMLElement | null | undefined,
  ) {
    const { status, droppedId, droppingId } = this;
    if (
      (status === 'end' || status === 'cancel') &&
      droppingId == null &&
      droppedId === id &&
      element
    ) {
      element.focus({
        preventScroll: true,
      });
    }
  }

  get reportBi() {
    return this.state.reportBi;
  }
}
