package tflint

import (
	"path/filepath"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	hcl "github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	sdk "github.com/terraform-linters/tflint-plugin-sdk/tflint"
	"github.com/terraform-linters/tflint/terraform"
	"github.com/terraform-linters/tflint/terraform/addrs"
	"github.com/zclconf/go-cty/cty"
)

func Test_NewModuleRunners_noModules(t *testing.T) {
	withinFixtureDir(t, "no_modules", func() {
		runner := testRunnerWithOsFs(t, moduleConfig())

		runners, err := NewModuleRunners(runner)
		if err != nil {
			t.Fatalf("Unexpected error occurred: %s", err)
		}

		if len(runners) > 0 {
			t.Fatal("`NewModuleRunners` must not return runners when there is no module")
		}
	})
}

func Test_NewModuleRunners_nestedModules(t *testing.T) {
	withinFixtureDir(t, "nested_modules", func() {
		runner := testRunnerWithOsFs(t, moduleConfig())

		runners, err := NewModuleRunners(runner)
		if err != nil {
			t.Fatalf("Unexpected error occurred: %s", err)
		}

		if len(runners) != 2 {
			t.Fatal("This function must return 2 runners because the config has 2 modules")
		}

		expectedVars := map[string]map[string]*terraform.Variable{
			"module.root": {
				"override": {
					Name:        "override",
					Default:     cty.StringVal("foo"),
					Type:        cty.DynamicPseudoType,
					Nullable:    true,
					ParsingMode: terraform.VariableParseLiteral,
					DeclRange: hcl.Range{
						Filename: filepath.Join("module", "module.tf"),
						Start:    hcl.Pos{Line: 1, Column: 1},
						End:      hcl.Pos{Line: 1, Column: 20},
					},
				},
				"no_default": {
					Name:        "no_default",
					Default:     cty.StringVal("bar"),
					Type:        cty.DynamicPseudoType,
					Nullable:    true,
					ParsingMode: terraform.VariableParseLiteral,
					DeclRange: hcl.Range{
						Filename: filepath.Join("module", "module.tf"),
						Start:    hcl.Pos{Line: 4, Column: 1},
						End:      hcl.Pos{Line: 4, Column: 22},
					},
				},
				"unknown": {
					Name:        "unknown",
					Default:     cty.UnknownVal(cty.DynamicPseudoType),
					Type:        cty.DynamicPseudoType,
					Nullable:    true,
					ParsingMode: terraform.VariableParseLiteral,
					DeclRange: hcl.Range{
						Filename: filepath.Join("module", "module.tf"),
						Start:    hcl.Pos{Line: 5, Column: 1},
						End:      hcl.Pos{Line: 5, Column: 19},
					},
				},
			},
			"module.root.module.test": {
				"override": {
					Name:        "override",
					Default:     cty.StringVal("foo"),
					Type:        cty.DynamicPseudoType,
					Nullable:    true,
					ParsingMode: terraform.VariableParseLiteral,
					DeclRange: hcl.Range{
						Filename: filepath.Join("module", "module1", "resource.tf"),
						Start:    hcl.Pos{Line: 1, Column: 1},
						End:      hcl.Pos{Line: 1, Column: 20},
					},
				},
				"no_default": {
					Name:        "no_default",
					Default:     cty.StringVal("bar"),
					Type:        cty.DynamicPseudoType,
					Nullable:    true,
					ParsingMode: terraform.VariableParseLiteral,
					DeclRange: hcl.Range{
						Filename: filepath.Join("module", "module1", "resource.tf"),
						Start:    hcl.Pos{Line: 4, Column: 1},
						End:      hcl.Pos{Line: 4, Column: 22},
					},
				},
				"unknown": {
					Name:        "unknown",
					Default:     cty.UnknownVal(cty.DynamicPseudoType),
					Type:        cty.DynamicPseudoType,
					Nullable:    true,
					ParsingMode: terraform.VariableParseLiteral,
					DeclRange: hcl.Range{
						Filename: filepath.Join("module", "module1", "resource.tf"),
						Start:    hcl.Pos{Line: 5, Column: 1},
						End:      hcl.Pos{Line: 5, Column: 19},
					},
				},
			},
		}

		for _, runner := range runners {
			expected, exists := expectedVars[runner.TFConfig.Path.String()]
			if !exists {
				t.Fatalf("`%s` is not found in module runners", runner.TFConfig.Path)
			}

			opts := []cmp.Option{
				cmpopts.IgnoreUnexported(cty.Type{}, cty.Value{}),
				cmpopts.IgnoreFields(hcl.Pos{}, "Byte"),
			}
			if !cmp.Equal(expected, runner.TFConfig.Module.Variables, opts...) {
				t.Fatalf("`%s` module variables are unmatched: Diff=%s", runner.TFConfig.Path, cmp.Diff(expected, runner.TFConfig.Module.Variables, opts...))
			}
		}
	})
}

func Test_NewModuleRunners_withCountForEach(t *testing.T) {
	withinFixtureDir(t, "module_with_count_for_each", func() {
		runner := testRunnerWithOsFs(t, moduleConfig())

		runners, err := NewModuleRunners(runner)
		if err != nil {
			t.Fatalf("Unexpected error occurred: %s", err)
		}

		if len(runners) != 5 {
			t.Fatalf("This function must return 5 runners, but returned %d", len(runners))
		}

		moduleNames := make([]string, 5)
		for idx, r := range runners {
			moduleNames[idx] = r.TFConfig.Path.String()
		}
		expected := []string{
			"module.count_is_one",
			"module.count_is_two",
			"module.count_is_two",
			"module.for_each_is_not_empty",
			"module.for_each_is_not_empty",
		}
		less := func(a, b string) bool { return a < b }
		if diff := cmp.Diff(moduleNames, expected, cmpopts.SortSlices(less)); diff != "" {
			t.Fatal(diff)
		}
	})
}

func Test_NewModuleRunners_modVars(t *testing.T) {
	withinFixtureDir(t, "nested_module_vars", func() {
		runner := testRunnerWithOsFs(t, moduleConfig())

		runners, err := NewModuleRunners(runner)
		if err != nil {
			t.Fatalf("Unexpected error occurred: %s", err)
		}

		if len(runners) != 2 {
			t.Fatal("This function must return 2 runners because the config has 2 modules")
		}

		child := runners[0]
		if child.TFConfig.Path.String() != "module.module1" {
			t.Fatalf(`Expected child config path name is "module.module1", but got "%s"`, child.TFConfig.Path.String())
		}

		expected := map[string]*moduleVariable{
			"foo": {
				Root: true,
				DeclRange: hcl.Range{
					Filename: "main.tf",
					Start:    hcl.Pos{Line: 4, Column: 9},
					End:      hcl.Pos{Line: 4, Column: 14},
				},
			},
			"bar": {
				Root: true,
				DeclRange: hcl.Range{
					Filename: "main.tf",
					Start:    hcl.Pos{Line: 5, Column: 9},
					End:      hcl.Pos{Line: 5, Column: 14},
				},
			},
		}
		opts := []cmp.Option{cmpopts.IgnoreFields(hcl.Pos{}, "Byte")}
		if !cmp.Equal(expected, child.modVars, opts...) {
			t.Fatalf("`%s` module variables are unmatched: Diff=%s", child.TFConfig.Path.String(), cmp.Diff(expected, child.modVars, opts...))
		}

		grandchild := runners[1]
		if grandchild.TFConfig.Path.String() != "module.module1.module.module2" {
			t.Fatalf(`Expected child config path name is "module.module1.module.module2", but got "%s"`, grandchild.TFConfig.Path.String())
		}

		expected = map[string]*moduleVariable{
			"red": {
				Root:    false,
				Parents: []*moduleVariable{expected["foo"], expected["bar"]},
				DeclRange: hcl.Range{
					Filename: filepath.Join("module", "main.tf"),
					Start:    hcl.Pos{Line: 8, Column: 11},
					End:      hcl.Pos{Line: 8, Column: 34},
				},
			},
			"blue": {
				Root:    false,
				Parents: []*moduleVariable{},
				DeclRange: hcl.Range{
					Filename: filepath.Join("module", "main.tf"),
					Start:    hcl.Pos{Line: 9, Column: 11},
					End:      hcl.Pos{Line: 9, Column: 17},
				},
			},
			"green": {
				Root:    false,
				Parents: []*moduleVariable{expected["foo"]},
				DeclRange: hcl.Range{
					Filename: filepath.Join("module", "main.tf"),
					Start:    hcl.Pos{Line: 10, Column: 11},
					End:      hcl.Pos{Line: 10, Column: 49},
				},
			},
		}
		opts = []cmp.Option{
			cmpopts.IgnoreFields(hcl.Pos{}, "Byte"),
			cmpopts.SortSlices(func(x, y *moduleVariable) bool { return x.DeclRange.Start.Line > y.DeclRange.Start.Line }),
		}
		if !cmp.Equal(expected, grandchild.modVars, opts...) {
			t.Fatalf("`%s` module variables are unmatched: Diff=%s", grandchild.TFConfig.Path.String(), cmp.Diff(expected, grandchild.modVars, opts...))
		}
	})
}

func Test_NewModuleRunners_ignoreModules(t *testing.T) {
	withinFixtureDir(t, "nested_modules", func() {
		config := moduleConfig()
		config.IgnoreModules["./module"] = true
		runner := testRunnerWithOsFs(t, config)

		runners, err := NewModuleRunners(runner)
		if err != nil {
			t.Fatalf("Unexpected error occurred: %s", err)
		}

		if len(runners) != 0 {
			t.Fatalf(`This function must not return runners because "ignore_module" is set. Got %d runner(s)`, len(runners))
		}
	})
}

func Test_RunnerFiles(t *testing.T) {
	runner := TestRunner(t, map[string]string{
		"main.tf": "",
	})

	expected := map[string]*hcl.File{
		"main.tf": {
			Body:  hcl.EmptyBody(),
			Bytes: []byte{},
		},
	}

	files := runner.Files()

	opt := cmpopts.IgnoreFields(hcl.File{}, "Body", "Nav")
	if !cmp.Equal(expected, files, opt) {
		t.Fatalf("Failed test: diff: %s", cmp.Diff(expected, files, opt))
	}
}

func Test_LookupIssues(t *testing.T) {
	tests := []struct {
		name     string
		in       string
		issues   Issues
		expected Issues
	}{
		{
			name: "multiple files",
			in:   "template.tf",
			issues: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test rule",
					Range: hcl.Range{
						Filename: "template.tf",
						Start:    hcl.Pos{Line: 1},
					},
				},
				{
					Rule:    &testRule{},
					Message: "This is test rule",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 1},
					},
				},
			},
			expected: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test rule",
					Range: hcl.Range{
						Filename: "template.tf",
						Start:    hcl.Pos{Line: 1},
					},
				},
			},
		},
		{
			name: "path normalization",
			in:   "./template.tf",
			issues: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test rule",
					Range: hcl.Range{
						Filename: "template.tf",
						Start:    hcl.Pos{Line: 1},
					},
				},
			},
			expected: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test rule",
					Range: hcl.Range{
						Filename: "template.tf",
						Start:    hcl.Pos{Line: 1},
					},
				},
			},
		},
	}

	runner := TestRunner(t, map[string]string{})

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			runner.Issues = test.issues

			got := runner.LookupIssues(test.in)
			if diff := cmp.Diff(test.expected, got); diff != "" {
				t.Fatal(diff)
			}
		})
	}
}

func TestLookupChanges(t *testing.T) {
	tests := []struct {
		name    string
		in      string
		changes map[string][]byte
		want    map[string][]byte
	}{
		{
			name: "multiple files",
			in:   "main.tf",
			changes: map[string][]byte{
				"main.tf":     []byte("foo = 1"),
				"resource.tf": []byte("bar = 2"),
			},
			want: map[string][]byte{
				"main.tf": []byte("foo = 1"),
			},
		},
		{
			name: "path normalization",
			in:   "./main.tf",
			changes: map[string][]byte{
				"main.tf": []byte("foo = 1"),
			},
			want: map[string][]byte{
				"main.tf": []byte("foo = 1"),
			},
		},
	}

	runner := TestRunner(t, map[string]string{})

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			runner.changes = test.changes

			got := runner.LookupChanges(test.in)
			if diff := cmp.Diff(test.want, got); diff != "" {
				t.Fatal(diff)
			}
		})
	}
}

type testRule struct{}

func (r *testRule) Name() string {
	return "test_rule"
}
func (r *testRule) Severity() Severity {
	return sdk.ERROR
}
func (r *testRule) Link() string {
	return ""
}

func Test_EmitIssue(t *testing.T) {
	sources := map[string]string{
		"test.tf":   "foo = 1",
		"module.tf": "bar = 2",
	}

	parseExpr := func(in string) hcl.Expression {
		expr, diags := hclsyntax.ParseExpression([]byte(in), "", hcl.InitialPos)
		if diags.HasErrors() {
			t.Fatal(diags)
		}
		return expr
	}

	type moduleConfig struct {
		currentExpr hcl.Expression
		variables   map[string]*moduleVariable
	}

	cases := []struct {
		Name        string
		Rule        Rule
		Message     string
		Location    hcl.Range
		Fixable     bool
		Annotations map[string]Annotations
		Module      *moduleConfig
		Expected    Issues
		Applied     bool
	}{
		{
			Name:    "basic",
			Rule:    &testRule{},
			Message: "This is test message",
			Location: hcl.Range{
				Filename: "test.tf",
				Start:    hcl.Pos{Line: 1},
			},
			Annotations: map[string]Annotations{},
			Expected: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test message",
					Range: hcl.Range{
						Filename: "test.tf",
						Start:    hcl.Pos{Line: 1},
					},
					Source: []byte("foo = 1"),
				},
			},
			Applied: true,
		},
		{
			Name:    "fixable",
			Rule:    &testRule{},
			Message: "This is test message",
			Location: hcl.Range{
				Filename: "test.tf",
				Start:    hcl.Pos{Line: 1},
			},
			Fixable:     true,
			Annotations: map[string]Annotations{},
			Expected: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test message",
					Range: hcl.Range{
						Filename: "test.tf",
						Start:    hcl.Pos{Line: 1},
					},
					Fixable: true,
					Source:  []byte("foo = 1"),
				},
			},
			Applied: true,
		},
		{
			Name:    "ignore",
			Rule:    &testRule{},
			Message: "This is test message",
			Location: hcl.Range{
				Filename: "test.tf",
				Start:    hcl.Pos{Line: 1},
			},
			Annotations: map[string]Annotations{
				"test.tf": {
					&LineAnnotation{
						Content: "test_rule",
						Token: hclsyntax.Token{
							Type: hclsyntax.TokenComment,
							Range: hcl.Range{
								Filename: "test.tf",
								Start:    hcl.Pos{Line: 1},
							},
						},
					},
				},
			},
			Expected: Issues{},
			Applied:  false,
		},
		{
			Name:    "module",
			Rule:    &testRule{},
			Message: "This is test message",
			Location: hcl.Range{
				Filename: "test.tf",
				Start:    hcl.Pos{Line: 1},
			},
			Module: &moduleConfig{
				currentExpr: parseExpr("var.foo"),
				variables: map[string]*moduleVariable{
					"foo": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 1}}},
				},
			},
			Expected: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test message",
					Range: hcl.Range{
						Filename: "module.tf",
						Start:    hcl.Pos{Line: 1},
					},
					Callers: []hcl.Range{
						{Filename: "module.tf", Start: hcl.Pos{Line: 1}},
						{Filename: "test.tf", Start: hcl.Pos{Line: 1}},
					},
					Source: []byte("bar = 2"),
				},
			},
			Applied: true,
		},
		{
			Name:    "no variables in module",
			Rule:    &testRule{},
			Message: "This is test message",
			Location: hcl.Range{
				Filename: "test.tf",
				Start:    hcl.Pos{Line: 1},
			},
			Module: &moduleConfig{
				currentExpr: parseExpr(`"foo"`),
				variables:   map[string]*moduleVariable{},
			},
			Expected: Issues{},
			Applied:  false,
		},
		{
			Name:    "multiple variables in module",
			Rule:    &testRule{},
			Message: "This is test message",
			Location: hcl.Range{
				Filename: "test.tf",
				Start:    hcl.Pos{Line: 1},
			},
			Module: &moduleConfig{
				currentExpr: parseExpr(`"${var.foo}-${var.bar}"`),
				variables: map[string]*moduleVariable{
					"foo": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 1}}},
					"bar": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 3}}},
				},
			},
			Expected: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test message",
					Range: hcl.Range{
						Filename: "module.tf",
						Start:    hcl.Pos{Line: 1},
					},
					Callers: []hcl.Range{
						{Filename: "module.tf", Start: hcl.Pos{Line: 1}},
						{Filename: "test.tf", Start: hcl.Pos{Line: 1}},
					},
					Source: []byte("bar = 2"),
				},
				{
					Rule:    &testRule{},
					Message: "This is test message",
					Range: hcl.Range{
						Filename: "module.tf",
						Start:    hcl.Pos{Line: 3},
					},
					Callers: []hcl.Range{
						{Filename: "module.tf", Start: hcl.Pos{Line: 3}},
						{Filename: "test.tf", Start: hcl.Pos{Line: 1}},
					},
					Source: []byte("bar = 2"),
				},
			},
			Applied: true,
		},
		{
			Name:    "ignored multiple variables in module",
			Rule:    &testRule{},
			Message: "This is test message",
			Location: hcl.Range{
				Filename: "test.tf",
				Start:    hcl.Pos{Line: 1},
			},
			Module: &moduleConfig{
				currentExpr: parseExpr(`"${var.foo}-${var.bar}"`),
				variables: map[string]*moduleVariable{
					"foo": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 1}}},
					"bar": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 3}}},
				},
			},
			Annotations: map[string]Annotations{
				"module.tf": {
					&LineAnnotation{
						Content: "test_rule",
						Token: hclsyntax.Token{
							Type: hclsyntax.TokenComment,
							Range: hcl.Range{
								Filename: "module.tf",
								Start:    hcl.Pos{Line: 1},
							},
						},
					},
				},
			},
			Expected: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test message",
					Range: hcl.Range{
						Filename: "module.tf",
						Start:    hcl.Pos{Line: 3},
					},
					Callers: []hcl.Range{
						{Filename: "module.tf", Start: hcl.Pos{Line: 3}},
						{Filename: "test.tf", Start: hcl.Pos{Line: 1}},
					},
					Source: []byte("bar = 2"),
				},
			},
			Applied: false,
		},
		{
			Name:    "fixable in module",
			Rule:    &testRule{},
			Message: "This is test message",
			Location: hcl.Range{
				Filename: "test.tf",
				Start:    hcl.Pos{Line: 1},
			},
			Fixable: true,
			Module: &moduleConfig{
				currentExpr: parseExpr("var.foo"),
				variables: map[string]*moduleVariable{
					"foo": {Root: true, DeclRange: hcl.Range{Filename: "module.tf", Start: hcl.Pos{Line: 1}}},
				},
			},
			Expected: Issues{
				{
					Rule:    &testRule{},
					Message: "This is test message",
					Range: hcl.Range{
						Filename: "module.tf",
						Start:    hcl.Pos{Line: 1},
					},
					Callers: []hcl.Range{
						{Filename: "module.tf", Start: hcl.Pos{Line: 1}},
						{Filename: "test.tf", Start: hcl.Pos{Line: 1}},
					},
					Fixable: false,
					Source:  []byte("bar = 2"),
				},
			},
			Applied: true,
		},
	}

	for _, tc := range cases {
		t.Run(tc.Name, func(t *testing.T) {
			runner := testRunnerWithAnnotations(t, sources, tc.Annotations)
			if tc.Module != nil {
				runner.TFConfig.Path = []string{"module", "module1"}
				runner.currentExpr = tc.Module.currentExpr
				runner.modVars = tc.Module.variables
			}

			got := runner.EmitIssue(tc.Rule, tc.Message, tc.Location, tc.Fixable)
			if got != tc.Applied {
				t.Fatalf("expected %v, got %v", tc.Applied, got)
			}

			if diff := cmp.Diff(runner.Issues.Sort(), tc.Expected); diff != "" {
				t.Fatalf("diff: %s", diff)
			}
		})
	}
}

func TestApplyChanges(t *testing.T) {
	tests := []struct {
		name    string
		files   map[string]string
		changes map[string][]byte
		want    map[string][]byte
	}{
		{
			name: "apply changes",
			files: map[string]string{
				"main.tf":      `variable "foo" {}`,
				"variables.tf": `variable "bar" {}`,
			},
			changes: map[string][]byte{
				"variables.tf": []byte(`variable "bar" { type = string }`),
			},
			want: map[string][]byte{
				"main.tf":      []byte(`variable "foo" {}`),
				"variables.tf": []byte(`variable "bar" { type = string }`),
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			runner := TestRunner(t, test.files)

			diags := runner.ApplyChanges(test.changes)
			if diags.HasErrors() {
				t.Fatal(diags)
			}

			if diff := cmp.Diff(test.want, runner.Sources()); diff != "" {
				t.Fatal(diff)
			}
			if diff := cmp.Diff(test.changes, runner.changes); diff != "" {
				t.Fatal(diff)
			}
		})
	}
}

func Test_listVarRefs(t *testing.T) {
	cases := []struct {
		Name     string
		Expr     string
		Expected map[string]addrs.InputVariable
	}{
		{
			Name:     "literal",
			Expr:     "1",
			Expected: map[string]addrs.InputVariable{},
		},
		{
			Name: "input variable",
			Expr: "var.foo",
			Expected: map[string]addrs.InputVariable{
				"var.foo": {Name: "foo"},
			},
		},
		{
			Name:     "local variable",
			Expr:     "local.bar",
			Expected: map[string]addrs.InputVariable{},
		},
		{
			Name: "multiple input variables",
			Expr: `format("Hello, %s %s!", var.first_name, var.last_name)`,
			Expected: map[string]addrs.InputVariable{
				"var.first_name": {Name: "first_name"},
				"var.last_name":  {Name: "last_name"},
			},
		},
		{
			Name: "map input variable",
			Expr: `{
  name = var.tags["name"]
  env  = var.tags["env"]
}`,
			Expected: map[string]addrs.InputVariable{
				"var.tags": {Name: "tags"},
			},
		},
		{
			Name:     "invalid expression",
			Expr:     "my_block",
			Expected: map[string]addrs.InputVariable{},
		},
	}

	for _, tc := range cases {
		expr, diags := hclsyntax.ParseExpression([]byte(tc.Expr), "template.tf", hcl.InitialPos)
		if diags.HasErrors() {
			t.Fatal(diags)
		}

		refs := listVarRefs(expr)

		opt := cmpopts.IgnoreUnexported(addrs.InputVariable{})
		if !cmp.Equal(tc.Expected, refs, opt) {
			t.Fatalf("%s: Diff=%s", tc.Name, cmp.Diff(tc.Expected, refs, opt))
		}
	}
}
