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

package local

import (
	"context"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"sync/atomic"

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

	"github.com/opentofu/opentofu/internal/addrs"
	"github.com/opentofu/opentofu/internal/backend"
	"github.com/opentofu/opentofu/internal/configs"
	"github.com/opentofu/opentofu/internal/configs/configload"
	"github.com/opentofu/opentofu/internal/engine/planning"
	"github.com/opentofu/opentofu/internal/engine/plugins"
	"github.com/opentofu/opentofu/internal/lang/eval"
	"github.com/opentofu/opentofu/internal/plans"
	"github.com/opentofu/opentofu/internal/states"
	"github.com/opentofu/opentofu/internal/states/statemgr"
	"github.com/opentofu/opentofu/internal/tfdiags"
)

/////////////////////////
// The definitions in this file are intended as temporary shims to help support
// the development of the new runtime engine, by allowing experiments-enabled
// builds to be opted in to the new implementation by setting the environment
// variable TOFU_X_EXPERIMENTAL_RUNTIME to any non-empty value.
//
// These shims should remain here only as long as the new runtime engine is
// under active development and is not yet adopted as the primary engine. It's
// also acceptable for work being done for other separate projects to ignore
// these shims and let this code become broken, as long as the code continues
// to compile: only those working on the implementation of the new engine are
// responsible for updating this if the rest of the system evolves to the point
// of that being necessary.
//
// Note that "tofu validate" is implemented outside of the backend abstraction
// and so does not respond to the experiment opt-in environment variable. For
// now, try out validation-related behaviors of the new runtime through
// "tofu plan" instead, which should implement a superset of the validation
// behavior.
/////////////////////////

// SetExperimentalRuntimeAllowed must be called with the argument set to true
// at some point before calling [New] or [NewWithBackend] in order for the
// experimental opt-in to be effective.
//
// In practice this is called by code in the "command" package early in the
// backend initialization codepath and enables the experimental runtime only
// in an experiments-enabled OpenTofu build, to make sure that it's not
// possible to accidentally enable this experimental functionality in normal
// release builds.
//
// Refer to "cmd/tofu/experiments.go" for information on how to produce an
// experiments-enabled build.
func SetExperimentalRuntimeAllowed(allowed bool) {
	experimentalRuntimeAllowed.Store(allowed)
}

var experimentalRuntimeAllowed atomic.Bool

func experimentalRuntimeEnabled() bool {
	if !experimentalRuntimeAllowed.Load() {
		// The experimental runtime is never enabled when it hasn't been
		// explicitly allowed.
		return false
	}

	optIn := os.Getenv("TOFU_X_EXPERIMENTAL_RUNTIME")
	return optIn != ""
}

func (b *Local) opPlanWithExperimentalRuntime(stopCtx context.Context, cancelCtx context.Context, op *backend.Operation, runningOp *backend.RunningOperation) {
	var diags tfdiags.Diagnostics
	log.Println("[WARN] Using plan implementation from the experimental language runtime")

	// Currently we're using the caller's "stopCtx" as the main context, using
	// it both for its values and as a signal for graceful shutdown. This is
	// just to get the closest fit with how current callers of the backend
	// API populate these contexts with what the new runtime is expecting. We
	// should revisit this and make sure this still makes sense before we
	// finalize any implementation here.
	ctx := stopCtx

	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
	}
	// Local planning requires a config, unless we're planning to destroy.
	if op.PlanMode != plans.DestroyMode && !op.HasConfig() {
		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 OpenTofu configuration file (.tf file) and try again.",
		))
		op.ReportResult(runningOp, diags)
		return
	}
	if len(op.GenerateConfigOut) > 0 {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Config generation not supported",
			"The experimental language runtime cannot yet support -generate-config-out.",
		))
		op.ReportResult(runningOp, diags)
		return
	}

	// The following is a limited inline reimplementation of the just parts of
	// Local.localRun that we need to start executing the new runtime, since
	// that codepath is currently quite specific to the needs of the old
	// runtime. At a later point we'll want to consolidate these back together
	// again somehow, but this is just enough to help us do basix execution
	// during the "walking skeleton" phase of the project.
	stateMgr, err := b.StateMgr(ctx, op.Workspace)
	if err != nil {
		diags = diags.Append(fmt.Errorf("error loading state: %w", err))
		op.ReportResult(runningOp, diags)
		return
	}
	prevRoundState, err := statemgr.RefreshAndRead(ctx, stateMgr)
	if err != nil {
		diags = diags.Append(fmt.Errorf("error loading state: %w", err))
		op.ReportResult(runningOp, diags)
		return
	}
	if prevRoundState == nil {
		prevRoundState = states.NewState() // this is the first round, starting with an empty state
	}

	plugins := plugins.NewRuntimePlugins(b.ContextOpts.Providers, b.ContextOpts.Provisioners)
	evalCtx := &eval.EvalContext{
		RootModuleDir:      op.ConfigDir,
		OriginalWorkingDir: b.ContextOpts.Meta.OriginalWorkingDir,
		Modules: &newRuntimeModules{
			loader: op.ConfigLoader,
		},
		Providers:    plugins,
		Provisioners: plugins,
	}
	defer func() {
		// We'll call close with a cancel-free context because we do still
		// want to shut the providers down even if we're dealing with
		// graceful shutdown after cancellation.
		err := plugins.Close(context.WithoutCancel(ctx))
		// If a provider fails to close there isn't really much we can do
		// about that... this shouldn't really be possible unless the
		// plugin process already exited for some other reason anyway.
		log.Printf("[ERROR] plugin shutdown failed: %s", err)
	}()

	// The new config-loading system wants to work in terms of module source
	// addresses rather than raw local filenames, so we'll ask the
	// addrs package to parse the path we were given. We need to adjust
	// a little though, because this function was designed for parsing
	// the "source" argument in a module block, not a plain filepath.
	// We should add a function in package addrs that's actually intended for
	// turning arbitrary filesystem paths in to addrs.LocalSource in the long
	// run, but this will do for now.
	configDir := op.ConfigDir
	if !filepath.IsAbs(configDir) {
		configDir = "." + string(filepath.Separator) + configDir
	}
	rootModuleSource, err := addrs.ParseModuleSource(configDir)
	if err != nil {
		diags = diags.Append(fmt.Errorf("invalid root module source address: %w", err))
		op.ReportResult(runningOp, diags)
		return
	}
	configCall := &eval.ConfigCall{
		RootModuleSource: rootModuleSource,
		// TODO: InputValues
		AllowImpureFunctions: false,
		EvalContext:          evalCtx,
	}
	configInst, moreDiags := eval.NewConfigInstance(ctx, configCall)
	diags = diags.Append(moreDiags)
	if moreDiags.HasErrors() {
		op.ReportResult(runningOp, diags)
		return
	}

	plan, moreDiags := planning.PlanChanges(ctx, prevRoundState, configInst, plugins)
	diags = diags.Append(moreDiags)
	// We intentionally continue with errors here because we make a best effort
	// to render a partial plan output even when we have errors, in case
	// the partial plan is helpful for debugging.

	// 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.CanApply()

	wroteConfig := false
	if path := op.PlanOutPath; path != "" {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Saved plan files not supported",
			"The experimental language runtime cannot yet support -out=PLANFILE.",
		))
		op.ReportResult(runningOp, diags)
		return
	}

	// TODO: Actually render the plan. But to do that we need provider schemas
	// and our schema-loading code expects us to be holding an old-style
	// *configs.Config, so we have some more work to do before we can do that.
	// For now, we'll just show the internals of the plan object.
	// (Note that we are expecting that [planning.PlanChanges] will return
	// something other than [plans.Plan] before long, because in our new
	// approach we want to save the execution graph as part of the plan and
	// so our old model is not sufficient. This is just a placeholder for now.)
	spew.Dump(plan)

	// 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 OpenTofu 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 (b *Local) opApplyWithExperimentalRuntime(stopCtx context.Context, cancelCtx context.Context, op *backend.Operation, runningOp *backend.RunningOperation) {
	log.Println("[WARN] Using apply implementation from the experimental language runtime")
	var diags tfdiags.Diagnostics
	diags = diags.Append(tfdiags.Sourceless(
		tfdiags.Error,
		"Operation unsupported in experimental language runtime",
		"The command \"tofu apply\" is not yet supported under the experimental language runtime.",
	))
	op.ReportResult(runningOp, diags)
}

func (b *Local) opRefreshWithExperimentalRuntime(stopCtx context.Context, cancelCtx context.Context, op *backend.Operation, runningOp *backend.RunningOperation) {
	log.Println("[WARN] Using refresh implementation from the experimental language runtime")
	var diags tfdiags.Diagnostics
	diags = diags.Append(tfdiags.Sourceless(
		tfdiags.Error,
		"Operation unsupported in experimental language runtime",
		"The command \"tofu refresh\" is not yet supported under the experimental language runtime.",
	))
	op.ReportResult(runningOp, diags)
}

// newRuntimeModules is an implementation of [eval.ExternalModules] that makes
// a best effort to shim to OpenTofu's current module loader, even though
// it works in some slightly-different terms than this new API expects.
type newRuntimeModules struct {
	loader *configload.Loader
}

var _ eval.ExternalModules = (*newRuntimeModules)(nil)

// ModuleConfig implements evalglue.ExternalModules.
func (n *newRuntimeModules) ModuleConfig(ctx context.Context, source addrs.ModuleSource, allowedVersions versions.Set, forCall *addrs.AbsModuleCall) (eval.UncompiledModule, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	var sourceDir string
	switch source := source.(type) {
	case addrs.ModuleSourceLocal:
		sourceDir = filepath.Clean(filepath.FromSlash(string(source)))
	default:
		// For this early stub implementation we only support local source
		// addresses. We'll expand this later but that'll require this codepath
		// to have access to the information about what's in the module cache
		// directory at ".terraform/modules", which we've not arranged for yet.
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"New runtime codepath only supports local module sources",
			fmt.Sprintf("Cannot load %q, because our temporary codepath for the new language runtime only supports local module sources for now.", source),
		))
		return nil, diags
	}
	log.Printf("[TRACE] backend/local: Loading module from %q from local path %q", source, sourceDir)

	mod, hclDiags := n.loader.Parser().LoadConfigDirUneval(sourceDir, configs.SelectiveLoadAll)
	diags = diags.Append(hclDiags)
	if hclDiags.HasErrors() {
		return nil, diags
	}

	return eval.PrepareTofu2024Module(source, mod), diags
}
