lib/Statux/tracker.ex

defmodule Statux.Tracker do
  use GenServer
  require Logger

  alias Statux.Models.EntityStatus
  alias Statux.Models.Status
  alias Statux.Models.TrackingData

  def start_link(args) do
    GenServer.start_link(
      __MODULE__,
      args,
      name: args[:name] || __MODULE__
    )
  end

  def put(server \\ __MODULE__, id, status_name, value, rule_set) do
    GenServer.cast(server, {:put, id, status_name, value, rule_set})
  end

  def get(server \\ __MODULE__, id) do
    GenServer.call(server, {:get, id})
  end

  def set(server \\ __MODULE__, id, status_name, option) do
    GenServer.call(server, {:set, id, status_name, option})
  end

  # CALLBACKS
  @impl true
  def init(args) do
    name = args[:name] || __MODULE__
    readable_name = case name do
      {:via, Registry, {_registry, name}} -> name
      {:global, name} -> name
      _ -> name
    end

    Logger.info("Starting #{__MODULE__} '#{inspect name}'")

    path = case args[:rule_set_file] || Application.get_env(:statux, :rule_set_file) do
      nil -> raise "Statux #{readable_name} - Missing configuration file for Statux. Configure as :statux, :rule_set_file or pass as argument :rule_set_file"
      path -> path |> Path.expand
    end

    rules =
      case path != nil and File.exists?(path) do
        true ->
          Statux.RuleSet.load_json!(path)
        false ->
          raise "Statux #{readable_name} - Missing configuration file for Statux. Expected at '#{path}'. Configure as :statux, :rule_set_file or pass as argument :rule_set_file."
      end

    pubsub = args[:pubsub] || Application.get_env(:statux, :pubsub)

    topic =
      if pubsub == nil do
        Logger.warn("Statux #{readable_name} - No PubSub configured for Statux. Configure as :statux, :pubsub or pass as argument :pubsub")
        nil
      else
        case args[:topic] || Application.get_env(:statux, :topic) do
          nil ->
            Logger.warn("Statux #{readable_name} - No PubSub topic configured for Statux. Configure as :statux, :topic or pass as argument :topic. Defaulting to topic 'Statux'")
            "Statux"
          topic ->
            topic
        end
      end

    initial_states =
      case args[:persist] do
        {true, folder} ->
          "#{folder}/#{readable_name}.dat"
          |> String.replace("//", "/")
          |> File.read!()
          |> :erlang.binary_to_term()
        _ -> %{}
      end

    {persist?, persistence_folder} = args[:persist] || {false, ""}

    Process.flag(:trap_exit, true)

    {
      :ok,
      %Statux.Models.TrackerState{
        name: readable_name,
        persistence: %{
          enabled: persist?,
          folder: persistence_folder,
        },
        pubsub: %{module: pubsub, topic: topic},
        rules: %{default: rules},
        states: initial_states,
      }
    }
  end

  @impl true
  def handle_cast({:put, id, status_name, value, rule_set} = _message, data) do
    {:noreply, data |> process_new_data(id, status_name, value, rule_set)}
  end

  @impl true
  def handle_cast(_message, data) do
    Logger.debug "#{__MODULE__} - handle_cast FALLBACK"
    {:noreply, data}
  end

  @impl true
  def handle_call({:get, id}, _from_pid, state) do
    {:reply, state.states[id][:current_status], state}
  end

  @impl true
  def handle_call({:set, id, status_name, option}, _from_pid, state) do
    {updated_status, updated_state} =
      set_status(state, id, status_name, option)

    {:reply, updated_status, updated_state}
  end

  @impl true
  # For reasons i dont understand, this seems to never be called.
  def handle_info({:EXIT, _pid, reason}, state) do
    Logger.warn("Statux #{state.name} - Stopped with reason #{inspect reason}")
    if state.persistence.enabled == true do
      path = "#{state.persistence.folder}/#{state.name}.dat"

      Logger.info("Statux #{state.name} - Persistence is enabled, storing states under #{path}")

      File.mkdir_p!(Path.dirname(path))
      File.write!(path, state.states |> :erlang.term_to_binary)
    end
    {:stop, reason, state}
  end

  def set_status(state, id, status_name, option) do
    defined_options =
      state.rules[state.states[id][:rule_set_name] || :default][status_name][:status] |> Map.keys()

    valid_option? =
      option in defined_options

    case valid_option? do
      false ->
        {{:error, :invalid_option}, state}
      true ->
        updated_status =
          state.states[id][:current_status][status_name]
          |> Status.transition(option)

        updated_tracking =
          state.states[id][:tracking][status_name]
          |> Map.keys
          |> Enum.reduce(state.states[id][:tracking][status_name], fn option, tracking ->
            tracking
            |> update_in([option], fn option_tracking_data ->
              option_tracking_data
              |> TrackingData.reset()
            end)
          end)

        updated_state = state
        |> put_in([:states, id, :current_status, status_name], updated_status)
        |> put_in([:states, id, :tracking, status_name], updated_tracking)

        {updated_status, updated_state}
    end
  end

  # Data processing
  def process_new_data(data, id, status_name, value, rule_set_name \\ :default) do
    rule_set = data.rules[rule_set_name] || data.rules[:default] || %{}
    cond do
      # no status with this name
      rule_set[status_name] == nil ->
        data
      # value should be ignored
      Statux.ValueRules.should_be_ignored?(value, rule_set[status_name]) ->
        Logger.debug "Value #{inspect value} is to be ignored for rule set '#{inspect status_name}'"
        data
      # process the value
      true -> data |> evaluate_new_status(id, status_name, value, rule_set)
    end
  end

  defp evaluate_new_status(data, id, status_name, value, rule_set) do
    entity_status =
      data.states[id] || EntityStatus.new_from_rule_set(id, rule_set)

    status_options =
      rule_set[status_name][:status]

    case status_options do
      nil -> data
      _ ->
        valid_options_for_value = value
        |> Statux.ValueRules.find_possible_valid_status(status_options)

        updated_entity_status = entity_status
        |> Statux.Entities.update_tracking_data(status_name, status_options, valid_options_for_value)

        transitions = updated_entity_status
        |> Statux.Constraints.filter_valid_transition_options(status_name, status_options, valid_options_for_value)

        transitioned_entity_status = updated_entity_status
        |> Statux.Transitions.transition(status_name, transitions, data.pubsub)

        put_in(data, [:states, id], transitioned_entity_status)
    end
  end

end