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

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

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/gopls/internal/analysis/fillstruct"
	"golang.org/x/tools/gopls/internal/analysis/unusedparams"
	"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"
)

// A fixer is a function that suggests a fix for a diagnostic produced
// by the analysis framework. This is done outside of the analyzer Run
// function so that the construction of expensive fixes can be
// deferred until they are requested by the user.
//
// The actual diagnostic is not provided; only its position, as the
// triple (pgf, start, end); the resulting SuggestedFix implicitly
// relates to that file.
//
// The supplied token positions (start, end) must belong to
// pkg.FileSet(), and the returned positions
// (SuggestedFix.TextEdits[*].{Pos,End}) must belong to the returned
// FileSet, which is not necessarily the same.
// (See [insertDeclsAfter] for explanation.)
//
// A fixer may return (nil, nil) if no fix is available.
type fixer func(ctx context.Context, s *cache.Snapshot, pkg *cache.Package, pgf *parsego.File, start, end token.Pos) (*token.FileSet, *analysis.SuggestedFix, error)

// A singleFileFixer is a [fixer] that inspects only a single file.
type singleFileFixer func(pkg *cache.Package, pgf *parsego.File, start, end token.Pos) (*token.FileSet, *analysis.SuggestedFix, error)

// singleFile adapts a [singleFileFixer] to a [fixer]
// by discarding the snapshot and the context it needs.
func singleFile(fixer1 singleFileFixer) fixer {
	return func(_ context.Context, _ *cache.Snapshot, pkg *cache.Package, pgf *parsego.File, start, end token.Pos) (*token.FileSet, *analysis.SuggestedFix, error) {
		return fixer1(pkg, pgf, start, end)
	}
}

// Names of ApplyFix.Fix created directly by the CodeAction handler.
const (
	fixExtractVariable         = "extract_variable" // (or constant)
	fixExtractVariableAll      = "extract_variable_all"
	fixExtractFunction         = "extract_function"
	fixExtractMethod           = "extract_method"
	fixInlineCall              = "inline_call" // keep consistent with go/analysis/passes/inline Diagnostic.Category
	fixInlineVariable          = "inline_variable"
	fixInvertIfCondition       = "invert_if_condition"
	fixSplitLines              = "split_lines"
	fixJoinLines               = "join_lines"
	fixCreateUndeclared        = "create_undeclared"
	fixMissingInterfaceMethods = "stub_missing_interface_method"
	fixMissingCalledFunction   = "stub_missing_called_function"
)

// ApplyFix applies the specified kind of suggested fix to the given
// file and range, returning the resulting changes.
//
// A fix kind is either the Category of an analysis.Diagnostic that
// had a SuggestedFix with no edits; or the name of a fix agreed upon
// by [CodeActions] and this function.
// Fix kinds identify fixes in the command protocol.
//
// TODO(adonovan): come up with a better mechanism for registering the
// connection between analyzers, code actions, and fixers. A flaw of
// the current approach is that the same Category could in theory
// apply to a Diagnostic with several lazy fixes, making them
// impossible to distinguish. It would more precise if there was a
// SuggestedFix.Category field, or some other way to squirrel metadata
// in the fix.
func ApplyFix(ctx context.Context, fix string, snapshot *cache.Snapshot, fh file.Handle, rng protocol.Range) ([]protocol.DocumentChange, error) {
	// This can't be expressed as an entry in the fixer table below
	// because it operates in the protocol (not go/{token,ast}) domain.
	// (Sigh; perhaps it was a mistake to factor out the
	// NarrowestPackageForFile/RangePos/suggestedFixToEdits
	// steps.)
	if fix == unusedparams.FixCategory {
		return removeParam(ctx, snapshot, fh, rng)
	}

	fixers := map[string]fixer{
		// Fixes for analyzer-provided diagnostics.
		// These match the Diagnostic.Category.
		fillstruct.FixCategory: singleFile(fillstruct.SuggestedFix),

		// Ad-hoc fixers: these are used when the command is
		// constructed directly by logic in server/code_action.
		fixExtractFunction:         singleFile(extractFunction),
		fixExtractMethod:           singleFile(extractMethod),
		fixExtractVariable:         singleFile(extractVariableOne),
		fixExtractVariableAll:      singleFile(extractVariableAll),
		fixInlineCall:              inlineCall,
		fixInlineVariable:          singleFile(inlineVariableOne),
		fixInvertIfCondition:       singleFile(invertIfCondition),
		fixSplitLines:              singleFile(splitLines),
		fixJoinLines:               singleFile(joinLines),
		fixCreateUndeclared:        singleFile(createUndeclared),
		fixMissingInterfaceMethods: stubMissingInterfaceMethodsFixer,
		fixMissingCalledFunction:   stubMissingCalledFunctionFixer,
	}
	fixer, ok := fixers[fix]
	if !ok {
		return nil, fmt.Errorf("no suggested fix function for %s", fix)
	}
	pkg, pgf, err := NarrowestPackageForFile(ctx, snapshot, fh.URI())
	if err != nil {
		return nil, err
	}
	start, end, err := pgf.RangePos(rng)
	if err != nil {
		return nil, err
	}
	fixFset, suggestion, err := fixer(ctx, snapshot, pkg, pgf, start, end)
	if err != nil {
		return nil, err
	}
	if suggestion == nil {
		return nil, nil
	}

	// Refine #68818 so we learn which values of 'fix'
	// break the assertion in suggestedFixToDocumentChange.
	for _, edit := range suggestion.TextEdits {
		if fixFset.File(edit.Pos) == nil {
			switch fix {
			case fillstruct.FixCategory:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixExtractFunction:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixExtractMethod:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixExtractVariable:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixExtractVariableAll:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixInlineCall:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixInlineVariable:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixInvertIfCondition:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixSplitLines:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixJoinLines:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixCreateUndeclared:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixMissingInterfaceMethods:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			case fixMissingCalledFunction:
				bug.Report("no token.File for TextEdit.Pos (#68818)")
			}
			break
		}
	}

	return suggestedFixToDocumentChange(ctx, snapshot, fixFset, suggestion)
}

// suggestedFixToDocumentChange converts the suggestion's edits from analysis form into protocol form.
func suggestedFixToDocumentChange(ctx context.Context, snapshot *cache.Snapshot, fset *token.FileSet, suggestion *analysis.SuggestedFix) ([]protocol.DocumentChange, error) {
	type fileInfo struct {
		fh     file.Handle
		mapper *protocol.Mapper
		edits  []protocol.TextEdit
	}
	files := make(map[protocol.DocumentURI]*fileInfo)
	for _, edit := range suggestion.TextEdits {
		tokFile := fset.File(edit.Pos)
		if tokFile == nil {
			return nil, bug.Errorf("no file for edit position (#68818)")
		}
		end := edit.End
		if !end.IsValid() {
			end = edit.Pos
		}
		uri := protocol.URIFromPath(tokFile.Name())
		info, ok := files[uri]
		if !ok {
			// First edit: create a mapper.
			fh, err := snapshot.ReadFile(ctx, uri)
			if err != nil {
				return nil, err
			}
			content, err := fh.Content()
			if err != nil {
				return nil, err
			}
			mapper := protocol.NewMapper(uri, content)
			info = &fileInfo{fh, mapper, nil}
			files[uri] = info
		}
		rng, err := info.mapper.PosRange(tokFile, edit.Pos, end)
		if err != nil {
			return nil, err
		}
		info.edits = append(info.edits, protocol.TextEdit{
			Range:   rng,
			NewText: string(edit.NewText),
		})
	}
	var changes []protocol.DocumentChange
	for _, info := range files {
		change := protocol.DocumentChangeEdit(info.fh, info.edits)
		changes = append(changes, change)
	}
	return changes, nil
}
