package service

import (
	"errors"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
	"github.com/netbirdio/netbird/shared/hash/argon2id"
	"github.com/netbirdio/netbird/shared/management/http/api"
	"github.com/netbirdio/netbird/shared/management/proto"
)

func validProxy() *Service {
	return &Service{
		Name:   "test",
		Domain: "example.com",
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true},
		},
	}
}

func TestValidate_Valid(t *testing.T) {
	require.NoError(t, validProxy().Validate())
}

func TestValidate_EmptyName(t *testing.T) {
	rp := validProxy()
	rp.Name = ""
	assert.ErrorContains(t, rp.Validate(), "name is required")
}

func TestValidate_EmptyDomain(t *testing.T) {
	rp := validProxy()
	rp.Domain = ""
	assert.ErrorContains(t, rp.Validate(), "domain is required")
}

func TestValidate_NoTargets(t *testing.T) {
	rp := validProxy()
	rp.Targets = nil
	assert.ErrorContains(t, rp.Validate(), "at least one target is required")
}

func TestValidate_EmptyTargetId(t *testing.T) {
	rp := validProxy()
	rp.Targets[0].TargetId = ""
	assert.ErrorContains(t, rp.Validate(), "empty target_id")
}

func TestValidate_InvalidTargetType(t *testing.T) {
	rp := validProxy()
	rp.Targets[0].TargetType = "invalid"
	assert.ErrorContains(t, rp.Validate(), "invalid target_type")
}

func TestValidate_ResourceTarget(t *testing.T) {
	rp := validProxy()
	rp.Targets = append(rp.Targets, &Target{
		TargetId:   "resource-1",
		TargetType: TargetTypeHost,
		Host:       "example.org",
		Port:       443,
		Protocol:   "https",
		Enabled:    true,
	})
	require.NoError(t, rp.Validate())
}

func TestValidate_MultipleTargetsOneInvalid(t *testing.T) {
	rp := validProxy()
	rp.Targets = append(rp.Targets, &Target{
		TargetId:   "",
		TargetType: TargetTypePeer,
		Host:       "10.0.0.2",
		Port:       80,
		Protocol:   "http",
		Enabled:    true,
	})
	err := rp.Validate()
	require.Error(t, err)
	assert.Contains(t, err.Error(), "target 1")
	assert.Contains(t, err.Error(), "empty target_id")
}

func TestValidateTargetOptions_PathRewrite(t *testing.T) {
	tests := []struct {
		name    string
		mode    PathRewriteMode
		wantErr string
	}{
		{"empty is default", "", ""},
		{"preserve is valid", PathRewritePreserve, ""},
		{"unknown rejected", "regex", "unknown path_rewrite mode"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			rp := validProxy()
			rp.Targets[0].Options.PathRewrite = tt.mode
			err := rp.Validate()
			if tt.wantErr == "" {
				assert.NoError(t, err)
			} else {
				assert.ErrorContains(t, err, tt.wantErr)
			}
		})
	}
}

func TestValidateTargetOptions_RequestTimeout(t *testing.T) {
	tests := []struct {
		name    string
		timeout time.Duration
		wantErr string
	}{
		{"valid 30s", 30 * time.Second, ""},
		{"valid 2m", 2 * time.Minute, ""},
		{"valid 10m", 10 * time.Minute, ""},
		{"zero is fine", 0, ""},
		{"negative", -1 * time.Second, "must be positive"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			rp := validProxy()
			rp.Targets[0].Options.RequestTimeout = tt.timeout
			err := rp.Validate()
			if tt.wantErr == "" {
				assert.NoError(t, err)
			} else {
				assert.ErrorContains(t, err, tt.wantErr)
			}
		})
	}
}

func TestValidateTargetOptions_CustomHeaders(t *testing.T) {
	t.Run("valid headers", func(t *testing.T) {
		rp := validProxy()
		rp.Targets[0].Options.CustomHeaders = map[string]string{
			"X-Custom": "value",
			"X-Trace":  "abc123",
		}
		assert.NoError(t, rp.Validate())
	})

	t.Run("CRLF in key", func(t *testing.T) {
		rp := validProxy()
		rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Bad\r\nKey": "value"}
		assert.ErrorContains(t, rp.Validate(), "not a valid HTTP header name")
	})

	t.Run("CRLF in value", func(t *testing.T) {
		rp := validProxy()
		rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Good": "bad\nvalue"}
		assert.ErrorContains(t, rp.Validate(), "invalid characters")
	})

	t.Run("hop-by-hop header rejected", func(t *testing.T) {
		for _, h := range []string{"Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection"} {
			rp := validProxy()
			rp.Targets[0].Options.CustomHeaders = map[string]string{h: "value"}
			assert.ErrorContains(t, rp.Validate(), "hop-by-hop", "header %q should be rejected", h)
		}
	})

	t.Run("reserved header rejected", func(t *testing.T) {
		for _, h := range []string{"X-Forwarded-For", "X-Real-IP", "X-Forwarded-Proto", "X-Forwarded-Host", "X-Forwarded-Port", "Cookie", "Forwarded", "Content-Length", "Content-Type"} {
			rp := validProxy()
			rp.Targets[0].Options.CustomHeaders = map[string]string{h: "value"}
			assert.ErrorContains(t, rp.Validate(), "managed by the proxy", "header %q should be rejected", h)
		}
	})

	t.Run("Host header rejected", func(t *testing.T) {
		rp := validProxy()
		rp.Targets[0].Options.CustomHeaders = map[string]string{"Host": "evil.com"}
		assert.ErrorContains(t, rp.Validate(), "pass_host_header")
	})

	t.Run("too many headers", func(t *testing.T) {
		rp := validProxy()
		headers := make(map[string]string, 17)
		for i := range 17 {
			headers[fmt.Sprintf("X-H%d", i)] = "v"
		}
		rp.Targets[0].Options.CustomHeaders = headers
		assert.ErrorContains(t, rp.Validate(), "exceeds maximum of 16")
	})

	t.Run("key too long", func(t *testing.T) {
		rp := validProxy()
		rp.Targets[0].Options.CustomHeaders = map[string]string{strings.Repeat("X", 129): "v"}
		assert.ErrorContains(t, rp.Validate(), "key")
		assert.ErrorContains(t, rp.Validate(), "exceeds maximum length")
	})

	t.Run("value too long", func(t *testing.T) {
		rp := validProxy()
		rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Ok": strings.Repeat("v", 4097)}
		assert.ErrorContains(t, rp.Validate(), "value exceeds maximum length")
	})

	t.Run("duplicate canonical keys rejected", func(t *testing.T) {
		rp := validProxy()
		rp.Targets[0].Options.CustomHeaders = map[string]string{
			"x-custom": "a",
			"X-Custom": "b",
		}
		assert.ErrorContains(t, rp.Validate(), "collide")
	})
}

func TestToProtoMapping_TargetOptions(t *testing.T) {
	rp := &Service{
		ID:        "svc-1",
		AccountID: "acc-1",
		Domain:    "example.com",
		Targets: []*Target{
			{
				TargetId:   "peer-1",
				TargetType: TargetTypePeer,
				Host:       "10.0.0.1",
				Port:       8080,
				Protocol:   "http",
				Enabled:    true,
				Options: TargetOptions{
					SkipTLSVerify:  true,
					RequestTimeout: 30 * time.Second,
					PathRewrite:    PathRewritePreserve,
					CustomHeaders:  map[string]string{"X-Custom": "val"},
				},
			},
		},
	}
	pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
	require.Len(t, pm.Path, 1)

	opts := pm.Path[0].Options
	require.NotNil(t, opts, "options should be populated")
	assert.True(t, opts.SkipTlsVerify)
	assert.Equal(t, proto.PathRewriteMode_PATH_REWRITE_PRESERVE, opts.PathRewrite)
	assert.Equal(t, map[string]string{"X-Custom": "val"}, opts.CustomHeaders)
	require.NotNil(t, opts.RequestTimeout)
	assert.Equal(t, int64(30), opts.RequestTimeout.Seconds)
}

func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) {
	rp := &Service{
		ID:        "svc-1",
		AccountID: "acc-1",
		Domain:    "example.com",
		Targets: []*Target{
			{
				TargetId:   "peer-1",
				TargetType: TargetTypePeer,
				Host:       "10.0.0.1",
				Port:       8080,
				Protocol:   "http",
				Enabled:    true,
			},
		},
	}
	pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
	require.Len(t, pm.Path, 1)
	assert.Nil(t, pm.Path[0].Options, "options should be nil when all defaults")
}

func TestIsDefaultPort(t *testing.T) {
	tests := []struct {
		scheme string
		port   uint16
		want   bool
	}{
		{"http", 80, true},
		{"https", 443, true},
		{"http", 443, false},
		{"https", 80, false},
		{"http", 8080, false},
		{"https", 8443, false},
		{"http", 0, false},
		{"https", 0, false},
	}
	for _, tt := range tests {
		t.Run(fmt.Sprintf("%s/%d", tt.scheme, tt.port), func(t *testing.T) {
			assert.Equal(t, tt.want, isDefaultPort(tt.scheme, tt.port))
		})
	}
}

func TestToProtoMapping_PortInTargetURL(t *testing.T) {
	oidcConfig := proxy.OIDCValidationConfig{}

	tests := []struct {
		name       string
		protocol   string
		host       string
		port       uint16
		wantTarget string
	}{
		{
			name:       "http with default port 80 omits port",
			protocol:   "http",
			host:       "10.0.0.1",
			port:       80,
			wantTarget: "http://10.0.0.1/",
		},
		{
			name:       "https with default port 443 omits port",
			protocol:   "https",
			host:       "10.0.0.1",
			port:       443,
			wantTarget: "https://10.0.0.1/",
		},
		{
			name:       "port 0 omits port",
			protocol:   "http",
			host:       "10.0.0.1",
			port:       0,
			wantTarget: "http://10.0.0.1/",
		},
		{
			name:       "non-default port is included",
			protocol:   "http",
			host:       "10.0.0.1",
			port:       8080,
			wantTarget: "http://10.0.0.1:8080/",
		},
		{
			name:       "https with non-default port is included",
			protocol:   "https",
			host:       "10.0.0.1",
			port:       8443,
			wantTarget: "https://10.0.0.1:8443/",
		},
		{
			name:       "http port 443 is included",
			protocol:   "http",
			host:       "10.0.0.1",
			port:       443,
			wantTarget: "http://10.0.0.1:443/",
		},
		{
			name:       "https port 80 is included",
			protocol:   "https",
			host:       "10.0.0.1",
			port:       80,
			wantTarget: "https://10.0.0.1:80/",
		},
		{
			name:       "domain host without port is unchanged",
			protocol:   "http",
			host:       "example.com",
			port:       0,
			wantTarget: "http://example.com/",
		},
		{
			name:       "domain host with non-default port is unchanged",
			protocol:   "http",
			host:       "example.com",
			port:       8080,
			wantTarget: "http://example.com:8080/",
		},
		{
			name:       "ipv6 host without port is bracketed",
			protocol:   "http",
			host:       "fb00:cafe:1::3",
			port:       0,
			wantTarget: "http://[fb00:cafe:1::3]/",
		},
		{
			name:       "ipv6 host with default port omits port and brackets host",
			protocol:   "http",
			host:       "fb00:cafe:1::3",
			port:       80,
			wantTarget: "http://[fb00:cafe:1::3]/",
		},
		{
			name:       "ipv6 host with non-default port is bracketed",
			protocol:   "http",
			host:       "fb00:cafe:1::3",
			port:       8080,
			wantTarget: "http://[fb00:cafe:1::3]:8080/",
		},
		{
			name:       "ipv6 loopback without port is bracketed",
			protocol:   "http",
			host:       "::1",
			port:       0,
			wantTarget: "http://[::1]/",
		},
		{
			name:       "ipv6 host with 5-digit port is bracketed",
			protocol:   "http",
			host:       "fb00:cafe::1",
			port:       18080,
			wantTarget: "http://[fb00:cafe::1]:18080/",
		},
		{
			name:       "pre-bracketed ipv6 without port stays single-bracketed",
			protocol:   "http",
			host:       "[fb00:cafe::1]",
			port:       0,
			wantTarget: "http://[fb00:cafe::1]/",
		},
		{
			name:       "pre-bracketed ipv6 with port is not double-bracketed",
			protocol:   "http",
			host:       "[fb00:cafe::1]",
			port:       8080,
			wantTarget: "http://[fb00:cafe::1]:8080/",
		},
		{
			name:       "v4-mapped ipv6 host without port is bracketed",
			protocol:   "http",
			host:       "::ffff:10.0.0.1",
			port:       0,
			wantTarget: "http://[::ffff:10.0.0.1]/",
		},
		{
			name:       "full-form 8-group ipv6 without port is bracketed",
			protocol:   "http",
			host:       "fb00:cafe:1:0:0:0:0:3",
			port:       0,
			wantTarget: "http://[fb00:cafe:1:0:0:0:0:3]/",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			rp := &Service{
				ID:        "test-id",
				AccountID: "acc-1",
				Domain:    "example.com",
				Targets: []*Target{
					{
						TargetId:   "peer-1",
						TargetType: TargetTypePeer,
						Host:       tt.host,
						Port:       tt.port,
						Protocol:   tt.protocol,
						Enabled:    true,
					},
				},
			}
			pm := rp.ToProtoMapping(Create, "token", oidcConfig)
			require.Len(t, pm.Path, 1, "should have one path mapping")
			assert.Equal(t, tt.wantTarget, pm.Path[0].Target)
		})
	}
}

func TestToProtoMapping_DisabledTargetSkipped(t *testing.T) {
	rp := &Service{
		ID:        "test-id",
		AccountID: "acc-1",
		Domain:    "example.com",
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 8080, Protocol: "http", Enabled: false},
			{TargetId: "peer-2", TargetType: TargetTypePeer, Host: "10.0.0.2", Port: 9090, Protocol: "http", Enabled: true},
		},
	}
	pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
	require.Len(t, pm.Path, 1)
	assert.Equal(t, "http://10.0.0.2:9090/", pm.Path[0].Target)
}

func TestToProtoMapping_OperationTypes(t *testing.T) {
	rp := validProxy()
	tests := []struct {
		op   Operation
		want proto.ProxyMappingUpdateType
	}{
		{Create, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED},
		{Update, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED},
		{Delete, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED},
	}
	for _, tt := range tests {
		t.Run(string(tt.op), func(t *testing.T) {
			pm := rp.ToProtoMapping(tt.op, "", proxy.OIDCValidationConfig{})
			assert.Equal(t, tt.want, pm.Type)
		})
	}
}

func TestAuthConfig_HashSecrets(t *testing.T) {
	tests := []struct {
		name     string
		config   *AuthConfig
		wantErr  bool
		validate func(*testing.T, *AuthConfig)
	}{
		{
			name: "hash password successfully",
			config: &AuthConfig{
				PasswordAuth: &PasswordAuthConfig{
					Enabled:  true,
					Password: "testPassword123",
				},
			},
			wantErr: false,
			validate: func(t *testing.T, config *AuthConfig) {
				if !strings.HasPrefix(config.PasswordAuth.Password, "$argon2id$") {
					t.Errorf("Password not hashed with argon2id, got: %s", config.PasswordAuth.Password)
				}
				// Verify the hash can be verified
				if err := argon2id.Verify("testPassword123", config.PasswordAuth.Password); err != nil {
					t.Errorf("Hash verification failed: %v", err)
				}
			},
		},
		{
			name: "hash PIN successfully",
			config: &AuthConfig{
				PinAuth: &PINAuthConfig{
					Enabled: true,
					Pin:     "123456",
				},
			},
			wantErr: false,
			validate: func(t *testing.T, config *AuthConfig) {
				if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") {
					t.Errorf("PIN not hashed with argon2id, got: %s", config.PinAuth.Pin)
				}
				// Verify the hash can be verified
				if err := argon2id.Verify("123456", config.PinAuth.Pin); err != nil {
					t.Errorf("Hash verification failed: %v", err)
				}
			},
		},
		{
			name: "hash both password and PIN",
			config: &AuthConfig{
				PasswordAuth: &PasswordAuthConfig{
					Enabled:  true,
					Password: "password",
				},
				PinAuth: &PINAuthConfig{
					Enabled: true,
					Pin:     "9999",
				},
			},
			wantErr: false,
			validate: func(t *testing.T, config *AuthConfig) {
				if !strings.HasPrefix(config.PasswordAuth.Password, "$argon2id$") {
					t.Errorf("Password not hashed with argon2id")
				}
				if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") {
					t.Errorf("PIN not hashed with argon2id")
				}
				if err := argon2id.Verify("password", config.PasswordAuth.Password); err != nil {
					t.Errorf("Password hash verification failed: %v", err)
				}
				if err := argon2id.Verify("9999", config.PinAuth.Pin); err != nil {
					t.Errorf("PIN hash verification failed: %v", err)
				}
			},
		},
		{
			name: "skip disabled password auth",
			config: &AuthConfig{
				PasswordAuth: &PasswordAuthConfig{
					Enabled:  false,
					Password: "password",
				},
			},
			wantErr: false,
			validate: func(t *testing.T, config *AuthConfig) {
				if config.PasswordAuth.Password != "password" {
					t.Errorf("Disabled password auth should not be hashed")
				}
			},
		},
		{
			name: "skip empty password",
			config: &AuthConfig{
				PasswordAuth: &PasswordAuthConfig{
					Enabled:  true,
					Password: "",
				},
			},
			wantErr: false,
			validate: func(t *testing.T, config *AuthConfig) {
				if config.PasswordAuth.Password != "" {
					t.Errorf("Empty password should remain empty")
				}
			},
		},
		{
			name: "skip nil password auth",
			config: &AuthConfig{
				PasswordAuth: nil,
				PinAuth: &PINAuthConfig{
					Enabled: true,
					Pin:     "1234",
				},
			},
			wantErr: false,
			validate: func(t *testing.T, config *AuthConfig) {
				if config.PasswordAuth != nil {
					t.Errorf("PasswordAuth should remain nil")
				}
				if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") {
					t.Errorf("PIN should still be hashed")
				}
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := tt.config.HashSecrets()
			if (err != nil) != tt.wantErr {
				t.Errorf("HashSecrets() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if tt.validate != nil {
				tt.validate(t, tt.config)
			}
		})
	}
}

func TestAuthConfig_HashSecrets_VerifyIncorrectSecret(t *testing.T) {
	config := &AuthConfig{
		PasswordAuth: &PasswordAuthConfig{
			Enabled:  true,
			Password: "correctPassword",
		},
	}

	if err := config.HashSecrets(); err != nil {
		t.Fatalf("HashSecrets() error = %v", err)
	}

	// Verify with wrong password should fail
	err := argon2id.Verify("wrongPassword", config.PasswordAuth.Password)
	if !errors.Is(err, argon2id.ErrMismatchedHashAndPassword) {
		t.Errorf("Expected ErrMismatchedHashAndPassword, got %v", err)
	}
}

func TestAuthConfig_ClearSecrets(t *testing.T) {
	config := &AuthConfig{
		PasswordAuth: &PasswordAuthConfig{
			Enabled:  true,
			Password: "hashedPassword",
		},
		PinAuth: &PINAuthConfig{
			Enabled: true,
			Pin:     "hashedPin",
		},
	}

	config.ClearSecrets()

	if config.PasswordAuth.Password != "" {
		t.Errorf("Password not cleared, got: %s", config.PasswordAuth.Password)
	}
	if config.PinAuth.Pin != "" {
		t.Errorf("PIN not cleared, got: %s", config.PinAuth.Pin)
	}
}

func TestGenerateExposeName(t *testing.T) {
	t.Run("no prefix generates 12-char name", func(t *testing.T) {
		name, err := GenerateExposeName("")
		require.NoError(t, err)
		assert.Len(t, name, 12)
		assert.Regexp(t, `^[a-z0-9]+$`, name)
	})

	t.Run("with prefix generates prefix-XXXX", func(t *testing.T) {
		name, err := GenerateExposeName("myapp")
		require.NoError(t, err)
		assert.True(t, strings.HasPrefix(name, "myapp-"), "name should start with prefix")
		suffix := strings.TrimPrefix(name, "myapp-")
		assert.Len(t, suffix, 4, "suffix should be 4 chars")
		assert.Regexp(t, `^[a-z0-9]+$`, suffix)
	})

	t.Run("unique names", func(t *testing.T) {
		names := make(map[string]bool)
		for i := 0; i < 50; i++ {
			name, err := GenerateExposeName("")
			require.NoError(t, err)
			names[name] = true
		}
		assert.Greater(t, len(names), 45, "should generate mostly unique names")
	})

	t.Run("valid prefixes", func(t *testing.T) {
		validPrefixes := []string{"a", "ab", "a1", "my-app", "web-server-01", "a-b"}
		for _, prefix := range validPrefixes {
			name, err := GenerateExposeName(prefix)
			assert.NoError(t, err, "prefix %q should be valid", prefix)
			assert.True(t, strings.HasPrefix(name, prefix+"-"), "name should start with %q-", prefix)
		}
	})

	t.Run("invalid prefixes", func(t *testing.T) {
		invalidPrefixes := []string{
			"-starts-with-dash",
			"ends-with-dash-",
			"has.dots",
			"HAS-UPPER",
			"has spaces",
			"has/slash",
			"a--",
		}
		for _, prefix := range invalidPrefixes {
			_, err := GenerateExposeName(prefix)
			assert.Error(t, err, "prefix %q should be invalid", prefix)
			assert.Contains(t, err.Error(), "invalid name prefix")
		}
	})
}

func TestExposeServiceRequest_ToService(t *testing.T) {
	t.Run("basic HTTP service", func(t *testing.T) {
		req := &ExposeServiceRequest{
			Port: 8080,
			Mode: "http",
		}

		service := req.ToService("account-1", "peer-1", "mysvc")

		assert.Equal(t, "account-1", service.AccountID)
		assert.Equal(t, "mysvc", service.Name)
		assert.True(t, service.Enabled)
		assert.Empty(t, service.Domain, "domain should be empty when not specified")
		require.Len(t, service.Targets, 1)

		target := service.Targets[0]
		assert.Equal(t, uint16(8080), target.Port)
		assert.Equal(t, "http", target.Protocol)
		assert.Equal(t, "peer-1", target.TargetId)
		assert.Equal(t, TargetTypePeer, target.TargetType)
		assert.True(t, target.Enabled)
		assert.Equal(t, "account-1", target.AccountID)
	})

	t.Run("with custom domain", func(t *testing.T) {
		req := &ExposeServiceRequest{
			Port:   3000,
			Domain: "example.com",
		}

		service := req.ToService("acc", "peer", "web")
		assert.Equal(t, "web.example.com", service.Domain)
	})

	t.Run("with PIN auth", func(t *testing.T) {
		req := &ExposeServiceRequest{
			Port: 80,
			Pin:  "1234",
		}

		service := req.ToService("acc", "peer", "svc")
		require.NotNil(t, service.Auth.PinAuth)
		assert.True(t, service.Auth.PinAuth.Enabled)
		assert.Equal(t, "1234", service.Auth.PinAuth.Pin)
		assert.Nil(t, service.Auth.PasswordAuth)
		assert.Nil(t, service.Auth.BearerAuth)
	})

	t.Run("with password auth", func(t *testing.T) {
		req := &ExposeServiceRequest{
			Port:     80,
			Password: "secret",
		}

		service := req.ToService("acc", "peer", "svc")
		require.NotNil(t, service.Auth.PasswordAuth)
		assert.True(t, service.Auth.PasswordAuth.Enabled)
		assert.Equal(t, "secret", service.Auth.PasswordAuth.Password)
	})

	t.Run("with user groups (bearer auth)", func(t *testing.T) {
		req := &ExposeServiceRequest{
			Port:       80,
			UserGroups: []string{"admins", "devs"},
		}

		service := req.ToService("acc", "peer", "svc")
		require.NotNil(t, service.Auth.BearerAuth)
		assert.True(t, service.Auth.BearerAuth.Enabled)
		assert.Equal(t, []string{"admins", "devs"}, service.Auth.BearerAuth.DistributionGroups)
	})

	t.Run("with all auth types", func(t *testing.T) {
		req := &ExposeServiceRequest{
			Port:       443,
			Domain:     "myco.com",
			Pin:        "9999",
			Password:   "pass",
			UserGroups: []string{"ops"},
		}

		service := req.ToService("acc", "peer", "full")
		assert.Equal(t, "full.myco.com", service.Domain)
		require.NotNil(t, service.Auth.PinAuth)
		require.NotNil(t, service.Auth.PasswordAuth)
		require.NotNil(t, service.Auth.BearerAuth)
	})
}

func TestValidate_TLSOnly(t *testing.T) {
	rp := &Service{
		Name:       "tls-svc",
		Mode:       "tls",
		Domain:     "example.com",
		ListenPort: 8443,
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
		},
	}
	require.NoError(t, rp.Validate())
}

func TestValidate_TLSMissingListenPort(t *testing.T) {
	rp := &Service{
		Name:       "tls-svc",
		Mode:       "tls",
		Domain:     "example.com",
		ListenPort: 0,
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
		},
	}
	assert.ErrorContains(t, rp.Validate(), "listen_port is required")
}

func TestValidate_TLSMissingDomain(t *testing.T) {
	rp := &Service{
		Name:       "tls-svc",
		Mode:       "tls",
		ListenPort: 8443,
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true},
		},
	}
	assert.ErrorContains(t, rp.Validate(), "domain is required")
}

func TestValidate_TCPValid(t *testing.T) {
	rp := &Service{
		Name:       "tcp-svc",
		Mode:       "tcp",
		Domain:     "cluster.test",
		ListenPort: 5432,
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
		},
	}
	require.NoError(t, rp.Validate())
}

func TestValidate_TCPMissingListenPort(t *testing.T) {
	rp := &Service{
		Name:   "tcp-svc",
		Mode:   "tcp",
		Domain: "cluster.test",
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
		},
	}
	require.NoError(t, rp.Validate(), "TCP with listen_port=0 is valid (auto-assigned by manager)")
}

func TestValidate_L4MultipleTargets(t *testing.T) {
	rp := &Service{
		Name:       "tcp-svc",
		Mode:       "tcp",
		Domain:     "cluster.test",
		ListenPort: 5432,
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
			{TargetId: "peer-2", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true},
		},
	}
	assert.ErrorContains(t, rp.Validate(), "exactly one target")
}

func TestValidate_L4TargetMissingPort(t *testing.T) {
	rp := &Service{
		Name:       "tcp-svc",
		Mode:       "tcp",
		Domain:     "cluster.test",
		ListenPort: 5432,
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 0, Enabled: true},
		},
	}
	assert.ErrorContains(t, rp.Validate(), "port is required")
}

func TestValidate_TLSInvalidTargetType(t *testing.T) {
	rp := &Service{
		Name:       "tls-svc",
		Mode:       "tls",
		Domain:     "example.com",
		ListenPort: 443,
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: "invalid", Protocol: "tcp", Port: 443, Enabled: true},
		},
	}
	assert.Error(t, rp.Validate())
}

func TestValidate_TLSSubnetValid(t *testing.T) {
	rp := &Service{
		Name:       "tls-subnet",
		Mode:       "tls",
		Domain:     "example.com",
		ListenPort: 8443,
		Targets: []*Target{
			{TargetId: "subnet-1", TargetType: TargetTypeSubnet, Protocol: "tcp", Port: 443, Host: "10.0.0.5", Enabled: true},
		},
	}
	require.NoError(t, rp.Validate())
}

func TestValidate_L4DomainTargetValid(t *testing.T) {
	modes := []struct {
		mode  string
		port  uint16
		proto string
	}{
		{"tcp", 5432, "tcp"},
		{"tls", 443, "tcp"},
		{"udp", 5432, "udp"},
	}
	for _, m := range modes {
		t.Run(m.mode, func(t *testing.T) {
			rp := &Service{
				Name:       m.mode + "-domain",
				Mode:       m.mode,
				Domain:     "cluster.test",
				ListenPort: m.port,
				Targets: []*Target{
					{TargetId: "resource-1", TargetType: TargetTypeDomain, Protocol: m.proto, Port: m.port, Enabled: true},
				},
			}
			require.NoError(t, rp.Validate())
		})
	}
}

func TestValidate_HTTPProxyProtocolRejected(t *testing.T) {
	rp := validProxy()
	rp.Targets[0].ProxyProtocol = true
	assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for HTTP")
}

func TestValidate_UDPProxyProtocolRejected(t *testing.T) {
	rp := &Service{
		Name:   "udp-svc",
		Mode:   "udp",
		Domain: "cluster.test",
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "udp", Port: 5432, Enabled: true, ProxyProtocol: true},
		},
	}
	assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for UDP")
}

func TestValidate_TCPProxyProtocolAllowed(t *testing.T) {
	rp := &Service{
		Name:       "tcp-svc",
		Mode:       "tcp",
		Domain:     "cluster.test",
		ListenPort: 5432,
		Targets: []*Target{
			{TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true, ProxyProtocol: true},
		},
	}
	require.NoError(t, rp.Validate())
}

func TestExposeServiceRequest_Validate_L4RejectsAuth(t *testing.T) {
	tests := []struct {
		name string
		req  ExposeServiceRequest
	}{
		{
			name: "tcp with pin",
			req:  ExposeServiceRequest{Port: 8080, Mode: "tcp", Pin: "123456"},
		},
		{
			name: "udp with password",
			req:  ExposeServiceRequest{Port: 8080, Mode: "udp", Password: "secret"},
		},
		{
			name: "tls with user groups",
			req:  ExposeServiceRequest{Port: 443, Mode: "tls", UserGroups: []string{"admins"}},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := tt.req.Validate()
			require.Error(t, err)
			assert.Contains(t, err.Error(), "authentication is not supported")
		})
	}
}

func TestExposeServiceRequest_Validate_HTTPAllowsAuth(t *testing.T) {
	req := ExposeServiceRequest{Port: 8080, Mode: "http", Pin: "123456"}
	require.NoError(t, req.Validate())
}

func TestValidate_HeaderAuths(t *testing.T) {
	t.Run("single valid header", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: true, Header: "X-API-Key", Value: "secret"},
			},
		}
		require.NoError(t, rp.Validate())
	})

	t.Run("multiple headers same canonical name allowed", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: true, Header: "Authorization", Value: "Bearer token-1"},
				{Enabled: true, Header: "Authorization", Value: "Bearer token-2"},
			},
		}
		require.NoError(t, rp.Validate())
	})

	t.Run("multiple headers different case same canonical allowed", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: true, Header: "x-api-key", Value: "key-1"},
				{Enabled: true, Header: "X-Api-Key", Value: "key-2"},
			},
		}
		require.NoError(t, rp.Validate())
	})

	t.Run("multiple different headers allowed", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: true, Header: "Authorization", Value: "Bearer tok"},
				{Enabled: true, Header: "X-API-Key", Value: "key"},
			},
		}
		require.NoError(t, rp.Validate())
	})

	t.Run("empty header name rejected", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: true, Header: "", Value: "val"},
			},
		}
		err := rp.Validate()
		require.Error(t, err)
		assert.Contains(t, err.Error(), "header name is required")
	})

	t.Run("hop-by-hop header rejected", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: true, Header: "Connection", Value: "val"},
			},
		}
		err := rp.Validate()
		require.Error(t, err)
		assert.Contains(t, err.Error(), "hop-by-hop")
	})

	t.Run("host header rejected", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: true, Header: "Host", Value: "val"},
			},
		}
		err := rp.Validate()
		require.Error(t, err)
		assert.Contains(t, err.Error(), "Host header cannot be used")
	})

	t.Run("disabled entries skipped", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: false, Header: "", Value: ""},
				{Enabled: true, Header: "X-Key", Value: "val"},
			},
		}
		require.NoError(t, rp.Validate())
	})

	t.Run("value too long rejected", func(t *testing.T) {
		rp := validProxy()
		rp.Auth = AuthConfig{
			HeaderAuths: []*HeaderAuthConfig{
				{Enabled: true, Header: "X-Key", Value: strings.Repeat("a", maxHeaderValueLen+1)},
			},
		}
		err := rp.Validate()
		require.Error(t, err)
		assert.Contains(t, err.Error(), "exceeds maximum length")
	})
}

func TestValidate_HTTPClusterTarget(t *testing.T) {
	rp := validProxy()
	rp.Targets = []*Target{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: TargetTypeCluster,
		Protocol:   "http",
		Host:       "backend.lan",
		Options:    TargetOptions{DirectUpstream: true},
		Enabled:    true,
	}}
	require.NoError(t, rp.Validate(), "HTTP cluster target with target_id, host, and direct_upstream must validate")
}

func TestValidate_HTTPClusterTarget_RequiresTargetId(t *testing.T) {
	rp := validProxy()
	rp.Targets = []*Target{{
		TargetType: TargetTypeCluster,
		Protocol:   "http",
		Host:       "backend.lan",
		Options:    TargetOptions{DirectUpstream: true},
		Enabled:    true,
	}}
	assert.ErrorContains(t, rp.Validate(), "empty target_id", "cluster target must reject empty target_id")
}

// TestValidate_HTTPClusterTarget_RequiresHost pins the new cluster-target
// rule that operator-supplied Host is mandatory: cluster targets dial the
// upstream via the host network stack (direct_upstream is implied), so an
// empty Host leaves the proxy with nothing to dial.
func TestValidate_HTTPClusterTarget_RequiresHost(t *testing.T) {
	rp := validProxy()
	rp.Targets = []*Target{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: TargetTypeCluster,
		Protocol:   "http",
		Options:    TargetOptions{DirectUpstream: true},
		Enabled:    true,
	}}
	assert.ErrorContains(t, rp.Validate(), "empty host", "cluster target must reject empty host")
}

// TestValidate_HTTPClusterTarget_RequiresDirectUpstream pins the second
// half of the cluster-target rule: DirectUpstream must be true so the
// stdlib transport branch in MultiTransport is taken. Without it the
// embedded NetBird client would try to dial the cluster address through
// the WG tunnel, which is the wrong network for a cluster upstream.
func TestValidate_HTTPClusterTarget_RequiresDirectUpstream(t *testing.T) {
	rp := validProxy()
	rp.Targets = []*Target{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: TargetTypeCluster,
		Protocol:   "http",
		Host:       "backend.lan",
		Enabled:    true,
	}}
	assert.ErrorContains(t, rp.Validate(), "direct upstream disabled", "cluster target must reject direct_upstream=false")
}

// TestValidate_L4ClusterTarget_RequiresPort confirms that an L4 cluster
// target without an explicit port is rejected. buildPathMappings emits
// net.JoinHostPort(target.Host, target.Port) for every L4 target — so
// allowing port=0 would let the proxy ship ":0" upstreams. The port
// requirement is the same as every other L4 target type.
func TestValidate_L4ClusterTarget_RequiresPort(t *testing.T) {
	rp := validProxy()
	rp.Mode = ModeTCP
	rp.ListenPort = 9000
	rp.Targets = []*Target{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: TargetTypeCluster,
		Protocol:   "tcp",
		Enabled:    true,
	}}
	assert.ErrorContains(t, rp.Validate(), "port is required",
		"L4 cluster target must require an explicit port like other L4 target types")

	rp.Targets[0].Port = 5432
	rp.Targets[0].Host = "db.lan"
	require.NoError(t, rp.Validate(), "L4 cluster target with host:port must validate")
}

func TestService_Copy_RoundtripsPrivate(t *testing.T) {
	svc := validProxy()
	svc.Private = true
	svc.AccessGroups = []string{"grp-admins", "grp-ops"}
	cp := svc.Copy()
	require.NotNil(t, cp)
	assert.True(t, cp.Private)
	assert.Equal(t, []string{"grp-admins", "grp-ops"}, cp.AccessGroups)

	cp.Private = false
	assert.True(t, svc.Private)

	cp.AccessGroups[0] = "grp-other"
	assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
}

func TestService_APIRoundtrip_Private(t *testing.T) {
	enabled := true
	private := true
	accessGroups := []string{"grp-admins"}
	targets := []api.ServiceTarget{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: api.ServiceTargetTargetType("cluster"),
		Protocol:   "http",
		Port:       80,
		Enabled:    true,
	}}
	req := &api.ServiceRequest{
		Name:         "svc-private",
		Domain:       "myapp.eu.proxy.netbird.io",
		Enabled:      enabled,
		Private:      &private,
		AccessGroups: &accessGroups,
		Targets:      &targets,
	}

	svc := &Service{}
	require.NoError(t, svc.FromAPIRequest(req, "acc-1"))
	assert.True(t, svc.Private)
	assert.Equal(t, []string{"grp-admins"}, svc.AccessGroups)

	resp := svc.ToAPIResponse()
	require.NotNil(t, resp.Private)
	assert.True(t, *resp.Private)
	require.NotNil(t, resp.AccessGroups)
	assert.Equal(t, []string{"grp-admins"}, *resp.AccessGroups)
}

func TestValidate_Private_RequiresAccessGroups(t *testing.T) {
	rp := validProxy()
	rp.Private = true
	rp.Targets = []*Target{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: TargetTypeCluster,
		Protocol:   "http",
		Host:       "backend.lan",
		Options:    TargetOptions{DirectUpstream: true},
		Enabled:    true,
	}}
	assert.ErrorContains(t, rp.Validate(), "access group")
}

func TestValidate_Private_RejectsBearerAuth(t *testing.T) {
	rp := validProxy()
	rp.Private = true
	rp.AccessGroups = []string{"grp-admins"}
	rp.Auth.BearerAuth = &BearerAuthConfig{
		Enabled:            true,
		DistributionGroups: []string{"grp-sso"},
	}
	rp.Targets = []*Target{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: TargetTypeCluster,
		Protocol:   "http",
		Host:       "backend.lan",
		Options:    TargetOptions{DirectUpstream: true},
		Enabled:    true,
	}}
	assert.ErrorContains(t, rp.Validate(), "mutually exclusive")
}

func TestValidate_Private_AcceptsNonClusterTargets(t *testing.T) {
	rp := validProxy()
	rp.Private = true
	rp.AccessGroups = []string{"grp-admins"}
	require.NoError(t, rp.Validate())
}

func TestValidate_Private_AcceptsClusterTargetWithAccessGroups(t *testing.T) {
	rp := validProxy()
	rp.Private = true
	rp.AccessGroups = []string{"grp-admins"}
	rp.Targets = []*Target{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: TargetTypeCluster,
		Protocol:   "http",
		Host:       "backend.lan",
		Options:    TargetOptions{DirectUpstream: true},
		Enabled:    true,
	}}
	require.NoError(t, rp.Validate())
}

func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
	rp := validProxy()
	rp.Private = true
	rp.AccessGroups = []string{"grp-admins"}
	rp.Mode = ModeTCP
	rp.Targets = []*Target{{
		TargetId:   "eu.proxy.netbird.io",
		TargetType: TargetTypeCluster,
		Protocol:   "tcp",
		Enabled:    true,
	}}
	assert.ErrorContains(t, rp.Validate(), "HTTP")
}
