// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package config

import (
	"testing"
	"time"

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

	"github.com/stacklok/toolhive/pkg/authserver"
	authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
)

func TestDefaultOperationalConfig(t *testing.T) {
	t.Parallel()

	cfg := DefaultOperationalConfig()

	require.NotNil(t, cfg)
	require.NotNil(t, cfg.Timeouts)
	require.NotNil(t, cfg.FailureHandling)
	require.NotNil(t, cfg.FailureHandling.CircuitBreaker)

	// Verify all defaults match constants
	assert.Equal(t, Duration(defaultTimeoutDefault), cfg.Timeouts.Default)
	assert.Nil(t, cfg.Timeouts.PerWorkload)
	assert.Equal(t, Duration(defaultHealthCheckInterval), cfg.FailureHandling.HealthCheckInterval)
	assert.Equal(t, defaultUnhealthyThreshold, cfg.FailureHandling.UnhealthyThreshold)
	assert.Equal(t, defaultPartialFailureMode, cfg.FailureHandling.PartialFailureMode)
	assert.Equal(t, defaultCircuitBreakerEnabled, cfg.FailureHandling.CircuitBreaker.Enabled)
	assert.Equal(t, defaultCircuitBreakerFailureThreshold, cfg.FailureHandling.CircuitBreaker.FailureThreshold)
	assert.Equal(t, Duration(defaultCircuitBreakerTimeout), cfg.FailureHandling.CircuitBreaker.Timeout)
}

func TestDefaultOperationalConfig_MultipleCalls(t *testing.T) {
	t.Parallel()

	// Ensure each call returns a new instance
	cfg1 := DefaultOperationalConfig()
	cfg2 := DefaultOperationalConfig()

	require.NotNil(t, cfg1)
	require.NotNil(t, cfg2)

	// Verify they are different instances
	assert.NotSame(t, cfg1, cfg2, "Each call should return a new instance")
	assert.NotSame(t, cfg1.Timeouts, cfg2.Timeouts, "Timeouts should be different instances")
	assert.NotSame(t, cfg1.FailureHandling, cfg2.FailureHandling, "FailureHandling should be different instances")
	assert.NotSame(t, cfg1.FailureHandling.CircuitBreaker, cfg2.FailureHandling.CircuitBreaker,
		"CircuitBreaker should be different instances")
}

func TestEnsureOperationalDefaults_NilConfig(t *testing.T) {
	t.Parallel()

	// Verify calling on nil Config does not panic
	var cfg *Config
	assert.NotPanics(t, func() {
		cfg.EnsureOperationalDefaults()
	}, "EnsureOperationalDefaults should not panic on nil receiver")
}

func TestEnsureOperationalDefaults(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name        string
		operational *OperationalConfig
		validate    func(t *testing.T, op *OperationalConfig)
	}{
		{
			name:        "nil operational gets full defaults",
			operational: nil,
			validate: func(t *testing.T, op *OperationalConfig) {
				t.Helper()
				require.NotNil(t, op.Timeouts)
				require.NotNil(t, op.FailureHandling)
				require.NotNil(t, op.FailureHandling.CircuitBreaker)
				assert.Equal(t, Duration(defaultTimeoutDefault), op.Timeouts.Default)
				assert.Equal(t, Duration(defaultHealthCheckInterval), op.FailureHandling.HealthCheckInterval)
			},
		},
		{
			name:        "empty operational gets full defaults",
			operational: &OperationalConfig{},
			validate: func(t *testing.T, op *OperationalConfig) {
				t.Helper()
				require.NotNil(t, op.Timeouts)
				require.NotNil(t, op.FailureHandling)
				require.NotNil(t, op.FailureHandling.CircuitBreaker)
				assert.Equal(t, Duration(defaultTimeoutDefault), op.Timeouts.Default)
				assert.Equal(t, Duration(defaultHealthCheckInterval), op.FailureHandling.HealthCheckInterval)
				assert.Equal(t, defaultUnhealthyThreshold, op.FailureHandling.UnhealthyThreshold)
				assert.Equal(t, defaultPartialFailureMode, op.FailureHandling.PartialFailureMode)
				assert.Equal(t, defaultCircuitBreakerEnabled, op.FailureHandling.CircuitBreaker.Enabled)
				assert.Equal(t, defaultCircuitBreakerFailureThreshold, op.FailureHandling.CircuitBreaker.FailureThreshold)
				assert.Equal(t, Duration(defaultCircuitBreakerTimeout), op.FailureHandling.CircuitBreaker.Timeout)
			},
		},
		{
			name: "only Timeouts provided with zero default",
			operational: &OperationalConfig{
				Timeouts: &TimeoutConfig{
					Default:     0, // zero value, should be filled
					PerWorkload: nil,
				},
			},
			validate: func(t *testing.T, op *OperationalConfig) {
				t.Helper()
				assert.Equal(t, Duration(defaultTimeoutDefault), op.Timeouts.Default,
					"Zero Default should be filled with default")
				require.NotNil(t, op.FailureHandling, "FailureHandling should be created")
				require.NotNil(t, op.FailureHandling.CircuitBreaker, "CircuitBreaker should be created")
			},
		},
		{
			name: "only FailureHandling provided with empty values",
			operational: &OperationalConfig{
				FailureHandling: &FailureHandlingConfig{},
			},
			validate: func(t *testing.T, op *OperationalConfig) {
				t.Helper()
				require.NotNil(t, op.Timeouts, "Timeouts should be created")
				assert.Equal(t, Duration(defaultTimeoutDefault), op.Timeouts.Default)
				assert.Equal(t, Duration(defaultHealthCheckInterval), op.FailureHandling.HealthCheckInterval)
				assert.Equal(t, defaultUnhealthyThreshold, op.FailureHandling.UnhealthyThreshold)
				assert.Equal(t, defaultPartialFailureMode, op.FailureHandling.PartialFailureMode)
				require.NotNil(t, op.FailureHandling.CircuitBreaker, "CircuitBreaker should be created")
			},
		},
		{
			name: "FailureHandling provided with nil CircuitBreaker",
			operational: &OperationalConfig{
				FailureHandling: &FailureHandlingConfig{
					HealthCheckInterval: Duration(15 * time.Second), // custom value
					UnhealthyThreshold:  2,                          // custom value
					PartialFailureMode:  "best_effort",              // custom value
					CircuitBreaker:      nil,                        // should be filled
				},
			},
			validate: func(t *testing.T, op *OperationalConfig) {
				t.Helper()
				// Custom values should be preserved
				assert.Equal(t, Duration(15*time.Second), op.FailureHandling.HealthCheckInterval)
				assert.Equal(t, 2, op.FailureHandling.UnhealthyThreshold)
				assert.Equal(t, "best_effort", op.FailureHandling.PartialFailureMode)
				// CircuitBreaker should be created with defaults
				require.NotNil(t, op.FailureHandling.CircuitBreaker, "CircuitBreaker should be created")
				assert.Equal(t, defaultCircuitBreakerEnabled, op.FailureHandling.CircuitBreaker.Enabled)
				assert.Equal(t, defaultCircuitBreakerFailureThreshold, op.FailureHandling.CircuitBreaker.FailureThreshold)
				assert.Equal(t, Duration(defaultCircuitBreakerTimeout), op.FailureHandling.CircuitBreaker.Timeout)
			},
		},
		{
			name: "CircuitBreaker provided with zero values",
			operational: &OperationalConfig{
				FailureHandling: &FailureHandlingConfig{
					CircuitBreaker: &CircuitBreakerConfig{
						Enabled:          false, // explicit false
						FailureThreshold: 0,     // zero, should be filled
						Timeout:          0,     // zero, should be filled
					},
				},
			},
			validate: func(t *testing.T, op *OperationalConfig) {
				t.Helper()
				// HealthCheckInterval, UnhealthyThreshold, PartialFailureMode should be filled
				assert.Equal(t, Duration(defaultHealthCheckInterval), op.FailureHandling.HealthCheckInterval)
				assert.Equal(t, defaultUnhealthyThreshold, op.FailureHandling.UnhealthyThreshold)
				assert.Equal(t, defaultPartialFailureMode, op.FailureHandling.PartialFailureMode)
				// CircuitBreaker zero values should be filled
				assert.Equal(t, false, op.FailureHandling.CircuitBreaker.Enabled,
					"Enabled should remain false (zero value is intentional)")
				assert.Equal(t, defaultCircuitBreakerFailureThreshold, op.FailureHandling.CircuitBreaker.FailureThreshold)
				assert.Equal(t, Duration(defaultCircuitBreakerTimeout), op.FailureHandling.CircuitBreaker.Timeout)
			},
		},
		{
			name: "Timeouts with PerWorkload but zero Default",
			operational: &OperationalConfig{
				Timeouts: &TimeoutConfig{
					Default: 0,
					PerWorkload: map[string]Duration{
						"workload1": Duration(45 * time.Second),
					},
				},
			},
			validate: func(t *testing.T, op *OperationalConfig) {
				t.Helper()
				assert.Equal(t, Duration(defaultTimeoutDefault), op.Timeouts.Default,
					"Zero Default should be filled")
				assert.Equal(t, Duration(45*time.Second), op.Timeouts.PerWorkload["workload1"],
					"PerWorkload should be preserved")
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			cfg := &Config{
				Name:        "test-vmcp",
				Group:       "test-group",
				Operational: tt.operational,
			}

			cfg.EnsureOperationalDefaults()

			require.NotNil(t, cfg.Operational, "Operational should not be nil after EnsureOperationalDefaults")
			tt.validate(t, cfg.Operational)
		})
	}
}

// TestInjectSubjectProviderNames tests the InjectSubjectProviderNames defaulting helper.
// Modelled on TestInjectUpstreamProviderIfNeeded in pkg/runner/middleware_test.go.
func TestInjectSubjectProviderNames(t *testing.T) {
	t.Parallel()

	makeTokenExchangeStrategy := func(subjectProviderName string) *authtypes.BackendAuthStrategy {
		return &authtypes.BackendAuthStrategy{
			Type: authtypes.StrategyTypeTokenExchange,
			TokenExchange: &authtypes.TokenExchangeConfig{
				TokenURL:            "https://oauth.example.com/token",
				SubjectProviderName: subjectProviderName,
			},
		}
	}

	makeRunConfig := func(upstreamNames ...string) *authserver.RunConfig {
		rc := &authserver.RunConfig{}
		for _, name := range upstreamNames {
			rc.Upstreams = append(rc.Upstreams, authserver.UpstreamRunConfig{Name: name})
		}
		return rc
	}

	tests := []struct {
		name          string
		cfg           *Config
		rc            *authserver.RunConfig
		wantDefault   string
		wantBackends  map[string]string // backend name → expected SubjectProviderName
		wantUnchanged bool              // OutgoingAuth must not be touched
	}{
		{
			name:          "nil_cfg_is_a_noop",
			cfg:           nil,
			rc:            makeRunConfig("github"),
			wantUnchanged: true,
		},
		{
			name:          "nil_run_config_leaves_config_unchanged",
			cfg:           &Config{OutgoingAuth: &OutgoingAuthConfig{Default: makeTokenExchangeStrategy("")}},
			rc:            nil,
			wantUnchanged: true,
		},
		{
			name:          "nil_outgoing_auth_leaves_config_unchanged",
			cfg:           &Config{OutgoingAuth: nil},
			rc:            makeRunConfig("github"),
			wantUnchanged: true,
		},
		{
			name: "named_upstream_populates_default_and_backend",
			cfg: &Config{
				OutgoingAuth: &OutgoingAuthConfig{
					Default: makeTokenExchangeStrategy(""),
					Backends: map[string]*authtypes.BackendAuthStrategy{
						"svc": makeTokenExchangeStrategy(""),
					},
				},
			},
			rc:           makeRunConfig("github"),
			wantDefault:  "github",
			wantBackends: map[string]string{"svc": "github"},
		},
		{
			name: "unnamed_upstream_falls_back_to_default",
			cfg: &Config{
				OutgoingAuth: &OutgoingAuthConfig{
					Default: makeTokenExchangeStrategy(""),
				},
			},
			rc:          makeRunConfig(""),
			wantDefault: authserver.DefaultUpstreamName,
		},
		{
			name: "empty_upstreams_falls_back_to_default",
			cfg: &Config{
				OutgoingAuth: &OutgoingAuthConfig{
					Default: makeTokenExchangeStrategy(""),
				},
			},
			rc:          makeRunConfig(), // no upstreams
			wantDefault: authserver.DefaultUpstreamName,
		},
		{
			name: "first_upstream_used_when_multiple_configured",
			cfg: &Config{
				OutgoingAuth: &OutgoingAuthConfig{
					Default: makeTokenExchangeStrategy(""),
				},
			},
			rc:          makeRunConfig("first", "second"),
			wantDefault: "first",
		},
		{
			name: "already_set_subject_provider_not_overridden",
			cfg: &Config{
				OutgoingAuth: &OutgoingAuthConfig{
					Default: makeTokenExchangeStrategy("explicit"),
					Backends: map[string]*authtypes.BackendAuthStrategy{
						"svc": makeTokenExchangeStrategy("also-explicit"),
					},
				},
			},
			rc:           makeRunConfig("github"),
			wantDefault:  "explicit",
			wantBackends: map[string]string{"svc": "also-explicit"},
		},
		{
			name: "non_token_exchange_strategy_left_unchanged",
			cfg: &Config{
				OutgoingAuth: &OutgoingAuthConfig{
					Default: &authtypes.BackendAuthStrategy{
						Type: authtypes.StrategyTypeHeaderInjection,
						HeaderInjection: &authtypes.HeaderInjectionConfig{
							HeaderName:  "Authorization",
							HeaderValue: "Bearer token",
						},
					},
				},
			},
			rc:          makeRunConfig("github"),
			wantDefault: "", // no TokenExchange on this strategy
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			// Snapshot the default strategy pointer before calling so we can verify
			// InjectSubjectProviderNames mutates in place rather than replacing pointers.
			var beforeDefault *authtypes.BackendAuthStrategy
			if tt.cfg != nil && tt.cfg.OutgoingAuth != nil {
				beforeDefault = tt.cfg.OutgoingAuth.Default
			}

			assert.NotPanics(t, func() { InjectSubjectProviderNames(tt.cfg, tt.rc) })

			if tt.wantUnchanged {
				if tt.cfg != nil && tt.cfg.OutgoingAuth != nil &&
					tt.cfg.OutgoingAuth.Default != nil &&
					tt.cfg.OutgoingAuth.Default.TokenExchange != nil {
					assert.Empty(t, tt.cfg.OutgoingAuth.Default.TokenExchange.SubjectProviderName,
						"SubjectProviderName should not have been set")
				}
				return
			}

			require.NotNil(t, tt.cfg.OutgoingAuth)

			// Verify the Default strategy.
			if tt.cfg.OutgoingAuth.Default != nil {
				if tt.cfg.OutgoingAuth.Default.TokenExchange != nil {
					assert.Equal(t, tt.wantDefault, tt.cfg.OutgoingAuth.Default.TokenExchange.SubjectProviderName,
						"Default SubjectProviderName mismatch")
				}
				// The pointer must not have changed — mutation must be in place.
				assert.Same(t, beforeDefault, tt.cfg.OutgoingAuth.Default,
					"Default strategy pointer must not change: InjectSubjectProviderNames should mutate in place")
			}

			// Verify per-backend strategies.
			for backendName, wantProvider := range tt.wantBackends {
				strategy, ok := tt.cfg.OutgoingAuth.Backends[backendName]
				require.True(t, ok, "backend %q not found in OutgoingAuth.Backends", backendName)
				require.NotNil(t, strategy.TokenExchange, "backend %q: TokenExchange is nil", backendName)
				assert.Equal(t, wantProvider, strategy.TokenExchange.SubjectProviderName,
					"backend %q SubjectProviderName mismatch", backendName)
			}
		})
	}
}

func TestEnsureOperationalDefaults_Idempotent(t *testing.T) {
	t.Parallel()

	cfg := &Config{
		Name:        "test-vmcp",
		Group:       "test-group",
		Operational: nil,
	}

	// Call EnsureOperationalDefaults multiple times
	cfg.EnsureOperationalDefaults()
	firstOp := cfg.Operational

	cfg.EnsureOperationalDefaults()
	secondOp := cfg.Operational

	cfg.EnsureOperationalDefaults()
	thirdOp := cfg.Operational

	// All calls should result in the same operational config (same pointer after first call)
	assert.Same(t, firstOp, secondOp, "Second call should not replace Operational")
	assert.Same(t, secondOp, thirdOp, "Third call should not replace Operational")

	// Values should remain consistent
	assert.Equal(t, Duration(defaultTimeoutDefault), cfg.Operational.Timeouts.Default)
	assert.Equal(t, Duration(defaultHealthCheckInterval), cfg.Operational.FailureHandling.HealthCheckInterval)
	assert.Equal(t, defaultUnhealthyThreshold, cfg.Operational.FailureHandling.UnhealthyThreshold)
}
