defmodule Flint.Types.Union do
  @moduledoc """
  Union type for Ecto. Allows the field to be any of the specified types.

  This is particularly useful if you want to allow one of multiple types and preserve the type of the
  input, so long as it adheres to the type (rather than forcing a cast).

  For example, suppose you want to allow an input to be a float, or an integer, but you want to
  preserve whichever type is passed (keep floats as floats and keep integers as integers), you could
  use a `Union`.

  ## Options
  * `:oneof` - Allowed types. Can be any valid `Ecto` type (including custom types). Required.
  * `:eager` - Whether to eagerly cast the field value into the `:oneof` in the order that
  they appear in the list. If `false`, `Flint` will attempt to determine if the value maps to a
  valid `Ecto` type before casting and will prefer that type (if it appears in `:oneof`). Defaults
  to `false`.

  ## Example

  ```elixir
  defmodule Character do
    use Flint

    embedded_schema do
      field! :name, :string
      field! :strength, Union, oneof: [:integer, :float]
    end
  end
  ```

  ```elixir
  Character.new!(%{name: "Elf", strength: 10})
  ```

  ```
  %Character{name: "Elf", strength: 10}
  ```

  ```elixir
  Character.new!(%{name: "Elf", strength: 10.0})
  ```

  ```
  %Character{name: "Elf", strength: 10.0}
  ```
  """
  use Ecto.ParameterizedType

  @impl true
  def init(opts) do
    # :schema and :field are implicitly passed by Ecto
    types = Keyword.fetch!(opts, :oneof)
    schema = Keyword.fetch!(opts, :schema)
    field = Keyword.fetch!(opts, :field)
    eager = Keyword.get(opts, :eager, false)

    types = Enum.map(types, &check_field_type!(schema, field, &1, opts))

    %{types: types, eager: eager}
  end

  # TODO: Should this be anything else (or does it matter if we're never expecting to interact with an adapter?)
  @impl true
  def type(_params), do: :any

  @impl true
  def cast(data, %{types: types, eager: true}) do
    Enum.find_value(types, :error, fn type ->
      case Ecto.Type.cast(type, data) do
        :error ->
          false

        other ->
          other
      end
    end)
  end

  def cast(data, %{types: types}) do
    inferred_type = value_type(data)

    cond do
      inferred_type in types ->
        Ecto.Type.cast(inferred_type, data)

      true ->
        cast(data, %{types: types, eager: true})
    end
  end

  @impl true
  def dump(nil, _, _), do: {:ok, nil}

  def dump(value, dumper, _params) do
    if Ecto.Type.composite?(value) do
      dumper.(value)
    else
      {:ok, value}
    end
  end

  @impl true
  def embed_as(_format, _params), do: :dump

  @impl true
  def load(_value, _loader, _params), do: :error

  # From https://github.com/elixir-ecto/ecto/blob/88100b862f69682e4bec4bd11ab8d459346817b0/lib/ecto/schema.ex#L2446

  defp check_field_type!(_mod, name, :datetime, _opts) do
    raise ArgumentError,
          "invalid type :datetime for field #{inspect(name)}. " <>
            "You probably meant to choose one between :naive_datetime " <>
            "(no time zone information) or :utc_datetime (time zone is set to UTC)"
  end

  defp check_field_type!(mod, name, type, opts) do
    cond do
      is_nil(type) ->
        nil

      composite?(type, name) ->
        {outer_type, inner_type} = type
        {outer_type, check_field_type!(mod, name, inner_type, opts)}

      not is_atom(type) ->
        raise ArgumentError, "invalid type #{Ecto.Type.format(type)} for field #{inspect(name)}"

      Ecto.Type.base?(type) ->
        type

      Code.ensure_compiled(type) == {:module, type} ->
        cond do
          function_exported?(type, :type, 0) ->
            type

          function_exported?(type, :type, 1) ->
            Ecto.ParameterizedType.init(type, Keyword.merge(opts, field: name, schema: mod))

          function_exported?(type, :__schema__, 1) ->
            raise ArgumentError,
                  "schema #{inspect(type)} is not a valid type for field #{inspect(name)}." <>
                    " Did you mean to use belongs_to, has_one, has_many, embeds_one, or embeds_many instead?"

          true ->
            raise ArgumentError,
                  "module #{inspect(type)} given as type for field #{inspect(name)} is not an Ecto.Type/Ecto.ParameterizedType"
        end

      true ->
        raise ArgumentError, "unknown type #{inspect(type)} for field #{inspect(name)}"
    end
  end

  defp composite?({composite, _} = type, name) do
    if Ecto.Type.composite?(composite) do
      true
    else
      raise ArgumentError,
            "invalid or unknown composite #{inspect(type)} for field #{inspect(name)}. " <>
              "Did you mean to use :array or :map as first element of the tuple instead?"
    end
  end

  defp composite?(_type, _name), do: false

  defp value_type(%Decimal{}), do: :decimal
  defp value_type(%Time{microsecond: {0, 0}}), do: :time
  defp value_type(%Time{}), do: :time_usec
  defp value_type(%NaiveDateTime{microsecond: {0, 0}}), do: :naive_datetime
  defp value_type(%NaiveDateTime{}), do: :naive_datetime_usec
  defp value_type(%DateTime{microsecond: {0, 0}}), do: :utc_datetime
  defp value_type(%DateTime{}), do: :utc_datetime_usec
  defp value_type(%{}), do: :map
  defp value_type(value) when is_integer(value), do: :integer
  defp value_type(value) when is_float(value), do: :float
  defp value_type(<<_first::utf8, _rest::binary>>), do: :string
  defp value_type(value) when is_binary(value), do: :binary
  defp value_type(value) when is_boolean(value), do: :boolean
  defp value_type([]), do: nil
  defp value_type([item] = value) when is_list(value), do: {:array, value_type(item)}
  defp value_type(_), do: nil
end
