// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package local

import (
	"context"
	"fmt"
	"log"
	"sort"
	"strings"

	"github.com/hashicorp/hcl/v2"
	"github.com/zclconf/go-cty/cty"

	"github.com/hashicorp/terraform/internal/backend/backendrun"
	"github.com/hashicorp/terraform/internal/configs"
	"github.com/hashicorp/terraform/internal/configs/configload"
	"github.com/hashicorp/terraform/internal/lang"
	"github.com/hashicorp/terraform/internal/plans/planfile"
	"github.com/hashicorp/terraform/internal/states/statemgr"
	"github.com/hashicorp/terraform/internal/terraform"
	"github.com/hashicorp/terraform/internal/tfdiags"
)

// backendrun.Local implementation.
func (b *Local) LocalRun(op *backendrun.Operation) (*backendrun.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
	// Make sure the type is invalid. We use this as a way to know not
	// to ask for input/validate. We're modifying this through a pointer,
	// so we're mutating an object that belongs to the caller here, which
	// seems bad but we're preserving it for now until we have time to
	// properly design this API, vs. just preserving whatever it currently
	// happens to do.
	op.Type = backendrun.OperationTypeInvalid

	op.StateLocker = op.StateLocker.WithContext(context.Background())

	lr, _, stateMgr, diags := b.localRun(op)
	return lr, stateMgr, diags
}

func (b *Local) localRun(op *backendrun.Operation) (*backendrun.LocalRun, *configload.Snapshot, statemgr.Full, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	// Get the latest state.
	log.Printf("[TRACE] backend/local: requesting state manager for workspace %q", op.Workspace)
	s, err := b.StateMgr(op.Workspace)
	if err != nil {
		diags = diags.Append(fmt.Errorf("error loading state: %w", err))
		return nil, nil, nil, diags
	}
	log.Printf("[TRACE] backend/local: requesting state lock for workspace %q", op.Workspace)
	if diags := op.StateLocker.Lock(s, op.Type.String()); diags.HasErrors() {
		return nil, nil, nil, diags
	}

	defer func() {
		// If we're returning with errors, and thus not producing a valid
		// context, we'll want to avoid leaving the workspace locked.
		if diags.HasErrors() {
			diags = diags.Append(op.StateLocker.Unlock())
		}
	}()

	log.Printf("[TRACE] backend/local: reading remote state for workspace %q", op.Workspace)
	if err := s.RefreshState(); err != nil {
		diags = diags.Append(fmt.Errorf("error loading state: %w", err))
		return nil, nil, nil, diags
	}

	ret := &backendrun.LocalRun{}

	// Initialize our context options
	var coreOpts terraform.ContextOpts
	if v := b.ContextOpts; v != nil {
		coreOpts = *v
	}
	coreOpts.UIInput = op.UIIn
	coreOpts.Hooks = op.Hooks

	var ctxDiags tfdiags.Diagnostics
	var configSnap *configload.Snapshot
	if op.PlanFile.IsCloud() {
		diags = diags.Append(fmt.Errorf("error: using a saved cloud plan when executing Terraform locally is not supported"))
		return nil, nil, nil, diags
	}

	if lp, ok := op.PlanFile.Local(); ok {
		var stateMeta *statemgr.SnapshotMeta
		// If the statemgr implements our optional PersistentMeta interface then we'll
		// additionally verify that the state snapshot in the plan file has
		// consistent metadata, as an additional safety check.
		if sm, ok := s.(statemgr.PersistentMeta); ok {
			m := sm.StateSnapshotMeta()
			stateMeta = &m
		}
		log.Printf("[TRACE] backend/local: populating backendrun.LocalRun from plan file")
		ret, configSnap, ctxDiags = b.localRunForPlanFile(op, lp, ret, &coreOpts, stateMeta)
		if ctxDiags.HasErrors() {
			diags = diags.Append(ctxDiags)
			return nil, nil, nil, diags
		}

		// Write sources into the cache of the main loader so that they are
		// available if we need to generate diagnostic message snippets.
		op.ConfigLoader.ImportSourcesFromSnapshot(configSnap)
	} else {
		log.Printf("[TRACE] backend/local: populating backendrun.LocalRun for current working directory")
		ret, configSnap, ctxDiags = b.localRunDirect(op, ret, &coreOpts, s)
	}
	diags = diags.Append(ctxDiags)
	if diags.HasErrors() {
		return nil, nil, nil, diags
	}

	// If we have an operation, then we automatically do the input/validate
	// here since every option requires this.
	if op.Type != backendrun.OperationTypeInvalid {
		// If input asking is enabled, then do that
		if op.PlanFile == nil && b.OpInput {
			mode := terraform.InputModeProvider

			log.Printf("[TRACE] backend/local: requesting interactive input, if necessary")
			inputDiags := ret.Core.Input(ret.Config, mode)
			diags = diags.Append(inputDiags)
			if inputDiags.HasErrors() {
				return nil, nil, nil, diags
			}
		}

		// If validation is enabled, validate
		if b.OpValidation {
			log.Printf("[TRACE] backend/local: running validation operation")
			validateDiags := ret.Core.Validate(ret.Config, nil)
			diags = diags.Append(validateDiags)
		}
	}

	return ret, configSnap, s, diags
}

func (b *Local) localRunDirect(op *backendrun.Operation, run *backendrun.LocalRun, coreOpts *terraform.ContextOpts, s statemgr.Full) (*backendrun.LocalRun, *configload.Snapshot, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	// Load the configuration using the caller-provided configuration loader.
	config, configSnap, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir)
	diags = diags.Append(configDiags)
	if configDiags.HasErrors() {
		return nil, nil, diags
	}
	run.Config = config

	if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 {
		var buf strings.Builder
		for _, err := range errs {
			fmt.Fprintf(&buf, "\n  - %s", err.Error())
		}
		var suggestion string
		switch {
		case op.DependencyLocks == nil:
			// If we get here then it suggests that there's a caller that we
			// didn't yet update to populate DependencyLocks, which is a bug.
			suggestion = "This run has no dependency lock information provided at all, which is a bug in Terraform; please report it!"
		case op.DependencyLocks.Empty():
			suggestion = "To make the initial dependency selections that will initialize the dependency lock file, run:\n  terraform init"
		default:
			suggestion = "To update the locked dependency selections to match a changed configuration, run:\n  terraform init -upgrade"
		}
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Inconsistent dependency lock file",
			fmt.Sprintf(
				"The following dependency selections recorded in the lock file are inconsistent with the current configuration:%s\n\n%s",
				buf.String(), suggestion,
			),
		))
	}

	var rawVariables map[string]backendrun.UnparsedVariableValue
	if op.AllowUnsetVariables {
		// Rather than prompting for input, we'll just stub out the required
		// but unset variables with unknown values to represent that they are
		// placeholders for values the user would need to provide for other
		// operations.
		rawVariables = b.stubUnsetRequiredVariables(op.Variables, config.Module.Variables)
	} else {
		// If interactive input is enabled, we might gather some more variable
		// values through interactive prompts.
		// TODO: Need to route the operation context through into here, so that
		// the interactive prompts can be sensitive to its timeouts/etc.
		rawVariables = b.interactiveCollectVariables(context.TODO(), op.Variables, config.Module.Variables, op.UIIn)
	}

	variables, varDiags := backendrun.ParseVariableValues(rawVariables, config.Module.Variables)
	diags = diags.Append(varDiags)
	if diags.HasErrors() {
		return nil, nil, diags
	}

	planOpts := &terraform.PlanOpts{
		Mode:               op.PlanMode,
		Targets:            op.Targets,
		ForceReplace:       op.ForceReplace,
		SetVariables:       variables,
		SkipRefresh:        op.Type != backendrun.OperationTypeRefresh && !op.PlanRefresh,
		GenerateConfigPath: op.GenerateConfigOut,
		DeferralAllowed:    op.DeferralAllowed,
	}
	run.PlanOpts = planOpts

	// For a "direct" local run, the input state is the most recently stored
	// snapshot, from the previous run.
	run.InputState = s.State()

	tfCtx, moreDiags := terraform.NewContext(coreOpts)
	diags = diags.Append(moreDiags)
	if moreDiags.HasErrors() {
		return nil, nil, diags
	}
	run.Core = tfCtx
	return run, configSnap, diags
}

func (b *Local) localRunForPlanFile(op *backendrun.Operation, pf *planfile.Reader, run *backendrun.LocalRun, coreOpts *terraform.ContextOpts, currentStateMeta *statemgr.SnapshotMeta) (*backendrun.LocalRun, *configload.Snapshot, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	const errSummary = "Invalid plan file"

	// A plan file has a snapshot of configuration embedded inside it, which
	// is used instead of whatever configuration might be already present
	// in the filesystem.
	snap, err := pf.ReadConfigSnapshot()
	if err != nil {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			errSummary,
			fmt.Sprintf("Failed to read configuration snapshot from plan file: %s.", err),
		))
		return nil, snap, diags
	}
	loader := configload.NewLoaderFromSnapshot(snap)
	loader.AllowLanguageExperiments(op.ConfigLoader.AllowsLanguageExperiments())
	config, configDiags := loader.LoadConfig(snap.Modules[""].Dir)
	diags = diags.Append(configDiags)
	if configDiags.HasErrors() {
		return nil, snap, diags
	}
	run.Config = config

	// NOTE: We're intentionally comparing the current locks with the
	// configuration snapshot, rather than the lock snapshot in the plan file,
	// because it's the current locks which dictate our plugin selections
	// in coreOpts below. However, we'll also separately check that the
	// plan file has identical locked plugins below, and thus we're effectively
	// checking consistency with both here.
	if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 {
		var buf strings.Builder
		for _, err := range errs {
			fmt.Fprintf(&buf, "\n  - %s", err.Error())
		}
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Inconsistent dependency lock file",
			fmt.Sprintf(
				"The following dependency selections recorded in the lock file are inconsistent with the configuration in the saved plan:%s\n\nA saved plan can be applied only to the same configuration it was created from. Create a new plan from the updated configuration.",
				buf.String(),
			),
		))
	}

	// This check is an important complement to the check above: the locked
	// dependencies in the configuration must match the configuration, and
	// the locked dependencies in the plan must match the locked dependencies
	// in the configuration, and so transitively we ensure that the locked
	// dependencies in the plan match the configuration too. However, this
	// additionally catches any inconsistency between the two sets of locks
	// even if they both happen to be valid per the current configuration,
	// which is one of several ways we try to catch the mistake of applying
	// a saved plan file in a different place than where we created it.
	depLocksFromPlan, moreDiags := pf.ReadDependencyLocks()
	diags = diags.Append(moreDiags)
	if depLocksFromPlan != nil && !op.DependencyLocks.Equal(depLocksFromPlan) {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Inconsistent dependency lock file",
			"The given plan file was created with a different set of external dependency selections than the current configuration. A saved plan can be applied only to the same configuration it was created from.\n\nCreate a new plan from the updated configuration.",
		))
	}

	// A plan file also contains a snapshot of the prior state the changes
	// are intended to apply to.
	priorStateFile, err := pf.ReadStateFile()
	if err != nil {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			errSummary,
			fmt.Sprintf("Failed to read prior state snapshot from plan file: %s.", err),
		))
		return nil, snap, diags
	}

	if currentStateMeta != nil {
		// If the caller sets this, we require that the stored prior state
		// has the same metadata, which is an extra safety check that nothing
		// has changed since the plan was created. (All of the "real-world"
		// state manager implementations support this, but simpler test backends
		// may not.)

		// Because the plan always contains a state, even if it is empty, the
		// first plan to be applied will have empty snapshot metadata. In this
		// case we compare only the serial in order to provide a more correct
		// error.
		firstPlan := priorStateFile.Lineage == "" && priorStateFile.Serial == 0

		switch {
		case !firstPlan && priorStateFile.Lineage != currentStateMeta.Lineage:
			diags = diags.Append(tfdiags.Sourceless(
				tfdiags.Error,
				"Saved plan does not match the given state",
				"The given plan file can not be applied because it was created from a different state lineage.",
			))

		case priorStateFile.Serial != currentStateMeta.Serial:
			diags = diags.Append(tfdiags.Sourceless(
				tfdiags.Error,
				"Saved plan is stale",
				"The given plan file can no longer be applied because the state was changed by another operation after the plan was created.",
			))
		}
	}
	// When we're applying a saved plan, the input state is the "prior state"
	// recorded in the plan, which incorporates the result of all of the
	// refreshing we did while building the plan.
	run.InputState = priorStateFile.State

	plan, err := pf.ReadPlan()
	if err != nil {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			errSummary,
			fmt.Sprintf("Failed to read plan from plan file: %s.", err),
		))
		return nil, snap, diags
	}
	// When we're applying a saved plan, we populate Plan instead of PlanOpts,
	// because a plan object incorporates the subset of data from PlanOps that
	// we need to apply the plan.
	run.Plan = plan

	tfCtx, moreDiags := terraform.NewContext(coreOpts)
	diags = diags.Append(moreDiags)
	if moreDiags.HasErrors() {
		return nil, nil, diags
	}
	run.Core = tfCtx
	return run, snap, diags
}

// interactiveCollectVariables attempts to complete the given existing
// map of variables by interactively prompting for any variables that are
// declared as required but not yet present.
//
// If interactive input is disabled for this backend instance then this is
// a no-op. If input is enabled but fails for some reason, the resulting
// map will be incomplete. For these reasons, the caller must still validate
// that the result is complete and valid.
//
// This function does not modify the map given in "existing", but may return
// it unchanged if no modifications are required. If modifications are required,
// the result is a new map with all of the elements from "existing" plus
// additional elements as appropriate.
//
// Interactive prompting is a "best effort" thing for first-time user UX and
// not something we expect folks to be relying on for routine use. Terraform
// is primarily a non-interactive tool and so we prefer to report in error
// messages that variables are not set rather than reporting that input failed:
// the primary resolution to missing variables is to provide them by some other
// means.
func (b *Local) interactiveCollectVariables(ctx context.Context, existing map[string]backendrun.UnparsedVariableValue, vcs map[string]*configs.Variable, uiInput terraform.UIInput) map[string]backendrun.UnparsedVariableValue {
	var needed []string
	if b.OpInput && uiInput != nil {
		for name, vc := range vcs {
			if !vc.Required() {
				continue // We only prompt for required variables
			}
			if _, exists := existing[name]; !exists {
				needed = append(needed, name)
			}
		}
	} else {
		log.Print("[DEBUG] backend/local: Skipping interactive prompts for variables because input is disabled")
	}
	if len(needed) == 0 {
		return existing
	}

	log.Printf("[DEBUG] backend/local: will prompt for input of unset required variables %s", needed)

	// If we get here then we're planning to prompt for at least one additional
	// variable's value.
	sort.Strings(needed) // prompt in lexical order
	ret := make(map[string]backendrun.UnparsedVariableValue, len(vcs))
	for k, v := range existing {
		ret[k] = v
	}
	for _, name := range needed {
		vc := vcs[name]
		rawValue, err := uiInput.Input(ctx, &terraform.InputOpts{
			Id:          fmt.Sprintf("var.%s", name),
			Query:       fmt.Sprintf("var.%s", name),
			Description: vc.Description,
			Secret:      vc.Sensitive,
		})
		if err != nil {
			// Since interactive prompts are best-effort, we'll just continue
			// here and let subsequent validation report this as a variable
			// not specified.
			log.Printf("[WARN] backend/local: Failed to request user input for variable %q: %s", name, err)
			continue
		}
		ret[name] = unparsedInteractiveVariableValue{Name: name, RawValue: rawValue}
	}
	return ret
}

// stubUnsetVariables ensures that all required variables defined in the
// configuration exist in the resulting map, by adding new elements as necessary.
//
// The stubbed value of any additions will be an unknown variable conforming
// to the variable's configured type constraint, meaning that no particular
// value is known and that one must be provided by the user in order to get
// a complete result.
//
// Unset optional attributes (those with default values) will not be populated
// by this function, under the assumption that a later step will handle those.
// In this sense, stubUnsetRequiredVariables is essentially a non-interactive,
// non-error-producing variant of interactiveCollectVariables that creates
// placeholders for values the user would be prompted for interactively on
// other operations.
//
// This function should be used only in situations where variables values
// will not be directly used and the variables map is being constructed only
// to produce a complete Terraform context for some ancillary functionality
// like "terraform console", "terraform state ...", etc.
//
// This function is guaranteed not to modify the given map, but it may return
// the given map unchanged if no additions are required. If additions are
// required then the result will be a new map containing everything in the
// given map plus additional elements.
func (b *Local) stubUnsetRequiredVariables(existing map[string]backendrun.UnparsedVariableValue, vcs map[string]*configs.Variable) map[string]backendrun.UnparsedVariableValue {
	var missing bool // Do we need to add anything?
	for name, vc := range vcs {
		if !vc.Required() {
			continue // We only stub required variables
		}
		if _, exists := existing[name]; !exists {
			missing = true
		}
	}
	if !missing {
		return existing
	}

	// If we get down here then there's at least one variable value to add.
	ret := make(map[string]backendrun.UnparsedVariableValue, len(vcs))
	for k, v := range existing {
		ret[k] = v
	}
	for name, vc := range vcs {
		if !vc.Required() {
			continue
		}
		if _, exists := existing[name]; !exists {
			ret[name] = unparsedUnknownVariableValue{Name: name, WantType: vc.Type}
		}
	}
	return ret
}

type unparsedInteractiveVariableValue struct {
	Name, RawValue string
}

var _ backendrun.UnparsedVariableValue = unparsedInteractiveVariableValue{}

func (v unparsedInteractiveVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics
	val, valDiags := mode.Parse(v.Name, v.RawValue)
	diags = diags.Append(valDiags)
	if diags.HasErrors() {
		return nil, diags
	}
	return &terraform.InputValue{
		Value:      val,
		SourceType: terraform.ValueFromInput,
	}, diags
}

type unparsedUnknownVariableValue struct {
	Name     string
	WantType cty.Type
}

var _ backendrun.UnparsedVariableValue = unparsedUnknownVariableValue{}

func (v unparsedUnknownVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) {
	return &terraform.InputValue{
		Value:      cty.UnknownVal(v.WantType),
		SourceType: terraform.ValueFromInput,
	}, nil
}

type unparsedTestVariableValue struct {
	Expr hcl.Expression
}

var _ backendrun.UnparsedVariableValue = unparsedTestVariableValue{}

func (v unparsedTestVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	value, valueDiags := v.Expr.Value(&hcl.EvalContext{
		Functions: lang.TestingFunctions(),
	})
	diags = diags.Append(valueDiags)
	if valueDiags.HasErrors() {
		return nil, diags
	}

	return &terraform.InputValue{
		Value:       value,
		SourceType:  terraform.ValueFromConfig,
		SourceRange: tfdiags.SourceRangeFromHCL(v.Expr.Range()),
	}, diags
}
