defmodule Skogsra.Env do
  @moduledoc """
  This module defines a `Skogsra` environment variable.
  """
  alias __MODULE__

  @typedoc """
  Variable namespace.
  """
  @type namespace :: nil | atom()

  @typedoc """
  Application name.
  """
  @type app_name :: nil | atom()

  @typedoc """
  Key.
  """
  @type key :: atom()

  @typedoc """
  List of keys that lead to the value of the variable.
  """
  @type keys :: [key()]

  @typedoc """
  Variable binding.
  """
  @type binding :: :config | :system | module()

  @typedoc """
  Variable binding list.
  """
  @type bindings :: [binding()]

  @typedoc """
  Types.
  """
  @type type ::
          :binary
          | :integer
          | :neg_integer
          | :non_neg_integer
          | :pos_integer
          | :float
          | :boolean
          | :atom
          | :module
          | :unsafe_module
          | :any
          | module()

  @typedoc """
  Environment variable options.
  - `binding_order` - Variable binding load order.
  - `binding_skip` - Skips loading a variable from the list of bindings.
  - `os_env` - The name of the OS environment variable.
  - `type` - Type to cast the OS environment variable value.
  - `namespace` - Default namespace for the variable.
  - `default` - Default value.
  - `required` - Whether the variable is required or not.
  - `cached` - Whether the variable is cached or not.
  - `env_overrides` - Options overrides for specific environments.
  """
  @type option ::
          {:binding_order, bindings()}
          | {:binding_skip, bindings()}
          | {:os_env, binary()}
          | {:type, type()}
          | {:namespace, namespace()}
          | {:default, term()}
          | {:required, boolean()}
          | {:cached, boolean()}
          | {:env_overrides, keyword()}
          | {atom(), term()}

  @typedoc """
  Environment variable options:
  """
  @type options :: [option()]

  @doc """
  Environment variable struct.
  """
  defstruct namespace: nil,
            app_name: nil,
            keys: [],
            options: []

  @typedoc """
  Skogsra environment variable.
  """
  @type t :: %Env{
          namespace: namespace :: namespace(),
          app_name: app_name :: app_name(),
          keys: keys :: keys(),
          options: options :: options()
        }

  @doc """
  Creates a new `Skogsra` environment variable.
  """
  @spec new(namespace(), app_name(), key(), options()) :: t()
  @spec new(namespace(), app_name(), keys(), options()) :: t()
  def new(namespace, app_name, keys, options)

  def new(namespace, app_name, key, options) when is_atom(key) do
    new(namespace, app_name, [key], options)
  end

  def new(namespace, app_name, keys, options) when is_list(keys) do
    namespace = if is_nil(namespace), do: options[:namespace], else: namespace
    options = defaults(options)

    %Env{
      namespace: namespace,
      app_name: app_name,
      keys: keys,
      options: options
    }
  end

  @doc """
  Gets the OS variable name for the `Skogsra` environment variable.
  """
  @spec os_env(t()) :: binary()
  def os_env(%Env{options: options} = env) do
    with true <- :system in Env.binding_order(env),
         value when not is_binary(value) <- options[:os_env] do
      namespace = gen_namespace(env)
      app_name = gen_app_name(env)
      keys = gen_keys(env)

      "#{namespace}#{app_name}_#{keys}"
    else
      false -> ""
      value -> value
    end
  end

  @doc """
  Gets the type of the `Skogsra` environment variable.
  """
  @spec type(t()) :: type() | tuple()
  def type(%Env{options: options} = env) do
    with nil <- options[:type] do
      env
      |> default()
      |> get_type()
    end
  end

  @doc """
  Gets the default value for a `Skogsra` environment variable.
  """
  @spec default(t()) :: term()
  def default(%Env{options: options}) do
    env = find_environment()
    global = [default: options[:default]]
    overrides = options[:env_overrides][env] || global

    overrides[:default]
  end

  @doc """
  Whether the `Skogsra` environment variable is required or not.
  """
  @spec required?(t()) :: boolean()
  def required?(%Env{options: options}) do
    env = find_environment()
    global = [required: options[:required]]
    overrides = options[:env_overrides][env] || global

    required = overrides[:required]

    if is_boolean(required), do: required, else: false
  end

  @doc """
  Whether the `Skogsra` environment variable is cached or not.
  """
  @spec cached?(t()) :: boolean()
  def cached?(%Env{options: options}) do
    case options[:cached] do
      false -> false
      _ -> true
    end
  end

  @doc """
  Gets the binding order for a `Skogsra` environment variable.
  """
  @spec binding_order(t()) :: bindings()
  def binding_order(%Env{options: options}) do
    options[:binding_order] -- options[:binding_skip]
  end

  @doc """
  Gets extra options.
  """
  @spec extra_options(t()) :: keyword()
  def extra_options(%Env{options: options}) do
    keys = [
      :binding_order,
      :binding_skip,
      :os_env,
      :type,
      :namespace,
      :default,
      :required,
      :cached,
      :env_overrides
    ]

    Keyword.drop(options, keys)
  end

  #########
  # Helpers

  @doc false
  @spec defaults(options()) :: options()
  def defaults(options) do
    options
    |> Keyword.put_new(:required, false)
    |> Keyword.put_new(:cached, true)
    |> Keyword.put_new(:env_overrides, [])
    |> set_binding_order()
    |> set_binding_skip()
  end

  @doc false
  @spec set_binding_order(options()) :: options()
  def set_binding_order(options) do
    default = [:system, :config]

    bindings =
      options[:binding_order] || Application.get_env(:skogsra, :binding_order)

    if is_bindings?(bindings) do
      Keyword.put(options, :binding_order, bindings)
    else
      Keyword.put(options, :binding_order, default)
    end
  end

  @doc false
  @spec set_binding_skip(options()) :: options()
  def set_binding_skip(options) do
    default = []

    bindings =
      options[:binding_skip] || Application.get_env(:skogsra, :binding_skip)

    if is_bindings?(bindings) do
      Keyword.put(options, :binding_skip, bindings)
    else
      Keyword.put(options, :binding_skip, default)
    end
  end

  @doc false
  @spec is_bindings?(term()) :: boolean()
  def is_bindings?(other) when not is_list(other), do: false

  def is_bindings?(bindings) do
    Enum.all?(bindings, fn binding ->
      binding in [:system, :config] or Code.ensure_loaded?(binding)
    end)
  end

  @doc false
  @spec gen_namespace(t()) :: binary()
  def gen_namespace(env)

  def gen_namespace(%Env{namespace: nil}) do
    ""
  end

  def gen_namespace(%Env{namespace: namespace}) do
    value =
      namespace
      |> Module.split()
      |> Stream.map(&String.upcase/1)
      |> Enum.join("_")

    "#{value}_"
  end

  @doc false
  @spec gen_app_name(t()) :: binary()
  def gen_app_name(env)

  def gen_app_name(%Env{app_name: app_name}) do
    app_name
    |> Atom.to_string()
    |> String.upcase()
  end

  @doc false
  @spec gen_keys(t()) :: binary()
  def gen_keys(env)

  def gen_keys(%Env{keys: keys}) do
    keys
    |> Stream.map(&Atom.to_string/1)
    |> Stream.map(&String.upcase/1)
    |> Enum.join("_")
  end

  @doc false
  @spec get_type(term()) :: type()
  def get_type(value)

  def get_type(nil), do: :binary
  def get_type(value) when is_binary(value), do: :binary
  def get_type(value) when is_integer(value), do: :integer
  def get_type(value) when is_float(value), do: :float
  def get_type(value) when is_boolean(value), do: :boolean
  def get_type(value) when is_atom(value), do: :atom
  def get_type(_), do: :any

  @env_name {Env, :env_name}

  @doc false
  @spec find_environment() :: atom()
  def find_environment do
    with :miss <- :persistent_term.get(@env_name, :miss) do
      Code.ensure_loaded!(Mix)

      if function_exported?(Mix, :env, 0) do
        Mix
        |> apply(:env, [])
        |> tap(&:persistent_term.put(@env_name, &1))
      else
        raise RuntimeError, message: "No mix project defined"
      end
    end
  rescue
    _ ->
      "MIX_ENV"
      |> System.get_env("prod")
      |> String.to_atom()
      |> tap(&:persistent_term.put(@env_name, &1))
  end
end
