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

package local

import (
	"context"
	"fmt"
	"io"
	"log"

	"github.com/hashicorp/terraform/internal/backend/backendrun"
	"github.com/hashicorp/terraform/internal/genconfig"
	"github.com/hashicorp/terraform/internal/logging"
	"github.com/hashicorp/terraform/internal/plans"
	"github.com/hashicorp/terraform/internal/plans/planfile"
	"github.com/hashicorp/terraform/internal/states/statefile"
	"github.com/hashicorp/terraform/internal/states/statemgr"
	"github.com/hashicorp/terraform/internal/terraform"
	"github.com/hashicorp/terraform/internal/tfdiags"
)

func (b *Local) opPlan(
	stopCtx context.Context,
	cancelCtx context.Context,
	op *backendrun.Operation,
	runningOp *backendrun.RunningOperation) {

	log.Printf("[INFO] backend/local: starting Plan operation")

	var diags tfdiags.Diagnostics

	if op.PlanFile != nil {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Can't re-plan a saved plan",
			"The plan command was given a saved plan file as its input. This command generates "+
				"a new plan, and so it requires a configuration directory as its argument.",
		))
		op.ReportResult(runningOp, diags)
		return
	}

	if !op.HasConfig() {
		switch {
		case op.Query:
			// Special diag for terraform query command
			diags = diags.Append(tfdiags.Sourceless(
				tfdiags.Error,
				"No configuration files",
				"Query requires a query configuration to be present. Create a Terraform query configuration file (.tfquery.hcl file) and try again.",
			))
			op.ReportResult(runningOp, diags)
			return
		case op.PlanMode != plans.DestroyMode:
			// Local planning requires a config, unless we're planning to destroy.
			diags = diags.Append(tfdiags.Sourceless(
				tfdiags.Error,
				"No configuration files",
				"Plan requires configuration to be present. Planning without a configuration would "+
					"mark everything for destruction, which is normally not what is desired. If you "+
					"would like to destroy everything, run plan with the -destroy option. Otherwise, "+
					"create a Terraform configuration file (.tf file) and try again.",
			))
			op.ReportResult(runningOp, diags)
			return
		}
	}

	if len(op.GenerateConfigOut) > 0 {
		if op.PlanMode != plans.NormalMode {
			diags = diags.Append(tfdiags.Sourceless(
				tfdiags.Error,
				"Invalid generate-config-out flag",
				"Config can only be generated during a normal plan operation, and not during a refresh-only or destroy plan."))
			op.ReportResult(runningOp, diags)
			return
		}

		diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut))
		if diags.HasErrors() {
			op.ReportResult(runningOp, diags)
			return
		}
	}

	if b.ContextOpts == nil {
		b.ContextOpts = new(terraform.ContextOpts)
	}

	// Set up backend and get our context
	lr, configSnap, opState, ctxDiags := b.localRun(op)

	diags = diags.Append(ctxDiags)
	if ctxDiags.HasErrors() {
		op.ReportResult(runningOp, diags)
		return
	}
	// the state was locked during succesfull context creation; unlock the state
	// when the operation completes
	defer func() {
		diags := op.StateLocker.Unlock()
		if diags.HasErrors() {
			op.View.Diagnostics(diags)
			runningOp.Result = backendrun.OperationFailure
		}
	}()

	// Since planning doesn't immediately change the persisted state, the
	// resulting state is always just the input state.
	runningOp.State = lr.InputState

	// Perform the plan in a goroutine so we can be interrupted
	var plan *plans.Plan
	var planDiags tfdiags.Diagnostics
	doneCh := make(chan struct{})
	go func() {
		defer logging.PanicHandler()
		defer close(doneCh)
		log.Printf("[INFO] backend/local: plan calling Plan")
		plan, planDiags = lr.Core.Plan(lr.Config, lr.InputState, lr.PlanOpts)
	}()

	if b.opWait(doneCh, stopCtx, cancelCtx, lr.Core, opState, op.View) {
		// If we get in here then the operation was cancelled, which is always
		// considered to be a failure.
		log.Printf("[INFO] backend/local: plan operation was force-cancelled by interrupt")
		runningOp.Result = backendrun.OperationFailure
		return
	}
	log.Printf("[INFO] backend/local: plan operation completed")

	// NOTE: We intentionally don't stop here on errors because we always want
	// to try to present a partial plan report and, if the user chose to,
	// generate a partial saved plan file for external analysis.
	// Plan() may produce some diagnostic warnings which were already
	// produced when setting up context above, so we deduplicate them here.
	diags = diags.AppendWithoutDuplicates(planDiags...)

	// Even if there are errors we need to handle anything that may be
	// contained within the plan, so only exit if there is no data at all.
	if plan == nil {
		runningOp.PlanEmpty = true
		op.ReportResult(runningOp, diags)
		return
	}

	// Record whether this plan includes any side-effects that could be applied.
	runningOp.PlanEmpty = !plan.Applyable

	// Save the plan to disk
	if path := op.PlanOutPath; path != "" {
		switch {
		case op.PlanOutStateStore != nil:
			plan.StateStore = op.PlanOutStateStore
		case op.PlanOutBackend != nil:
			plan.Backend = op.PlanOutBackend
		default:
			// This is always a bug in the operation caller; it's not valid
			// to set PlanOutPath without also setting PlanOutStateStore or PlanOutBackend.
			// Even when there is no state_store or backend block in the configuration, there should be a PlanOutBackend
			// describing the implied local backend.
			diags = diags.Append(fmt.Errorf(
				"PlanOutPath set without also setting PlanOutStateStore or PlanOutBackend (this is a bug in Terraform)"),
			)
			op.ReportResult(runningOp, diags)
			return
		}

		// We may have updated the state in the refresh step above, but we
		// will freeze that updated state in the plan file for now and
		// only write it if this plan is subsequently applied.
		plannedStateFile := statemgr.PlannedStateUpdate(opState, plan.PriorState)

		// We also include a file containing the state as it existed before
		// we took any action at all, but this one isn't intended to ever
		// be saved to the backend (an equivalent snapshot should already be
		// there) and so we just use a stub state file header in this case.
		// NOTE: This won't be exactly identical to the latest state snapshot
		// in the backend because it's still been subject to state upgrading
		// to make it consumable by the current Terraform version, and
		// intentionally doesn't preserve the header info.
		prevStateFile := &statefile.File{
			State: plan.PrevRunState,
		}

		log.Printf("[INFO] backend/local: writing plan output to: %s", path)
		err := planfile.Create(path, planfile.CreateArgs{
			ConfigSnapshot:       configSnap,
			PreviousRunStateFile: prevStateFile,
			StateFile:            plannedStateFile,
			Plan:                 plan,
			DependencyLocks:      op.DependencyLocks,
		})
		if err != nil {
			diags = diags.Append(tfdiags.Sourceless(
				tfdiags.Error,
				"Failed to write plan file",
				fmt.Sprintf("The plan file could not be written: %s.", err),
			))
			op.ReportResult(runningOp, diags)
			return
		}
	}

	// Render the plan, if we produced one.
	// (This might potentially be a partial plan with Errored set to true)
	schemas, moreDiags := lr.Core.Schemas(lr.Config, lr.InputState)
	diags = diags.Append(moreDiags)
	if moreDiags.HasErrors() {
		op.ReportResult(runningOp, diags)
		return
	}

	// Write out any generated config, before we render the plan.
	wroteConfig, moreDiags := maybeWriteGeneratedConfig(plan, op.GenerateConfigOut)
	diags = diags.Append(moreDiags)
	if moreDiags.HasErrors() {
		op.ReportResult(runningOp, diags)
		return
	}

	op.View.Plan(plan, schemas)

	// Report all policy results that may have accumulated during the plan
	op.View.PolicyResults(plan.PolicyResults, nil)

	// If we've accumulated any diagnostics along the way then we'll show them
	// here just before we show the summary and next steps. This can potentially
	// include errors, because we intentionally try to show a partial plan
	// above even if Terraform Core encountered an error partway through
	// creating it.
	op.ReportResult(runningOp, diags)

	if !runningOp.PlanEmpty {
		if wroteConfig {
			op.View.PlanNextStep(op.PlanOutPath, op.GenerateConfigOut)
		} else {
			op.View.PlanNextStep(op.PlanOutPath, "")
		}
	}
}

func maybeWriteGeneratedConfig(plan *plans.Plan, out string) (wroteConfig bool, diags tfdiags.Diagnostics) {
	if genconfig.ShouldWriteConfig(out) {
		diags := genconfig.ValidateTargetFile(out)
		if diags.HasErrors() {
			return false, diags
		}

		var writer io.Writer
		for _, c := range plan.Changes.Resources {
			change := genconfig.Change{
				Addr:            c.Addr.String(),
				GeneratedConfig: c.GeneratedConfig,
			}
			if c.Importing != nil {
				change.ImportID = c.Importing.ID
			}

			var moreDiags tfdiags.Diagnostics
			writer, wroteConfig, moreDiags = change.MaybeWriteConfig(writer, out)
			if moreDiags.HasErrors() {
				return false, diags.Append(moreDiags)
			}
		}

		// When running a list operation, the results are stored as queries and the
		// resource changes above are not populated.
		for _, q := range plan.Changes.Queries {
			change := genconfig.Change{
				Addr:            q.Addr.String(),
				GeneratedConfig: q.Generated.String(),
			}

			var moreDiags tfdiags.Diagnostics
			writer, _, moreDiags = change.MaybeWriteConfig(writer, out)
			if moreDiags.HasErrors() {
				return false, diags.Append(moreDiags)
			}
		}
	}

	if wroteConfig {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Warning,
			"Config generation is experimental",
			"Generating configuration during import is currently experimental, and the generated configuration format may change in future versions."))
	}

	return wroteConfig, diags
}
