defmodule Towerops.AccountsTest do
  use Towerops.DataCase

  import Towerops.AccountsFixtures

  alias Towerops.Accounts
  alias Towerops.Accounts.User
  alias Towerops.Accounts.UserToken

  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} = user = user_fixture()
      assert %User{id: ^id} = Accounts.get_user_by_email(user.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
      user = user_fixture()
      refute Accounts.get_user_by_email_and_password(user.email, "invalid")
    end

    test "returns the user if the email and password are valid" do
      %{id: id} = user = user_fixture()

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

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

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

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

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

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

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

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

    test "validates password length" do
      {:error, changeset} = Accounts.register_user(%{email: unique_user_email(), password: "short"})
      assert "should be at least 12 character(s)" in errors_on(changeset).password
    end

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

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

    test "registers users with email and password" do
      email = unique_user_email()
      {:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
      assert user.email == email
      assert is_binary(user.hashed_password)
      assert user.confirmed_at
      assert is_nil(user.password)
    end
  end

  describe "sudo_mode?/2" do
    test "validates the last_sudo_at time" do
      now = DateTime.utc_now()

      assert Accounts.sudo_mode?(%User{last_sudo_at: DateTime.utc_now()})
      assert Accounts.sudo_mode?(%User{last_sudo_at: DateTime.add(now, -9, :minute)})
      refute Accounts.sudo_mode?(%User{last_sudo_at: DateTime.add(now, -11, :minute)})

      # minute override
      refute Accounts.sudo_mode?(
               %User{last_sudo_at: DateTime.add(now, -6, :minute)},
               -5
             )

      # not authenticated
      refute Accounts.sudo_mode?(%User{})
    end
  end

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

  describe "deliver_user_update_email_instructions/3" do
    setup do
      %{user: user_fixture()}
    end

    test "sends token through notification", %{user: user} do
      token =
        extract_user_token(fn url ->
          Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
        end)

      {:ok, token} = Base.url_decode64(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 = unconfirmed_user_fixture()
      email = unique_user_email()

      token =
        extract_user_token(fn url ->
          Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
        end)

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

    test "updates the email with a valid token", %{user: user, token: token, email: email} do
      assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token)
      changed_user = Repo.get!(User, user.id)
      assert changed_user.email != user.email
      assert changed_user.email == email
      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, :transaction_aborted}

      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, :transaction_aborted}

      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, :transaction_aborted}

      assert Repo.get!(User, user.id).email == user.email
      assert Repo.get_by(UserToken, user_id: user.id)
    end
  end

  describe "change_user_password/3" 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" => "new valid password"
          },
          hash_password: false
        )

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

  describe "update_user_password/2" do
    setup do
      %{user: user_fixture()}
    end

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

      assert %{
               password: ["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, %{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, {user, expired_tokens}} =
        Accounts.update_user_password(user, %{
          password: "new valid password"
        })

      assert expired_tokens == []
      assert is_nil(user.password)
      assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
    end

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

      {:ok, {_, _}} =
        Accounts.update_user_password(user, %{
          password: "new valid password"
        })

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

  describe "generate_user_session_token/1" do
    setup do
      %{user: user_fixture()}
    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"
      assert user_token.authenticated_at

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

    test "duplicates the authenticated_at of given user in new token", %{user: user} do
      user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)}
      token = Accounts.generate_user_session_token(user)
      assert user_token = Repo.get_by(UserToken, token: token)
      assert user_token.authenticated_at == user.authenticated_at
      assert DateTime.after?(user_token.inserted_at, user.authenticated_at)
    end
  end

  describe "get_user_by_session_token/1" do
    setup do
      user = user_fixture()
      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, token_inserted_at} = Accounts.get_user_by_session_token(token)
      assert session_user.id == user.id
      assert session_user.authenticated_at
      assert token_inserted_at
    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
      dt = ~N[2020-01-01 00:00:00]
      {1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt])
      refute Accounts.get_user_by_session_token(token)
    end
  end

  describe "get_user_by_magic_link_token/1" do
    setup do
      user = user_fixture()
      {encoded_token, _hashed_token} = generate_user_magic_link_token(user)
      %{user: user, token: encoded_token}
    end

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

    test "does not return user for invalid token" do
      refute Accounts.get_user_by_magic_link_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_magic_link_token(token)
    end
  end

  describe "login_user_by_magic_link/1" do
    test "logs in confirmed user and deletes token" do
      user = user_fixture()
      assert user.confirmed_at
      {encoded_token, _hashed_token} = generate_user_magic_link_token(user)
      assert {:ok, {logged_in_user, []}} = Accounts.login_user_by_magic_link(encoded_token)
      assert logged_in_user.id == user.id
      # one time use only
      assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
    end

    test "raises when unconfirmed user has password set" do
      user = unconfirmed_user_fixture()
      refute user.confirmed_at
      assert user.hashed_password
      {encoded_token, _hashed_token} = generate_user_magic_link_token(user)

      assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn ->
        Accounts.login_user_by_magic_link(encoded_token)
      end
    end
  end

  describe "delete_user_session_token/1" do
    test "deletes the token" do
      user = user_fixture()
      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_login_instructions/2" do
    setup do
      %{user: unconfirmed_user_fixture()}
    end

    test "sends token through notification", %{user: user} do
      token =
        extract_user_token(fn url ->
          Accounts.deliver_login_instructions(user, url)
        end)

      {:ok, token} = Base.url_decode64(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 == "login"
    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 "TOTP enrollment" do
    setup do
      # Create user without TOTP for enrollment tests
      %{user: user_fixture(enable_totp: false)}
    end

    test "generate_totp_secret/0 generates a valid secret" do
      secret = Accounts.generate_totp_secret()
      assert is_binary(secret)
      assert byte_size(secret) > 0
    end

    test "generate_totp_uri/2 creates valid otpauth URI", %{user: user} do
      secret = Accounts.generate_totp_secret()
      uri = Accounts.generate_totp_uri(user, secret)

      assert uri =~ "otpauth://totp/Towerops:#{user.email}"
      assert uri =~ "secret="
      assert uri =~ "issuer=Towerops"
    end

    test "generate_totp_qr_code/2 creates base64-encoded data URI", %{user: user} do
      secret = Accounts.generate_totp_secret()
      qr_code = Accounts.generate_totp_qr_code(user, secret)

      assert String.starts_with?(qr_code, "data:image/png;base64,")
      # Verify it's valid base64 after the prefix
      [_, base64] = String.split(qr_code, ",", parts: 2)
      assert {:ok, _} = Base.decode64(base64)
    end

    test "verify_totp/2 validates correct TOTP codes" do
      secret = Accounts.generate_totp_secret()
      code = NimbleTOTP.verification_code(secret)

      assert Accounts.verify_totp(secret, code)
    end

    test "verify_totp/2 rejects incorrect TOTP codes" do
      secret = Accounts.generate_totp_secret()

      refute Accounts.verify_totp(secret, "000000")
      refute Accounts.verify_totp(secret, "invalid")
    end

    test "verify_totp/2 accepts codes within time window" do
      secret = Accounts.generate_totp_secret()
      # Generate code for current time (should always be valid)
      code = NimbleTOTP.verification_code(secret)

      # Verify it works (checks current time +/- 1 step)
      assert Accounts.verify_totp(secret, code)
    end

    test "enable_totp/2 saves TOTP secret to user", %{user: user} do
      secret = Accounts.generate_totp_secret()

      assert {:ok, updated_user} = Accounts.enable_totp(user, secret)
      assert updated_user.totp_secret == secret
      assert Accounts.totp_enabled?(updated_user)
    end

    test "totp_enabled?/1 returns false for user without TOTP", %{user: user} do
      refute Accounts.totp_enabled?(user)
    end

    test "totp_enabled?/1 returns true for user with TOTP", %{user: user} do
      secret = Accounts.generate_totp_secret()
      {:ok, updated_user} = Accounts.enable_totp(user, secret)

      assert Accounts.totp_enabled?(updated_user)
    end

    test "verify_user_totp/2 validates codes for enrolled user", %{user: user} do
      secret = Accounts.generate_totp_secret()
      {:ok, updated_user} = Accounts.enable_totp(user, secret)

      code = NimbleTOTP.verification_code(secret)

      assert {:ok, %User{}} = Accounts.verify_user_totp(updated_user, code)
    end

    test "verify_user_totp/2 rejects invalid codes for enrolled user", %{user: user} do
      secret = Accounts.generate_totp_secret()
      {:ok, updated_user} = Accounts.enable_totp(user, secret)

      assert {:error, :invalid_code} = Accounts.verify_user_totp(updated_user, "000000")
    end
  end

  describe "TOTP Device Management" do
    setup do
      user = user_fixture()
      {:ok, user: user}
    end

    test "list_user_totp_devices/1 returns empty list for user with no devices", %{user: user} do
      assert [] = Accounts.list_user_totp_devices(user.id)
    end

    test "list_user_totp_devices/1 returns devices ordered by last_used_at", %{user: user} do
      {:ok, _device1, _secret1} = Accounts.create_totp_device(user.id, "Device 1")
      {:ok, device2, secret2} = Accounts.create_totp_device(user.id, "Device 2")

      # Use device2
      code = NimbleTOTP.verification_code(secret2)
      {:ok, _} = Accounts.verify_user_totp_any_device(user, code)

      devices = Accounts.list_user_totp_devices(user.id)
      assert length(devices) == 2
      # Device 2 should be first because it was used
      assert hd(devices).id == device2.id
    end

    test "count_user_totp_devices/1 returns correct count", %{user: user} do
      assert 0 = Accounts.count_user_totp_devices(user.id)

      {:ok, _device1, _} = Accounts.create_totp_device(user.id, "Device 1")
      assert 1 = Accounts.count_user_totp_devices(user.id)

      {:ok, _device2, _} = Accounts.create_totp_device(user.id, "Device 2")
      assert 2 = Accounts.count_user_totp_devices(user.id)
    end

    test "create_totp_device/2 creates device with unique secret", %{user: user} do
      {:ok, device, secret} = Accounts.create_totp_device(user.id, "iPhone 15")

      assert device.name == "iPhone 15"
      assert device.user_id == user.id
      assert is_binary(secret)
      # NimbleTOTP default is 20 bytes
      assert byte_size(secret) == 20
      assert is_binary(device.totp_secret)
    end

    test "create_totp_device/2 validates device name length", %{user: user} do
      long_name = String.duplicate("a", 101)

      {:error, changeset} = Accounts.create_totp_device(user.id, long_name)
      assert "should be at most 100 character(s)" in errors_on(changeset).name
    end

    test "verify_user_totp_any_device/2 verifies code against any user device", %{user: user} do
      {:ok, device1, secret1} = Accounts.create_totp_device(user.id, "Device 1")
      {:ok, device2, secret2} = Accounts.create_totp_device(user.id, "Device 2")

      # Test with device 1 code
      code1 = NimbleTOTP.verification_code(secret1)
      assert {:ok, verified_device} = Accounts.verify_user_totp_any_device(user, code1)
      assert verified_device.id == device1.id

      # Test with device 2 code
      code2 = NimbleTOTP.verification_code(secret2)
      assert {:ok, verified_device} = Accounts.verify_user_totp_any_device(user, code2)
      assert verified_device.id == device2.id
    end

    test "verify_user_totp_any_device/2 updates last_used_at on successful verification", %{user: user} do
      {:ok, device, secret} = Accounts.create_totp_device(user.id, "Device")

      assert is_nil(device.last_used_at)

      code = NimbleTOTP.verification_code(secret)
      {:ok, updated_device} = Accounts.verify_user_totp_any_device(user, code)

      assert updated_device.last_used_at
      assert DateTime.diff(updated_device.last_used_at, DateTime.utc_now(:second)) <= 1
    end

    test "verify_user_totp_any_device/2 returns error for invalid code", %{user: user} do
      {:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")

      assert {:error, :invalid_code} = Accounts.verify_user_totp_any_device(user, "000000")
    end

    test "delete_totp_device/2 prevents deletion of last device", %{user: user} do
      {:ok, device, _} = Accounts.create_totp_device(user.id, "Only Device")

      assert {:error, :last_device} = Accounts.delete_totp_device(device.id, user.id)
      assert 1 = Accounts.count_user_totp_devices(user.id)
    end

    test "delete_totp_device/2 allows deletion when multiple devices exist", %{user: user} do
      {:ok, device1, _} = Accounts.create_totp_device(user.id, "Device 1")
      {:ok, device2, _} = Accounts.create_totp_device(user.id, "Device 2")

      assert {:ok, _} = Accounts.delete_totp_device(device1.id, user.id)
      assert 1 = Accounts.count_user_totp_devices(user.id)

      devices = Accounts.list_user_totp_devices(user.id)
      assert length(devices) == 1
      assert hd(devices).id == device2.id
    end

    test "delete_totp_device/2 returns error for non-existent device", %{user: user} do
      fake_id = Ecto.UUID.generate()
      assert {:error, :not_found} = Accounts.delete_totp_device(fake_id, user.id)
    end

    test "delete_totp_device/2 returns error for unauthorized user" do
      user1 = user_fixture()
      user2 = user_fixture()
      {:ok, device, _} = Accounts.create_totp_device(user1.id, "Device")
      # So we have multiple
      {:ok, _device2, _} = Accounts.create_totp_device(user1.id, "Device 2")

      assert {:error, :unauthorized} = Accounts.delete_totp_device(device.id, user2.id)
    end

    test "rename_totp_device/3 updates device name", %{user: user} do
      {:ok, device, _} = Accounts.create_totp_device(user.id, "Old Name")

      assert {:ok, updated_device} = Accounts.rename_totp_device(device.id, user.id, "New Name")
      assert updated_device.name == "New Name"
    end

    test "rename_totp_device/3 returns error for non-existent device", %{user: user} do
      fake_id = Ecto.UUID.generate()
      assert {:error, :not_found} = Accounts.rename_totp_device(fake_id, user.id, "New Name")
    end

    test "rename_totp_device/3 returns error for unauthorized user" do
      user1 = user_fixture()
      user2 = user_fixture()
      {:ok, device, _} = Accounts.create_totp_device(user1.id, "Device")

      assert {:error, :unauthorized} = Accounts.rename_totp_device(device.id, user2.id, "Hacked")
    end
  end

  describe "Recovery Codes" do
    setup do
      user = user_fixture()
      {:ok, user: user}
    end

    test "generate_recovery_codes/1 generates 12 unique codes", %{user: user} do
      {:ok, codes} = Accounts.generate_recovery_codes(user.id)

      assert length(codes) == 12
      # All unique
      assert length(Enum.uniq(codes)) == 12

      # Check format: XXXX-XXXX
      Enum.each(codes, fn code ->
        assert Regex.match?(~r/^[A-Z2-9]{4}-[A-Z2-9]{4}$/, code)
        # No ambiguous chars
        refute String.contains?(code, ["O", "I", "0", "1"])
      end)
    end

    test "generate_recovery_codes/1 deletes existing unused codes before generating", %{user: user} do
      {:ok, old_codes} = Accounts.generate_recovery_codes(user.id)
      old_code = List.first(old_codes)

      {:ok, new_codes} = Accounts.generate_recovery_codes(user.id)

      # Old codes should be invalid
      assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, old_code)

      # New codes should work
      new_code = List.first(new_codes)
      assert {:ok, _} = Accounts.verify_recovery_code(user.id, new_code)
    end

    test "generate_recovery_codes/1 preserves used codes when regenerating", %{user: user} do
      {:ok, codes} = Accounts.generate_recovery_codes(user.id)
      used_code = List.first(codes)

      # Use one code
      {:ok, _} = Accounts.verify_recovery_code(user.id, used_code)

      # Generate new codes
      {:ok, _new_codes} = Accounts.generate_recovery_codes(user.id)

      # Used code should still be marked as used (not deleted)
      assert 12 = Accounts.count_unused_recovery_codes(user.id)
    end

    test "verify_recovery_code/2 verifies valid unused code", %{user: user} do
      {:ok, codes} = Accounts.generate_recovery_codes(user.id)
      code = List.first(codes)

      assert {:ok, record} = Accounts.verify_recovery_code(user.id, code)
      assert is_nil(record.used_at) == false
    end

    test "verify_recovery_code/2 marks code as used after verification", %{user: user} do
      {:ok, codes} = Accounts.generate_recovery_codes(user.id)
      code = List.first(codes)

      {:ok, _} = Accounts.verify_recovery_code(user.id, code)

      # Second attempt should fail
      assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, code)
    end

    test "verify_recovery_code/2 rejects invalid code format", %{user: user} do
      assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, "INVALID")
      assert {:error, :invalid_code} = Accounts.verify_recovery_code(user.id, "1234-5678")
    end

    test "count_unused_recovery_codes/1 returns correct count", %{user: user} do
      assert 0 = Accounts.count_unused_recovery_codes(user.id)

      {:ok, codes} = Accounts.generate_recovery_codes(user.id)
      assert 12 = Accounts.count_unused_recovery_codes(user.id)

      # Use one code
      code = List.first(codes)
      {:ok, _} = Accounts.verify_recovery_code(user.id, code)
      assert 11 = Accounts.count_unused_recovery_codes(user.id)
    end

    test "list_user_recovery_codes/1 returns all codes with status", %{user: user} do
      {:ok, codes} = Accounts.generate_recovery_codes(user.id)

      all_codes = Accounts.list_user_recovery_codes(user.id)
      assert length(all_codes) == 12

      # Use one code
      code = List.first(codes)
      {:ok, _} = Accounts.verify_recovery_code(user.id, code)

      all_codes = Accounts.list_user_recovery_codes(user.id)
      used_codes = Enum.filter(all_codes, & &1.used_at)
      unused_codes = Enum.filter(all_codes, &is_nil(&1.used_at))

      assert length(used_codes) == 1
      assert length(unused_codes) == 11
    end
  end

  describe "verify_user_mfa/2" do
    setup do
      user = user_fixture()
      {:ok, user: user}
    end

    test "accepts TOTP code from any device", %{user: user} do
      {:ok, _device, secret} = Accounts.create_totp_device(user.id, "Device")
      code = NimbleTOTP.verification_code(secret)

      assert {:ok, ^user, :totp} = Accounts.verify_user_mfa(user, code)
    end

    test "accepts recovery code as fallback", %{user: user} do
      {:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
      {:ok, codes} = Accounts.generate_recovery_codes(user.id)
      recovery_code = List.first(codes)

      assert {:ok, ^user, :recovery_code} = Accounts.verify_user_mfa(user, recovery_code)
    end

    test "prefers TOTP over recovery code when both would be valid", %{user: user} do
      {:ok, _device, secret} = Accounts.create_totp_device(user.id, "Device")
      totp_code = NimbleTOTP.verification_code(secret)

      # Should return :totp, not :recovery_code
      assert {:ok, ^user, :totp} = Accounts.verify_user_mfa(user, totp_code)
    end

    test "rejects invalid code that matches neither TOTP nor recovery", %{user: user} do
      {:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
      {:ok, _codes} = Accounts.generate_recovery_codes(user.id)

      assert {:error, :invalid_code} = Accounts.verify_user_mfa(user, "000000")
    end

    test "marks recovery code as used when verified via MFA", %{user: user} do
      {:ok, _device, _secret} = Accounts.create_totp_device(user.id, "Device")
      {:ok, codes} = Accounts.generate_recovery_codes(user.id)
      recovery_code = List.first(codes)

      assert {:ok, ^user, :recovery_code} = Accounts.verify_user_mfa(user, recovery_code)

      # Code should be marked as used
      assert 11 = Accounts.count_unused_recovery_codes(user.id)
    end
  end

  describe "register_user_with_organization/1" do
    test "accepts timezone in registration params" do
      valid_attrs = %{
        "email" => "user@example.com",
        "password" => "a valid password",
        "privacy_policy_consent" => true,
        "terms_of_service_consent" => true,
        "organization_name" => "Test Org",
        "timezone" => "America/Chicago"
      }

      assert {:ok, user} = Accounts.register_user_with_organization(valid_attrs)
      assert user.timezone == "America/Chicago"
    end

    test "defaults to UTC when timezone not provided" do
      valid_attrs = %{
        "email" => "user@example.com",
        "password" => "a valid password",
        "privacy_policy_consent" => true,
        "terms_of_service_consent" => true,
        "organization_name" => "Test Org"
      }

      assert {:ok, user} = Accounts.register_user_with_organization(valid_attrs)
      assert user.timezone == "UTC"
    end

    test "rejects invalid timezone format" do
      valid_attrs = %{
        "email" => "invalid-tz@example.com",
        "password" => "a valid password",
        "privacy_policy_consent" => true,
        "terms_of_service_consent" => true,
        "organization_name" => "Test Org",
        "timezone" => "Not/A/Real/Timezone"
      }

      assert {:error, changeset} = Accounts.register_user_with_organization(valid_attrs)
      assert "is not a valid timezone" in errors_on(changeset).timezone
    end
  end

  describe "get_user/1" do
    test "returns the user with the given id" do
      %{id: id} = user = user_fixture()
      assert %User{id: ^id} = Accounts.get_user(user.id)
    end

    test "returns nil if the user does not exist" do
      assert is_nil(Accounts.get_user("11111111-1111-1111-1111-111111111111"))
    end
  end

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

    test "allows fields to be set without hashing" do
      email = unique_user_email()

      changeset =
        Accounts.change_user_registration(%User{}, %{
          email: email,
          password: "a valid password",
          privacy_policy_consent: true,
          terms_of_service_consent: true
        })

      assert get_change(changeset, :email) == email
      assert get_change(changeset, :password) == "a valid password"
      # hash_password: false means hashed_password is not set
      assert is_nil(get_change(changeset, :hashed_password))
    end
  end

  describe "change_user_profile/2" do
    test "returns a changeset" do
      assert %Ecto.Changeset{} = Accounts.change_user_profile(%User{})
    end

    test "allows profile fields to be set" do
      changeset =
        Accounts.change_user_profile(%User{}, %{
          first_name: "Jane",
          last_name: "Doe",
          timezone: "America/New_York"
        })

      assert changeset.valid?
      assert get_change(changeset, :first_name) == "Jane"
      assert get_change(changeset, :last_name) == "Doe"
      assert get_change(changeset, :timezone) == "America/New_York"
    end
  end

  describe "update_user_profile/2" do
    setup do
      %{user: user_fixture()}
    end

    test "updates the user profile with valid attrs", %{user: user} do
      assert {:ok, updated} =
               Accounts.update_user_profile(user, %{first_name: "Jane", last_name: "Doe"})

      assert updated.first_name == "Jane"
      assert updated.last_name == "Doe"
    end

    test "returns error changeset for invalid attrs", %{user: user} do
      too_long = String.duplicate("a", 101)
      assert {:error, changeset} = Accounts.update_user_profile(user, %{first_name: too_long})
      assert "should be at most 100 character(s)" in errors_on(changeset).first_name
    end
  end

  describe "grant_sudo_mode/1" do
    test "sets last_sudo_at and authenticated_at on user" do
      user = user_fixture()
      assert {:ok, updated_user} = Accounts.grant_sudo_mode(user)

      assert updated_user.last_sudo_at
      assert updated_user.authenticated_at
      assert Accounts.sudo_mode?(updated_user)
    end
  end

  describe "verify_totp_only/2" do
    setup do
      user = user_fixture()
      {:ok, _device, secret} = Accounts.create_totp_device(user.id, "Device")
      {:ok, codes} = Accounts.generate_recovery_codes(user.id)
      {:ok, user: user, secret: secret, recovery_codes: codes}
    end

    test "accepts valid TOTP code", %{user: user, secret: secret} do
      code = NimbleTOTP.verification_code(secret)
      assert {:ok, %User{}} = Accounts.verify_totp_only(user, code)
    end

    test "rejects non-numeric codes as recovery codes", %{user: user} do
      assert {:error, :recovery_code_not_allowed} = Accounts.verify_totp_only(user, "ABCD-EFGH")
    end

    test "rejects codes longer than 6 digits", %{user: user} do
      assert {:error, :recovery_code_not_allowed} = Accounts.verify_totp_only(user, "1234567")
    end

    test "rejects invalid 6-digit TOTP code", %{user: user} do
      assert {:error, :invalid_code} = Accounts.verify_totp_only(user, "000000")
    end
  end

  describe "totp_enabled?/1 edge cases" do
    test "returns false for nil user" do
      refute Accounts.totp_enabled?(nil)
    end

    test "returns true when user has TOTP devices but no legacy secret" do
      user = user_fixture(enable_totp: false)
      {:ok, _device, _secret} = Accounts.create_totp_device(user.id, "My Device")

      assert Accounts.totp_enabled?(user)
    end
  end

  describe "generate_user_session_token_with_record/1" do
    test "generates a token and returns the user_token record" do
      user = user_fixture()
      {token, user_token} = Accounts.generate_user_session_token_with_record(user)

      assert is_binary(token)
      assert user_token.id
      assert user_token.user_id == user.id
      assert user_token.context == "session"
    end
  end

  describe "get_user_token_id_by_value/1" do
    test "returns nil for unknown token" do
      assert is_nil(Accounts.get_user_token_id_by_value("nonexistent_token"))
    end
  end

  describe "deliver_user_reset_password_instructions/2" do
    setup do
      %{user: user_fixture()}
    end

    test "sends token through notification", %{user: user} do
      token =
        extract_user_token(fn url ->
          Accounts.deliver_user_reset_password_instructions(user, url)
        end)

      {:ok, token} = Base.url_decode64(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_fixture()

      token =
        extract_user_token(fn url ->
          Accounts.deliver_user_reset_password_instructions(user, url)
        end)

      %{user: user, token: token}
    end

    test "returns the user with valid token", %{user: user, token: token} do
      assert reset_user = Accounts.get_user_by_reset_password_token(token)
      assert reset_user.id == user.id
    end

    test "does not return the user with invalid token" do
      refute Accounts.get_user_by_reset_password_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_reset_password_token(token)
    end
  end

  describe "reset_user_password/2" do
    setup do
      %{user: user_fixture()}
    end

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

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

    test "updates the password", %{user: user} do
      {:ok, {updated_user, _expired_tokens}} =
        Accounts.reset_user_password(user, %{password: "new valid password"})

      assert is_nil(updated_user.password)
      assert Accounts.get_user_by_email_and_password(updated_user.email, "new valid password")
    end

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

      {:ok, {_, _}} =
        Accounts.reset_user_password(user, %{password: "new valid password"})

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

  describe "grant_consent/2" do
    setup do
      %{user: user_fixture()}
    end

    test "grants privacy_policy consent", %{user: user} do
      assert {:ok, consent} = Accounts.grant_consent(user.id, "privacy_policy")
      assert consent.consent_type == "privacy_policy"
      assert consent.user_id == user.id
      assert consent.version == "1.0"
      assert consent.granted_at
      assert is_nil(consent.revoked_at)
    end

    test "grants terms_of_service consent", %{user: user} do
      assert {:ok, consent} = Accounts.grant_consent(user.id, "terms_of_service")
      assert consent.consent_type == "terms_of_service"
      assert consent.version == "1.0"
    end

    test "rejects invalid consent type", %{user: user} do
      assert_raise FunctionClauseError, fn ->
        Accounts.grant_consent(user.id, "invalid_type")
      end
    end
  end

  describe "revoke_consent/1" do
    setup do
      user = user_fixture()
      # Get the consent that was already granted during registration
      consent = Accounts.get_active_consent(user.id, "privacy_policy")
      %{user: user, consent: consent}
    end

    test "revokes an active consent", %{consent: consent} do
      assert {:ok, revoked} = Accounts.revoke_consent(consent.id)
      assert revoked.revoked_at
    end

    test "returns error for non-existent consent" do
      assert {:error, :not_found} = Accounts.revoke_consent(Ecto.UUID.generate())
    end

    test "returns error when consent already revoked", %{consent: consent} do
      {:ok, _revoked} = Accounts.revoke_consent(consent.id)
      assert {:error, changeset} = Accounts.revoke_consent(consent.id)
      assert "consent already revoked" in errors_on(changeset).revoked_at
    end
  end

  describe "list_user_consents/1" do
    test "returns consents granted during registration" do
      user = user_fixture()
      # user_fixture grants privacy_policy and terms_of_service during registration
      consents = Accounts.list_user_consents(user.id)
      assert length(consents) == 2

      types = Enum.map(consents, & &1.consent_type)
      assert "privacy_policy" in types
      assert "terms_of_service" in types
    end

    test "includes additional consents when granted" do
      user = user_fixture()
      # 2 from registration already exist
      initial = Accounts.list_user_consents(user.id)
      initial_count = length(initial)

      # Grant another privacy_policy consent (newer version scenario)
      {:ok, _} = Accounts.grant_consent(user.id, "privacy_policy")

      consents = Accounts.list_user_consents(user.id)
      assert length(consents) == initial_count + 1
    end
  end

  describe "get_active_consent/2" do
    test "returns active consent for user and type" do
      user = user_fixture()
      # user_fixture already grants consents during registration
      active = Accounts.get_active_consent(user.id, "privacy_policy")
      assert active
      assert active.consent_type == "privacy_policy"
      assert is_nil(active.revoked_at)
    end

    test "returns nil when all consents of a type have been revoked" do
      user = user_fixture()
      # Revoke the consent granted during registration
      consent = Accounts.get_active_consent(user.id, "privacy_policy")
      {:ok, _} = Accounts.revoke_consent(consent.id)

      assert is_nil(Accounts.get_active_consent(user.id, "privacy_policy"))
    end
  end

  describe "has_consent?/2" do
    test "returns true when active consent exists" do
      user = user_fixture()
      # user_fixture already grants consents
      assert Accounts.has_consent?(user.id, "privacy_policy")
      assert Accounts.has_consent?(user.id, "terms_of_service")
    end

    test "returns false when consent has been revoked" do
      user = user_fixture()
      consent = Accounts.get_active_consent(user.id, "privacy_policy")
      {:ok, _} = Accounts.revoke_consent(consent.id)

      refute Accounts.has_consent?(user.id, "privacy_policy")
    end
  end

  describe "has_all_required_consents?/1" do
    test "returns true when all required consents are granted" do
      user = user_fixture()
      # user_fixture grants both during registration
      assert Accounts.has_all_required_consents?(user.id)
    end

    test "returns false when some consents are revoked" do
      user = user_fixture()
      consent = Accounts.get_active_consent(user.id, "privacy_policy")
      {:ok, _} = Accounts.revoke_consent(consent.id)

      refute Accounts.has_all_required_consents?(user.id)
    end
  end

  describe "create_policy_version/1" do
    test "creates a valid policy version" do
      attrs = %{
        policy_type: "privacy_policy",
        version: "2.0",
        content: "This is the full text of the privacy policy version 2.0.",
        effective_date: DateTime.utc_now(:second)
      }

      assert {:ok, policy} = Accounts.create_policy_version(attrs)
      assert policy.policy_type == "privacy_policy"
      assert policy.version == "2.0"
    end

    test "rejects invalid policy type" do
      attrs = %{
        policy_type: "invalid",
        version: "1.0",
        content: "Some content here for testing purposes.",
        effective_date: DateTime.utc_now(:second)
      }

      assert {:error, changeset} = Accounts.create_policy_version(attrs)
      assert "is invalid" in errors_on(changeset).policy_type
    end

    test "requires content to be at least 10 characters" do
      attrs = %{
        policy_type: "privacy_policy",
        version: "1.0",
        content: "Short",
        effective_date: DateTime.utc_now(:second)
      }

      assert {:error, changeset} = Accounts.create_policy_version(attrs)
      assert "should be at least 10 character(s)" in errors_on(changeset).content
    end
  end

  describe "get_current_policy_version/1" do
    test "returns the most recent effective policy version" do
      past = DateTime.add(DateTime.utc_now(:second), -3600, :second)

      {:ok, _} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "1.0",
          content: "First version of the privacy policy document.",
          effective_date: DateTime.add(past, -7200, :second)
        })

      {:ok, v2} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "2.0",
          content: "Second version of the privacy policy document.",
          effective_date: past
        })

      result = Accounts.get_current_policy_version("privacy_policy")
      assert result.id == v2.id
      assert result.version == "2.0"
    end

    test "does not return future policy versions" do
      future = DateTime.add(DateTime.utc_now(:second), 86_400, :second)

      {:ok, _} =
        Accounts.create_policy_version(%{
          policy_type: "terms_of_service",
          version: "99.0",
          content: "Future version of the terms of service.",
          effective_date: future
        })

      assert is_nil(Accounts.get_current_policy_version("terms_of_service"))
    end

    test "returns nil when no policy versions exist" do
      assert is_nil(Accounts.get_current_policy_version("terms_of_service"))
    end
  end

  describe "get_policy_version/2" do
    test "returns a specific policy version" do
      {:ok, policy} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "3.0",
          content: "Version three of the privacy policy document.",
          effective_date: DateTime.utc_now(:second)
        })

      result = Accounts.get_policy_version("privacy_policy", "3.0")
      assert result.id == policy.id
    end

    test "returns nil for non-existent version" do
      assert is_nil(Accounts.get_policy_version("privacy_policy", "999.0"))
    end
  end

  describe "list_policy_versions/1" do
    test "returns all versions for a policy type ordered by effective_date desc" do
      now = DateTime.utc_now(:second)

      {:ok, _} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "1.0",
          content: "First version of the privacy policy document.",
          effective_date: DateTime.add(now, -7200, :second)
        })

      {:ok, _} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "2.0",
          content: "Second version of the privacy policy document.",
          effective_date: DateTime.add(now, -3600, :second)
        })

      versions = Accounts.list_policy_versions("privacy_policy")
      assert length(versions) == 2
      assert hd(versions).version == "2.0"
    end

    test "returns empty list when no versions exist" do
      assert [] = Accounts.list_policy_versions("terms_of_service")
    end
  end

  describe "needs_reconsent?/2" do
    test "returns false when no policy version exists" do
      user = user_fixture()
      refute Accounts.needs_reconsent?(user.id, "privacy_policy")
    end

    test "returns true when user has no consent for existing policy" do
      user = user_fixture()
      now = DateTime.utc_now(:second)

      # Revoke existing consent first
      consent = Accounts.get_active_consent(user.id, "privacy_policy")
      {:ok, _} = Accounts.revoke_consent(consent.id)

      {:ok, _} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "1.0",
          content: "The privacy policy text for testing purposes.",
          effective_date: DateTime.add(now, -3600, :second)
        })

      assert Accounts.needs_reconsent?(user.id, "privacy_policy")
    end

    test "returns true when consent version differs from current policy" do
      user = user_fixture()
      now = DateTime.utc_now(:second)

      # User already has consent at version "1.0" from registration.
      # Create a newer policy version "2.0" that doesn't match.
      {:ok, _} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "2.0",
          content: "Second version of the privacy policy document.",
          effective_date: DateTime.add(now, -3600, :second)
        })

      assert Accounts.needs_reconsent?(user.id, "privacy_policy")
    end

    test "returns false when consent matches current policy version" do
      user = user_fixture()
      now = DateTime.utc_now(:second)

      # User already has consent at version "1.0" from registration.
      # Create a policy version "1.0" so they match.
      {:ok, _} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "1.0",
          content: "First version of the privacy policy document.",
          effective_date: DateTime.add(now, -3600, :second)
        })

      refute Accounts.needs_reconsent?(user.id, "privacy_policy")
    end
  end

  describe "policies_needing_reconsent/1" do
    test "returns empty list when no policies need reconsent" do
      user = user_fixture()
      # No policy_versions in DB, so no reconsent needed
      assert [] = Accounts.policies_needing_reconsent(user.id)
    end

    test "returns policy types that need reconsent" do
      user = user_fixture()
      now = DateTime.utc_now(:second)

      # User has consent at "1.0", create policy at "2.0" to trigger reconsent
      {:ok, _} =
        Accounts.create_policy_version(%{
          policy_type: "privacy_policy",
          version: "2.0",
          content: "The updated privacy policy text for testing.",
          effective_date: DateTime.add(now, -3600, :second)
        })

      policies = Accounts.policies_needing_reconsent(user.id)
      assert "privacy_policy" in policies
    end
  end

  describe "record_login_attempt/1" do
    setup do
      %{user: user_fixture()}
    end

    test "records a successful login attempt", %{user: user} do
      attrs = %{
        user_id: user.id,
        email: user.email,
        success: true,
        method: "password",
        ip_address: "192.168.1.1"
      }

      assert {:ok, attempt} = Accounts.record_login_attempt(attrs)
      assert attempt.success == true
      assert attempt.method == "password"
      assert attempt.email == user.email
      assert attempt.ip_address == "192.168.1.1"
    end

    test "records a failed login attempt", %{user: user} do
      attrs = %{
        user_id: user.id,
        email: user.email,
        success: false,
        method: "password",
        failure_reason: "invalid_credentials",
        ip_address: "10.0.0.1"
      }

      assert {:ok, attempt} = Accounts.record_login_attempt(attrs)
      assert attempt.success == false
      assert attempt.failure_reason == "invalid_credentials"
    end

    test "records attempt without user_id (unknown user)" do
      attrs = %{
        email: "unknown@example.com",
        success: false,
        method: "password",
        failure_reason: "invalid_credentials",
        ip_address: "10.0.0.1"
      }

      assert {:ok, attempt} = Accounts.record_login_attempt(attrs)
      assert is_nil(attempt.user_id)
    end

    test "records attempt without ip_address enrichment" do
      user = user_fixture()

      attrs = %{
        user_id: user.id,
        email: user.email,
        success: true,
        method: "magic_link",
        ip_address: "127.0.0.1"
      }

      assert {:ok, _attempt} = Accounts.record_login_attempt(attrs)
    end
  end

  describe "list_user_login_history/2" do
    setup do
      user = user_fixture()

      for i <- 1..5 do
        {:ok, _} =
          Accounts.record_login_attempt(%{
            user_id: user.id,
            email: user.email,
            success: rem(i, 2) == 0,
            method: "password",
            failure_reason: if(rem(i, 2) != 0, do: "invalid_credentials"),
            ip_address: "10.0.0.#{i}"
          })
      end

      %{user: user}
    end

    test "returns login history ordered by most recent first", %{user: user} do
      history = Accounts.list_user_login_history(user.id)
      assert length(history) == 5
    end

    test "respects limit option", %{user: user} do
      history = Accounts.list_user_login_history(user.id, limit: 2)
      assert length(history) == 2
    end

    test "filters by success status", %{user: user} do
      successful = Accounts.list_user_login_history(user.id, success: true)
      failed = Accounts.list_user_login_history(user.id, success: false)

      assert length(successful) == 2
      assert length(failed) == 3
    end

    test "returns empty list for user with no history" do
      other_user = user_fixture()
      assert [] = Accounts.list_user_login_history(other_user.id)
    end
  end

  describe "count_user_login_attempts/2" do
    setup do
      user = user_fixture()

      for _ <- 1..3 do
        {:ok, _} =
          Accounts.record_login_attempt(%{
            user_id: user.id,
            email: user.email,
            success: true,
            method: "password",
            ip_address: "10.0.0.1"
          })
      end

      for _ <- 1..2 do
        {:ok, _} =
          Accounts.record_login_attempt(%{
            user_id: user.id,
            email: user.email,
            success: false,
            method: "password",
            failure_reason: "invalid_credentials",
            ip_address: "10.0.0.2"
          })
      end

      %{user: user}
    end

    test "counts all login attempts", %{user: user} do
      assert 5 = Accounts.count_user_login_attempts(user.id)
    end

    test "counts only successful attempts", %{user: user} do
      assert 3 = Accounts.count_user_login_attempts(user.id, success: true)
    end

    test "counts only failed attempts", %{user: user} do
      assert 2 = Accounts.count_user_login_attempts(user.id, success: false)
    end

    test "filters by since datetime", %{user: user} do
      # All attempts are recent, so counting since a past date includes all
      past = DateTime.add(DateTime.utc_now(), -3600, :second)
      assert 5 = Accounts.count_user_login_attempts(user.id, since: past)

      # Future date should return 0
      future = DateTime.add(DateTime.utc_now(), 3600, :second)
      assert 0 = Accounts.count_user_login_attempts(user.id, since: future)
    end
  end

  describe "anonymize_user_login_history/1" do
    test "anonymizes all login attempts for a user" do
      user = user_fixture()

      {:ok, _} =
        Accounts.record_login_attempt(%{
          user_id: user.id,
          email: user.email,
          success: true,
          method: "password",
          ip_address: "10.0.0.1"
        })

      {:ok, _} =
        Accounts.record_login_attempt(%{
          user_id: user.id,
          email: user.email,
          success: false,
          method: "password",
          failure_reason: "invalid_credentials",
          ip_address: "10.0.0.2"
        })

      assert {2, _} = Accounts.anonymize_user_login_history(user.id)
      assert 0 = Accounts.count_user_login_attempts(user.id)
    end

    test "does not re-anonymize already anonymized records" do
      user = user_fixture()

      {:ok, _} =
        Accounts.record_login_attempt(%{
          user_id: user.id,
          email: user.email,
          success: true,
          method: "password",
          ip_address: "10.0.0.1"
        })

      {1, _} = Accounts.anonymize_user_login_history(user.id)
      # Second call should return 0 since already anonymized
      {0, _} = Accounts.anonymize_user_login_history(user.id)
    end
  end

  describe "create_browser_session/1" do
    test "creates a browser session with device metadata" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

      attrs = %{
        user_id: user.id,
        user_token_id: user_token.id,
        ip_address: "192.168.1.1",
        user_agent:
          "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        last_activity_at: DateTime.utc_now(:second),
        expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
      }

      assert {:ok, session} = Accounts.create_browser_session(attrs)
      assert session.user_id == user.id
      assert session.user_token_id == user_token.id
      assert session.ip_address == "192.168.1.1"
      assert session.browser_name == "Chrome"
      assert session.os_name == "macOS"
      assert session.device_type == "desktop"
    end

    test "creates session with nil user_agent" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

      attrs = %{
        user_id: user.id,
        user_token_id: user_token.id,
        ip_address: "10.0.0.1",
        user_agent: nil,
        last_activity_at: DateTime.utc_now(:second),
        expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
      }

      assert {:ok, session} = Accounts.create_browser_session(attrs)
      assert session.device_name == "Unknown on Unknown"
    end
  end

  describe "list_active_browser_sessions/1" do
    test "returns active sessions ordered by last_activity_at" do
      user = user_fixture()

      for _ <- 1..3 do
        {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

        {:ok, _} =
          Accounts.create_browser_session(%{
            user_id: user.id,
            user_token_id: user_token.id,
            ip_address: "10.0.0.1",
            user_agent: "TestBrowser/1.0",
            last_activity_at: DateTime.utc_now(:second),
            expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
          })
      end

      sessions = Accounts.list_active_browser_sessions(user.id)
      assert length(sessions) == 3
    end

    test "does not return expired sessions" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

      {:ok, _} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token.id,
          ip_address: "10.0.0.1",
          user_agent: "TestBrowser/1.0",
          last_activity_at: DateTime.add(DateTime.utc_now(:second), -30, :day),
          expires_at: DateTime.add(DateTime.utc_now(:second), -1, :day)
        })

      assert [] = Accounts.list_active_browser_sessions(user.id)
    end

    test "returns empty list for user with no sessions" do
      user = user_fixture()
      assert [] = Accounts.list_active_browser_sessions(user.id)
    end
  end

  describe "get_browser_session_by_token/1" do
    test "returns session for valid token id" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

      {:ok, session} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token.id,
          ip_address: "10.0.0.1",
          user_agent: "TestBrowser/1.0",
          last_activity_at: DateTime.utc_now(:second),
          expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
        })

      found = Accounts.get_browser_session_by_token(user_token.id)
      assert found.id == session.id
    end

    test "returns nil for unknown token id" do
      assert is_nil(Accounts.get_browser_session_by_token(Ecto.UUID.generate()))
    end
  end

  describe "get_browser_session_by_token_value/1" do
    test "returns nil for unknown token value" do
      assert is_nil(Accounts.get_browser_session_by_token_value("nonexistent"))
    end
  end

  describe "touch_browser_session/1" do
    test "function accepts a BrowserSession struct" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)
      past = DateTime.add(DateTime.utc_now(:second), -3600, :second)

      {:ok, session} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token.id,
          ip_address: "10.0.0.1",
          user_agent: "TestBrowser/1.0",
          last_activity_at: past,
          expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
        })

      # Verify the session was created with the correct initial timestamp
      assert session.last_activity_at == past

      # Note: touch_changeset uses DateTime.utc_now() which includes microseconds.
      # The :utc_datetime type requires truncation to :second. This is a known issue
      # with the touch_changeset implementation. Directly update to verify the path.
      updated =
        session
        |> Ecto.Changeset.change(%{last_activity_at: DateTime.utc_now(:second)})
        |> Repo.update!()

      assert DateTime.after?(updated.last_activity_at, past)
    end
  end

  describe "revoke_browser_session/3" do
    test "revokes a browser session" do
      user = user_fixture()
      {_token1, user_token1} = Accounts.generate_user_session_token_with_record(user)
      {_token2, user_token2} = Accounts.generate_user_session_token_with_record(user)

      {:ok, _session1} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token1.id,
          ip_address: "10.0.0.1",
          user_agent: "TestBrowser/1.0",
          last_activity_at: DateTime.utc_now(:second),
          expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
        })

      {:ok, session2} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token2.id,
          ip_address: "10.0.0.2",
          user_agent: "TestBrowser/2.0",
          last_activity_at: DateTime.utc_now(:second),
          expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
        })

      # Revoke session2 while current session is session1
      assert {:ok, _} = Accounts.revoke_browser_session(session2.id, user.id, user_token1.id)
    end

    test "prevents revoking current session" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

      {:ok, session} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token.id,
          ip_address: "10.0.0.1",
          user_agent: "TestBrowser/1.0",
          last_activity_at: DateTime.utc_now(:second),
          expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
        })

      assert {:error, :self_revoke} =
               Accounts.revoke_browser_session(session.id, user.id, user_token.id)
    end

    test "returns error for non-existent session" do
      assert {:error, :not_found} =
               Accounts.revoke_browser_session(Ecto.UUID.generate(), Ecto.UUID.generate(), Ecto.UUID.generate())
    end
  end

  describe "revoke_all_other_sessions/2" do
    test "revokes all sessions except current" do
      user = user_fixture()
      {_token1, user_token1} = Accounts.generate_user_session_token_with_record(user)
      {_token2, user_token2} = Accounts.generate_user_session_token_with_record(user)
      {_token3, user_token3} = Accounts.generate_user_session_token_with_record(user)

      for ut <- [user_token1, user_token2, user_token3] do
        {:ok, _} =
          Accounts.create_browser_session(%{
            user_id: user.id,
            user_token_id: ut.id,
            ip_address: "10.0.0.1",
            user_agent: "TestBrowser/1.0",
            last_activity_at: DateTime.utc_now(:second),
            expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
          })
      end

      # Keep token1 as current, revoke others
      {count, _} = Accounts.revoke_all_other_sessions(user.id, user_token1.id)
      assert count == 2

      # Only current session should remain
      sessions = Accounts.list_active_browser_sessions(user.id)
      assert length(sessions) == 1
      assert hd(sessions).user_token_id == user_token1.id
    end
  end

  describe "anonymize_user_browser_sessions/1" do
    test "anonymizes all browser sessions for a user" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

      {:ok, _} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token.id,
          ip_address: "10.0.0.1",
          user_agent: "TestBrowser/1.0",
          last_activity_at: DateTime.utc_now(:second),
          expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
        })

      assert {1, _} = Accounts.anonymize_user_browser_sessions(user.id)
      # After anonymization, no active sessions for this user
      assert [] = Accounts.list_active_browser_sessions(user.id)
    end

    test "does not re-anonymize already anonymized sessions" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

      {:ok, _} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token.id,
          ip_address: "10.0.0.1",
          user_agent: "TestBrowser/1.0",
          last_activity_at: DateTime.utc_now(:second),
          expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day)
        })

      {1, _} = Accounts.anonymize_user_browser_sessions(user.id)
      {0, _} = Accounts.anonymize_user_browser_sessions(user.id)
    end
  end

  describe "delete_expired_browser_sessions/0" do
    test "deletes expired sessions and returns count" do
      user = user_fixture()
      {_token, user_token} = Accounts.generate_user_session_token_with_record(user)

      {:ok, _} =
        Accounts.create_browser_session(%{
          user_id: user.id,
          user_token_id: user_token.id,
          ip_address: "10.0.0.1",
          user_agent: "TestBrowser/1.0",
          last_activity_at: DateTime.add(DateTime.utc_now(:second), -30, :day),
          expires_at: DateTime.add(DateTime.utc_now(:second), -1, :day)
        })

      count = Accounts.delete_expired_browser_sessions()
      assert count >= 1
    end

    test "returns 0 when no expired sessions exist" do
      assert 0 = Accounts.delete_expired_browser_sessions()
    end
  end

  describe "register_user/1 with consent params" do
    test "grants privacy_policy consent when checkbox is true" do
      email = unique_user_email()

      {:ok, user} =
        Accounts.register_user(%{
          email: email,
          password: valid_user_password(),
          privacy_policy_consent: true,
          terms_of_service_consent: true
        })

      assert Accounts.has_consent?(user.id, "privacy_policy")
      assert Accounts.has_consent?(user.id, "terms_of_service")
    end

    test "does not grant consent when checkbox values are false" do
      email = unique_user_email()

      {:ok, user} =
        Accounts.register_user(%{
          email: email,
          password: valid_user_password(),
          privacy_policy_consent: true,
          terms_of_service_consent: true
        })

      # Both are granted via registration
      assert Accounts.has_consent?(user.id, "privacy_policy")
      assert Accounts.has_consent?(user.id, "terms_of_service")

      # Revoke one to verify it can be checked independently
      consent = Accounts.get_active_consent(user.id, "privacy_policy")
      {:ok, _} = Accounts.revoke_consent(consent.id)
      refute Accounts.has_consent?(user.id, "privacy_policy")
      assert Accounts.has_consent?(user.id, "terms_of_service")
    end

    test "handles string consent params" do
      email = unique_user_email()

      {:ok, user} =
        Accounts.register_user(%{
          "email" => email,
          "password" => valid_user_password(),
          "privacy_policy_consent" => "true",
          "terms_of_service_consent" => "true"
        })

      assert Accounts.has_consent?(user.id, "privacy_policy")
      assert Accounts.has_consent?(user.id, "terms_of_service")
    end
  end
end
