// Copyright 2022 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 checker_test

import (
	"bytes"
	"flag"
	"fmt"
	"go/ast"
	"go/token"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"slices"
	"strings"
	"testing"

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/analysis/checker"
	"golang.org/x/tools/go/analysis/multichecker"
	"golang.org/x/tools/go/analysis/passes/inspect"
	"golang.org/x/tools/go/ast/inspector"
	"golang.org/x/tools/go/packages"
	"golang.org/x/tools/internal/diff"
	"golang.org/x/tools/internal/expect"
	"golang.org/x/tools/internal/testenv"
	"golang.org/x/tools/internal/testfiles"
	"golang.org/x/tools/txtar"
)

func TestMain(m *testing.M) {
	// If the CHECKER_TEST_CHILD environment variable is set,
	// this process should behave like a multichecker.
	// Analyzers are selected by flags.
	if _, ok := os.LookupEnv("CHECKER_TEST_CHILD"); ok {
		multichecker.Main(
			markerAnalyzer,
			noendAnalyzer,
			renameAnalyzer,
			relatedAnalyzer,
		)
		panic("unreachable")
	}

	// ordinary test
	flag.Parse()
	os.Exit(m.Run())
}

const (
	exitCodeSuccess     = 0 // success (no diagnostics, or successful -fix)
	exitCodeFailed      = 1 // analysis failed to run
	exitCodeDiagnostics = 3 // diagnostics were reported (and no -fix)
)

// TestReportInvalidDiagnostic tests that a call to pass.Report with
// certain kind of invalid diagnostic (e.g. conflicting fixes)
// promptly results in a panic.
func TestReportInvalidDiagnostic(t *testing.T) {
	testenv.NeedsGoPackages(t)

	// Load the errors package.
	cfg := &packages.Config{Mode: packages.LoadAllSyntax}
	initial, err := packages.Load(cfg, "errors")
	if err != nil {
		t.Fatal(err)
	}

	for _, test := range []struct {
		name string
		want string
		diag func(pos token.Pos) analysis.Diagnostic
	}{
		// Diagnostic has two alternative fixes with the same Message.
		{
			"duplicate message",
			`analyzer "a" suggests two fixes with same Message \(fix\)`,
			func(pos token.Pos) analysis.Diagnostic {
				return analysis.Diagnostic{
					Pos:     pos,
					Message: "oops",
					SuggestedFixes: []analysis.SuggestedFix{
						{Message: "fix"},
						{Message: "fix"},
					},
				}
			},
		},
		// TextEdit has invalid Pos.
		{
			"bad Pos",
			`analyzer "a" suggests invalid fix .*: no token.File for TextEdit.Pos .0.`,
			func(pos token.Pos) analysis.Diagnostic {
				return analysis.Diagnostic{
					Pos:     pos,
					Message: "oops",
					SuggestedFixes: []analysis.SuggestedFix{
						{
							Message:   "fix",
							TextEdits: []analysis.TextEdit{{}},
						},
					},
				}
			},
		},
		// TextEdit has invalid End.
		{
			"End < Pos",
			`analyzer "a" suggests invalid fix .*: TextEdit.Pos .* > TextEdit.End .*`,
			func(pos token.Pos) analysis.Diagnostic {
				return analysis.Diagnostic{
					Pos:     pos,
					Message: "oops",
					SuggestedFixes: []analysis.SuggestedFix{
						{
							Message: "fix",
							TextEdits: []analysis.TextEdit{{
								Pos: pos + 2,
								End: pos,
							}},
						},
					},
				}
			},
		},
		// Two TextEdits overlap.
		{
			"overlapping edits",
			`analyzer "a" suggests invalid fix .*: overlapping edits to .*errors.go \(1:1-1:3 and 1:2-1:4\)`,
			func(pos token.Pos) analysis.Diagnostic {
				return analysis.Diagnostic{
					Pos:     pos,
					Message: "oops",
					SuggestedFixes: []analysis.SuggestedFix{
						{
							Message: "fix",
							TextEdits: []analysis.TextEdit{
								{Pos: pos, End: pos + 2},
								{Pos: pos + 1, End: pos + 3},
							},
						},
					},
				}
			},
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			reached := false
			a := &analysis.Analyzer{Name: "a", Doc: "doc", Run: func(pass *analysis.Pass) (any, error) {
				reached = true
				panics(t, test.want, func() {
					pos := pass.Files[0].FileStart
					pass.Report(test.diag(pos))
				})
				return nil, nil
			}}
			if _, err := checker.Analyze([]*analysis.Analyzer{a}, initial, &checker.Options{}); err != nil {
				t.Fatalf("Analyze failed: %v", err)
			}
			if !reached {
				t.Error("analyzer was never invoked")
			}
		})
	}
}

// TestScript runs script-driven tests in testdata/*.txt.
// Each file is a txtar archive, expanded to a temporary directory.
//
// The comment section of the archive is a script, with the following
// commands:
//
//	# comment
//		ignored
//	blank line
//		ignored
//	skip k=v...
//		Skip the test if any k=v string is a substring of the string
//		"GOOS=darwin GOARCH=arm64" appropriate to the current build.
//	checker args...
//		Run the checker command with the specified space-separated
//		arguments; this fork+execs the [TestMain] function above.
//		If the archive has a "stdout" section, its contents must
//		match the stdout output of the checker command.
//		Do NOT use this for testing -diff: tests should not
//		rely on the particulars of the diff algorithm.
//	exit int
//		Assert that previous checker command had this exit code.
//	stderr regexp
//		Assert that stderr output from previous checker run matches this pattern.
//
// The script must include at least one 'checker' command.
func TestScript(t *testing.T) {
	testenv.NeedsExec(t)
	testenv.NeedsGoPackages(t)

	txtfiles, err := filepath.Glob("testdata/*.txt")
	if err != nil {
		t.Fatal(err)
	}
	for _, txtfile := range txtfiles {
		t.Run(txtfile, func(t *testing.T) {
			t.Parallel()

			// Expand archive into tmp tree.
			ar, err := txtar.ParseFile(txtfile)
			if err != nil {
				t.Fatal(err)
			}
			fs, err := txtar.FS(ar)
			if err != nil {
				t.Fatal(err)
			}
			dir := testfiles.CopyToTmp(t, fs)

			// Parse txtar comment as a script.
			const noExitCode = -999
			var (
				// state variables operated on by script
				lastExitCode = noExitCode
				lastStderr   string
			)
			for i, line := range strings.Split(string(ar.Comment), "\n") {
				line = strings.TrimSpace(line)
				if line == "" || line[0] == '#' {
					continue // skip blanks and comments
				}

				command, rest, _ := strings.Cut(line, " ")
				prefix := fmt.Sprintf("%s:%d: %s", txtfile, i+1, command) // for error messages
				switch command {
				case "checker":
					cmd := exec.Command(os.Args[0], strings.Fields(rest)...)
					cmd.Dir = dir
					cmd.Stdout = new(strings.Builder)
					cmd.Stderr = new(strings.Builder)
					cmd.Env = append(os.Environ(), "CHECKER_TEST_CHILD=1", "GOPROXY=off")
					if err := cmd.Run(); err != nil {
						if err, ok := err.(*exec.ExitError); ok {
							lastExitCode = err.ExitCode()
							// fall through
						} else {
							t.Fatalf("%s: failed to execute checker: %v (%s)", prefix, err, cmd)
						}
					} else {
						lastExitCode = 0 // success
					}

					// Eliminate nondeterministic strings from the output.
					clean := func(x any) string {
						s := fmt.Sprint(x)
						pwd, _ := os.Getwd()
						if realDir, err := filepath.EvalSymlinks(dir); err == nil {
							// Work around checker's packages.Load failing to
							// set Config.Dir to dir, causing the filenames
							// of loaded packages not to be a subdir of dir.
							s = strings.ReplaceAll(s, realDir, dir)
						}
						s = strings.ReplaceAll(s, dir, string(os.PathSeparator)+"TMP")
						s = strings.ReplaceAll(s, pwd, string(os.PathSeparator)+"PWD")
						s = strings.ReplaceAll(s, cmd.Path, filepath.Base(cmd.Path))
						return s
					}

					lastStderr = clean(cmd.Stderr)
					stdout := clean(cmd.Stdout)

					// Detect bad markers out of band:
					// though they cause a non-zero exit,
					// that may be expected.
					if strings.Contains(lastStderr, badMarker) {
						t.Errorf("marker analyzer encountered errors; stderr=%s", lastStderr)
					}

					// debugging
					if false {
						t.Logf("%s: $ %s\nstdout:\n%s\nstderr:\n%s", prefix, clean(cmd), stdout, lastStderr)
					}

					// Keep error reporting logic below consistent with
					// applyDiffsAndCompare in ../../analysistest/analysistest.go!

					unified := func(xlabel, ylabel string, x, y []byte) string {
						x = append(slices.Clip(bytes.TrimSpace(x)), '\n')
						y = append(slices.Clip(bytes.TrimSpace(y)), '\n')
						return diff.Unified(xlabel, ylabel, string(x), string(y))
					}

					// Check stdout, if there's a section of that name.
					//
					// Do not use this for testing -diff! It exposes tests to the
					// internals of our (often suboptimal) diff algorithm.
					// Instead, use the want/ mechanism.
					if f := section(ar, "stdout"); f != nil {
						got, want := []byte(stdout), f.Data
						if diff := unified("got", "want", got, want); diff != "" {
							t.Errorf("%s: unexpected stdout: -- got --\n%s-- want --\n%s-- diff --\n%s",
								prefix,
								got, want, diff)
						}
					}

					for _, f := range ar.Files {
						// For each file named want/X, assert that the
						// current content of X now equals want/X.
						if filename, ok := strings.CutPrefix(f.Name, "want/"); ok {
							fixed, err := os.ReadFile(filepath.Join(dir, filename))
							if err != nil {
								t.Errorf("reading %s: %v", filename, err)
								continue
							}
							var original []byte
							if f := section(ar, filename); f != nil {
								original = f.Data
							}
							want := f.Data
							if diff := unified(filename+" (fixed)", filename+" (want)", fixed, want); diff != "" {
								t.Errorf("%s: unexpected %s content:\n"+
									"-- original --\n%s\n"+
									"-- fixed --\n%s\n"+
									"-- want --\n%s\n"+
									"-- diff original fixed --\n%s\n"+
									"-- diff fixed want --\n%s",
									prefix, filename,
									original,
									fixed,
									want,
									unified(filename+" (original)", filename+" (fixed)", original, fixed),
									diff)
							}
						}
					}

				case "skip":
					config := fmt.Sprintf("GOOS=%s GOARCH=%s", runtime.GOOS, runtime.GOARCH)
					for word := range strings.FieldsSeq(rest) {
						if strings.Contains(config, word) {
							t.Skip(word)
						}
					}

				case "exit":
					if lastExitCode == noExitCode {
						t.Fatalf("%s: no prior 'checker' command", prefix)
					}
					var want int
					if _, err := fmt.Sscanf(rest, "%d", &want); err != nil {
						t.Fatalf("%s: requires one numeric operand", prefix)
					}
					if want != lastExitCode {
						// plan9 ExitCode() currently only returns 0 for success or 1 for failure
						if !(runtime.GOOS == "plan9" && want != exitCodeSuccess && lastExitCode != exitCodeSuccess) {
							t.Errorf("%s: exit code was %d, want %d", prefix, lastExitCode, want)
						}
					}

				case "stderr":
					if lastExitCode == noExitCode {
						t.Fatalf("%s: no prior 'checker' command", prefix)
					}
					if matched, err := regexp.MatchString(rest, lastStderr); err != nil {
						t.Fatalf("%s: invalid regexp: %v", prefix, err)
					} else if !matched {
						t.Errorf("%s: output didn't match pattern %q:\n%s", prefix, rest, lastStderr)
					}

				default:
					t.Errorf("%s: unknown command", prefix)
				}
			}
			if lastExitCode == noExitCode {
				t.Errorf("test script contains no 'checker' command")
			}
		})
	}
}

const badMarker = "[bad marker]"

// The marker analyzer generates fixes from @marker annotations in the
// source. Each marker is of the form:
//
//	@message("pattern", "replacement)
//
// The "message" is used for both the Diagnostic.Message and
// SuggestedFix.Message field. Multiple markers with the same
// message form a single diagnostic and fix with a list of textedits.
//
// The "pattern" is a regular expression that must match on the
// current line (though it may extend beyond if the pattern starts
// with "(?s)"), and whose extent forms the TextEdit.{Pos,End}
// deletion. If the pattern contains one subgroup, its range will be
// used; this allows contextual matching.
//
// The "replacement" is a literal string that forms the
// TextEdit.NewText.
//
// Fixes are applied in the order they are first mentioned in the
// source.
var markerAnalyzer = &analysis.Analyzer{
	Name:     "marker",
	Doc:      "doc",
	Requires: []*analysis.Analyzer{inspect.Analyzer},
	Run: func(pass *analysis.Pass) (_ any, err error) {
		// Errors returned by this analyzer cause the
		// checker command to exit non-zero, but that
		// may be the expected outcome for other reasons
		// (e.g. there were diagnostics).
		//
		// So, we report these errors out of band by logging
		// them with a special badMarker string that the
		// TestScript harness looks for, to ensure that the
		// test fails in that case.
		defer func() {
			if err != nil {
				log.Printf("%s: %v", badMarker, err)
			}
		}()

		// Parse all notes in the files.
		var keys []string
		edits := make(map[string][]analysis.TextEdit)
		for _, file := range pass.Files {
			tokFile := pass.Fset.File(file.FileStart)
			content, err := pass.ReadFile(tokFile.Name())
			if err != nil {
				return nil, err
			}
			notes, err := expect.ExtractGo(tokFile, file)
			if err != nil {
				return nil, err
			}
			for _, note := range notes {
				edit, err := markerEdit(tokFile, content, note)
				if err != nil {
					return nil, fmt.Errorf("%s: %v", tokFile.Position(note.Pos), err)
				}
				// Preserve note order as it determines fix order.
				if edits[note.Name] == nil {
					keys = append(keys, note.Name)
				}
				edits[note.Name] = append(edits[note.Name], edit)
			}
		}

		// Report each fix in its own Diagnostic.
		for _, key := range keys {
			edits := edits[key]
			// debugging
			if false {
				log.Printf("%s: marker: @%s: %+v", pass.Fset.Position(edits[0].Pos), key, edits)
			}
			pass.Report(analysis.Diagnostic{
				Pos:     edits[0].Pos,
				End:     edits[0].Pos,
				Message: key,
				SuggestedFixes: []analysis.SuggestedFix{{
					Message:   key,
					TextEdits: edits,
				}},
			})
		}
		return nil, nil
	},
}

// markerEdit returns the TextEdit denoted by note.
func markerEdit(tokFile *token.File, content []byte, note *expect.Note) (analysis.TextEdit, error) {
	if len(note.Args) != 2 {
		return analysis.TextEdit{}, fmt.Errorf("got %d args, want @%s(pattern, replacement)", len(note.Args), note.Name)
	}

	pattern, ok := note.Args[0].(string)
	if !ok {
		return analysis.TextEdit{}, fmt.Errorf("got %T for pattern, want string", note.Args[0])
	}
	rx, err := regexp.Compile(pattern)
	if err != nil {
		return analysis.TextEdit{}, fmt.Errorf("invalid pattern regexp: %v", err)
	}

	// Match the pattern against the current line.
	lineStart := tokFile.LineStart(tokFile.Position(note.Pos).Line)
	lineStartOff := tokFile.Offset(lineStart)
	lineEndOff := tokFile.Offset(note.Pos)
	matches := rx.FindSubmatchIndex(content[lineStartOff:])
	if len(matches) == 0 {
		return analysis.TextEdit{}, fmt.Errorf("no match for regexp %q", rx)
	}
	var start, end int // line-relative offset
	switch len(matches) {
	case 2:
		// no subgroups: return the range of the regexp expression
		start, end = matches[0], matches[1]
	case 4:
		// one subgroup: return its range
		start, end = matches[2], matches[3]
	default:
		return analysis.TextEdit{}, fmt.Errorf("invalid location regexp %q: expect either 0 or 1 subgroups, got %d", rx, len(matches)/2-1)
	}
	if start > lineEndOff-lineStartOff {
		// The start of the match must be between the start of the line and the
		// marker position (inclusive).
		return analysis.TextEdit{}, fmt.Errorf("no matching range found starting on the current line")
	}

	replacement, ok := note.Args[1].(string)
	if !ok {
		return analysis.TextEdit{}, fmt.Errorf("second argument must be pattern, got %T", note.Args[1])
	}

	// debugging: show matched portion
	if false {
		log.Printf("%s: %s: r%q (%q) -> %q",
			tokFile.Position(note.Pos),
			note.Name,
			pattern,
			content[lineStartOff+start:lineStartOff+end],
			replacement)
	}

	return analysis.TextEdit{
		Pos:     lineStart + token.Pos(start),
		End:     lineStart + token.Pos(end),
		NewText: []byte(replacement),
	}, nil
}

var renameAnalyzer = &analysis.Analyzer{
	Name:             "rename",
	Requires:         []*analysis.Analyzer{inspect.Analyzer},
	Doc:              "renames symbols named bar to baz",
	RunDespiteErrors: true,
	Run: func(pass *analysis.Pass) (any, error) {
		const (
			from = "bar"
			to   = "baz"
		)
		inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
		nodeFilter := []ast.Node{(*ast.Ident)(nil)}
		inspect.Preorder(nodeFilter, func(n ast.Node) {
			ident := n.(*ast.Ident)
			if ident.Name == from {
				msg := fmt.Sprintf("renaming %q to %q", from, to)
				pass.Report(analysis.Diagnostic{
					Pos:     ident.Pos(),
					End:     ident.End(),
					Message: msg,
					SuggestedFixes: []analysis.SuggestedFix{{
						Message: msg,
						TextEdits: []analysis.TextEdit{{
							Pos:     ident.Pos(),
							End:     ident.End(),
							NewText: []byte(to),
						}},
					}},
				})
			}
		})
		return nil, nil
	},
}

var noendAnalyzer = &analysis.Analyzer{
	Name: "noend",
	Doc:  "inserts /*hello*/ before first decl",
	Run: func(pass *analysis.Pass) (any, error) {
		decl := pass.Files[0].Decls[0]
		pass.Report(analysis.Diagnostic{
			Pos:     decl.Pos(),
			End:     token.NoPos,
			Message: "say hello",
			SuggestedFixes: []analysis.SuggestedFix{{
				Message: "say hello",
				TextEdits: []analysis.TextEdit{{
					Pos:     decl.Pos(),
					End:     token.NoPos,
					NewText: []byte("/*hello*/"),
				}},
			}},
		})
		return nil, nil
	},
}

var relatedAnalyzer = &analysis.Analyzer{
	Name: "related",
	Doc:  "reports a Diagnostic with RelatedInformaiton",
	Run: func(pass *analysis.Pass) (any, error) {
		decl := pass.Files[0].Decls[0]
		pass.Report(analysis.Diagnostic{
			Pos:     decl.Pos(),
			End:     decl.Pos() + 1,
			Message: "decl starts here",
			Related: []analysis.RelatedInformation{{
				Message: "decl ends here",
				Pos:     decl.End() - 1,
				End:     decl.End(),
			}},
		})
		return nil, nil
	},
}

// panics asserts that f() panics with a value whose printed form matches the regexp want.
func panics(t *testing.T, want string, f func()) {
	defer func() {
		if x := recover(); x == nil {
			t.Errorf("function returned normally, wanted panic")
		} else if m, err := regexp.MatchString(want, fmt.Sprint(x)); err != nil {
			t.Errorf("panics: invalid regexp %q", want)
		} else if !m {
			t.Errorf("function panicked with value %q, want match for %q", x, want)
		}
	}()
	f()
}

// section returns the named archive section, or nil.
func section(ar *txtar.Archive, name string) *txtar.File {
	for i, f := range ar.Files {
		if f.Name == name {
			return &ar.Files[i]
		}
	}
	return nil
}
