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

package mocking

import (
	"fmt"

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

	"github.com/hashicorp/terraform/internal/configs/configschema"
	"github.com/hashicorp/terraform/internal/tfdiags"
)

// PlanComputedValuesForResource accepts a target value, and populates its computed
// values with values from the provider 'with' argument, and if 'with' is not provided,
// it sets the computed values to cty.UnknownVal.
//
// The latter behaviour simulates the behaviour of a plan request in a real
// provider.
func PlanComputedValuesForResource(original cty.Value, with *MockedData, schema *configschema.Block) (cty.Value, tfdiags.Diagnostics) {
	if with == nil {
		with = &MockedData{
			Value:             cty.NilVal,
			ComputedAsUnknown: true,
		}
	}
	return populateComputedValues(original, *with, schema, isNull)
}

// ApplyComputedValuesForResource accepts a target value, and populates it
// either with values from the provided with argument, or with generated values
// created semi-randomly. This will only target values that are computed and
// unknown.
//
// This method basically simulates the behaviour of an apply request in a real
// provider.
func ApplyComputedValuesForResource(original cty.Value, with *MockedData, schema *configschema.Block) (cty.Value, tfdiags.Diagnostics) {
	if with == nil {
		with = &MockedData{
			Value: cty.NilVal,
		}
	}
	return populateComputedValues(original, *with, schema, isUnknown)
}

// ComputedValuesForDataSource accepts a target value, and populates it either
// with values from the provided with argument, or with generated values created
// semi-randomly. This will only target values that are computed and null.
//
// This function does what PlanComputedValuesForResource and
// ApplyComputedValuesForResource do but in a single step with no intermediary
// unknown stage.
//
// This method basically simulates the behaviour of a get data source request
// in a real provider.
func ComputedValuesForDataSource(original cty.Value, with *MockedData, schema *configschema.Block) (cty.Value, tfdiags.Diagnostics) {
	if with == nil {
		with = &MockedData{
			Value: cty.NilVal,
		}
	}
	return populateComputedValues(original, *with, schema, isNull)
}

type processValue func(value cty.Value) bool

type generateValue func(attribute *configschema.Attribute, with cty.Value, path cty.Path) (cty.Value, tfdiags.Diagnostics)

func populateComputedValues(target cty.Value, with MockedData, schema *configschema.Block, processValue processValue) (cty.Value, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	var generateValue generateValue
	// If the computed attributes should be ignored, then we will generate
	// unknown values for them, otherwise we will
	// generate their values based on the mocked data.
	if with.ComputedAsUnknown {
		generateValue = makeUnknown
	} else {
		generateValue = with.makeKnown
	}

	if !with.validate() {
		// This is actually a user error, it means the user wrote something like
		// `values = "not an object"` when defining the replacement values for
		// this in the mock or test file. We should have caught this earlier in
		// the validation, but we want this function to be robust and not panic
		// so we'll check again just in case.
		diags = diags.Append(&hcl.Diagnostic{
			Severity: hcl.DiagError,
			Summary:  "Invalid replacement value",
			Detail:   fmt.Sprintf("The requested replacement value must be an object type, but was %s.", with.Value.Type().FriendlyName()),
			Subject:  with.Range.Ptr(),
		})

		// We still need to produce valid data for this. So, let's pretend we
		// had no mocked data. We still return the error diagnostic so whatever
		// operation was happening will still fail, but we won't cause any
		// panics or anything.
		with = MockedData{
			Value: cty.NilVal,
		}
	}

	// We're going to search for any elements within the target value that meet
	// the joint criteria of being computed and whatever processValue is
	// checking.
	//
	// We'll then replace anything that meets the criteria with the output of
	// generateValue.
	//
	// This transform should be robust (in that it should never fail), the
	// inner call to generateValue should be robust as well so it should always
	// return a valid value for us to use even if the embedded diagnostics
	// return errors.
	value, err := cty.Transform(target, func(path cty.Path, target cty.Value) (cty.Value, error) {

		// Get the attribute for the current target.
		attribute := schema.AttributeByPath(path)

		if attribute == nil {
			// Then this is an intermediate path which does not represent an
			// attribute, and it cannot be computed.
			return target, nil
		}

		// Now, we check if we should be replacing this value with something.
		if attribute.Computed && processValue(target) {

			// Get the value we should be replacing target with.
			data, dataDiags := with.getMockedDataForPath(path)
			diags = diags.Append(dataDiags)

			// Upstream code (in node_resource_abstract_instance.go) expects
			// us to return a valid object (even if we have errors). That means
			// no unknown values, no cty.NilVals, etc. So, we're going to go
			// ahead and call generateValue with whatever getMockedDataForPath
			// gave us. getMockedDataForPath is robust, so even in an error it
			// should have given us something we can use in generateValue.

			// Now get the replacement value. This function should be robust in
			// that it may return diagnostics explaining why it couldn't replace
			// the value, but it'll still return a value for us to use.
			value, valueDiags := generateValue(attribute, data, path)
			diags = diags.Append(valueDiags)

			// We always return a valid value, the diags are attached to the
			// global diags outside the nested function.
			return value, nil
		}

		// If we don't need to replace this value, then just return it
		// untouched.
		return target, nil
	})
	if err != nil {
		// This shouldn't actually happen - we never return an error from inside
		// the transform function. But, just in case:
		diags = diags.Append(&hcl.Diagnostic{
			Severity: hcl.DiagError,
			Detail:   "Failed to generate values",
			Summary:  fmt.Sprintf("Terraform failed to generate computed values for a mocked resource, data source, or module: %s. This is a bug in Terraform - please report it.", err),
			Subject:  with.Range.Ptr(),
		})
	}

	return value, diags
}

func isNull(target cty.Value) bool {
	return target.IsNull()
}

func isUnknown(target cty.Value) bool {
	return !target.IsKnown()
}

// makeUnknown produces an unknown value for the provided attribute. This is
// basically the output of a plan() call for a computed attribute in a mocked
// resource.
func makeUnknown(target *configschema.Attribute, _ cty.Value, _ cty.Path) (cty.Value, tfdiags.Diagnostics) {
	return cty.UnknownVal(target.ImpliedType()), nil
}

// MockedData wraps the value and the source location of the value into a single
// struct for easy access.
type MockedData struct {
	Value             cty.Value
	Range             hcl.Range
	ComputedAsUnknown bool // If true, computed values are replaced with unknown, otherwise they are replaced with overridden or generated values.
}

// NewMockedData creates a new MockedData struct with the given value and range.
func NewMockedData(value cty.Value, computedAsUnknown bool, rng hcl.Range) MockedData {
	return MockedData{
		Value:             value,
		ComputedAsUnknown: computedAsUnknown,
		Range:             rng,
	}
}

// makeKnown produces a valid value for the given attribute. The input value
// can provide data for this attribute or child attributes if this attribute
// represents an object. The input value is expected to be a representation of
// the schema of this attribute rather than a direct value.
func (data MockedData) makeKnown(attribute *configschema.Attribute, with cty.Value, path cty.Path) (cty.Value, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	if with != cty.NilVal {
		// Then we have a pre-made value to use as the basis for our value. We
		// just need to make sure the value is of the right type.

		if value, err := FillAttribute(with, attribute); err != nil {
			var relPath cty.Path
			if err, ok := err.(cty.PathError); ok {
				relPath = err.Path
			}

			diags = diags.Append(tfdiags.AttributeValue(
				tfdiags.Error,
				"Failed to compute attribute",
				fmt.Sprintf("Terraform could not compute a value for the target type %s with the mocked data defined at %s with the attribute %q: %s.", attribute.ImpliedType().FriendlyName(), data.Range, fmtPath(append(path, relPath...)), err),
				path))

			// We still want to return a valid value here. If the conversion did
			// not work we carry on and just create a value instead. We've made
			// a note of the diagnostics tracking why it didn't work so the
			// overall operation will still fail, but we won't crash later on
			// because of an unknown value or something.

			// Fall through to the GenerateValueForAttribute call below.
		} else {
			// Successful conversion! We can just return the new value.
			return value, diags
		}
	}

	// Otherwise, we'll have to generate some values.
	return GenerateValueForAttribute(attribute), diags
}

// We can only do replacements if the replacement value is an object type.
func (data MockedData) validate() bool {
	return data.Value == cty.NilVal || data.Value.Type().IsObjectType()
}

// getMockedDataForPath walks the path to find any potential mock data for the
// given path. We have implemented custom logic for walking the path here.
//
// This is to support nested block types. It's complicated to work out how to
// replace computed values within nested types. For example, how would a user
// say they just want to replace values at index 3? Or how would users indicate
// they want to replace anything at all within nested sets. The indices for sets
// will never be the same because the user supplied values will, by design, have
// values for the computed attributes which will be null or unknown within the
// values from Terraform so the paths will never match.
//
// What the above paragraph means is that for nested blocks and attributes,
// users can only specify a single replacement value that will apply to all
// the values within the nested collection.
func (data MockedData) getMockedDataForPath(path cty.Path) (cty.Value, tfdiags.Diagnostics) {
	var diags tfdiags.Diagnostics

	if data.Value == cty.NilVal {
		return cty.NilVal, diags
	}

	// We want to provide a nice print out of the path in case of an error.
	// We'll format it as we go.
	var currentPath cty.Path

	// We are copying the implementation within AttributeByPath inside the
	// schema for this. We skip over GetIndexSteps as they'll be referring to
	// the intermediate nested blocks and attributes that we aren't capturing
	// within the user supplied mock values.
	current := data.Value
	for _, step := range path {
		switch step := step.(type) {
		case cty.GetAttrStep:

			if !current.Type().IsObjectType() {
				// As we're still traversing the path, we expect things to be
				// objects at every level.
				diags = diags.Append(tfdiags.AttributeValue(
					tfdiags.Error,
					"Failed to compute attribute",
					fmt.Sprintf("Terraform expected an object type for attribute %q defined within the mocked data at %s, but found %s.", fmtPath(currentPath), data.Range, current.Type().FriendlyName()),
					currentPath))

				return cty.NilVal, diags
			}

			if !current.Type().HasAttribute(step.Name) {
				// Then we have no mocked data for this attribute.
				return cty.NilVal, diags
			}

			current = current.GetAttr(step.Name)
		}

		currentPath = append(currentPath, step)
	}

	return current, diags
}

func fmtPath(path cty.Path) string {
	var current string

	first := true
	for _, step := range path {
		// Since we only ever parse the attribute steps when finding replacement
		// values, we can do the same again here.
		switch step := step.(type) {
		case cty.GetAttrStep:
			if first {
				first = false
				current = step.Name
				continue
			}
			current = fmt.Sprintf("%s.%s", current, step.Name)
		}
	}
	return current
}
