defmodule Cog.Repository.Groups.BadIdError do
  defexception [:message]

  def exception(value) do
    msg = "'#{value}' is not a valid uuid"
    %__MODULE__{message: msg}
  end
end

defmodule Cog.Repository.Groups do
  @moduledoc """
  Behavioral API for interacting with user groups. Prefer these
  functions over direct manipulation with 'Cog.Repo'.
  """

  alias Cog.Repo
  alias Cog.Models.Group
  alias Cog.Models.User
  alias Cog.Models.Role
  import Ecto.Query, only: [from: 2]

  @preloads [[users: from(u in User, order_by: u.username)],
             [roles: from(r in Role, order_by: r.name)]]

  @doc """
  Creates a new user group given a map of attributes
  """
  @spec new(Map.t) :: {:ok, %Group{}} | {:error, Ecto.Changeset.t}
  def new(attrs) do
    new_group = %Group{}
    |> Group.changeset(attrs)
    |> Repo.insert

    case new_group do
      {:ok, group} ->
        {:ok, Repo.preload(group, @preloads)}
      error ->
        error
    end
  end

  @doc """
  Retrieves all user groups
  """
  @spec all :: [%Group{}]
  def all,
    do: Repo.all(Group) |> Repo.preload(@preloads)

  @doc """
  Retrieves all user groups in the list of names.
  """
  @spec all_by_name(List.t) :: [%Group{}]
  def all_by_name(names) do
    Repo.all(from g in Group, where: g.name in ^names)
    |> Repo.preload(@preloads)
  end

  @doc """
  Retrieves a user group based on it's name
  """
  @spec by_name(String.t) :: %Group{}
  def by_name(name) do
    case Repo.get_by(Group, name: name) do
      %Group{} = group ->
        {:ok, Repo.preload(group, @preloads)}
      nil ->
        {:error, :not_found}
    end
  end

  @doc """
  Retrieves a single user group based on the id. The given id must be
  a valid UUID.
  """
  @spec by_id(String.t) :: {:ok, %Group{}} | {:error, Ecto.Changeset.t} | {:error, Atom.t}
  def by_id(id) do
    if Cog.UUID.is_uuid?(id) do
      case Repo.get(Group, id) do
        %Group{} = group ->
          group = Repo.preload(group, @preloads)
          {:ok, group}
        nil ->
          {:error, :not_found}
      end
    else
      {:error, :bad_id}
    end
  end

  @doc """
  Like by_id/1 but raises an error if no results are returned
  """
  @spec by_id!(String.t) :: %Group{} | no_return()
  def by_id!(id) do
    if Cog.UUID.is_uuid?(id) do
      Repo.get!(Group, id)
      |> Repo.preload(@preloads)
    else
      raise(Cog.Repository.Groups.BadIdError, id)
    end
  end

  @doc """
  Deletes a user group
  """
  @spec delete(%Group{}) :: {:ok, %Group{}} | {:error, Ecto.Changeset.t} | {:error, Atom.t}
  def delete(%Group{}=group) do
    try do
      group
      |> Group.changeset(:delete)
      |> Repo.delete
    rescue
      Ecto.StaleModelError ->
        {:error, :not_found}
    end
  end

  @doc """
  Updates a user group
  """
  @spec update(%Group{}, Map.t) :: {:ok, %Group{}} | {:error, Ecto.Changeset.t}
  def update(%Group{name: unquote(Cog.Util.Misc.admin_group)=name}, _),
    do: {:error, {:protected_group, name}}
  def update(%Group{}=group, attrs) do
    changeset = Group.changeset(group, attrs)
    Repo.update(changeset)
  end


  # NOTE: This could use a rewrite. It was basically copied from the
  # user controller as is. It could some work to fit better with the
  # repository.
  # Manage the membership of a group. Users and groups can be added
  # and removed (multiples of each, all at the same time!) using this
  # function. Everything is governed by the request body, which we'll
  # call a "member spec".
  #
  # %{"members" => %{"users" => %{"add" => ["user_to_add_1", "user_to_add_2"],
  #                               "remove" => ["user_to_remove"]},
  #                  "roles" => %{"add" => ["role_to_add_1", "role_to_add_2"],
  #                               "remove" => ["role_to_remove"]},
  #                  "add" => [%User{}, %Role{}],
  #                  "remove" => [%User{}, %Role{}]}
  #
  # Provide the usernames of Users, that you want to be members (or not)
  # of the target group (as specified by `id`). All changes are made
  # transactionally, so if any given names don't refer to a database entity,
  # no changes in membership are made.
  #
  # NOTE: As currently coded, this API endpoint is a bit too "chatty"
  # from a database interaction perspective. The resolution of names to
  # entities is only batched by type and operation (e.g., all users to add
  # are looked up at once), all these entities are returned from the
  # database, and each subsequent addition and removal are performed
  # one-at-a-time, rather than in bulk. As you can see, a request that
  # manipulates many users and groups at once could end up making
  # quite a few database calls.
  #
  # For the time being, in these early days, this is OK, but a
  # second-pass should be made. We can create a stored procedure that
  # takes all the names and does the resolution and membership changes
  # much more efficiently in a single operation.
  def manage_membership(%{"id" => id}=params) do
    case by_id(id) do
      {:ok, group} ->
        manage_membership(group, params)
      error ->
        error
    end
  end

  def manage_membership(%Group{}=group, %{"members" => member_spec}) do
    Repo.transaction(fn() ->
      users_to_add    = lookup_or_fail(member_spec, ["users", "add"])
      roles_to_add    = lookup_or_fail(member_spec, ["roles", "add"])
      users_to_remove = lookup_or_fail(member_spec, ["users", "remove"])
      roles_to_remove = lookup_or_fail(member_spec, ["roles", "remove"])

      # If we already have a model, there is no need to look it up again
      {user_models_to_add, role_models_to_add} = get_models("add", member_spec)
      {user_models_to_remove, role_models_to_remove} = get_models("remove", member_spec)

      result = with :ok <- add(group, users_to_add ++ user_models_to_add),
                    :ok <- grant(group, roles_to_add ++ role_models_to_add),
                    :ok <- remove(group, users_to_remove ++ user_models_to_remove),
                    :ok <- revoke(group, roles_to_remove ++ role_models_to_remove),
                    do: by_id(group.id) # refetch group with updates

      case result do
        {:ok, group} ->
          Repo.preload(group, @preloads)
        {:error, error} ->
          Repo.rollback(error)
      end
    end)
  end

  # Extracts models from member_spec
  defp get_models(action, member_spec) when action in ["add", "remove"] do
    Map.get(member_spec, action, [])
    |> Enum.reduce({[],[]}, fn
      (%User{}=user, {users, roles}) -> {[user | users], roles}
      (%Role{}=role, {users, roles}) -> {users, [role | roles]}
    end)
  end

  # Resolves the names given in the `member_spec` portion of the
  # request body into database entities.
  #
  # Calls `Repo.rollback/1` if any of the given names don't exist, so
  # run this inside a transaction.
  #
  # Example:
  #
  #     > lookup_or_fail(%{"users" => %{"add" => ["cog"]}},
  #                         ["users", "add"],
  #                         User)
  #     [%User{username: "cog", ...}]
  #
  defp lookup_or_fail(member_spec, [kind, _operation]=path) do
    names = get_in(member_spec, path)
            |> List.wrap()
    case lookup_all(kind, names) do
      {:ok, structs} -> structs
      {:error, reason} ->
        Repo.rollback(reason)
    end
  end

  # Helper to retrieve mulitple Users or Groups, given names, in one
  # database call.
  #
  # If any of the given names does not refer to an existing database
  # entity, an error tuple is returned with a list of all "bad" names.
  #
  # Example:
  #
  #     > lookup_all("users", ["cog"])
  #     {:ok, [%User{username: "cog", ...}]}
  #
  #     > lookup_all("users", ["not_a_user", "cog", "badguy"])
  #     {:error, {:not_found, {"users", ["not_a_user", "badguy"]}}}
  #
  defp lookup_all(_, []), do: {:ok, []} # Don't bother with a DB lookup
  defp lookup_all(kind, names) when kind in ["users", "groups", "roles"] do

    type = kind_to_type(kind) # e.g. "users" -> User
    unique_name_field = unique_name_field(type) # e.g. User -> :username

    results = Repo.all(from t in type, where: field(t, ^unique_name_field) in ^names)

    # make sure we got a result for each name given
    case length(results) == length(names) do
      true ->
        # Each name corresponds to an entity in the database
        {:ok, results}
      false ->
        # We got at least one name that doesn't map to any existing
        # user or group. Find out what's missing and report back
        retrieved_names = Enum.map(results, &Map.get(&1, unique_name_field))
        bad_names = names -- retrieved_names
        {:error, {:not_found, {kind, bad_names}}}
    end
  end

  # Add multiple `%User{}` or `%Group{}` members to `group`, returning
  # `:ok` or the first error tuple encountered.
  #
  # Note that `members` can be a mix of types.
  defp add(group, members) do
    Enum.reduce_while(members, :ok, fn member, :ok ->
      case Groupable.add_to(member, group) do
        :ok ->
          {:cont, :ok}
        error ->
          {:halt, error}
      end
    end)
  end

  # TODO: note: members is always roles
  def grant(%Group{}=group, members) do
    members = List.wrap(members)
    Enum.reduce_while(members, :ok, fn member, :ok ->
      case Permittable.grant_to(group, member) do
        :ok ->
          {:cont, :ok}
        error ->
          {:halt, error}
      end
    end)
  end

  # Remove multiple `%User{}` or `%Group{}` members from `group`, returning
  # `:ok` or the first error tuple encountered.
  #
  # Note that `members` can be a mix of types.
  defp remove(group, members) do
    Enum.reduce_while(members, :ok, fn member, :ok ->
      case Groupable.remove_from(member, group) do
        :ok ->
          {:cont, :ok}
        error ->
          {:halt, error}
      end
    end)
  end

  # TODO: note: members is always roles
  def revoke(%Group{}=group, members) do
    members = List.wrap(members)
    Enum.reduce_while(members, :ok, fn member, :ok ->
      case Permittable.revoke_from(group, member) do
        :ok ->
          {:cont, :ok}
        error ->
          {:halt, error}
      end
    end)
  end

  # Given a member_spec key, return the underlying type
  defp kind_to_type("users"), do: User
  defp kind_to_type("groups"), do: Group
  defp kind_to_type("roles"), do: Role

  # Given a type, return the field for its unique name
  defp unique_name_field(User), do: :username
  defp unique_name_field(Group), do: :name
  defp unique_name_field(Role), do: :name

end
