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

package depsfile

import (
	"bufio"
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"

	"github.com/opentofu/opentofu/internal/addrs"
	"github.com/opentofu/opentofu/internal/getproviders"
	"github.com/opentofu/opentofu/internal/tfdiags"
)

func TestLoadLocksFromFile(t *testing.T) {
	// For ease of test maintenance we treat every file under
	// test-data/locks-files as a test case which is subject
	// at least to testing that it produces an expected set
	// of diagnostics represented via specially-formatted comments
	// in the fixture files (which might be the empty set, if
	// there are no such comments).
	//
	// Some of the files also have additional assertions that
	// are encoded in the test code below. These must pass
	// in addition to the standard diagnostics tests, if present.
	files, err := os.ReadDir("testdata/locks-files")
	if err != nil {
		t.Fatal(err.Error())
	}

	for _, info := range files {
		testName := filepath.Base(info.Name())
		filename := filepath.Join("testdata/locks-files", testName)
		t.Run(testName, func(t *testing.T) {
			f, err := os.Open(filename)
			if err != nil {
				t.Fatal(err.Error())
			}
			defer f.Close()
			const errorPrefix = "# ERROR: "
			const warningPrefix = "# WARNING: "
			wantErrors := map[int]string{}
			wantWarnings := map[int]string{}
			sc := bufio.NewScanner(f)
			lineNum := 1
			for sc.Scan() {
				l := sc.Text()
				if pos := strings.Index(l, errorPrefix); pos != -1 {
					wantSummary := l[pos+len(errorPrefix):]
					wantErrors[lineNum] = wantSummary
				}
				if pos := strings.Index(l, warningPrefix); pos != -1 {
					wantSummary := l[pos+len(warningPrefix):]
					wantWarnings[lineNum] = wantSummary
				}
				lineNum++
			}
			if err := sc.Err(); err != nil {
				t.Fatal(err.Error())
			}

			locks, diags := LoadLocksFromFile(filename)
			gotErrors := map[int]string{}
			gotWarnings := map[int]string{}
			for _, diag := range diags {
				summary := diag.Description().Summary
				if diag.Source().Subject == nil {
					// We don't expect any sourceless diagnostics here.
					t.Errorf("unexpected sourceless diagnostic: %s", summary)
					continue
				}
				lineNum := diag.Source().Subject.Start.Line
				switch sev := diag.Severity(); sev {
				case tfdiags.Error:
					gotErrors[lineNum] = summary
				case tfdiags.Warning:
					gotWarnings[lineNum] = summary
				default:
					t.Errorf("unexpected diagnostic severity %s", sev)
				}
			}

			if diff := cmp.Diff(wantErrors, gotErrors); diff != "" {
				t.Errorf("wrong errors\n%s", diff)
			}
			if diff := cmp.Diff(wantWarnings, gotWarnings); diff != "" {
				t.Errorf("wrong warnings\n%s", diff)
			}

			switch testName {
			// These are the file-specific test assertions. Not all files
			// need custom test assertions in addition to the standard
			// diagnostics assertions implemented above, so the cases here
			// don't need to be exhaustive for all files.
			//
			// Please keep these in alphabetical order so the list is easy
			// to scan!

			case "empty.hcl":
				if got, want := len(locks.providers), 0; got != want {
					t.Errorf("wrong number of providers %d; want %d", got, want)
				}

			case "valid-provider-locks.hcl":
				if got, want := len(locks.providers), 3; got != want {
					t.Errorf("wrong number of providers %d; want %d", got, want)
				}

				t.Run("version-only", func(t *testing.T) {
					if lock := locks.Provider(addrs.MustParseProviderSourceString("terraform.io/test/version-only")); lock != nil {
						if got, want := lock.Version().String(), "1.0.0"; got != want {
							t.Errorf("wrong version\ngot:  %s\nwant: %s", got, want)
						}
						if got, want := getproviders.VersionConstraintsString(lock.VersionConstraints()), ""; got != want {
							t.Errorf("wrong version constraints\ngot:  %s\nwant: %s", got, want)
						}
						if got, want := len(lock.hashes), 0; got != want {
							t.Errorf("wrong number of hashes %d; want %d", got, want)
						}
					}
				})

				t.Run("version-and-constraints", func(t *testing.T) {
					if lock := locks.Provider(addrs.MustParseProviderSourceString("terraform.io/test/version-and-constraints")); lock != nil {
						if got, want := lock.Version().String(), "1.2.0"; got != want {
							t.Errorf("wrong version\ngot:  %s\nwant: %s", got, want)
						}
						if got, want := getproviders.VersionConstraintsString(lock.VersionConstraints()), "~> 1.2"; got != want {
							t.Errorf("wrong version constraints\ngot:  %s\nwant: %s", got, want)
						}
						if got, want := len(lock.hashes), 0; got != want {
							t.Errorf("wrong number of hashes %d; want %d", got, want)
						}
					}
				})

				t.Run("all-the-things", func(t *testing.T) {
					if lock := locks.Provider(addrs.MustParseProviderSourceString("terraform.io/test/all-the-things")); lock != nil {
						if got, want := lock.Version().String(), "3.0.10"; got != want {
							t.Errorf("wrong version\ngot:  %s\nwant: %s", got, want)
						}
						if got, want := getproviders.VersionConstraintsString(lock.VersionConstraints()), ">= 3.0.2"; got != want {
							t.Errorf("wrong version constraints\ngot:  %s\nwant: %s", got, want)
						}
						wantHashes := []getproviders.Hash{
							getproviders.MustParseHash("test:placeholder-hash-1"),
							getproviders.MustParseHash("test:placeholder-hash-2"),
							getproviders.MustParseHash("test:placeholder-hash-3"),
						}
						if diff := cmp.Diff(wantHashes, lock.hashes); diff != "" {
							t.Errorf("wrong hashes\n%s", diff)
						}
					}
				})
			}
		})
	}
}

func TestLoadLocksFromFileAbsent(t *testing.T) {
	t.Run("lock file is a directory", func(t *testing.T) {
		// This can never happen when OpenTofu is the one generating the
		// lock file, but might arise if the user makes a directory with the
		// lock file's name for some reason. (There is no actual reason to do
		// so, so that would always be a mistake.)
		locks, diags := LoadLocksFromFile("testdata")
		if len(locks.providers) != 0 {
			t.Errorf("returned locks has providers; expected empty locks")
		}
		if !diags.HasErrors() {
			t.Fatalf("LoadLocksFromFile succeeded; want error")
		}
		// This is a generic error message from HCL itself, so upgrading HCL
		// in future might cause a different error message here.
		want := `Failed to read file: The configuration file "testdata" could not be read.`
		got := diags.Err().Error()
		if got != want {
			t.Errorf("wrong error message\ngot:  %s\nwant: %s", got, want)
		}
	})
	t.Run("lock file doesn't exist", func(t *testing.T) {
		locks, diags := LoadLocksFromFile("testdata/nonexist.hcl")
		if len(locks.providers) != 0 {
			t.Errorf("returned locks has providers; expected empty locks")
		}
		if !diags.HasErrors() {
			t.Fatalf("LoadLocksFromFile succeeded; want error")
		}
		// This is a generic error message from HCL itself, so upgrading HCL
		// in future might cause a different error message here.
		want := `Failed to read file: The configuration file "testdata/nonexist.hcl" could not be read.`
		got := diags.Err().Error()
		if got != want {
			t.Errorf("wrong error message\ngot:  %s\nwant: %s", got, want)
		}
	})
}

func TestSaveLocksToFile(t *testing.T) {
	locks := NewLocks()

	fooProvider := addrs.MustParseProviderSourceString("test/foo")
	barProvider := addrs.MustParseProviderSourceString("test/bar")
	bazProvider := addrs.MustParseProviderSourceString("test/baz")
	booProvider := addrs.MustParseProviderSourceString("test/boo")
	oneDotOh := getproviders.MustParseVersion("1.0.0")
	oneDotTwo := getproviders.MustParseVersion("1.2.0")
	atLeastOneDotOh := getproviders.MustParseVersionConstraints(">= 1.0.0")
	pessimisticOneDotOh := getproviders.MustParseVersionConstraints("~> 1")
	abbreviatedOneDotTwo := getproviders.MustParseVersionConstraints("1.2")
	hashes := []getproviders.Hash{
		getproviders.MustParseHash("test:cccccccccccccccccccccccccccccccccccccccccccccccc"),
		getproviders.MustParseHash("test:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
		getproviders.MustParseHash("test:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
	}
	locks.SetProvider(fooProvider, oneDotOh, atLeastOneDotOh, hashes)
	locks.SetProvider(barProvider, oneDotTwo, pessimisticOneDotOh, nil)
	locks.SetProvider(bazProvider, oneDotTwo, nil, nil)
	locks.SetProvider(booProvider, oneDotTwo, abbreviatedOneDotTwo, nil)

	dir := t.TempDir()

	filename := filepath.Join(dir, LockFilePath)
	diags := SaveLocksToFile(context.Background(), locks, filename)
	if diags.HasErrors() {
		t.Fatalf("unexpected errors\n%s", diags.Err().Error())
	}

	fileInfo, err := os.Stat(filename)
	if err != nil {
		t.Fatalf("%s", err.Error())
	}
	if mode := fileInfo.Mode(); mode&0111 != 0 {
		t.Fatalf("Expected lock file to be non-executable: %o", mode)
	}

	gotContentBytes, err := os.ReadFile(filename)
	if err != nil {
		t.Fatalf("%s", err.Error())
	}
	gotContent := string(gotContentBytes)
	wantContent := `# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.

provider "registry.opentofu.org/test/bar" {
  version     = "1.2.0"
  constraints = "~> 1.0"
}

provider "registry.opentofu.org/test/baz" {
  version = "1.2.0"
}

provider "registry.opentofu.org/test/boo" {
  version     = "1.2.0"
  constraints = "1.2.0"
}

provider "registry.opentofu.org/test/foo" {
  version     = "1.0.0"
  constraints = ">= 1.0.0"
  hashes = [
    "test:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "test:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "test:cccccccccccccccccccccccccccccccccccccccccccccccc",
  ]
}
`
	if diff := cmp.Diff(wantContent, gotContent); diff != "" {
		t.Errorf("wrong result\n%s", diff)
	}
}
