defmodule TarakanWeb.Plugs.ClientIp do
  @moduledoc """
  Rewrites `conn.remote_ip` from a trusted reverse proxy's forwarded headers.

  Only applies when:

  1. `:trusted_proxies` is configured (CIDR strings or IPs), and
  2. the direct TCP peer is inside that set.

  Without trusted proxies configured, the peer address is left untouched so
  clients cannot spoof `X-Forwarded-For` against a publicly exposed app.
  """

  @behaviour Plug

  @impl true
  def init(opts), do: opts

  @impl true
  def call(conn, _opts) do
    case trusted_proxies() do
      [] ->
        conn

      proxies ->
        if trusted_ip?(conn.remote_ip, proxies) do
          case client_ip_from_headers(forwarded_values(conn), proxies) do
            ip when is_tuple(ip) -> %{conn | remote_ip: ip}
            nil -> conn
          end
        else
          conn
        end
    end
  end

  @doc """
  Resolves the per-IP rate-limit bucket key for a Plug.Conn.

  IPv4 clients bucket by their full address. IPv6 clients bucket by their
  /64 prefix so rotating through the lower 64 bits of a delegated prefix
  cannot mint unlimited fresh rate-limit buckets.
  """
  def remote_ip_bucket(%Plug.Conn{} = conn) do
    case normalize_ip(conn.remote_ip) do
      {a, b, c, d} -> {:v4, a, b, c, d}
      {a, b, c, d, _e, _f, _g, _h} -> {:v6, a, b, c, d}
    end
  rescue
    _error -> :unavailable
  end

  @doc "Resolves a client IP string for rate limiting from a Plug.Conn."
  def remote_ip_string(%Plug.Conn{} = conn) do
    conn.remote_ip
    |> normalize_ip()
    |> :inet.ntoa()
    |> to_string()
  rescue
    _error -> "unavailable"
  end

  @doc false
  def trusted_ip?(ip, proxies) when is_tuple(ip) and is_list(proxies) do
    normalized = normalize_ip(ip)
    Enum.any?(proxies, &ip_in_proxy?(normalized, &1))
  end

  def trusted_ip?(_ip, _proxies), do: false

  @doc """
  Selects the real client IP from forwarded header values.

  `values` is the list of raw `X-Forwarded-For` header strings (each may itself
  be comma-separated). Proxies append the peer they received from, so the
  rightmost entry is closest to us; we walk from the right, skip every trusted
  proxy, and take the first untrusted hop. That address is the furthest one we
  can still attribute to a hop we trust - anything to its left is
  client-supplied and spoofable. Returns an IP tuple, or `nil` when nothing
  parses.
  """
  def client_ip_from_headers(values, proxies) when is_list(values) and is_list(proxies) do
    ips =
      values
      |> Enum.flat_map(&String.split(&1, ",", trim: true))
      |> Enum.map(&String.trim/1)
      |> Enum.reject(&(&1 == ""))
      |> Enum.map(&parse_ip/1)
      |> Enum.reject(&is_nil/1)

    case ips do
      [] ->
        nil

      _ ->
        rightmost_untrusted =
          ips
          |> Enum.reverse()
          |> Enum.drop_while(&trusted_ip?(&1, proxies))
          |> List.first()

        # When every hop is a trusted proxy, the leftmost entry is the best
        # available guess at the original client.
        normalize_ip(rightmost_untrusted || List.first(ips))
    end
  end

  defp forwarded_values(conn) do
    Enum.flat_map(forwarded_headers(), &Plug.Conn.get_req_header(conn, &1))
  end

  defp parse_ip(nil), do: nil

  defp parse_ip(value) do
    value = value |> String.trim_leading("[") |> String.trim_trailing("]")

    case :inet.parse_address(String.to_charlist(value)) do
      {:ok, ip} -> ip
      {:error, _reason} -> nil
    end
  end

  # IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) arrive as 8-tuples on a
  # dual-stack (`::`) listener; fold them to the plain IPv4 tuple so trusted
  # proxy CIDRs match and rate-limit keys stay stable across families.
  defp normalize_ip({0, 0, 0, 0, 0, 0xFFFF, g, h}) do
    {Bitwise.bsr(g, 8), Bitwise.band(g, 0xFF), Bitwise.bsr(h, 8), Bitwise.band(h, 0xFF)}
  end

  defp normalize_ip(ip), do: ip

  defp ip_in_proxy?(ip, %{} = proxy), do: cidr_contains?(proxy, ip)

  defp trusted_proxies do
    Application.get_env(:tarakan, :trusted_proxies, [])
  end

  defp forwarded_headers do
    Application.get_env(:tarakan, :remote_ip_headers, ["x-forwarded-for"])
  end

  # Minimal IPv4/IPv6 CIDR matcher. Proxies are pre-parsed at config load.
  defp cidr_contains?(%{family: family, net: net, mask: mask}, ip)
       when tuple_size(ip) == tuple_size(net) do
    ip_int = ip_to_integer(ip)
    net_int = ip_to_integer(net)
    host_bits = if family == :inet, do: 32, else: 128
    shift = host_bits - mask

    if shift >= 0 do
      Bitwise.bsr(ip_int, shift) == Bitwise.bsr(net_int, shift)
    else
      false
    end
  end

  defp cidr_contains?(_proxy, _ip), do: false

  defp ip_to_integer({a, b, c, d}),
    do:
      Bitwise.bor(
        Bitwise.bor(Bitwise.bsl(a, 24), Bitwise.bsl(b, 16)),
        Bitwise.bor(Bitwise.bsl(c, 8), d)
      )

  defp ip_to_integer({a, b, c, d, e, f, g, h}) do
    [a, b, c, d, e, f, g, h]
    |> Enum.reduce(0, fn part, acc -> Bitwise.bor(Bitwise.bsl(acc, 16), part) end)
  end
end
