/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.routing.Walker');
goog.provide('shaka.routing.Walker.Implementation');

goog.require('goog.asserts');
goog.require('shaka.routing.Node');
goog.require('shaka.routing.Payload');
goog.require('shaka.util.Destroyer');
goog.require('shaka.util.IDestroyable');


/**
 * The walker moves through a graph node-by-node executing asynchronous work
 * as it enters each node.
 *
 * The walker accepts requests for where it should go next. Requests are queued
 * and executed in FIFO order. If the current request can be interrupted, it
 * will be cancelled and the next request started.
 *
 * A request says "I want to change where we are going". When the walker is
 * ready to change destinations, it will resolve the request, allowing the
 * destination to differ based on the current state and not the state when
 * the request was appended.
 *
 * Example (from shaka.Player):
 *  When we unload, we need to either go to the attached or detached state based
 *  on whether or not we have a video element.
 *
 *  When we are asked to unload, we don't know what other pending requests may
 *  be ahead of us (there could be attach requests or detach requests). We need
 *  to wait until its our turn to know if:
 *    - we should go to the attach state because we have a media element
 *    - we should go to the detach state because we don't have a media element
 *
 * The walker allows the caller to specify if a route can or cannot be
 * interrupted. This is to allow potentially dependent routes to wait until
 * other routes have finished.
 *
 * Example (from shaka.Player):
 *  A request to load content depends on an attach request finishing. We don't
 *  want load request to interrupt an attach request. By marking the attach
 *  request as non-interruptible we ensure that calling load before attach
 *  finishes will work.
 *
 * @implements {shaka.util.IDestroyable}
 * @final
 */
shaka.routing.Walker = class {
  /**
   * Create a new walker that starts at |startingAt| and with |startingWith|.
   * The instance of |startingWith| will be the one that the walker holds and
   * uses for its life. No one else should reference it.
   *
   * The per-instance behaviour for the walker is provided via |implementation|
   * which is used to connect this walker with the "outside world".
   *
   * @param {shaka.routing.Node} startingAt
   * @param {shaka.routing.Payload} startingWith
   * @param {shaka.routing.Walker.Implementation} implementation
   */
  constructor(startingAt, startingWith, implementation) {
    /** @private {?shaka.routing.Walker.Implementation} */
    this.implementation_ = implementation;

    /** @private {shaka.routing.Node} */
    this.currentlyAt_ = startingAt;

    /** @private {shaka.routing.Payload} */
    this.currentlyWith_ = startingWith;

    /**
     * When we run out of work to do, we will set this promise so that when
     * new work is added (and this is not null) it can be resolved. The only
     * time when this should be non-null is when we are waiting for more work.
     *
     * @private {shaka.util.PublicPromise}
     */
    this.waitForWork_ = null;

    /** @private {!Array.<shaka.routing.Walker.Request_>} */
    this.requests_ = [];

    /** @private {?shaka.routing.Walker.ActiveRoute_} */
    this.currentRoute_ = null;

    /** @private {shaka.util.AbortableOperation} */
    this.currentStep_ = null;

    /**
     * Hold a reference to the main loop's promise so that we know when it has
     * exited. This will determine when |destroy| can resolve. Purposely make
     * the main loop start next interpreter cycle so that the constructor will
     * finish before it starts.
     *
     * @private {!Promise}
     */
    this.mainLoopPromise_ = Promise.resolve().then(() => this.mainLoop_());

    /** @private {!shaka.util.Destroyer} */
    this.destroyer_ = new shaka.util.Destroyer(() => this.doDestroy_());
  }

  /**
   * Get the current routing payload.
   *
   * @return {shaka.routing.Payload}
   */
  getCurrentPayload() {
    return this.currentlyWith_;
  }

  /** @override */
  destroy() {
    return this.destroyer_.destroy();
  }

  /** @private */
  async doDestroy_() {
    // If we are executing a current step, we want to interrupt it so that we
    // can force the main loop to terminate.
    if (this.currentStep_) {
      this.currentStep_.abort();
    }

    // If we are waiting for more work, we want to wake-up the main loop so that
    // it can exit on its own.
    this.unblockMainLoop_();

    // Wait for the main loop to terminate so that an async operation won't
    // try and use state that we released.
    await this.mainLoopPromise_;

    // Any routes that we are not going to finish, we need to cancel. If we
    // don't do this, those listening will be left hanging.
    if (this.currentRoute_) {
      this.currentRoute_.listeners.onCancel();
    }
    for (const request of this.requests_) {
      request.listeners.onCancel();
    }

    // Release anything that could hold references to anything outside of this
    // class.
    this.currentRoute_ = null;
    this.requests_ = [];
    this.implementation_ = null;
  }

  /**
   * Ask the walker to start a new route. When the walker is ready to start a
   * new route, it will call |create| and |create| will provide the walker with
   * a new route to execute.
   *
   * If any previous calls to |startNewRoute| created non-interruptible routes,
   * |create| won't be called until all previous non-interruptible routes have
   * finished.
   *
   * This method will return a collection of listeners that the caller can hook
   * into. Any listener that the caller is interested should be assigned
   * immediately after calling |startNewRoute| or else they could miss the event
   * they want to listen for.
   *
   * @param {function(shaka.routing.Payload):?shaka.routing.Walker.Route} create
   * @return {shaka.routing.Walker.Listeners}
   */
  startNewRoute(create) {
    const listeners = {
      onStart: () => {},
      onEnd: () => {},
      onCancel: () => {},
      onError: (error) => {},
      onSkip: () => {},
      onEnter: () => {},
    };

    this.requests_.push({
      create: create,
      listeners: listeners,
    });

    // If we are in the middle of a step, try to abort it. If this is successful
    // the main loop will error and the walker will enter recovery mode.
    if (this.currentStep_) {
      this.currentStep_.abort();
    }

    // Tell the main loop that new work is available. If the main loop was not
    // blocked, this will be a no-op.
    this.unblockMainLoop_();

    return listeners;
  }

  /**
   * @return {!Promise}
   * @private
   */
  async mainLoop_() {
    while (!this.destroyer_.destroyed()) {
      // eslint-disable-next-line no-await-in-loop
      await this.doOneThing_();
    }
  }

  /**
   * Do one thing to move the walker closer to its destination. This can be:
   *   1. Starting a new route.
   *   2. Taking one more step/finishing a route.
   *   3. Wait for a new route.
   *
   * @return {!Promise}
   * @private
   */
  doOneThing_() {
    if (this.tryNewRoute_()) {
      return Promise.resolve();
    }

    if (this.currentRoute_) {
      return this.takeNextStep_();
    }

    goog.asserts.assert(this.waitForWork_ == null,
        'We should not have a promise yet.');

    // We have no more work to do. We will wait until new work has been provided
    // via request route or until we are destroyed.

    this.implementation_.onIdle(this.currentlyAt_);

    // Wait on a new promise so that we can be resolved by |waitForWork|. This
    // avoids us acting like a busy-wait.
    this.waitForWork_ = new shaka.util.PublicPromise();
    return this.waitForWork_;
  }

  /**
   * Check if the walker can start a new route. There are a couple ways this can
   * happen:
   *  1. We have a new request but no current route
   *  2. We have a new request and our current route can be interrupted
   *
   * @return {boolean}
   *    |true| when a new route was started (regardless of reason) and |false|
   *    when no new route was started.
   *
   * @private
   */
  tryNewRoute_() {
    goog.asserts.assert(
        this.currentStep_ == null,
        'We should never have a current step between taking steps.');

    if (this.requests_.length == 0) {
      return false;
    }

    // If the current route cannot be interrupted, we can't start a new route.
    if (this.currentRoute_ && !this.currentRoute_.interruptible) {
      return false;
    }

    // Stop any previously active routes. Even if we don't pick-up a new route,
    // this route should stop.
    if (this.currentRoute_) {
      this.currentRoute_.listeners.onCancel();
      this.currentRoute_ = null;
    }

    // Create and start the next route. We may not take any steps because it may
    // be interrupted by the next request.
    const request = this.requests_.shift();
    const newRoute = request.create(this.currentlyWith_);

    // Based on the current state of |payload|, a new route may not be
    // possible. In these cases |create| will return |null| to signal that
    // we should just stop the current route and move onto the next request
    // (in the next main loop iteration).
    if (newRoute) {
      request.listeners.onStart();

      // Convert the route created from the request's create method to an
      // active route.
      this.currentRoute_ = {
        node: newRoute.node,
        payload: newRoute.payload,
        interruptible: newRoute.interruptible,
        listeners: request.listeners,
      };
    } else {
      request.listeners.onSkip();
    }

    return true;
  }


  /**
   * Move forward one step on our current route. This assumes that we have a
   * current route. A couple things can happen when moving forward:
   *  1. An error - if an error occurs, it will signal an error occurred,
   *     attempt to recover, and drop the route.
   *  2. Move - if no error occurs, we will move forward. When we arrive at
   *     our destination, it will signal the end and drop the route.
   *
   * In the event of an error or arriving at the destination, we drop the
   * current route. This allows us to pick-up a new route next time the main
   * loop iterates.
   *
   * @return {!Promise}
   * @private
   */
  async takeNextStep_() {
    goog.asserts.assert(
        this.currentRoute_,
        'We need a current route to take the next step.');

    // Figure out where we are supposed to go next.
    this.currentlyAt_ = this.implementation_.getNext(
        this.currentlyAt_,
        this.currentlyWith_,
        this.currentRoute_.node,
        this.currentRoute_.payload);

    this.currentRoute_.listeners.onEnter(this.currentlyAt_);

    // Enter the new node, this is where things can go wrong since it is
    // possible for "supported errors" to occur - errors that the code using
    // the walker can't predict but can recover from.
    try {
      // TODO: This is probably a false-positive.  See eslint/eslint#11687.
      // eslint-disable-next-line require-atomic-updates
      this.currentStep_ = this.implementation_.enterNode(
          /* node= */ this.currentlyAt_,
          /* has= */ this.currentlyWith_,
          /* wants= */ this.currentRoute_.payload);

      await this.currentStep_.promise;
      this.currentStep_ = null;

      // If we are at the end of the route, we need to signal it and clear the
      // route so that we will pick-up a new route next iteration.
      if (this.currentlyAt_ == this.currentRoute_.node) {
        this.currentRoute_.listeners.onEnd();
        this.currentRoute_ = null;
      }
    } catch (error) {
      if (error.code == shaka.util.Error.Code.OPERATION_ABORTED) {
        goog.asserts.assert(
            this.currentRoute_.interruptible,
            'Do not put abortable steps in non-interruptible routes!');
        this.currentRoute_.listeners.onCancel();
      } else {
        // There was an error with this route, so we going to abandon it and
        // resolve the error. We don't reset the payload because the payload may
        // still contain useful information.
        this.currentRoute_.listeners.onError(error);
      }

      // The route and step are done. Clear them before we handle the error or
      // else we may attempt to abort |currentStep_| when handling the error.
      this.currentRoute_ = null;
      this.currentStep_ = null;

      // Still need to handle error because aborting an operation could leave us
      // in an unexpected state.
      this.currentlyAt_ = await this.implementation_.handleError(
          this.currentlyWith_,
          error);
    }
  }

  /**
   * If the main loop is blocked waiting for new work, then resolve the promise
   * so that the next iteration of the main loop can execute.
   *
   * @private
   */
  unblockMainLoop_() {
    if (this.waitForWork_) {
      this.waitForWork_.resolve();
      this.waitForWork_ = null;
    }
  }
};

/**
 * @typedef {{
 *   getNext: function(
 *       shaka.routing.Node,
 *       shaka.routing.Payload,
 *       shaka.routing.Node,
 *       shaka.routing.Payload):shaka.routing.Node,
 *   enterNode: function(
 *       shaka.routing.Node,
 *       shaka.routing.Payload,
 *       shaka.routing.Payload):!shaka.util.AbortableOperation,
 *   handleError: function(
 *       shaka.routing.Payload,
 *       !Error):!Promise.<shaka.routing.Node>,
 *   onIdle: function(shaka.routing.Node)
 * }}
 *
 * @description
 *   There are some parts of the walker that will be per-instance. This type
 *   provides those per-instance parts.
 *
 * @property {function(
 *     shaka.routing.Node,
 *     shaka.routing.Payload,
 *     shaka.routing.Node,
 *     shaka.routing.Payload):shaka.routing.Node getNext
 *   Get the next node that the walker should move to. This method will be
 *   passed (in this order) the current node, current payload, destination
 *   node, and destination payload.
 *
 * @property {function(
 *     shaka.routing.Node,
 *     shaka.routing.Payload,
 *     shaka.routing.Payload):!Promise} enterNode
 *   When the walker moves into a node, it will call |enterNode| and allow the
 *   implementation to change the current payload. This method will be passed
 *   (in this order) the node the walker is entering, the current payload, and
 *   the destination payload. This method should NOT modify the destination
 *   payload.
 *
 * @property {function(
 *     shaka.routing.Payload,
 *     !Error):!Promise.<shaka.routing.Node> handleError
 *   This is the callback for when |enterNode| fails. It is passed the current
 *   payload and the error. If a step is aborted, the error will be
 *   OPERATION_ABORTED. It should reset all external dependences, modify the
 *   payload, and return the new current node. Calls to |handleError| should
 *   always resolve and the walker should always be able to continue operating.
 *
 * @property {function(shaka.routing.Node)} onIdle
 *   This is the callback for when the walker has finished processing all route
 *   requests and needs to wait for more work. |onIdle| will be passed the
 *   current node.  After |onIdle| has been called, the walker will block until
 *   a new request is made, or the walker is destroyed.
 */
shaka.routing.Walker.Implementation;

/**
 * @typedef {{
 *   onStart: function(),
 *   onEnd: function(),
 *   onCancel: function(),
 *   onError: function(!Error),
 *   onSkip: function(),
 *   onEnter: function(shaka.routing.Node)
 * }}
 *
 * @description
 *   The collection of callbacks that the walker will call while executing a
 *   route. By setting these immediately after calling |startNewRoute|
 *   the user can react to route-specific events.
 *
 * @property {function()} onStart
 *   The callback for when the walker has accepted the route and will soon take
 *   the first step unless interrupted. Either |onStart| or |onSkip| will be
 *   called.
 *
 * @property {function()} onEnd
 *   The callback for when the walker has reached the end of the route. For
 *   every route that had |onStart| called, either |onEnd|, |onCancel|, or
 *   |onError| will be called.
 *
 * @property {function()} onCancel
 *   The callback for when the walker is stopping a route before getting to the
 *   end. This will be called either when a new route is interrupting the route,
 *   or the walker is being destroyed mid-route. |onCancel| will only be called
 *   when a route has been interrupted by another route or the walker is being
 *   destroyed.
 *
 * @property {function()} onError
 *   The callback for when the walker failed to execute the route because an
 *   unexpected error occurred. The walker will enter a recovery mode and the
 *   route will be abandoned.
 *
 * @property {function()} onSkip
 *   The callback for when the walker was ready to start the route, but the
 *   create-method returned |null|.
 *
 * @property {function()} onEnter
 *   The callback for when the walker enters a node. This will allow us to
 *   track the progress of the walker within a per-route scope.
 */
shaka.routing.Walker.Listeners;

/**
 * @typedef {{
 *   node: shaka.routing.Node,
 *   payload: shaka.routing.Payload,
 *   interruptible: boolean
 * }}
 *
 * @description
 *   The public description of where the walker should go. This is created
 *   when the callback given to |startNewRoute| is called by the walker.
 *
 * @property {shaka.routing.Node} node
 *   The node that the walker should move towards. This will be passed to
 *   |shaka.routing.Walker.Implementation.getNext| to help determine where to
 *   go next.
 *
 * @property {shaka.routing.Payload| payload
 *   The payload that the walker should have once it arrives at |node|. This
 *   will be passed to the |shaka.routing.Walker.Implementation.getNext| to
 *   help determine where to go next.
 *
 * @property {boolean} interruptible
 *   Whether or not this route can be interrupted by another request. When
 *   |true| this route will be interrupted so that a pending request can be
 *   resolved. When |false|, the route will be allowed to finished before
 *   resolving the next request.
 */
shaka.routing.Walker.Route;

/**
 * @typedef {{
 *   node: shaka.routing.Node,
 *   payload: shaka.routing.Payload,
 *   interruptible: boolean,
 *   listeners: shaka.routing.Walker.Listeners
 * }}
 *
 * @description
 *   The active route is the walker's internal representation of a route. It
 *   is the union of |shaka.routing.Walker.Request_| and the
 *   |shaka.routing.Walker.Route| created by |shaka.routing.Walker.Request_|.
 *
 * @property {shaka.routing.Node} node
 *   The node that the walker should move towards. This will be passed to
 *   |shaka.routing.Walker.Implementation.getNext| to help determine where to
 *   go next.
 *
 * @property {shaka.routing.Payload| payload
 *   The payload that the walker should have once it arrives at |node|. This
 *   will be passed to the |shaka.routing.Walker.Implementation.getNext| to
 *   help determine where to go next.
 *
 * @property {boolean} interruptible
 *   Whether or not this route can be interrupted by another request. When
 *   |true| this route will be interrupted so that a pending request can be
 *   resolved. When |false|, the route will be allowed to finished before
 *   resolving the next request.
 *
 * @property {shaka.routing.Walker.Listeners} listeners
 *   The listeners that the walker can used to communicate with whoever
 *   requested the route.
 *
 * @private
 */
shaka.routing.Walker.ActiveRoute_;

/**
 * @typedef {{
 *   create: function(shaka.routing.Payload):?shaka.routing.Walker.Route,
 *   listeners: shaka.routing.Walker.Listeners
 * }}
 *
 * @description
 *   The request is how users can talk to the walker. They can give the walker
 *   a request and when the walker is ready, it will resolve the request by
 *   calling |create|.
 *
 * @property {
 *     function(shaka.routing.Payload):?shaka.routing.Walker.Route} create
 *   The function called when the walker is ready to start a new route. This can
 *   return |null| to say that the request was not possible and should be
 *   skipped.
 *
 * @property {shaka.routing.Walker.Listeners} listeners
 *   The collection of callbacks that the walker will use to talk to whoever
 *   provided the request.
 *
 * @private
 */
shaka.routing.Walker.Request_;
