import { Raf } from '@/components/Raf';
import { Timeline } from '@/components/Timeline';
import { isNumber } from '@/internal/isNumber';
import { toPixels } from '@/utils';
import { clamp, lerp, loop, scoped } from '@/utils/math';

import { ISnapTransitionArg, Snap } from '../..';
import { LERP_APPROXIMATION } from '../../props';
import { SnapLogic } from '../SnapLogic';

export class SnapTrack extends SnapLogic {
  /** The animation frame */
  private _raf: Raf;

  /** The animationtimeline */
  private _timeline?: Timeline;

  /** Interpolation influence */
  private _influence = {
    current: 0,
    target: 0,
  };

  /** The current track value */
  private _current = 0;

  /** The target track value */
  private _target = 0;

  constructor(snap: Snap) {
    super(snap);

    // Create the animation frame
    this._raf = new Raf();
    this._raf.on('frame', () => this._handleRaf());
    this._raf.on('play', () => snap.callbacks.emit('rafPlay', undefined));
    this._raf.on('pause', () => snap.callbacks.emit('rafPause', undefined));

    // Destroy raf
    this.addDestructor(() => this._raf.destroy());

    // Destroy timeline
    this.addDestructor(() => this.cancelTransition());
  }

  /** Whether the track is interpolated */
  private get isInterpolated() {
    return this.current === this.target && this._influence.current === 0;
  }

  /** Gets the interpolation influence */
  get influence() {
    return this._influence.current;
  }

  /** Sets the interpolation influence */
  set influence(value: number) {
    this._influence.current = value;
    this._influence.target = value;
  }

  /** Gets the current track value. */
  get current() {
    return this._current;
  }

  /** Sets the current track value */
  set current(value: number) {
    this._current = value;
  }

  /** Gets the target track value. */
  get target() {
    return this._target;
  }

  /** Sets the target track value */
  set target(value: number) {
    const { containerSize } = this.snap;
    const diff = value - this._target;

    this._target = value;

    this._influence.target += containerSize ? diff / containerSize : 0;
    this._influence.target = clamp(this._influence.target, -1, 1);
  }

  /** Detect if can loop */
  get canLoop() {
    const { snap } = this;

    return snap.props.loop && snap.slides.length > 1;
  }

  /** Get looped current value */
  get loopedCurrent() {
    return this.loopCoord(this.current);
  }

  /** Get track offset */
  get offset() {
    const { snap } = this;

    return snap.props.centered
      ? snap.containerSize / 2 - snap.firstSlideSize / 2
      : 0;
  }

  /** Get loop count */
  get loopCount() {
    return Math.floor(this.current / this.max);
  }

  /** If transition in progress */
  get isTransitioning() {
    return !!this._timeline;
  }

  /** Set a value to current & target value instantly */
  public set(value: number) {
    this.current = value;
    this.target = value;
    this._influence.current = 0;
    this._influence.target = 0;
  }

  /** Loop a coordinate if can loop */
  public loopCoord(coord: number) {
    return this.canLoop ? loop(coord, this.min, this.max) : coord;
  }

  /** Get minimum track value */
  get min() {
    const { snap } = this;

    if (this.canLoop || snap.isEmpty) {
      return 0;
    }

    if (snap.props.centered) {
      const firstSlide = snap.slides[0];

      if (firstSlide.size > snap.containerSize) {
        return snap.containerSize / 2 - firstSlide.size / 2;
      }
    }

    return 0;
  }

  /** Get maximum track value */
  get max() {
    const { containerSize, slides, isEmpty, props } = this.snap;
    const { canLoop } = this;

    if (isEmpty) {
      return 0;
    }

    const firstSlide = slides[0];
    const lastSlide = slides[slides.length - 1];
    const lastCoordWithSlide = lastSlide.staticCoord + lastSlide.size;

    let max = canLoop
      ? lastCoordWithSlide + toPixels(props.gap)
      : lastCoordWithSlide - containerSize;

    if (canLoop) {
      return max;
    }

    if (props.centered) {
      max += containerSize / 2 - firstSlide.size / 2;

      if (lastSlide.size < containerSize) {
        max += containerSize / 2 - lastSlide.size / 2;
      }
    }

    if (!props.centered) {
      max = Math.max(max, 0);
    }

    return max;
  }

  /** Get track progress. From 0 to 1 if not loop. From -Infinity to Infinity if loop */
  get progress() {
    return this.current / this.max;
  }

  /** Awake requestAnimationFrame */
  public awake() {
    this._raf.play();
  }

  /** Iterate track target value */
  public iterateTarget(delta: number) {
    this.target += delta;

    this.awake();
  }

  /** Set track target value */
  public setTarget(value: number) {
    this.target = value;

    this.awake();
  }

  /** Clamp target value between min and max values */
  public clampTarget() {
    if (!this.canLoop) {
      this.target = clamp(this.target, this.min, this.max);
    }

    this.awake();
  }

  /** If the start has been reached */
  get isStart() {
    if (this.snap.props.loop) {
      return false;
    }

    return Math.floor(this.target) <= Math.floor(this.min);
  }

  /** If the end has been reached */
  get isEnd() {
    if (this.snap.props.loop) {
      return false;
    }

    return Math.floor(this.target) >= Math.floor(this.max);
  }

  /** Handle RAF update, interpolate track values */
  private _handleRaf() {
    const { snap } = this;

    if (snap.isTransitioning) {
      return;
    }

    // Interpolate track value
    const ease = this._raf.lerpFactor(snap.props.lerp);
    this.lerp(ease);

    // Stop raf if target reached
    if (this.isInterpolated) {
      this._raf.pause();
    }

    // Render the scene
    snap.render(this._raf.duration);
  }

  /** Interpolate the current track value */
  public lerp(initialFactor: number) {
    const { snap, min, max } = this;
    let { target } = this;

    let lerpFactor = initialFactor;
    const influence = this._influence;

    // Edge space & resistance

    if (!snap.props.loop) {
      const { containerSize } = snap;
      const edgeSpace = (1 - snap.props.edgeFriction) * containerSize;

      if (target < min) {
        const edgeProgress = 1 - scoped(target, -containerSize, min);
        target = min - edgeProgress * edgeSpace;
      } else if (target > max) {
        const edgeProgress = scoped(target, max, max + containerSize);
        target = max + edgeProgress * edgeSpace;
      }

      target = clamp(target, min - edgeSpace, max + edgeSpace);
    }

    // Interpolate current value

    const rest = Math.abs(this.current - target);
    const fastThreshold = 3;

    if (rest < fastThreshold) {
      const fastProgress = 1 - rest / fastThreshold;
      const additionalFactor = (1 - lerpFactor) / 15;
      lerpFactor += additionalFactor * fastProgress;
    }

    this.current = lerp(this.current, target, lerpFactor, LERP_APPROXIMATION);

    // Interpolate influence

    influence.target = lerp(
      influence.target,
      0,
      lerpFactor,
      LERP_APPROXIMATION,
    );

    influence.current = lerp(
      influence.current,
      influence.target,
      lerpFactor,
      LERP_APPROXIMATION,
    );
  }

  /** Cancel sticky behavior */
  public cancelTransition() {
    this._timeline?.destroy();
    this._timeline = undefined;
  }

  /** Go to a definite coordinate */
  public toCoord(coordinate: number, options?: ISnapTransitionArg) {
    const { snap } = this;
    const { props, callbacks } = snap;

    if (snap.isEmpty || snap.isDestroyed) {
      return false;
    }

    this.cancelTransition();

    const start = this.current;
    const end = coordinate;
    const diff = Math.abs(end - start);

    const durationProp = options?.duration ?? snap.props.duration;

    let duration = isNumber(durationProp) ? durationProp : durationProp(diff);
    if (diff === 0) {
      duration = 0;
    }

    const easing = options?.easing ?? props.easing;

    const tm = new Timeline({ duration, easing });

    this._timeline = tm;

    tm.on('start', () => {
      callbacks.emit('timelineStart', undefined);
      options?.onStart?.();
    });

    tm.on('update', (data) => {
      this.current = lerp(start, end, data.eased);
      this.target = this.current;
      this.influence *= 1 - data.progress;

      if (data.progress === 1) {
        snap.$_targetIndex = undefined;
        this._timeline = undefined;
      }

      snap.render();

      callbacks.emit('timelineUpdate', data);
      options?.onUpdate?.(data);
    });

    tm.on('end', () => {
      tm.destroy();

      callbacks.emit('timelineEnd', undefined);
      options?.onEnd?.();
    });

    tm.on('destroy', () => {
      snap.$_targetIndex = undefined;
    });

    tm.play();

    return true;
  }
}
