package auth

import (
	"net/http"
	"testing"
)

// The hop arithmetic is the whole feature, so the table is written as
// deployment topologies rather than abstract inputs.
func TestClientIP(t *testing.T) {
	tests := []struct {
		name       string
		hops       int
		remoteAddr string
		forwarded  string
		realIP     string
		want       string
	}{
		{
			name:       "no proxy configured, socket peer wins",
			hops:       0,
			remoteAddr: "203.0.113.5:51234",
			want:       "203.0.113.5",
		},
		{
			// hops 0 is the setting for a directly-exposed instance, and it
			// must make forged headers inert.
			name:       "hops 0 ignores a forged forwarded header",
			hops:       0,
			remoteAddr: "203.0.113.5:51234",
			forwarded:  "198.51.100.99",
			want:       "203.0.113.5",
		},
		{
			// The common case: one TLS-terminating proxy. Note RemoteAddr is
			// PUBLIC here — a proxy on its own host — which the previous
			// private-range heuristic got wrong.
			name:       "one proxy on a public address yields the client",
			hops:       1,
			remoteAddr: "203.0.113.200:40000",
			forwarded:  "198.51.100.7",
			want:       "198.51.100.7",
		},
		{
			name:       "one proxy on a private address yields the client",
			hops:       1,
			remoteAddr: "172.18.0.1:40000",
			forwarded:  "198.51.100.7",
			want:       "198.51.100.7",
		},
		{
			// client -> Cloudflare -> own proxy -> app.
			// Trusting only our own proxy, the honest answer is Cloudflare:
			// that's the address our proxy actually observed.
			name:       "cdn chain with hops 1 stops at the cdn",
			hops:       1,
			remoteAddr: "172.18.0.1:40000",
			forwarded:  "198.51.100.7, 203.0.113.50",
			want:       "203.0.113.50",
		},
		{
			// Same chain, both hops trusted — now we reach the real client.
			name:       "cdn chain with hops 2 reaches the client",
			hops:       2,
			remoteAddr: "172.18.0.1:40000",
			forwarded:  "198.51.100.7, 203.0.113.50",
			want:       "198.51.100.7",
		},
		{
			// A client prepending a lie is only reachable if the operator
			// over-counts their proxies; at the correct depth it's skipped.
			name:       "forged prefix is not reached at the correct depth",
			hops:       1,
			remoteAddr: "172.18.0.1:40000",
			forwarded:  "1.2.3.4, 198.51.100.7",
			want:       "198.51.100.7",
		},
		{
			// The documented mis-set failure, pinned so it stays a KNOWN
			// consequence rather than a surprise: depth deeper than the real
			// chain reads attacker-supplied input.
			name:       "hops set deeper than the chain clamps to the leftmost entry",
			hops:       5,
			remoteAddr: "172.18.0.1:40000",
			forwarded:  "1.2.3.4, 198.51.100.7",
			want:       "1.2.3.4",
		},
		{
			name:       "no forwarding header falls back to the socket peer",
			hops:       1,
			remoteAddr: "203.0.113.5:51234",
			want:       "203.0.113.5",
		},
		{
			name:       "x-real-ip used when forwarded-for is absent",
			hops:       1,
			remoteAddr: "172.18.0.1:40000",
			realIP:     "198.51.100.7",
			want:       "198.51.100.7",
		},
		{
			name:       "forwarded-for wins over x-real-ip when both present",
			hops:       1,
			remoteAddr: "172.18.0.1:40000",
			forwarded:  "198.51.100.7",
			realIP:     "1.2.3.4",
			want:       "198.51.100.7",
		},
		{
			// Positions are preserved, so a garbage hop can be selected —
			// in which case we fall back rather than return nonsense.
			name:       "unparseable selected entry falls back to the socket peer",
			hops:       1,
			remoteAddr: "172.18.0.1:40000",
			forwarded:  "198.51.100.7, not-an-ip",
			want:       "172.18.0.1",
		},
		{
			name:       "ipv6 client through one proxy",
			hops:       1,
			remoteAddr: "[fd00::1]:40000",
			forwarded:  "2001:db8::5",
			want:       "2001:db8::5",
		},
		{
			name:       "ipv6 socket peer without proxy",
			hops:       0,
			remoteAddr: "[2001:db8::1]:51234",
			want:       "2001:db8::1",
		},
		{
			name:       "remote addr without a port is tolerated",
			hops:       0,
			remoteAddr: "203.0.113.5",
			want:       "203.0.113.5",
		},
		{
			name:       "empty remote addr yields empty",
			hops:       1,
			remoteAddr: "",
			want:       "",
		},
		{
			name:       "whitespace-only forwarded header is treated as absent",
			hops:       1,
			remoteAddr: "172.18.0.1:40000",
			forwarded:  "   ",
			want:       "172.18.0.1",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			r, err := http.NewRequest(http.MethodGet, "/api/me/sessions", nil)
			if err != nil {
				t.Fatalf("NewRequest: %v", err)
			}
			r.RemoteAddr = tc.remoteAddr
			if tc.forwarded != "" {
				r.Header.Set("X-Forwarded-For", tc.forwarded)
			}
			if tc.realIP != "" {
				r.Header.Set("X-Real-IP", tc.realIP)
			}
			if got := ClientIP(r, tc.hops); got != tc.want {
				t.Errorf("ClientIP(hops=%d) = %q, want %q", tc.hops, got, tc.want)
			}
		})
	}
}
