defmodule Towerops.ApiTokensTest do
  use Towerops.DataCase
  use ExUnitProperties

  import Towerops.AccountsFixtures
  import Towerops.OrganizationsFixtures

  alias Towerops.ApiTokens
  alias Towerops.ApiTokens.ApiToken

  describe "create_api_token/1" do
    setup do
      user = user_fixture()
      organization = organization_fixture(user.id)
      %{user: user, organization: organization}
    end

    test "creates an API token with valid attributes", %{
      user: user,
      organization: organization
    } do
      attrs = %{
        organization_id: organization.id,
        user_id: user.id,
        name: "Test Token"
      }

      assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs)
      assert api_token.name == "Test Token"
      assert api_token.organization_id == organization.id
      assert api_token.user_id == user.id
      assert is_binary(api_token.token_hash)
      assert String.starts_with?(raw_token, "towerops_")
      assert is_nil(api_token.last_used_at)
      assert is_nil(api_token.expires_at)
    end

    test "creates an API token without user_id", %{organization: organization} do
      attrs = %{
        organization_id: organization.id,
        name: "Test Token"
      }

      assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs)
      assert api_token.name == "Test Token"
      assert api_token.organization_id == organization.id
      assert is_nil(api_token.user_id)
      assert is_binary(api_token.token_hash)
      assert String.starts_with?(raw_token, "towerops_")
    end

    test "creates an API token with expiration", %{
      user: user,
      organization: organization
    } do
      expires_at = DateTime.add(DateTime.utc_now(), 30, :day)

      attrs = %{
        organization_id: organization.id,
        user_id: user.id,
        name: "Expiring Token",
        expires_at: expires_at
      }

      assert {:ok, {api_token, _raw_token}} = ApiTokens.create_api_token(attrs)
      assert api_token.expires_at == DateTime.truncate(expires_at, :second)
    end

    test "returns error with invalid attributes" do
      assert {:error, %Ecto.Changeset{}} = ApiTokens.create_api_token(%{})
    end

    test "returns error with blank name", %{organization: organization} do
      attrs = %{
        organization_id: organization.id,
        name: ""
      }

      assert {:error, changeset} = ApiTokens.create_api_token(attrs)
      assert "can't be blank" in errors_on(changeset).name
    end

    test "returns error without organization_id" do
      attrs = %{
        name: "Test Token"
      }

      assert {:error, changeset} = ApiTokens.create_api_token(attrs)
      assert "can't be blank" in errors_on(changeset).organization_id
    end

    test "generates unique tokens", %{user: user, organization: organization} do
      attrs = %{
        organization_id: organization.id,
        user_id: user.id,
        name: "Test Token"
      }

      {:ok, {_token1, raw_token1}} = ApiTokens.create_api_token(attrs)
      {:ok, {_token2, raw_token2}} = ApiTokens.create_api_token(attrs)

      assert raw_token1 != raw_token2
    end
  end

  describe "list_organization_api_tokens/1" do
    setup do
      user = user_fixture()
      organization = organization_fixture(user.id)
      %{user: user, organization: organization}
    end

    test "returns all tokens for an organization", %{
      user: user,
      organization: organization
    } do
      {:ok, {token1, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 1"
        })

      {:ok, {token2, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 2"
        })

      tokens = ApiTokens.list_organization_api_tokens(organization.id)

      assert length(tokens) == 2
      assert Enum.any?(tokens, fn t -> t.id == token1.id end)
      assert Enum.any?(tokens, fn t -> t.id == token2.id end)
    end

    test "returns tokens ordered by creation date", %{
      user: user,
      organization: organization
    } do
      {:ok, {token1, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 1"
        })

      {:ok, {token2, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 2"
        })

      tokens = ApiTokens.list_organization_api_tokens(organization.id)

      assert length(tokens) == 2
      # Verify tokens are ordered by inserted_at (descending)
      [first, second] = tokens

      assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
      # Verify both tokens are present
      token_ids = Enum.map(tokens, & &1.id)
      assert token1.id in token_ids
      assert token2.id in token_ids
    end

    test "returns empty list for organization with no tokens", %{organization: organization} do
      assert [] = ApiTokens.list_organization_api_tokens(organization.id)
    end

    test "does not return tokens from other organizations", %{user: user, organization: org1} do
      org2 = organization_fixture(user.id)

      {:ok, {_token1, _}} =
        ApiTokens.create_api_token(%{
          organization_id: org1.id,
          user_id: user.id,
          name: "Org 1 Token"
        })

      {:ok, {_token2, _}} =
        ApiTokens.create_api_token(%{
          organization_id: org2.id,
          user_id: user.id,
          name: "Org 2 Token"
        })

      tokens = ApiTokens.list_organization_api_tokens(org1.id)

      assert length(tokens) == 1
      assert hd(tokens).name == "Org 1 Token"
    end
  end

  describe "list_user_api_tokens/1" do
    setup do
      user = user_fixture()
      organization = organization_fixture(user.id)
      %{user: user, organization: organization}
    end

    test "returns all tokens created by a user", %{user: user, organization: organization} do
      {:ok, {token1, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 1"
        })

      {:ok, {token2, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 2"
        })

      tokens = ApiTokens.list_user_api_tokens(user.id)

      assert length(tokens) == 2
      assert Enum.any?(tokens, fn t -> t.id == token1.id end)
      assert Enum.any?(tokens, fn t -> t.id == token2.id end)
    end

    test "preloads organization", %{user: user, organization: organization} do
      {:ok, {_token, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 1"
        })

      tokens = ApiTokens.list_user_api_tokens(user.id)

      assert [token] = tokens
      assert %Ecto.Association.NotLoaded{} != token.organization
      assert token.organization.id == organization.id
    end

    test "returns tokens ordered by creation date", %{
      user: user,
      organization: organization
    } do
      {:ok, {token1, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 1"
        })

      {:ok, {token2, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Token 2"
        })

      tokens = ApiTokens.list_user_api_tokens(user.id)

      assert length(tokens) == 2
      # Verify tokens are ordered by inserted_at (descending)
      [first, second] = tokens

      assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
      # Verify both tokens are present
      token_ids = Enum.map(tokens, & &1.id)
      assert token1.id in token_ids
      assert token2.id in token_ids
    end

    test "returns empty list for user with no tokens", %{user: user} do
      assert [] = ApiTokens.list_user_api_tokens(user.id)
    end

    test "does not return tokens created by other users", %{organization: organization} do
      user1 = user_fixture()
      user2 = user_fixture()

      {:ok, {_token1, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user1.id,
          name: "User 1 Token"
        })

      {:ok, {_token2, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user2.id,
          name: "User 2 Token"
        })

      tokens = ApiTokens.list_user_api_tokens(user1.id)

      assert length(tokens) == 1
      assert hd(tokens).name == "User 1 Token"
    end
  end

  describe "get_api_token!/1" do
    setup do
      user = user_fixture()
      organization = organization_fixture(user.id)

      {:ok, {token, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Test Token"
        })

      %{token: token}
    end

    test "returns the token with the given id", %{token: token} do
      fetched_token = ApiTokens.get_api_token!(token.id)
      assert fetched_token.id == token.id
      assert fetched_token.name == token.name
    end

    test "raises if the token does not exist" do
      assert_raise Ecto.NoResultsError, fn ->
        ApiTokens.get_api_token!(Ecto.UUID.generate())
      end
    end
  end

  describe "verify_token/1" do
    setup do
      user = user_fixture()
      organization = organization_fixture(user.id)

      {:ok, {token, raw_token}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Test Token"
        })

      %{token: token, raw_token: raw_token, organization: organization, user: user}
    end

    test "returns organization_id and user for valid token", %{
      raw_token: raw_token,
      organization: organization,
      user: user
    } do
      assert {:ok, org_id, returned_user} = ApiTokens.verify_token(raw_token)
      assert org_id == organization.id
      assert returned_user.id == user.id
    end

    test "updates last_used_at timestamp", %{token: token, raw_token: raw_token} do
      assert is_nil(token.last_used_at)

      {:ok, _org_id, _user} = ApiTokens.verify_token(raw_token)

      # Give the async Task time to complete
      Process.sleep(50)

      updated_token = ApiTokens.get_api_token!(token.id)
      assert updated_token.last_used_at
      assert DateTime.diff(updated_token.last_used_at, DateTime.utc_now(), :second) <= 1
    end

    test "returns error for invalid token" do
      assert {:error, :invalid_token} = ApiTokens.verify_token("invalid_token")
    end

    test "returns error for expired token", %{
      user: user,
      organization: organization
    } do
      expires_at = DateTime.add(DateTime.utc_now(), -1, :day)

      {:ok, {_token, raw_token}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Expired Token",
          expires_at: expires_at
        })

      assert {:error, :invalid_token} = ApiTokens.verify_token(raw_token)
    end

    test "works for token without expiration", %{
      raw_token: raw_token,
      organization: organization
    } do
      assert {:ok, org_id, _user} = ApiTokens.verify_token(raw_token)
      assert org_id == organization.id
    end

    test "works for token without user_id", %{organization: organization} do
      {:ok, {_token, raw_token}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          name: "No User Token"
        })

      assert {:ok, org_id, nil} = ApiTokens.verify_token(raw_token)
      assert org_id == organization.id
    end
  end

  describe "delete_api_token/1" do
    setup do
      user = user_fixture()
      organization = organization_fixture(user.id)

      {:ok, {token, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Test Token"
        })

      %{token: token}
    end

    test "deletes the token", %{token: token} do
      assert {:ok, %ApiToken{}} = ApiTokens.delete_api_token(token)

      assert_raise Ecto.NoResultsError, fn ->
        ApiTokens.get_api_token!(token.id)
      end
    end
  end

  describe "update_api_token/2" do
    setup do
      user = user_fixture()
      organization = organization_fixture(user.id)

      {:ok, {token, _}} =
        ApiTokens.create_api_token(%{
          organization_id: organization.id,
          user_id: user.id,
          name: "Test Token"
        })

      %{token: token}
    end

    test "updates the token name", %{token: token} do
      assert {:ok, updated_token} = ApiTokens.update_api_token(token, %{name: "New Name"})
      assert updated_token.name == "New Name"
    end

    test "updates the expiration", %{token: token} do
      expires_at = DateTime.add(DateTime.utc_now(), 30, :day)

      assert {:ok, updated_token} =
               ApiTokens.update_api_token(token, %{expires_at: expires_at})

      assert updated_token.expires_at == DateTime.truncate(expires_at, :second)
    end

    test "returns error with invalid name", %{token: token} do
      assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: ""})
      assert "can't be blank" in errors_on(changeset).name
    end

    test "returns error with too long name", %{token: token} do
      long_name = String.duplicate("a", 256)
      assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: long_name})
      assert "should be at most 255 character(s)" in errors_on(changeset).name
    end
  end

  describe "property-based tests" do
    property "generated tokens always start with towerops_" do
      check all(_ <- constant(nil), max_runs: 50) do
        user = user_fixture()
        organization = organization_fixture(user.id)

        {:ok, {_token, raw_token}} =
          ApiTokens.create_api_token(%{
            organization_id: organization.id,
            user_id: user.id,
            name: "Test Token"
          })

        assert String.starts_with?(raw_token, "towerops_")
        # Token should be sufficiently long (prefix + base64 encoded 32 bytes)
        assert String.length(raw_token) > 40
      end
    end

    property "tokens with valid names can always be created" do
      check all(name <- string(:alphanumeric, min_length: 1, max_length: 255), max_runs: 10) do
        user = user_fixture()
        organization = organization_fixture(user.id)

        result =
          ApiTokens.create_api_token(%{
            organization_id: organization.id,
            user_id: user.id,
            name: name
          })

        case result do
          {:ok, {token, raw_token}} ->
            assert token.name == name
            assert String.starts_with?(raw_token, "towerops_")

          {:error, _changeset} ->
            flunk("Valid name '#{name}' was rejected")
        end
      end
    end

    property "verify_token roundtrip always works for non-expired tokens" do
      check all(_ <- constant(nil), max_runs: 20) do
        user = user_fixture()
        organization = organization_fixture(user.id)

        {:ok, {_token, raw_token}} =
          ApiTokens.create_api_token(%{
            organization_id: organization.id,
            user_id: user.id,
            name: "Test Token"
          })

        # Verify the token we just created
        assert {:ok, org_id, returned_user} = ApiTokens.verify_token(raw_token)
        assert org_id == organization.id
        assert returned_user.id == user.id
      end
    end

    property "expired tokens always return error" do
      # Create fixtures once outside the property check
      user = user_fixture()
      organization = organization_fixture(user.id)

      check all(days_ago <- integer(1..365), max_runs: 20) do
        expires_at = DateTime.add(DateTime.utc_now(), -days_ago, :day)

        {:ok, {_token, raw_token}} =
          ApiTokens.create_api_token(%{
            organization_id: organization.id,
            user_id: user.id,
            name: "Expired Token",
            expires_at: expires_at
          })

        assert {:error, :invalid_token} = ApiTokens.verify_token(raw_token)
      end
    end

    property "list operations return consistent counts" do
      check all(token_count <- integer(0..5), max_runs: 10) do
        user = user_fixture()
        organization = organization_fixture(user.id)

        # Create specified number of tokens
        if token_count > 0 do
          for i <- 1..token_count do
            ApiTokens.create_api_token(%{
              organization_id: organization.id,
              user_id: user.id,
              name: "Token #{i}"
            })
          end
        end

        org_tokens = ApiTokens.list_organization_api_tokens(organization.id)
        user_tokens = ApiTokens.list_user_api_tokens(user.id)

        assert length(org_tokens) == token_count
        assert length(user_tokens) == token_count
        assert length(org_tokens) == length(user_tokens)
      end
    end

    property "deleting tokens reduces count by one" do
      check all(_ <- constant(nil), max_runs: 20) do
        user = user_fixture()
        organization = organization_fixture(user.id)

        # Create two tokens
        {:ok, {token1, _}} =
          ApiTokens.create_api_token(%{
            organization_id: organization.id,
            user_id: user.id,
            name: "Token 1"
          })

        {:ok, {_token2, _}} =
          ApiTokens.create_api_token(%{
            organization_id: organization.id,
            user_id: user.id,
            name: "Token 2"
          })

        initial_count = length(ApiTokens.list_organization_api_tokens(organization.id))
        assert initial_count == 2

        # Delete one token
        {:ok, _} = ApiTokens.delete_api_token(token1)

        final_count = length(ApiTokens.list_organization_api_tokens(organization.id))
        assert final_count == initial_count - 1
      end
    end

    property "updating token name preserves token hash" do
      check all(
              original_name <- string(:alphanumeric, min_length: 1, max_length: 50),
              new_name <- string(:alphanumeric, min_length: 1, max_length: 50),
              max_runs: 10
            ) do
        user = user_fixture()
        organization = organization_fixture(user.id)

        {:ok, {token, raw_token}} =
          ApiTokens.create_api_token(%{
            organization_id: organization.id,
            user_id: user.id,
            name: original_name
          })

        original_hash = token.token_hash

        # Update the name
        {:ok, updated_token} = ApiTokens.update_api_token(token, %{name: new_name})

        # Token hash should remain the same (name changes don't affect token)
        assert updated_token.token_hash == original_hash
        assert updated_token.name == new_name

        # Original raw token should still work
        assert {:ok, org_id, _user} = ApiTokens.verify_token(raw_token)
        assert org_id == organization.id
      end
    end
  end
end
