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

package stackruntime

import (
	"context"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/hashicorp/go-slug/sourceaddrs"
	"github.com/hashicorp/go-slug/sourcebundle"
	"github.com/zclconf/go-cty/cty"

	"github.com/hashicorp/terraform/internal/addrs"
	"github.com/hashicorp/terraform/internal/configs/configschema"
	"github.com/hashicorp/terraform/internal/depsfile"
	"github.com/hashicorp/terraform/internal/plans"
	"github.com/hashicorp/terraform/internal/providers"
	"github.com/hashicorp/terraform/internal/stacks/stackaddrs"
	"github.com/hashicorp/terraform/internal/stacks/stackconfig"
	"github.com/hashicorp/terraform/internal/stacks/stackplan"
	"github.com/hashicorp/terraform/internal/stacks/stackstate"
	"github.com/hashicorp/terraform/internal/tfdiags"
)

// This file has helper functions used by other tests. It doesn't contain any
// test cases of its own.

// TestContext contains all the information shared across multiple operations
// in a single test.
type TestContext struct {
	// timestamp is the timestamp that should be applied for this test.
	timestamp *time.Time

	// config is the config to use for this test.
	config *stackconfig.Config

	// providers are the providers that should be available within this test.
	providers map[addrs.Provider]providers.Factory

	// dependencyLocks is the locks file that should be used for this test.
	dependencyLocks depsfile.Locks
}

// TestCycle defines a single plan / apply cycle that should be performed within
// a test.
type TestCycle struct {
	// Validate options

	wantValidateDiags tfdiags.Diagnostics

	// Plan options

	planMode           plans.Mode
	planInputs         map[string]cty.Value
	wantPlannedChanges []stackplan.PlannedChange
	wantPlannedHooks   *ExpectedHooks
	wantPlannedDiags   tfdiags.Diagnostics

	// Apply options

	applyInputs        map[string]cty.Value
	wantAppliedChanges []stackstate.AppliedChange
	wantAppliedHooks   *ExpectedHooks
	wantAppliedDiags   tfdiags.Diagnostics
}

func (tc TestContext) Validate(t *testing.T, ctx context.Context, cycle TestCycle) {
	t.Helper()

	gotDiags := Validate(ctx, &ValidateRequest{
		Config:             tc.config,
		ProviderFactories:  tc.providers,
		DependencyLocks:    tc.dependencyLocks,
		ExperimentsAllowed: true,
	})
	validateDiags(t, cycle.wantValidateDiags, gotDiags)
}

func (tc TestContext) Plan(t *testing.T, ctx context.Context, state *stackstate.State, cycle TestCycle) *stackplan.Plan {
	request := PlanRequest{
		PlanMode:  cycle.planMode,
		Config:    tc.config,
		PrevState: state,
		InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue {
			inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(cycle.planInputs))
			for k, v := range cycle.planInputs {
				inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v}
			}
			return inputs
		}(),
		ProviderFactories:  tc.providers,
		DependencyLocks:    tc.dependencyLocks,
		ForcePlanTimestamp: tc.timestamp,
		ExperimentsAllowed: true,
	}

	changesCh := make(chan stackplan.PlannedChange)
	diagsCh := make(chan tfdiags.Diagnostic)
	response := PlanResponse{
		PlannedChanges: changesCh,
		Diagnostics:    diagsCh,
	}

	capturedHooks := NewCapturedHooks(true)
	go Plan(ContextWithHooks(ctx, capturedHooks.captureHooks()), &request, &response)
	changes, diags := collectPlanOutput(changesCh, diagsCh)
	validateDiags(t, cycle.wantPlannedDiags, diags)

	if cycle.wantPlannedChanges != nil {

		var filteredChanges []stackplan.PlannedChange
		for _, change := range changes {
			if _, ok := change.(*stackplan.PlannedChangePriorStateElement); ok {
				// Remove the prior state elements from the analysis for tests
				// using this framework. They're really difficult to properly
				// compare as they use the raw state and raw key, and they're
				// actually ignored by most of the stacks runtime and so aren't
				// useful to be included in these kind of tests.
				continue
			}
			filteredChanges = append(filteredChanges, change)
		}

		// if this is nil (as opposed to empty) then we don't validate the
		// returned changes.

		sort.SliceStable(filteredChanges, func(i, j int) bool {
			return plannedChangeSortKey(filteredChanges[i]) < plannedChangeSortKey(filteredChanges[j])
		})
		if diff := cmp.Diff(cycle.wantPlannedChanges, filteredChanges, changesCmpOpts); len(diff) > 0 {
			t.Errorf("wrong planned changes\n%s", diff)
		}
	}

	if cycle.wantPlannedHooks != nil {
		cycle.wantPlannedHooks.Validate(t, &capturedHooks.ExpectedHooks)
	}

	planLoader := stackplan.NewLoader()
	for _, change := range changes {
		proto, err := change.PlannedChangeProto()
		if err != nil {
			t.Fatal(err)
		}

		for _, rawMsg := range proto.Raw {
			err = planLoader.AddRaw(rawMsg)
			if err != nil {
				t.Fatal(err)
			}
		}
	}
	plan, err := planLoader.Plan()
	if err != nil {
		t.Fatal(err)
	}

	return plan
}

func (tc TestContext) Apply(t *testing.T, ctx context.Context, plan *stackplan.Plan, cycle TestCycle) *stackstate.State {
	request := ApplyRequest{
		Config: tc.config,
		Plan:   plan,
		InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue {
			inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(cycle.applyInputs))
			for k, v := range cycle.applyInputs {
				inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v}
			}
			return inputs
		}(),
		ProviderFactories:  tc.providers,
		ExperimentsAllowed: true,
		DependencyLocks:    tc.dependencyLocks,
	}

	changesCh := make(chan stackstate.AppliedChange)
	diagsCh := make(chan tfdiags.Diagnostic)
	response := ApplyResponse{
		AppliedChanges: changesCh,
		Diagnostics:    diagsCh,
	}

	capturedHooks := NewCapturedHooks(false)
	go Apply(ContextWithHooks(ctx, capturedHooks.captureHooks()), &request, &response)
	changes, diags := collectApplyOutput(changesCh, diagsCh)
	validateDiags(t, cycle.wantAppliedDiags, diags)

	if cycle.wantAppliedChanges != nil {
		// nil indicates skip this check, empty slice indicates no changes expected.
		sort.SliceStable(changes, func(i, j int) bool {
			return appliedChangeSortKey(changes[i]) < appliedChangeSortKey(changes[j])
		})
		if diff := cmp.Diff(cycle.wantAppliedChanges, changes, changesCmpOpts); diff != "" {
			t.Errorf("wrong applied changes\n%s", diff)
		}
	}

	if cycle.wantAppliedHooks != nil {
		cycle.wantAppliedHooks.Validate(t, &capturedHooks.ExpectedHooks)
	}

	stateLoader := stackstate.NewLoader()
	for _, change := range changes {
		proto, err := change.AppliedChangeProto()
		if err != nil {
			t.Fatal(err)
		}

		for _, rawMsg := range proto.Raw {
			if rawMsg.Value == nil {
				// This is a removal notice, so we don't need to add it to the
				// state.
				continue
			}
			err = stateLoader.AddRaw(rawMsg.Key, rawMsg.Value)
			if err != nil {
				t.Fatal(err)
			}
		}
	}
	return stateLoader.State()
}

func initDiags(cb func(diags tfdiags.Diagnostics) tfdiags.Diagnostics) tfdiags.Diagnostics {
	var diags tfdiags.Diagnostics
	return cb(diags)
}

func validateDiags(t *testing.T, wantDiags, gotDiags tfdiags.Diagnostics) {
	t.Helper()

	sort.SliceStable(gotDiags, diagnosticSortFunc(gotDiags))
	sort.SliceStable(wantDiags, diagnosticSortFunc(wantDiags))

	gotDiags = gotDiags.ForRPC()
	wantDiags = wantDiags.ForRPC()
	if diff := cmp.Diff(wantDiags, gotDiags); len(diff) > 0 {
		t.Errorf("wrong diagnostics\n%s", diff)
	}
}

// loadConfigForTest is a test helper that tries to open bundleRoot as a
// source bundle, and then if successful tries to load the given source address
// from it as a stack configuration. If any part of the operation fails then
// it halts execution of the test and doesn't return.
func loadConfigForTest(t *testing.T, bundleRoot string, configSourceAddr string) *stackconfig.Config {
	t.Helper()
	sources, err := sourcebundle.OpenDir(bundleRoot)
	if err != nil {
		t.Fatalf("cannot load source bundle: %s", err)
	}

	// We force using remote source addresses here because that avoids
	// us having to deal with the extra version constraints argument
	// that registry sources require. Exactly what source address type
	// we use isn't relevant for tests in this package, since it's
	// the sourcebundle package's responsibility to make sure its
	// abstraction works for all of the source types.
	sourceAddr, err := sourceaddrs.ParseRemoteSource(configSourceAddr)
	if err != nil {
		t.Fatalf("invalid config source address: %s", err)
	}

	cfg, diags := stackconfig.LoadConfigDir(sourceAddr, sources)
	reportDiagnosticsForTest(t, diags)
	return cfg
}

func mainBundleSourceAddrStr(dirName string) string {
	return "git::https://example.com/test.git//" + dirName
}

// loadMainBundleConfigForTest is a convenience wrapper around
// loadConfigForTest that knows the location and package address of our
// "main" source bundle, in ./testdata/mainbundle, so that we can use that
// conveniently without duplicating its location and synthetic package address
// in every single test function.
//
// dirName should begin with the name of a subdirectory that's present in
// ./testdata/mainbundle/test . It can optionally refer to subdirectories
// thereof, using forward slashes as the path separator just as we'd do
// in the subdirectory portion of a remote source address (which is exactly
// what we're using this as.)
func loadMainBundleConfigForTest(t *testing.T, dirName string) *stackconfig.Config {
	t.Helper()
	fullSourceAddr := mainBundleSourceAddrStr(dirName)
	return loadConfigForTest(t, "./testdata/mainbundle", fullSourceAddr)
}

type expectedDiagnostic struct {
	severity tfdiags.Severity
	summary  string
	detail   string
}

func expectDiagnostic(severity tfdiags.Severity, summary, detail string) expectedDiagnostic {
	return expectedDiagnostic{
		severity: severity,
		summary:  summary,
		detail:   detail,
	}
}

func expectDiagnosticsForTest(t *testing.T, actual tfdiags.Diagnostics, expected ...expectedDiagnostic) {
	t.Helper()

	max := len(expected)
	if len(actual) > max {
		max = len(actual)
	}

	for ix := 0; ix < max; ix++ {
		if ix >= len(expected) {
			t.Errorf("unexpected diagnostic [%d]: %s - %s", ix, actual[ix].Description().Summary, actual[ix].Description().Detail)
			continue
		}

		if ix >= len(actual) {
			t.Errorf("missing diagnostic [%d]: %s - %s", ix, expected[ix].summary, expected[ix].detail)
			continue
		}

		if actual[ix].Severity() != expected[ix].severity {
			t.Errorf("diagnostic [%d] has wrong severity: %s (expected %s)", ix, actual[ix].Severity(), expected[ix].severity)
		}

		if diff := cmp.Diff(actual[ix].Description().Summary, expected[ix].summary); len(diff) > 0 {
			t.Errorf("diagnostic [%d] has wrong summary: %s", ix, diff)
		}

		if diff := cmp.Diff(actual[ix].Description().Detail, expected[ix].detail); len(diff) > 0 {
			t.Errorf("diagnostic [%d] has wrong detail: %s", ix, diff)
		}
	}
}

// reportDiagnosticsForTest creates a test log entry for every diagnostic in
// the given diags, and halts the test if any of them are error diagnostics.
func reportDiagnosticsForTest(t *testing.T, diags tfdiags.Diagnostics) {
	t.Helper()
	for _, diag := range diags {
		var b strings.Builder
		desc := diag.Description()
		locs := diag.Source()

		switch sev := diag.Severity(); sev {
		case tfdiags.Error:
			b.WriteString("Error: ")
		case tfdiags.Warning:
			b.WriteString("Warning: ")
		default:
			t.Errorf("unsupported diagnostic type %s", sev)
		}
		b.WriteString(desc.Summary)
		if desc.Address != "" {
			b.WriteString("\nwith ")
			b.WriteString(desc.Summary)
		}
		if locs.Subject != nil {
			b.WriteString("\nat ")
			b.WriteString(locs.Subject.StartString())
		}
		if desc.Detail != "" {
			b.WriteString("\n\n")
			b.WriteString(desc.Detail)
		}
		t.Log(b.String())
	}
	if diags.HasErrors() {
		t.FailNow()
	}
}

// appliedChangeSortKey returns a string that can be used to sort applied
// changes in a predictable order for testing purposes. This is used to
// ensure that we can compare applied changes in a consistent way across
// different test runs.
func appliedChangeSortKey(change stackstate.AppliedChange) string {
	switch change := change.(type) {
	case *stackstate.AppliedChangeResourceInstanceObject:
		return change.ResourceInstanceObjectAddr.String()
	case *stackstate.AppliedChangeComponentInstance:
		return change.ComponentInstanceAddr.String()
	case *stackstate.AppliedChangeComponentInstanceRemoved:
		return change.ComponentInstanceAddr.String()
	case *stackstate.AppliedChangeOutputValue:
		return change.Addr.String()
	case *stackstate.AppliedChangeInputVariable:
		return change.Addr.String()
	case *stackstate.AppliedChangeDiscardKeys:
		// There should only be a single discard keys in a plan, so we can just
		// return a static string here.
		return "discard"
	default:
		// This is only going to happen during tests, so we can panic here.
		panic(fmt.Errorf("unrecognized applied change type: %T", change))
	}
}

// plannedChangeSortKey returns a string that can be used to sort planned
// changes in a predictable order for testing purposes. This is used to
// ensure that we can compare planned changes in a consistent way across
// different test runs.
func plannedChangeSortKey(change stackplan.PlannedChange) string {
	switch change := change.(type) {
	case *stackplan.PlannedChangeRootInputValue:
		return change.Addr.String()
	case *stackplan.PlannedChangeComponentInstance:
		return change.Addr.String()
	case *stackplan.PlannedChangeComponentInstanceRemoved:
		return change.Addr.String()
	case *stackplan.PlannedChangeResourceInstancePlanned:
		return change.ResourceInstanceObjectAddr.String()
	case *stackplan.PlannedChangeDeferredResourceInstancePlanned:
		return change.ResourceInstancePlanned.ResourceInstanceObjectAddr.String()
	case *stackplan.PlannedChangeOutputValue:
		return change.Addr.String()
	case *stackplan.PlannedChangeHeader:
		// There should only be a single header in a plan, so we can just return
		// a static string here.
		return "header"
	case *stackplan.PlannedChangeApplyable:
		// There should only be a single applyable marker in a plan, so we can
		// just return a static string here.
		return "applyable"
	case *stackplan.PlannedChangePlannedTimestamp:
		// There should only be a single timestamp in a plan, so we can
		// just return a static string here.
		return "planned-timestamp"
	case *stackplan.PlannedChangeProviderFunctionResults:
		// There should only be a single timestamp in a plan, so we can just
		// return a simple string.
		return "function-results"
	case *stackplan.PlannedChangeActionInvocationInstancePlanned:
		return change.ActionInvocationAddr.String()
	case *stackplan.PlannedChangeDeferredActionInvocation:
		return change.ActionInvocationPlanned.ActionInvocationAddr.String()
	default:
		// This is only going to happen during tests, so we can panic here.
		panic(fmt.Errorf("unrecognized planned change type: %T", change))
	}
}

func diagnosticSortFunc(diags tfdiags.Diagnostics) func(i, j int) bool {
	sortDescription := func(i, j tfdiags.Description) bool {
		if i.Summary != j.Summary {
			return i.Summary < j.Summary
		}
		return i.Detail < j.Detail
	}

	sortPos := func(i, j tfdiags.SourcePos) bool {
		if i.Line != j.Line {
			return i.Line < j.Line
		}
		return i.Column < j.Column
	}

	sortRange := func(i, j *tfdiags.SourceRange) bool {
		if i.Filename != j.Filename {
			return i.Filename < j.Filename
		}
		if !cmp.Equal(i.Start, j.Start) {
			return sortPos(i.Start, j.Start)
		}
		return sortPos(i.End, j.End)
	}

	return func(i, j int) bool {
		id, jd := diags[i], diags[j]
		if id.Severity() != jd.Severity() {
			return id.Severity() == tfdiags.Error
		}
		if !cmp.Equal(id.Description(), jd.Description()) {
			return sortDescription(id.Description(), jd.Description())
		}
		if id.Source().Subject != nil && jd.Source().Subject != nil {
			return sortRange(id.Source().Subject, jd.Source().Subject)
		}

		return false
	}
}

func mustDefaultRootProvider(provider string) addrs.AbsProviderConfig {
	return addrs.AbsProviderConfig{
		Module:   addrs.RootModule,
		Provider: addrs.NewDefaultProvider(provider),
	}
}

func mustAbsResourceInstance(addr string) addrs.AbsResourceInstance {
	ret, diags := addrs.ParseAbsResourceInstanceStr(addr)
	if len(diags) > 0 {
		panic(fmt.Sprintf("failed to parse resource instance address %q: %s", addr, diags))
	}
	return ret
}

func mustAbsResourceInstanceObject(addr string) stackaddrs.AbsResourceInstanceObject {
	ret, diags := stackaddrs.ParseAbsResourceInstanceObjectStr(addr)
	if len(diags) > 0 {
		panic(fmt.Sprintf("failed to parse resource instance object address %q: %s", addr, diags))
	}
	return ret
}

func mustAbsResourceInstanceObjectPtr(addr string) *stackaddrs.AbsResourceInstanceObject {
	ret := mustAbsResourceInstanceObject(addr)
	return &ret
}

func mustAbsComponentInstance(addr string) stackaddrs.AbsComponentInstance {
	ret, diags := stackaddrs.ParsePartialComponentInstanceStr(addr)
	if len(diags) > 0 {
		panic(fmt.Sprintf("failed to parse component instance address %q: %s", addr, diags))
	}
	return ret
}

func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocationInstance {
	ret, diags := stackaddrs.ParseActionInvocationInstanceStr(addr)
	if len(diags) > 0 {
		panic(fmt.Sprintf("failed to parse action invocation instance address %q: %s", addr, diags))
	}
	return ret
}

func mustAbsComponent(addr string) stackaddrs.AbsComponent {
	ret, diags := stackaddrs.ParsePartialComponentInstanceStr(addr)
	if len(diags) > 0 {
		panic(fmt.Sprintf("failed to parse component instance address %q: %s", addr, diags))
	}
	return stackaddrs.AbsComponent{
		Stack: ret.Stack,
		Item:  ret.Item.Component,
	}
}

// mustPlanDynamicValue is a helper function that constructs a
// plans.DynamicValue from the given cty.Value, panicking if the construction
// fails.
func mustPlanDynamicValue(v cty.Value) plans.DynamicValue {
	ret, err := plans.NewDynamicValue(v, v.Type())
	if err != nil {
		panic(err)
	}
	return ret
}

// mustPlanDynamicValueDynamicType is a helper function that constructs a
// plans.DynamicValue from the given cty.Value, using cty.DynamicPseudoType as
// the type, and panicking if the construction fails.
func mustPlanDynamicValueDynamicType(v cty.Value) plans.DynamicValue {
	ret, err := plans.NewDynamicValue(v, cty.DynamicPseudoType)
	if err != nil {
		panic(err)
	}
	return ret
}

// mustPlanDynamicValueSchema is a helper function that constructs a
// plans.DynamicValue from the given cty.Value and configschema.Block, panicking
// if the construction fails.
func mustPlanDynamicValueSchema(v cty.Value, block *configschema.Block) plans.DynamicValue {
	ty := block.ImpliedType()
	ret, err := plans.NewDynamicValue(v, ty)
	if err != nil {
		panic(err)
	}
	return ret
}

func mustInputVariable(name string) addrs.InputVariable {
	return addrs.InputVariable{Name: name}
}

func mustStackInputVariable(name string) stackaddrs.InputVariable {
	return stackaddrs.InputVariable{Name: name}
}

func mustStackOutputValue(name string) stackaddrs.OutputValue {
	return stackaddrs.OutputValue{Name: name}
}

func mustMarshalJSONAttrs(attrs map[string]interface{}) []byte {
	jsonAttrs, err := json.Marshal(attrs)
	if err != nil {
		panic(err)
	}
	return jsonAttrs
}

func providerFunctionHashArgs(provider addrs.Provider, name string, args ...cty.Value) []byte {
	sum := sha256.New()

	sum.Write([]byte(provider.String()))
	sum.Write([]byte("|"))
	sum.Write([]byte(name))
	for _, arg := range args {
		sum.Write([]byte("|"))
		sum.Write([]byte(arg.GoString()))
	}

	return sum.Sum(nil)
}

func providerFunctionHashResult(value cty.Value) []byte {
	bytes := sha256.Sum256([]byte(value.GoString()))
	return bytes[:]
}
