defmodule AprsmeWeb.Plugs.RateLimiterTest do
  use AprsmeWeb.ConnCase, async: true

  alias AprsmeWeb.Plugs.RateLimiter

  # Use a unique key per test to avoid cross-test interference with the
  # process-wide ETS rate-limit table. Each test gets a string key that
  # hasn't been seen before.
  defp unique_key, do: "test-#{System.unique_integer([:positive])}"

  describe "init/1" do
    test "applies default options" do
      opts = RateLimiter.init([])
      assert opts[:scale] == 60_000
      assert opts[:limit] == 100
      assert opts[:key] == :ip
      assert opts[:error_message] == "Too many requests"
    end

    test "user options override defaults" do
      opts = RateLimiter.init(limit: 5, scale: 1_000, error_message: "nope")
      assert opts[:limit] == 5
      assert opts[:scale] == 1_000
      assert opts[:error_message] == "nope"
      # Unspecified options retain their defaults
      assert opts[:key] == :ip
    end
  end

  describe "call/2 with :ip key" do
    test "allows requests under the limit", %{conn: conn} do
      opts = RateLimiter.init(key: unique_key(), limit: 5, scale: 60_000)
      result = RateLimiter.call(conn, opts)
      refute result.halted
    end

    test "denies once the limit is exceeded", %{conn: conn} do
      opts = RateLimiter.init(key: unique_key(), limit: 2, scale: 60_000)

      # Two under-limit calls succeed
      assert refute_halted(RateLimiter.call(conn, opts))
      assert refute_halted(RateLimiter.call(conn, opts))

      # Third call exceeds the limit and halts with a 429
      result = RateLimiter.call(conn, opts)
      assert result.halted
      assert result.status == 429
      assert result.resp_body =~ "Too many requests"
    end

    test "uses the configured error_message in the body", %{conn: conn} do
      opts =
        RateLimiter.init(
          key: unique_key(),
          limit: 1,
          error_message: "Custom limit message"
        )

      RateLimiter.call(conn, opts)
      result = RateLimiter.call(conn, opts)
      assert result.halted
      body = Jason.decode!(result.resp_body)
      assert body["error"] == "Custom limit message"
    end
  end

  describe "call/2 with Cloudflare/X-Forwarded-For/X-Real-IP headers" do
    test "honors CF-Connecting-IP header", %{conn: conn} do
      key_name = unique_key()
      conn = Plug.Conn.put_req_header(conn, "cf-connecting-ip", "203.0.113.5")
      opts = RateLimiter.init(limit: 1, key: :ip, error_message: "m")
      # Prefix the key with a known distinct IP to avoid cross-test interference
      _ = run_once(conn, opts)
      result = RateLimiter.call(conn, opts)
      assert result.halted
      # Second unique-IP request should be allowed
      other = Plug.Conn.put_req_header(conn, "cf-connecting-ip", "203.0.113.99-#{key_name}")
      result2 = RateLimiter.call(other, opts)
      refute result2.halted
    end

    test "splits X-Forwarded-For on comma", %{conn: conn} do
      conn = Plug.Conn.put_req_header(conn, "x-forwarded-for", "198.51.100.1, 10.0.0.1")
      opts = RateLimiter.init(limit: 1, key: :ip)
      run_once(conn, opts)
      result = RateLimiter.call(conn, opts)
      assert result.halted
    end

    test "honors X-Real-IP when other headers absent", %{conn: conn} do
      conn = Plug.Conn.put_req_header(conn, "x-real-ip", "192.0.2.1")
      opts = RateLimiter.init(limit: 1, key: :ip)
      run_once(conn, opts)
      result = RateLimiter.call(conn, opts)
      assert result.halted
    end

    test "falls back to remote_ip when no headers present", %{conn: conn} do
      # Bare build_conn has {127, 0, 0, 1} remote_ip; just make sure call doesn't crash.
      opts = RateLimiter.init(limit: 100, key: :ip)
      refute_halted(RateLimiter.call(conn, opts))
    end
  end

  describe "call/2 with :user_agent key" do
    test "uses the user-agent header as the key", %{conn: conn} do
      opts = RateLimiter.init(limit: 1, key: :user_agent)
      conn = Plug.Conn.put_req_header(conn, "user-agent", "TestBot/#{unique_key()}")
      run_once(conn, opts)
      result = RateLimiter.call(conn, opts)
      assert result.halted
    end

    test "uses 'unknown' key when no user-agent header", %{conn: _conn} do
      # Run this test with a fresh conn that has no user-agent. Only make sure
      # the code path doesn't crash and eventually halts after hitting the limit.
      conn = Phoenix.ConnTest.build_conn()
      opts = RateLimiter.init(limit: 1_000_000, key: :user_agent)
      refute_halted(RateLimiter.call(conn, opts))
    end
  end

  describe "call/2 with function key" do
    test "invokes the function to derive the key", %{conn: conn} do
      unique = unique_key()
      fun = fn _conn -> unique end
      opts = RateLimiter.init(limit: 1, key: fun)
      run_once(conn, opts)
      result = RateLimiter.call(conn, opts)
      assert result.halted
    end
  end

  describe "call/2 with binary key" do
    test "uses the string directly as the key", %{conn: conn} do
      opts = RateLimiter.init(limit: 1, key: unique_key())
      run_once(conn, opts)
      result = RateLimiter.call(conn, opts)
      assert result.halted
    end
  end

  describe "call/2 with custom prefix option" do
    test "uses custom prefix in the ETS key to namespace limits", %{conn: conn} do
      unique = unique_key()
      # Under default prefix, requests are allowed
      opts_default = RateLimiter.init(key: unique, limit: 1, scale: 60_000)
      _ = run_once(conn, opts_default)
      result = RateLimiter.call(conn, opts_default)
      assert result.halted

      # Under a different prefix, same key should be allowed again (separate bucket)
      opts_custom = RateLimiter.init(key: unique, limit: 1, scale: 60_000, prefix: "custom_ns")
      refute_halted(RateLimiter.call(conn, opts_custom))
      result2 = RateLimiter.call(conn, opts_custom)
      assert result2.halted
    end
  end

  describe "auth-style rate limiting (simulates :auth pipeline)" do
    test "enforces stricter limits with auth prefix", %{conn: conn} do
      opts = RateLimiter.init(limit: 3, scale: 60_000, prefix: "auth_rate_limit", key: unique_key())

      Enum.each(1..3, fn _ -> refute_halted(RateLimiter.call(conn, opts)) end)
      # 4th request is denied
      result = RateLimiter.call(conn, opts)
      assert result.halted
      assert result.status == 429
    end

    test "auth prefix is isolated from default browser prefix", %{conn: conn} do
      unique = unique_key()
      # Exhaust browser limit (200/min default limit almost)
      _browser_opts = RateLimiter.init(limit: 1, key: unique)
      auth_opts = RateLimiter.init(limit: 3, scale: 60_000, key: unique, prefix: "auth_rate_limit")

      # Auth requests succeed even if browser is separate
      Enum.each(1..3, fn _ -> refute_halted(RateLimiter.call(conn, auth_opts)) end)
      result = RateLimiter.call(conn, auth_opts)
      assert result.halted
    end
  end

  # Helpers

  defp run_once(conn, opts) do
    result = RateLimiter.call(conn, opts)
    refute result.halted
    result
  end

  defp refute_halted(conn) do
    refute conn.halted
    conn
  end
end
