defmodule Cloak.Ecto.HMAC do
  @moduledoc """
  A custom `Ecto.Type` for hashing fields using `:crypto.hmac/3`.

  ## Why

  If you store a hash of a field's value, you can then query on it as a proxy
  for an encrypted field. This works because HMAC is deterministic and
  always results in the same value, while secure encryption does not. Be
  warned, however, that hashing will expose which fields have the same value,
  because they will contain the same hash.

  ## Security

  HMAC is **more secure** than `Cloak.Ecto.SHA256`, because it uses a
  secret to obfuscate the hash. This makes it harder to guess the value of
  the field.

  ## Configuration

  Create an `HMAC` field in your project:

      defmodule MyApp.Hashed.HMAC do
        use Cloak.Ecto.HMAC, otp_app: :my_app
      end

  Then, configure it with a `:secret` and `:algorithm`, either using
  mix configuration:

      config :my_app, MyApp.Hashed.HMAC,
        algorithm: :sha512,
        secret: "secret"

  Or using the `init/1` callback to fetch configuration at runtime:

      defmodule MyApp.Hashed.HMAC do
        use Cloak.Ecto.HMAC, otp_app: :my_app

        @impl Cloak.Ecto.HMAC
        def init(config) do
          config = Keyword.merge(config, [
            algorithm: :sha512,
            secret: System.get_env("HMAC_SECRET")
          ])

          {:ok, config}
        end
      end

  ## Usage

  Create the hash field with the type `:binary`. Add it to your schema
  definition like this:

      schema "table" do
        field :field_name, MyApp.Encrypted.Binary
        field :field_name_hash, MyApp.Hashed.HMAC
      end

  Ensure that the hash is updated whenever the target field changes with the
  `put_change/3` function:

      def changeset(struct, attrs \\\\ %{}) do
        struct
        |> cast(attrs, [:field_name, :field_name_hash])
        |> put_hashed_fields()
      end

      defp put_hashed_fields(changeset) do
        changeset
        |> put_change(:field_name_hash, get_field(changeset, :field_name))
      end

  Query the Repo using the `:field_name_hash` in any place you would typically
  query by `:field_name`.

      user = Repo.get_by(User, email_hash: "user@email.com")
  """

  @typedoc "HMAC algorithms supported by Cloak.Field.HMAC"
  @type algorithms :: :md5 | :ripemd160 | :sha | :sha224 | :sha256 | :sha384 | :sha512

  @doc """
  Configures the `HMAC` field using runtime information.

  ## Example

      @impl Cloak.Fields.HMAC
      def init(config) do
        config = Keyword.merge(config, [
          algorithm: :sha512,
          secret: System.get_env("HMAC_SECRET")
        ])

        {:ok, config}
      end
  """
  @callback init(config :: Keyword.t()) :: {:ok, Keyword.t()} | {:error, any}

  @doc false
  defmacro __using__(opts) do
    otp_app = Keyword.fetch!(opts, :otp_app)

    quote do
      alias Cloak.Ecto.Crypto

      @behaviour Cloak.Ecto.HMAC
      @behaviour Ecto.Type
      @algorithms ~w[
        md5
        ripemd160
        sha
        sha224
        sha256
        sha384
        sha512
      ]a

      @impl Cloak.Ecto.HMAC
      def init(config) do
        {:ok, config}
      end

      @impl Ecto.Type
      def type, do: :binary

      @impl Ecto.Type
      def cast(nil) do
        {:ok, nil}
      end

      def cast(value) when is_binary(value) do
        {:ok, value}
      end

      def cast(_value) do
        :error
      end

      @impl Ecto.Type
      def dump(nil) do
        {:ok, nil}
      end

      def dump(value) when is_binary(value) do
        config = build_config()
        {:ok, Crypto.hmac(config[:algorithm], config[:secret], value)}
      end

      def dump(_value) do
        :error
      end

      @impl Ecto.Type
      def embed_as(_format) do
        :self
      end

      @impl Ecto.Type
      def equal?(term1, term2) do
        term1 == term2
      end

      @impl Ecto.Type
      def load(value) do
        {:ok, value}
      end

      defoverridable init: 1, type: 0, cast: 1, dump: 1, load: 1, embed_as: 1, equal?: 2

      defp build_config do
        {:ok, config} =
          unquote(otp_app)
          |> Application.get_env(__MODULE__, [])
          |> init()

        validate_config(config)
      end

      defp validate_config(config) do
        unless is_binary(config[:secret]) do
          secret = inspect(config[:secret])

          raise Cloak.InvalidConfig, "#{secret} is an invalid secret for #{inspect(__MODULE__)}"
        end

        unless config[:algorithm] in @algorithms do
          algo = inspect(config[:algorithm])

          raise Cloak.InvalidConfig,
                "#{algo} is an invalid hash algorithm for #{inspect(__MODULE__)}"
        end

        config
      end
    end
  end
end
