// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1

package local

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"

	"github.com/hashicorp/terraform/internal/backend"
	"github.com/hashicorp/terraform/internal/backend/backendrun"
	"github.com/hashicorp/terraform/internal/configs/configschema"
	"github.com/hashicorp/terraform/internal/states"
	"github.com/hashicorp/terraform/internal/states/statefile"
	"github.com/hashicorp/terraform/internal/states/statemgr"
	"github.com/hashicorp/terraform/internal/tfdiags"
	"github.com/zclconf/go-cty/cty"
)

func TestLocal_impl(t *testing.T) {
	var _ backendrun.OperationsBackend = New()
	var _ backendrun.Local = New()
	var _ backendrun.CLI = New()
}

func TestLocal_backend(t *testing.T) {
	_ = testTmpDir(t)
	b := New()
	backend.TestBackendStates(t, b)
	backend.TestBackendStateLocks(t, b, b)
}

func TestLocal_PrepareConfig(t *testing.T) {
	// Setup
	_ = testTmpDir(t)

	b := New()

	// PATH ATTR
	// Empty string path attribute isn't valid
	config := cty.ObjectVal(map[string]cty.Value{
		"path":          cty.StringVal(""),
		"workspace_dir": cty.NullVal(cty.String),
	})
	_, diags := b.PrepareConfig(config)
	if !diags.HasErrors() {
		t.Fatalf("expected an error from PrepareConfig but got none")
	}
	expectedErr := `The "path" attribute value must not be empty`
	if !strings.Contains(diags.Err().Error(), expectedErr) {
		t.Fatalf("expected an error containing %q, got: %q", expectedErr, diags.Err())
	}

	// PrepareConfig doesn't enforce the path value has .tfstate extension
	config = cty.ObjectVal(map[string]cty.Value{
		"path":          cty.StringVal("path/to/state/my-state.docx"),
		"workspace_dir": cty.NullVal(cty.String),
	})
	_, diags = b.PrepareConfig(config)
	if diags.HasErrors() {
		t.Fatalf("unexpected error returned from PrepareConfig")
	}

	// WORKSPACE_DIR ATTR
	// Empty string workspace_dir attribute isn't valid
	config = cty.ObjectVal(map[string]cty.Value{
		"path":          cty.NullVal(cty.String),
		"workspace_dir": cty.StringVal(""),
	})
	_, diags = b.PrepareConfig(config)
	if !diags.HasErrors() {
		t.Fatalf("expected an error from PrepareConfig but got none")
	}
	expectedErr = `The "workspace_dir" attribute value must not be empty`
	if !strings.Contains(diags.Err().Error(), expectedErr) {
		t.Fatalf("expected an error containing %q, got: %q", expectedErr, diags.Err())
	}

	// Existence of directory isn't checked during PrepareConfig
	// (Non-existent directories are created as a side-effect of WriteState)
	config = cty.ObjectVal(map[string]cty.Value{
		"path":          cty.NullVal(cty.String),
		"workspace_dir": cty.StringVal("this/does/not/exist"),
	})
	_, diags = b.PrepareConfig(config)
	if diags.HasErrors() {
		t.Fatalf("unexpected error returned from PrepareConfig")
	}
}

// The `path` attribute should only affect the default workspace's state
// file location and name.
//
// Non-default workspaces' states names and locations are unaffected.
func TestLocal_useOfPathAttribute(t *testing.T) {
	// Setup
	td := testTmpDir(t)

	b := New()

	// Configure local state-storage backend (skip call to PrepareConfig)
	path := "path/to/foobar.tfstate"
	config := cty.ObjectVal(map[string]cty.Value{
		"path":          cty.StringVal(path), // Set
		"workspace_dir": cty.NullVal(cty.String),
	})
	diags := b.Configure(config)
	if diags.HasErrors() {
		t.Fatalf("unexpected error returned from Configure")
	}

	// State file at the `path` location doesn't exist yet
	workspace := backend.DefaultStateName
	stmgr, sDiags := b.StateMgr(workspace)
	if sDiags.HasErrors() {
		t.Fatalf("unexpected error returned from StateMgr: %s", sDiags.Err())
	}
	defaultStatePath := fmt.Sprintf("%s/%s", td, path)
	if _, err := os.Stat(defaultStatePath); !strings.Contains(err.Error(), "no such file or directory") {
		if err != nil {
			t.Fatalf("expected \"no such file or directory\" error when accessing file %q, got: %s", path, err)
		}
		t.Fatalf("expected the state file %q to not exist, but it did", path)
	}

	// Writing to the default workspace's state creates a file
	// at the `path` location.
	// Directories are created to enable the path.
	s := states.NewState()
	s.RootOutputValues = map[string]*states.OutputValue{
		"foobar": {
			Value: cty.StringVal("foobar"),
		},
	}
	err := stmgr.WriteState(s)
	if err != nil {
		t.Fatalf("unexpected error returned from WriteState")
	}
	_, err = os.Stat(defaultStatePath)
	if err != nil {
		// The file should exist post-WriteState
		t.Fatalf("unexpected error when getting stats on the state file %q", path)
	}

	// Writing to a non-default workspace's state creates a file
	// that's unaffected by the `path` location
	workspace = "fizzbuzz"
	stmgr, sDiags = b.StateMgr(workspace)
	if sDiags.HasErrors() {
		t.Fatalf("unexpected error returned from StateMgr: %s", sDiags.Err())
	}
	fizzbuzzStatePath := fmt.Sprintf("%s/terraform.tfstate.d/%s/terraform.tfstate", td, workspace)
	err = stmgr.WriteState(s)
	if err != nil {
		t.Fatalf("unexpected error returned from WriteState")
	}

	// The file should exist post-WriteState
	checkState(t, fizzbuzzStatePath, s.String())
}

// Using non-tfstate file extensions in the value of the `path` attribute
// doesn't affect writing to state
func TestLocal_pathAttributeWrongExtension(t *testing.T) {
	// Setup
	td := testTmpDir(t)

	b := New()

	// The path value doesn't have the expected .tfstate file extension
	path := "foobar.docx"
	fullPath := fmt.Sprintf("%s/%s", td, path)
	config := cty.ObjectVal(map[string]cty.Value{
		"path":          cty.StringVal(path), // Set
		"workspace_dir": cty.NullVal(cty.String),
	})
	diags := b.Configure(config)
	if diags.HasErrors() {
		t.Fatalf("unexpected error returned from Configure")
	}

	// Writing to the default workspace's state creates a file
	workspace := backend.DefaultStateName
	stmgr, sDiags := b.StateMgr(workspace)
	if sDiags.HasErrors() {
		t.Fatalf("unexpected error returned from StateMgr: %s", sDiags.Err())
	}
	s := states.NewState()
	s.RootOutputValues = map[string]*states.OutputValue{
		"foobar": {
			Value: cty.StringVal("foobar"),
		},
	}
	err := stmgr.WriteState(s)
	if err != nil {
		t.Fatalf("unexpected error returned from WriteState")
	}

	// The file should exist post-WriteState, despite the odd file extension,
	// be readable, and contain the correct state
	checkState(t, fullPath, s.String())
}

// The `workspace_dir` attribute should only affect where non-default workspaces'
// state files are saved.
//
// The default workspace's name and location are unaffected by this attribute.
func TestLocal_useOfWorkspaceDirAttribute(t *testing.T) {
	// Setup
	td := testTmpDir(t)

	b := New()

	// Configure local state-storage backend (skip call to PrepareConfig)
	workspaceDir := "path/to/workspaces"
	config := cty.ObjectVal(map[string]cty.Value{
		"path":          cty.NullVal(cty.String),
		"workspace_dir": cty.StringVal(workspaceDir), // set
	})
	diags := b.Configure(config)
	if diags.HasErrors() {
		t.Fatalf("unexpected error returned from Configure")
	}

	// Writing to the default workspace's state creates a file.
	// As path attribute was left null, the default location
	// ./terraform.tfstate is used.
	// Unaffected by the `workspace_dir` location.
	workspace := backend.DefaultStateName
	defaultStatePath := fmt.Sprintf("%s/terraform.tfstate", td)
	stmgr, sDiags := b.StateMgr(workspace)
	if sDiags.HasErrors() {
		t.Fatalf("unexpected error returned from StateMgr: %s", sDiags.Err())
	}
	s := states.NewState()
	s.RootOutputValues = map[string]*states.OutputValue{
		"foobar": {
			Value: cty.StringVal("foobar"),
		},
	}
	err := stmgr.WriteState(s)
	if err != nil {
		t.Fatalf("unexpected error returned from WriteState")
	}
	// Assert state
	checkState(t, defaultStatePath, s.String())

	// Writing to a non-default workspace's state creates a file
	// that's affected by the `workspace_dir` location
	workspace = "fizzbuzz"
	fizzbuzzStatePath := fmt.Sprintf("%s/%s/%s/terraform.tfstate", td, workspaceDir, workspace)
	stmgr, sDiags = b.StateMgr(workspace)
	if sDiags.HasErrors() {
		t.Fatalf("unexpected error returned from StateMgr: %s", sDiags.Err())
	}
	err = stmgr.WriteState(s)
	if err != nil {
		t.Fatalf("unexpected error returned from WriteState")
	}
	// Assert state
	checkState(t, fizzbuzzStatePath, s.String())
}

// When using the local state storage you cannot delete the default workspace's state
func TestLocal_cannotDeleteDefaultState(t *testing.T) {
	// Setup
	_ = testTmpDir(t)
	dflt := backend.DefaultStateName
	expectedStates := []string{dflt}

	b := New()

	// Only default workspace exists initially.
	states, wDiags := b.Workspaces()
	if wDiags.HasErrors() {
		t.Fatal(wDiags.Err())
	}
	if !reflect.DeepEqual(states, expectedStates) {
		t.Fatalf("expected []string{%q}, got %q", dflt, states)
	}

	// Attempt to delete default state - force=false
	dwDiags := b.DeleteWorkspace(dflt, false)
	if !dwDiags.HasErrors() {
		t.Fatal("expected error but there was none")
	}
	expectedErr := "cannot delete default state"
	if dwDiags.Err().Error() != expectedErr {
		t.Fatalf("expected error %q, got: %q", expectedErr, dwDiags.Err())
	}

	// Setting force=true doesn't change outcome
	dwDiags = b.DeleteWorkspace(dflt, true)
	if !dwDiags.HasErrors() {
		t.Fatal("expected error but there was none")
	}
	if dwDiags.Err().Error() != expectedErr {
		t.Fatalf("expected error %q, got: %q", expectedErr, dwDiags.Err())
	}
}

func TestLocal_addAndRemoveStates(t *testing.T) {
	// Setup
	_ = testTmpDir(t)
	dflt := backend.DefaultStateName
	expectedStates := []string{dflt}

	b := New()

	// Only the default workspace exists initially.
	states, wDiags := b.Workspaces()
	if wDiags.HasErrors() {
		t.Fatal(wDiags.Err())
	}

	if !reflect.DeepEqual(states, expectedStates) {
		t.Fatalf("expected []string{%q}, got %q", dflt, states)
	}

	// Calling StateMgr with a new workspace name creates that workspace's state file.
	expectedA := "test_A"
	if _, sDiags := b.StateMgr(expectedA); sDiags.HasErrors() {
		t.Fatal(sDiags)
	}

	states, wDiags = b.Workspaces()
	if wDiags.HasErrors() {
		t.Fatal(wDiags.Err())
	}

	expectedStates = append(expectedStates, expectedA)
	if !reflect.DeepEqual(states, expectedStates) {
		t.Fatalf("expected %q, got %q", expectedStates, states)
	}

	// Creating another workspace appends it to the list of present workspaces.
	expectedB := "test_B"
	if _, sDiags := b.StateMgr(expectedB); sDiags.HasErrors() {
		t.Fatal(sDiags.Err())
	}

	states, wDiags = b.Workspaces()
	if wDiags.HasErrors() {
		t.Fatal(wDiags.Err())
	}

	expectedStates = append(expectedStates, expectedB)
	if !reflect.DeepEqual(states, expectedStates) {
		t.Fatalf("expected %q, got %q", expectedStates, states)
	}

	// Can delete a given workspace
	if dwDiags := b.DeleteWorkspace(expectedA, true); dwDiags.HasErrors() {
		t.Fatal(dwDiags.Err())
	}

	states, wDiags = b.Workspaces()
	if wDiags.HasErrors() {
		t.Fatal(wDiags.Err())
	}

	expectedStates = []string{dflt, expectedB}
	if !reflect.DeepEqual(states, expectedStates) {
		t.Fatalf("expected %q, got %q", expectedStates, states)
	}

	// Can delete another workspace
	if diags := b.DeleteWorkspace(expectedB, true); diags.HasErrors() {
		t.Fatal(diags.Err())
	}

	states, wDiags = b.Workspaces()
	if wDiags.HasErrors() {
		t.Fatal(wDiags.Err())
	}

	expectedStates = []string{dflt}
	if !reflect.DeepEqual(states, expectedStates) {
		t.Fatalf("expected %q, got %q", expectedStates, states)
	}

	// You cannot delete the default workspace
	if diags := b.DeleteWorkspace(dflt, true); !diags.HasErrors() {
		t.Fatal("expected error deleting default state")
	}
}

func TestLocal_StatePaths_defaultWorkspace(t *testing.T) {

	// Default paths are returned for the default workspace
	// when nothing is set via config or overrides
	b := New()
	path, out, back := b.StatePaths("")

	if path != DefaultStateFilename {
		t.Fatalf("expected %q, got %q", DefaultStateFilename, path)
	}

	if out != DefaultStateFilename {
		t.Fatalf("expected %q, got %q", DefaultStateFilename, out)
	}

	dfltBackup := DefaultStateFilename + DefaultBackupExtension
	if back != dfltBackup {
		t.Fatalf("expected %q, got %q", dfltBackup, back)
	}

	// If `path` is set in the config, this impacts returned paths for the default workspace
	b = New()
	configPath := "new-path.tfstate"
	b.StatePath = configPath    // equivalent of path = "new-path.tfstate" in config
	b.StateOutPath = configPath // equivalent of path = "new-path.tfstate" in config

	path, out, back = b.StatePaths("")

	if path != configPath {
		t.Fatalf("expected %q, got %q", configPath, path)
	}

	if out != configPath {
		t.Fatalf("expected %q, got %q", configPath, out)
	}

	altBackup := configPath + DefaultBackupExtension
	if back != altBackup {
		t.Fatalf("expected %q, got %q", altBackup, back)
	}

	// If overrides are set, they override default values or those from config
	b = New()
	b.StatePath = configPath    // equivalent of path = "new-path.tfstate" in config
	b.StateOutPath = configPath // equivalent of path = "new-path.tfstate" in config
	override := "override.tfstate"
	b.OverrideStatePath = override
	b.OverrideStateOutPath = override
	b.OverrideStateBackupPath = override

	path, out, back = b.StatePaths("")

	if path != override {
		t.Fatalf("expected %q, got %q", override, path)
	}

	if out != override {
		t.Fatalf("expected %q, got %q", override, out)
	}

	if back != override {
		t.Fatalf("expected %q, got %q", override, back)
	}
}

func TestLocal_StatePaths_nonDefaultWorkspace(t *testing.T) {

	// Default paths are returned for a custom workspace
	// when nothing is set via config or overrides
	b := New()
	workspace := "test_env"
	path, out, back := b.StatePaths(workspace)

	expectedPath := filepath.Join(DefaultWorkspaceDir, workspace, DefaultStateFilename)
	expectedOut := expectedPath
	expectedBackup := expectedPath + DefaultBackupExtension

	if path != expectedPath {
		t.Fatalf("expected %q, got %q", expectedPath, path)
	}

	if out != expectedOut {
		t.Fatalf("expected %q, got %q", expectedOut, out)
	}

	if back != expectedBackup {
		t.Fatalf("expected %q, got %q", expectedBackup, back)
	}

	// This is unaffected by a user setting the path attribute
	b = New()
	b.StatePath = "path-from-config.tfstate" // equivalent of setting path = "path-from-config.tfstate" in config
	b.StateOutPath = "path-from-config.tfstate"

	path, out, back = b.StatePaths(workspace)

	if path != expectedPath {
		t.Fatalf("expected %q, got %q", expectedPath, path)
	}

	if out != expectedOut {
		t.Fatalf("expected %q, got %q", expectedOut, out)
	}

	if back != expectedBackup {
		t.Fatalf("expected %q, got %q", expectedBackup, back)
	}

	// If a user set working_dir in config it affects returned values
	b = New()
	workingDir := "my/alternative/state/dir"
	b.StateWorkspaceDir = workingDir // equivalent of setting working_dir = "my/alternative/state/dir" in config

	path, out, back = b.StatePaths(workspace)

	expectedPath = filepath.Join(workingDir, workspace, DefaultStateFilename)
	expectedOut = filepath.Join(workingDir, workspace, DefaultStateFilename)
	expectedBackup = filepath.Join(workingDir, workspace, DefaultStateFilename) + DefaultBackupExtension

	if path != expectedPath {
		t.Fatalf("expected %q, got %q", expectedPath, path)
	}

	if out != expectedOut {
		t.Fatalf("expected %q, got %q", expectedOut, out)
	}

	if back != expectedBackup {
		t.Fatalf("expected %q, got %q", expectedBackup, back)
	}

	// Overrides affect returned values regardless of config
	b = New()
	b.StateWorkspaceDir = workingDir // equivalent of setting working_dir = "my/alternative/state/dir" in config
	override := "override.tfstate"
	b.OverrideStatePath = override
	b.OverrideStateOutPath = override
	b.OverrideStateBackupPath = override

	path, out, back = b.StatePaths(workspace)

	if path != override {
		t.Fatalf("expected %q, got %q", override, path)
	}

	if out != override {
		t.Fatalf("expected %q, got %q", override, out)
	}

	if back != override {
		t.Fatalf("expected %q, got %q", override, back)
	}
}

// TestLocal_PathsConflictWith does not include testing the effects of CLI commands -state, -state-out, and -state-backup
// because PathsConflictWith is only used during state migrations, and the init command does not accept those flags.
// Those flags would cause the local backend struct to have override fields set.
func TestLocal_PathsConflictWith(t *testing.T) {
	// Create a working directory with default and non-default workspace states
	td := testTmpDir(t)
	exampleState := states.NewState()
	exampleState.RootOutputValues = map[string]*states.OutputValue{
		"foobar": {
			Value: cty.StringVal("foobar"),
		},
	}
	foobar := "foobar"
	originalBackend := New()

	// Create a default workspace state file in a non-root directory
	originalBackend.StatePath = "foobar/terraform.tfstate"
	defaultStatePath := filepath.Join(td, originalBackend.StatePath)
	stmgrDefault, _ := originalBackend.StateMgr("")
	err := stmgrDefault.WriteState(exampleState)
	if err != nil {
		t.Fatalf("unexpected error returned from WriteState")
	}
	checkState(t, defaultStatePath, exampleState.String())

	// Create a non-default workspace and state file there
	stmgrFoobar, _ := originalBackend.StateMgr(foobar)
	err = stmgrFoobar.WriteState(exampleState)
	if err != nil {
		t.Fatalf("unexpected error returned from WriteState")
	}
	foobarStatePath := filepath.Join(td, DefaultWorkspaceDir, foobar, DefaultStateFilename)
	checkState(t, foobarStatePath, exampleState.String())

	// Scenario where:
	// * original backend has state for a 'foobar' workspace at terraform.tfstate.d/foobar/terraform.tfstate
	// * new local backend is configured via `path` to store 'default' state at terraform.tfstate.d/foobar/terraform.tfstate
	scenario1 := New()
	scenario1.StatePath = foobarStatePath

	if !originalBackend.PathsConflictWith(scenario1) {
		t.Fatal("expected conflict but got none")
	}

	// Scenario where:
	// * original backend has state for the default workspace at ./foobar/terrform.tfstate
	// * local backend is configured to store non-default workspace state in the root dir
	//     this means a foobar workspace would also store state at ./foobar/terrform.tfstate
	scenario2 := New()
	scenario2.StateWorkspaceDir = "."

	if !originalBackend.PathsConflictWith(scenario2) {
		t.Fatal("expected conflict but got none")
	}
}

// a local backend which returns errors for methods to
// verify it's being called.
type testDelegateBackend struct {
	*Local
}

var errTestDelegatePrepareConfig = errors.New("prepare config called")
var errTestDelegateConfigure = errors.New("configure called")
var errTestDelegateState = errors.New("state called")
var errTestDelegateStates = errors.New("states called")
var errTestDelegateDeleteState = errors.New("delete called")

func (b *testDelegateBackend) ConfigSchema() *configschema.Block {
	return nil
}

func (b *testDelegateBackend) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics
	return cty.NilVal, diags.Append(errTestDelegatePrepareConfig)
}

func (b *testDelegateBackend) Configure(obj cty.Value) tfdiags.Diagnostics {
	var diags tfdiags.Diagnostics
	return diags.Append(errTestDelegateConfigure)
}

func (b *testDelegateBackend) StateMgr(name string) (statemgr.Full, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics
	return nil, diags.Append(errTestDelegateState)
}

func (b *testDelegateBackend) Workspaces() ([]string, tfdiags.Diagnostics) {
	return nil, tfdiags.Diagnostics{}.Append(errTestDelegateStates)
}

func (b *testDelegateBackend) DeleteWorkspace(name string, force bool) tfdiags.Diagnostics {
	return tfdiags.Diagnostics{}.Append(errTestDelegateDeleteState)
}

// Verify that all backend.Backend methods are dispatched to the correct Backend when
// the local backend created with a separate state storage backend.
//
// The Local struct type implements both backendrun.OperationsBackend and backend.Backend interfaces.
// If the Local struct is not created with a separate state storage backend then it'll use its own
// backend.Backend method implementations. If a separate state storage backend IS supplied, then
// it should pass those method calls through to the separate backend.Backend.
func TestLocal_callsMethodsOnStateBackend(t *testing.T) {
	// assign a separate backend where we can read the state
	b := NewWithBackend(&testDelegateBackend{})

	if schema := b.ConfigSchema(); schema != nil {
		t.Fatal("expected a nil schema, got:", schema)
	}

	if _, diags := b.PrepareConfig(cty.NilVal); !diags.HasErrors() {
		t.Fatal("expected errTestDelegatePrepareConfig error, got:", diags.Err())
	}

	if diags := b.Configure(cty.NilVal); !diags.HasErrors() {
		t.Fatal("expected errTestDelegateConfigure error, got:", diags.Err())
	}

	if _, sDiags := b.StateMgr("test"); sDiags.Err().Error() != errTestDelegateState.Error() {
		t.Fatal("expected errTestDelegateState, got:", sDiags.Err())
	}

	if _, diags := b.Workspaces(); !diags.HasErrors() || diags.Err().Error() != errTestDelegateStates.Error() {
		t.Fatal("expected errTestDelegateStates, got:", diags.Err())
	}

	if diags := b.DeleteWorkspace("test", true); !diags.HasErrors() || diags.Err().Error() != errTestDelegateDeleteState.Error() {
		t.Fatal("expected errTestDelegateDeleteState, got:", diags.Err())
	}
}

// testTmpDir changes into a tmp dir and change back automatically when the test
// and all its subtests complete.
func testTmpDir(t *testing.T) string {
	tmp := t.TempDir()

	old, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}

	if err := os.Chdir(tmp); err != nil {
		t.Fatal(err)
	}

	t.Cleanup(func() {
		// ignore errors and try to clean up
		os.Chdir(old)
	})

	return tmp
}

func checkState(t *testing.T, path, expected string) {
	t.Helper()
	// Read the state
	f, err := os.Open(path)
	if err != nil {
		t.Fatalf("err: %s", err)
	}

	state, err := statefile.Read(f)
	f.Close()
	if err != nil {
		t.Fatalf("err: %s", err)
	}

	actual := state.State.String()
	expected = strings.TrimSpace(expected)
	if actual != expected {
		t.Fatalf("state does not match! actual:\n%s\n\nexpected:\n%s", actual, expected)
	}
}
