// Copyright 2020 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 source

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

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/internal/lsp/analysis/fillstruct"
	"golang.org/x/tools/internal/lsp/analysis/undeclaredname"
	"golang.org/x/tools/internal/lsp/protocol"
	"golang.org/x/tools/internal/span"
)

type Command struct {
	Name, Title string

	// appliesFn is an optional field to indicate whether or not a command can
	// be applied to the given inputs. If it returns false, we should not
	// suggest this command for these inputs.
	appliesFn AppliesFunc

	// suggestedFixFn is an optional field to generate the edits that the
	// command produces for the given inputs.
	suggestedFixFn SuggestedFixFunc
}

type AppliesFunc func(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, pkg *types.Package, info *types.Info) bool

// SuggestedFixFunc is a function used to get the suggested fixes for a given
// gopls command, some of which are provided by go/analysis.Analyzers. Some of
// the analyzers in internal/lsp/analysis are not efficient enough to include
// suggested fixes with their diagnostics, so we have to compute them
// separately. Such analyzers should provide a function with a signature of
// SuggestedFixFunc.
type SuggestedFixFunc func(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, pkg *types.Package, info *types.Info) (*analysis.SuggestedFix, error)

// Commands are the commands currently supported by gopls.
var Commands = []*Command{
	CommandGenerate,
	CommandFillStruct,
	CommandRegenerateCgo,
	CommandTest,
	CommandTidy,
	CommandUndeclaredName,
	CommandUpgradeDependency,
	CommandVendor,
	CommandExtractVariable,
	CommandExtractFunction,
	CommandToggleDetails,
}

var (
	// CommandTest runs `go test` for a specific test function.
	CommandTest = &Command{
		Name:  "test",
		Title: "Run test(s)",
	}

	// CommandGenerate runs `go generate` for a given directory.
	CommandGenerate = &Command{
		Name:  "generate",
		Title: "Run go generate",
	}

	// CommandTidy runs `go mod tidy` for a module.
	CommandTidy = &Command{
		Name:  "tidy",
		Title: "Run go mod tidy",
	}

	// CommandVendor runs `go mod vendor` for a module.
	CommandVendor = &Command{
		Name:  "vendor",
		Title: "Run go mod vendor",
	}

	// CommandUpgradeDependency upgrades a dependency.
	CommandUpgradeDependency = &Command{
		Name:  "upgrade_dependency",
		Title: "Upgrade dependency",
	}

	// CommandRegenerateCgo regenerates cgo definitions.
	CommandRegenerateCgo = &Command{
		Name:  "regenerate_cgo",
		Title: "Regenerate cgo",
	}

	// CommandToggleDetails controls calculation of gc annotations.
	CommandToggleDetails = &Command{
		Name:  "gc_details",
		Title: "Toggle gc_details",
	}

	// CommandFillStruct is a gopls command to fill a struct with default
	// values.
	CommandFillStruct = &Command{
		Name:           "fill_struct",
		suggestedFixFn: fillstruct.SuggestedFix,
	}

	// CommandUndeclaredName adds a variable declaration for an undeclared
	// name.
	CommandUndeclaredName = &Command{
		Name:           "undeclared_name",
		suggestedFixFn: undeclaredname.SuggestedFix,
	}

	// CommandExtractVariable extracts an expression to a variable.
	CommandExtractVariable = &Command{
		Name:           "extract_variable",
		Title:          "Extract to variable",
		suggestedFixFn: extractVariable,
		appliesFn: func(_ *token.FileSet, rng span.Range, _ []byte, file *ast.File, _ *types.Package, _ *types.Info) bool {
			_, _, ok, _ := canExtractVariable(rng, file)
			return ok
		},
	}

	// CommandExtractFunction extracts statements to a function.
	CommandExtractFunction = &Command{
		Name:           "extract_function",
		Title:          "Extract to function",
		suggestedFixFn: extractFunction,
		appliesFn: func(fset *token.FileSet, rng span.Range, src []byte, file *ast.File, _ *types.Package, info *types.Info) bool {
			_, _, _, _, _, ok, _ := canExtractFunction(fset, rng, src, file, info)
			return ok
		},
	}
)

// Applies reports whether the command c implements a suggested fix that is
// relevant to the given rng.
func (c *Command) Applies(ctx context.Context, snapshot Snapshot, fh FileHandle, pRng protocol.Range) bool {
	// If there is no applies function, assume that the command applies.
	if c.appliesFn == nil {
		return true
	}
	fset, rng, src, file, _, pkg, info, err := getAllSuggestedFixInputs(ctx, snapshot, fh, pRng)
	if err != nil {
		return false
	}
	return c.appliesFn(fset, rng, src, file, pkg, info)
}

// IsSuggestedFix reports whether the given command is intended to work as a
// suggested fix. Suggested fix commands are intended to return edits which are
// then applied to the workspace.
func (c *Command) IsSuggestedFix() bool {
	return c.suggestedFixFn != nil
}

// SuggestedFix applies the command's suggested fix to the given file and
// range, returning the resulting edits.
func (c *Command) SuggestedFix(ctx context.Context, snapshot Snapshot, fh VersionedFileHandle, pRng protocol.Range) ([]protocol.TextDocumentEdit, error) {
	if c.suggestedFixFn == nil {
		return nil, fmt.Errorf("no suggested fix function for %s", c.Name)
	}
	fset, rng, src, file, m, pkg, info, err := getAllSuggestedFixInputs(ctx, snapshot, fh, pRng)
	if err != nil {
		return nil, err
	}
	fix, err := c.suggestedFixFn(fset, rng, src, file, pkg, info)
	if err != nil {
		return nil, err
	}
	if fix == nil {
		return nil, nil
	}

	var edits []protocol.TextDocumentEdit
	for _, edit := range fix.TextEdits {
		rng := span.NewRange(fset, edit.Pos, edit.End)
		spn, err := rng.Span()
		if err != nil {
			return nil, err
		}
		clRng, err := m.Range(spn)
		if err != nil {
			return nil, err
		}
		edits = append(edits, protocol.TextDocumentEdit{
			TextDocument: protocol.VersionedTextDocumentIdentifier{
				Version: fh.Version(),
				TextDocumentIdentifier: protocol.TextDocumentIdentifier{
					URI: protocol.URIFromSpanURI(fh.URI()),
				},
			},
			Edits: []protocol.TextEdit{
				{
					Range:   clRng,
					NewText: string(edit.NewText),
				},
			},
		})
	}
	return edits, nil
}

// getAllSuggestedFixInputs is a helper function to collect all possible needed
// inputs for an AppliesFunc or SuggestedFixFunc.
func getAllSuggestedFixInputs(ctx context.Context, snapshot Snapshot, fh FileHandle, pRng protocol.Range) (*token.FileSet, span.Range, []byte, *ast.File, *protocol.ColumnMapper, *types.Package, *types.Info, error) {
	pkg, pgf, err := getParsedFile(ctx, snapshot, fh, NarrowestPackage)
	if err != nil {
		return nil, span.Range{}, nil, nil, nil, nil, nil, fmt.Errorf("getting file for Identifier: %w", err)
	}
	spn, err := pgf.Mapper.RangeSpan(pRng)
	if err != nil {
		return nil, span.Range{}, nil, nil, nil, nil, nil, err
	}
	rng, err := spn.Range(pgf.Mapper.Converter)
	if err != nil {
		return nil, span.Range{}, nil, nil, nil, nil, nil, err
	}
	src, err := fh.Read()
	if err != nil {
		return nil, span.Range{}, nil, nil, nil, nil, nil, err
	}
	return snapshot.FileSet(), rng, src, pgf.File, pgf.Mapper, pkg.GetTypes(), pkg.GetTypesInfo(), nil
}
