/**
 * For controlling hobby servos, continuous rotation servos
 * @module j5e/servo
 * @requires module:j5e/animation
 * @requires module:j5e/easing
 * @requires module:j5e/event
 * @requires module:j5e/fn
 */

import Animation from "j5e/animation";
import { Emitter } from "j5e/event";
import { constrain, normalizeDevice, normalizeIO, getProvider, map, fmap, timer } from "j5e/fn";
import { inOutSine } from "j5e/easing";

/**
 * Class representing a Servo
 * @classdesc The Servo class allows for control of hobby servos
 * @async
 * @extends module:j5e/event.Emitter
 * @fires move:complete - Fires when a servo reaches its requested position
 */
class Servo extends Emitter {

  #state = {
    history: [],
    isRunning: false,
    animation: null,
    value: null
  };

  /**
   * Instantiate a Servo
   * @param {number|string|object} io - Pin identifier or IO Options (See {@tutorial C-INSTANTIATING})
   * @example
   * <caption>Sweep a servo back and forth</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.sweep();
   *
   */
  constructor(io) {
    return (async() => {
      io = normalizeIO(io);

      const Provider = await getProvider(io, "PWM");
      super();

      this.io = new Provider({
        pin: io.pin,
        mode: Provider.Output,
        hz: 50
      });

      this.configure();

      return this;
    })();
  }

  /**
   * Configure a Servo
   * @returns {Servo} The instance on which the method was called
   * @param {object} options - Device configuration options
   * @param {(number|string)} [options.pin] - If passing an object, a pin number or pin identifier
   * @param {(string|constructor)} [options.io=builtin/pwm] - If passing an object, a string specifying a path to the IO provider or a constructor
   * @param {string} [options.type="standard"] - Type of servo ("standard" or "continuous")
   * @param {number[]} [options.pwmRange=[600, 2400]] - The pulse width range in microseconds
   * @param {number[]} [options.deadband=[90,90]] - The degree range at which a continuos motion servo will not turn
   * @param {number[]} [options.range=[0, 180]] - The allowed range of motion in degrees
   * @param {number[]} [options.deviceRange=[0, 180]] - The physical range (throw) of the servo in degrees
   * @param {number} [options.startAt="Any value within options.range"] - The desired start position of the servo
   * @param {number} [options.offset=0] - Adjust the position of the servo for trimming
   * @param {boolean} [options.invert=false] - Reverses the direction of rotation
   * @param {boolean} [options.center=false] - Center the servo on instantiation
   * @example
   * <caption>Move a continuos rotation servo forward</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo({ pin: 12 });
   * servo.configure({type: "continuous"});
   * servo.cw();
   */
  configure(options = {}) {
    options = normalizeDevice(options);

    this.#state.pwmRange = options.pwmRange || [600, 2400];
    this.#state.deviceRange = options.deviceRange || [0, 180];
    this.#state.deadband = options.deadband || [90, 90];
    this.#state.offset = options.offset || 0;
    this.#state.startAt = options.startAt || (this.#state.deviceRange[1] - this.#state.deviceRange[0]) / 2;
    this.#state.range = options.range || [0 - this.offset, 180 - this.offset];
    this.#state.type = options.type || "standard";
    this.#state.invert = options.invert || false;
    this.#state.isRunning = false;

    this.initialize(options);

    if (typeof options.startAt !== "undefined") {
      this.to(options.startAt);
    } else {

      if (options.type === "continuous") {
        this.stop();
      } else {
        this.center();
      }

    }

    return this;
  }

  /**
   * The last five position updates
   * @returns {object[]}
   * @property {date} timestamp - Timestamp of position update
   * @property {number} target - The user requested position
   * @property {number} degrees - The actual position (factors in offset and invert)
   * @property {number} pulseWidth - The corrected pulseWidth (factors in offset and invert)
   * @readonly
   */
  get history() {
    return this.#state.history.slice(-5);
  }

  /**
   * The most recent position update
   * @returns {object}
   * @property {date} timestamp - Timestamp of position update
   * @property {number} target - The user requested position
   * @property {number} degrees - The actual position (factors in offset and invert)
   * @property {number} pulseWidth - The corrected pulseWidth (factors in offset and invert)
   * @readonly
   */
  get last() {
    if (this.#state.history.length) {
      return this.#state.history[this.#state.history.length - 1];
    } else {
      return {
        timestamp: Date.now(),
        degrees: null,
        target: null
      };
    }
  }

  /**
   * The most recent request and corrected position (factors in offset and invert)
   * @returns {number}
   * @readonly
   */
  get position() {
    return this.#state.history.length ? this.#state.history[this.#state.history.length - 1].degrees : -1;
  }

  /**
   * The initialization code for a servo
   * @ignore
   */
  initialize(options) {
    // No operation here... Meant to be overwritten by subclasses
  }

  /**
   * Calls the write param on the IO instance for this servo.
   * @param {number} pulseWidth - The target pulseWidth
   * @returns {Servo}
   * @ignore
   */
  update(degrees) {

    // If same degrees return immediately.
    if (this.last && this.last.degrees === degrees) {
      return this;
    }

    let microseconds = fmap(degrees, this.#state.deviceRange[0], this.#state.deviceRange[1], this.#state.pwmRange[0], this.#state.pwmRange[1]);

    // Presumably some IO's will support writeMicroseconds
    if (this.io.writeMicroseconds) {
      this.io.writeMicroseconds(microseconds | 0);
    } else {
      let value = fmap(microseconds, 0, 20_000, 0, 2 ** this.io.resolution - 1);
      this.io.write(value | 0);
    }

  }

  /**
   * Set the servo's position to given degree over time.
   *
   * @param {Number|Object} degrees Degrees to move servo to or an animation object (See {@tutorial D-ANIMATING})
   * @param {Number} [time] Time to spend in motion. If degrees is a number and this value is ommitted, the servo will move to the requested degrees as quickly as possible.
   * @param {Number} [rate=20] The target frame rate of the motion transiton
   * @return {Servo} instance
   * @example
   * <caption>Move a servo to 180° as quickly as possible</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.to(180);
   *
   * @example
   * <caption>Move a servo to 180° over one second</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.to(180, 1000);
   *
   * @example
   * <caption>Move a servo to 180° using an animation segment object</caption>
   * import Servo from "j5e/servo";
   * import { inOutQuad } from "j5e/easing";
   *
   * const servo = await new Servo(12);
   * servo.to({
   *   duration: 1000,
   *   cuePoints: [0, 1.0],
   *   keyFrames: [ null, 180 ],
   *   easing: inOutQuad
   * });
   */
  to(degrees, time, rate) {

    let options = {
      duration: 1000,
      cuePoints: [0, 1.0],
      keyFrames: [
        null,
        {
          value: typeof degrees.degrees === "number" ? degrees.degrees : this.startAt
        }
      ],
      oncomplete: () => {
        // Enforce async execution for user "oncomplete"
        timer.setImmediate(() => {
          if (typeof degrees.oncomplete === "function") {
            degrees.oncomplete();
          }
          this.emit("move:complete");
        });
      }
    };

    if (typeof degrees === "object") {

      Object.assign(options, degrees);

      this.#state.isRunning = true;
      this.#state.animation = this.#state.animation || new Animation(this);
      this.#state.animation.enqueue(options);

    } else {

      const target = degrees;

      // Enforce limited range of motion
      degrees = constrain(degrees, this.#state.range[0], this.#state.range[1]);

      if (typeof time !== "undefined") {

        options.duration = time;
        options.keyFrames = [null, {
          degrees: degrees
        }];
        options.fps = rate || 20;

        this.to(options);

      } else {

        this.#state.value = degrees;

        degrees += this.#state.offset;

        if (this.#state.invert) {
          degrees = map(
            degrees,
            this.#state.deviceRange[0], this.#state.deviceRange[1],
            this.#state.deviceRange[1], this.#state.deviceRange[0]
          );
        }

        this.update(degrees);

        if (this.#state.history.length > 5) {
          this.#state.history.shift();
        }

        this.#state.history.push({
          timestamp: Date.now(),
          degrees: degrees,
          target: target
        });

      }
    }

    return this;
  }


  /**
   * Update the servo position by specified degrees (over time)
   *
   * @param  {Number} degrees Degrees to turn servo to.
   * @param  {Number} [time] Time to spend in motion.
   * @return {Servo}
   * @example
   * <caption>Move a servo to 120° as quickly as possible</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * // The servo's default position is 90 degrees
   * servo.step(30);
   *
   * @example
   * <caption>Move a servo to 120° over one second</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * // The servo's default position is 90 degrees
   * servo.step(30, 1000);
   */
  step(degrees, time) {
    return this.to(this.last.target + degrees, time);
  }

  /**
   * min Set Servo to minimum degrees, defaults to 0deg
   * @param  {Number} [time]      Time to spend in motion.
   * @return {Servo}
   * @example
   * <caption>Move a servo to 0° as quickly as possible</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.min();
   *
   * @example
   * <caption>Move a servo to 0° over one second</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.min(1000);
   */
  min(time) {
    return this.to(this.#state.deviceRange[0], time);
  };

  /**
   * Set Servo to maximum degrees, defaults to 180deg
   * @param  {Number} [time] Time to spend in motion.
   * @return {Object} instance
   * @example
   * <caption>Move a standard servo to 180° as quickly as possible</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.max();
   *
   * @example
   * <caption>Move a standard servo to 180° over one second</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.max(1000);
   */
  max(time) {
    return this.to(this.#state.deviceRange[1], time);
  }

  /**
   * Set Servo to centerpoint, defaults to 90deg
   * @param  {Number} [time] Time to spend in motion.
   * @return {Object} instance
   * @example
   * <caption>Move a servo to 180° and then back to 90° as quickly as possible</caption>
   * import Servo from "j5e/servo";
   * import { timer } from "j5e/fn";
   *
   * const servo = await new Servo(12);
   * servo.to(180);
   *
   * timer.setTimeout(function() {
   *   servo.center();
   * }, 1000);
   *
   * @example
   * <caption>Move a servo to 180° and then back to 90° over one second</caption>
   * import Servo from "j5e/servo";
   * import { timer } from "j5e/fn";
   *
   * const servo = await new Servo(12);
   * servo.to(180);
   *
   * timer.setTimeout(function() {
   *   servo.center(1000);
   * }, 1000);
   */
  center(time) {
    return this.to(Math.abs((this.#state.deviceRange[0] + this.#state.deviceRange[1]) / 2), time);
  }

  /**
   * Return Servo to its startAt position
   * @return {Servo}
   * @example
   * <caption>Move a servo to 180° and then back to 90° as quickly as possible</caption>
   * import Servo from "j5e/servo";
   * import { timer } from "j5e/fn";
   *
   * const servo = await new Servo(12);
   * servo.to(180);
   *
   * timer.setTimeout(function() {
   *   servo.home();
   * }, 1000);
   */
  home() {
    return this.to(this.#state.startAt);
  }

  /**
   * Sweep the servo between min and max or provided range
   * @param  {Array|Object} opts An array describing the range of the sweep in degrees or an animation segment options object
   * @return {Servo}
   * @example
   * <caption>Sweep a servo back and forth between 10° to 170°</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.sweep([10, 170]));
   *
   * example
   * <caption>Sweep a servo back and forth between 10° to 170° with inOutCirc easing</caption>
   * import Servo from "j5e/servo";
   * import {inOutCirc} from "j5e/easing";
   *
   * const servo = await new Servo(12);
   * servo.sweep({
   *   keyFrames: [10, 170],
   *   cuePoints: [0, 1],
   *   easing: inOutCirc
   * });
   */
  sweep(opts) {

    var options = {
      keyFrames: [{
        value: this.#state.deviceRange[0]
      }, {
        value: this.#state.deviceRange[1]
      }],
      metronomic: true,
      loop: true,
      easing: inOutSine,
      duration: 1000
    };

    // If opts is an array, then assume a range was passed
    if (Array.isArray(opts)) {
      options.keyFrames = rangeToKeyFrames(opts);
    } else {
      if (typeof opts === "object" && opts !== null) {
        Object.assign(options, opts);
        if (Array.isArray(options.range)) {
          options.keyFrames = rangeToKeyFrames(options.range);
        }
      }
    }

    return this.to(options);
  }

  /**
   * Stop a moving servo
   * return {Servo}
   * @example
   * <caption>Sweep a servo back and forth for two seconds then stop</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo(12);
   * servo.sweep([10, 170]));
   *
   * timer.setTimeout(function() {
   *   servo.stop();
   * }, 2000);
   */
  stop() {

    if (this.#state.animation) {
      this.#state.animation.stop();
    }

    if (this.#state.type === "continuous") {
      this.to(
        this.#state.deadband.reduce(function(a, b) {
          return (a + b) / 2;
        })
      );
    }

    return this;
  }

  /**
   * Move a continuous rotation servo clockwise
   * @param  {number} speed Speed between 0 and 1.
   * @return {Servo}
   * @example
   * <caption>Move a continuos rotation servo clockwise</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo({ pin: 12, type: "continuous"});
   * servo.cw();
   *
   * @example
   * <caption>Move a continuos rotation servo clockwise at half speed</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo({ pin: 12, type: "continuous"});
   * servo.cw(0.5);
   */
  cw(speed = 1) {
    speed = constrain(speed, 0, 1);
    speed = map(speed, 0, 1, this.#state.deadband[1] + 1, this.#state.deviceRange[1]);
    return this.to(speed);
  }

  /**
   * Move a continuous rotation servo counter-clockwise
   * @param  {number} speed Speed between 0 and 1.
   * @return {Servo}
   * @example
   * <caption>Move a continuos rotation servo counter-clockwise</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo({ pin: 12, type: "continuous"});
   * servo.ccw();
   *
   * @example
   * <caption>Move a continuos rotation servo counter-clockwise at half speed</caption>
   * import Servo from "j5e/servo";
   *
   * const servo = await new Servo({ pin: 12, type: "continuous"});
   * servo.ccw(0.5);
   */
  ccw(speed = 1) {
    speed = constrain(speed, 0, 1);
    speed = map(speed, 0, 1, this.#state.deadband[0] - 1, this.#state.deviceRange[0]);
    return this.to(speed);
  }


  /**
   * @param [number || object] keyFrames An array of step values or a keyFrame objects
   * @ignore
   */
  normalize(keyFrames) {

    let last = this.last ? this.last.target : this.startAt;

    // If user passes null as the first element in keyFrames use current position
    if (keyFrames[0] === null) {
      keyFrames[0] = {
        value: last
      };
    }

    // If user passes a number as the first element in keyFrames make it a step
    if (typeof keyFrames[0] === "number") {
      keyFrames[0] = {
        value: last + keyFrames[0]
      };
    }

    return keyFrames.map(function(frame) {
      let value = frame;

      /* istanbul ignore else */
      if (frame !== null) {
        // frames that are just numbers represent _step_
        if (typeof frame === "number") {
          frame = {
            step: value,
          };
        } else {
          if (typeof frame.degrees === "number") {
            frame.value = frame.degrees;
            delete frame.degrees;
          }
          if (typeof frame.copyDegrees === "number") {
            frame.copyValue = frame.copyDegrees;
            delete frame.copyDegrees;
          }
        }

      }
      return frame;
    });
  }

  /**
   * render
   * @position [number] value to set the servo to
   * @ignore
   */
  render(position) {
    return this.to(position[0]);
  }

}

function rangeToKeyFrames(range) {
  return range.map(function(value) {
    return { value: value };
  });
}

export default Servo;