//go:build sops

package config //nolint:testpackage // needs access to sopsDecryptFileImpl

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"testing"

	"github.com/gruntwork-io/terragrunt/internal/strict/controls"
	"github.com/gruntwork-io/terragrunt/internal/vsops"
	"github.com/gruntwork-io/terragrunt/test/helpers/logger"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// generateTestSecretFiles creates plain JSON files in a temp directory.
// No SOPS encryption needed — the test injects a mock decrypter to read raw files.
func generateTestSecretFiles(t *testing.T, count int) []string {
	t.Helper()

	dir := t.TempDir()

	var files []string

	for i := 1; i <= count; i++ {
		unitDir := filepath.Join(dir, fmt.Sprintf("unit-%02d", i))
		require.NoError(t, os.MkdirAll(unitDir, 0755))

		secretFile := filepath.Join(unitDir, "secret.enc.json")
		require.NoError(t, os.WriteFile(secretFile,
			[]byte(fmt.Sprintf(`{"value":"secret-from-unit-%02d"}`, i)), 0644))

		files = append(files, secretFile)
	}

	return files
}

// TestSOPSDecryptEnvPropagation is a deterministic regression test for
// https://github.com/gruntwork-io/terragrunt/issues/5515
//
// The original customer-reported bug: sops_decrypt_file() during HCL evaluation
// couldn't authenticate to KMS because auth-provider credentials were not yet
// loaded into opts.Env. This caused SOPS to return empty/wrong secrets.
//
// This test verifies the env propagation contract of sopsDecryptFileImpl:
//   - Existing process env vars are preserved (not overridden by opts.Env)
//   - Missing env vars from opts.Env are set during decrypt and unset after
//   - Without credentials, decrypt fails (reproduces the original bug)
//   - Concurrent goroutines with different credentials are properly isolated
func TestSOPSDecryptEnvPropagation(t *testing.T) { //nolint:paralleltest // mutates process env vars
	const authKey = "SOPS_TEST_AUTH_CRED"

	t.Cleanup(func() {
		os.Unsetenv(authKey) //nolint:errcheck
	})

	secretFiles := generateTestSecretFiles(t, 1)
	secretFile := secretFiles[0]

	// Mock decrypter that requires authKey to be set — simulates KMS auth.
	authRequiringDecrypter := vsops.NewMemDecrypter(func(path string, _ string) ([]byte, error) {
		token := os.Getenv(authKey)
		if token == "" {
			return nil, errors.New("KMS auth failed: no credential set")
		}

		return os.ReadFile(path)
	})

	// Subtest 1: Existing process env vars are preserved (not overridden).
	// Models: CI runner has real AWS_SESSION_TOKEN, auth-provider returns empty token.
	// sopsDecryptFileImpl must NOT override the real token with empty — SOPS uses process env.
	t.Run(
		"existing_process_env_preserved",
		func(t *testing.T) { //nolint:paralleltest // mutates process env
			t.Setenv(authKey, "real-ci-token")

			l := logger.CreateLogger()
			ctx := WithConfigValues(t.Context())
			_, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New()))
			pctx.WorkingDir = filepath.Dir(secretFile)
			// pctx.Venv.Env has empty value for authKey (like auth-provider returning empty session token)
			pctx.Venv.Env = map[string]string{authKey: ""}

			result, err := sopsDecryptFileImpl(
				ctx,
				pctx,
				l,
				secretFile,
				"json",
				authRequiringDecrypter,
			)
			require.NoError(t, err, "decrypt must succeed using existing process env credentials")
			assert.Contains(t, result, `"value":"secret-from-unit-01"`)

			// Process env must still have the real token — not overridden
			assert.Equal(t, "real-ci-token", os.Getenv(authKey),
				"existing process env var must not be overridden")
		},
	)

	// Subtest 2: Credentials injected when absent from process env.
	// Models: first run, auth-provider loaded creds into opts.Env, process env was empty.
	t.Run( //nolint:paralleltest // mutates process env
		"new_creds_set_when_absent_from_process_env",
		func(t *testing.T) {
			os.Unsetenv(authKey) //nolint:errcheck

			l := logger.CreateLogger()
			ctx := WithConfigValues(t.Context())
			_, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New()))
			pctx.WorkingDir = filepath.Dir(secretFile)
			pctx.Venv.Env = map[string]string{authKey: "fresh-token"}

			result, err := sopsDecryptFileImpl(
				ctx,
				pctx,
				l,
				secretFile,
				"json",
				authRequiringDecrypter,
			)
			require.NoError(t, err, "decrypt must succeed with fresh credentials from opts.Env")
			assert.Contains(t, result, `"value":"secret-from-unit-01"`)

			// Process env must be unset (not empty string) after decrypt
			_, exists := os.LookupEnv(authKey)
			assert.False(t, exists,
				"env var must be unset after decrypt, not set to empty string")
		},
	)

	// Subtest 3: Missing credentials cause decrypt failure.
	// Reproduces the ORIGINAL bug: auth-provider hasn't run yet, opts.Env has no
	// auth token, process env has no auth token → SOPS can't authenticate to KMS.
	t.Run( //nolint:paralleltest // mutates process env
		"missing_creds_fails_decrypt",
		func(t *testing.T) {
			os.Unsetenv(authKey) //nolint:errcheck

			l := logger.CreateLogger()
			ctx := WithConfigValues(t.Context())
			_, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New()))
			pctx.WorkingDir = filepath.Dir(secretFile)
			// Empty env — simulates auth-provider NOT having run (the original bug)
			pctx.Venv.Env = map[string]string{}

			_, err := sopsDecryptFileImpl(ctx, pctx, l, secretFile, "json", authRequiringDecrypter)
			require.Error(t, err,
				"decrypt must fail without auth credentials — reproduces original issue #5515")
		},
	)

	// Subtest 4: Concurrent goroutines with DIFFERENT auth tokens are isolated.
	// Models production: multiple units decrypt in parallel, each with different
	// auth-provider credentials. The lock must ensure each sees its OWN token.
	t.Run( //nolint:paralleltest // mutates process env
		"concurrent_different_creds_isolated",
		func(t *testing.T) {
			const numGoroutines = 5

			os.Unsetenv(authKey) //nolint:errcheck

			files := generateTestSecretFiles(t, numGoroutines)

			var wg sync.WaitGroup

			barrier := make(chan struct{})

			var failures atomic.Int32

			ctx := WithConfigValues(t.Context())

			for i, f := range files {
				wg.Add(1)

				go func(idx int, filePath string) {
					defer wg.Done()

					<-barrier

					expectedToken := fmt.Sprintf("token-%d", idx)

					// Each goroutine's decrypter verifies it sees ITS OWN token
					tokenCheckDecrypter := vsops.NewMemDecrypter(
						func(path string, _ string) ([]byte, error) {
							actual := os.Getenv(authKey)
							if actual != expectedToken {
								return nil, fmt.Errorf(
									"goroutine %d: expected %q, got %q",
									idx,
									expectedToken,
									actual,
								)
							}

							return os.ReadFile(path)
						},
					)

					l := logger.CreateLogger()
					_, pctx := NewParsingContext(ctx, l, WithStrictControls(controls.New()))
					pctx.WorkingDir = filepath.Dir(filePath)
					pctx.Venv.Env = map[string]string{authKey: expectedToken}

					result, decryptErr := sopsDecryptFileImpl(
						ctx,
						pctx,
						l,
						filePath,
						"json",
						tokenCheckDecrypter,
					)
					if decryptErr != nil {
						t.Logf("goroutine %d failed: %v", idx, decryptErr)
						failures.Add(1)

						return
					}

					expectedPrefix := `{"value":"secret-from-unit-`
					if len(result) < len(expectedPrefix) ||
						result[:len(expectedPrefix)] != expectedPrefix {
						t.Logf("goroutine %d: wrong content: %s", idx, result)
						failures.Add(1)
					}
				}(i, f)
			}

			close(barrier)
			wg.Wait()

			require.Zero(
				t,
				failures.Load(),
				"all goroutines must see their own auth token during decrypt — env isolation failed",
			)

			assert.Empty(t, os.Getenv(authKey),
				"process env must be clean after all concurrent decrypts")
		},
	)
}
