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

package format

import (
	"bufio"
	"bytes"
	"fmt"
	"iter"
	"slices"
	"sort"
	"strings"

	viewsjson "github.com/hashicorp/terraform/internal/command/views/json"
	"github.com/hashicorp/terraform/internal/tfdiags"

	"github.com/mitchellh/colorstring"
	wordwrap "github.com/mitchellh/go-wordwrap"
)

var disabledColorize = &colorstring.Colorize{
	Colors:  colorstring.DefaultColors,
	Disable: true,
}

// Diagnostic formats a single diagnostic message.
//
// The width argument specifies at what column the diagnostic messages will
// be wrapped. If set to zero, messages will not be wrapped by this function
// at all. Although the long-form text parts of the message are wrapped,
// not all aspects of the message are guaranteed to fit within the specified
// terminal width.
func Diagnostic(diag tfdiags.Diagnostic, sources map[string][]byte, color *colorstring.Colorize, width int) string {
	return DiagnosticFromJSON(viewsjson.NewDiagnostic(diag, sources), color, width)
}

func DiagnosticFromJSON(diag *viewsjson.Diagnostic, color *colorstring.Colorize, width int) string {
	if diag == nil {
		// No good reason to pass a nil diagnostic in here...
		return ""
	}

	var buf bytes.Buffer

	// these leftRule* variables are markers for the beginning of the lines
	// containing the diagnostic that are intended to help sighted users
	// better understand the information hierarchy when diagnostics appear
	// alongside other information or alongside other diagnostics.
	//
	// Without this, it seems (based on folks sharing incomplete messages when
	// asking questions, or including extra content that's not part of the
	// diagnostic) that some readers have trouble easily identifying which
	// text belongs to the diagnostic and which does not.
	var leftRuleLine, leftRuleStart, leftRuleEnd string
	var leftRuleWidth int // in visual character cells

	switch diag.Severity {
	case viewsjson.DiagnosticSeverityError:
		buf.WriteString(color.Color("[bold][red]Error: [reset]"))
		leftRuleLine = color.Color("[red]│[reset] ")
		leftRuleStart = color.Color("[red]╷[reset]")
		leftRuleEnd = color.Color("[red]╵[reset]")
		leftRuleWidth = 2
	case viewsjson.DiagnosticSeverityWarning:
		buf.WriteString(color.Color("[bold][yellow]Warning: [reset]"))
		leftRuleLine = color.Color("[yellow]│[reset] ")
		leftRuleStart = color.Color("[yellow]╷[reset]")
		leftRuleEnd = color.Color("[yellow]╵[reset]")
		leftRuleWidth = 2
	default:
		// Clear out any coloring that might be applied by Terraform's UI helper,
		// so our result is not context-sensitive.
		buf.WriteString(color.Color("\n[reset]"))
	}

	// We don't wrap the summary, since we expect it to be terse, and since
	// this is where we put the text of a native Go error it may not always
	// be pure text that lends itself well to word-wrapping.
	fmt.Fprintf(&buf, color.Color("[bold]%s[reset]\n\n"), diag.Summary)

	f := &snippetFormatter{&buf, diag, color}
	f.write()

	if diag.Detail != "" {
		paraWidth := width - leftRuleWidth - 1 // leave room for the left rule
		if paraWidth > 0 {
			lines := strings.Split(diag.Detail, "\n")
			for _, line := range lines {
				if !strings.HasPrefix(line, " ") {
					line = wordwrap.WrapString(color.Color(line), uint(paraWidth))
				}
				fmt.Fprintf(&buf, "%s\n", line)
			}
		} else {
			fmt.Fprintf(&buf, "%s\n", diag.Detail)
		}
	}

	// Before we return, we'll finally add the left rule prefixes to each
	// line so that the overall message is visually delimited from what's
	// around it. We'll do that by scanning over what we already generated
	// and adding the prefix for each line.
	var ruleBuf strings.Builder
	sc := bufio.NewScanner(&buf)
	ruleBuf.WriteString(leftRuleStart)
	ruleBuf.WriteByte('\n')
	for sc.Scan() {
		line := sc.Text()
		prefix := leftRuleLine
		if line == "" {
			// Don't print the space after the line if there would be nothing
			// after it anyway.
			prefix = strings.TrimSpace(prefix)
		}
		ruleBuf.WriteString(prefix)
		ruleBuf.WriteString(line)
		ruleBuf.WriteByte('\n')
	}
	ruleBuf.WriteString(leftRuleEnd)
	ruleBuf.WriteByte('\n')

	return ruleBuf.String()
}

// DiagnosticPlain is an alternative to Diagnostic which minimises the use of
// virtual terminal formatting sequences.
//
// It is intended for use in automation and other contexts in which diagnostic
// messages are parsed from the Terraform output.
func DiagnosticPlain(diag tfdiags.Diagnostic, sources map[string][]byte, width int) string {
	return DiagnosticPlainFromJSON(viewsjson.NewDiagnostic(diag, sources), width)
}

func DiagnosticPlainFromJSON(diag *viewsjson.Diagnostic, width int) string {
	if diag == nil {
		// No good reason to pass a nil diagnostic in here...
		return ""
	}

	var buf bytes.Buffer

	switch diag.Severity {
	case viewsjson.DiagnosticSeverityError:
		buf.WriteString("\nError: ")
	case viewsjson.DiagnosticSeverityWarning:
		buf.WriteString("\nWarning: ")
	default:
		buf.WriteString("\n")
	}

	// We don't wrap the summary, since we expect it to be terse, and since
	// this is where we put the text of a native Go error it may not always
	// be pure text that lends itself well to word-wrapping.
	fmt.Fprintf(&buf, "%s\n\n", diag.Summary)

	f := &snippetFormatter{&buf, diag, disabledColorize}
	f.write()

	if diag.Detail != "" {
		if width > 1 {
			lines := strings.Split(diag.Detail, "\n")
			for _, line := range lines {
				if !strings.HasPrefix(line, " ") {
					line = wordwrap.WrapString(disabledColorize.Color(line), uint(width-1))
				}
				fmt.Fprintf(&buf, "%s\n", line)
			}
		} else {
			fmt.Fprintf(&buf, "%s\n", diag.Detail)
		}
	}

	return buf.String()
}

// DiagnosticWarningsCompact is an alternative to Diagnostic for when all of
// the given diagnostics are warnings and we want to show them compactly,
// with only two lines per warning and excluding all of the detail information.
//
// The caller may optionally pre-process the given diagnostics with
// ConsolidateWarnings, in which case this function will recognize consolidated
// messages and include an indication that they are consolidated.
//
// Do not pass non-warning diagnostics to this function, or the result will
// be nonsense.
func DiagnosticWarningsCompact(diags tfdiags.Diagnostics, color *colorstring.Colorize) string {
	var b strings.Builder
	b.WriteString(color.Color("[bold][yellow]Warnings:[reset]\n\n"))
	for _, diag := range diags {
		sources := tfdiags.WarningGroupSourceRanges(diag)
		b.WriteString(fmt.Sprintf("- %s\n", diag.Description().Summary))
		if len(sources) > 0 {
			mainSource := sources[0]
			if mainSource.Subject != nil {
				if len(sources) > 1 {
					b.WriteString(fmt.Sprintf(
						"  on %s line %d (and %d more)\n",
						mainSource.Subject.Filename,
						mainSource.Subject.Start.Line,
						len(sources)-1,
					))
				} else {
					b.WriteString(fmt.Sprintf(
						"  on %s line %d\n",
						mainSource.Subject.Filename,
						mainSource.Subject.Start.Line,
					))
				}
			} else if len(sources) > 1 {
				b.WriteString(fmt.Sprintf(
					"  (%d occurences of this warning)\n",
					len(sources),
				))
			}
		}
	}

	return b.String()
}

// snippetFormatter handles formatting diagnostic information with source snippets
type snippetFormatter struct {
	buf   *bytes.Buffer
	diag  *viewsjson.Diagnostic
	color *colorstring.Colorize
}

func (f *snippetFormatter) write() {
	diag := f.diag
	buf := f.buf

	// The beginning of a Terraform snippet points to the source code location
	// where the snippet was defined, (e.g. "on main.tf line 7")
	// However, if the diagnostic is associated with a policy evaluation,
	// the policy diagnostic will first indicate which policy was violated,
	// and then include the terraform source code location as well.
	mainSnippetPrefix := "  on"
	if diag.PolicyRange != nil {
		mainSnippetPrefix = "  while evaluating policy for"

		// if the diagnostic has a policy range, then it contains policy-specific information
		// and we will write that first.
		f.writePolicySnippet(diag)
	}

	if diag.Address != "" {
		fmt.Fprintf(buf, "  with %s,\n", diag.Address)
	}

	if diag.Range == nil {
		return
	}

	if diag.Snippet == nil {
		// This should generally not happen, as long as sources are always
		// loaded through the main loader. We may load things in other
		// ways in weird cases, so we'll tolerate it at the expense of
		// a not-so-helpful error message.
		fmt.Fprintf(buf, "%s %s line %d:\n  (source code not available)\n",
			mainSnippetPrefix, diag.Range.Filename, diag.Range.Start.Line)
	} else {
		f.writeSnippet(mainSnippetPrefix, diag.Range, diag.Snippet)

		if diag.DeprecationOriginDescription != "" {
			fmt.Fprintf(buf, "\n  The deprecation originates from %s\n", diag.DeprecationOriginDescription)
		}
	}

	buf.WriteByte('\n')
}

func (f *snippetFormatter) writePolicySnippet(diag *viewsjson.Diagnostic) {
	if diag.PolicySnippet == nil {
		fmt.Fprintf(f.buf, "  on %s line %d:\n  (source code not available)\n",
			diag.PolicyRange.Filename, diag.PolicyRange.Start.Line)
	} else {
		f.writeSnippet("  on", diag.PolicyRange, diag.PolicySnippet)
	}

	f.buf.WriteByte('\n')
}

// writeSnippet renders a diagnostic output from the given snippet and range.
// The snippetPrefix is used as the prefix for the context line.
func (f *snippetFormatter) writeSnippet(snippetPrefix string, rng *viewsjson.DiagnosticRange, snippet *viewsjson.DiagnosticSnippet) {
	buf := f.buf
	color := f.color
	var contextStr string

	// Build the context string from the snippet's context, if available
	if snippet.Context != nil {
		contextStr = fmt.Sprintf(", in %s", *snippet.Context)
	}
	context := fmt.Sprintf("%s %s line %d%s:\n",
		snippetPrefix, rng.Filename, rng.Start.Line, contextStr)
	buf.WriteString(context)

	code := snippet.Code
	// Split the snippet and render the highlighted section with underlines
	start := snippet.HighlightStartOffset
	end := snippet.HighlightEndOffset

	// Only buggy diagnostics can have an end range before the start, but
	// we need to ensure we don't crash here if that happens.
	if end < start {
		end = start + 1
		end = min(end, len(code))
	}

	// If either start or end is out of range for the code buffer then
	// we'll cap them at the bounds just to avoid a panic, although
	// this would happen only if there's a bug in the code generating
	// the snippet objects.
	if start < 0 {
		start = 0
	} else if start > len(code) {
		start = len(code)
	}
	if end < 0 {
		end = 0
	} else if end > len(code) {
		end = len(code)
	}

	before, highlight, after := code[0:start], code[start:end], code[end:]
	code = fmt.Sprintf(color.Color("%s[underline]%s[reset]%s"), before, highlight, after)

	// Split the snippet into lines and render one at a time
	lines := strings.Split(code, "\n")
	for i, line := range lines {
		fmt.Fprintf(
			buf, "%4d: %s\n",
			snippet.StartLine+i,
			line,
		)
	}

	if len(snippet.Values) > 0 || (snippet.FunctionCall != nil && snippet.FunctionCall.Signature != nil) || snippet.TestAssertionExpr != nil {
		// The diagnostic may also have information about the dynamic
		// values of relevant variables at the point of evaluation.
		// This is particularly useful for expressions that get evaluated
		// multiple times with different values, such as blocks using
		// "count" and "for_each", or within "for" expressions.
		values := slices.Clone(snippet.Values)
		sort.Slice(values, func(i, j int) bool {
			return values[i].Traversal < values[j].Traversal
		})

		fmt.Fprint(buf, color.Color("    [dark_gray]├────────────────[reset]\n"))
		if callInfo := snippet.FunctionCall; callInfo != nil && callInfo.Signature != nil {

			fmt.Fprintf(buf, color.Color("    [dark_gray]│[reset] while calling [bold]%s[reset]("), callInfo.CalledAs)
			for i, param := range callInfo.Signature.Params {
				if i > 0 {
					buf.WriteString(", ")
				}
				buf.WriteString(param.Name)
			}
			if param := callInfo.Signature.VariadicParam; param != nil {
				if len(callInfo.Signature.Params) > 0 {
					buf.WriteString(", ")
				}
				buf.WriteString(param.Name)
				buf.WriteString("...")
			}
			buf.WriteString(")\n")
		}

		// always print the values unless in the case of a test assertion, where we only print them if the user has requested verbose output
		printValues := snippet.TestAssertionExpr == nil || snippet.TestAssertionExpr.ShowVerbose

		// The diagnostic may also have information about failures from test assertions
		// in a `terraform test` run. This is useful for understanding the values that
		// were being compared when the assertion failed.
		// Also, we'll print a JSON diff of the two values to make it easier to see the
		// differences.
		if snippet.TestAssertionExpr != nil {
			f.printTestDiagOutput(snippet.TestAssertionExpr)
		}

		if printValues {
			for _, value := range values {
				// if the statement is one line, we'll just print it as is
				// otherwise, we have to ensure that each line is indented correctly
				// and that the first line has the traversal information
				valSlice := strings.Split(value.Statement, "\n")
				fmt.Fprintf(buf, color.Color("    [dark_gray]│[reset] [bold]%s[reset] %s\n"),
					value.Traversal, valSlice[0])

				for _, line := range valSlice[1:] {
					fmt.Fprintf(buf, color.Color("    [dark_gray]│[reset]   %s\n"), line)
				}
			}
		}
	}
}

func (f *snippetFormatter) printTestDiagOutput(diag *viewsjson.DiagnosticTestBinaryExpr) {
	buf := f.buf
	color := f.color
	// We only print the LHS and RHS if the user has requested verbose output
	// for the test assertion.
	if diag.ShowVerbose {
		fmt.Fprint(buf, color.Color("    [dark_gray]│[reset] [bold]LHS[reset]:\n"))
		for line := range strings.SplitSeq(diag.LHS, "\n") {
			fmt.Fprintf(buf, color.Color("    [dark_gray]│[reset]   %s\n"), line)
		}
		fmt.Fprint(buf, color.Color("    [dark_gray]│[reset] [bold]RHS[reset]:\n"))
		for line := range strings.SplitSeq(diag.RHS, "\n") {
			fmt.Fprintf(buf, color.Color("    [dark_gray]│[reset]   %s\n"), line)
		}
	}
	if diag.Warning != "" {
		fmt.Fprintf(buf, color.Color("    [dark_gray]│[reset] [bold]Warning[reset]: %s\n"), diag.Warning)
	}
	f.printJSONDiff(diag.LHS, diag.RHS)
	buf.WriteByte('\n')
}

// printJSONDiff prints a colorized line-by-line diff of the JSON values of the LHS and RHS expressions
// in a test assertion.
// It visually distinguishes removed and added lines, helping users identify
// discrepancies between an "actual" (lhsStr) and an "expected" (rhsStr) JSON output.
func (f *snippetFormatter) printJSONDiff(lhsStr, rhsStr string) {

	buf := f.buf
	color := f.color
	// No visible difference in the JSON, so we'll just return
	if lhsStr == rhsStr {
		return
	}
	fmt.Fprint(buf, color.Color("    [dark_gray]│[reset] [bold]Diff[reset]:\n"))
	fmt.Fprint(buf, color.Color("    [dark_gray]│[reset] [red][bold]--- actual[reset]\n"))
	fmt.Fprint(buf, color.Color("    [dark_gray]│[reset] [green][bold]+++ expected[reset]\n"))
	nextLhs, stopLhs := iter.Pull(strings.SplitSeq(lhsStr, "\n"))
	nextRhs, stopRhs := iter.Pull(strings.SplitSeq(rhsStr, "\n"))

	printLine := func(prefix, line string) {
		var colour string
		switch prefix {
		case "-":
			colour = "[red]"
		case "+":
			colour = "[green]"
		default:
		}
		msg := fmt.Sprintf("    [dark_gray]│[reset] %s%s[reset] %s\n", colour, prefix, line)
		fmt.Fprint(buf, color.Color(msg))
	}

	// Collect differing lines separately for each side
	removedLines := []string{}
	addedLines := []string{}

	// Function to print collected diffs and reset buffers
	printDiffs := func() {
		for _, line := range removedLines {
			printLine("-", line)
		}
		for _, line := range addedLines {
			printLine("+", line)
		}
		removedLines = []string{}
		addedLines = []string{}
	}

	// We'll iterate over both sides of the expression and collect the differences
	// along the way. When a match is found, we'll then print all the collected diffs
	// and the matching line, and then reset the buffers.
	for {
		lhsLine, lhsOk := nextLhs()
		rhsLine, rhsOk := nextRhs()

		if !lhsOk && !rhsOk { // Both sides are done, so we'll print the diffs and break
			printDiffs()
			break
		}

		// If one side is done, we'll just print the remaining lines from the other side
		if !lhsOk {
			addedLines = append(addedLines, rhsLine)
			continue
		}
		if !rhsOk {
			removedLines = append(removedLines, lhsLine)
			continue
		}

		if lhsLine == rhsLine {
			printDiffs()
			printLine(" ", lhsLine)
		} else {
			removedLines = append(removedLines, lhsLine)
			addedLines = append(addedLines, rhsLine)
		}
	}

	stopLhs()
	stopRhs()
}
