import { EVENT_MOVE, EVENT_MOVED, EVENT_REFRESH, EVENT_RESIZED, EVENT_UPDATED } from '../../constants/events';
import { DEFAULT_EVENT_PRIORITY } from '../../constants/priority';
import { IDLE, MOVING } from '../../constants/states';
import { FADE, LOOP, SLIDE } from '../../constants/types';
import { EventInterface } from '../../constructors';
import { Splide } from '../../core/Splide/Splide';
import { AnyFunction, BaseComponent, Components, Options } from '../../types';
import { abs, clamp, isUndefined, rect } from '../../utils';
import { SNAP_THRESHOLD } from './constants';


/**
 * The interface for the Move component.
 *
 * @since 3.0.0
 */
export interface MoveComponent extends BaseComponent {
  move( dest: number, index: number, prev: number, callback?: AnyFunction ): void;
  jump( index: number ): void;
  translate( position: number ): void;
  cancel(): void;
  toIndex( position: number ): number;
  toPosition( index: number, trimming?: boolean ): number;
  getPosition(): number;
  getLimit( max: boolean ): number;
  isBusy(): boolean;
  exceededLimit( max?: boolean | undefined, position?: number ): boolean;
}

/**
 * The component for moving the slider.
 *
 * @since 3.0.0
 *
 * @param Splide     - A Splide instance.
 * @param Components - A collection of components.
 * @param options    - Options.
 *
 * @return A Move component object.
 */
export function Move( Splide: Splide, Components: Components, options: Options ): MoveComponent {
  const { on, emit } = EventInterface( Splide );
  const { slideSize, getPadding, totalSize, listSize, sliderSize } = Components.Layout;
  const { resolve, orient } = Components.Direction;
  const { list, track } = Components.Elements;

  /**
   * Indicates whether the component can move the slider or not.
   */
  let waiting: boolean;

  /**
   * Indicates whether the the slider should snap the position to the specific slide or not.
   */
  let shouldSnap = true;

  /**
   * Called when the component is mounted.
   */
  function mount(): void {
    if ( ! Splide.is( FADE ) ) {
      on( [ EVENT_RESIZED, EVENT_UPDATED, EVENT_REFRESH ], reposition, DEFAULT_EVENT_PRIORITY - 1 );
    }
  }

  /**
   * Repositions the slider.
   * This must be called before the Slide component checks the visibility.
   */
  function reposition(): void {
    if ( exceededLimit( true ) ) {
      translate( getLimit( true ) );
    } else if ( shouldSnap || ( shouldSnap = canSnap() ) ) {
      jump( Splide.index );
    }
  }

  /**
   * Moves the slider to the dest index with the Transition component.
   *
   * @param dest     - A destination index to go to, including clones'.
   * @param index    - A slide index.
   * @param prev     - A previous index.
   * @param callback - Optional. A callback function invoked after transition ends.
   */
  function move( dest: number, index: number, prev: number, callback?: AnyFunction ): void {
    if ( ! isBusy() ) {
      const { set } = Splide.state;
      const position = getPosition();
      const looping  = dest !== index;

      waiting = looping || options.waitForTransition;
      set( MOVING );
      emit( EVENT_MOVE, index, prev, dest );

      Components.Transition.start( dest, () => {
        looping && jump( index );
        waiting = false;
        set( IDLE );
        emit( EVENT_MOVED, index, prev, dest );

        if ( options.trimSpace === 'move' && dest !== prev && position === getPosition() ) {
          Components.Controller.go( dest > prev ? '>' : '<', false, callback );
        } else {
          callback && callback();
        }
      } );
    }
  }

  /**
   * Jumps to the slide at the specified index.
   *
   * @param index - An index to jump to.
   */
  function jump( index: number ): void {
    translate( toPosition( index, true ) );
  }

  /**
   * Moves the slider to the provided position.
   *
   * @param position - The position to move to.
   */
  function translate( position: number ): void {
    position   = loop( position );
    shouldSnap = canSnap( position );

    Components.Style.ruleBy(
      list,
      'transform',
      `translate${ resolve( 'X' ) }(${ 100 * position / listSize() }%)`
    );
  }

  /**
   * Loops the provided position if it exceeds bounds.
   *
   * @param position - A position to loop.
   */
  function loop( position: number ): number {
    if ( ! waiting && Splide.is( LOOP ) ) {
      const diff        = position - getPosition();
      const exceededMin = exceededLimit( false, position );
      const exceededMax = exceededLimit( true, position );

      if ( ( exceededMin && diff > 0 ) || ( exceededMax && diff < 0 ) ) {
        position += orient( sliderSize() * ( exceededMin ? 1 : -1 ) );
      }
    }

    return position;
  }

  /**
   * Cancels transition.
   */
  function cancel(): void {
    waiting = false;
    Components.Transition.cancel();
    translate( getPosition() );
  }

  /**
   * Returns the closest index to the position.
   *
   * @param position - A position to convert.
   *
   * @return The closest index to the position.
   */
  function toIndex( position: number ): number {
    const Slides = Components.Slides.get();

    let index       = 0;
    let minDistance = Infinity;

    for ( let i = 0; i < Slides.length; i++ ) {
      const slideIndex = Slides[ i ].index;
      const distance   = abs( toPosition( slideIndex, true ) - position );

      if ( distance < minDistance ) {
        minDistance = distance;
        index       = slideIndex;
      } else {
        break;
      }
    }

    return index;
  }

  /**
   * Converts the slide index to the position.
   *
   * @param index    - An index to convert.
   * @param trimming - Optional. Whether to trim edge spaces or not.
   *
   * @return The position corresponding with the index.
   */
  function toPosition( index: number, trimming?: boolean ): number {
    const position = orient( totalSize( index - 1 ) - offset( index ) );
    return trimming ? trim( position ) : position;
  }

  /**
   * Returns the current position.
   *
   * @return The position of the list element.
   */
  function getPosition(): number {
    const left = resolve( 'left' );
    return rect( list )[ left ] - rect( track )[ left ] + orient( getPadding( false ) );
  }

  /**
   * Trims spaces on the edge of the slider.
   *
   * @param position - A position to trim.
   *
   * @return A trimmed position.
   */
  function trim( position: number ): number {
    if ( options.trimSpace && Splide.is( SLIDE ) ) {
      position = clamp( position, 0, orient( sliderSize() - listSize() ) );
    }

    return position;
  }

  /**
   * Returns the offset amount.
   *
   * @param index - An index.
   */
  function offset( index: number ): number {
    const { focus } = options;
    return focus === 'center' ? ( listSize() - slideSize( index, true ) ) / 2 : +focus * slideSize( index ) || 0;
  }

  /**
   * Returns the limit number that the slider can move to.
   *
   * @param max - Determines whether to return the maximum or minimum limit.
   *
   * @return The border number.
   */
  function getLimit( max: boolean ): number {
    return toPosition( max ? Components.Controller.getEnd() : 0, !! options.trimSpace );
  }

  /**
   * Checks if the provided position is enough close to some slide to snap or not.
   *
   * @param position - A position to test.
   *
   * @return `true` if found the slide to snap, or otherwise `false`.
   */
  function canSnap( position?: number ): boolean {
    position = isUndefined( position ) ? getPosition() : position;
    return abs( position - toPosition( toIndex( position ), true ) ) < SNAP_THRESHOLD;
  }

  /**
   * Checks if the slider can move now or not.
   *
   * @return `true` if the slider can move, or otherwise `false`.
   */
  function isBusy(): boolean {
    return !! waiting;
  }

  /**
   * Checks if the provided position exceeds the minimum or maximum limit or not.
   *
   * @param max      - Optional. `true` for testing max, `false` for min, and `undefined` for both.
   * @param position - Optional. A position to test. If omitted, tests the current position.
   *
   * @return `true` if the position exceeds the limit, or otherwise `false`.
   */
  function exceededLimit( max?: boolean | undefined, position?: number ): boolean {
    position = isUndefined( position ) ? getPosition() : position;
    const exceededMin = max !== true && orient( position ) < orient( getLimit( false ) );
    const exceededMax = max !== false && orient( position ) > orient( getLimit( true ) );
    return exceededMin || exceededMax;
  }

  return {
    mount,
    move,
    jump,
    translate,
    cancel,
    toIndex,
    toPosition,
    getPosition,
    getLimit,
    isBusy,
    exceededLimit,
  };
}
