// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package tfdiags

import "fmt"

// Consolidate checks if there is an unreasonable amount of diagnostics
// with the same summary in the receiver and, if so, returns a new diagnostics
// with some of those diagnostics consolidated into a single diagnostic in order
// to reduce the verbosity of the output.
//
// This mechanism is here primarily for diagnostics printed out at the CLI. In
// other contexts it is likely better to just return the diagnostic directly,
// particularly if they are going to be interpreted by software rather than
// by a human reader.
//
// The returned slice always has a separate backing array from the receiver,
// but some diagnostic values themselves might be shared.
//
// The definition of "unreasonable" is given as the threshold argument. At most
// that many diagnostics with the same summary will be shown.
func (diags Diagnostics) Consolidate(threshold int, level Severity) Diagnostics {
	if len(diags) == 0 {
		return nil
	}

	newDiags := make(Diagnostics, 0, len(diags))

	// We'll track how many times we've seen each diagnostic summary so we can
	// decide when to start consolidating. Once we _have_ started consolidating,
	// we'll also track the object representing the consolidated diagnostic
	// so we can continue appending to it.
	diagnosticStats := make(map[string]int)
	diagnosticGroups := make(map[string]*consolidatedGroup)

	for _, diag := range diags {
		severity := diag.Severity()
		if severity != level || diag.Source().Subject == nil {
			// Only the given level can get special treatment, and we only
			// consolidate diagnostics that have source locations because
			// our primary goal here is to deal with the situation where
			// some configuration language feature is producing a diagnostic
			// each time it's used across a potentially-large config.
			newDiags = newDiags.Append(diag)
			continue
		}

		if DoNotConsolidateDiagnostic(diag) {
			// Then do not consolidate this diagnostic.
			newDiags = newDiags.Append(diag)
			continue
		}

		desc := diag.Description()
		consolidationKey := desc.Summary
		// If the diagnostic has a keyable extra info and it's not empty,
		// use it as the consolidation key, along with the summary.
		// Otherwise use the summary only.
		if key, keyOk := diag.ExtraInfo().(Keyable); keyOk {
			consolidationKey += key.ExtraInfoKey()
		}

		if g, ok := diagnosticGroups[consolidationKey]; ok {
			// We're already grouping this one, so we'll just continue it.
			g.Append(diag)
			continue
		}

		diagnosticStats[consolidationKey]++
		if diagnosticStats[consolidationKey] == threshold {
			// Initially creating the group doesn't really change anything
			// visibly in the result, since a group with only one diagnostic
			// is just a passthrough anyway, but once we do this any additional
			// diagnostics with the same consolidationKey will get appended to this group.
			g := &consolidatedGroup{}
			newDiags = newDiags.Append(g)
			diagnosticGroups[consolidationKey] = g
			g.Append(diag)
			continue
		}

		// If this diagnostic is not consolidating yet then we'll just append
		// it directly.
		newDiags = newDiags.Append(diag)
	}

	return newDiags
}

// Keyable is an interface that can be implemented by ExtraInfo to provide a key for consolidation.
// This is used to group diagnostics with the same summary and an extra info key.
// The key is used to determine if two diagnostics should be consolidated.
type Keyable interface {
	ExtraInfoKey() string
}

// A consolidatedGroup is one or more diagnostics grouped together for
// UI consolidation purposes.
//
// A consolidatedGroup with only one diagnostic in it is just a passthrough for
// that one diagnostic. If it has more than one then it will behave mostly
// like the first one but its detail message will include an additional
// sentence mentioning the consolidation. A consolidatedGroup with no diagnostics
// at all is invalid and will panic when used.
type consolidatedGroup struct {
	Consolidated Diagnostics
}

var _ Diagnostic = (*consolidatedGroup)(nil)

func (wg *consolidatedGroup) Severity() Severity {
	return wg.Consolidated[0].Severity()
}

func (wg *consolidatedGroup) Description() Description {
	desc := wg.Consolidated[0].Description()
	if len(wg.Consolidated) == 1 {
		return desc
	}
	extraCount := len(wg.Consolidated) - 1
	var msg string
	var diagType string
	switch wg.Severity() {
	case Error:
		diagType = "error"
	case Warning:
		diagType = "warning"
	default:
		panic(fmt.Sprintf("Invalid diagnostic severity: %#v", wg.Severity()))
	}

	switch extraCount {
	case 1:
		msg = fmt.Sprintf("(and one more similar %s elsewhere)", diagType)
	default:
		msg = fmt.Sprintf("(and %d more similar %ss elsewhere)", extraCount, diagType)
	}
	if desc.Detail != "" {
		desc.Detail = desc.Detail + "\n\n" + msg
	} else {
		desc.Detail = msg
	}
	return desc
}

func (wg *consolidatedGroup) Source() Source {
	return wg.Consolidated[0].Source()
}

func (wg *consolidatedGroup) FromExpr() *FromExpr {
	return wg.Consolidated[0].FromExpr()
}

func (wg *consolidatedGroup) ExtraInfo() interface{} {
	return wg.Consolidated[0].ExtraInfo()
}

func (wg *consolidatedGroup) Append(diag Diagnostic) {
	if len(wg.Consolidated) != 0 && diag.Severity() != wg.Severity() {
		panic("can't append a non-warning diagnostic to a warningGroup")
	}
	wg.Consolidated = append(wg.Consolidated, diag)
}

// ConsolidatedGroupSourceRanges can be used in conjunction with
// Diagnostics.Consolidate to recover the full set of original source
// locations from a consolidated diagnostic.
//
// For convenience, this function accepts any diagnostic and will just return
// the single Source value from any diagnostic that isn't a consolidated group.
func ConsolidatedGroupSourceRanges(diag Diagnostic) []Source {
	wg, ok := diag.(*consolidatedGroup)
	if !ok {
		return []Source{diag.Source()}
	}

	ret := make([]Source, len(wg.Consolidated))
	for i, wrappedDiag := range wg.Consolidated {
		ret[i] = wrappedDiag.Source()
	}
	return ret
}
