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

package stackeval

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"testing"

	"github.com/hashicorp/go-slug/sourceaddrs"
	"github.com/hashicorp/go-slug/sourcebundle"
	"github.com/zclconf/go-cty/cty"
	"google.golang.org/protobuf/reflect/protoreflect"
	"google.golang.org/protobuf/types/known/anypb"

	_ "github.com/hashicorp/terraform/internal/logging"
	"github.com/hashicorp/terraform/internal/promising"
	"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 contains some general test utilities that many of our other
// _test.go files rely on. It doesn't actually contain any tests itself.

// testStackConfig loads a stack configuration from the source bundle in this
// package's testdata directory.
//
// "collection" is the name of one of the synthetic source packages that's
// declared in the source bundle, and "subPath" is the path within that
// package.
func testStackConfig(t *testing.T, collection string, subPath string) *stackconfig.Config {
	t.Helper()

	// Our collection of test configurations is laid out like a source
	// bundle that was installed from some source addresses that don't
	// really exist, and so we'll construct a suitable fake source
	// address following that scheme.
	fakeSrcStr := fmt.Sprintf("https://testing.invalid/%s.tar.gz//%s", collection, subPath)
	fakeSrc, err := sourceaddrs.ParseRemoteSource(fakeSrcStr)
	if err != nil {
		t.Fatalf("artificial source address string %q is invalid: %s", fakeSrcStr, err)
	}

	sources := testSourceBundle(t)
	ret, diags := stackconfig.LoadConfigDir(fakeSrc, sources)
	if diags.HasErrors() {
		diags.Sort()
		t.Fatalf("configuration is invalid\n%s", testFormatDiagnostics(t, diags))
	}
	return ret
}

func testStackConfigEmpty(t *testing.T) *stackconfig.Config {
	t.Helper()
	sources := testSourceBundle(t)
	fakeAddr := sourceaddrs.MustParseSource("https://testing.invalid/nonexist.tar.gz").(sourceaddrs.RemoteSource)
	return stackconfig.NewEmptyConfig(fakeAddr, sources)
}

func testSourceBundle(t *testing.T) *sourcebundle.Bundle {
	t.Helper()
	sources, err := sourcebundle.OpenDir("testdata/sourcebundle")
	if err != nil {
		t.Fatalf("cannot open source bundle: %s", err)
	}
	return sources
}

func testPriorState(t *testing.T, msgs map[string]protoreflect.ProtoMessage) *stackstate.State {
	t.Helper()
	ret, err := stackstate.LoadFromDirectProto(msgs)
	if err != nil {
		t.Fatal(err)
	}
	return ret
}

func testPlan(t *testing.T, main *Main) (*stackplan.Plan, tfdiags.Diagnostics) {
	t.Helper()
	outp, outpTest := testPlanOutput(t)
	main.PlanAll(context.Background(), outp)
	return outpTest.Close(t)
}

func testPlanOutput(t *testing.T) (PlanOutput, *planOutputTester) {
	t.Helper()
	tester := &planOutputTester{}
	outp := PlanOutput{
		AnnouncePlannedChange: func(ctx context.Context, pc stackplan.PlannedChange) {
			tester.mu.Lock()
			tester.planned = append(tester.planned, pc)
			tester.mu.Unlock()
		},
		AnnounceDiagnostics: func(ctx context.Context, d tfdiags.Diagnostics) {
			tester.mu.Lock()
			tester.diags = tester.diags.Append(d)
			tester.mu.Unlock()
		},
	}
	return outp, tester
}

type planOutputTester struct {
	planned []stackplan.PlannedChange
	diags   tfdiags.Diagnostics
	mu      sync.Mutex
}

// PlannedChanges returns the planned changes that have been accumulated in the
// receiver.
//
// It isn't safe to access the returned slice concurrently with a planning
// operation. Use this method only once the plan operation is complete and
// thus the changes are finalized.
func (pot *planOutputTester) PlannedChanges() []stackplan.PlannedChange {
	return pot.planned
}

// RawChanges returns the protobuf representation changes that have been
// accumulated in the receiver.
//
// It isn't safe to call this method concurrently with a planning
// operation. Use this method only once the plan operation is complete and
// thus the raw changes are finalized.
func (pot *planOutputTester) RawChanges(t *testing.T) []*anypb.Any {
	t.Helper()

	var msgs []*anypb.Any
	for _, change := range pot.planned {
		protoChange, err := change.PlannedChangeProto()
		if err != nil {
			t.Fatalf("failed to encode %T: %s", change, err)
		}
		msgs = append(msgs, protoChange.Raw...)
	}

	// Normally it's the stackeval caller (in stackruntime) that marks a
	// plan as "applyable", but since we're calling into the stackeval functions
	// directly here we'll need to add that extra item ourselves.
	if !pot.diags.HasErrors() {
		change := stackplan.PlannedChangeApplyable{
			Applyable: true,
		}
		protoChange, err := change.PlannedChangeProto()
		if err != nil {
			t.Fatalf("failed to encode %T: %s", change, err)
		}
		msgs = append(msgs, protoChange.Raw...)
	}

	return msgs
}

// Diags returns the diagnostics that have been accumulated in the
// receiver.
//
// It isn't safe to access the returned slice concurrently with a planning
// operation. Use this method only once the plan operation is complete and
// thus the diagnostics are finalized.
func (pot *planOutputTester) Diags() tfdiags.Diagnostics {
	return pot.diags
}

func (pot *planOutputTester) Close(t *testing.T) (*stackplan.Plan, tfdiags.Diagnostics) {
	t.Helper()

	// Caller shouldn't close concurrently with other work anyway, but we'll
	// include this just to help make things behave more consistently even when
	// the caller is buggy.
	pot.mu.Lock()
	defer pot.mu.Unlock()

	// We'll now round-trip all of the planned changes through the serialize
	// and deserialize logic to approximate the effect of this plan having been
	// saved and then reloaded during a subsequent apply phase, since
	// the reloaded plan is a more convenient artifact to inspect in tests.
	msgs := pot.RawChanges(t)
	plan, err := stackplan.LoadFromProto(msgs)
	if err != nil {
		t.Fatalf("failed to reload saved plan: %s", err)
	}
	return plan, pot.diags
}

func testApplyOutput(t *testing.T, priorStateRaw map[string]*anypb.Any) (ApplyOutput, *applyOutputTester) {
	t.Helper()
	tester := &applyOutputTester{}
	outp := ApplyOutput{
		AnnounceAppliedChange: func(ctx context.Context, ac stackstate.AppliedChange) {
			tester.mu.Lock()
			tester.applied = append(tester.applied, ac)
			tester.mu.Unlock()
		},
		AnnounceDiagnostics: func(ctx context.Context, d tfdiags.Diagnostics) {
			tester.mu.Lock()
			tester.diags = tester.diags.Append(d)
			tester.mu.Unlock()
		},
	}
	return outp, tester
}

type applyOutputTester struct {
	prior   map[string]*anypb.Any
	applied []stackstate.AppliedChange
	diags   tfdiags.Diagnostics
	mu      sync.Mutex
}

// AppliedChanges returns the applied change objects that have been accumulated
// in the receiver.
//
// It isn't safe to access the returned slice concurrently with an apply
// operation. Use this method only once the apply operation is complete and
// thus the changes are finalized.
func (aot *applyOutputTester) AppliedChanges() []stackstate.AppliedChange {
	return aot.applied
}

// RawUpdatedState returns the protobuf representation of the state with the
// accumulated changes merged into it.
//
// It isn't safe to call this method concurrently with an apply
// operation. Use this method only once the apply operation is complete and
// thus the changes are finalized.
func (aot *applyOutputTester) RawUpdatedState(t *testing.T) map[string]*anypb.Any {
	t.Helper()

	msgs := make(map[string]*anypb.Any)
	for k, v := range aot.prior {
		msgs[k] = v
	}
	for _, change := range aot.applied {
		protoChange, err := change.AppliedChangeProto()
		if err != nil {
			t.Fatalf("failed to encode %T: %s", change, err)
		}
		for _, protoRaw := range protoChange.Raw {
			if protoRaw.Value != nil {
				msgs[protoRaw.Key] = protoRaw.Value
			} else {
				delete(msgs, protoRaw.Key)
			}
		}
	}

	return msgs
}

// Diags returns the diagnostics that have been accumulated in the
// receiver.
//
// It isn't safe to access the returned slice concurrently with an apply
// operation. Use this method only once the apply operation is complete and
// thus the diagnostics are finalized.
func (aot *applyOutputTester) Diags() tfdiags.Diagnostics {
	return aot.diags
}

func (aot *applyOutputTester) Close(t *testing.T) (*stackstate.State, tfdiags.Diagnostics) {
	t.Helper()

	// Caller shouldn't close concurrently with other work anyway, but we'll
	// include this just to help make things behave more consistently even when
	// the caller is buggy.
	aot.mu.Lock()
	defer aot.mu.Unlock()

	// We'll now round-trip all of the applied changes through the serialize
	// and deserialize logic to approximate the effect of this having having been
	// saved and then reloaded during a subsequent planning phase.
	msgs := aot.RawUpdatedState(t)
	state, err := stackstate.LoadFromProto(msgs)
	if err != nil {
		t.Fatalf("failed to reload saved state: %s", err)
	}
	return state, aot.diags
}

func testFormatDiagnostics(t *testing.T, diags tfdiags.Diagnostics) string {
	t.Helper()
	var buf strings.Builder
	for _, diag := range diags {
		buf.WriteString(testFormatDiagnostic(t, diag))
		buf.WriteByte('\n')
	}
	return buf.String()
}

func testFormatDiagnostic(t *testing.T, diag tfdiags.Diagnostic) string {
	t.Helper()

	var buf strings.Builder
	switch diag.Severity() {
	case tfdiags.Error:
		buf.WriteString("[ERROR] ")
	case tfdiags.Warning:
		buf.WriteString("[WARNING] ")
	default:
		buf.WriteString("[PROBLEM] ")
	}
	desc := diag.Description()
	buf.WriteString(desc.Summary)
	buf.WriteByte('\n')
	if subj := diag.Source().Subject; subj != nil {
		buf.WriteString("at " + subj.StartString() + "\n")
	}
	if desc.Detail != "" {
		buf.WriteByte('\n')
		buf.WriteString(desc.Detail)
		buf.WriteByte('\n')
	}
	return buf.String()
}

func assertNoDiagnostics(t *testing.T, diags tfdiags.Diagnostics) {
	t.Helper()
	if len(diags) != 0 {
		diags.Sort()
		t.Fatalf("unexpected diagnostics\n\n%s", testFormatDiagnostics(t, diags))
	}
}

func assertNoErrors(t *testing.T, diags tfdiags.Diagnostics) {
	t.Helper()
	if diags.HasErrors() {
		diags.Sort()
		t.Fatalf("unexpected errors\n\n%s", testFormatDiagnostics(t, diags))
	}
}

// testEvaluator constructs a [Main] that's configured for [InspectPhase] using
// the given configuration, state, and other options.
//
// This evaluator is suitable for tests that focus only on evaluation logic
// within this package, but will not be suitable for all situations. Some
// tests should instantiate [Main] directly, particularly if they intend to
// exercise phase-specific functionality like planning or applying component
// instances.
func testEvaluator(t *testing.T, opts testEvaluatorOpts) *Main {
	t.Helper()
	if opts.Config == nil {
		t.Fatal("Config field must not be nil")
	}
	if opts.State == nil {
		opts.State = stackstate.NewState()
	}

	inputVals := make(map[stackaddrs.InputVariable]ExternalInputValue, len(opts.InputVariableValues))
	for name, val := range opts.InputVariableValues {
		inputVals[stackaddrs.InputVariable{Name: name}] = ExternalInputValue{
			Value: val,
			DefRange: tfdiags.SourceRange{
				Filename: "<test-input>",
				Start: tfdiags.SourcePos{
					Line:   1,
					Column: 1,
					Byte:   0,
				},
				End: tfdiags.SourcePos{
					Line:   1,
					Column: 1,
					Byte:   0,
				},
			},
		}
	}

	main := NewForInspecting(opts.Config, opts.State, InspectOpts{
		InputVariableValues: inputVals,
		ProviderFactories:   opts.ProviderFactories,
		TestOnlyGlobals:     opts.TestOnlyGlobals,
	})
	t.Cleanup(func() {
		main.DoCleanup(context.Background())
	})
	return main
}

type testEvaluatorOpts struct {
	// Config is required.
	Config *stackconfig.Config

	// State is optional; testEvaluator will use an empty state if this is nil.
	State *stackstate.State

	// InputVariableValues is optional and if set will provide the values
	// for the root stack input variables. Any variable not defined here
	// will evaluate to an unknown value of the configured type.
	InputVariableValues map[string]cty.Value

	// ProviderFactories is optional and if set provides factory functions
	// for provider types that the test can use. If not set then any attempt
	// to use provider configurations will lead to some sort of error.
	ProviderFactories ProviderFactories

	// TestOnlyGlobals is optional and if set makes it possible to use
	// references like _test_only_global.name to refer to values from this
	// map from anywhere in the entire stack configuration.
	//
	// This is intended as a kind of "test double" so that we can write more
	// minimal unit tests that can avoid relying on too many language features
	// all at once, so that hopefully future maintenance will not require
	// making broad changes across many different tests at once, which would
	// then risk inadvertently treating a regression as expected behavior.
	//
	// Configurations that refer to test-only globals are not valid for use
	// outside of the test suite of this package.
	TestOnlyGlobals map[string]cty.Value
}

// SetTestOnlyGlobals assigns the test-only globals map for the receiving
// main evaluator.
//
// This may be used only from unit tests in this package and must be called
// before performing any other operations against the reciever. It's invalid
// to change the test-only globals after some evaluation has already been
// performed, because the evaluator expects its input to be immutable and
// caches values derived from that input, and there's no mechanism to
// invalidate those caches.
//
// This is intentionally defined in a _test.go file to prevent it from
// being used from non-test code, despite being named as if it's exported.
// It's named as if exported to help differentiate it from unexported
// methods that are intended only as internal API, since it's a public API
// from the perspective of a test caller even though it's not public to
// other callers.
func (m *Main) SetTestOnlyGlobals(t *testing.T, vals map[string]cty.Value) {
	m.testOnlyGlobals = vals
}

func assertNoDiags(t *testing.T, diags tfdiags.Diagnostics) {
	t.Helper()
	if len(diags) != 0 {
		t.Fatalf("unexpected diagnostics\n%s", diags.Err())
	}
}

func assertMatchingDiag(t *testing.T, diags tfdiags.Diagnostics, check func(diag tfdiags.Diagnostic) bool) {
	t.Helper()
	for _, diag := range diags {
		if check(diag) {
			return
		}
	}
	t.Fatalf("none of the diagnostics is the one we are expecting\n%s", diags.Err())
}

// inPromisingTask is a helper for conveniently running some code in the context
// of a [promising.MainTask], with automatic promise error checking. This
// makes it valid to call functions that expect to run only as part of a
// promising task, which is true of essentially every method in this package
// that takes a [context.Context] as its first argument.
//
// Specifically, if the function encounters any direct promise-related failures,
// such as failure to resolve a promise before returning, this function will
// halt the test with an error message.
func inPromisingTask(t *testing.T, f func(ctx context.Context, t *testing.T)) {
	t.Helper()

	// We'll introduce an extra cancellable context here just to make
	// sure everything descending from this task gets terminated promptly
	// after the test is complete.
	ctx, cancel := context.WithCancel(context.Background())
	t.Cleanup(func() {
		cancel()
	})

	_, err := promising.MainTask(ctx, func(ctx context.Context) (struct{}, error) {
		t.Helper()

		f(ctx, t)
		return struct{}{}, nil
	})
	if err != nil {
		// We could get here if the test produces any self-references or
		// if it creates any promises that are left unresolved once it exits.
		t.Fatalf("promise resolution failure: %s", err)
	}
}

// subtestInPromisingTask compiles [testing.T.Run] with [inPromisingTask] as
// a convenience wrapper for running an entire subtest as a [promising.MainTask].
func subtestInPromisingTask(t *testing.T, name string, f func(ctx context.Context, t *testing.T)) {
	t.Helper()
	t.Run(name, func(t *testing.T) {
		t.Helper()
		inPromisingTask(t, f)
	})
}
