// Pass 4 — host HTTP allowlist + IP guard tests.
//
// Locks in the SSRF defenses added in Pass 2 (dot-bounded host suffix
// matching, empty-entry rejection) and Pass 3 (RFC6598 CGN rejection).
package plugin

import (
	"context"
	"errors"
	"net"
	"testing"
)

func newTestRegistry(allowlist []string) *Registry {
	return &Registry{cfg: Config{HTTPAllowlist: allowlist}}
}

func TestHostAllowedDotBoundary(t *testing.T) {
	r := newTestRegistry([]string{"api.example.com", "example.org"})
	cases := []struct {
		host string
		ok   bool
	}{
		{"api.example.com", true},
		{"v1.api.example.com", true},
		{"example.org", true},
		{"sub.example.org", true},
		// Sibling-domain attack — must NOT match.
		{"evil-api.example.com", false},
		{"notexample.com", false},
		{"example.com", false}, // not in list
		{"evil.com", false},
		{"", false},
		{"api.example.com.evil.com", false},
	}
	for _, c := range cases {
		got := r.hostAllowed(c.host)
		if got != c.ok {
			t.Errorf("hostAllowed(%q) = %v, want %v", c.host, got, c.ok)
		}
	}
}

func TestHostAllowedEmptyEntryRejected(t *testing.T) {
	r := newTestRegistry([]string{""})
	if r.hostAllowed("anything.com") {
		t.Fatal("empty allowlist entry must NOT wildcard-match")
	}
	if r.hostAllowed("") {
		t.Fatal("empty host must not match empty entry")
	}
}

func TestHostAllowedCaseInsensitive(t *testing.T) {
	r := newTestRegistry([]string{"API.Example.COM"})
	if !r.hostAllowed("api.example.com") {
		t.Fatal("hostAllowed should be case-insensitive")
	}
	if !r.hostAllowed("API.example.com") {
		t.Fatal("hostAllowed should be case-insensitive")
	}
}

func TestHostAllowedTrailingDot(t *testing.T) {
	r := newTestRegistry([]string{"api.example.com"})
	if !r.hostAllowed("api.example.com.") {
		t.Fatal("FQDN trailing dot should match")
	}
}

func TestIPAllowedRejectsAllRanges(t *testing.T) {
	cases := []string{
		"127.0.0.1",       // loopback
		"127.5.6.7",       // loopback range
		"10.0.0.1",        // RFC1918
		"172.16.5.5",      // RFC1918
		"172.31.255.255",  // RFC1918 high
		"192.168.1.1",     // RFC1918
		"169.254.169.254", // AWS metadata / link-local
		"100.64.5.5",      // RFC6598 CGN
		"100.127.255.255", // RFC6598 CGN high
		"::1",             // IPv6 loopback
		"fc00::1",         // RFC4193 ULA
		"fe80::1",         // IPv6 link-local
		"0.0.0.0",         // unspecified
		"::",              // IPv6 unspecified
		"224.0.0.1",       // multicast
		"ff00::1",         // IPv6 multicast
	}
	for _, addr := range cases {
		ip := net.ParseIP(addr)
		if ip == nil {
			t.Fatalf("ParseIP(%q) failed", addr)
		}
		if err := ipAllowed(ip); err == nil {
			t.Errorf("ipAllowed(%s) should have returned error", addr)
		}
	}
}

func TestIPAllowedAcceptsPublic(t *testing.T) {
	cases := []string{
		"8.8.8.8",
		"1.1.1.1",
		"203.0.113.5", // RFC5737 documentation but not in any reject set
		"2606:4700:4700::1111",
	}
	for _, addr := range cases {
		ip := net.ParseIP(addr)
		if ip == nil {
			t.Fatalf("ParseIP(%q) failed", addr)
		}
		if err := ipAllowed(ip); err != nil {
			t.Errorf("ipAllowed(%s) should have been allowed, got %v", addr, err)
		}
	}
}

// TestGuardedDial_FallsBackAcrossVettedIPs locks the W2-6 fix: an allowlisted
// dual-stack/round-robin host whose first record is unreachable must connect
// via the next vetted record instead of hard-failing.
func TestGuardedDial_FallsBackAcrossVettedIPs(t *testing.T) {
	origLookup, origDial := lookupIPAddr, dialContext
	t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial })

	lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) {
		return []net.IPAddr{
			{IP: net.ParseIP("192.0.2.1")}, // TEST-NET, "down"
			{IP: net.ParseIP("192.0.2.2")}, // "reachable"
		}, nil
	}
	var attempts []string
	c1, c2 := net.Pipe()
	t.Cleanup(func() { _ = c1.Close(); _ = c2.Close() })
	dialContext = func(_ context.Context, _ string, addr string) (net.Conn, error) {
		attempts = append(attempts, addr)
		if addr == "192.0.2.1:443" {
			return nil, errors.New("connection refused")
		}
		return c1, nil
	}

	conn, err := GuardedDialContext()(context.Background(), "tcp", "api.example.com:443")
	if err != nil {
		t.Fatalf("guarded dial should fall back to the next vetted IP: %v", err)
	}
	if conn != c1 {
		t.Fatal("expected the fallback connection")
	}
	want := []string{"192.0.2.1:443", "192.0.2.2:443"}
	if len(attempts) != 2 || attempts[0] != want[0] || attempts[1] != want[1] {
		t.Fatalf("dial attempts = %v, want %v", attempts, want)
	}
}

// TestGuardedDial_PrivateRecordRefusesBeforeAnyDial: one private record among
// the resolved set refuses the whole request before a single dial happens.
func TestGuardedDial_PrivateRecordRefusesBeforeAnyDial(t *testing.T) {
	origLookup, origDial := lookupIPAddr, dialContext
	t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial })

	lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) {
		return []net.IPAddr{
			{IP: net.ParseIP("192.0.2.1")},
			{IP: net.ParseIP("10.0.0.5")}, // poisoned private record
		}, nil
	}
	dialed := false
	dialContext = func(_ context.Context, _ string, _ string) (net.Conn, error) {
		dialed = true
		return nil, errors.New("must not be reached")
	}

	_, err := GuardedDialContext()(context.Background(), "tcp", "api.example.com:443")
	if !errors.Is(err, ErrHTTPHostDenied) {
		t.Fatalf("want ErrHTTPHostDenied, got %v", err)
	}
	if dialed {
		t.Fatal("no dial may happen when any resolved record is private")
	}
}

func TestIPAllowedNilRejected(t *testing.T) {
	if err := ipAllowed(nil); err == nil {
		t.Fatal("nil IP should be rejected")
	}
}
