package discovery_test

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/gruntwork-io/terragrunt/internal/component"
	"github.com/gruntwork-io/terragrunt/internal/discovery"
	"github.com/gruntwork-io/terragrunt/internal/filter"
	"github.com/gruntwork-io/terragrunt/internal/git"
	"github.com/gruntwork-io/terragrunt/internal/venv"
	"github.com/gruntwork-io/terragrunt/internal/vexec"
	"github.com/gruntwork-io/terragrunt/pkg/options"
	"github.com/gruntwork-io/terragrunt/test/helpers"
	"github.com/gruntwork-io/terragrunt/test/helpers/logger"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestDiscovery_GraphExpressionFilters tests graph expression filter functionality.
func TestDiscovery_GraphExpressionFilters(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// To speed up this test, make the temporary directory a git repository.
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create dependency graph: vpc -> db -> app
	vpcDir := filepath.Join(tmpDir, "vpc")
	dbDir := filepath.Join(tmpDir, "db")
	appDir := filepath.Join(tmpDir, "app")

	testDirs := []string{vpcDir, dbDir, appDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	// Create test files with dependencies
	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	tests := []struct {
		name          string
		filterQueries []string
		wantUnits     []string
	}{
		{
			name:          "dependency discovery - app...",
			filterQueries: []string{"app..."},
			wantUnits:     []string{appDir, dbDir, vpcDir},
		},
		{
			name:          "braced path with dependencies - {./app}...",
			filterQueries: []string{"{./app}..."},
			wantUnits:     []string{appDir, dbDir, vpcDir},
		},
	}

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

			filters, err := filter.ParseFilterQueries(l, tt.filterQueries)
			require.NoError(t, err)

			d := discovery.NewDiscovery(tmpDir).
				WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
				WithFilters(filters)

			components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
			require.NoError(t, err)

			units := components.Filter(component.UnitKind).Paths()
			assert.ElementsMatch(t, tt.wantUnits, units, "Units mismatch for test: %s", tt.name)
		})
	}
}

// TestDiscovery_GraphExpressionFilters_ComplexGraph tests graph expressions with a more complex graph.
func TestDiscovery_GraphExpressionFilters_ComplexGraph(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// To speed up this test, make the temporary directory a git repository.
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create complex graph: vpc -> [db, cache] -> app
	vpcDir := filepath.Join(tmpDir, "vpc")
	dbDir := filepath.Join(tmpDir, "db")
	cacheDir := filepath.Join(tmpDir, "cache")
	appDir := filepath.Join(tmpDir, "app")

	testDirs := []string{vpcDir, dbDir, cacheDir, appDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}

dependency "cache" {
	config_path = "../cache"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
		filepath.Join(cacheDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	t.Run("dependency traversal from app finds all dependencies", func(t *testing.T) {
		t.Parallel()

		filters, err := filter.ParseFilterQueries(l, []string{"app..."})
		require.NoError(t, err)

		d := discovery.NewDiscovery(tmpDir).
			WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
			WithFilters(filters)

		configs, err := d.Discover(ctx, l, venv.OSVenv(), opts)
		require.NoError(t, err)

		units := configs.Filter(component.UnitKind).Paths()
		assert.ElementsMatch(t, []string{appDir, dbDir, cacheDir, vpcDir}, units)
	})
}

// TestDiscovery_GraphExpressionFilters_OnlyMatchingComponentsTriggerDiscovery tests selective graph discovery.
func TestDiscovery_GraphExpressionFilters_OnlyMatchingComponentsTriggerDiscovery(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Create components: app depends on db, but there's also an unrelated component
	appDir := filepath.Join(tmpDir, "app")
	dbDir := filepath.Join(tmpDir, "db")
	unrelatedDir := filepath.Join(tmpDir, "unrelated")

	testDirs := []string{appDir, dbDir, unrelatedDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"):        ``,
		filepath.Join(unrelatedDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	t.Run("graph expression only discovers dependencies of matching component", func(t *testing.T) {
		t.Parallel()

		// Filter for app and its dependencies - unrelated should not be included
		filters, err := filter.ParseFilterQueries(l, []string{"app..."})
		require.NoError(t, err)

		d := discovery.NewDiscovery(tmpDir).
			WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
			WithFilters(filters)

		configs, err := d.Discover(ctx, l, venv.OSVenv(), opts)
		require.NoError(t, err)

		units := configs.Filter(component.UnitKind).Paths()
		// Should include app and db, but NOT unrelated
		assert.ElementsMatch(t, []string{appDir, dbDir}, units)
		assert.NotContains(t, units, unrelatedDir)
	})
}

// TestDiscovery_GraphExpressionFilters_FiltersAppliedAfterDiscovery tests additional filters after graph discovery.
func TestDiscovery_GraphExpressionFilters_FiltersAppliedAfterDiscovery(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Create dependency graph: vpc -> db -> app
	vpcDir := filepath.Join(tmpDir, "vpc")
	dbDir := filepath.Join(tmpDir, "db")
	appDir := filepath.Join(tmpDir, "app")

	testDirs := []string{vpcDir, dbDir, appDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	t.Run("additional filters applied after graph discovery", func(t *testing.T) {
		t.Parallel()

		// Graph expression discovers app and its dependencies, then additional filter excludes vpc
		filters, err := filter.ParseFilterQueries(l, []string{"app...", "!vpc"})
		require.NoError(t, err)

		d := discovery.NewDiscovery(tmpDir).
			WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
			WithFilters(filters)

		configs, err := d.Discover(ctx, l, venv.OSVenv(), opts)
		require.NoError(t, err)

		units := configs.Filter(component.UnitKind).Paths()
		// Should include app and db (from graph), but exclude vpc (from filter)
		assert.ElementsMatch(t, []string{appDir, dbDir}, units)
		assert.NotContains(t, units, vpcDir)
	})
}

// TestDiscovery_ReadingAttributeFilters tests reading attribute filter functionality.
func TestDiscovery_ReadingAttributeFilters(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Create shared configuration files
	sharedHCL := filepath.Join(tmpDir, "shared.hcl")
	sharedTFVars := filepath.Join(tmpDir, "shared.tfvars")
	commonVars := filepath.Join(tmpDir, "common", "variables.hcl")
	dbConfig := filepath.Join(tmpDir, "database.yaml")

	require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "common"), 0755))

	require.NoError(t, os.WriteFile(sharedHCL, []byte(`
locals {
	common_value = "test"
}
`), 0644))

	require.NoError(t, os.WriteFile(sharedTFVars, []byte(`
test_var = "value"
another_var = "test"
`), 0644))

	require.NoError(t, os.WriteFile(commonVars, []byte(`
locals {
	vpc_cidr = "10.0.0.0/16"
}
`), 0644))

	require.NoError(t, os.WriteFile(dbConfig, []byte(`
locals {
	db_host = "localhost"
	db_port = 5432
}
`), 0644))

	// Create test components with different file reads
	frontendDir := filepath.Join(tmpDir, "apps", "frontend")
	backendDir := filepath.Join(tmpDir, "apps", "backend")
	legacyDir := filepath.Join(tmpDir, "apps", "legacy")
	dbDir := filepath.Join(tmpDir, "libs", "db")
	cacheDir := filepath.Join(tmpDir, "libs", "cache")

	testDirs := []string{frontendDir, backendDir, legacyDir, dbDir, cacheDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	// Create test files with different file reading patterns
	// Note: Only read_terragrunt_config and read_tfvars_file populate the Reading slice
	testFiles := map[string]string{
		filepath.Join(frontendDir, "terragrunt.hcl"): `
locals {
	shared = read_terragrunt_config("../../shared.hcl")
	vars = read_tfvars_file("../../shared.tfvars")
}
`,
		filepath.Join(backendDir, "terragrunt.hcl"): `
locals {
	shared = read_terragrunt_config("../../shared.hcl")
	common = read_terragrunt_config("../../common/variables.hcl")
}
`,
		filepath.Join(legacyDir, "terragrunt.hcl"): `
locals {
	# Uses a file that will be tracked
	db_config = read_terragrunt_config("../../database.yaml")
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"): `
locals {
	common = read_terragrunt_config("../../common/variables.hcl")
	db_config = read_terragrunt_config("../../database.yaml")
}
`,
		filepath.Join(cacheDir, "terragrunt.hcl"): `
# No file reads
`,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	tests := []struct {
		name          string
		filterQueries []string
		wantUnits     []string
	}{
		{
			name:          "filter by exact file - shared.hcl",
			filterQueries: []string{"reading=shared.hcl"},
			wantUnits:     []string{frontendDir, backendDir},
		},
		{
			name:          "filter by exact file - database.yaml",
			filterQueries: []string{"reading=database.yaml"},
			wantUnits:     []string{legacyDir, dbDir},
		},
		{
			name:          "filter by glob - shared prefix",
			filterQueries: []string{"reading=shared*"},
			wantUnits:     []string{frontendDir, backendDir},
		},
		{
			name:          "filter by exact nested path",
			filterQueries: []string{"reading=common/variables.hcl"},
			wantUnits:     []string{backendDir, dbDir},
		},
		{
			name:          "negation - exclude components reading shared.hcl",
			filterQueries: []string{"!reading=shared.hcl"},
			wantUnits:     []string{legacyDir, dbDir, cacheDir},
		},
		{
			name:          "negation with glob - exclude components reading database.yaml",
			filterQueries: []string{"!reading=database.yaml"},
			wantUnits:     []string{frontendDir, backendDir, cacheDir},
		},
		{
			name:          "intersection - apps directory reading shared.hcl",
			filterQueries: []string{"./apps/* | reading=shared.hcl"},
			wantUnits:     []string{frontendDir, backendDir},
		},
		{
			name:          "intersection - libs directory with common variables",
			filterQueries: []string{"./libs/* | reading=common/variables.hcl"},
			wantUnits:     []string{dbDir},
		},
		{
			name:          "multiple filters - union semantics",
			filterQueries: []string{"reading=shared.hcl", "reading=database.yaml"},
			wantUnits:     []string{frontendDir, backendDir, legacyDir, dbDir},
		},
		{
			name:          "complex - apps not reading database.yaml",
			filterQueries: []string{"./apps/* | !reading=database.yaml"},
			wantUnits:     []string{frontendDir, backendDir},
		},
		{
			name:          "no matches - nonexistent file",
			filterQueries: []string{"reading=nonexistent.hcl"},
			wantUnits:     []string{},
		},
		{
			name:          "components that don't read any files",
			filterQueries: []string{"cache"},
			wantUnits:     []string{cacheDir},
		},
	}

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

			filters, err := filter.ParseFilterQueries(l, tt.filterQueries)
			require.NoError(t, err)

			d := discovery.NewDiscovery(tmpDir).
				WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
				WithFilters(filters).
				WithReadFiles()

			configs, err := d.Discover(ctx, l, venv.OSVenv(), opts)
			require.NoError(t, err)

			units := configs.Filter(component.UnitKind).Paths()
			assert.ElementsMatch(t, tt.wantUnits, units, "Units mismatch for test: %s", tt.name)
		})
	}
}

// TestDiscovery_ReadingAttributeFiltersAbsolutePaths tests reading attribute filter with absolute paths.
func TestDiscovery_ReadingAttributeFiltersAbsolutePaths(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Create a shared file with absolute path
	sharedFile := filepath.Join(tmpDir, "shared.hcl")
	require.NoError(t, os.WriteFile(sharedFile, []byte(`
locals {
	value = "test"
}
`), 0644))

	// Create test component
	appDir := filepath.Join(tmpDir, "app")
	require.NoError(t, os.MkdirAll(appDir, 0755))

	terragruntConfig := filepath.Join(appDir, "terragrunt.hcl")
	require.NoError(t, os.WriteFile(terragruntConfig, []byte(`
locals {
	shared = read_terragrunt_config("../shared.hcl")
}
`), 0644))

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	// Test with absolute path filter
	filterQueries := []string{"reading=" + sharedFile}
	filters, err := filter.ParseFilterQueries(l, filterQueries)
	require.NoError(t, err)

	d := discovery.NewDiscovery(tmpDir).
		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
		WithFilters(filters).
		WithReadFiles()

	configs, err := d.Discover(ctx, l, venv.OSVenv(), opts)
	require.NoError(t, err)

	// Should find the app component when filtering by absolute path
	units := configs.Filter(component.UnitKind).Paths()
	assert.ElementsMatch(
		t,
		[]string{appDir},
		units,
		"Should find component by absolute path to read file",
	)
}

// TestDiscovery_ReadingAttributeFiltersErrorHandling tests error handling for invalid reading filters.
func TestDiscovery_ReadingAttributeFiltersErrorHandling(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	appDir := filepath.Join(tmpDir, "app")
	require.NoError(t, os.MkdirAll(appDir, 0755))
	require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(""), 0644))

	tests := []struct {
		name                 string
		filterQueries        []string
		errorExpectedOnParse bool
	}{
		{
			name:                 "invalid glob pattern in reading filter",
			filterQueries:        []string{"reading=[invalid"},
			errorExpectedOnParse: true,
		},
		{
			name:                 "valid reading filter - no error",
			filterQueries:        []string{"reading=*.hcl"},
			errorExpectedOnParse: false,
		},
	}

	l := logger.CreateLogger()

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

			// Parse filter queries
			_, err := filter.ParseFilterQueries(l, tt.filterQueries)
			if tt.errorExpectedOnParse {
				require.Error(t, err, "Expected error for filter: %v", tt.filterQueries)
			} else {
				require.NoError(t, err)
			}
		})
	}
}

// TestDiscovery_AttributeFilters tests path, name, type, and external attribute filters.
func TestDiscovery_AttributeFilters(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Create test directory structure
	appsDir := filepath.Join(tmpDir, "apps")
	frontendDir := filepath.Join(appsDir, "frontend")
	backendDir := filepath.Join(appsDir, "backend")
	legacyDir := filepath.Join(appsDir, "legacy")

	libsDir := filepath.Join(tmpDir, "libs")
	dbDir := filepath.Join(libsDir, "db")
	cacheDir := filepath.Join(libsDir, "cache")

	stackDir := filepath.Join(tmpDir, "stack")

	testDirs := []string{
		frontendDir,
		backendDir,
		legacyDir,
		dbDir,
		cacheDir,
		stackDir,
	}

	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(frontendDir, "terragrunt.hcl"): ``,
		filepath.Join(backendDir, "terragrunt.hcl"):  ``,
		filepath.Join(legacyDir, "terragrunt.hcl"):   ``,
		filepath.Join(dbDir, "terragrunt.hcl"):       ``,
		filepath.Join(cacheDir, "terragrunt.hcl"):    ``,
		filepath.Join(stackDir, "terragrunt.stack.hcl"): `
unit "test" {
  source = "."
  path   = "test"
}
`,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	tests := []struct {
		name          string
		filterQueries []string
		wantUnits     []string
		wantStacks    []string
	}{
		{
			name:          "path filter - apps directory",
			filterQueries: []string{"./apps/*"},
			wantUnits:     []string{frontendDir, backendDir, legacyDir},
			wantStacks:    []string{},
		},
		{
			name:          "path filter with wildcard",
			filterQueries: []string{"./libs/*"},
			wantUnits:     []string{dbDir, cacheDir},
			wantStacks:    []string{},
		},
		{
			name:          "name filter - specific component",
			filterQueries: []string{"frontend"},
			wantUnits:     []string{frontendDir},
			wantStacks:    []string{},
		},
		{
			name:          "name filter with equals",
			filterQueries: []string{"name=backend"},
			wantUnits:     []string{backendDir},
			wantStacks:    []string{},
		},
		{
			name:          "type filter - units only",
			filterQueries: []string{"type=unit"},
			wantUnits:     []string{frontendDir, backendDir, legacyDir, dbDir, cacheDir},
			wantStacks:    []string{},
		},
		{
			name:          "type filter - stacks only",
			filterQueries: []string{"type=stack"},
			wantUnits:     []string{},
			wantStacks:    []string{stackDir},
		},
		{
			name:          "negation filter - exclude legacy",
			filterQueries: []string{"!legacy"},
			wantUnits:     []string{frontendDir, backendDir, dbDir, cacheDir},
			wantStacks:    []string{stackDir},
		},
		{
			name:          "negation filter - exclude apps directory",
			filterQueries: []string{"!./apps/*"},
			wantUnits:     []string{dbDir, cacheDir},
			wantStacks:    []string{stackDir},
		},
		{
			name:          "intersection filter - apps and not legacy",
			filterQueries: []string{"./apps/* | !legacy"},
			wantUnits:     []string{frontendDir, backendDir},
			wantStacks:    []string{},
		},
		{
			name:          "multiple filters - union semantics",
			filterQueries: []string{"./apps/frontend", "./libs/db"},
			wantUnits:     []string{frontendDir, dbDir},
			wantStacks:    []string{},
		},
		{
			name:          "braced path filter",
			filterQueries: []string{"{./apps/*}"},
			wantUnits:     []string{frontendDir, backendDir, legacyDir},
			wantStacks:    []string{},
		},
		{
			name:          "absolute path filter",
			filterQueries: []string{stackDir},
			wantUnits:     []string{},
			wantStacks:    []string{stackDir},
		},
	}

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

			filters, err := filter.ParseFilterQueries(l, tt.filterQueries)
			require.NoError(t, err)

			d := discovery.NewDiscovery(tmpDir).
				WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
				WithFilters(filters)

			configs, err := d.Discover(ctx, l, venv.OSVenv(), opts)
			require.NoError(t, err)

			units := configs.Filter(component.UnitKind).Paths()
			stacks := configs.Filter(component.StackKind).Paths()

			assert.ElementsMatch(t, tt.wantUnits, units, "Units mismatch for test: %s", tt.name)
			assert.ElementsMatch(t, tt.wantStacks, stacks, "Stacks mismatch for test: %s", tt.name)
		})
	}
}

// TestDiscovery_FilterEdgeCases tests edge cases in filter handling.
func TestDiscovery_FilterEdgeCases(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Create a single component for edge case testing
	unitDir := filepath.Join(tmpDir, "unit #1")
	err := os.MkdirAll(unitDir, 0755)
	require.NoError(t, err)

	err = os.WriteFile(filepath.Join(unitDir, "terragrunt.hcl"), []byte(""), 0644)
	require.NoError(t, err)

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir: tmpDir,
	}

	ctx := t.Context()

	tests := []struct {
		name       string
		filters    []string
		wantUnits  []string
		wantStacks []string
	}{
		{
			name:       "filter with spaces in path",
			filters:    []string{"{unit #1}"},
			wantUnits:  []string{unitDir},
			wantStacks: []string{},
		},
		{
			name:       "filter with spaces in name",
			filters:    []string{"unit #1"},
			wantUnits:  []string{unitDir},
			wantStacks: []string{},
		},
		{
			name:       "non-matching filter",
			filters:    []string{"nonexistent"},
			wantUnits:  []string{},
			wantStacks: []string{},
		},
		{
			name:       "non-matching path filter",
			filters:    []string{"./nonexistent/*"},
			wantUnits:  []string{},
			wantStacks: []string{},
		},
		{
			name:       "negation of non-matching filter",
			filters:    []string{"!nonexistent"},
			wantUnits:  []string{unitDir},
			wantStacks: []string{},
		},
		{
			name:       "empty intersection",
			filters:    []string{"unit #1 | nonexistent"},
			wantUnits:  []string{},
			wantStacks: []string{},
		},
		{
			name:       "double negation",
			filters:    []string{"!!unit #1"},
			wantUnits:  []string{unitDir},
			wantStacks: []string{},
		},
	}

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

			filters, err := filter.ParseFilterQueries(l, tt.filters)
			require.NoError(t, err)

			d := discovery.NewDiscovery(tmpDir).
				WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
				WithFilters(filters)

			configs, err := d.Discover(ctx, l, venv.OSVenv(), opts)
			require.NoError(t, err)

			units := configs.Filter(component.UnitKind).Paths()
			stacks := configs.Filter(component.StackKind).Paths()

			assert.ElementsMatch(t, tt.wantUnits, units, "Units mismatch for test: %s", tt.name)
			assert.ElementsMatch(t, tt.wantStacks, stacks, "Stacks mismatch for test: %s", tt.name)
		})
	}
}

// TestDiscovery_FilterErrorHandling tests error handling for invalid filters.
func TestDiscovery_FilterErrorHandling(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	appDir := filepath.Join(tmpDir, "app")
	require.NoError(t, os.MkdirAll(appDir, 0755))
	require.NoError(t, os.WriteFile(filepath.Join(appDir, "terragrunt.hcl"), []byte(""), 0644))

	tests := []struct {
		name          string
		filterQueries []string
		errorExpected bool
	}{
		{
			name:          "invalid filter syntax",
			filterQueries: []string{"invalid[filter"},
			errorExpected: true,
		},
		{
			name:          "empty filter query",
			filterQueries: []string{""},
			errorExpected: true,
		},
		{
			name:          "malformed glob pattern",
			filterQueries: []string{"./apps/["},
			errorExpected: true,
		},
		{
			name:          "invalid attribute key",
			filterQueries: []string{"invalid=value"},
			errorExpected: true,
		},
		{
			name:          "invalid type value",
			filterQueries: []string{"type=invalid"},
			errorExpected: true,
		},
		{
			name:          "invalid external value",
			filterQueries: []string{"external=maybe"},
			errorExpected: true,
		},
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir: tmpDir,
	}

	ctx := t.Context()

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

			// Parse filter queries
			filters, err := filter.ParseFilterQueries(l, tt.filterQueries)

			// Some errors occur during parsing (like empty filter), others during evaluation
			if tt.errorExpected && err != nil {
				// Error occurred during parsing - this is expected for some test cases
				return
			}

			require.NoError(t, err) // Parsing should succeed for evaluation error test cases

			// Create discovery with filters
			d := discovery.NewDiscovery(tmpDir).
				WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
				WithFilters(filters)

			// Attempt discovery - errors should occur during evaluation
			_, err = d.Discover(ctx, l, venv.OSVenv(), opts)
			if tt.errorExpected {
				require.Error(t, err, "Expected error for filter: %v", tt.filterQueries)
			} else {
				require.NoError(t, err)
			}
		})
	}
}

// TestDiscovery_ExternalAttributeFilter tests external attribute filtering.
func TestDiscovery_ExternalAttributeFilter(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Create external component outside the working directory to make it truly external
	internalDir := filepath.Join(tmpDir, "internal")
	externalDir := filepath.Join(tmpDir, "external")

	appDir := filepath.Join(internalDir, "app")
	externalAppDir := filepath.Join(externalDir, "app")
	dbDir := filepath.Join(internalDir, "db")

	testDirs := []string{appDir, externalAppDir, dbDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}

dependency "external" {
	config_path = "../../external/app"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"):          ``,
		filepath.Join(externalAppDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     internalDir,
		RootWorkingDir: internalDir,
	}

	ctx := t.Context()

	t.Run("external=true filter", func(t *testing.T) {
		t.Parallel()

		filters, err := filter.ParseFilterQueries(l, []string{"{./**}... | external=true"})
		require.NoError(t, err)

		d := discovery.NewDiscovery(internalDir).
			WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: internalDir}).
			WithFilters(filters)

		components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
		require.NoError(t, err)

		units := components.Filter(component.UnitKind).Paths()
		assert.ElementsMatch(t, []string{externalAppDir}, units)
	})

	t.Run("external=false filter", func(t *testing.T) {
		t.Parallel()

		filters, err := filter.ParseFilterQueries(l, []string{"{./**}... | external=false"})
		require.NoError(t, err)

		d := discovery.NewDiscovery(internalDir).
			WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: internalDir}).
			WithFilters(filters)

		components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
		require.NoError(t, err)

		units := components.Filter(component.UnitKind).Paths()
		assert.ElementsMatch(t, []string{appDir, dbDir}, units)
	})
}

// TestDiscovery_DependentDiscovery_Standalone tests standalone dependent discovery (...vpc).
// This verifies that ...vpc finds all units that depend on vpc.
func TestDiscovery_DependentDiscovery_Standalone(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// To speed up this test, make the temporary directory a git repository.
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create dependency graph: app -> db -> vpc
	// Dependents of vpc: db, app (db depends on vpc, app depends on db which depends on vpc)
	vpcDir := filepath.Join(tmpDir, "vpc")
	dbDir := filepath.Join(tmpDir, "db")
	appDir := filepath.Join(tmpDir, "app")

	testDirs := []string{vpcDir, dbDir, appDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	// Use ...vpc to find all dependents of vpc
	filters, err := filter.ParseFilterQueries(l, []string{"...vpc"})
	require.NoError(t, err)

	d := discovery.NewDiscovery(tmpDir).
		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
		WithFilters(filters)

	components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
	require.NoError(t, err)

	// Should include vpc (target) and db (direct dependent) and app (transitive dependent)
	units := components.Filter(component.UnitKind).Paths()
	assert.ElementsMatch(t,
		[]string{vpcDir, dbDir, appDir}, units,
		"...vpc should find vpc and all its dependents (db, app)")
}

// TestDiscovery_DependentDiscovery_ExcludeTarget tests dependent discovery with target exclusion (^...vpc).
func TestDiscovery_DependentDiscovery_ExcludeTarget(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// To speed up this test, make the temporary directory a git repository.
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create dependency graph: app -> vpc
	vpcDir := filepath.Join(tmpDir, "vpc")
	appDir := filepath.Join(tmpDir, "app")

	testDirs := []string{vpcDir, appDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	// Use ...^vpc to find dependents but exclude the target (vpc)
	filters, err := filter.ParseFilterQueries(l, []string{"...^vpc"})
	require.NoError(t, err)

	d := discovery.NewDiscovery(tmpDir).
		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
		WithFilters(filters)

	components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
	require.NoError(t, err)

	// Should include only app (dependent), not vpc (target is excluded)
	units := components.Filter(component.UnitKind).Paths()
	assert.ElementsMatch(
		t,
		[]string{appDir},
		units,
		"...^vpc should find only dependents, not the target",
	)
	assert.NotContains(t, units, vpcDir, "vpc should be excluded as the target")
}

// TestDiscovery_DependencyDiscovery_ExcludeTarget tests dependency discovery with target exclusion (^app...).
// This is the inverse of TestDiscovery_DependentDiscovery_ExcludeTarget - it tests excluding the target
// from the dependency direction rather than the dependent direction.
func TestDiscovery_DependencyDiscovery_ExcludeTarget(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// To speed up this test, make the temporary directory a git repository.
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create dependency graph: app -> db -> vpc
	vpcDir := filepath.Join(tmpDir, "vpc")
	dbDir := filepath.Join(tmpDir, "db")
	appDir := filepath.Join(tmpDir, "app")

	testDirs := []string{vpcDir, dbDir, appDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	// Use ^app... to find dependencies but exclude the target (app)
	filters, err := filter.ParseFilterQueries(l, []string{"^app..."})
	require.NoError(t, err)

	d := discovery.NewDiscovery(tmpDir).
		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
		WithFilters(filters)

	components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
	require.NoError(t, err)

	// Should include only db and vpc (dependencies), not app (target is excluded)
	units := components.Filter(component.UnitKind).Paths()
	assert.ElementsMatch(
		t,
		[]string{dbDir, vpcDir},
		units,
		"^app... should find only dependencies, not the target",
	)
	assert.NotContains(t, units, appDir, "app should be excluded as the target")
}

// TestDiscovery_DependentDiscovery_Bidirectional tests bidirectional discovery (...db...).
func TestDiscovery_DependentDiscovery_Bidirectional(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// To speed up this test, make the temporary directory a git repository.
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create dependency graph: app -> db -> vpc
	vpcDir := filepath.Join(tmpDir, "vpc")
	dbDir := filepath.Join(tmpDir, "db")
	appDir := filepath.Join(tmpDir, "app")

	testDirs := []string{vpcDir, dbDir, appDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	// Use ...db... to find both dependencies and dependents of db
	filters, err := filter.ParseFilterQueries(l, []string{"...db..."})
	require.NoError(t, err)

	d := discovery.NewDiscovery(tmpDir).
		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
		WithFilters(filters)

	components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
	require.NoError(t, err)

	// Should include: app (dependent), db (target), vpc (dependency)
	units := components.Filter(component.UnitKind).Paths()
	assert.ElementsMatch(t,
		[]string{appDir, dbDir, vpcDir}, units,
		"...db... should find dependents, target, and dependencies")
}

// TestDiscovery_DependentDiscovery_OutsideWorkingDir tests that dependent discovery
// finds dependents outside the working directory but within the git root.
// This validates the upward filesystem walking feature.
func TestDiscovery_DependentDiscovery_OutsideWorkingDir(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Initialize git repository at the root
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create structure:
	// /repo (git root)
	// ├── app/
	// │   └── vpc/           <- target
	// │       └── terragrunt.hcl
	// └── other-app/
	//     └── consumer/      <- depends on vpc (outside working dir)
	//         └── terragrunt.hcl
	appDir := filepath.Join(tmpDir, "app")
	vpcDir := filepath.Join(appDir, "vpc")
	otherAppDir := filepath.Join(tmpDir, "other-app")
	consumerDir := filepath.Join(otherAppDir, "consumer")

	testDirs := []string{vpcDir, consumerDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
		filepath.Join(consumerDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../../app/vpc"
}
`,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()

	// Set working directory to app/ subdirectory, NOT the git root
	opts := &options.TerragruntOptions{
		WorkingDir:     appDir,
		RootWorkingDir: appDir,
	}

	ctx := t.Context()

	// Use ...vpc to find dependents of vpc
	// consumer is outside the working directory (app/) but should be found
	// via upward filesystem walking bounded by git root
	filters, err := filter.ParseFilterQueries(l, []string{"...vpc"})
	require.NoError(t, err)

	d := discovery.NewDiscovery(appDir).
		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: appDir}).
		WithFilters(filters)

	components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
	require.NoError(t, err)

	// Should include vpc (target) and consumer (dependent outside working dir)
	units := components.Filter(component.UnitKind).Paths()
	assert.Contains(t, units, vpcDir, "vpc should be discovered as the target")
	assert.Contains(
		t,
		units,
		consumerDir,
		"consumer should be discovered even though it's outside working dir",
	)
	assert.ElementsMatch(t,
		[]string{vpcDir, consumerDir}, units,
		"...vpc should find vpc and consumer (outside working dir)")
}

// TestDiscovery_DependentDiscovery_OutsideWorkingDir_MultipleLevels tests that dependent discovery
// finds dependents at multiple levels outside the working directory.
func TestDiscovery_DependentDiscovery_OutsideWorkingDir_MultipleLevels(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// Initialize git repository at the root
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create structure:
	// /repo (git root)
	// ├── infra/
	// │   └── vpc/              <- target
	// │       └── terragrunt.hcl
	// ├── services/
	// │   └── api/              <- depends on vpc (sibling directory)
	// │       └── terragrunt.hcl
	// └── apps/
	//     └── frontend/         <- depends on api (transitive dependent of vpc)
	//         └── terragrunt.hcl
	infraDir := filepath.Join(tmpDir, "infra")
	vpcDir := filepath.Join(infraDir, "vpc")
	servicesDir := filepath.Join(tmpDir, "services")
	apiDir := filepath.Join(servicesDir, "api")
	appsDir := filepath.Join(tmpDir, "apps")
	frontendDir := filepath.Join(appsDir, "frontend")

	testDirs := []string{vpcDir, apiDir, frontendDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(vpcDir, "terragrunt.hcl"): ``,
		filepath.Join(apiDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../../infra/vpc"
}
`,
		filepath.Join(frontendDir, "terragrunt.hcl"): `
dependency "api" {
	config_path = "../../services/api"
}
`,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()

	// Set working directory to infra/ subdirectory
	opts := &options.TerragruntOptions{
		WorkingDir:     infraDir,
		RootWorkingDir: infraDir,
	}

	ctx := t.Context()

	// Use ...vpc to find dependents of vpc
	// api and frontend are both outside the working directory (infra/)
	filters, err := filter.ParseFilterQueries(l, []string{"...vpc"})
	require.NoError(t, err)

	d := discovery.NewDiscovery(infraDir).
		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: infraDir}).
		WithFilters(filters)

	components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
	require.NoError(t, err)

	// Should include vpc (target), api (direct dependent), and frontend (transitive dependent)
	units := components.Filter(component.UnitKind).Paths()
	assert.Contains(t, units, vpcDir, "vpc should be discovered as the target")
	assert.Contains(
		t,
		units,
		apiDir,
		"api should be discovered (direct dependent outside working dir)",
	)
	assert.Contains(
		t,
		units,
		frontendDir,
		"frontend should be discovered (transitive dependent outside working dir)",
	)
	assert.ElementsMatch(t, []string{vpcDir, apiDir, frontendDir}, units)
}

// TestDiscovery_DependentDiscovery_DirectDependentOnly tests that dependent discovery
// finds direct dependents correctly.
func TestDiscovery_DependentDiscovery_DirectDependentOnly(t *testing.T) {
	t.Parallel()

	tmpDir := helpers.TmpDirWOSymlinks(t)

	// To speed up this test, make the temporary directory a git repository.
	runner, err := git.NewGitRunner(vexec.NewOSExec())
	require.NoError(t, err)

	runner = runner.WithWorkDir(tmpDir)

	err = runner.Init(t.Context())
	require.NoError(t, err)

	// Create dependency graph: api -> db, web -> db
	// Both api and web directly depend on db
	dbDir := filepath.Join(tmpDir, "db")
	apiDir := filepath.Join(tmpDir, "api")
	webDir := filepath.Join(tmpDir, "web")
	unrelatedDir := filepath.Join(tmpDir, "unrelated")

	testDirs := []string{dbDir, apiDir, webDir, unrelatedDir}
	for _, dir := range testDirs {
		err := os.MkdirAll(dir, 0755)
		require.NoError(t, err)
	}

	testFiles := map[string]string{
		filepath.Join(apiDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
		filepath.Join(webDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
		filepath.Join(dbDir, "terragrunt.hcl"):        ``,
		filepath.Join(unrelatedDir, "terragrunt.hcl"): ``,
	}

	for path, content := range testFiles {
		err := os.WriteFile(path, []byte(content), 0644)
		require.NoError(t, err)
	}

	l := logger.CreateLogger()
	opts := &options.TerragruntOptions{
		WorkingDir:     tmpDir,
		RootWorkingDir: tmpDir,
	}

	ctx := t.Context()

	// Use ...db to find all dependents of db
	filters, err := filter.ParseFilterQueries(l, []string{"...db"})
	require.NoError(t, err)

	d := discovery.NewDiscovery(tmpDir).
		WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
		WithFilters(filters)

	components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
	require.NoError(t, err)

	// Should include db (target), api (dependent), web (dependent)
	// Should NOT include unrelated
	units := components.Filter(component.UnitKind).Paths()
	assert.ElementsMatch(
		t,
		[]string{dbDir, apiDir, webDir},
		units,
		"...db should find db and all its dependents",
	)
	assert.NotContains(t, units, unrelatedDir, "unrelated should not be included")
}

// TestDiscovery_NegatedGraphFilters tests that negated graph expressions correctly
// trigger the graph discovery phase and produce the expected results.
//
// Example with dependency chain: app -> db -> vpc (app depends on db, db depends on vpc)
//   - `!...db` should exclude db and everything that depends on db (db and app)
//   - `!db...` should exclude db and everything db depends on (db and vpc)
func TestDiscovery_NegatedGraphFilters(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name          string
		filters       []string
		expectedPaths []string
		excludedPaths []string
	}{
		{
			name:          "negated dependent filter excludes target and dependents",
			filters:       []string{"!...db"},
			expectedPaths: []string{"vpc"},
			excludedPaths: []string{"db", "app"},
		},
		{
			name:          "negated dependency filter excludes target and dependencies",
			filters:       []string{"!db..."},
			expectedPaths: []string{"app"},
			excludedPaths: []string{"db", "vpc"},
		},
		{
			name:          "positive filter with negated graph filter",
			filters:       []string{"app...", "!vpc"},
			expectedPaths: []string{"app", "db"},
			excludedPaths: []string{"vpc"},
		},
		{
			name:          "negated bidirectional filter",
			filters:       []string{"!...db..."},
			expectedPaths: []string{},
			excludedPaths: []string{"app", "db", "vpc"},
		},
	}

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

			tmpDir := helpers.TmpDirWOSymlinks(t)

			runner, err := git.NewGitRunner(vexec.NewOSExec())
			require.NoError(t, err)

			runner = runner.WithWorkDir(tmpDir)

			err = runner.Init(t.Context())
			require.NoError(t, err)

			vpcDir := filepath.Join(tmpDir, "vpc")
			dbDir := filepath.Join(tmpDir, "db")
			appDir := filepath.Join(tmpDir, "app")

			testDirs := []string{vpcDir, dbDir, appDir}
			for _, dir := range testDirs {
				err := os.MkdirAll(dir, 0755)
				require.NoError(t, err)
			}

			testFiles := map[string]string{
				filepath.Join(appDir, "terragrunt.hcl"): `
dependency "db" {
	config_path = "../db"
}
`,
				filepath.Join(dbDir, "terragrunt.hcl"): `
dependency "vpc" {
	config_path = "../vpc"
}
`,
				filepath.Join(vpcDir, "terragrunt.hcl"): ``,
			}

			for path, content := range testFiles {
				err := os.WriteFile(path, []byte(content), 0644)
				require.NoError(t, err)
			}

			l := logger.CreateLogger()
			opts := &options.TerragruntOptions{
				WorkingDir:     tmpDir,
				RootWorkingDir: tmpDir,
			}

			ctx := t.Context()

			filters, err := filter.ParseFilterQueries(l, tt.filters)
			require.NoError(t, err)

			d := discovery.NewDiscovery(tmpDir).
				WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
				WithFilters(filters)

			components, err := d.Discover(ctx, l, venv.OSVenv(), opts)
			require.NoError(t, err)

			paths := components.Filter(component.UnitKind).Paths()

			for _, expected := range tt.expectedPaths {
				expectedPath := filepath.Join(tmpDir, expected)
				assert.Contains(
					t,
					paths,
					expectedPath,
					"expected %s to be included for filters %v",
					expected,
					tt.filters,
				)
			}

			for _, excluded := range tt.excludedPaths {
				excludedPath := filepath.Join(tmpDir, excluded)

				assert.NotContains(
					t,
					paths,
					excludedPath,
					"expected %s to be excluded for filters %v",
					excluded,
					tt.filters,
				)
			}
		})
	}
}
