defmodule GitGud.Email do
  @moduledoc """
  Email schema and helper functions.

  A `GitGud.Email` is used for different tasks such as user authentication & verification, notifications,
  identification of Git commit authors & commiters, etc.
  """

  use Ecto.Schema

  alias GitGud.DB
  alias GitGud.User

  alias Ecto.Multi

  import Ecto.Query, only: [from: 2]
  import Ecto.Changeset

  schema "emails" do
    belongs_to :user, User
    field :address, :string
    field :verified, :boolean, default: false
    timestamps(updated_at: false)
    field :verified_at, :naive_datetime
  end

  @type t :: %__MODULE__{
    id: pos_integer,
    user_id: pos_integer,
    user: User.t,
    address: binary,
    verified: boolean,
    inserted_at: NaiveDateTime.t,
    verified_at: NaiveDateTime.t
  }

  @doc """
  Creates a new email with the given `params`.

  ```elixir
  {:ok, email} = GitGud.Email.create(user, address: "m.flach@almightycouch.com")
  ```

  This function validates the given `params` using `changeset/2`.
  """
  @spec create(User.t, map|keyword) :: {:ok, t} | {:error, Ecto.Changeset.t}
  def create(user, params) do
    DB.insert(changeset(%__MODULE__{user_id: user.id}, Map.new(params)))
  end

  @doc """
  Similar to `create/1`, but raises an `Ecto.InvalidChangesetError` if an error occurs.
  """
  @spec create!(User.t, map|keyword) :: t
  def create!(user, params) do
    DB.insert!(changeset(%__MODULE__{user_id: user.id}, Map.new(params)))
  end

  @doc """
  Verifies the given `email`.

  ```elixir
  {:ok, email} = GitGud.Email.verify(email)
  ```

  This function verifies the given `email` and ensures that the associated user
  is updated accordingly.
  """
  @spec verify(t) :: {:ok, t} | {:error, Ecto.Changeset.t}
  def verify(%__MODULE__{} = email) do
    case DB.transaction(verify_email_consistency(email)) do
      {:ok, %{email: email}} ->
        {:ok, email}
      {:error, _operation, changeset, _changes} ->
        {:error, changeset}
    end
  end

  @doc """
  Similar to `verify/1`, but raises an `Ecto.InvalidChangesetError` if an error occurs.
  """
  @spec verify!(t) :: t
  def verify!(%__MODULE__{} = email) do
    case verify(email) do
      {:ok, email} -> email
      {:error, changeset} -> raise Ecto.InvalidChangesetError, action: changeset.action, changeset: changeset
    end
  end

  @doc """
  Deletes the given `email`.

  ```elixir
  {:ok, email} = GitGud.Email.delete(email)
  ```
  """
  @spec delete(t) :: {:ok, t} | {:error, Ecto.Changeset.t}
  def delete(%__MODULE__{} = email) do
    DB.delete(email)
  end

  @doc """
  Similar to `delete!/1`, but raises an `Ecto.InvalidChangesetError` if an error occurs.
  """
  @spec delete!(t) :: t
  def delete!(%__MODULE__{} = email) do
    DB.delete!(email)
  end

  @doc """
  Returns a changeset for the given `params`.
  """
  @spec changeset(t, map) :: Ecto.Changeset.t
  def changeset(%__MODULE__{} = email, params \\ %{}) do
    email
    |> cast(params, [:address])
    |> validate_required([:address])
    |> validate_format(:address, ~r/^[a-zA-Z0-9.!#$%&’*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/)
    |> check_constraint(:address, name: :emails_address_constraint, message: "already taken")
    |> unique_constraint(:address, name: :emails_user_id_address_index)
  end

  @doc """
  Returns a changeset for verifying the given `email`.
  """
  @spec verification_changeset(t) :: Ecto.Changeset.t
  def verification_changeset(%__MODULE__{} = email) do
    email
    |> change(%{verified: true, verified_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)})
    |> check_constraint(:address, name: :emails_address_constraint, message: "already taken")
  end

  #
  # Helpers
  #

  defp verify_email_consistency(email) do
    Multi.new()
    |> Multi.update(:email, verification_changeset(email))
    |> Multi.delete_all(:unverified_emails, from(e in __MODULE__, where: e.verified == false and e.address == ^email.address))
    |> Multi.run(:user, &fetch_user/2)
    |> Multi.run(:user_with_primary_email, &update_user_primary_email/2)
  end

  defp fetch_user(db, %{email: email}) do
    {:ok, db.get!(User, email.user_id)}
  end

  defp update_user_primary_email(db, %{user: user, email: email}) do
    unless user.primary_email_id,
      do: db.update(User.email_changeset(user, :primary_email, email)),
    else: {:ok, user}
  end
end
