import { bindAll, isFunction, each } from 'underscore';
import { Position } from '../common';
import { on, off, normalizeFloat } from './mixins';

type RectDimRotator = {
  w: number;
  h: number;
  t: number;
  l: number;
  r: number;
};

type BoundingRectRotator = {
  left: number;
  top: number;
  width: number;
  height: number;
};

type CallbackOptionsRotator = {
  docs: any;
  config: any;
  el: HTMLElement;
  rotator: Rotator;
};

export function rotateCoordinate(
  coordinate: Pick<RectDimRotator, 'l' | 't'>,
  rect: RectDimRotator
): Pick<RectDimRotator, 'l' | 't'> {
  const cx = rect.l + rect.w / 2;
  const cy = rect.t + rect.h / 2;

  const a = rect.r;
  const theta = a * (Math.PI / 180);

  const x = coordinate.l;
  const y = coordinate.t;

  const rx = (x - cx) * Math.cos(theta) - (y - cy) * Math.sin(theta) + cx;
  const ry = (x - cx) * Math.sin(theta) + (y - cy) * Math.cos(theta) + cy;

  return { l: rx, t: ry };
}

export interface RotatorOptions {
  /**
   * Function which returns custom X and Y coordinates of the mouse.
   */
  mousePosFetcher?: (ev: MouseEvent) => Position;

  /**
   * Indicates custom target updating strategy.
   */
  updateTarget?: (el: HTMLElement, rect: RectDimRotator, opts: any) => void;

  /**
   * Function which gets HTMLElement as an arg and returns it relative position
   */
  posFetcher?: (el: HTMLElement, opts: any) => BoundingRectRotator;

  /**
   * On rotate start callback.
   */
  onStart?: (ev: Event, opts: CallbackOptionsRotator) => void;

  /**
   * On rotate move callback.
   */
  onMove?: (ev: Event) => void;

  /**
   * On rotate end callback.
   */
  onEnd?: (ev: Event, opts: CallbackOptionsRotator) => void;

  /**
   * On container update callback.
   */
  onUpdateContainer?: (opts: any) => void;

  /**
   * Rotate unit step.
   * @default 1
   */
  step?: number;

  /**
   * Minimum dimension.
   * @default 10
   */
  minDim?: number;

  /**
   * Maximum dimension.
   * @default Infinity
   */
  maxDim?: number;

  /**
   * If true, will override unitHeight and unitWidth, on start, with units
   * from the current focused element (currently used only in SelectComponent).
   * @default true
   */
  currentUnit?: boolean;

  /**
   * With this option enabled the mousemove event won't be altered when the pointer comes over iframes.
   * @default false
   */
  silentFrames?: boolean;

  /**
   * If true the container of handlers won't be updated.
   * @default false
   */
  avoidContainerUpdate?: boolean;

  /**
   * Class prefix.
   */
  prefix?: string;

  /**
   * Where to append rotate container (default body element).
   */
  appendTo?: HTMLElement;

  /**
   * Offset before snap to guides.
   * @default 5
   */
  snapOffset?: number;

  /**
   * Offset before snap to guides.
   * @default 45
   */
  snapPoints?: number;

  rotationAngle?: number;
}

const getBoundingRect = (el: HTMLElement, win?: Window): BoundingRectRotator => {
  var w = win || window;

  return {
    left: el.offsetLeft + w.pageXOffset,
    top: el.offsetTop + w.pageYOffset,
    width: el.offsetWidth,
    height: el.offsetHeight,
  };
};

export default class Rotator {
  defOpts: RotatorOptions;
  opts: RotatorOptions;
  container?: HTMLElement;
  handler?: HTMLElement;
  el?: HTMLElement;
  selectedHandler?: HTMLElement;
  handlerAttr?: string;
  center?: Position;
  startDim?: RectDimRotator;
  rectDim?: RectDimRotator;
  delta?: Position;
  startPos?: Position;
  currentPos?: Position;
  docs?: Document[];
  keys?: { shift: boolean; ctrl: boolean; alt: boolean };
  snapOffset?: number;
  snapPoints?: number;
  mousePosFetcher?: RotatorOptions['mousePosFetcher'];
  updateTarget?: RotatorOptions['updateTarget'];
  posFetcher?: RotatorOptions['posFetcher'];
  onStart?: RotatorOptions['onStart'];
  onMove?: RotatorOptions['onMove'];
  onEnd?: RotatorOptions['onEnd'];
  onUpdateContainer?: RotatorOptions['onUpdateContainer'];

  /**
   * Init the Rotator with options
   * @param  {Object} options
   */
  constructor(opts: RotatorOptions = {}) {
    this.defOpts = {
      onUpdateContainer: () => {},
      step: 1,
      minDim: 10,
      maxDim: Infinity,
      currentUnit: true,
      silentFrames: false,
      avoidContainerUpdate: false,
      snapOffset: 5,
      snapPoints: 45,
    };
    this.opts = { ...this.defOpts };
    this.setOptions(opts);
    bindAll(this, 'handleKeyDown', 'handleMouseDown', 'move', 'stop');
  }

  /**
   * Get current connfiguration options
   * @return {Object}
   */
  getConfig() {
    return this.opts;
  }

  /**
   * Setup options
   * @param {Object} options
   */
  setOptions(options: Partial<RotatorOptions> = {}, reset?: boolean) {
    this.opts = {
      ...(reset ? this.defOpts : this.opts),
      ...options,
    };
    this.setup();
  }

  /**
   * Setup rotator
   */
  setup() {
    const opts = this.opts;
    const pfx = opts.prefix || '';
    const appendTo = opts.appendTo || document.body;
    let container = this.container;

    // Create container if not yet exist
    if (!container) {
      container = document.createElement('div');
      container.className = `${pfx}rotator-c`;
      appendTo.appendChild(container);
      this.container = container;
    }

    while (container.firstChild) {
      container.removeChild(container.firstChild);
    }

    this.handler = container;
    this.mousePosFetcher = opts.mousePosFetcher;
    this.updateTarget = opts.updateTarget;
    this.posFetcher = opts.posFetcher;
    this.onStart = opts.onStart;
    this.onMove = opts.onMove;
    this.onEnd = opts.onEnd;
    this.onUpdateContainer = opts.onUpdateContainer;
  }

  /**
   * Toggle iframes pointer event
   * @param {Boolean} silent If true, iframes will be silented
   */
  toggleFrames(silent?: boolean) {
    if (this.opts.silentFrames) {
      const frames = document.querySelectorAll('iframe');
      each(frames, frame => (frame.style.pointerEvents = silent ? 'none' : ''));
    }
  }

  /**
   * Detects if the passed element is a rotate handler
   * @param  {HTMLElement} el
   * @return {Boolean}
   */
  isHandler(el: HTMLElement) {
    const { handler } = this;
    return handler === el;
  }

  /**
   * Returns the focused element
   * @return {HTMLElement}
   */
  getFocusedEl() {
    return this.el;
  }

  /**
   * Returns the parent of the focused element
   * @return {HTMLElement}
   */
  getParentEl() {
    return this.el?.parentElement;
  }

  /**
   * Returns documents
   */
  getDocumentEl() {
    return [this.el!.ownerDocument, document];
  }

  /**
   * Return element position
   * @param  {HTMLElement} el
   * @param  {Object} opts Custom options
   * @return {Object}
   */
  getElementPos(el: HTMLElement, opts = {}) {
    const { posFetcher } = this;
    return posFetcher ? posFetcher(el, opts) : getBoundingRect(el);
  }

  /**
   * Return element rotation
   * @param   {HTMLElement} el
   * @returns {number} rotation
   */
  getElementRotation(el: HTMLElement): number {
    var rotation = window.getComputedStyle(el, null).getPropertyValue('rotate') ?? null;
    return rotation ? Number(rotation.replace('deg', '')) : 0;
  }

  /**
   * Focus rotator on the element, attaches handlers to it
   * @param {HTMLElement} el
   */
  focus(el: HTMLElement) {
    // Avoid focusing on already focused element
    if (el && el === this.el) {
      return;
    }

    this.el = el;
    this.updateContainer({ forceShow: true });
    on(this.getDocumentEl(), 'pointerdown', this.handleMouseDown);
  }

  /**
   * Blur from element
   */
  blur() {
    this.container!.style.display = 'none';

    if (this.el) {
      off(this.getDocumentEl(), 'pointerdown', this.handleMouseDown);
      delete this.el;
    }
  }

  /**
   * Start rotating
   * @param  {Event} e
   */
  start(ev: Event) {
    const e = ev as PointerEvent;
    // @ts-ignore Right or middel click
    if (e.button !== 0) return;
    e.preventDefault();
    e.stopPropagation();
    const el = this.el!;
    const rotator = this;
    const config = this.opts || {};
    const attrName = 'data-' + config.prefix + 'handler';
    const rectRotation = this.getElementRotation(el!);
    const rect = this.getElementPos(el!, { target: 'el', avoidFrameZoom: true });
    const target = e.target as HTMLElement;
    this.handlerAttr = target.getAttribute(attrName)!;

    const mouseFetch = this.mousePosFetcher;
    this.startPos = mouseFetch
      ? mouseFetch(e)
      : {
          x: e.clientX,
          y: e.clientY,
        };

    this.startDim = {
      t: rect.top,
      l: rect.left,
      w: rect.width,
      h: rect.height,
      r: rectRotation - (this.opts.rotationAngle ?? 0),
    };
    this.rectDim = {
      t: rect.top,
      l: rect.left,
      w: rect.width,
      h: rect.height,
      r: rectRotation,
    };

    const dims = this.getElementPos(el!, { target: 'el', avoidFrameZoom: true, avoidFrameOffset: true });
    this.center = {
      x: dims.left + dims.width / 2,
      y: dims.top + dims.height / 2,
    };

    // Listen events
    const docs = this.getDocumentEl();
    this.docs = docs;
    on(docs, 'pointermove', this.move);
    on(docs, 'keydown', this.handleKeyDown);
    on(docs, 'pointerup', this.stop);
    isFunction(this.onStart) && this.onStart(e, { docs, config, el, rotator });
    this.toggleFrames(true);
    this.move(e);
  }

  /**
   * While rotating
   * @param  {Event} e
   */
  move(ev: PointerEvent | Event) {
    const e = ev as PointerEvent;
    const onMove = this.onMove;
    const mouseFetch = this.mousePosFetcher;
    const currentPos = mouseFetch
      ? mouseFetch(e)
      : {
          x: e.clientX,
          y: e.clientY,
        };
    this.currentPos = currentPos;

    const dX = this.startPos!.x - this.center!.x;
    const dY = this.startPos!.y - this.center!.y;
    const R = Math.sqrt(dX * dX + dY * dY);

    const vX = currentPos.x - this.center!.x;
    const vY = currentPos.y - this.center!.y;
    const magV = Math.sqrt(vX * vX + vY * vY);
    const aX = this.center!.x + (vX / magV) * R;
    const aY = this.center!.y + (vY / magV) * R;

    this.delta = {
      x: aX - this.center!.x,
      y: aY - this.center!.y,
    };
    this.keys = {
      shift: e.shiftKey,
      ctrl: e.ctrlKey,
      alt: e.altKey,
    };

    this.rectDim = this.calc(this);
    this.updateRect(false);

    // Move callback
    onMove && onMove(e);
  }

  /**
   * Stop rotating
   * @param  {Event} e
   */
  stop(e: Event) {
    const el = this.el!;
    const config = this.opts;
    const docs = this.docs || this.getDocumentEl();
    off(docs, 'pointermove', this.move);
    off(docs, 'keydown', this.handleKeyDown);
    off(docs, 'pointerup', this.stop);
    this.updateRect(true);
    this.toggleFrames();
    isFunction(this.onEnd) && this.onEnd(e, { docs, config, el, rotator: this });
    delete this.docs;
  }

  /**
   * Update rect
   */
  updateRect(store: boolean) {
    const el = this.el!;
    const rotator = this;
    const config = this.opts;
    const rect = this.rectDim!;
    const updateTarget = this.updateTarget;

    // Use custom updating strategy if requested
    if (isFunction(updateTarget)) {
      updateTarget(el, rect, {
        store,
        rotator,
        config,
      });
    } else {
      const elStyle = el.style as Record<string, any>;
      elStyle.rotate = rect.r + 'deg';
    }

    this.updateContainer();
  }

  updateContainer(opt: { forceShow?: boolean } = {}) {
    const { opts, container, el } = this;
    const { style } = container!;

    if (!opts.avoidContainerUpdate && el) {
      // On component rotate container fits the tool,
      // to check if this update is required somewhere else point
      // const toUpdate = ['rotate'];
      // const rectEl = this.getElementPos(el, { target: 'container' });
      // toUpdate.forEach(pos => (style[pos] = `${rectEl[pos]}px`));
      if (opt.forceShow) style.display = 'block';
    }

    this.onUpdateContainer?.({
      el: container!,
      rotator: this,
      opts: {
        ...opts,
        ...opt,
      },
    });
  }

  /**
   * Handle ESC key
   * @param  {Event} e
   */
  handleKeyDown(e: Event) {
    // @ts-ignore
    if (e.keyCode === 27) {
      // Rollback to initial rotation
      this.rectDim = this.startDim;
      this.stop(e);
    }
  }

  /**
   * Handle mousedown to check if it's possible to start rotating
   * @param  {Event} e
   */
  handleMouseDown(e: Event) {
    const el = e.target as HTMLElement;
    if (this.isHandler(el)) {
      this.selectedHandler = el;
      this.start(e);
    } else if (el !== this.el) {
      delete this.selectedHandler;
      this.blur();
    }
  }

  /**
   * All positioning logic
   * @return {Object}
   */
  calc(data: Rotator): RectDimRotator | undefined {
    const startDim = this.startDim!;
    const deltaX = data.delta!.x;
    const deltaY = data.delta!.y;
    const box: RectDimRotator = {
      l: startDim.l,
      t: startDim.t,
      w: startDim.w,
      h: startDim.h,
      r: startDim.r,
    };

    if (!data) return;

    const angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI + 90 - (this.opts.rotationAngle ?? 0);

    box.r = angle;

    const snappingPoints = this.defOpts.snapPoints!;
    const snappingOffset = this.defOpts.snapOffset!;

    const prevSnappingPoint = Math.round(angle / snappingPoints) * snappingPoints;
    const nextSnappingPoint = prevSnappingPoint + snappingPoints;
    const closestSnappingPoint =
      angle - prevSnappingPoint > nextSnappingPoint - angle ? nextSnappingPoint : prevSnappingPoint;

    const isWithinSnapRange = Math.abs(angle - closestSnappingPoint) < snappingOffset;

    // Enforce rotation snapping (unless shift key is being held)
    const shouldSnap = !data.keys!.shift && isWithinSnapRange;

    // Override the rotation when the element is supposed to snap
    if (shouldSnap) {
      box.r = closestSnappingPoint;
    }

    return box;
  }
}
