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

package moduletest

import (
	"fmt"
	"time"

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

	"github.com/hashicorp/terraform/internal/addrs"
	"github.com/hashicorp/terraform/internal/configs"
	"github.com/hashicorp/terraform/internal/configs/configschema"
	"github.com/hashicorp/terraform/internal/plans"
	"github.com/hashicorp/terraform/internal/providers"
	"github.com/hashicorp/terraform/internal/states"
	"github.com/hashicorp/terraform/internal/tfdiags"
)

type Run struct {
	Config *configs.TestRun

	// ModuleConfig is the config that this run block is executing against.
	ModuleConfig *configs.Config

	Verbose *Verbose

	Name   string
	Index  int
	Status Status

	// Outputs are set by the Terraform Test graph once this run block is
	// executed. Callers should only access this if the Status field is set to
	// Pass or Fail as both of these cases indicate the run block was executed
	// successfully, and actually had values to write.
	Outputs cty.Value

	Diagnostics tfdiags.Diagnostics

	// ExecutionMeta captures metadata about how the test run was executed.
	//
	// This field is not always populated. A run that has never been executed
	// will definitely have a nil value for this field. A run that was
	// executed may or may not populate this field, depending on exactly what
	// happened during the run execution. Callers accessing this field MUST
	// check for nil and handle that case in some reasonable way.
	//
	// Executing the same run multiple times may or may not update this field
	// on each execution.
	ExecutionMeta *RunExecutionMeta
}

func NewRun(config *configs.TestRun, moduleConfig *configs.Config, index int) *Run {
	return &Run{
		Config:       config,
		ModuleConfig: moduleConfig,
		Name:         config.Name,
		Index:        index,
	}
}

type RunExecutionMeta struct {
	Start    time.Time
	Duration time.Duration
}

// StartTimestamp returns the start time metadata as a timestamp formatted as YYYY-MM-DDTHH:MM:SSZ.
// Times are converted to UTC, if they aren't already.
// If the start time is unset an empty string is returned.
func (m *RunExecutionMeta) StartTimestamp() string {
	if m.Start.IsZero() {
		return ""
	}
	return m.Start.UTC().Format(time.RFC3339)
}

// Verbose is a utility struct that holds all the information required for a run
// to render the results verbosely.
//
// At the moment, this basically means printing out the plan. To do that we need
// all the information within this struct.
type Verbose struct {
	Plan         *plans.Plan
	State        *states.State
	Config       *configs.Config
	Providers    map[addrs.Provider]providers.ProviderSchema
	Provisioners map[string]*configschema.Block
}

func (run *Run) Addr() addrs.Run {
	return addrs.Run{Name: run.Name}
}

// ExplainExpectedFailures is similar to ValidateExpectedFailures except it
// looks for any diagnostics produced by custom conditions and are included in
// the expected failures and adds an additional explanation that clarifies the
// expected failures are being ignored this time round.
//
// Generally, this function is used during an `apply` operation to explain that
// an expected failure during the planning stage will still result in the
// overall test failing as the plan failed and we couldn't even execute the
// apply stage.
func ExplainExpectedFailures(config *configs.TestRun, originals tfdiags.Diagnostics) tfdiags.Diagnostics {

	// We're going to capture all the checkable objects that are referenced
	// from the expected failures.
	expectedFailures := addrs.MakeMap[addrs.Referenceable, bool]()
	sourceRanges := addrs.MakeMap[addrs.Referenceable, tfdiags.SourceRange]()

	for _, traversal := range config.ExpectFailures {
		// Ignore the diagnostics returned from the reference parsing, these
		// references will have been checked earlier in the process by the
		// validate stage so we don't need to do that again here.
		reference, _ := addrs.ParseRefFromTestingScope(traversal)
		expectedFailures.Put(reference.Subject, false)
		sourceRanges.Put(reference.Subject, reference.SourceRange)
	}

	var diags tfdiags.Diagnostics
	for _, diag := range originals {
		if diag.Severity() == tfdiags.Warning {
			// Then it's fine, the test will carry on without us doing anything.
			diags = diags.Append(diag)
			continue
		}

		if rule, ok := addrs.DiagnosticOriginatesFromCheckRule(diag); ok {

			var rng *hcl.Range
			expected := false
			switch rule.Container.CheckableKind() {
			case addrs.CheckableOutputValue:
				addr := rule.Container.(addrs.AbsOutputValue)
				if !addr.Module.IsRoot() {
					// failures can only be expected against checkable objects
					// in the root module. This diagnostic will be added into
					// returned set below.
					break
				}
				if expectedFailures.Has(addr.OutputValue) {
					expected = true
					rng = sourceRanges.Get(addr.OutputValue).ToHCL().Ptr()
				}

			case addrs.CheckableInputVariable:
				addr := rule.Container.(addrs.AbsInputVariableInstance)
				if !addr.Module.IsRoot() {
					// failures can only be expected against checkable objects
					// in the root module. This diagnostic will be added into
					// returned set below.
					break
				}
				if expectedFailures.Has(addr.Variable) {
					expected = true
				}

			case addrs.CheckableResource:
				addr := rule.Container.(addrs.AbsResourceInstance)
				if !addr.Module.IsRoot() {
					// failures can only be expected against checkable objects
					// in the root module. This diagnostic will be added into
					// returned set below.
					break
				}
				if expectedFailures.Has(addr.Resource) {
					expected = true
				}

				if expectedFailures.Has(addr.Resource.Resource) {
					expected = true
				}

			case addrs.CheckableCheck:
				// Check blocks only produce warnings so this branch shouldn't
				// ever be triggered anyway.
			default:
				panic("unrecognized CheckableKind: " + rule.Container.CheckableKind().String())
			}

			if expected {
				// Then this diagnostic was produced by a custom condition that
				// was expected to fail. But, it happened at the wrong time (eg.
				// we're trying to run an apply operation and this condition
				// failed during the plan so the overall test operation still
				// fails).
				//
				// We'll add a warning diagnostic explaining why the overall
				// test is still failing even though the error was expected, and
				// then add the original error into our diagnostics directly
				// after.

				diags = diags.Append(&hcl.Diagnostic{
					Severity: hcl.DiagWarning,
					Summary:  "Expected failure while planning",
					Detail:   fmt.Sprintf("A custom condition within %s failed during the planning stage and prevented the requested apply operation. While this was an expected failure, the apply operation could not be executed and so the overall test case will be marked as a failure and the original diagnostic included in the test report.", rule.Container.String()),
					Subject:  rng,
				})
				diags = diags.Append(diag)
				continue
			}
		}

		// Otherwise, there is nothing special about this diagnostic so just
		// carry it through.
		diags = diags.Append(diag)
	}
	return diags
}

// ValidateExpectedFailures steps through the provided diagnostics (which should
// be the result of a plan or an apply operation), and does 3 things:
//  1. Removes diagnostics that match the expected failures from the config.
//  2. Upgrades warnings from check blocks into errors where appropriate so the
//     test will fail later.
//  3. Adds diagnostics for any expected failures that were not satisfied.
//
// Point 2 is a bit complicated so worth expanding on. In normal Terraform
// execution, any error that originates within a check block (either from an
// assertion or a scoped data source) is wrapped up as a Warning to be
// identified to the user but not to fail the actual Terraform operation. During
// test execution, we want to upgrade (or rollback) these warnings into errors
// again so the test will fail. We do that as part of this function as we are
// already processing the diagnostics from check blocks in here anyway.
//
// The way the function works out which diagnostics are relevant to expected
// failures is by using the tfdiags Extra functionality to detect which
// diagnostics were generated by custom conditions. Terraform adds the
// addrs.CheckRule that generated each diagnostic to the diagnostic itself so we
// can tell which diagnostics can be expected.
func ValidateExpectedFailures(config *configs.TestRun, originals tfdiags.Diagnostics) tfdiags.Diagnostics {

	// We're going to capture all the checkable objects that are referenced
	// from the expected failures.
	expectedFailures := addrs.MakeMap[addrs.Referenceable, bool]()
	sourceRanges := addrs.MakeMap[addrs.Referenceable, tfdiags.SourceRange]()

	for _, traversal := range config.ExpectFailures {
		// Ignore the diagnostics returned from the reference parsing, these
		// references will have been checked earlier in the process by the
		// validate stage so we don't need to do that again here.
		reference, _ := addrs.ParseRefFromTestingScope(traversal)
		expectedFailures.Put(reference.Subject, false)
		sourceRanges.Put(reference.Subject, reference.SourceRange)
	}

	var diags tfdiags.Diagnostics
	for _, diag := range originals {

		if rule, ok := addrs.DiagnosticOriginatesFromCheckRule(diag); ok {
			switch rule.Container.CheckableKind() {
			case addrs.CheckableOutputValue:
				addr := rule.Container.(addrs.AbsOutputValue)
				if !addr.Module.IsRoot() {
					// failures can only be expected against checkable objects
					// in the root module. This diagnostic will be added into
					// returned set below.
					break
				}

				if diag.Severity() == tfdiags.Warning {
					// Warnings don't count as errors. This diagnostic will be
					// added into the returned set below.
					break
				}

				if expectedFailures.Has(addr.OutputValue) {
					// Then this failure is expected! Mark the original map as
					// having found a failure and swallow this error by
					// continuing and not adding it into the returned set of
					// diagnostics.
					expectedFailures.Put(addr.OutputValue, true)
					continue
				}

				// Otherwise, this isn't an expected failure so just fall out
				// and add it into the returned set of diagnostics below.

			case addrs.CheckableInputVariable:
				addr := rule.Container.(addrs.AbsInputVariableInstance)
				if !addr.Module.IsRoot() {
					// failures can only be expected against checkable objects
					// in the root module. This diagnostic will be added into
					// returned set below.
					break
				}

				if diag.Severity() == tfdiags.Warning {
					// Warnings don't count as errors. This diagnostic will be
					// added into the returned set below.
					break
				}
				if expectedFailures.Has(addr.Variable) {
					// Then this failure is expected! Mark the original map as
					// having found a failure and swallow this error by
					// continuing and not adding it into the returned set of
					// diagnostics.
					expectedFailures.Put(addr.Variable, true)
					continue
				}

				// Otherwise, this isn't an expected failure so just fall out
				// and add it into the returned set of diagnostics below.

			case addrs.CheckableResource:
				addr := rule.Container.(addrs.AbsResourceInstance)
				if !addr.Module.IsRoot() {
					// failures can only be expected against checkable objects
					// in the root module. This diagnostic will be added into
					// returned set below.
					break
				}

				if diag.Severity() == tfdiags.Warning {
					// Warnings don't count as errors. This diagnostic will be
					// added into the returned set below.
					break
				}

				if expectedFailures.Has(addr.Resource) {
					// Then this failure is expected! Mark the original map as
					// having found a failure and swallow this error by
					// continuing and not adding it into the returned set of
					// diagnostics.
					expectedFailures.Put(addr.Resource, true)
					continue
				}

				if expectedFailures.Has(addr.Resource.Resource) {
					// We can also blanket expect failures in all instances for
					// a resource so we check for that here as well.
					expectedFailures.Put(addr.Resource.Resource, true)
					continue
				}

				// Otherwise, this isn't an expected failure so just fall out
				// and add it into the returned set of diagnostics below.

			case addrs.CheckableCheck:
				addr := rule.Container.(addrs.AbsCheck)

				// Check blocks are a bit more difficult than the others. Check
				// block diagnostics could be from a nested data block, or
				// from a failed assertion, and have all been marked as just
				// warning severity.
				//
				// For diagnostics from failed assertions, we want to check if
				// it was expected and skip it if it was. But if it wasn't
				// expected we want to upgrade the diagnostic from a warning
				// into an error so the test case will fail overall.
				//
				// For diagnostics from nested data blocks, we have two
				// categories of diagnostics. First, diagnostics that were
				// originally errors and we mapped into warnings. Second,
				// diagnostics that were originally warnings and stayed that
				// way. For the first case, we want to turn these back to errors
				// and use them as part of the expected failures functionality.
				// The second case should remain as warnings and be ignored by
				// the expected failures functionality.
				//
				// Note, as well that we still want to upgrade failed checks
				// from child modules into errors, so in the other branches we
				// just do a simple blanket skip off all diagnostics not
				// from the root module. We're more selective here, only
				// diagnostics from the root module are considered for the
				// expect failures functionality but we do also upgrade
				// diagnostics from child modules back into errors.

				if rule.Type == addrs.CheckAssertion {
					// Then this diagnostic is from a check block assertion, it
					// is something we want to treat as an error even though it
					// is actually claiming to be a warning.

					if addr.Module.IsRoot() && expectedFailures.Has(addr.Check) {
						// Then this failure is expected! Mark the original map as
						// having found a failure and continue.
						expectedFailures.Put(addr.Check, true)
						continue
					}

					// Otherwise, let's package this up as an error and move on.
					diags = diags.Append(tfdiags.Override(diag, tfdiags.Error, nil))
					continue
				} else if rule.Type == addrs.CheckDataResource {
					// Then the diagnostic we have was actually overridden so
					// let's get back to the original.
					original := tfdiags.UndoOverride(diag)

					// This diagnostic originated from a scoped data source.
					if addr.Module.IsRoot() && original.Severity() == tfdiags.Error {
						// Okay, we have a genuine error from the root module,
						// so we can now check if we want to ignore it or not.
						if expectedFailures.Has(addr.Check) {
							// Then this failure is expected! Mark the original map as
							// having found a failure and continue.
							expectedFailures.Put(addr.Check, true)
							continue
						}
					}

					// In all other cases, we want to add the original error
					// into the set we return to the testing framework and move
					// onto the next one.
					diags = diags.Append(original)
					continue
				} else {
					panic("invalid CheckType: " + rule.Type.String())
				}
			default:
				panic("unrecognized CheckableKind: " + rule.Container.CheckableKind().String())
			}
		}

		// If we get here, then we're not modifying the original diagnostic at
		// all. We just want the testing framework to treat it as normal.
		diags = diags.Append(diag)
	}

	// Okay, we've checked all our diagnostics to see if any were expected.
	// Now, let's make sure that all the checkable objects we expected to fail
	// actually did!

	for _, elem := range expectedFailures.Elems {
		addr := elem.Key
		failed := elem.Value

		if !failed {
			// Then we expected a failure, and it did not occur. Add it to the
			// diagnostics.
			diags = diags.Append(&hcl.Diagnostic{
				Severity: hcl.DiagError,
				Summary:  "Missing expected failure",
				Detail:   fmt.Sprintf("The checkable object, %s, was expected to report an error but did not.", addr.String()),
				Subject:  sourceRanges.Get(addr).ToHCL().Ptr(),
				Extra:    missingExpectedFailure(true),
			})
		}
	}

	return diags
}

// DiagnosticExtraFromMissingExpectedFailure provides an interface for diagnostic ExtraInfo to
// denote that a diagnostic was generated as a result of a missing expected failure.
type DiagnosticExtraFromMissingExpectedFailure interface {
	DiagnosticFromMissingExpectedFailure() bool
}

// DiagnosticFromMissingExpectedFailure checks if the provided diagnostic
// is a result of a missing expected failure.
func DiagnosticFromMissingExpectedFailure(diag tfdiags.Diagnostic) bool {
	maybe := tfdiags.ExtraInfo[DiagnosticExtraFromMissingExpectedFailure](diag)
	if maybe == nil {
		return false
	}
	return maybe.DiagnosticFromMissingExpectedFailure()
}

type missingExpectedFailure bool

func (missingExpectedFailure) DiagnosticFromMissingExpectedFailure() bool {
	return true
}
