// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package modernize

import (
	"cmp"
	"fmt"
	"go/ast"
	"go/constant"
	"go/token"
	"go/types"
	"maps"
	"slices"
	"strings"

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/analysis/passes/inspect"
	"golang.org/x/tools/go/ast/edge"
	"golang.org/x/tools/go/ast/inspector"
	"golang.org/x/tools/internal/analysis/analyzerutil"
	typeindexanalyzer "golang.org/x/tools/internal/analysis/typeindex"
	"golang.org/x/tools/internal/astutil"
	"golang.org/x/tools/internal/refactor"
	"golang.org/x/tools/internal/typesinternal/typeindex"
)

var StringsBuilderAnalyzer = &analysis.Analyzer{
	Name: "stringsbuilder",
	Doc:  analyzerutil.MustExtractDoc(doc, "stringsbuilder"),
	Requires: []*analysis.Analyzer{
		inspect.Analyzer,
		typeindexanalyzer.Analyzer,
	},
	Run: stringsbuilder,
	URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringbuilder",
}

// stringsbuilder replaces string += string in a loop by strings.Builder.
func stringsbuilder(pass *analysis.Pass) (any, error) {
	// Skip the analyzer in packages where its
	// fixes would create an import cycle.
	if within(pass, "strings", "runtime") {
		return nil, nil
	}

	var (
		inspect = pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
		index   = pass.ResultOf[typeindexanalyzer.Analyzer].(*typeindex.Index)
		info    = pass.TypesInfo
	)

	// Gather all local string variables that appear on the
	// LHS of some string += string assignment.
	candidates := make(map[*types.Var]bool)
	for curAssign := range inspect.Root().Preorder((*ast.AssignStmt)(nil)) {
		assign := curAssign.Node().(*ast.AssignStmt)
		if assign.Tok == token.ADD_ASSIGN && is[*ast.Ident](assign.Lhs[0]) {
			if v, ok := info.Uses[assign.Lhs[0].(*ast.Ident)].(*types.Var); ok &&
				v.Kind() == types.LocalVar &&
				types.Identical(v.Type(), builtinString.Type()) {
				candidates[v] = true
			}
		}
	}

	lexicalOrder := func(x, y *types.Var) int { return cmp.Compare(x.Pos(), y.Pos()) }

	// File and Pos of last fix edit,
	// for overlapping fix span detection.
	var (
		lastEditFile *ast.File
		lastEditEnd  token.Pos
	)

	// Now check each candidate variable's decl and uses.
nextcand:
	for _, v := range slices.SortedFunc(maps.Keys(candidates), lexicalOrder) {
		var edits, postEdits []analysis.TextEdit // postEdits are emitted last

		// Check declaration of s has one of these forms:
		//
		//    s := expr
		//    var s [string] [= expr]
		//    var ( ...; s [string] [= expr] )			(s is last)
		//
		// and transform to one of:
		//
		//    var   s strings.Builder ; s.WriteString(expr)
		//    var ( s strings.Builder); s.WriteString(expr)
		//
		def, ok := index.Def(v)
		if !ok {
			continue
		}

		// To avoid semantic conflicts, do not offer a fix if its edit
		// range (ignoring import edits) overlaps a previous fix.
		// This fixes #76983 and is an ad-hoc mitigation of #76476.
		file := astutil.EnclosingFile(def)
		if file == lastEditFile && v.Pos() < lastEditEnd {
			continue
		}
		filename := pass.Fset.File(file.FileStart).Name()
		// Suppress diagnostics in test files, where suggested fixes may increase
		// verbosity, and performance doesn't matter as much.
		// See https://go.dev/issue/78613
		if strings.HasSuffix(filename, "_test.go") {
			continue
		}

		ek := def.ParentEdgeKind()
		if ek == edge.AssignStmt_Lhs &&
			len(def.Parent().Node().(*ast.AssignStmt).Lhs) == 1 {
			// Have: s := expr
			// => var s strings.Builder; s.WriteString(expr)

			assign := def.Parent().Node().(*ast.AssignStmt)

			// Reject "if s := f(); ..." since in that context
			// we can't replace the assign with two statements.
			switch def.Parent().Parent().Node().(type) {
			case *ast.BlockStmt, *ast.CaseClause, *ast.CommClause:
				// OK: these are the parts of syntax that
				// allow unrestricted statement lists.
			default:
				continue
			}

			// Add strings import.
			prefix, importEdits := refactor.AddImport(
				info, astutil.EnclosingFile(def), "strings", "strings", "Builder", v.Pos())
			edits = append(edits, importEdits...)

			if isEmptyString(info, assign.Rhs[0]) {
				// s := ""
				// ---------------------
				// var s strings.Builder
				edits = append(edits, analysis.TextEdit{
					Pos:     assign.Pos(),
					End:     assign.End(),
					NewText: fmt.Appendf(nil, "var %[1]s %[2]sBuilder", v.Name(), prefix),
				})

			} else {
				// s :=                                 expr
				// -------------------------------------    -
				// var s strings.Builder; s.WriteString(expr)
				edits = append(edits, []analysis.TextEdit{
					{
						Pos:     assign.Pos(),
						End:     assign.Rhs[0].Pos(),
						NewText: fmt.Appendf(nil, "var %[1]s %[2]sBuilder; %[1]s.WriteString(", v.Name(), prefix),
					},
					{
						Pos:     assign.End(),
						End:     assign.End(),
						NewText: []byte(")"),
					},
				}...)

			}

		} else if ek == edge.ValueSpec_Names &&
			len(def.Parent().Node().(*ast.ValueSpec).Names) == 1 &&
			first(def.Parent().Parent().LastChild()) == def.Parent() {
			// Have: var   s [string] [= expr]
			//   or: var ( s [string] [= expr] )
			// => var s strings.Builder; s.WriteString(expr)
			//
			// The LastChild check rejects this case:
			//   var ( s [string] [= expr]; others... )
			// =>
			//   var ( s strings.Builder; others... ); s.WriteString(expr)
			// since it moves 'expr' across 'others', requiring
			// reformatting of syntax, which in general is lossy
			// of comments and vertical space.
			// We expect this to be rare.

			// Add strings import.
			prefix, importEdits := refactor.AddImport(
				info, astutil.EnclosingFile(def), "strings", "strings", "Builder", v.Pos())
			edits = append(edits, importEdits...)

			spec := def.Parent().Node().(*ast.ValueSpec)
			decl := def.Parent().Parent().Node().(*ast.GenDecl)

			init := spec.Names[0].End() // start of " = expr"
			if spec.Type != nil {
				init = spec.Type.End()
			}

			// Replace (possibly absent) type:
			//
			// var s [string]
			//      ----------------
			// var s strings.Builder
			edits = append(edits, analysis.TextEdit{
				Pos:     spec.Names[0].End(),
				End:     init,
				NewText: fmt.Appendf(nil, " %sBuilder", prefix),
			})

			if len(spec.Values) > 0 && !isEmptyString(info, spec.Values[0]) {
				if decl.Rparen.IsValid() {
					// var decl with explicit parens:
					//
					// var ( ...  =               expr )
					//           -                     -
					// var ( ... ); s.WriteString(expr)
					edits = append(edits, []analysis.TextEdit{
						{
							Pos:     init,
							End:     init,
							NewText: []byte(")"),
						},
						{
							Pos: spec.Values[0].End(),
							End: decl.End(),
						},
					}...)
				}

				// =               expr
				// ----------------    -
				// ; s.WriteString(expr)
				edits = append(edits, []analysis.TextEdit{
					{
						Pos:     init,
						End:     spec.Values[0].Pos(),
						NewText: fmt.Appendf(nil, "; %s.WriteString(", v.Name()),
					},
					{
						Pos:     spec.Values[0].End(),
						End:     spec.Values[0].End(),
						NewText: []byte(")"),
					},
				}...)
			} else {
				// delete "= expr"
				edits = append(edits, analysis.TextEdit{
					Pos: init,
					End: spec.End(),
				})
			}

		} else {
			continue
		}

		// Check uses of s.
		//
		// - All uses of s except the final one must be of the form
		//
		//    s += expr
		//
		//   Each of these will become s.WriteString(expr).
		//   At least one of them must be in an intervening loop
		//   w.r.t. the declaration of s:
		//
		//    var s string
		//    for ... { s += expr }
		//
		// - All uses of s after the last += must be rvalue uses (e.g. use(s), not &s).
		//   Each of these will become s.String().
		//
		//   Perhaps surprisingly, it is fine for there to be an
		//   intervening loop or lambda w.r.t. the declaration of s:
		//
		//    var s strings.Builder
		//    for range kSmall { s.WriteString(expr) }
		//    for range kLarge { use(s.String()) } // called repeatedly
		//
		//   Even though that might cause the s.String() operation to be
		//   executed repeatedly, this is not a deoptimization because,
		//   by design, (*strings.Builder).String does not allocate.
		var (
			numLoopAssigns int             // number of += assignments within a loop
			loopAssign     *ast.AssignStmt // first += assignment within a loop
			seenRvalueUse  bool            // => we've seen at least one rvalue use of s
		)
		for curUse := range index.Uses(v) {
			// Strip enclosing parens around Ident.
			curUse = astutil.UnparenEnclosingCursor(curUse)
			ek := curUse.ParentEdgeKind()

			// intervening reports whether cur has an ancestor of
			// one of the given types that is within the scope of v.
			intervening := func(types ...ast.Node) bool {
				for cur := range curUse.Enclosing(types...) {
					if v.Pos() <= cur.Node().Pos() { // in scope of v
						return true
					}
				}
				return false
			}

			if ek == edge.AssignStmt_Lhs {
				// After an rvalue use, no more assignments are allowed.
				if seenRvalueUse {
					continue nextcand
				}

				assign := curUse.Parent().Node().(*ast.AssignStmt)
				if assign.Tok != token.ADD_ASSIGN {
					continue nextcand
				}
				// Have: s += expr

				// At least one of the += operations
				// must appear within a loop.
				// relative to the declaration of s.
				if intervening((*ast.ForStmt)(nil), (*ast.RangeStmt)(nil)) {
					numLoopAssigns++
					if loopAssign == nil {
						loopAssign = assign
					}
				}

				// s +=          expr
				//  -------------    -
				// s.WriteString(expr)
				edits = append(edits, analysis.TextEdit{
					// replace " += " with ".WriteString("
					Pos:     assign.Lhs[0].End(),
					End:     assign.Rhs[0].Pos(),
					NewText: []byte(".WriteString("),
				})

				// Delay inserting the closing parenthesis, in case it overlaps with a
				// .String() edit, since it would need to come after.
				postEdits = append(postEdits, analysis.TextEdit{
					// insert ")"
					Pos:     assign.End(),
					End:     assign.End(),
					NewText: []byte(")"),
				})

			} else if ek == edge.UnaryExpr_X &&
				curUse.Parent().Node().(*ast.UnaryExpr).Op == token.AND {
				// Have: use(&s)
				continue nextcand // s is used as an lvalue; reject

			} else {
				// The only possible l-value uses of a string variable
				// are assignments (s=expr, s+=expr, etc) and &s.
				// (For strings, we can ignore method calls s.m().)
				// All other uses are r-values.
				seenRvalueUse = true

				edits = append(edits, analysis.TextEdit{
					// insert ".String()"
					Pos:     curUse.Node().End(),
					End:     curUse.Node().End(),
					NewText: []byte(".String()"),
				})
			}
		}
		if !seenRvalueUse {
			continue nextcand // no rvalue use; reject
		}
		if numLoopAssigns == 0 {
			continue nextcand // no += in a loop; reject
		}

		edits = append(edits, postEdits...)

		lastEditFile = file
		lastEditEnd = edits[len(edits)-1].End

		pass.Report(analysis.Diagnostic{
			Pos:     loopAssign.Pos(),
			End:     loopAssign.End(),
			Message: "using string += string in a loop is inefficient",
			SuggestedFixes: []analysis.SuggestedFix{{
				Message:   "Replace string += string with strings.Builder",
				TextEdits: edits,
			}},
		})
	}

	return nil, nil
}

// isEmptyString reports whether e (a string-typed expression) has constant value "".
func isEmptyString(info *types.Info, e ast.Expr) bool {
	tv, ok := info.Types[e]
	return ok && tv.Value != nil && constant.StringVal(tv.Value) == ""
}
