<?php

namespace OSInet\Finite_State_Machine;

/**
 * Abstract state machine.
 *
 * @copyright  (c) 2007-2012 Ouest Systèmes Informatiques
 * @author     Frederic G. MARAND
 * @license    CeCILL 2.0
 * @link       http://wiki.audean.com/fsm/fsm
 * @since      FSM 1.6
 *
 * This class must be inherited by code implementing actual FSMs.
 *
 * Applications must create a Machine descendant in which they will:
 *   - define the fTransitions table, usually in their constructor
 *   - invoke the parent constructor to set up the FSM from the transitions table
 *   - create "f_foo" functions for each "foo" event, except "idle", which is builtin.
 *
 * Applications may:
 *   - disable event processing by setting $this->event to one of the fsm::EVENT_* constants
 *   - disable post-event actions by setting $this->allowsActions to false
 *   - disable the builtin idle event by setting $this->idleWork to false
 *   - query the current state by using $this->getState()
 *   - send an idle event by using $this->idle()
 *   - submit any event (including idle) by using $this->applyEvent($eventName)
 */
abstract class Machine {
  const VERSION      = '12D22';

  const IDLE_EVENT   = 'idle'; // must not change name: method fIdle depends on it

  const INIT_STATE   = 'init';
  const FINAL_STATE  = 'final';

  const EVENT_NORMAL = 1; // processes events
  const EVENT_QUEUE  = 2; // queue events for later use
  const EVENT_SINK   = 3; // throw away events

  public $idleWork     = TRUE;
  public $allowActions = TRUE;

  protected $fEventMode = self::EVENT_NORMAL;
  protected $fQueue     = array(); // event queue for EVENT_QUEUE mode

  /**
   * the current state of the object
   * @var string
   */
  protected $fState;

  /**
   * Transitions holds the transitions table
   * state1
   *   event1
   *     result1
   *     state_name|Result
   *   event2
   *     ...
   *   ..
   * ..
   * @var array
   */
  public $fTransitions = null;

  /**
   * constructor initializes the FSM to the first
   * state in the transitions table
   * @return void
   */
  public function __construct() {
    $this->checkTransitions();

    reset($this->fTransitions);
    $x = each($this->fTransitions);
    $x = $x[0];
    $this->fState = $x;
  }

  /**
   * Make sure a transitions graph has been defined
   *
   * @return void
   */
  protected function checkTransitions() {
    if (!isset($this->fTransitions))
      throw new Exception('No FSM processing is allowed without a transitions table\n');
  }

  /**
   * Getter for fState
   *
   * @return string
   */
  public function getState() {
    return $this->fState;
  }

  /**
   * return the list of events accepted in the current state
   * @return array
   */
  public function getAcceptedEvents() {
    $this->checkTransitions();

    try {
      $events = array_keys($this->fTransitions[$this->fState]);
      // echo func_name() . ": state $this->fState, accepted events are:\n" . print_r($events, true). "\n";
    }
    catch (Exception $e) {
      echo "Exception in getAcceptedEvents" . print_r($e);
      print_r(debug_backtrace());
      $events = array();
    }

    return $events;
  }

  /**
   * return the list of outcome accepted in the current state for the give event
   * @param string $eventName
   * @param mixed $outcome
   * @return array
   */
  public function getAcceptedOutcomes($eventName) {
    // echo func_name() . "\n";
    $this->checkTransitions();

    /**
     * Spare some paranioa
     *
     if (!$this->isEventAllowed($eventName))
       throw new Exception(func_name() . ": event \"$eventName\" not allowed in state \"$this->fState\"\n");
     */

    $outcomes = array_keys($this->fTransitions[$this->fState][$eventName]);
    // print_r($this->fTransitions[$this->fState][$eventName]);
    // echo "outcomes for event $eventName: " . var_dump($outcomes) . "\n";
    return $outcomes;
  }

  /**
   * is this event accepted in the current state
   * the FSM is in ?
   *
   * @param string $eventName
   * @return boolean
   */
  public function isEventAllowed($eventName) {
    // echo func_name() . "($eventName)";
    $this->checkTransitions();

    $ret = in_array($eventName, $this->getAcceptedEvents());
    // echo " in state $this->fState, result = <$ret>\n";
    return $ret;
  }

  /**
   * is a given outcome available for a given event,
   * considering the current context ?
   *
   * @param string $eventName
   * @param mixed $outcome
   * @return boolean
   */
  public function isOutcomeAllowed($eventName, $outcome) {
    $this->checkTransitions();

    if (!$this->isEventAllowed($eventName)) {
      return false;
    }

    $ret = array_key_exists($outcome, $this->getAcceptedOutcomes($eventName));
    return $ret;
  }

  /**
   * apply an event, and the resulting event chain if needed
   *
   * @param string $eventName
   * @param array $params the
   * @return Result resulting state
   */
  public function applyEvent($eventName) {
    // echo "Start of " . func_name() . "\n";

    do {
      $result = $this->applySimpleEvent($eventName);
      if ($this->allowActions) {
        $eventName = $result->fsmAction; // can be NULL
      } else {
        $eventName = NULL;
      }
    } while ($eventName);
    // echo "End of " . func_name() . "\n";
    return $result;
  }

  /**
   * Helper for applyEvent that does not implement the post-transition action
   *
   * @see applyEvent()
   *
   * @param string $eventName
   *
   * @return Result
   */
  private function applySimpleEvent($eventName) {
    // echo "Start of " . func_name() . ", event = $eventName\n";
    $currentState = $this->fState;
    if (($eventName == self::IDLE_EVENT) && !$this->idleWork) {
      return new Result();
    }

    if (!$this->isEventAllowed($eventName)) {
      die("Event $eventName not allowed in current state $currentState.\n");
      /* throw new Exception(func_name()
       . ":  Event \"$eventName\" not accepted in current state \"$currentState\"");
      */
    }

    $outcomes = $this->getAcceptedOutcomes($eventName);

    $methodName = "event$eventName";
    if (!method_exists($this, $methodName)) {
      die (func_name() . ": missing method "
        . get_class($this) . "::$methodName. Aborting.\n");
    }
    $outcome = $this->$methodName();

    if (!in_array($outcome, $outcomes)) {
      throw new \Exception(func_name()
        . ": event guard. Transition on \"$eventName\" return invalid outcome: "
        . print_r($outcome, true)
        . " for state $this->fState\n");
    }

    $transition = &$this->fTransitions[$currentState][$eventName][$outcome];
    $result = new Result (
        $outcome,
        $transition[0],
        $transition[1]
    );
    if (isset($result->fsmState)) {
      $this->fState = $result->fsmState;
    }
    /* print_r($this->fTransitions[$currentState][$eventName]);
     var_dump($result); */
    if (!isset($outcome)) {
      $outcome = 'NULL';
    }

    /*
     echo func_name()
    . ": $currentState: " . $eventName . '[' . $outcome . ']'
    . " -> $this->fState / " . $result->fsmAction . PHP_EOL;
    */
    return $result;
  }

  /**
   * Default event.
   *
   * @return boolean
   */
  public function fIdle() {
    return TRUE;
  }

  /**
   * Apply an fsm::IDLE_EVENT event. Do not confuse with fIdle !
   *
   * @return Result
   */
  public function idle() {
    return $this->applyEvent(self::IDLE_EVENT);
  }

  /**
   * return the current operating mode of the FSM
   * @return int
   */
  public function getEventMode() {
    return $this->fEventMode;
  }

  /**
   * set the current operating mode for the FSM
   *
   * @param int $mode fsm::EVENT_* constants
   * @return int $mode fsm::EVENT_* constants
   */
  public function setEventMode($mode) {
    switch ($mode) {
      case self::EVENT_NORMAL :
        if (count($this->fQueue) > 0) {
          while (($event = array_shift($this->fQueue)) !== NULL) {
            $this->applyEvent($event);
          }
        }
        break;

      // Nothing special to do
      case self::EVENT_QUEUE:
        break;

      // Empty queue if needed
      case self::EVENT_SINK :
        if (count($this->fQueue) > 0) {
          $this->fQueue = array();
        }
        break;

      default:
        throw new Exception("Trying to set unknown FSM mode $mode");
    }

    $this->fEventMode = $mode;
    return $this->fEventMode;
  }

  /**
   * Load the fTransitions table from an external resource.
   *
   * @param string $url
   *   Optional: defaults to the name of the class, . ".xml"
   */
  public function loadFsm($url = NULL) {
    $osd = FALSE; // on screen display (debug)
    if ($osd) {
      echo "Loading FSM from $url\n";
    }

    if (!isset($url)) {
      $url = get_class($this) . ".xml";
    }

    $fsm = simplexml_load_file($url);
    $fsmVersion = (string) $fsm['fsm_version'];
    if ($fsmVersion !== '1.3') {
      die("Revision $fsmVersion of schema is not supported.\n");
    }

    $this->idleWork     = ($fsm['idle_work']     == 1);
    $this->allowActions = ($fsm['allow_actions'] == 1);

    $this->fTransitions = array();
    $t = &$this->fTransitions;

    // (string) casts in this loop are required: RHS is a SimpleXMLElement
    foreach ($fsm->state as $state) {
      $id = (string) $state['id'];
      if ($osd) {
        echo "State $id :\n";
      }
      $t[$id] = array();
      switch ($id) {
        case self::INIT_STATE :
          if ($osd) {
            echo "  Initial state\n";
          }
          break;

        case self::FINAL_STATE :
          if ($osd) {
            echo "  Final state\n";
          }
          break;
      }

      foreach ($state->event as $event) {
        $name = (string) $event['name'];
        if ($osd) {
          echo "  Event $name";
        }

        if (!isset($event['type'])) {
          $event['type'] = 'void';
        }

        $eventType = (string) $event['type'];
        if ($osd) {
          echo ", type $eventType\n";
        }

        foreach ($event as $next) {
          if ($event['type'] == 'void') {
            $next['result'] = 'always';
            $result = null;
          }
          else {
            $result = (string) $next['result'];
          }

          if (!isset($next['state'])) {
            $next['state'] = (string) $state['id'];
          }
          if ($osd) {
            echo "    Next(" . $next['result'] . ') = ' . $next['state'];
            if (isset($next['action'])) {
              echo " + event " . $next['action'];
            }
            echo PHP_EOL;
          }
          $t[$id][$name][$result] = array(
            (string) $next['state'],
            (string) $next['action']);
        }
      }
    }
  }
}