defmodule WhoNeedHelp.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias WhoNeedHelp.Pagination
  alias WhoNeedHelp.Repo

  alias WhoNeedHelp.Accounts.{
    AuthIdentity,
    Scope,
    SocialIdentity,
    User,
    UserNotifier,
    UserToken
  }

  ## Database getters

  @public_user_fields [:id, :display_name, :bio, :tip_url, :inserted_at]
  @public_social_identity_fields [
    :id,
    :provider,
    :profile_url,
    :handle,
    :verified_at,
    :user_id,
    :inserted_at
  ]
  @registration_key_atoms %{
    "display_name" => :display_name,
    "email" => :email,
    "email_verified" => :email_verified,
    "locale" => :locale,
    "provider_uid" => :provider_uid,
    "terms_accepted" => :terms_accepted
  }

  @doc """
  Returns the deliberately small user projection used by public and
  participant-facing views.

  Authentication, account settings, and moderation queries must continue to
  load the full schema explicitly. In particular, this projection never loads
  email, password hashes, roles, or moderation notes.
  """
  def public_user_query(options \\ []) do
    query =
      from user in User,
        select: struct(user, ^@public_user_fields)

    if Keyword.get(options, :social_identities, false) do
      public_social_identity = public_social_identity_query()
      preload(query, social_identities: ^public_social_identity)
    else
      query
    end
  end

  def public_social_identity_query do
    from identity in SocialIdentity,
      select: struct(identity, ^@public_social_identity_fields)
  end

  @doc """
  Gets a user by email.

  ## Examples

      iex> get_user_by_email("foo@example.com")
      %User{}

      iex> get_user_by_email("unknown@example.com")
      nil

  """
  def get_user_by_email(email) when is_binary(email) do
    Repo.get_by(User, email: String.trim(email))
  end

  @doc """
  Gets a user by email and password.

  ## Examples

      iex> get_user_by_email_and_password("foo@example.com", "correct_password")
      %User{}

      iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
      nil

  """
  def get_user_by_email_and_password(email, password)
      when is_binary(email) and is_binary(password) do
    user = get_user_by_email(email)

    if User.valid_password?(user, password) and user.moderation_status != :suspended,
      do: user
  end

  @doc """
  Gets a single user.

  Raises `Ecto.NoResultsError` if the User does not exist.

  ## Examples

      iex> get_user!(123)
      %User{}

      iex> get_user!(456)
      ** (Ecto.NoResultsError)

  """
  def get_user!(id), do: Repo.get!(User, id)

  def eligible_for_trust_actions?(%User{
        confirmed_at: confirmed_at,
        accepted_terms_at: accepted_terms_at,
        moderation_status: :active
      }) do
    not is_nil(confirmed_at) and not is_nil(accepted_terms_at)
  end

  def eligible_for_trust_actions?(_user), do: false

  def eligible_user_id?(user_id) do
    Repo.exists?(
      from user in User,
        where:
          user.id == ^user_id and user.moderation_status == :active and
            not is_nil(user.confirmed_at) and not is_nil(user.accepted_terms_at)
    )
  end

  def moderator?(%User{role: role}), do: role in [:moderator, :admin]
  def moderator?(_user), do: false

  def moderator_authorized?(%User{id: id, role: role})
      when role in [:moderator, :admin],
      do:
        Repo.exists?(
          from user in User,
            where:
              user.id == ^id and user.role in [:moderator, :admin] and
                user.moderation_status == :active
        )

  def moderator_authorized?(_user), do: false

  def admin?(%User{role: :admin}), do: true
  def admin?(_user), do: false

  def admin_authorized?(%User{id: id, role: :admin}),
    do:
      Repo.exists?(
        from user in User,
          where: user.id == ^id and user.role == :admin and user.moderation_status == :active
      )

  def admin_authorized?(_user), do: false

  def list_users_for_moderation(%Scope{user: user}) do
    paginate_users_for_moderation(%Scope{user: user}).entries
  end

  def paginate_users_for_moderation(%Scope{user: user}, options \\ []) do
    if moderator_authorized?(user) do
      limit = Pagination.limit(options)
      cursor = Pagination.cursor(options)

      User
      |> before_moderation_user(cursor)
      |> order_by([user], desc: user.inserted_at, desc: user.id)
      |> limit(^(limit + 1))
      |> Repo.all()
      |> Pagination.page(limit, &{&1.inserted_at, &1.id})
    else
      %Pagination.Page{}
    end
  end

  def moderate_user(%User{} = moderator, user_id, attrs) do
    with {:ok, user_id} <- cast_id(user_id),
         true <- moderator_authorized?(moderator) do
      Repo.transact(fn ->
        active_admins = lock_active_admins()
        user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()

        if user do
          requested_status = attrs["moderation_status"] || attrs[:moderation_status]

          cond do
            user.id == moderator.id and
                requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
              {:error, :cannot_restrict_self}

            user.role == :admin and user.moderation_status == :active and
              requested_status not in [:active, "active"] and length(active_admins) == 1 ->
              {:error, :last_admin}

            true ->
              with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do
                session_tokens =
                  if user.moderation_status == :suspended do
                    tokens =
                      UserToken
                      |> where([token], token.user_id == ^user.id and token.context == "session")
                      |> select([token], token.token)
                      |> Repo.all()

                    Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id)
                    tokens
                  else
                    []
                  end

                {:ok, %{user: user, expired_session_tokens: session_tokens}}
              end
          end
        else
          {:error, :not_found}
        end
      end)
    else
      false -> {:error, :forbidden}
      {:error, :not_found} = error -> error
    end
  end

  def change_user_role(%User{} = admin, user_id, attrs) do
    with {:ok, user_id} <- cast_id(user_id),
         true <- admin_authorized?(admin) do
      Repo.transact(fn ->
        active_admins = lock_active_admins()
        user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()

        if user do
          requested_role = attrs["role"] || attrs[:role]

          if user.role == :admin and user.moderation_status == :active and
               requested_role not in [:admin, "admin"] and length(active_admins) == 1 do
            {:error, :last_admin}
          else
            user |> User.role_changeset(attrs) |> Repo.update()
          end
        else
          {:error, :not_found}
        end
      end)
    else
      false -> {:error, :forbidden}
      {:error, :not_found} = error -> error
    end
  end

  defp lock_active_admins do
    User
    |> where([user], user.role == :admin and user.moderation_status == :active)
    |> order_by([user], asc: user.id)
    |> lock("FOR UPDATE")
    |> Repo.all()
  end

  ## User registration

  @doc """
  Registers a user.

  ## Examples

      iex> register_user(%{field: value})
      {:ok, %User{}}

      iex> register_user(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def register_user(attrs) do
    %User{}
    |> User.registration_changeset(attrs)
    |> Repo.insert()
  end

  def change_user_registration(%User{} = user, attrs \\ %{}, opts \\ []) do
    User.registration_changeset(user, attrs, opts)
  end

  def change_user_profile(%User{} = user, attrs \\ %{}) do
    User.profile_changeset(user, attrs)
  end

  def update_user_profile(%User{} = user, attrs) do
    user
    |> User.profile_changeset(attrs)
    |> Repo.update()
  end

  def list_social_identities(%User{id: user_id}) do
    SocialIdentity
    |> where([identity], identity.user_id == ^user_id)
    |> order_by([identity], asc: identity.provider, asc: identity.inserted_at)
    |> Repo.all()
  end

  def change_social_identity(%SocialIdentity{} = identity, attrs \\ %{}) do
    SocialIdentity.changeset(identity, attrs)
  end

  def add_social_identity(%User{id: user_id}, attrs) do
    %SocialIdentity{user_id: user_id}
    |> SocialIdentity.changeset(attrs)
    |> Repo.insert()
  end

  def upsert_verified_social_identity(
        %User{id: user_id},
        %{provider: provider, provider_uid: provider_uid} = attrs
      ) do
    Repo.transact(fn ->
      existing =
        SocialIdentity
        |> where(
          [identity],
          identity.provider == ^provider and identity.provider_uid == ^provider_uid
        )
        |> lock("FOR UPDATE")
        |> Repo.one()

      case existing do
        %SocialIdentity{user_id: ^user_id} = identity ->
          identity
          |> SocialIdentity.verified_changeset(Map.put(attrs, :user_id, user_id))
          |> Repo.update()

        %SocialIdentity{} ->
          {:error, :already_linked}

        nil ->
          %SocialIdentity{}
          |> SocialIdentity.verified_changeset(Map.put(attrs, :user_id, user_id))
          |> Repo.insert()
      end
    end)
  end

  def delete_social_identity(%User{id: user_id}, identity_id) do
    with {:ok, identity_id} <- Ecto.UUID.cast(identity_id),
         %SocialIdentity{} = identity <-
           Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
      Repo.delete(identity)
    else
      _invalid_or_missing -> {:error, :not_found}
    end
  end

  ## Authentication identities

  def google_auth_connected?(%User{id: user_id}) do
    Repo.exists?(
      from identity in AuthIdentity,
        where: identity.user_id == ^user_id and identity.provider == :google
    )
  end

  def login_user_by_google(identity_attrs) do
    with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
      Repo.transact(fn ->
        identity =
          AuthIdentity
          |> where(
            [identity],
            identity.provider == :google and
              identity.provider_uid == ^identity_attrs.provider_uid
          )
          |> lock("FOR UPDATE")
          |> Repo.one()

        case identity do
          nil ->
            {:error, :not_linked}

          %AuthIdentity{} = identity ->
            with %User{} = user <-
                   User
                   |> where([user], user.id == ^identity.user_id)
                   |> lock("FOR UPDATE")
                   |> Repo.one(),
                 true <- user.moderation_status != :suspended,
                 {:ok, _identity} <-
                   identity
                   |> AuthIdentity.changeset(%{email: identity_attrs.email})
                   |> Repo.update() do
              {:ok, user}
            else
              _missing_or_suspended -> {:error, :not_found}
            end
        end
      end)
    end
  end

  def register_user_by_google(identity_attrs, registration_attrs)
      when is_map(registration_attrs) do
    with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
      Repo.transact(fn ->
        identity =
          AuthIdentity
          |> where(
            [identity],
            identity.provider == :google and
              identity.provider_uid == ^identity_attrs.provider_uid
          )
          |> lock("FOR UPDATE")
          |> Repo.one()

        case identity do
          %AuthIdentity{} = identity ->
            google_identity_user(identity, identity_attrs.email)

          nil ->
            register_google_identity(identity_attrs, registration_attrs)
        end
      end)
    end
  end

  def register_user_by_google(_identity_attrs, _registration_attrs),
    do: {:error, :invalid_registration}

  def link_google_identity(%User{id: user_id}, identity_attrs) do
    with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
      Repo.transact(fn ->
        user =
          User
          |> where([user], user.id == ^user_id)
          |> lock("FOR UPDATE")
          |> Repo.one()

        cond do
          is_nil(user) or user.moderation_status == :suspended ->
            {:error, :not_found}

          true ->
            upsert_google_identity_for_user(user, identity_attrs)
        end
      end)
    end
  end

  def unlink_google_identity(%User{id: user_id}) do
    Repo.transact(fn ->
      identity =
        AuthIdentity
        |> where(
          [identity],
          identity.provider == :google and identity.user_id == ^user_id
        )
        |> lock("FOR UPDATE")
        |> Repo.one()

      case identity do
        %AuthIdentity{} = identity -> Repo.delete(identity)
        nil -> {:error, :not_found}
      end
    end)
  end

  defp register_google_identity(identity_attrs, registration_attrs) do
    existing_user =
      User
      |> where([user], user.email == ^identity_attrs.email)
      |> lock("FOR UPDATE")
      |> Repo.one()

    if existing_user do
      {:error, :email_already_registered}
    else
      attrs = %{
        "email" => identity_attrs.email,
        "display_name" => identity_attrs.display_name,
        "locale" => registration_value(registration_attrs, "locale", "en"),
        "terms_accepted" => registration_value(registration_attrs, "terms_accepted", false)
      }

      with {:ok, user} <- register_user(attrs),
           {:ok, user} <- user |> User.confirm_changeset() |> Repo.update(),
           {:ok, identity} <-
             %AuthIdentity{}
             |> AuthIdentity.changeset(%{
               provider: :google,
               provider_uid: identity_attrs.provider_uid,
               email: identity_attrs.email,
               user_id: user.id
             })
             |> Repo.insert() do
        {:ok, {user, identity}}
      end
    end
  end

  defp google_identity_user(identity, current_email) do
    user =
      User
      |> where([user], user.id == ^identity.user_id)
      |> lock("FOR UPDATE")
      |> Repo.one()

    with %User{} = user <- user,
         true <- user.moderation_status != :suspended,
         {:ok, identity} <-
           identity
           |> AuthIdentity.changeset(%{email: current_email})
           |> Repo.update() do
      {:ok, {user, identity}}
    else
      _missing_or_suspended -> {:error, :not_found}
    end
  end

  defp upsert_google_identity_for_user(user, identity_attrs) do
    provider_identity =
      AuthIdentity
      |> where(
        [identity],
        identity.provider == :google and
          identity.provider_uid == ^identity_attrs.provider_uid
      )
      |> lock("FOR UPDATE")
      |> Repo.one()

    user_identity =
      AuthIdentity
      |> where(
        [identity],
        identity.provider == :google and identity.user_id == ^user.id
      )
      |> lock("FOR UPDATE")
      |> Repo.one()

    cond do
      provider_identity && provider_identity.user_id != user.id ->
        {:error, :already_linked}

      user_identity && user_identity.provider_uid != identity_attrs.provider_uid ->
        {:error, :provider_already_linked}

      identity = provider_identity || user_identity ->
        identity
        |> AuthIdentity.changeset(%{email: identity_attrs.email})
        |> Repo.update()

      true ->
        %AuthIdentity{}
        |> AuthIdentity.changeset(%{
          provider: :google,
          provider_uid: identity_attrs.provider_uid,
          email: identity_attrs.email,
          user_id: user.id
        })
        |> Repo.insert()
    end
  end

  defp normalize_google_identity(identity_attrs) when is_map(identity_attrs) do
    provider_uid = registration_value(identity_attrs, "provider_uid", nil)
    email = registration_value(identity_attrs, "email", nil)
    email_verified = registration_value(identity_attrs, "email_verified", false)
    display_name = registration_value(identity_attrs, "display_name", nil)

    cond do
      email_verified != true ->
        {:error, :email_not_verified}

      not is_binary(provider_uid) or provider_uid == "" or byte_size(provider_uid) > 255 ->
        {:error, :invalid_provider_identity}

      not is_binary(email) or email == "" or byte_size(email) > 160 ->
        {:error, :invalid_provider_identity}

      not is_binary(display_name) or String.trim(display_name) == "" ->
        {:error, :invalid_provider_identity}

      true ->
        {:ok,
         %{
           provider_uid: provider_uid,
           email: email |> String.trim() |> String.downcase(),
           email_verified: true,
           display_name: display_name |> String.trim() |> String.slice(0, 80)
         }}
    end
  end

  defp normalize_google_identity(_identity_attrs),
    do: {:error, :invalid_provider_identity}

  defp registration_value(attrs, key, default) do
    case Map.fetch(attrs, key) do
      {:ok, value} ->
        value

      :error ->
        case Map.fetch(@registration_key_atoms, key) do
          {:ok, atom_key} -> Map.get(attrs, atom_key, default)
          :error -> default
        end
    end
  end

  ## Settings

  @doc """
  Checks whether the user is in sudo mode.

  The user is in sudo mode when the last authentication was done no further
  than 20 minutes ago. The limit can be given as second argument in minutes.
  """
  def sudo_mode?(user, minutes \\ -20)

  def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
    DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
  end

  def sudo_mode?(_user, _minutes), do: false

  @doc """
  Returns an `%Ecto.Changeset{}` for changing the user email.

  See `WhoNeedHelp.Accounts.User.email_changeset/3` for a list of supported options.

  ## Examples

      iex> change_user_email(user)
      %Ecto.Changeset{data: %User{}}

  """
  def change_user_email(user, attrs \\ %{}, opts \\ []) do
    User.email_changeset(user, attrs, opts)
  end

  @doc """
  Updates the user email using the given token.

  If the token matches, the user email is updated and the token is deleted.
  """
  def update_user_email(user, token) do
    context = "change:#{user.email}"

    Repo.transact(fn ->
      with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
           %UserToken{sent_to: email} <- query |> lock("FOR UPDATE") |> Repo.one(),
           {:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
           {count, _result} when count > 0 <-
             Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
        {:ok, user}
      else
        _ -> {:error, :transaction_aborted}
      end
    end)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for changing the user password.

  See `WhoNeedHelp.Accounts.User.password_changeset/3` for a list of supported options.

  ## Examples

      iex> change_user_password(user)
      %Ecto.Changeset{data: %User{}}

  """
  def change_user_password(user, attrs \\ %{}, opts \\ []) do
    User.password_changeset(user, attrs, opts)
  end

  @doc """
  Updates the user password.

  Returns a tuple with the updated user, as well as a list of expired tokens.

  ## Examples

      iex> update_user_password(user, %{password: ...})
      {:ok, {%User{}, [...]}}

      iex> update_user_password(user, %{password: "too short"})
      {:error, %Ecto.Changeset{}}

  """
  def update_user_password(user, attrs) do
    user
    |> User.password_changeset(attrs)
    |> update_user_and_delete_all_tokens()
  end

  ## Session

  @doc """
  Generates a session token.
  """
  def generate_user_session_token(user) do
    {token, user_token} = UserToken.build_session_token(user)
    Repo.insert!(user_token)
    token
  end

  @doc """
  Gets the user with the given signed token.

  If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
  """
  def get_user_by_session_token(token) do
    {:ok, query} = UserToken.verify_session_token_query(token)
    Repo.one(query)
  end

  @doc """
  Gets the user with the given magic link token.
  """
  def get_user_by_magic_link_token(token) do
    with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
         {%User{moderation_status: status} = user, _token} <- Repo.one(query),
         true <- status != :suspended do
      user
    else
      _ -> nil
    end
  end

  @doc """
  Logs the user in by magic link.

  There are three cases to consider:

  1. The user has already confirmed their email. They are logged in
     and the magic link is expired.

  2. The user has not confirmed their email and no password is set.
     In this case, the user gets confirmed, logged in, and all tokens -
     including session ones - are expired. In theory, no other tokens
     exist but we delete all of them for best security practices.

  3. The user has not confirmed their email but a password is set.
     This cannot happen in the default implementation but may be the
     source of security pitfalls. See the "Mixing magic link and password registration" section of
     `mix help phx.gen.auth`.
  """
  def login_user_by_magic_link(token) do
    with {:ok, query} <- UserToken.verify_magic_link_token_query(token) do
      result =
        Repo.transact(fn ->
          case query |> lock("FOR UPDATE") |> Repo.one() do
            {%User{moderation_status: :suspended}, token} ->
              with {:ok, _token} <- Repo.delete(token) do
                {:ok, {:rejected, :not_found}}
              end

            # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
            {%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
              raise """
              magic link log in is not allowed for unconfirmed users with a password set!

              This cannot happen with the default implementation, which indicates that you
              might have adapted the code to a different use case. Please make sure to read the
              "Mixing magic link and password registration" section of `mix help phx.gen.auth`.
              """

            {%User{confirmed_at: nil} = user, _token} ->
              user
              |> User.confirm_changeset()
              |> update_user_and_delete_all_tokens_in_transaction()

            {user, token} ->
              with {:ok, _token} <- Repo.delete(token) do
                {:ok, {user, []}}
              end

            nil ->
              {:error, :not_found}
          end
        end)

      case result do
        {:ok, {:rejected, reason}} -> {:error, reason}
        other -> other
      end
    else
      _invalid_token -> {:error, :not_found}
    end
  end

  @doc ~S"""
  Delivers the update email instructions to the given user.

  ## Examples

      iex> deliver_user_update_email_instructions(user, current_email, &"https://example.test/users/settings/confirm-email#token=#{&1}")
      {:ok, %{to: ..., body: ...}}

  """
  def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
      when is_function(update_email_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")

    Repo.insert!(user_token)
    UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
  end

  @doc """
  Delivers the magic link login instructions to the given user.
  """
  def deliver_login_instructions(%User{} = user, magic_link_url_fun)
      when is_function(magic_link_url_fun, 1) do
    {encoded_token, user_token} = UserToken.build_email_token(user, "login")
    Repo.insert!(user_token)
    UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
  end

  @doc """
  Deletes the signed token with the given context.
  """
  def delete_user_session_token(token) do
    Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
    :ok
  end

  def delete_expired_user_tokens(now \\ DateTime.utc_now(:second)) do
    now
    |> UserToken.expired_tokens_query()
    |> Repo.delete_all()
  end

  ## Token helper

  defp before_moderation_user(query, nil), do: query

  defp before_moderation_user(query, {inserted_at, id}) do
    where(
      query,
      [user],
      user.inserted_at < ^inserted_at or
        (user.inserted_at == ^inserted_at and user.id < ^id)
    )
  end

  defp cast_id(value) do
    case Ecto.UUID.cast(value) do
      {:ok, id} -> {:ok, id}
      :error -> {:error, :not_found}
    end
  end

  defp update_user_and_delete_all_tokens(changeset) do
    Repo.transact(fn -> update_user_and_delete_all_tokens_in_transaction(changeset) end)
  end

  defp update_user_and_delete_all_tokens_in_transaction(changeset) do
    with {:ok, user} <- Repo.update(changeset) do
      tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)

      Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))

      {:ok, {user, tokens_to_expire}}
    end
  end
end
