defmodule Ambry.AccountsTest do
  use Ambry.DataCase

  alias Ambry.Accounts
  alias Ambry.Accounts.User
  alias Ambry.Accounts.UserToken

  describe "list_users/0" do
    test "returns the first 10 users sorted by name" do
      insert_list(11, :user)

      {returned_users, has_more?} = Accounts.list_users()

      assert has_more?
      assert length(returned_users) == 10
    end
  end

  describe "list_users/1" do
    test "accepts an offset" do
      insert_list(11, :user)

      {returned_users, has_more?} = Accounts.list_users(10)

      refute has_more?
      assert length(returned_users) == 1
    end
  end

  describe "list_users/2" do
    test "accepts a limit" do
      insert_list(6, :user)

      {returned_users, has_more?} = Accounts.list_users(0, 5)

      assert has_more?
      assert length(returned_users) == 5
    end
  end

  describe "list_users/3" do
    test "accepts a 'search' filter that searches by user email" do
      [_, _, %{id: id, email: email}, _, _] = insert_list(5, :user)

      {[matched], has_more?} = Accounts.list_users(0, 10, %{search: email})

      refute has_more?
      assert matched.id == id
    end

    test "accepts an 'admin' filter that allows returning only admins or non-admins" do
      insert_list(4, :user)
      %{id: admin_id} = insert(:admin)

      assert {[%{id: ^admin_id}], false} = Accounts.list_users(0, 10, %{admin: true})

      {regular_users, false} = Accounts.list_users(0, 10, %{admin: false})

      assert length(regular_users) == 4
    end

    test "accepts a 'confirmed' filter that allows returning only confirmed or non-confirmed users" do
      insert_list(4, :user)
      %{id: confirmed_user_id} = insert(:confirmed_user)

      assert {[%{id: ^confirmed_user_id}], false} = Accounts.list_users(0, 10, %{confirmed: true})

      {unconfirmed_users, false} = Accounts.list_users(0, 10, %{confirmed: false})

      assert length(unconfirmed_users) == 4
    end
  end

  describe "list_users/4" do
    test "allows sorting results by any field on the schema" do
      %{id: user1_id} = insert(:user, email: "a@example.com")
      %{id: user2_id} = insert(:user, email: "b@example.com")
      %{id: user3_id} = insert(:user, email: "c@example.com")

      {users, false} = Accounts.list_users(0, 10, %{}, :email)

      assert [
               %{id: ^user1_id},
               %{id: ^user2_id},
               %{id: ^user3_id}
             ] = users

      {users, false} = Accounts.list_users(0, 10, %{}, {:desc, :email})

      assert [
               %{id: ^user3_id},
               %{id: ^user2_id},
               %{id: ^user1_id}
             ] = users
    end
  end

  describe "count_users/0" do
    test "returns the number of users in the database" do
      insert_list(3, :user)

      assert 3 = Accounts.count_users()
    end
  end

  describe "get_user_by_email/1" do
    test "does not return the user if the email does not exist" do
      refute Accounts.get_user_by_email("unknown@example.com")
    end

    test "returns the user if the email exists" do
      %{id: id, email: email} = insert(:user)
      assert %User{id: ^id} = Accounts.get_user_by_email(email)
    end
  end

  describe "get_user_by_email_and_password/2" do
    test "does not return the user if the email does not exist" do
      refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
    end

    test "does not return the user if the password is not valid" do
      %{email: email} = :user |> build() |> with_password() |> insert()
      refute Accounts.get_user_by_email_and_password(email, "invalid")
    end

    test "returns the user if the email and password are valid" do
      %{id: id, email: email} = :user |> build() |> with_password() |> insert()

      assert %User{id: ^id} = Accounts.get_user_by_email_and_password(email, valid_password())
    end
  end

  describe "get_user!/1" do
    test "raises if id is invalid" do
      assert_raise Ecto.NoResultsError, fn ->
        Accounts.get_user!(-1)
      end
    end

    test "returns the user with the given id" do
      %{id: id} = insert(:user)
      assert %User{id: ^id} = Accounts.get_user!(id)
    end
  end

  describe "register_user/1" do
    test "requires email and password to be set" do
      {:error, changeset} = Accounts.register_user(%{})

      assert %{
               password: ["can't be blank"],
               email: ["can't be blank"]
             } = errors_on(changeset)
    end

    test "validates email and password when given" do
      {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"})

      assert %{
               email: ["must have the @ sign and no spaces"],
               password: [
                 "at least one digit or punctuation character",
                 "at least one upper case character",
                 "should be at least 12 character(s)"
               ]
             } = errors_on(changeset)
    end

    test "validates maximum values for email and password for security" do
      too_long = String.duplicate("db", 100)
      {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long})
      assert "should be at most 160 character(s)" in errors_on(changeset).email
      assert "should be at most 72 character(s)" in errors_on(changeset).password
    end

    test "validates email uniqueness" do
      %{email: email} = insert(:user)
      {:error, changeset} = Accounts.register_user(%{email: email})
      assert "has already been taken" in errors_on(changeset).email

      # Now try with the upper cased email too, to check that email case is ignored.
      {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
      assert "has already been taken" in errors_on(changeset).email
    end

    test "registers users with a hashed password" do
      %{email: email} = params = params_for(:user, password: valid_password())
      {:ok, user} = Accounts.register_user(params)
      assert user.email == email
      assert is_binary(user.hashed_password)
      assert is_nil(user.confirmed_at)
      assert is_nil(user.password)
    end
  end

  describe "change_user_registration/2" do
    test "returns a changeset" do
      assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{})
      assert changeset.required == [:password, :email]
    end

    test "allows fields to be set" do
      %{email: email, password: password} = params = params_for(:user, password: valid_password())

      changeset = Accounts.change_user_registration(%User{}, params)

      assert changeset.valid?
      assert get_change(changeset, :email) == email
      assert get_change(changeset, :password) == password
      assert is_nil(get_change(changeset, :hashed_password))
    end
  end

  describe "change_user_email/2" do
    test "returns a user changeset" do
      assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
      assert changeset.required == [:email]
    end
  end

  describe "apply_user_email/3" do
    setup do
      %{user: :user |> build() |> with_password() |> insert()}
    end

    test "requires email to change", %{user: user} do
      {:error, changeset} = Accounts.apply_user_email(user, valid_password(), %{})
      assert %{email: ["did not change"]} = errors_on(changeset)
    end

    test "validates email", %{user: user} do
      {:error, changeset} =
        Accounts.apply_user_email(user, valid_password(), %{email: "not valid"})

      assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
    end

    test "validates maximum value for email for security", %{user: user} do
      too_long = String.duplicate("db", 100)

      {:error, changeset} = Accounts.apply_user_email(user, valid_password(), %{email: too_long})

      assert "should be at most 160 character(s)" in errors_on(changeset).email
    end

    test "validates email uniqueness", %{user: user} do
      %{email: email} = insert(:user)

      {:error, changeset} = Accounts.apply_user_email(user, valid_password(), %{email: email})

      assert "has already been taken" in errors_on(changeset).email
    end

    test "validates current password", %{user: user} do
      %{email: email} = params_for(:user)

      {:error, changeset} = Accounts.apply_user_email(user, "invalid", %{email: email})

      assert %{current_password: ["is not valid"]} = errors_on(changeset)
    end

    test "applies the email without persisting it", %{user: user} do
      %{email: email} = params_for(:user)
      {:ok, user} = Accounts.apply_user_email(user, valid_password(), %{email: email})
      assert user.email == email
      assert Accounts.get_user!(user.id).email != email
    end
  end

  describe "deliver_user_update_email_instructions/3" do
    setup do
      %{user: :user |> build() |> with_password() |> insert()}
    end

    test "sends token through notification", %{user: user} do
      {:ok, encoded_token} =
        Accounts.deliver_user_update_email_instructions(user, "current@example.com", & &1)

      {:ok, token} = Base.url_decode64(encoded_token, padding: false)
      assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
      assert user_token.user_id == user.id
      assert user_token.sent_to == user.email
      assert user_token.context == "change:current@example.com"
    end
  end

  describe "update_user_email/2" do
    setup do
      user = :user |> build() |> with_password() |> insert()
      %{email: email} = params_for(:user)

      {:ok, encoded_token} =
        Accounts.deliver_user_update_email_instructions(
          %{user | email: email},
          user.email,
          & &1
        )

      %{user: user, token: encoded_token, email: email}
    end

    test "updates the email with a valid token", %{user: user, token: token, email: email} do
      assert Accounts.update_user_email(user, token) == :ok
      changed_user = Repo.get!(User, user.id)
      assert changed_user.email != user.email
      assert changed_user.email == email
      assert changed_user.confirmed_at
      assert changed_user.confirmed_at != user.confirmed_at
      refute Repo.get_by(UserToken, user_id: user.id)
    end

    test "does not update email with invalid token", %{user: user} do
      assert Accounts.update_user_email(user, "oops") == :error
      assert Repo.get!(User, user.id).email == user.email
      assert Repo.get_by(UserToken, user_id: user.id)
    end

    test "does not update email if user email changed", %{user: user, token: token} do
      assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error
      assert Repo.get!(User, user.id).email == user.email
      assert Repo.get_by(UserToken, user_id: user.id)
    end

    test "does not update email if token expired", %{user: user, token: token} do
      {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
      assert Accounts.update_user_email(user, token) == :error
      assert Repo.get!(User, user.id).email == user.email
      assert Repo.get_by(UserToken, user_id: user.id)
    end
  end

  describe "change_user_password/2" do
    test "returns a user changeset" do
      assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
      assert changeset.required == [:password]
    end

    test "allows fields to be set" do
      changeset =
        Accounts.change_user_password(%User{}, %{
          "password" => valid_new_password()
        })

      assert changeset.valid?
      assert get_change(changeset, :password) == valid_new_password()
      assert is_nil(get_change(changeset, :hashed_password))
    end
  end

  describe "update_user_password/3" do
    setup do
      %{user: :user |> build() |> with_password() |> insert()}
    end

    test "validates password", %{user: user} do
      {:error, changeset} =
        Accounts.update_user_password(user, valid_password(), %{
          password: "not valid",
          password_confirmation: "another"
        })

      assert %{
               password: [
                 "at least one digit or punctuation character",
                 "at least one upper case character",
                 "should be at least 12 character(s)"
               ],
               password_confirmation: ["does not match password"]
             } = errors_on(changeset)
    end

    test "validates maximum values for password for security", %{user: user} do
      too_long = String.duplicate("db", 100)

      {:error, changeset} =
        Accounts.update_user_password(user, valid_password(), %{password: too_long})

      assert "should be at most 72 character(s)" in errors_on(changeset).password
    end

    test "validates current password", %{user: user} do
      {:error, changeset} =
        Accounts.update_user_password(user, "invalid", %{password: valid_password()})

      assert %{current_password: ["is not valid"]} = errors_on(changeset)
    end

    test "updates the password", %{user: user} do
      {:ok, user} =
        Accounts.update_user_password(user, valid_password(), %{
          password: valid_new_password()
        })

      assert is_nil(user.password)
      assert Accounts.get_user_by_email_and_password(user.email, valid_new_password())
    end

    test "deletes all tokens for the given user", %{user: user} do
      _token = Accounts.generate_user_session_token(user)

      {:ok, _} =
        Accounts.update_user_password(user, valid_password(), %{
          password: valid_new_password()
        })

      refute Repo.get_by(UserToken, user_id: user.id)
    end
  end

  describe "update_user_loaded_player_state/2" do
    test "updates a users currently loaded player state" do
      user = insert(:user)

      player_state =
        insert(:player_state, user_id: user.id, media: build(:media, book: build(:book)))

      {:ok, user} = Accounts.update_user_loaded_player_state(user, player_state.id)

      assert user.loaded_player_state_id == player_state.id
    end
  end

  describe "generate_user_session_token/1" do
    setup do
      %{user: :user |> build() |> with_password() |> insert()}
    end

    test "generates a token", %{user: user} do
      token = Accounts.generate_user_session_token(user)
      assert user_token = Repo.get_by(UserToken, token: token)
      assert user_token.context == "session"

      user2 = :user |> build() |> with_password() |> insert()

      # Creating the same token for another user should fail
      assert_raise Ecto.ConstraintError, fn ->
        Repo.insert!(%UserToken{
          token: user_token.token,
          user_id: user2.id,
          context: "session"
        })
      end
    end
  end

  describe "get_user_by_session_token/1" do
    setup do
      user = :user |> build() |> with_password() |> insert()
      token = Accounts.generate_user_session_token(user)
      %{user: user, token: token}
    end

    test "returns user by token", %{user: user, token: token} do
      assert session_user = Accounts.get_user_by_session_token(token)
      assert session_user.id == user.id
    end

    test "does not return user for invalid token" do
      refute Accounts.get_user_by_session_token("oops")
    end

    test "does not return user for expired token", %{token: token} do
      {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
      refute Accounts.get_user_by_session_token(token)
    end
  end

  describe "delete_user_session_token/1" do
    test "deletes the token" do
      user = :user |> build() |> with_password() |> insert()
      token = Accounts.generate_user_session_token(user)
      assert Accounts.delete_user_session_token(token) == :ok
      refute Accounts.get_user_by_session_token(token)
    end
  end

  describe "deliver_user_confirmation_instructions/2" do
    setup do
      %{user: :user |> build() |> with_password() |> insert()}
    end

    test "sends token through notification", %{user: user} do
      {:ok, encoded_token} = Accounts.deliver_user_confirmation_instructions(user, & &1)

      {:ok, token} = Base.url_decode64(encoded_token, padding: false)
      assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
      assert user_token.user_id == user.id
      assert user_token.sent_to == user.email
      assert user_token.context == "confirm"
    end
  end

  describe "confirm_user/1" do
    setup do
      user = :user |> build() |> with_password() |> insert()

      {:ok, encoded_token} = Accounts.deliver_user_confirmation_instructions(user, & &1)

      %{user: user, token: encoded_token}
    end

    test "confirms the email with a valid token", %{user: user, token: token} do
      assert {:ok, confirmed_user} = Accounts.confirm_user(token)
      assert confirmed_user.confirmed_at
      assert confirmed_user.confirmed_at != user.confirmed_at
      assert Repo.get!(User, user.id).confirmed_at
      refute Repo.get_by(UserToken, user_id: user.id)
    end

    test "does not confirm with invalid token", %{user: user} do
      assert Accounts.confirm_user("oops") == :error
      refute Repo.get!(User, user.id).confirmed_at
      assert Repo.get_by(UserToken, user_id: user.id)
    end

    test "does not confirm email if token expired", %{user: user, token: token} do
      {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
      assert Accounts.confirm_user(token) == :error
      refute Repo.get!(User, user.id).confirmed_at
      assert Repo.get_by(UserToken, user_id: user.id)
    end
  end

  describe "deliver_user_reset_password_instructions/2" do
    setup do
      %{user: :user |> build() |> with_password() |> insert()}
    end

    test "sends token through notification", %{user: user} do
      {:ok, encoded_token} = Accounts.deliver_user_reset_password_instructions(user, & &1)
      {:ok, token} = Base.url_decode64(encoded_token, padding: false)

      assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
      assert user_token.user_id == user.id
      assert user_token.sent_to == user.email
      assert user_token.context == "reset_password"
    end
  end

  describe "get_user_by_reset_password_token/1" do
    setup do
      user = :user |> build() |> with_password() |> insert()

      {:ok, encoded_token} = Accounts.deliver_user_reset_password_instructions(user, & &1)

      %{user: user, token: encoded_token}
    end

    test "returns the user with valid token", %{user: %{id: id}, token: token} do
      assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token)
      assert Repo.get_by(UserToken, user_id: id)
    end

    test "does not return the user with invalid token", %{user: user} do
      refute Accounts.get_user_by_reset_password_token("oops")
      assert Repo.get_by(UserToken, user_id: user.id)
    end

    test "does not return the user if token expired", %{user: user, token: token} do
      {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
      refute Accounts.get_user_by_reset_password_token(token)
      assert Repo.get_by(UserToken, user_id: user.id)
    end
  end

  describe "reset_user_password/2" do
    setup do
      %{user: :user |> build() |> with_password() |> insert()}
    end

    test "validates password", %{user: user} do
      {:error, changeset} =
        Accounts.reset_user_password(user, %{
          password: "not valid",
          password_confirmation: "another"
        })

      assert %{
               password: [
                 "at least one digit or punctuation character",
                 "at least one upper case character",
                 "should be at least 12 character(s)"
               ],
               password_confirmation: ["does not match password"]
             } = errors_on(changeset)
    end

    test "validates maximum values for password for security", %{user: user} do
      too_long = String.duplicate("db", 100)
      {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long})
      assert "should be at most 72 character(s)" in errors_on(changeset).password
    end

    test "updates the password", %{user: user} do
      {:ok, updated_user} = Accounts.reset_user_password(user, %{password: valid_new_password()})
      assert is_nil(updated_user.password)
      assert Accounts.get_user_by_email_and_password(user.email, valid_new_password())
    end

    test "deletes all tokens for the given user", %{user: user} do
      _token = Accounts.generate_user_session_token(user)
      {:ok, _} = Accounts.reset_user_password(user, %{password: valid_new_password()})
      refute Repo.get_by(UserToken, user_id: user.id)
    end
  end

  describe "promote_user_to_admin/1" do
    test "turns a regular user into an admin" do
      user = insert(:user)

      refute user.admin

      {:ok, user} = Accounts.promote_user_to_admin(user)

      assert user.admin
    end
  end

  describe "demote_user_from_admin/1" do
    test "turns a n admin into a regular user" do
      user = insert(:admin)

      assert user.admin

      {:ok, user} = Accounts.demote_user_from_admin(user)

      refute user.admin
    end
  end

  describe "admin_exists?/0" do
    test "returns a boolean indicating if any admin user exists" do
      refute Accounts.admin_exists?()

      insert(:admin)

      assert Accounts.admin_exists?()
    end
  end

  describe "delete_user/1" do
    test "deletes a user" do
      user = insert(:user)

      assert :ok = Accounts.delete_user(user)

      assert_raise Ecto.NoResultsError, fn ->
        Accounts.get_user!(user.id)
      end
    end
  end

  describe "inspect/2 for the User module" do
    test "does not include password" do
      refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
    end
  end

  describe "deliver_user_invitation/2" do
    test "creates an unconfirmed user and sends invitation token" do
      email = "new_user@example.com"
      {:ok, encoded_token} = Accounts.deliver_user_invitation(email, & &1)

      {:ok, token} = Base.url_decode64(encoded_token, padding: false)
      assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
      assert user_token.context == "invitation"

      # Verify new user was created
      user = Accounts.get_user_by_email(email)
      assert user
      assert user.id == user_token.user_id
      assert user_token.sent_to == email
      refute user.confirmed_at
    end
  end

  describe "get_user_by_invitation_token/1" do
    setup do
      email = "new_user@example.com"
      {:ok, encoded_token} = Accounts.deliver_user_invitation(email, & &1)
      %{token: encoded_token, email: email}
    end

    test "returns the user with valid token", %{token: token, email: email} do
      assert user = Accounts.get_user_by_invitation_token(token)
      assert user.email == email
      refute user.confirmed_at
    end

    test "does not return the user with invalid token" do
      refute Accounts.get_user_by_invitation_token("oops")
    end

    test "does not return the user if token expired", %{token: token} do
      {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
      refute Accounts.get_user_by_invitation_token(token)
    end
  end

  describe "accept_user_invitation/2" do
    setup do
      email = "new_user@example.com"
      {:ok, encoded_token} = Accounts.deliver_user_invitation(email, & &1)
      user = Accounts.get_user_by_invitation_token(encoded_token)
      %{user: user}
    end

    test "validates password", %{user: user} do
      {:error, changeset} =
        Accounts.accept_user_invitation(user, %{
          password: "not valid",
          password_confirmation: "another"
        })

      assert %{
               password: [
                 "at least one digit or punctuation character",
                 "at least one upper case character",
                 "should be at least 12 character(s)"
               ],
               password_confirmation: ["does not match password"]
             } = errors_on(changeset)
    end

    test "validates maximum values for password for security", %{user: user} do
      too_long = String.duplicate("db", 100)
      {:error, changeset} = Accounts.accept_user_invitation(user, %{password: too_long})
      assert "should be at most 72 character(s)" in errors_on(changeset).password
    end

    test "accepts the invitation with valid password", %{user: user} do
      {:ok, updated_user} =
        Accounts.accept_user_invitation(user, %{
          password: valid_new_password(),
          password_confirmation: valid_new_password()
        })

      assert is_nil(updated_user.password)
      assert updated_user.confirmed_at
      assert Accounts.get_user_by_email_and_password(user.email, valid_new_password())
    end

    test "deletes all tokens for the given user", %{user: user} do
      _token = Accounts.generate_user_session_token(user)

      {:ok, _} =
        Accounts.accept_user_invitation(user, %{
          password: valid_new_password(),
          password_confirmation: valid_new_password()
        })

      refute Repo.get_by(UserToken, user_id: user.id)
    end
  end
end
