/*
  A very simple Finite State Machine system.  If you're struggling with managing
  a mixture of JavaScript API styles (Promise, callback, events) then this will
  help get things organized.  The best example is in the `client/components/HLSVideo.svelte` file.

  The main advantage an FSM has is it's ability to constrain and organize random events, and it's
  ability to fully log what's happening in a stream of random events.  The `HLSVideo.svelte` example
  is a great demonstration of this because it has to juggle events from two different styles of APIs,
  network events, user UI events, and __video__ loading events.  Since all of these can come in at
  random the FSM will help order them and do the right thing at the right time.

  ### Theory

  One way to look at a Finite State Machine is an ___explicit if-while-loop___.
  If you have a `while-loop` that is doing a lot of processing based on
  variables, and you're using a lot of `if-statements` or `switch-case`
  statements, then the `FSM` simply formalizes exactly __what__ states and when
  they can run.

  For example, if you have a mess of nested `if-statements`, and each one might
  not cover every possible variable, then you'll have bugs as different
  branches of the `if-statements` run or don't run.  The `FSM` simply says, "The only
  allowed states at this point in time are X, Y, and Z.  The only events I expect are
  event1, event2, and event3.  If I get anything else that's an error."

  It's a different way to think about data processing, but it is very reliable _and_
  easy to debug because you can log every event, state, and all the data while processing
  random events.

  ### FSMs in User Interfaces

  Most UIs are simple enough that you can get away with the basic Svelte variables updating
  the state.  When UIs get more complex--and especially when they trigger network events--you
  run into problems with random events requiring different updates to the UI.  For example, if
  a user clicks on a button that loads a video, but they're already loading a different video,
  then you have to deal with the current click, the old video loading state, and the new video
  playing state.  An FSM would be able to "serialize" the click, old video load, and new video
  load and play better than the usual Svelte variables.

  In short, if you're struggling with properly updating a user interface as random events come
  in from the user and the network, then switch to an FSM.

  ### Usage

  The `class FSM` takes two things:  data and a event transition object.  The `data` is only
  for logging and debugging the FSM, as it gets printed out with each log message.  The core
  of the FSM is your event handler, which has functions named after each event:

  ```javascript
  class VideoEvents {
    async mount(state) {
      case "START":
        return "COUNTING_DOWN";
      default:
        return "ERROR";
    }
  }
  ```

  In this snippet the `class VideoEvents` has a `async mount` event that works like this:

  + This `mount()` event takes a single `state` which is just a string for the state the FSM is currently in.
  + You then figure out what to do based on this `state`, typically using a `switch/case`.
  + Based on what you've done, you return the next state as another string.
  + The `FSM` then waits for a new event, and calls that function with this new state.

  To trigger this `mount` event you would then write:

  ```javascript
  await fsm.do("mount");
  ```

  You can pass additional arguments to the `do` function and they will be passed to the
  event as additional arguments:

  ```javascript
  // if fsm is in "START" state
  await fsm.do("mount", 1, 2, 3);

  // then becomes
  ev.mount("START", 1, 2, 3);
  ```

  ### Logging Events

  The `FSM` class will already call `log.debug` on many things, but if you want to do your own logging
  you can use the `onTransition` callback:

  ```javascript
  fsm = new FSM(video_config, new VideoEvents());
  fsm.onTransition(fsm => {
    video_config.state = fsm.state
    log.info("I'm logging something else too", the_other_thing);
  });
  ```

  This is a slight modification of how the FSM is used in `HLSVideo.svelte`.  Remember that the `video_config` is only passed to `FSM()` so that it gets logged while the `FSM` is running. This is _incredibly_ useful when debugging why your `FSM` is doing something.

  In the `onTransition` callback I'm simply updating the `state` in `video_config` and then I added a little `log.info` to show you could log anything else you wanted there.

  ### Internal Transitions

  If I'm remembering my graduate courses right, this is called an "epsilon transition", but it's
  basically triggering another event inside the current event, effectively changing state without
  waiting for a new event.  I could be wrong on the terminology, but you do this by returning an
  `Array[state, cb()]` pair. The `FSM` will then set state to `state` and call your callback so
  you can do further processing.

  ___BUG___: This could be improved but for now it works.  Look at `HLSVideo.svelte:detect_video` for an example of doing this.

  ### State Names

  The first state is "START" by default, but after that you can use any string. I prefer to make
  the states UPPERCASE so they aren't confused with events, which are `lowercase()` function
  names in the event handler class (`VideoEvents` above).

 */
import assert from "./assert.js";
import { log } from "./logging.js";

/*
  A very simple Finite State Machine class.  Nothing fancy, just a state
  and some transition callbacks based on the function names found in `this.events`.
 */
export class FSM {

  /*
    Sets up this FSM to use the `events` callback as the transitions, and then
    "START" as the first state. It does ___not___ call any initial transitions
    so you have to do the first call to `FSM.do()`.  The `data` isn't used
    internally and only passed to logging functions for debugging.

    ### Event Class Signature

    The `events` can be anything, and you construct it so it will have your own data.
    The only requirement is that the functions have a `name(state)` signature and that
    `state` is going to be a string.  It should then return the next state string for
    the next event to receive.

    + `data Anything` -- Whatever you want to log while debugging.
    + `events Event Object` -- An object/class that can handle event calls sent to it.
   */
  constructor(data, events) {
    // data is really only for logging/debugging
    this.data = data;
    this.events = events;
    this.state = "START";
    this.on_state = undefined;
  }

  /*
    A callback that runs whenever an event handler returns a state. The `cb` callback
    receives this `FSM` so it can debug it or do more to it.

    ___WARNING___: This is called called even if the state didn't change, which
    might confuse people if they think it's only when a "transition" happens.

    + `cb(FSM)` -- Callback after the transition. Receives this `FSM`.
   */
  onTransition(cb) {
    this.transition_cb = cb;
  }

  /*
    Mostly used internally to perform the transition then call the `onTransition` callback,
    but sometimes you might need to force a new state externally.

    + `next_state string` -- The new state name.
   */
  transition(next_state) {
    this.state = next_state;

    if(this.transition_cb) {
      this.transition_cb(this);
    }
  }

  /*
    All event names available in the `this.events` class.
   */
  event_names() {
    return Object.getOwnPropertyNames(Object.getPrototypeOf(this.events)).filter(k => k !== "constructor");
  }

  /*
    The core of this `FSM` is the `do()` function. It handles finding the next
    event by it's name, loading it, and calling it with the given additional
    arguments.

    + `event string` -- The __name__ of the target event, not the actual function. It's a string.
    + `...args` -- Additional arguments to pass to the event.
    + ___return___ `this.state string` -- The current state of this `FSM`.
   */
  async do(event, ...args) {
    const evhandler = this.events[event];
    assert(evhandler !== undefined, `Invalid event ${event}. Available ones are '${this.event_names()}'.`);

    // NOTE: you have to use .call to pass in the this.events object or else this === undefined in the call
    const next_state = await evhandler.call(this.events, this.state, ...args);
    assert(next_state, `The event "${event}" returned "${next_state}" but must be a truthy true state.`);

    if(Array.isArray(next_state)) {
      assert(next_state.length == 2, `Returning an array only allows 2 elements (state, func) but you returned ${next_state}`);
      let [state, func] = next_state;

      log.debug(`FSM ${this.events.constructor.name}: (${event}) = ${this.state} -> ${state} (${args})`, "DATA:", this.data, func ? `THEN ${func.name}()` : undefined);

      this.transition(state);
      await func();
    } else {
      log.debug(`FSM ${this.events.constructor.name}: (${event}) = ${this.state} -> ${next_state} (${args})`, "DATA:", this.data);
      this.transition(next_state);
    }

    return this.state;
  }
}

export default FSM;
