package filter_test

import (
	"testing"

	"github.com/gruntwork-io/terragrunt/internal/component"
	"github.com/gruntwork-io/terragrunt/internal/filter"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

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

	tests := []struct {
		name              string
		filterStr         string
		expectGraphFilter bool
		expectDependents  bool
	}{
		{
			name:              "negated dependent filter triggers HasGraphFilters and HasDependentFilters",
			filterStr:         "!...db",
			expectGraphFilter: true,
			expectDependents:  true,
		},
		{
			name:              "negated dependency filter triggers HasGraphFilters",
			filterStr:         "!db...",
			expectGraphFilter: true,
			expectDependents:  false,
		},
		{
			name:              "non-negated dependent filter",
			filterStr:         "...db",
			expectGraphFilter: true,
			expectDependents:  true,
		},
		{
			name:              "non-negated dependency filter",
			filterStr:         "db...",
			expectGraphFilter: true,
			expectDependents:  false,
		},
		{
			name:              "simple path filter",
			filterStr:         "./foo",
			expectGraphFilter: false,
			expectDependents:  false,
		},
		{
			name:              "negated path filter",
			filterStr:         "!./foo",
			expectGraphFilter: false,
			expectDependents:  false,
		},
	}

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

			f, err := filter.Parse(tt.filterStr)
			require.NoError(t, err, "failed to parse filter")

			classifier := filter.NewClassifier(filter.Filters{f})

			assert.Equal(t, tt.expectGraphFilter, classifier.HasGraphFilters(),
				"HasGraphFilters() mismatch for filter %q", tt.filterStr)
			assert.Equal(t, tt.expectDependents, classifier.HasDependentFilters(),
				"HasDependentFilters() mismatch for filter %q", tt.filterStr)
		})
	}
}

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

	tests := []struct {
		name            string
		filterStr       string
		expectIsNegated []bool
	}{
		{
			name:            "single negated dependent filter",
			filterStr:       "!...db",
			expectIsNegated: []bool{true},
		},
		{
			name:            "single negated dependency filter",
			filterStr:       "!db...",
			expectIsNegated: []bool{true},
		},
		{
			name:            "non-negated dependent filter",
			filterStr:       "...db",
			expectIsNegated: []bool{false},
		},
		{
			name:            "non-negated dependency filter",
			filterStr:       "db...",
			expectIsNegated: []bool{false},
		},
	}

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

			f, err := filter.Parse(tt.filterStr)
			require.NoError(t, err, "failed to parse filter")

			classifier := filter.NewClassifier(filter.Filters{f})

			graphExprs := classifier.GraphExpressions()
			require.Len(t, graphExprs, len(tt.expectIsNegated),
				"unexpected number of graph expressions")

			for i, expected := range tt.expectIsNegated {
				assert.Equal(t, expected, graphExprs[i].IsNegated,
					"IsNegated mismatch for graph expression %d", i)
			}
		})
	}
}

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

	fooFilter, err := filter.Parse("...foo")
	require.NoError(t, err)

	barFilter, err := filter.Parse("!...bar")
	require.NoError(t, err)

	classifier := filter.NewClassifier(filter.Filters{fooFilter, barFilter})

	assert.True(t, classifier.HasGraphFilters(), "should have graph filters")
	assert.True(t, classifier.HasDependentFilters(), "should have dependent filters")

	graphExprs := classifier.GraphExpressions()
	require.Len(t, graphExprs, 2, "should have 2 graph expressions")

	// First one is positive (...foo)
	assert.False(t, graphExprs[0].IsNegated, "first graph expression should not be negated")
	assert.Equal(t, 0, graphExprs[0].Index, "first graph expression should have index 0")
	assert.True(t, graphExprs[0].IncludeDependents, "first should include dependents")

	// Second one is negated (!...bar)
	assert.True(t, graphExprs[1].IsNegated, "second graph expression should be negated")
	assert.Equal(t, 1, graphExprs[1].Index, "second graph expression should have index 1")
	assert.True(t, graphExprs[1].IncludeDependents, "second should include dependents")
}

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

	target, err := filter.NewPathFilter("./db")
	require.NoError(t, err)

	graphExpr := filter.NewGraphExpression(target).WithDependencies()
	negatedExpr := filter.NewPrefixExpression("!", graphExpr)

	f := filter.NewFilter(negatedExpr, "!./db...")

	classifier := filter.NewClassifier(filter.Filters{f})

	assert.True(t, classifier.HasGraphFilters(), "should have graph filters")
	assert.False(t, classifier.HasDependentFilters(), "should not have dependent filters (db... is dependencies)")

	graphExprs := classifier.GraphExpressions()
	require.Len(t, graphExprs, 1)
	assert.True(t, graphExprs[0].IsNegated)
	assert.False(t, graphExprs[0].IncludeDependents)
	assert.True(t, graphExprs[0].IncludeDependencies)
}

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

	target, err := filter.NewPathFilter("./db")
	require.NoError(t, err)

	graphExpr := filter.NewGraphExpression(target).WithDependents().WithDependencies()
	negatedExpr := filter.NewPrefixExpression("!", graphExpr)

	f := filter.NewFilter(negatedExpr, "!...db...")

	classifier := filter.NewClassifier(filter.Filters{f})

	assert.True(t, classifier.HasGraphFilters(), "should have graph filters")
	assert.True(t, classifier.HasDependentFilters(), "should have dependent filters")

	graphExprs := classifier.GraphExpressions()
	require.Len(t, graphExprs, 1)
	assert.True(t, graphExprs[0].IsNegated)
	assert.True(t, graphExprs[0].IncludeDependencies)
	assert.True(t, graphExprs[0].IncludeDependents)
}

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

	tests := []struct {
		name               string
		componentPath      string
		componentRef       string
		componentKind      component.Kind
		filterStrs         []string
		expectedStatus     filter.ClassificationStatus
		expectedReason     filter.CandidacyReason
		expectedIdx        int
		parseDataAvailable bool
		expectIdxGteZero   bool
	}{
		{
			name:           "no_filters_returns_ready_for_filter",
			filterStrs:     nil,
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "only_matches_negation_returns_excluded",
			filterStrs:     []string{"!./apps/app1"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusExcluded,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:             "matches_negated_graph_expression_target_returns_candidate",
			filterStrs:       []string{"!...db"},
			componentPath:    "./libs/db",
			expectedStatus:   filter.StatusCandidate,
			expectedReason:   filter.CandidacyReasonGraphTarget,
			expectIdxGteZero: true,
		},
		{
			name:               "parse_expressions_without_parse_data_returns_candidate",
			filterStrs:         []string{"reading=config/*.hcl"},
			componentPath:      "./apps/app1",
			parseDataAvailable: false,
			expectedStatus:     filter.StatusCandidate,
			expectedReason:     filter.CandidacyReasonRequiresParse,
			expectedIdx:        -1,
		},
		{
			name:           "matches_path_expression_returns_ready_for_filter",
			filterStrs:     []string{"./apps/*"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "matches_git_expression_returns_ready_for_filter",
			filterStrs:     []string{"[main...feature]"},
			componentPath:  "./apps/app1",
			componentRef:   "main",
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "matches_graph_expression_target_returns_candidate",
			filterStrs:     []string{"./libs/db..."},
			componentPath:  "./libs/db",
			expectedStatus: filter.StatusCandidate,
			expectedReason: filter.CandidacyReasonGraphTarget,
			expectedIdx:    0,
		},
		{
			name:               "dependent_filters_without_parse_data_returns_candidate",
			filterStrs:         []string{"...vpc"},
			componentPath:      "./apps/app1",
			parseDataAvailable: false,
			expectedStatus:     filter.StatusCandidate,
			expectedReason:     filter.CandidacyReasonPotentialDependent,
			expectedIdx:        -1,
		},
		{
			name:           "negation_exists_component_not_matching_returns_ready_for_filter",
			filterStrs:     []string{"!./libs/db"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "positive_filters_no_match_returns_excluded",
			filterStrs:     []string{"./libs/*"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusExcluded,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "positive_path_match_with_negation_returns_excluded",
			filterStrs:     []string{"!./apps/app1", "./apps/*"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusExcluded,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "multiple_graph_expressions_returns_correct_index",
			filterStrs:     []string{"./libs/api...", "./libs/db..."},
			componentPath:  "./libs/db",
			expectedStatus: filter.StatusCandidate,
			expectedReason: filter.CandidacyReasonGraphTarget,
			expectedIdx:    1,
		},
		{
			name:           "graph_expression_index_with_preceding_non_graph_filter",
			filterStrs:     []string{"./libs/api", "./libs/db..."},
			componentPath:  "./libs/db",
			expectedStatus: filter.StatusCandidate,
			expectedReason: filter.CandidacyReasonGraphTarget,
			expectedIdx:    1,
		},
		{
			name:           "graph_expression_index_with_multiple_preceding_non_graph_filters",
			filterStrs:     []string{"./libs/api", "!./libs/cache", "./libs/db..."},
			componentPath:  "./libs/db",
			expectedStatus: filter.StatusCandidate,
			expectedReason: filter.CandidacyReasonGraphTarget,
			expectedIdx:    2,
		},
		{
			name:               "parse_expressions_with_parse_data_evaluates_normally",
			filterStrs:         []string{"reading=config/*.hcl"},
			componentPath:      "./apps/app1",
			parseDataAvailable: true,
			expectedStatus:     filter.StatusExcluded,
			expectedReason:     filter.CandidacyReasonNone,
			expectedIdx:        -1,
		},
		{
			name:               "dependent_filters_with_parse_data_no_potential_dependent",
			filterStrs:         []string{"...vpc"},
			componentPath:      "./apps/app1",
			parseDataAvailable: true,
			expectedStatus:     filter.StatusExcluded,
			expectedReason:     filter.CandidacyReasonNone,
			expectedIdx:        -1,
		},
		{
			name:           "git_expression_component_without_ref_no_match",
			filterStrs:     []string{"[main...feature]"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusExcluded,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "name_attribute_filter_matches",
			filterStrs:     []string{"name=app1"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:               "filesystem_match_requires_parse_when_parse_exprs_exist",
			filterStrs:         []string{"./apps/*", "reading=config/*.hcl"},
			componentPath:      "./apps/app1",
			parseDataAvailable: false,
			expectedStatus:     filter.StatusCandidate,
			expectedReason:     filter.CandidacyReasonRequiresParse,
			expectedIdx:        -1,
		},
		{
			name:               "no_filesystem_match_still_requires_parse",
			filterStrs:         []string{"./apps/*", "reading=config/*.hcl"},
			componentPath:      "./libs/db",
			parseDataAvailable: false,
			expectedStatus:     filter.StatusCandidate,
			expectedReason:     filter.CandidacyReasonRequiresParse,
			expectedIdx:        -1,
		},
		{
			name:               "negation_excluded_not_rescued_by_parse_exprs",
			filterStrs:         []string{"!./apps/app1", "reading=config/*.hcl"},
			componentPath:      "./apps/app1",
			parseDataAvailable: false,
			expectedStatus:     filter.StatusExcluded,
			expectedReason:     filter.CandidacyReasonNone,
			expectedIdx:        -1,
		},
		{
			name:           "attribute_filter_does_not_discount_negation",
			filterStrs:     []string{"!./apps/app1", "type=unit"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusExcluded,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "git_expression_discounts_negation_returns_ready_for_filter",
			filterStrs:     []string{"!./apps/app1", "[main...feature]"},
			componentPath:  "./apps/app1",
			componentRef:   "main",
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:             "graph_expression_discounts_negation",
			filterStrs:       []string{"!./libs/db", "./libs/db..."},
			componentPath:    "./libs/db",
			expectedStatus:   filter.StatusCandidate,
			expectedReason:   filter.CandidacyReasonGraphTarget,
			expectIdxGteZero: true,
		},
		{
			name:           "attribute_filter_no_match_returns_excluded",
			filterStrs:     []string{"type=stack"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusExcluded,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "attribute_filter_matches_stack",
			filterStrs:     []string{"type=stack"},
			componentPath:  "./stacks/my-stack",
			componentKind:  component.StackKind,
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "negation_wins_over_both_attribute_and_path",
			filterStrs:     []string{"!./apps/app1", "type=unit", "./apps/*"},
			componentPath:  "./apps/app1",
			expectedStatus: filter.StatusExcluded,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "positive_filter_with_unrelated_negation_excludes_non_matching",
			filterStrs:     []string{"./apps/app1", "!./libs/db"},
			componentPath:  "./apps/app2",
			expectedStatus: filter.StatusExcluded,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:           "compound_negation_filter_does_not_force_exclude_by_default",
			filterStrs:     []string{"!./_stacks | type=stack"},
			componentPath:  "./unit1",
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
		{
			name:             "git_graph_target_with_intersected_attribute_returns_graph_target_candidate",
			filterStrs:       []string{"...[main...feature] | type=unit"},
			componentPath:    "./libs/db",
			componentRef:     "main",
			expectedStatus:   filter.StatusCandidate,
			expectedReason:   filter.CandidacyReasonGraphTarget,
			expectIdxGteZero: true,
		},
		{
			name:             "path_graph_target_with_intersected_attribute_returns_graph_target_candidate",
			filterStrs:       []string{"db... | type=unit"},
			componentPath:    "./libs/db",
			expectedStatus:   filter.StatusCandidate,
			expectedReason:   filter.CandidacyReasonGraphTarget,
			expectIdxGteZero: true,
		},
		{
			name:           "attribute_filter_without_graph_expression_stays_ready_for_filter",
			filterStrs:     []string{"type=unit"},
			componentPath:  "./libs/db",
			expectedStatus: filter.StatusReadyForFilter,
			expectedReason: filter.CandidacyReasonNone,
			expectedIdx:    -1,
		},
	}

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

			var filters filter.Filters

			for _, filterStr := range tt.filterStrs {
				f, err := filter.Parse(filterStr)
				require.NoError(t, err)

				filters = append(filters, f)
			}

			classifier := filter.NewClassifier(filters)

			var comp component.Component

			switch {
			case tt.componentKind == component.StackKind:
				comp = newTestStack(tt.componentPath)
			case tt.componentRef != "":
				comp = newTestComponentWithRef(tt.componentPath, tt.componentRef)
			default:
				comp = newTestComponent(tt.componentPath)
			}

			status, reason, idx := classifier.Classify(comp, filter.ClassificationContext{
				ParseDataAvailable: tt.parseDataAvailable,
			})

			assert.Equal(t, tt.expectedStatus, status)
			assert.Equal(t, tt.expectedReason, reason)

			if tt.expectIdxGteZero {
				assert.GreaterOrEqual(t, idx, 0)
			} else {
				assert.Equal(t, tt.expectedIdx, idx)
			}
		})
	}
}

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

	tests := []struct {
		expected string
		status   filter.ClassificationStatus
	}{
		{status: filter.StatusReadyForFilter, expected: "discovered"},
		{status: filter.StatusCandidate, expected: "candidate"},
		{status: filter.StatusExcluded, expected: "excluded"},
		{status: filter.ClassificationStatus(99), expected: "unknown"},
	}

	for _, tt := range tests {
		t.Run(tt.expected, func(t *testing.T) {
			t.Parallel()
			assert.Equal(t, tt.expected, tt.status.String())
		})
	}
}

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

	tests := []struct {
		expected string
		reason   filter.CandidacyReason
	}{
		{reason: filter.CandidacyReasonNone, expected: "none"},
		{reason: filter.CandidacyReasonGraphTarget, expected: "graph-target"},
		{reason: filter.CandidacyReasonRequiresParse, expected: "requires-parse"},
		{reason: filter.CandidacyReasonPotentialDependent, expected: "potential-dependent"},
		{reason: filter.CandidacyReason(99), expected: "unknown"},
	}

	for _, tt := range tests {
		t.Run(tt.expected, func(t *testing.T) {
			t.Parallel()
			assert.Equal(t, tt.expected, tt.reason.String())
		})
	}
}

func newTestComponent(path string) component.Component {
	return component.NewUnit(path).WithDiscoveryContext(&component.DiscoveryContext{
		WorkingDir: ".",
	})
}

func newTestStack(path string) component.Component {
	return component.NewStack(path).WithDiscoveryContext(&component.DiscoveryContext{
		WorkingDir: ".",
	})
}

func newTestComponentWithRef(path, ref string) component.Component {
	return component.NewUnit(path).WithDiscoveryContext(&component.DiscoveryContext{
		WorkingDir: ".",
		Ref:        ref,
	})
}
