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

  def reload_rule_set(server \\ __MODULE__) do
    GenServer.cast(server, :reload_default_rule_set)
  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 = load_rule_set_file(path)

    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

    persist? = args[:enable_persistence] || Application.get_env(:statux, :enable_persistence)
    folder = args[:persistence_folder] || Application.get_env(:statux, :persistence_folder)

    initial_states =
      case {persist?, folder} do
        {true, nil} ->
          raise "Statux #{readable_name}: You have enabled persistence, but did not provide a folder to persist data to. Configure as :statux, :persistence_folder or pass as argument :persistence_folder."
        {true, folder} ->
          Logger.info("Statux - Persistence is enabled, trying to read file for #{readable_name} from #{folder}")
          file_name = "#{folder}/#{readable_name}.dat"

          file_name
          |> String.replace("//", "/")
          |> File.exists?()
          |> case do
            false ->
              Logger.warn("Statux - Could not find existing state for #{readable_name} at #{folder}/#{readable_name}.dat. Creating empty state.")
              %{}
            true ->
              file_name
              |> File.read!()
              |> :erlang.binary_to_term()
          end
        _ -> %{}
      end

    Process.flag(:trap_exit, true)

    Logger.info("Statux - Successfully started for #{readable_name}")

    {
      :ok,
      %Statux.Models.TrackerState{
        name: readable_name,
        persistence: %{
          enabled: persist?,
          folder: folder,
        },
        pubsub: %{module: pubsub, topic: topic},
        rules: %{default: rules},
        rule_set_file: path,
        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(:reload_default_rule_set, data) do
    updated_data = try do
      new_rule_set =
        data.rule_set_file
        |> load_rule_set_file()

      data
      |> put_in([:rules, :default], new_rule_set)
    rescue
      _ ->
        Logger.error("Statux #{data.name} - Could not reload rule set file from #{data.rule_set_file}")
        data
    end

    {:noreply, updated_data}
  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 don't understand, this seems to never be called.
  def handle_info({:EXIT, _from, reason}, state) do
    Logger.warn("Statux #{state.name} - Exited with reason #{inspect reason}")
    maybe_persist_state(state)
    {:stop, reason, state}
  end

  @impl true
  def terminate(reason, state) do
    Logger.warn("Statux #{state.name} - Terminated with reason #{inspect reason}")
    maybe_persist_state(state)
    reason
  end

  defp load_rule_set_file(path) do
    case path != nil and File.exists?(path) do
      true ->
        Statux.RuleSet.load_json!(path)
      false ->
        raise "Statux - Missing configuration file for Statux. Expected at '#{path}'. Configure as :statux, :rule_set_file or pass as argument :rule_set_file."
    end
  end
  defp maybe_persist_state(%{persistence: %{enabled: true, folder: folder}} = state) do
    path = "#{folder}/#{state.name}.dat"

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

    File.mkdir_p!(Path.dirname(path))
    File.write!(path, state.states |> :erlang.term_to_binary)
  end
  defp maybe_persist_state(state) do
    Logger.info("Statux #{state.name} - Persistence is disabled")
  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