// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package configs

import (
	"context"
	"fmt"
	"os"
	"path"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"

	"github.com/davecgh/go-spew/spew"

	version "github.com/hashicorp/go-version"
	"github.com/hashicorp/hcl/v2"
)

func TestBuildConfig(t *testing.T) {
	parser := NewParser(nil)
	mod, diags := parser.LoadConfigDir("testdata/config-build", RootModuleCallForTesting())
	assertNoDiagnostics(t, diags)
	if mod == nil {
		t.Fatal("got nil root module; want non-nil")
	}

	versionI := 0
	cfg, diags := BuildConfig(t.Context(), mod, ModuleWalkerFunc(
		func(_ context.Context, req *ModuleRequest) (*Module, *version.Version, hcl.Diagnostics) {
			// For the sake of this test we're going to just treat our
			// SourceAddr as a path relative to our fixture directory.
			// A "real" implementation of ModuleWalker should accept the
			// various different source address syntaxes OpenTofu supports.
			sourcePath := filepath.Join("testdata/config-build", req.SourceAddr.String())

			mod, modDiags := parser.LoadConfigDir(sourcePath, req.Call)
			version, _ := version.NewVersion(fmt.Sprintf("1.0.%d", versionI))
			versionI++
			return mod, version, modDiags
		},
	))
	assertNoDiagnostics(t, diags)
	if cfg == nil {
		t.Fatal("got nil config; want non-nil")
	}

	var got []string
	cfg.DeepEach(func(c *Config) {
		got = append(got, fmt.Sprintf("%s %s", strings.Join(c.Path, "."), c.Version))
	})
	sort.Strings(got)
	want := []string{
		" <nil>",
		"child_a 1.0.0",
		"child_a.child_c 1.0.1",
		"child_b 1.0.2",
		"child_b.child_c 1.0.3",
	}

	if !reflect.DeepEqual(got, want) {
		t.Fatalf("wrong result\ngot: %swant: %s", spew.Sdump(got), spew.Sdump(want))
	}

	if _, exists := cfg.Children["child_a"].Children["child_c"].Module.Outputs["hello"]; !exists {
		t.Fatalf("missing output 'hello' in child_a.child_c")
	}
	if _, exists := cfg.Children["child_b"].Children["child_c"].Module.Outputs["hello"]; !exists {
		t.Fatalf("missing output 'hello' in child_b.child_c")
	}
	if cfg.Children["child_a"].Children["child_c"].Module == cfg.Children["child_b"].Children["child_c"].Module {
		t.Fatalf("child_a.child_c is same object as child_b.child_c; should not be")
	}
}

func TestBuildConfigDiags(t *testing.T) {
	parser := NewParser(nil)
	mod, diags := parser.LoadConfigDir("testdata/nested-errors", RootModuleCallForTesting())
	assertNoDiagnostics(t, diags)
	if mod == nil {
		t.Fatal("got nil root module; want non-nil")
	}

	versionI := 0
	cfg, diags := BuildConfig(t.Context(), mod, ModuleWalkerFunc(
		func(_ context.Context, req *ModuleRequest) (*Module, *version.Version, hcl.Diagnostics) {
			// For the sake of this test we're going to just treat our
			// SourceAddr as a path relative to our fixture directory.
			// A "real" implementation of ModuleWalker should accept the
			// various different source address syntaxes OpenTofu supports.
			sourcePath := filepath.Join("testdata/nested-errors", req.SourceAddr.String())

			mod, modDiags := parser.LoadConfigDir(sourcePath, req.Call)
			version, _ := version.NewVersion(fmt.Sprintf("1.0.%d", versionI))
			versionI++
			return mod, version, modDiags
		},
	))

	wantDiag := filepath.FromSlash(`testdata/nested-errors/child_c/child_c.tf:5,1-8: `) +
		`Unsupported block type; Blocks of type "invalid" are not expected here.`
	assertExactDiagnostics(t, diags, []string{wantDiag})

	// we should still have module structure loaded
	var got []string
	cfg.DeepEach(func(c *Config) {
		got = append(got, fmt.Sprintf("%s %s", strings.Join(c.Path, "."), c.Version))
	})
	sort.Strings(got)
	want := []string{
		" <nil>",
		"child_a 1.0.0",
		"child_a.child_c 1.0.1",
	}

	if !reflect.DeepEqual(got, want) {
		t.Fatalf("wrong result\ngot: %swant: %s", spew.Sdump(got), spew.Sdump(want))
	}
}

func TestBuildConfigChildModuleBackend(t *testing.T) {
	parser := NewParser(nil)
	mod, diags := parser.LoadConfigDir("testdata/nested-backend-warning", RootModuleCallForTesting())
	assertNoDiagnostics(t, diags)
	if mod == nil {
		t.Fatal("got nil root module; want non-nil")
	}

	cfg, diags := BuildConfig(t.Context(), mod, ModuleWalkerFunc(
		func(_ context.Context, req *ModuleRequest) (*Module, *version.Version, hcl.Diagnostics) {
			// For the sake of this test we're going to just treat our
			// SourceAddr as a path relative to our fixture directory.
			// A "real" implementation of ModuleWalker should accept the
			// various different source address syntaxes OpenTofu supports.
			sourcePath := filepath.Join("testdata/nested-backend-warning", req.SourceAddr.String())

			mod, modDiags := parser.LoadConfigDir(sourcePath, req.Call)
			version, _ := version.NewVersion("1.0.0")
			return mod, version, modDiags
		},
	))

	assertDiagnosticSummary(t, diags, "Backend configuration ignored")

	// we should still have module structure loaded
	var got []string
	cfg.DeepEach(func(c *Config) {
		got = append(got, fmt.Sprintf("%s %s", strings.Join(c.Path, "."), c.Version))
	})
	sort.Strings(got)
	want := []string{
		" <nil>",
		"child 1.0.0",
	}

	if !reflect.DeepEqual(got, want) {
		t.Fatalf("wrong result\ngot: %swant: %s", spew.Sdump(got), spew.Sdump(want))
	}
}

func TestBuildConfigInvalidModules(t *testing.T) {
	testDir := "testdata/config-diagnostics"
	dirs, err := os.ReadDir(testDir)
	if err != nil {
		t.Fatal(err)
	}

	for _, info := range dirs {
		name := info.Name()
		t.Run(name, func(t *testing.T) {
			parser := NewParser(nil)
			path := filepath.Join(testDir, name)

			mod, diags := parser.LoadConfigDirWithTests(path, "tests", RootModuleCallForTesting())
			if diags.HasErrors() {
				// these tests should only trigger errors that are caught in
				// the config loader.
				t.Errorf("error loading config dir")
				for _, diag := range diags {
					t.Logf("- %s", diag)
				}
			}

			readDiags := func(data []byte, _ error) []string {
				var expected []string
				for _, s := range strings.Split(string(data), "\n") {
					msg := strings.TrimSpace(s)
					msg = strings.ReplaceAll(msg, `\n`, "\n")
					// The filepath preset in testdata with unix-style slash.
					// We should from slash to adapt to Linux, Windows and others OS.
					msgSplit := strings.SplitN(msg, ":", 2)
					if len(msgSplit) == 2 {
						msgSplit[0] = filepath.FromSlash(msgSplit[0])
						msg = strings.Join(msgSplit, ":")
					}

					if msg != "" {
						expected = append(expected, msg)
					}
				}
				return expected
			}

			// Load expected errors and warnings.
			// Each line in the file is matched as a substring against the
			// diagnostic outputs.
			// Capturing part of the path and source range in the message lets
			// us also ensure the diagnostic is being attributed to the
			// expected location in the source, but is not required.
			// The literal characters `\n` are replaced with newlines, but
			// otherwise the string is unchanged.
			expectedErrs := readDiags(os.ReadFile(filepath.Join(testDir, name, "errors")))
			expectedWarnings := readDiags(os.ReadFile(filepath.Join(testDir, name, "warnings")))

			_, buildDiags := BuildConfig(t.Context(), mod, ModuleWalkerFunc(
				func(_ context.Context, req *ModuleRequest) (*Module, *version.Version, hcl.Diagnostics) {
					// for simplicity, these tests will treat all source
					// addresses as relative to the root module
					sourcePath := filepath.Join(path, req.SourceAddr.String())
					mod, diags := parser.LoadConfigDir(sourcePath, req.Call)
					version, _ := version.NewVersion("1.0.0")
					return mod, version, diags
				},
			))

			// we can make this less repetitive later if we want
			for _, msg := range expectedErrs {
				found := false
				for _, diag := range buildDiags {
					if diag.Severity == hcl.DiagError && strings.Contains(diag.Error(), msg) {
						found = true
						break
					}
				}

				if !found {
					t.Errorf("Expected error diagnostic containing:\n    %s", msg)
				}
			}

			for _, diag := range buildDiags {
				if diag.Severity != hcl.DiagError {
					continue
				}
				found := false
				for _, msg := range expectedErrs {
					if strings.Contains(diag.Error(), msg) {
						found = true
						break
					}
				}

				if !found {
					t.Errorf("Unexpected error:\n    %s", diag)
				}
			}

			for _, msg := range expectedWarnings {
				found := false
				for _, diag := range buildDiags {
					if diag.Severity == hcl.DiagWarning && strings.Contains(diag.Error(), msg) {
						found = true
						break
					}
				}

				if !found {
					t.Errorf("Expected warning diagnostic containing:\n    %s", msg)
				}
			}

			for _, diag := range buildDiags {
				if diag.Severity != hcl.DiagWarning {
					continue
				}
				found := false
				for _, msg := range expectedWarnings {
					if strings.Contains(diag.Error(), msg) {
						found = true
						break
					}
				}

				if !found {
					t.Errorf("Unexpected warning:\n    %s", diag)
				}
			}

		})
	}
}

func TestBuildConfig_WithNestedTestModules(t *testing.T) {
	parser := NewParser(nil)
	mod, diags := parser.LoadConfigDirWithTests("testdata/valid-modules/with-tests-nested-module", "tests", RootModuleCallForTesting())
	assertNoDiagnostics(t, diags)
	if mod == nil {
		t.Fatal("got nil root module; want non-nil")
	}

	cfg, diags := BuildConfig(t.Context(), mod, ModuleWalkerFunc(
		func(_ context.Context, req *ModuleRequest) (*Module, *version.Version, hcl.Diagnostics) {

			// Bit of a hack to get the test working, but we know all the source
			// addresses in this test are locals, so we can just treat them as
			// paths in the filesystem.

			addr := req.SourceAddr.String()
			current := req.Parent
			for current.SourceAddr != nil {
				addr = path.Join(current.SourceAddr.String(), addr)
				current = current.Parent
			}
			sourcePath := filepath.Join("testdata/valid-modules/with-tests-nested-module", addr)

			mod, modDiags := parser.LoadConfigDir(sourcePath, req.Call)
			version, _ := version.NewVersion("1.0.0")
			return mod, version, modDiags
		},
	))
	assertNoDiagnostics(t, diags)
	if cfg == nil {
		t.Fatal("got nil config; want non-nil")
	}

	// We should have loaded our test case, and one of the test runs should
	// have loaded an alternate module.

	if len(cfg.Module.Tests) != 1 {
		t.Fatalf("expected exactly one test case but found %d", len(cfg.Module.Tests))
	}

	test := cfg.Module.Tests["main.tftest.hcl"]
	if len(test.Runs) != 1 {
		t.Fatalf("expected two test runs but found %d", len(test.Runs))
	}

	run := test.Runs[0]
	if run.ConfigUnderTest == nil {
		t.Fatalf("the first test run should have loaded config but did not")
	}

	if run.ConfigUnderTest.Parent != nil {
		t.Errorf("config under test should not have a parent")
	}

	if run.ConfigUnderTest.Root != run.ConfigUnderTest {
		t.Errorf("config under test root should be itself")
	}

	if len(run.ConfigUnderTest.Path) > 0 {
		t.Errorf("config under test path should be the root module")
	}

	// We should also have loaded a single child underneath the config under
	// test, and it should have valid paths.

	child := run.ConfigUnderTest.Children["child"]

	if child.Parent != run.ConfigUnderTest {
		t.Errorf("child should point back to root")
	}

	if len(child.Path) != 1 || child.Path[0] != "child" {
		t.Errorf("child should have rebased against virtual root")
	}

	if child.Root != run.ConfigUnderTest {
		t.Errorf("child root should be main config under test")
	}
}

func TestBuildConfig_WithTestModule(t *testing.T) {
	parser := NewParser(nil)
	mod, diags := parser.LoadConfigDirWithTests("testdata/valid-modules/with-tests-module", "tests", RootModuleCallForTesting())
	assertNoDiagnostics(t, diags)
	if mod == nil {
		t.Fatal("got nil root module; want non-nil")
	}

	cfg, diags := BuildConfig(t.Context(), mod, ModuleWalkerFunc(
		func(_ context.Context, req *ModuleRequest) (*Module, *version.Version, hcl.Diagnostics) {
			// For the sake of this test we're going to just treat our
			// SourceAddr as a path relative to our fixture directory.
			// A "real" implementation of ModuleWalker should accept the
			// various different source address syntaxes OpenTofu supports.
			sourcePath := filepath.Join("testdata/valid-modules/with-tests-module", req.SourceAddr.String())

			mod, modDiags := parser.LoadConfigDir(sourcePath, req.Call)
			version, _ := version.NewVersion("1.0.0")
			return mod, version, modDiags
		},
	))
	assertNoDiagnostics(t, diags)
	if cfg == nil {
		t.Fatal("got nil config; want non-nil")
	}

	// We should have loaded our test case, and one of the test runs should
	// have loaded an alternate module.

	if len(cfg.Module.Tests) != 1 {
		t.Fatalf("expected exactly one test case but found %d", len(cfg.Module.Tests))
	}

	test := cfg.Module.Tests["main.tftest.hcl"]
	if len(test.Runs) != 2 {
		t.Fatalf("expected two test runs but found %d", len(test.Runs))
	}

	run := test.Runs[0]
	if run.ConfigUnderTest == nil {
		t.Fatalf("the first test run should have loaded config but did not")
	}

	if run.ConfigUnderTest.Parent != nil {
		t.Errorf("config under test should not have a parent")
	}

	if run.ConfigUnderTest.Root != run.ConfigUnderTest {
		t.Errorf("config under test root should be itself")
	}

	if len(run.ConfigUnderTest.Path) > 0 {
		t.Errorf("config under test path should be the root module")
	}
}
