defmodule Vault do
  @external_resource readme = Path.join([__DIR__, "../README.md"])

  @moduledoc readme
             |> File.read!()
             |> String.split("<!-- MDOC -->")
             |> Enum.fetch!(1)

  @vault_key :__vault__

  @doc """
  Initializes `Vault` in the current process with the given data.

  ## Examples

      iex> Vault.init(current_user: "alice")
      :ok
      iex> Vault.get(:current_user)
      "alice"

  """
  def init(initial_data) do
    case get_dictionary_value(self(), @vault_key) do
      nil ->
        if is_nil(vault()) do
          Process.put(@vault_key, Map.new(initial_data))
          :ok
        else
          raise "Cannot initialize vault: ancestor process already has vault initialized"
        end

      _ ->
        raise "Vault already initialized in this process"
    end
  end

  if ProcessTree.OtpRelease.optimized_dictionary_access?() do
    defp get_dictionary_value(pid, key) do
      case Process.info(pid, {:dictionary, key}) do
        {{:dictionary, ^key}, :undefined} ->
          nil

        {{:dictionary, ^key}, value} ->
          value

        nil ->
          nil
      end
    end
  else
    defp get_dictionary_value(pid, key) do
      case Process.info(pid, :dictionary) do
        nil ->
          nil

        {:dictionary, dictionary} ->
          case List.keyfind(dictionary, key, 0) do
            {_key, value} ->
              value

            nil ->
              nil
          end
      end
    end
  end

  @doc """
  Returns the vault map for the current process or its nearest ancestor.

  ## Examples

      iex> Vault.init(current_user: "bob")
      :ok
      iex> Vault.vault()
      %{current_user: "bob"}

  """
  def vault(opts \\ []) do
    propagate_vault = Keyword.get(opts, :propagate_vault, :current)

    ProcessTree.get(@vault_key, cache: propagate_vault == :current)
  end

  # Reads

  @doc """
  Extracts the value from the current process vault OR from the nearest ancestor
  process vault (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "charlie")
      :ok
      iex> Vault.fetch!(:current_user)
      "charlie"

  See `Map.fetch!/2` for details.
  """
  def fetch!(key) do
    case vault(propagate_vault: :current) do
      nil -> raise KeyError, key: key
      vault_map -> Map.fetch!(vault_map, key)
    end
  end

  @doc """
  Extracts the value from the current process vault OR from the nearest ancestor
  process vault (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "diana")
      :ok
      iex> Vault.fetch(:current_user)
      {:ok, "diana"}

      iex> Vault.fetch(:missing_key)
      :error

  See `Map.fetch/2` for details.
  """
  def fetch(key) do
    case vault(propagate_vault: :current) do
      nil -> :error
      vault_map -> Map.fetch(vault_map, key)
    end
  end

  @doc """
  Extracts the value from the current process vault OR from the nearest ancestor
  process vault (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "eve")
      :ok
      iex> Vault.get(:current_user)
      "eve"

      iex> Vault.get(:missing_key, "default")
      "default"

  See `Map.get/3` for details.
  """
  def get(key, default \\ nil) do
    case vault(propagate_vault: :current) do
      nil -> default
      vault_map -> Map.get(vault_map, key, default)
    end
  end

  @doc """
  Extracts the value from the current process vault OR from the nearest ancestor
  process vault (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "frank")
      :ok
      iex> Vault.get_lazy(:current_user, fn -> "default" end)
      "frank"

      iex> Vault.get_lazy(:missing_key, fn -> "lazy_default" end)
      "lazy_default"

  See `Map.get_lazy/3` for details.
  """
  def get_lazy(key, fun) when is_function(fun, 0) do
    case vault(propagate_vault: :current) do
      nil -> fun.()
      vault_map -> Map.get_lazy(vault_map, key, fun)
    end
  end

  @doc """
  Checks if the key exists in the current process vault OR in the nearest ancestor
  process vault (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "grace")
      :ok
      iex> Vault.has_key?(:current_user)
      true

      iex> Vault.has_key?(:missing_key)
      false

  See `Map.has_key?/2` for details.
  """
  def has_key?(key) do
    case vault(propagate_vault: :current) do
      nil -> false
      vault_map -> Map.has_key?(vault_map, key)
    end
  end

  @doc """
  Returns the keys from the current process vault OR from the nearest ancestor process
  (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "henry", role: "admin")
      :ok
      iex> Vault.keys() |> Enum.sort()
      [:current_user, :role]

  See `Map.keys/1` for details.
  """
  def keys() do
    case vault(propagate_vault: :current) do
      nil -> []
      vault_map -> Map.keys(vault_map)
    end
  end

  @doc """
  Extracts the specified keys and their values from the current process vault OR from the nearest ancestor
  process vault (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "iris", role: "admin", team: "ops")
      :ok
      iex> Vault.take([:current_user, :role])
      %{current_user: "iris", role: "admin"}

  See `Map.take/2` for details.
  """
  def take(keys) when is_list(keys) do
    case vault(propagate_vault: :current) do
      nil -> %{}
      vault_map -> Map.take(vault_map, keys)
    end
  end

  @doc """
  Returns the vault as a keyword list from the current process OR from the nearest ancestor process
  (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "jack")
      :ok
      iex> Vault.to_list()
      [current_user: "jack"]

  See `Map.to_list/1` for details.
  """
  def to_list() do
    case vault(propagate_vault: :current) do
      nil -> []
      vault_map -> Map.to_list(vault_map)
    end
  end

  @doc """
  Returns the values from the current process vault OR from the nearest ancestor process
  (if any).

  If vault is found in an ancestor process, it's cached in the current process
  dictionary for faster subsequent access.

  ## Examples

      iex> Vault.init(current_user: "kate", role: "user")
      :ok
      iex> Vault.values() |> Enum.sort()
      ["kate", "user"]

  See `Map.values/1` for details.
  """
  def values() do
    case vault(propagate_vault: :current) do
      nil -> []
      vault_map -> Map.values(vault_map)
    end
  end

  # Unsafe updates

  @doc """
  Puts the given key-value pair into the current process vault.

  ## Examples

      iex> Vault.init(current_user: "leo")
      :ok
      iex> Vault.unsafe_put(:current_user, "updated_leo")
      :ok
      iex> Vault.get(:current_user)
      "updated_leo"

  The updates won't be propagated to processes that already have a vault initialized.
  """
  def unsafe_put(key, value) do
    current_vault = vault(propagate_vault: :none) || %{}
    new_vault = Map.put(current_vault, key, value)
    Process.put(@vault_key, new_vault)
    :ok
  end

  @doc """
  Updates the value for the given key in the current process vault.

  ## Examples

      iex> Vault.init(current_user: "mia")
      :ok
      iex> Vault.unsafe_update(:current_user, "default", fn user -> String.upcase(user) end)
      :ok
      iex> Vault.get(:current_user)
      "MIA"

  """
  def unsafe_update(key, default, fun) do
    current_vault = vault(propagate_vault: :none) || %{}
    new_vault = Map.update(current_vault, key, default, fun)
    Process.put(@vault_key, new_vault)
    :ok
  end

  @doc """
  Merges the given data into the current process vault.

  ## Examples

      iex> Vault.init(current_user: "nick")
      :ok
      iex> Vault.unsafe_merge(role: "admin", team: "engineering")
      :ok
      iex> Vault.get(:current_user)
      "nick"
      iex> Vault.get(:role)
      "admin"

  """
  def unsafe_merge(new_data) when is_map(new_data) or is_list(new_data) do
    current_vault = vault(propagate_vault: :none) || %{}
    new_vault = Map.merge(current_vault, Map.new(new_data))
    Process.put(@vault_key, new_vault)
    :ok
  end
end
