defmodule Microwaveprop.Accounts.UserTokenTest do
  use Microwaveprop.DataCase, async: true

  import Microwaveprop.AccountsFixtures

  alias Microwaveprop.Accounts.UserToken

  @hash_algorithm :sha256
  @rand_size 32

  describe "build_session_token/1" do
    test "returns the raw token and a matching UserToken struct" do
      user = user_fixture()
      {token, user_token} = UserToken.build_session_token(user)

      assert is_binary(token)
      assert byte_size(token) == @rand_size
      assert %UserToken{} = user_token
      assert user_token.token == token
      assert user_token.context == "session"
      assert user_token.user_id == user.id
    end

    test "uses the user's authenticated_at when present" do
      user = user_fixture()
      dt = DateTime.add(DateTime.utc_now(:second), -30, :minute)
      user = %{user | authenticated_at: dt}

      {_token, user_token} = UserToken.build_session_token(user)
      assert DateTime.compare(user_token.authenticated_at, dt) == :eq
    end

    test "falls back to current UTC time when authenticated_at is nil" do
      user = user_fixture()

      {_token, user_token} = UserToken.build_session_token(user)
      assert %DateTime{} = user_token.authenticated_at
      assert DateTime.diff(DateTime.utc_now(), user_token.authenticated_at, :second) in 0..5
    end

    test "emits a different token on each call" do
      user = user_fixture()
      {token_a, _} = UserToken.build_session_token(user)
      {token_b, _} = UserToken.build_session_token(user)
      refute token_a == token_b
    end
  end

  describe "verify_session_token_query/1" do
    setup do
      user = user_fixture()
      {token, user_token} = UserToken.build_session_token(user)
      {:ok, persisted} = Repo.insert(user_token)
      %{user: user, token: token, user_token: persisted}
    end

    test "returns the user and inserted_at for a valid session token", %{
      user: user,
      token: token,
      user_token: persisted
    } do
      {:ok, query} = UserToken.verify_session_token_query(token)
      assert {fetched_user, inserted_at} = Repo.one(query)
      assert fetched_user.id == user.id
      assert fetched_user.authenticated_at == persisted.authenticated_at
      assert inserted_at == persisted.inserted_at
    end

    test "returns nil for an unknown token" do
      {:ok, query} = UserToken.verify_session_token_query(:crypto.strong_rand_bytes(@rand_size))
      assert Repo.one(query) == nil
    end

    test "returns nil when the token has expired", %{token: token} do
      # 14 days is the session validity window.
      offset_user_token(token, -15, :day)
      {:ok, query} = UserToken.verify_session_token_query(token)
      assert Repo.one(query) == nil
    end

    test "still returns the user exactly at the validity boundary", %{token: token, user: user} do
      # 13 days ago is still within the 14-day validity window.
      offset_user_token(token, -13, :day)
      {:ok, query} = UserToken.verify_session_token_query(token)
      assert {fetched, _inserted_at} = Repo.one(query)
      assert fetched.id == user.id
    end

    test "does not match against a non-session context", %{user: user} do
      {_raw, email_token} = UserToken.build_email_token(user, "confirm")
      {:ok, _} = Repo.insert(email_token)
      # The session verifier only matches the "session" context.
      {:ok, query} = UserToken.verify_session_token_query(email_token.token)
      assert Repo.one(query) == nil
    end
  end

  describe "build_email_token/2" do
    test "returns a url-safe encoded token and a hashed stored token" do
      user = user_fixture()
      {encoded, user_token} = UserToken.build_email_token(user, "confirm")

      assert is_binary(encoded)
      assert {:ok, decoded} = Base.url_decode64(encoded, padding: false)
      assert byte_size(decoded) == @rand_size

      assert user_token.context == "confirm"
      assert user_token.sent_to == user.email
      assert user_token.user_id == user.id
      assert user_token.token == :crypto.hash(@hash_algorithm, decoded)
      refute user_token.token == decoded
    end

    test "different contexts produce different stored rows" do
      user = user_fixture()
      {_a, tok_a} = UserToken.build_email_token(user, "confirm")
      {_b, tok_b} = UserToken.build_email_token(user, "login")

      assert tok_a.context == "confirm"
      assert tok_b.context == "login"
      refute tok_a.token == tok_b.token
    end
  end

  describe "verify_magic_link_token_query/1" do
    setup do
      user = user_fixture()
      {encoded, user_token} = UserToken.build_email_token(user, "login")
      {:ok, persisted} = Repo.insert(user_token)
      %{user: user, encoded: encoded, raw_token: persisted.token}
    end

    test "returns the user and token record for a valid magic-link token", %{
      user: user,
      encoded: encoded
    } do
      {:ok, query} = UserToken.verify_magic_link_token_query(encoded)
      assert {fetched_user, fetched_token} = Repo.one(query)
      assert fetched_user.id == user.id
      assert fetched_token.context == "login"
    end

    test "returns :error for a malformed (non-base64) token" do
      assert UserToken.verify_magic_link_token_query("not base64!!") == :error
    end

    test "returns nil when the token is not in the database" do
      foreign = Base.url_encode64(:crypto.strong_rand_bytes(@rand_size), padding: false)
      {:ok, query} = UserToken.verify_magic_link_token_query(foreign)
      assert Repo.one(query) == nil
    end

    test "returns nil once the token has expired", %{encoded: encoded, raw_token: raw} do
      # Magic link validity is 15 minutes.
      Repo.update_all(
        from(t in UserToken, where: t.token == ^raw),
        set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -16, :minute)]
      )

      {:ok, query} = UserToken.verify_magic_link_token_query(encoded)
      assert Repo.one(query) == nil
    end

    test "returns nil when the user's email no longer matches sent_to", %{
      user: user,
      encoded: encoded
    } do
      # sent_to == user.email is asserted by the query; changing the user's
      # email invalidates the token.
      {:ok, _user} =
        user
        |> Ecto.Changeset.change(email: "changed-#{System.unique_integer([:positive])}@example.com")
        |> Repo.update()

      {:ok, query} = UserToken.verify_magic_link_token_query(encoded)
      assert Repo.one(query) == nil
    end
  end

  describe "verify_confirm_token_query/1" do
    setup do
      user = user_fixture()
      {encoded, user_token} = UserToken.build_email_token(user, "confirm")
      {:ok, persisted} = Repo.insert(user_token)
      %{user: user, encoded: encoded, raw_token: persisted.token}
    end

    test "returns user and token for a valid confirm token", %{user: user, encoded: encoded} do
      {:ok, query} = UserToken.verify_confirm_token_query(encoded)
      assert {fetched, token} = Repo.one(query)
      assert fetched.id == user.id
      assert token.context == "confirm"
    end

    test "returns :error for a malformed token" do
      assert UserToken.verify_confirm_token_query("***not-base64***") == :error
    end

    test "returns nil for an unknown token" do
      other = Base.url_encode64(:crypto.strong_rand_bytes(@rand_size), padding: false)
      {:ok, query} = UserToken.verify_confirm_token_query(other)
      assert Repo.one(query) == nil
    end

    test "returns nil when the token is older than 1 day", %{encoded: encoded, raw_token: raw} do
      Repo.update_all(
        from(t in UserToken, where: t.token == ^raw),
        set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -2, :day)]
      )

      {:ok, query} = UserToken.verify_confirm_token_query(encoded)
      assert Repo.one(query) == nil
    end

    test "returns nil when the user's email has changed", %{user: user, encoded: encoded} do
      {:ok, _user} =
        user
        |> Ecto.Changeset.change(email: "new-#{System.unique_integer([:positive])}@example.com")
        |> Repo.update()

      {:ok, query} = UserToken.verify_confirm_token_query(encoded)
      assert Repo.one(query) == nil
    end

    test "does not match a token stored under a different context", %{user: user} do
      # Build a "login" token, encode it, and try to verify against "confirm".
      {encoded, user_token} = UserToken.build_email_token(user, "login")
      {:ok, _} = Repo.insert(user_token)

      {:ok, query} = UserToken.verify_confirm_token_query(encoded)
      assert Repo.one(query) == nil
    end
  end

  describe "verify_change_email_token_query/2" do
    setup do
      user = user_fixture()
      context = "change:#{user.email}"
      {encoded, user_token} = UserToken.build_email_token(user, context)
      {:ok, persisted} = Repo.insert(user_token)
      %{user: user, encoded: encoded, context: context, raw_token: persisted.token}
    end

    test "returns the user_token for a valid change-email token", %{
      encoded: encoded,
      context: context,
      user: user
    } do
      {:ok, query} = UserToken.verify_change_email_token_query(encoded, context)
      assert %UserToken{} = token = Repo.one(query)
      assert token.context == context
      assert token.sent_to == user.email
      assert token.user_id == user.id
    end

    test "rejects a context that does not start with \"change:\"" do
      encoded = Base.url_encode64(:crypto.strong_rand_bytes(@rand_size), padding: false)

      assert_raise FunctionClauseError, fn ->
        UserToken.verify_change_email_token_query(encoded, "confirm")
      end
    end

    test "returns :error for a malformed token", %{context: context} do
      assert UserToken.verify_change_email_token_query("***", context) == :error
    end

    test "returns nil for an unknown token", %{context: context} do
      foreign = Base.url_encode64(:crypto.strong_rand_bytes(@rand_size), padding: false)
      {:ok, query} = UserToken.verify_change_email_token_query(foreign, context)
      assert Repo.one(query) == nil
    end

    test "returns nil once the token is older than 7 days", %{
      encoded: encoded,
      context: context,
      raw_token: raw
    } do
      Repo.update_all(
        from(t in UserToken, where: t.token == ^raw),
        set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -8, :day)]
      )

      {:ok, query} = UserToken.verify_change_email_token_query(encoded, context)
      assert Repo.one(query) == nil
    end

    test "returns nil when the stored row was written under a different context", %{
      user: user
    } do
      {encoded, user_token} = UserToken.build_email_token(user, "change:stale@example.com")
      {:ok, _} = Repo.insert(user_token)

      {:ok, query} =
        UserToken.verify_change_email_token_query(encoded, "change:#{user.email}")

      assert Repo.one(query) == nil
    end
  end
end
