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

package tfdiags

import (
	"github.com/hashicorp/hcl/v2"
	"github.com/zclconf/go-cty/cty"
	"github.com/zclconf/go-cty/cty/gocty"
)

// The "contextual" family of diagnostics are designed to allow separating
// the detection of a problem from placing that problem in context. For
// example, some code that is validating an object extracted from configuration
// may not have access to the configuration that generated it, but can still
// report problems within that object which the caller can then place in
// context by calling IsConfigBody on the returned diagnostics.
//
// When contextual diagnostics are used, the documentation for a method must
// be very explicit about what context is implied for any diagnostics returned,
// to help ensure the expected result.

// contextualFromConfig is an interface type implemented by diagnostic types
// that can elaborate themselves when given information about the configuration
// body they are embedded in, as well as the runtime address associated with
// that configuration.
//
// Usually this entails extracting source location information in order to
// populate the "Subject" range.
type contextualFromConfigBody interface {
	ElaborateFromConfigBody(hcl.Body, string) Diagnostic
}

// InConfigBody returns a copy of the receiver with any config-contextual
// diagnostics elaborated in the context of the given body. An optional address
// argument may be added to indicate which instance of the configuration the
// error related to.
func (diags Diagnostics) InConfigBody(body hcl.Body, addr string) Diagnostics {
	if len(diags) == 0 {
		return nil
	}

	ret := make(Diagnostics, len(diags))
	for i, srcDiag := range diags {
		if cd, isCD := srcDiag.(contextualFromConfigBody); isCD {
			ret[i] = cd.ElaborateFromConfigBody(body, addr)
		} else {
			ret[i] = srcDiag
		}
	}

	return ret
}

// AttributeValue returns a diagnostic about an attribute value in an implied current
// configuration context. This should be returned only from functions whose
// interface specifies a clear configuration context that this will be
// resolved in.
//
// The given path is relative to the implied configuration context. To describe
// a top-level attribute, it should be a single-element cty.Path with a
// cty.GetAttrStep. It's assumed that the path is returning into a structure
// that would be produced by our conventions in the configschema package; it
// may return unexpected results for structures that can't be represented by
// configschema.
//
// Since mapping attribute paths back onto configuration is an imprecise
// operation (e.g. dynamic block generation may cause the same block to be
// evaluated multiple times) the diagnostic detail should include the attribute
// name and other context required to help the user understand what is being
// referenced in case the identified source range is not unique.
//
// The returned attribute will not have source location information until
// context is applied to the containing diagnostics using diags.InConfigBody.
// After context is applied, the source location is the value assigned to the
// named attribute, or the containing body's "missing item range" if no
// value is present.
func AttributeValue(severity Severity, summary, detail string, attrPath cty.Path) Diagnostic {
	return &attributeDiagnostic{
		diagnosticBase: diagnosticBase{
			severity: severity,
			summary:  summary,
			detail:   detail,
		},
		attrPath: attrPath,
	}
}

// GetAttribute extracts an attribute cty.Path from a diagnostic if it contains
// one. Normally this is not accessed directly, and instead the config body is
// added to the Diagnostic to create a more complete message for the user. In
// some cases however, we may want to know just the name of the attribute that
// generated the Diagnostic message.
// This returns a nil cty.Path if it does not exist in the Diagnostic.
func GetAttribute(d Diagnostic) cty.Path {
	if d, ok := d.(*attributeDiagnostic); ok {
		return d.attrPath
	}
	return nil
}

type attributeDiagnostic struct {
	diagnosticBase
	attrPath cty.Path
	subject  *SourceRange // populated only after ElaborateFromConfigBody
}

// ElaborateFromConfigBody finds the most accurate possible source location
// for a diagnostic's attribute path within the given body.
//
// Backing out from a path back to a source location is not always entirely
// possible because we lose some information in the decoding process, so
// if an exact position cannot be found then the returned diagnostic will
// refer to a position somewhere within the containing body, which is assumed
// to be better than no location at all.
//
// If possible it is generally better to report an error at a layer where
// source location information is still available, for more accuracy. This
// is not always possible due to system architecture, so this serves as a
// "best effort" fallback behavior for such situations.
func (d *attributeDiagnostic) ElaborateFromConfigBody(body hcl.Body, addr string) Diagnostic {
	// don't change an existing address
	if d.address == "" {
		d.address = addr
	}

	if len(d.attrPath) < 1 {
		// Should never happen, but we'll allow it rather than crashing.
		return d
	}

	if d.subject != nil {
		// Don't modify an already-elaborated diagnostic.
		return d
	}

	ret := *d

	// This function will often end up re-decoding values that were already
	// decoded by an earlier step. This is non-ideal but is architecturally
	// more convenient than arranging for source location information to be
	// propagated to every place in Terraform, and this happens only in the
	// presence of errors where performance isn't a concern.

	traverse := d.attrPath[:]
	final := d.attrPath[len(d.attrPath)-1]

	// Index should never be the first step
	// as indexing of top blocks (such as resources & data sources)
	// is handled elsewhere
	if _, isIdxStep := traverse[0].(cty.IndexStep); isIdxStep {
		subject := SourceRangeFromHCL(body.MissingItemRange())
		ret.subject = &subject
		return &ret
	}

	// Process index separately
	idxStep, hasIdx := final.(cty.IndexStep)
	if hasIdx {
		final = d.attrPath[len(d.attrPath)-2]
		traverse = d.attrPath[:len(d.attrPath)-1]
	}

	// If we have more than one step after removing index
	// then we'll first try to traverse to a child body
	// corresponding to the requested path.
	if len(traverse) > 1 {
		body = traversePathSteps(traverse, body)
	}

	// Default is to indicate a missing item in the deepest body we reached
	// while traversing.
	subject := SourceRangeFromHCL(body.MissingItemRange())
	ret.subject = &subject

	// Once we get here, "final" should be a GetAttr step that maps to an
	// attribute in our current body.
	finalStep, isAttr := final.(cty.GetAttrStep)
	if !isAttr {
		return &ret
	}

	content, _, contentDiags := body.PartialContent(&hcl.BodySchema{
		Attributes: []hcl.AttributeSchema{
			{
				Name:     finalStep.Name,
				Required: true,
			},
		},
	})
	if contentDiags.HasErrors() {
		return &ret
	}

	if attr, ok := content.Attributes[finalStep.Name]; ok {
		hclRange := attr.Expr.Range()
		if hasIdx {
			// Try to be more precise by finding index range
			hclRange = hclRangeFromIndexStepAndAttribute(idxStep, attr)
		}
		subject = SourceRangeFromHCL(hclRange)
		ret.subject = &subject
	}

	return &ret
}

func traversePathSteps(traverse []cty.PathStep, body hcl.Body) hcl.Body {
	for i := 0; i < len(traverse); i++ {
		step := traverse[i]

		switch tStep := step.(type) {
		case cty.GetAttrStep:

			var next cty.PathStep
			if i < (len(traverse) - 1) {
				next = traverse[i+1]
			}

			// Will be indexing into our result here?
			var indexType cty.Type
			var indexVal cty.Value
			if nextIndex, ok := next.(cty.IndexStep); ok {
				indexVal = nextIndex.Key
				indexType = indexVal.Type()
				i++ // skip over the index on subsequent iterations
			}

			var blockLabelNames []string
			if indexType == cty.String {
				// Map traversal means we expect one label for the key.
				blockLabelNames = []string{"key"}
			}

			// For intermediate steps we expect to be referring to a child
			// block, so we'll attempt decoding under that assumption.
			content, _, contentDiags := body.PartialContent(&hcl.BodySchema{
				Blocks: []hcl.BlockHeaderSchema{
					{
						Type:       tStep.Name,
						LabelNames: blockLabelNames,
					},
				},
			})
			if contentDiags.HasErrors() {
				return body
			}
			filtered := make([]*hcl.Block, 0, len(content.Blocks))
			for _, block := range content.Blocks {
				if block.Type == tStep.Name {
					filtered = append(filtered, block)
				}
			}
			if len(filtered) == 0 {
				// Step doesn't refer to a block
				continue
			}

			switch indexType {
			case cty.NilType: // no index at all
				if len(filtered) != 1 {
					return body
				}
				body = filtered[0].Body
			case cty.Number:
				var idx int
				err := gocty.FromCtyValue(indexVal, &idx)
				if err != nil || idx >= len(filtered) {
					return body
				}
				body = filtered[idx].Body
			case cty.String:
				key := indexVal.AsString()
				var block *hcl.Block
				for _, candidate := range filtered {
					if candidate.Labels[0] == key {
						block = candidate
						break
					}
				}
				if block == nil {
					// No block with this key, so we'll just indicate a
					// missing item in the containing block.
					return body
				}
				body = block.Body
			default:
				// Should never happen, because only string and numeric indices
				// are supported by cty collections.
				return body
			}

		default:
			// For any other kind of step, we'll just return our current body
			// as the subject and accept that this is a little inaccurate.
			return body
		}
	}
	return body
}

func hclRangeFromIndexStepAndAttribute(idxStep cty.IndexStep, attr *hcl.Attribute) hcl.Range {
	switch idxStep.Key.Type() {
	case cty.Number:
		var idx int
		err := gocty.FromCtyValue(idxStep.Key, &idx)
		items, diags := hcl.ExprList(attr.Expr)
		if diags.HasErrors() {
			return attr.Expr.Range()
		}
		if err != nil || idx >= len(items) {
			return attr.NameRange
		}
		return items[idx].Range()
	case cty.String:
		pairs, diags := hcl.ExprMap(attr.Expr)
		if diags.HasErrors() {
			return attr.Expr.Range()
		}
		stepKey := idxStep.Key.AsString()
		for _, kvPair := range pairs {
			key, diags := kvPair.Key.Value(nil)
			if diags.HasErrors() {
				return attr.Expr.Range()
			}
			if key.AsString() == stepKey {
				startRng := kvPair.Value.StartRange()
				return startRng
			}
		}
		return attr.NameRange
	}
	return attr.Expr.Range()
}

func (d *attributeDiagnostic) Source() Source {
	return Source{
		Subject: d.subject,
	}
}

// WholeContainingBody returns a diagnostic about the body that is an implied
// current configuration context. This should be returned only from
// functions whose interface specifies a clear configuration context that this
// will be resolved in.
//
// The returned attribute will not have source location information until
// context is applied to the containing diagnostics using diags.InConfigBody.
// After context is applied, the source location is currently the missing item
// range of the body. In future, this may change to some other suitable
// part of the containing body.
func WholeContainingBody(severity Severity, summary, detail string) Diagnostic {
	return &wholeBodyDiagnostic{
		diagnosticBase: diagnosticBase{
			severity: severity,
			summary:  summary,
			detail:   detail,
		},
	}
}

type wholeBodyDiagnostic struct {
	diagnosticBase
	subject *SourceRange // populated only after ElaborateFromConfigBody
}

func (d *wholeBodyDiagnostic) ElaborateFromConfigBody(body hcl.Body, addr string) Diagnostic {
	// don't change an existing address
	if d.address == "" {
		d.address = addr
	}

	if d.subject != nil {
		// Don't modify an already-elaborated diagnostic.
		return d
	}

	ret := *d
	rng := SourceRangeFromHCL(body.MissingItemRange())
	ret.subject = &rng
	return &ret
}

func (d *wholeBodyDiagnostic) Source() Source {
	return Source{
		Subject: d.subject,
	}
}
