// Copyright 2019 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 golang

import (
	"cmp"
	"context"
	"go/ast"
	"go/token"
	"slices"
	"strings"

	"golang.org/x/tools/gopls/internal/cache"
	"golang.org/x/tools/gopls/internal/cache/parsego"
	"golang.org/x/tools/gopls/internal/file"
	"golang.org/x/tools/gopls/internal/protocol"
	"golang.org/x/tools/gopls/internal/util/bug"
	"golang.org/x/tools/gopls/internal/util/safetoken"
)

// FoldingRange gets all of the folding range for f.
func FoldingRange(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle, lineFoldingOnly bool) ([]protocol.FoldingRange, error) {
	// TODO(suzmue): consider limiting the number of folding ranges returned, and
	// implement a way to prioritize folding ranges in that case.
	pgf, err := snapshot.ParseGo(ctx, fh, parsego.Full)
	if err != nil {
		return nil, err
	}

	// With parse errors, we wouldn't be able to produce accurate folding info.
	// LSP protocol (3.16) currently does not have a way to handle this case
	// (https://github.com/microsoft/language-server-protocol/issues/1200).
	// We cannot return an error either because we are afraid some editors
	// may not handle errors nicely. As a workaround, we now return an empty
	// result and let the client handle this case by double check the file
	// contents (i.e. if the file is not empty and the folding range result
	// is empty, raise an internal error).
	if pgf.ParseErr != nil {
		return nil, nil
	}

	// Get folding ranges for comments separately as they are not walked by ast.Inspect.
	ranges := commentsFoldingRange(pgf)

	// Walk the ast and collect folding ranges.
	filter := []ast.Node{
		(*ast.BasicLit)(nil),
		(*ast.BlockStmt)(nil),
		(*ast.CallExpr)(nil),
		(*ast.CaseClause)(nil),
		(*ast.CommClause)(nil),
		(*ast.CompositeLit)(nil),
		(*ast.FieldList)(nil),
		(*ast.GenDecl)(nil),
	}
	for cur := range pgf.Cursor().Preorder(filter...) {
		var kind protocol.FoldingRangeKind
		// start and end define the range of content to fold away.
		var start, end token.Pos
		switch n := cur.Node().(type) {
		case *ast.BlockStmt:
			// Fold between positions of or lines between "{" and "}".
			start, end = bracketedFoldingRange(pgf, n.Lbrace, n.Rbrace, lineFoldingOnly)

		case *ast.CaseClause:
			// Fold from position of ":" to end.
			start, end = n.Colon+1, n.End()

		case *ast.CommClause:
			// Fold from position of ":" to end.
			start, end = n.Colon+1, n.End()

		case *ast.CallExpr:
			// Fold between positions of or lines between "(" and ")".
			start, end = bracketedFoldingRange(pgf, n.Lparen, n.Rparen, lineFoldingOnly)

		case *ast.FieldList:
			// Fold between positions of or lines between opening parenthesis/brace and closing parenthesis/brace.
			start, end = bracketedFoldingRange(pgf, n.Opening, n.Closing, lineFoldingOnly)

		case *ast.GenDecl:
			// If this is an import declaration, set the kind to be protocol.Imports.
			if n.Tok == token.IMPORT {
				kind = protocol.Imports
			}
			// Fold between positions of or lines between "(" and ")".
			start, end = bracketedFoldingRange(pgf, n.Lparen, n.Rparen, lineFoldingOnly)

		case *ast.BasicLit:
			// Fold raw string literals from position of "`" to position of "`".
			if n.Kind == token.STRING && len(n.Value) >= 2 && n.Value[0] == '`' && n.Value[len(n.Value)-1] == '`' {
				start, end = n.Pos(), n.End()
			}

		case *ast.CompositeLit:
			// Fold between positions of or lines between "{" and "}".
			start, end = bracketedFoldingRange(pgf, n.Lbrace, n.Rbrace, lineFoldingOnly)

		default:
			panic(n)
		}

		// Check that folding positions are valid.
		if !start.IsValid() || !end.IsValid() {
			continue
		}
		if start == end {
			// Nothing to fold.
			continue
		}
		// in line folding mode, do not fold if the start and end lines are the same.
		if lineFoldingOnly && safetoken.Line(pgf.Tok, start) == safetoken.Line(pgf.Tok, end) {
			continue
		}
		rng, err := pgf.PosRange(start, end)
		if err != nil {
			bug.Reportf("failed to create range: %s", err) // can't happen
			continue
		}
		ranges = append(ranges, foldingRange(kind, rng))
	}

	// Sort by start position.
	slices.SortFunc(ranges, func(x, y protocol.FoldingRange) int {
		if d := cmp.Compare(*x.StartLine, *y.StartLine); d != 0 {
			return d
		}
		return cmp.Compare(*x.StartCharacter, *y.StartCharacter)
	})

	return ranges, nil
}

// bracketedFoldingRange returns the folding range for nodes with parentheses/braces/brackets
// that potentially can take up multiple lines.
func bracketedFoldingRange(pgf *parsego.File, open, close token.Pos, lineFoldingOnly bool) (token.Pos, token.Pos) {
	if !open.IsValid() || !close.IsValid() {
		return token.NoPos, token.NoPos
	}
	if open+1 == close {
		// Nothing to fold: (), {} or [].
		return token.NoPos, token.NoPos
	}

	if !lineFoldingOnly {
		return open + 1, close
	}

	// Clients with "LineFoldingOnly" set to true can fold only full lines.
	// This is also checked in the caller.
	//
	// Clients that support folding ranges can display them in various ways
	// (e.g., how are folding ranges marked? is the final line displayed?).
	// The most common client
	// is vscode, which displays the first line followed by ..., and then does not
	// display any other lines in the range, but other clients might also display
	// final line of the range. For example, the following code
	//
	//	var x = []string{"a",
	//	"b",
	//	"c" }
	//
	// can be folded (in vscode) to
	//
	// var x = []string{"a", ...
	//
	// or in some other client
	//
	//	var x = []string{"a", ...
	//	"c" }
	//
	// This code displays the final line containing ),},], but not the closing quote
	// of a multi-line string

	prevLineEnd := pgf.Tok.LineStart(safetoken.Line(pgf.Tok, close)) - 1 // there was a previous line
	if prevLineEnd <= open {                                             // all the same line
		return token.NoPos, token.NoPos
	}
	return open + 1, prevLineEnd
}

// commentsFoldingRange returns the folding ranges for all comment blocks in file.
// The folding range starts at the end of the first line of the comment block, and ends at the end of the
// comment block and has kind protocol.Comment.
func commentsFoldingRange(pgf *parsego.File) (comments []protocol.FoldingRange) {
	tokFile := pgf.Tok
	for _, commentGrp := range pgf.File.Comments {
		startGrpLine, endGrpLine := safetoken.Line(tokFile, commentGrp.Pos()), safetoken.Line(tokFile, commentGrp.End())
		if startGrpLine == endGrpLine {
			// Don't fold single line comments.
			continue
		}

		firstComment := commentGrp.List[0]
		startPos, endLinePos := firstComment.Pos(), firstComment.End()
		startCmmntLine, endCmmntLine := safetoken.Line(tokFile, startPos), safetoken.Line(tokFile, endLinePos)
		if startCmmntLine != endCmmntLine {
			// If the first comment spans multiple lines, then we want to have the
			// folding range start at the end of the first line.
			endLinePos = token.Pos(int(startPos) + len(strings.Split(firstComment.Text, "\n")[0]))
		}
		rng, err := pgf.PosRange(endLinePos, commentGrp.End())
		if err != nil {
			bug.Reportf("failed to create mapped range: %s", err) // can't happen
			continue
		}
		// Fold from the end of the first line comment to the end of the comment block.
		comments = append(comments, foldingRange(protocol.Comment, rng))
	}
	return comments
}

func foldingRange(kind protocol.FoldingRangeKind, rng protocol.Range) protocol.FoldingRange {
	return protocol.FoldingRange{
		// (I guess LSP doesn't use a protocol.Range here
		// because missing means something different from zero.)
		StartLine:      varOf(rng.Start.Line),
		StartCharacter: varOf(rng.Start.Character),
		EndLine:        varOf(rng.End.Line),
		EndCharacter:   varOf(rng.End.Character),
		Kind:           string(kind),
	}
}

// varOf returns a new variable whose value is x.
func varOf[T any](x T) *T { return &x }
