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

import (
	"context"
	"errors"
	"fmt"
	"regexp"
	"slices"
	"strings"

	"golang.org/x/mod/modfile"
	"golang.org/x/mod/module"
	"golang.org/x/tools/go/packages"
	"golang.org/x/tools/gopls/internal/file"
	"golang.org/x/tools/gopls/internal/label"
	"golang.org/x/tools/gopls/internal/protocol"
	"golang.org/x/tools/gopls/internal/protocol/command"
	"golang.org/x/tools/gopls/internal/util/memoize"
	"golang.org/x/tools/internal/event"
)

// A ParsedModule contains the results of parsing a go.mod file.
type ParsedModule struct {
	URI         protocol.DocumentURI
	File        *modfile.File // maybe absent in case of error
	ReplaceMap  map[module.Version]module.Version
	Mapper      *protocol.Mapper
	ParseErrors []*Diagnostic
}

// ParseMod parses a go.mod file, using a cache. It may return partial results and an error.
func (s *Snapshot) ParseMod(ctx context.Context, fh file.Handle) (*ParsedModule, error) {
	uri := fh.URI()

	s.mu.Lock()
	entry, hit := s.parseModHandles.Get(uri)
	s.mu.Unlock()

	type parseModKey file.Identity
	type parseModResult struct {
		parsed *ParsedModule
		err    error
	}

	// cache miss?
	if !hit {
		promise, release := s.store.Promise(parseModKey(fh.Identity()), func(ctx context.Context, _ any) any {
			parsed, err := parseModImpl(ctx, fh)
			return parseModResult{parsed, err}
		})

		entry = promise
		s.mu.Lock()
		s.parseModHandles.Set(uri, entry, func(_, _ any) { release() })
		s.mu.Unlock()
	}

	// Await result.
	v, err := s.awaitPromise(ctx, entry)
	if err != nil {
		return nil, err
	}
	res := v.(parseModResult)
	return res.parsed, res.err
}

// parseModImpl parses the go.mod file whose name and contents are in fh.
// It may return partial results and an error.
func parseModImpl(ctx context.Context, fh file.Handle) (*ParsedModule, error) {
	_, done := event.Start(ctx, "cache.ParseMod", label.URI.Of(fh.URI()))
	defer done()

	contents, err := fh.Content()
	if err != nil {
		return nil, err
	}
	m := protocol.NewMapper(fh.URI(), contents)
	file, parseErr := modfile.Parse(fh.URI().Path(), contents, nil)
	// Attempt to convert the error to a standardized parse error.
	var parseErrors []*Diagnostic
	if parseErr != nil {
		mfErrList, ok := parseErr.(modfile.ErrorList)
		if !ok {
			return nil, fmt.Errorf("unexpected parse error type %v", parseErr)
		}
		for _, mfErr := range mfErrList {
			rng, err := m.OffsetRange(mfErr.Pos.Byte, mfErr.Pos.Byte)
			if err != nil {
				return nil, err
			}
			parseErrors = append(parseErrors, &Diagnostic{
				URI:      fh.URI(),
				Range:    rng,
				Severity: protocol.SeverityError,
				Source:   ParseError,
				Message:  mfErr.Err.Error(),
			})
		}
	}

	replaceMap := make(map[module.Version]module.Version)
	if parseErr == nil {
		for _, rep := range file.Replace {
			replaceMap[rep.Old] = rep.New
		}
	}

	return &ParsedModule{
		URI:         fh.URI(),
		Mapper:      m,
		File:        file,
		ReplaceMap:  replaceMap,
		ParseErrors: parseErrors,
	}, parseErr
}

// A ParsedWorkFile contains the results of parsing a go.work file.
type ParsedWorkFile struct {
	URI         protocol.DocumentURI
	File        *modfile.WorkFile
	Mapper      *protocol.Mapper
	ParseErrors []*Diagnostic
}

// ParseWork parses a go.work file, using a cache. It may return partial results and an error.
// TODO(adonovan): move to new work.go file.
func (s *Snapshot) ParseWork(ctx context.Context, fh file.Handle) (*ParsedWorkFile, error) {
	uri := fh.URI()

	s.mu.Lock()
	entry, hit := s.parseWorkHandles.Get(uri)
	s.mu.Unlock()

	type parseWorkKey file.Identity
	type parseWorkResult struct {
		parsed *ParsedWorkFile
		err    error
	}

	// cache miss?
	if !hit {
		handle, release := s.store.Promise(parseWorkKey(fh.Identity()), func(ctx context.Context, _ any) any {
			parsed, err := parseWorkImpl(ctx, fh)
			return parseWorkResult{parsed, err}
		})

		entry = handle
		s.mu.Lock()
		s.parseWorkHandles.Set(uri, entry, func(_, _ any) { release() })
		s.mu.Unlock()
	}

	// Await result.
	v, err := s.awaitPromise(ctx, entry)
	if err != nil {
		return nil, err
	}
	res := v.(parseWorkResult)
	return res.parsed, res.err
}

// parseWorkImpl parses a go.work file. It may return partial results and an error.
func parseWorkImpl(ctx context.Context, fh file.Handle) (*ParsedWorkFile, error) {
	_, done := event.Start(ctx, "cache.ParseWork", label.URI.Of(fh.URI()))
	defer done()

	content, err := fh.Content()
	if err != nil {
		return nil, err
	}
	m := protocol.NewMapper(fh.URI(), content)
	file, parseErr := modfile.ParseWork(fh.URI().Path(), content, nil)
	// Attempt to convert the error to a standardized parse error.
	var parseErrors []*Diagnostic
	if parseErr != nil {
		mfErrList, ok := parseErr.(modfile.ErrorList)
		if !ok {
			return nil, fmt.Errorf("unexpected parse error type %v", parseErr)
		}
		for _, mfErr := range mfErrList {
			rng, err := m.OffsetRange(mfErr.Pos.Byte, mfErr.Pos.Byte)
			if err != nil {
				return nil, err
			}
			parseErrors = append(parseErrors, &Diagnostic{
				URI:      fh.URI(),
				Range:    rng,
				Severity: protocol.SeverityError,
				Source:   ParseError,
				Message:  mfErr.Err.Error(),
			})
		}
	}
	return &ParsedWorkFile{
		URI:         fh.URI(),
		Mapper:      m,
		File:        file,
		ParseErrors: parseErrors,
	}, parseErr
}

// ModWhy returns the "go mod why" result for each module named in a
// require statement in the go.mod file.
// TODO(adonovan): move to new mod_why.go file.
func (s *Snapshot) ModWhy(ctx context.Context, fh file.Handle) (map[string]string, error) {
	uri := fh.URI()

	if s.FileKind(fh) != file.Mod {
		return nil, fmt.Errorf("%s is not a go.mod file", uri)
	}

	s.mu.Lock()
	entry, hit := s.modWhyHandles.Get(uri)
	s.mu.Unlock()

	type modWhyResult struct {
		why map[string]string
		err error
	}

	// cache miss?
	if !hit {
		handle := memoize.NewPromise("modWhy", func(ctx context.Context, arg any) any {
			why, err := modWhyImpl(ctx, arg.(*Snapshot), fh)
			return modWhyResult{why, err}
		})

		entry = handle
		s.mu.Lock()
		s.modWhyHandles.Set(uri, entry, nil)
		s.mu.Unlock()
	}

	// Await result.
	v, err := s.awaitPromise(ctx, entry)
	if err != nil {
		return nil, err
	}
	res := v.(modWhyResult)
	return res.why, res.err
}

// modWhyImpl returns the result of "go mod why -m" on the specified go.mod file.
func modWhyImpl(ctx context.Context, snapshot *Snapshot, fh file.Handle) (map[string]string, error) {
	ctx, done := event.Start(ctx, "cache.ModWhy", label.URI.Of(fh.URI()))
	defer done()

	pm, err := snapshot.ParseMod(ctx, fh)
	if err != nil {
		return nil, err
	}
	// No requires to explain.
	if len(pm.File.Require) == 0 {
		return nil, nil // empty result
	}
	// Run `go mod why` on all the dependencies.
	args := []string{"why", "-m"}
	for _, req := range pm.File.Require {
		args = append(args, req.Mod.Path)
	}
	inv, cleanupInvocation, err := snapshot.GoCommandInvocation(NoNetwork, fh.URI().DirPath(), "mod", args)
	if err != nil {
		return nil, err
	}
	defer cleanupInvocation()
	stdout, err := snapshot.View().GoCommandRunner().Run(ctx, *inv)
	if err != nil {
		return nil, err
	}
	whyList := strings.Split(stdout.String(), "\n\n")
	if len(whyList) != len(pm.File.Require) {
		return nil, fmt.Errorf("mismatched number of results: got %v, want %v", len(whyList), len(pm.File.Require))
	}
	why := make(map[string]string, len(pm.File.Require))
	for i, req := range pm.File.Require {
		why[req.Mod.Path] = whyList[i]
	}
	return why, nil
}

// extractGoCommandErrors tries to parse errors that come from the go command
// and shape them into go.mod diagnostics.
// TODO: rename this to 'load errors'
func (s *Snapshot) extractGoCommandErrors(ctx context.Context, goCmdError error) []*Diagnostic {
	if goCmdError == nil {
		return nil
	}

	type locatedErr struct {
		loc protocol.Location
		msg string
	}
	diagLocations := map[*ParsedModule]locatedErr{}
	backupDiagLocations := map[*ParsedModule]locatedErr{}

	// If moduleErrs is non-nil, go command errors are scoped to specific
	// modules.
	var moduleErrs *moduleErrorMap
	_ = errors.As(goCmdError, &moduleErrs)

	// Match the error against all the mod files in the workspace.
	for _, uri := range s.View().ModFiles() {
		fh, err := s.ReadFile(ctx, uri)
		if err != nil {
			event.Error(ctx, "getting modfile for Go command error", err)
			continue
		}
		pm, err := s.ParseMod(ctx, fh)
		if err != nil {
			// Parsing errors are reported elsewhere
			return nil
		}
		var msgs []string // error messages to consider
		if moduleErrs != nil {
			if pm.File.Module != nil {
				for _, mes := range moduleErrs.errs[pm.File.Module.Mod.Path] {
					msgs = append(msgs, mes.Error())
				}
			}
		} else {
			msgs = append(msgs, goCmdError.Error())
		}
		for _, msg := range msgs {
			if strings.Contains(goCmdError.Error(), "errors parsing go.mod") {
				// The go command emits parse errors for completely invalid go.mod files.
				// Those are reported by our own diagnostics and can be ignored here.
				// As of writing, we are not aware of any other errors that include
				// file/position information, so don't even try to find it.
				continue
			}
			loc, found, err := s.matchErrorToModule(pm, msg)
			if err != nil {
				event.Error(ctx, "matching error to module", err)
				continue
			}
			le := locatedErr{
				loc: loc,
				msg: msg,
			}
			if found {
				diagLocations[pm] = le
			} else {
				backupDiagLocations[pm] = le
			}
		}
	}

	// If we didn't find any good matches, assign diagnostics to all go.mod files.
	if len(diagLocations) == 0 {
		diagLocations = backupDiagLocations
	}

	var srcErrs []*Diagnostic
	for pm, le := range diagLocations {
		diag, err := s.goCommandDiagnostic(pm, le.loc, le.msg)
		if err != nil {
			event.Error(ctx, "building go command diagnostic", err)
			continue
		}
		srcErrs = append(srcErrs, diag)
	}
	return srcErrs
}

var moduleVersionInErrorRe = regexp.MustCompile(`[:\s]([+-._~0-9A-Za-z]+)@([+-._~0-9A-Za-z]+)[:\s]`)

// matchErrorToModule matches a go command error message to a go.mod file.
// Some examples:
//
//	example.com@v1.2.2: reading example.com/@v/v1.2.2.mod: no such file or directory
//	go: github.com/cockroachdb/apd/v2@v2.0.72: reading github.com/cockroachdb/apd/go.mod at revision v2.0.72: unknown revision v2.0.72
//	go: example.com@v1.2.3 requires\n\trandom.org@v1.2.3: parsing go.mod:\n\tmodule declares its path as: bob.org\n\tbut was required as: random.org
//
// It returns the location of a reference to the one of the modules and true
// if one exists. If none is found it returns a fallback location and false.
func (s *Snapshot) matchErrorToModule(pm *ParsedModule, goCmdError string) (protocol.Location, bool, error) {
	var reference *modfile.Line
	matches := moduleVersionInErrorRe.FindAllStringSubmatch(goCmdError, -1)

	for _, match := range slices.Backward(matches) {
		ver := module.Version{Path: match[1], Version: match[2]}
		if err := module.Check(ver.Path, ver.Version); err != nil {
			continue
		}
		reference = findModuleReference(pm.File, ver)
		if reference != nil {
			break
		}
	}

	if reference == nil {
		// No match for the module path was found in the go.mod file.
		// Show the error on the module declaration, if one exists, or
		// just the first line of the file.
		var start, end int
		if pm.File.Module != nil && pm.File.Module.Syntax != nil {
			syntax := pm.File.Module.Syntax
			start, end = syntax.Start.Byte, syntax.End.Byte
		}
		loc, err := pm.Mapper.OffsetLocation(start, end)
		return loc, false, err
	}

	loc, err := pm.Mapper.OffsetLocation(reference.Start.Byte, reference.End.Byte)
	return loc, true, err
}

// goCommandDiagnostic creates a diagnostic for a given go command error.
func (s *Snapshot) goCommandDiagnostic(pm *ParsedModule, loc protocol.Location, goCmdError string) (*Diagnostic, error) {
	matches := moduleVersionInErrorRe.FindAllStringSubmatch(goCmdError, -1)
	var innermost *module.Version
	for _, match := range slices.Backward(matches) {
		ver := module.Version{Path: match[1], Version: match[2]}
		if err := module.Check(ver.Path, ver.Version); err != nil {
			continue
		}
		innermost = &ver
		break
	}

	switch {
	case strings.Contains(goCmdError, "inconsistent vendoring"):
		cmd := command.NewVendorCommand("Run go mod vendor", command.URIArg{URI: pm.URI})
		return &Diagnostic{
			URI:      pm.URI,
			Range:    loc.Range,
			Severity: protocol.SeverityError,
			Source:   ListError,
			Message: `Inconsistent vendoring detected. Please re-run "go mod vendor".
See https://github.com/golang/go/issues/39164 for more detail on this issue.`,
			SuggestedFixes: []SuggestedFix{SuggestedFixFromCommand(cmd, protocol.QuickFix)},
		}, nil

	case strings.Contains(goCmdError, "updates to go.sum needed"), strings.Contains(goCmdError, "missing go.sum entry"):
		var args []protocol.DocumentURI
		args = append(args, s.View().ModFiles()...)
		tidyCmd := command.NewTidyCommand("Run go mod tidy", command.URIArgs{URIs: args})
		updateCmd := command.NewUpdateGoSumCommand("Update go.sum", command.URIArgs{URIs: args})
		msg := "go.sum is out of sync with go.mod. Please update it by applying the quick fix."
		if innermost != nil {
			msg = fmt.Sprintf("go.sum is out of sync with go.mod: entry for %v is missing. Please updating it by applying the quick fix.", innermost)
		}
		return &Diagnostic{
			URI:      pm.URI,
			Range:    loc.Range,
			Severity: protocol.SeverityError,
			Source:   ListError,
			Message:  msg,
			SuggestedFixes: []SuggestedFix{
				SuggestedFixFromCommand(tidyCmd, protocol.QuickFix),
				SuggestedFixFromCommand(updateCmd, protocol.QuickFix),
			},
		}, nil
	case strings.Contains(goCmdError, "disabled by GOPROXY=off") && innermost != nil:
		title := fmt.Sprintf("Download %v@%v", innermost.Path, innermost.Version)
		cmd := command.NewAddDependencyCommand(title, command.DependencyArgs{
			URI:        pm.URI,
			AddRequire: false,
			GoCmdArgs:  []string{fmt.Sprintf("%v@%v", innermost.Path, innermost.Version)},
		})
		return &Diagnostic{
			URI:            pm.URI,
			Range:          loc.Range,
			Severity:       protocol.SeverityError,
			Message:        fmt.Sprintf("%v@%v has not been downloaded", innermost.Path, innermost.Version),
			Source:         ListError,
			SuggestedFixes: []SuggestedFix{SuggestedFixFromCommand(cmd, protocol.QuickFix)},
		}, nil
	default:
		return &Diagnostic{
			URI:      pm.URI,
			Range:    loc.Range,
			Severity: protocol.SeverityError,
			Source:   ListError,
			Message:  goCmdError,
		}, nil
	}
}

func findModuleReference(mf *modfile.File, ver module.Version) *modfile.Line {
	for _, req := range mf.Require {
		if req.Mod == ver {
			return req.Syntax
		}
	}
	for _, ex := range mf.Exclude {
		if ex.Mod == ver {
			return ex.Syntax
		}
	}
	for _, rep := range mf.Replace {
		if rep.New == ver || rep.Old == ver {
			return rep.Syntax
		}
	}
	return nil
}

// ResolvedVersion returns the version used for a module, which considers replace directive.
func ResolvedVersion(module *packages.Module) string {
	// don't visit replace recursively as src/cmd/go/internal/modinfo/info.go
	// visits replace field only once.
	if module.Replace != nil {
		return module.Replace.Version
	}
	return module.Version
}

// ResolvedPath returns the module path, which considers replace directive.
func ResolvedPath(module *packages.Module) string {
	if module.Replace != nil {
		return module.Replace.Path
	}
	return module.Path
}

// ResolvedString returns a representation of the Version suitable for logging
// (Path@Version, or just Path if Version is empty),
// which considers replace directive.
func ResolvedString(module *packages.Module) string {
	if ResolvedVersion(module) == "" {
		ResolvedPath(module)
	}
	return ResolvedPath(module) + "@" + ResolvedVersion(module)
}
