// Copyright 2025 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 refactor

// This file defines operations for computing deletion edits.

import (
	"fmt"
	"go/ast"
	"go/token"
	"go/types"
	"slices"

	"golang.org/x/tools/go/ast/edge"
	"golang.org/x/tools/go/ast/inspector"
	"golang.org/x/tools/internal/astutil"
	"golang.org/x/tools/internal/typesinternal"
	"golang.org/x/tools/internal/typesinternal/typeindex"
)

// DeleteVar returns edits to delete the declaration of a variable or
// constant whose defining identifier is curId.
//
// It handles variants including:
// - GenDecl > ValueSpec versus AssignStmt;
// - RHS expression has effects, or not;
// - entire statement/declaration may be eliminated;
// and removes associated comments.
//
// If it cannot make the necessary edits, such as for a function
// parameter or result, it returns nil.
func DeleteVar(tokFile *token.File, info *types.Info, curId inspector.Cursor) []Edit {
	switch curId.ParentEdgeKind() {
	case edge.ValueSpec_Names:
		return deleteVarFromValueSpec(tokFile, info, curId)

	case edge.AssignStmt_Lhs:
		return deleteVarFromAssignStmt(tokFile, info, curId)
	}

	// e.g. function receiver, parameter, or result,
	// or "switch v := expr.(T) {}" (which has no object).
	return nil
}

// deleteVarFromValueSpec returns edits to delete the declaration of a
// variable or constant within a ValueSpec.
//
// Precondition: curId is Ident beneath ValueSpec.Names beneath GenDecl.
//
// See also [deleteVarFromAssignStmt], which has parallel structure.
func deleteVarFromValueSpec(tokFile *token.File, info *types.Info, curIdent inspector.Cursor) []Edit {
	var (
		id      = curIdent.Node().(*ast.Ident)
		curSpec = curIdent.Parent()
		spec    = curSpec.Node().(*ast.ValueSpec)
	)

	declaresOtherNames := slices.ContainsFunc(spec.Names, func(name *ast.Ident) bool {
		return name != id && name.Name != "_"
	})
	noRHSEffects := !slices.ContainsFunc(spec.Values, func(rhs ast.Expr) bool {
		return !typesinternal.NoEffects(info, rhs)
	})
	if !declaresOtherNames && noRHSEffects {
		// The spec is no longer needed, either to declare
		// other variables, or for its side effects.
		return DeleteSpec(tokFile, curSpec)
	}

	// The spec is still needed, either for
	// at least one LHS, or for effects on RHS.
	// Blank out or delete just one LHS.

	index := curIdent.ParentEdgeIndex() // index of LHS within ValueSpec.Names

	// If there is no RHS, we can delete the LHS.
	if len(spec.Values) == 0 {
		var pos, end token.Pos
		if index == len(spec.Names)-1 {
			// Delete final name.
			//
			// var _, lhs1 T
			//      ------
			pos = spec.Names[index-1].End()
			end = spec.Names[index].End()
		} else {
			// Delete non-final name.
			//
			// var lhs0, _ T
			//     ------
			pos = spec.Names[index].Pos()
			end = spec.Names[index+1].Pos()
		}
		return []Edit{{
			Pos: pos,
			End: end,
		}}
	}

	// If the assignment is n:n and the RHS has no effects,
	// we can delete the LHS and its corresponding RHS.
	if len(spec.Names) == len(spec.Values) &&
		typesinternal.NoEffects(info, spec.Values[index]) {

		if index == len(spec.Names)-1 {
			// Delete final items.
			//
			// var _, lhs1 = rhs0, rhs1
			//      ------       ------
			return []Edit{
				{
					Pos: spec.Names[index-1].End(),
					End: spec.Names[index].End(),
				},
				{
					Pos: spec.Values[index-1].End(),
					End: spec.Values[index].End(),
				},
			}
		} else {
			// Delete non-final items.
			//
			// var lhs0, _ = rhs0, rhs1
			//     ------    ------
			return []Edit{
				{
					Pos: spec.Names[index].Pos(),
					End: spec.Names[index+1].Pos(),
				},
				{
					Pos: spec.Values[index].Pos(),
					End: spec.Values[index+1].Pos(),
				},
			}
		}
	}

	// We cannot delete the RHS.
	// Blank out the LHS.
	return []Edit{{
		Pos:     id.Pos(),
		End:     id.End(),
		NewText: []byte("_"),
	}}
}

// Precondition: curId is Ident beneath AssignStmt.Lhs.
//
// See also [deleteVarFromValueSpec], which has parallel structure.
func deleteVarFromAssignStmt(tokFile *token.File, info *types.Info, curIdent inspector.Cursor) []Edit {
	var (
		id      = curIdent.Node().(*ast.Ident)
		curStmt = curIdent.Parent()
		assign  = curStmt.Node().(*ast.AssignStmt)
	)

	declaresOtherNames := slices.ContainsFunc(assign.Lhs, func(lhs ast.Expr) bool {
		lhsId, ok := lhs.(*ast.Ident)
		return ok && lhsId != id && lhsId.Name != "_"
	})
	noRHSEffects := !slices.ContainsFunc(assign.Rhs, func(rhs ast.Expr) bool {
		return !typesinternal.NoEffects(info, rhs)
	})
	if !declaresOtherNames && noRHSEffects {
		// The assignment is no longer needed, either to
		// declare other variables, or for its side effects.
		if edits := DeleteStmt(tokFile, curStmt); edits != nil {
			return edits
		}
		// Statement could not not be deleted in this context.
		// Fall back to conservative deletion.
	}

	// The assign is still needed, either for
	// at least one LHS, or for effects on RHS,
	// or because it cannot deleted because of its context.
	// Blank out or delete just one LHS.

	// If the assignment is 1:1 and the RHS has no effects,
	// we can delete the LHS and its corresponding RHS.
	index := curIdent.ParentEdgeIndex()
	if len(assign.Lhs) > 1 &&
		len(assign.Lhs) == len(assign.Rhs) &&
		typesinternal.NoEffects(info, assign.Rhs[index]) {

		if index == len(assign.Lhs)-1 {
			// Delete final items.
			//
			// _, lhs1 := rhs0, rhs1
			//  ------        ------
			return []Edit{
				{
					Pos: assign.Lhs[index-1].End(),
					End: assign.Lhs[index].End(),
				},
				{
					Pos: assign.Rhs[index-1].End(),
					End: assign.Rhs[index].End(),
				},
			}
		} else {
			// Delete non-final items.
			//
			// lhs0, _ := rhs0, rhs1
			// ------     ------
			return []Edit{
				{
					Pos: assign.Lhs[index].Pos(),
					End: assign.Lhs[index+1].Pos(),
				},
				{
					Pos: assign.Rhs[index].Pos(),
					End: assign.Rhs[index+1].Pos(),
				},
			}
		}
	}

	// We cannot delete the RHS.
	// Blank out the LHS.
	edits := []Edit{{
		Pos:     id.Pos(),
		End:     id.End(),
		NewText: []byte("_"),
	}}

	// If this eliminates the final variable declared by
	// an := statement, we need to turn it into an =
	// assignment to avoid a "no new variables on left
	// side of :=" error.
	if !declaresOtherNames {
		edits = append(edits, Edit{
			Pos:     assign.TokPos,
			End:     assign.TokPos + token.Pos(len(":=")),
			NewText: []byte("="),
		})
	}

	return edits
}

// DeleteSpec returns edits to delete the {Type,Value}Spec identified by curSpec.
//
// TODO(adonovan): add test suite. Test for consts as well.
func DeleteSpec(tokFile *token.File, curSpec inspector.Cursor) []Edit {
	var (
		spec    = curSpec.Node().(ast.Spec)
		curDecl = curSpec.Parent()
		decl    = curDecl.Node().(*ast.GenDecl)
	)

	// If it is the sole spec in the decl,
	// delete the entire decl.
	if len(decl.Specs) == 1 {
		return DeleteDecl(tokFile, curDecl)
	}

	// Delete the spec and its comments.
	index := curSpec.ParentEdgeIndex() // index of ValueSpec within GenDecl.Specs
	pos, end := spec.Pos(), spec.End()
	if doc := astutil.DocComment(spec); doc != nil {
		pos = doc.Pos() // leading comment
	}
	if index == len(decl.Specs)-1 {
		// Delete final spec.
		if c := eolComment(spec); c != nil {
			//  var (v int // comment \n)
			end = c.End()
		}
	} else {
		// Delete non-final spec.
		//   var ( a T; b T )
		//         -----
		end = decl.Specs[index+1].Pos()
	}
	return []Edit{{
		Pos: pos,
		End: end,
	}}
}

// DeleteDecl returns edits to delete the ast.Decl identified by curDecl.
//
// TODO(adonovan): add test suite.
func DeleteDecl(tokFile *token.File, curDecl inspector.Cursor) []Edit {
	decl := curDecl.Node().(ast.Decl)

	ek := curDecl.ParentEdgeKind()
	switch ek {
	case edge.DeclStmt_Decl:
		return DeleteStmt(tokFile, curDecl.Parent())

	case edge.File_Decls:
		pos, end := decl.Pos(), decl.End()
		if doc := astutil.DocComment(decl); doc != nil {
			pos = doc.Pos()
		}

		// Delete free-floating comments on same line as rparen.
		//    var (...) // comment
		var (
			file        = curDecl.Parent().Node().(*ast.File)
			lineOf      = tokFile.Line
			declEndLine = lineOf(decl.End())
		)
		for _, cg := range file.Comments {
			for _, c := range cg.List {
				if c.Pos() < end {
					continue // too early
				}
				commentEndLine := lineOf(c.End())
				if commentEndLine > declEndLine {
					break // too late
				} else if lineOf(c.Pos()) == declEndLine && commentEndLine == declEndLine {
					end = c.End()
				}
			}
		}

		return []Edit{{
			Pos: pos,
			End: end,
		}}

	default:
		panic(fmt.Sprintf("Decl parent is %v, want DeclStmt or File", ek))
	}
}

// find leftmost Pos bigger than start and rightmost less than end
func filterPos(nds []*ast.Comment, start, end token.Pos) (token.Pos, token.Pos, bool) {
	l, r := end, token.NoPos
	ok := false
	for _, n := range nds {
		if n.Pos() > start && n.Pos() < l {
			l = n.Pos()
			ok = true
		}
		if n.End() <= end && n.End() > r {
			r = n.End()
			ok = true
		}
	}
	return l, r, ok
}

// DeleteStmt returns the edits to remove the [ast.Stmt] identified by
// curStmt if it recognizes the context. It returns nil otherwise.
// TODO(pjw, adonovan): it should not return nil, it should return an error
//
// DeleteStmt is called with just the AST so it has trouble deciding if
// a comment is associated with the statement to be deleted. For instance,
//
//	for /*A*/ init()/*B*/;/*C/cond()/*D/;/*E*/post() /*F*/ { /*G*/}
//
// comment B and C are indistinguishable, as are D and E. That is, as the
// AST does not say where the semicolons are, B and C could go either
// with the init() or the cond(), so cannot be removed safely. The same
// is true for D, E, and the post(). (And there are other similar cases.)
// But the other comments can be removed as they are unambiguously
// associated with the statement being deleted. In particular,
// it removes whole lines like
//
//	stmt // comment
func DeleteStmt(file *token.File, curStmt inspector.Cursor) []Edit {
	// if the stmt is on a line by itself, or a range of lines, delete the whole thing
	// including comments. Except for the heads of switches, type
	// switches, and for-statements that's the usual case. Complexity occurs where
	// there are multiple statements on the same line, and adjacent comments.

	// In that case we remove some adjacent comments:
	// In me()/*A*/;b(), comment A cannot be removed, because the ast
	// is indistinguishable from me();/*A*/b()
	// and the same for cases like switch me()/*A*/; x.(type) {

	// this would be more precise with the file contents, or if the ast
	// contained the location of semicolons
	var (
		stmt          = curStmt.Node().(ast.Stmt)
		tokFile       = file
		lineOf        = tokFile.Line
		stmtStartLine = lineOf(stmt.Pos())
		stmtEndLine   = lineOf(stmt.End())

		leftSyntax, rightSyntax     token.Pos      // pieces of parent node on stmt{Start,End}Line
		leftComments, rightComments []*ast.Comment // comments before/after stmt on the same line
	)

	// remember the Pos that are on the same line as stmt
	use := func(left, right token.Pos) {
		if lineOf(left) == stmtStartLine {
			leftSyntax = left
		}
		if lineOf(right) == stmtEndLine {
			rightSyntax = right
		}
	}

	// find the comments, if any, on the same line
Big:
	for _, cg := range astutil.EnclosingFile(curStmt).Comments {
		for _, co := range cg.List {
			if lineOf(co.End()) < stmtStartLine {
				continue
			} else if lineOf(co.Pos()) > stmtEndLine {
				break Big // no more are possible
			}
			if lineOf(co.End()) == stmtStartLine && co.End() <= stmt.Pos() {
				// comment is before the statement
				leftComments = append(leftComments, co)
			} else if lineOf(co.Pos()) == stmtEndLine && co.Pos() >= stmt.End() {
				// comment is after the statement
				rightComments = append(rightComments, co)
			}
		}
	}

	// find any other syntax on the same line
	var (
		leftStmt, rightStmt token.Pos // end/start positions of sibling statements in a []Stmt list
		inStmtList          = false
		curParent           = curStmt.Parent()
	)
	switch parent := curParent.Node().(type) {
	case *ast.BlockStmt:
		use(parent.Lbrace, parent.Rbrace)
		inStmtList = true
	case *ast.CaseClause:
		use(parent.Colon, curStmt.Parent().Parent().Node().(*ast.BlockStmt).Rbrace)
		inStmtList = true
	case *ast.CommClause:
		if parent.Comm == stmt {
			return nil // maybe the user meant to remove the entire CommClause?
		}
		use(parent.Colon, curStmt.Parent().Parent().Node().(*ast.BlockStmt).Rbrace)
		inStmtList = true
	case *ast.ForStmt:
		use(parent.For, parent.Body.Lbrace)
		// special handling, as init;cond;post BlockStmt is not a statment list
		if parent.Init != nil && parent.Cond != nil && stmt == parent.Init && lineOf(parent.Cond.Pos()) == lineOf(stmt.End()) {
			rightStmt = parent.Cond.Pos()
		} else if parent.Post != nil && parent.Cond != nil && stmt == parent.Post && lineOf(parent.Cond.End()) == lineOf(stmt.Pos()) {
			leftStmt = parent.Cond.End()
		}
	case *ast.IfStmt:
		switch stmt {
		case parent.Init:
			use(parent.If, parent.Body.Lbrace)
		case parent.Else:
			// stmt is the {...} in "if cond {} else {...}" and removing
			// it would require removing the 'else' keyword, but the ast
			// does not contain its position.
			return nil
		}
	case *ast.SwitchStmt:
		use(parent.Switch, parent.Body.Lbrace)
	case *ast.TypeSwitchStmt:
		if stmt == parent.Assign {
			return nil // don't remove .(type)
		}
		use(parent.Switch, parent.Body.Lbrace)
	default:
		return nil // not one of ours
	}

	if inStmtList {
		// find the siblings, if any, on the same line
		if prev, found := curStmt.PrevSibling(); found && lineOf(prev.Node().End()) == stmtStartLine {
			if _, ok := prev.Node().(ast.Stmt); ok {
				leftStmt = prev.Node().End() // preceding statement ends on same line
			}
		}
		if next, found := curStmt.NextSibling(); found && lineOf(next.Node().Pos()) == stmtEndLine {
			rightStmt = next.Node().Pos() // following statement begins on same line
		}
	}

	// compute the left and right limits of the edit
	var leftEdit, rightEdit token.Pos
	if leftStmt.IsValid() {
		leftEdit = stmt.Pos() // can't remove preceding comments: a()/*A*/; me()
	} else if leftSyntax.IsValid() {
		// remove intervening leftComments
		if a, _, ok := filterPos(leftComments, leftSyntax, stmt.Pos()); ok {
			leftEdit = a
		} else {
			leftEdit = stmt.Pos()
		}
	} else { // remove whole line
		for leftEdit = stmt.Pos(); lineOf(leftEdit) == stmtStartLine; leftEdit-- {
		}
		if leftEdit < stmt.Pos() {
			leftEdit++ // beginning of line
		}
	}
	if rightStmt.IsValid() {
		rightEdit = stmt.End() // can't remove following comments
	} else if rightSyntax.IsValid() {
		// remove intervening rightComments
		if _, b, ok := filterPos(rightComments, stmt.End(), rightSyntax); ok {
			rightEdit = b
		} else {
			rightEdit = stmt.End()
		}
	} else { // remove whole line
		fend := token.Pos(file.Base()) + token.Pos(file.Size())
		for rightEdit = stmt.End(); fend >= rightEdit && lineOf(rightEdit) == stmtEndLine; rightEdit++ {
		}
		// don't remove \n if there was other stuff earlier
		if leftSyntax.IsValid() || leftStmt.IsValid() {
			rightEdit--
		}
	}

	return []Edit{{Pos: leftEdit, End: rightEdit}}
}

// DeleteUnusedVars computes the edits required to delete the
// declarations of any local variables whose last uses are in the
// curDelend subtree, which is about to be deleted.
func DeleteUnusedVars(index *typeindex.Index, info *types.Info, tokFile *token.File, curDelend inspector.Cursor) []Edit {
	// TODO(adonovan): we might want to generalize this by
	// splitting the two phases below, so that we can gather
	// across a whole sequence of deletions then finally compute the
	// set of variables that are no longer wanted.

	// Count number of deletions of each var.
	delcount := make(map[*types.Var]int)
	for curId := range curDelend.Preorder((*ast.Ident)(nil)) {
		id := curId.Node().(*ast.Ident)
		if v, ok := info.Uses[id].(*types.Var); ok &&
			typesinternal.GetVarKind(v) == typesinternal.LocalVar { // always false before go1.25
			delcount[v]++
		}
	}

	// Delete declaration of each var that became unused.
	var edits []Edit
	for v, count := range delcount {
		if len(slices.Collect(index.Uses(v))) == count {
			if curDefId, ok := index.Def(v); ok {
				edits = append(edits, DeleteVar(tokFile, info, curDefId)...)
			}
		}
	}
	return edits
}

func eolComment(n ast.Node) *ast.CommentGroup {
	// TODO(adonovan): support:
	//    func f() {...} // comment
	switch n := n.(type) {
	case *ast.GenDecl:
		if !n.TokPos.IsValid() && len(n.Specs) == 1 {
			return eolComment(n.Specs[0])
		}
	case *ast.ValueSpec:
		return n.Comment
	case *ast.TypeSpec:
		return n.Comment
	}
	return nil
}
