Skip to main content

lib/squidie/runtime/deadline.ex

# credo:disable-for-this-file ExSlop.Check.Readability.DocFalseOnPublicFunction
defmodule Squidie.Runtime.Deadline do
  @moduledoc false

  alias Squidie.Workflow.Definition

  @escalation_outcomes [:diagnostic, :operator_action, :workflow_step, :host_callback]
  @escalation_outcome_strings Map.new(@escalation_outcomes, fn outcome ->
                                {Atom.to_string(outcome), outcome}
                              end)
  @deadline_keys %{
    "policy" => :policy,
    "started_at" => :started_at,
    "due_at" => :due_at,
    "due_soon_at" => :due_soon_at,
    "escalated_at" => :escalated_at,
    "within" => :within,
    "due_soon" => :due_soon,
    "escalate_after" => :escalate_after,
    "escalation" => :escalation,
    "outcome" => :outcome,
    "target" => :target
  }

  @type policy :: %{
          required(:within) => pos_integer(),
          optional(:due_soon) => non_neg_integer(),
          optional(:escalate_after) => non_neg_integer(),
          required(:escalation) => %{required(:outcome) => atom(), optional(:target) => term()}
        }

  @doc false
  @spec from_definition(Definition.t(), atom(), DateTime.t()) ::
          {:ok, map() | nil} | {:error, {:invalid_deadline, term()} | {:unknown_step, atom()}}
  def from_definition(definition, step_name, %DateTime{} = started_at) when is_atom(step_name) do
    with {:ok, %{opts: opts}} <- Definition.step(definition, step_name),
         {:ok, policy} <- policy_from_opts(opts) do
      {:ok, from_policy(policy, started_at)}
    end
  end

  @doc false
  @spec policy_from_opts(keyword()) ::
          {:ok, policy() | nil} | {:error, {:invalid_deadline, term()}}
  def policy_from_opts(opts) when is_list(opts) do
    case Keyword.fetch(opts, :deadline) do
      {:ok, deadline} -> normalize_policy(deadline)
      :error -> {:ok, nil}
    end
  end

  @doc false
  @spec normalize_policy(term()) :: {:ok, policy()} | {:error, {:invalid_deadline, term()}}
  def normalize_policy(policy) when is_list(policy) do
    if Keyword.keyword?(policy) do
      policy
      |> Map.new()
      |> normalize_policy()
    else
      {:error, {:invalid_deadline, policy}}
    end
  end

  def normalize_policy(policy) when is_map(policy) do
    policy = Map.new(policy, fn {key, value} -> {normalize_key(key), value} end)

    with {:ok, within} <- positive_integer(policy, :within),
         {:ok, due_soon} <- optional_non_negative_integer(policy, :due_soon),
         {:ok, escalate_after} <- optional_non_negative_integer(policy, :escalate_after),
         :ok <- validate_due_soon(due_soon, within),
         {:ok, escalation} <- normalize_escalation(Map.get(policy, :escalation, :diagnostic)) do
      {:ok,
       %{
         within: within,
         due_soon: due_soon,
         escalate_after: escalate_after,
         escalation: escalation
       }}
    else
      {:error, reason} -> {:error, {:invalid_deadline, reason}}
    end
  end

  def normalize_policy(policy), do: {:error, {:invalid_deadline, policy}}

  @doc false
  @spec from_policy(policy() | nil, DateTime.t()) :: map() | nil
  def from_policy(nil, %DateTime{}), do: nil

  def from_policy(policy, %DateTime{} = started_at) when is_map(policy) do
    due_at = DateTime.add(started_at, Map.fetch!(policy, :within), :millisecond)
    due_soon = Map.get(policy, :due_soon)
    escalate_after = Map.get(policy, :escalate_after)

    compact(%{
      policy: policy,
      started_at: started_at,
      due_at: due_at,
      due_soon_at: maybe_add(due_at, due_soon, -1),
      escalated_at: maybe_add(due_at, escalate_after, 1)
    })
  end

  @doc false
  @spec evaluate(term(), DateTime.t(), keyword()) :: map() | nil
  def evaluate(deadline, now, opts \\ [])

  def evaluate(nil, %DateTime{}, _opts), do: nil

  def evaluate(deadline, %DateTime{} = now, opts) when is_map(deadline) do
    deadline = normalize_deadline_map(deadline)
    due_at = Map.get(deadline, :due_at)

    if match?(%DateTime{}, due_at) do
      deadline
      |> Map.merge(%{
        status: status(deadline, now),
        overdue?: not before?(now, due_at),
        remaining_ms: DateTime.diff(due_at, now, :millisecond)
      })
      |> maybe_put(:step, Keyword.get(opts, :step))
      |> maybe_put(:runnable_key, Keyword.get(opts, :runnable_key))
      |> maybe_put(:escalation, escalation(deadline))
      |> compact()
    else
      nil
    end
  end

  def evaluate(_deadline, %DateTime{}, _opts), do: nil

  @doc false
  @spec most_urgent([map() | nil]) :: map() | nil
  def most_urgent(deadlines) when is_list(deadlines) do
    deadlines
    |> Enum.reject(&is_nil/1)
    |> Enum.sort_by(&urgency_sort_key/1)
    |> List.first()
  end

  @doc false
  @spec public_summary(term()) :: map() | nil
  def public_summary(deadline) when is_map(deadline) do
    deadline
    |> normalize_deadline_map()
    |> Map.take([
      :status,
      :overdue?,
      :remaining_ms,
      :started_at,
      :due_at,
      :due_soon_at,
      :escalated_at,
      :step,
      :runnable_key
    ])
    |> maybe_put(:escalation, public_escalation(escalation(normalize_deadline_map(deadline))))
    |> compact()
  end

  def public_summary(_deadline), do: nil

  defp normalize_deadline_map(deadline) do
    deadline
    |> Map.new(fn {key, value} -> {normalize_key(key), value} end)
    |> Map.update(:policy, %{}, &normalize_policy_map/1)
  end

  defp normalize_policy_map(policy) when is_map(policy) do
    policy
    |> Map.new(fn {key, value} -> {normalize_key(key), value} end)
    |> Map.update(:escalation, %{outcome: :diagnostic}, fn escalation ->
      case normalize_escalation(escalation) do
        {:ok, normalized} -> normalized
        {:error, _reason} -> %{outcome: :diagnostic}
      end
    end)
  end

  defp normalize_policy_map(_policy), do: %{}

  defp status(deadline, now) do
    cond do
      at_or_after?(now, Map.get(deadline, :escalated_at)) -> :escalated
      at_or_after?(now, Map.get(deadline, :due_at)) -> :overdue
      at_or_after?(now, Map.get(deadline, :due_soon_at)) -> :due_soon
      true -> :on_time
    end
  end

  defp escalation(deadline) do
    deadline
    |> Map.get(:policy, %{})
    |> Map.get(:escalation)
  end

  defp urgency_sort_key(deadline) do
    status_rank =
      case Map.get(deadline, :status) do
        :escalated -> 0
        :overdue -> 1
        :due_soon -> 2
        :on_time -> 3
        _other -> 4
      end

    due_at =
      case Map.get(deadline, :due_at) do
        %DateTime{} = due_at -> DateTime.to_unix(due_at, :microsecond)
        _missing -> 9_223_372_036_854_775_807
      end

    {status_rank, due_at}
  end

  defp positive_integer(policy, key) do
    case Map.get(policy, key) do
      value when is_integer(value) and value > 0 -> {:ok, value}
      value -> {:error, {key, value}}
    end
  end

  defp optional_non_negative_integer(policy, key) do
    case Map.get(policy, key) do
      nil -> {:ok, nil}
      value when is_integer(value) and value >= 0 -> {:ok, value}
      value -> {:error, {key, value}}
    end
  end

  defp validate_due_soon(nil, _within), do: :ok
  defp validate_due_soon(due_soon, within) when due_soon < within, do: :ok
  defp validate_due_soon(due_soon, _within), do: {:error, {:due_soon, due_soon}}

  defp normalize_escalation(outcome) when outcome in @escalation_outcomes do
    {:ok, %{outcome: outcome}}
  end

  defp normalize_escalation(outcome) when is_binary(outcome) do
    case Map.fetch(@escalation_outcome_strings, outcome) do
      {:ok, outcome} -> {:ok, %{outcome: outcome}}
      :error -> {:error, {:escalation, outcome}}
    end
  end

  defp normalize_escalation(%{} = escalation) do
    escalation = Map.new(escalation, fn {key, value} -> {normalize_key(key), value} end)

    case normalize_escalation(Map.get(escalation, :outcome)) do
      {:ok, %{outcome: outcome}} ->
        {:ok, maybe_put(%{outcome: outcome}, :target, Map.get(escalation, :target))}

      {:error, _reason} ->
        {:error, {:escalation, escalation}}
    end
  end

  defp normalize_escalation(escalation), do: {:error, {:escalation, escalation}}

  defp maybe_add(_datetime, nil, _direction), do: nil

  defp maybe_add(%DateTime{} = datetime, amount, direction),
    do: DateTime.add(datetime, amount * direction, :millisecond)

  defp at_or_after?(%DateTime{} = now, %DateTime{} = deadline) do
    DateTime.compare(now, deadline) in [:eq, :gt]
  end

  defp at_or_after?(%DateTime{}, _deadline), do: false

  defp before?(%DateTime{} = left, %DateTime{} = right) do
    DateTime.compare(left, right) == :lt
  end

  defp normalize_key(key) when is_binary(key) do
    Map.get(@deadline_keys, key, key)
  end

  defp normalize_key(key), do: key

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)

  defp public_escalation(%{outcome: outcome}), do: %{outcome: outcome}
  defp public_escalation(_escalation), do: nil

  defp compact(map) do
    Map.reject(map, fn {_key, value} -> is_nil(value) end)
  end
end