package apidiff

import (
	"bufio"
	"fmt"
	"go/types"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
	"golang.org/x/tools/go/packages"
	"golang.org/x/tools/go/packages/packagestest"
)

// The version to write into the go.mod file used to load the packages being tested.
const testGoVersion = "1.22"

func TestModuleChanges(t *testing.T) {
	packagestest.TestAll(t, testModuleChanges)
}

func testModuleChanges(t *testing.T, x packagestest.Exporter) {
	e := packagestest.Export(t, x, []packagestest.Module{
		{
			Name: "example.com/moda",
			Files: map[string]any{
				"foo/foo.go":     "package foo\n\nconst Version = 1",
				"foo/baz/baz.go": "package baz",
			},
		},
		{
			Name: "example.com/modb",
			Files: map[string]any{
				"foo/foo.go": "package foo\n\nconst Version = 2\nconst Other = 1",
				"bar/bar.go": "package bar",
			},
		},
	})
	defer e.Cleanup()

	a, err := loadModule(t, e.Config, "example.com/moda")
	if err != nil {
		t.Fatal(err)
	}
	b, err := loadModule(t, e.Config, "example.com/modb")
	if err != nil {
		t.Fatal(err)
	}
	report := ModuleChanges(a, b)
	if len(report.Changes) == 0 {
		t.Fatal("expected some changes, but got none")
	}
	wanti := []string{
		"./foo.Version: value changed from 1 to 2",
		"package example.com/moda/foo/baz: removed",
	}
	sort.Strings(wanti)

	got := report.messages(false)
	sort.Strings(got)

	if diff := cmp.Diff(wanti, got); diff != "" {
		t.Errorf("incompatibles: mismatch (-want, +got)\n%s", diff)
	}

	wantc := []string{
		"./foo.Other: added",
		"package example.com/modb/bar: added",
	}
	sort.Strings(wantc)

	got = report.messages(true)
	sort.Strings(got)

	if diff := cmp.Diff(wantc, got); diff != "" {
		t.Errorf("compatibles: mismatch (-want, +got)\n%s", diff)
	}
}

func TestChanges(t *testing.T) {
	testfiles, err := filepath.Glob(filepath.Join("testdata", "*.go"))
	if err != nil {
		t.Fatal(err)
	}
	for _, testfile := range testfiles {
		name := strings.TrimSuffix(filepath.Base(testfile), ".go")
		t.Run(name, func(t *testing.T) {
			dir := filepath.Join(t.TempDir(), "go")
			wanti, wantc := splitIntoPackages(t, testfile, dir)
			sort.Strings(wanti)
			sort.Strings(wantc)

			oldpkg, err := loadPackage(t, "apidiff/old", dir)
			if err != nil {
				t.Fatal(err)
			}
			newpkg, err := loadPackage(t, "apidiff/new", dir)
			if err != nil {
				t.Fatal(err)
			}

			report := Changes(oldpkg.Types, newpkg.Types)

			got := report.messages(false)
			if diff := cmp.Diff(wanti, got); diff != "" {
				t.Errorf("incompatibles: mismatch (-want, +got)\n%s", diff)
			}
			got = report.messages(true)
			if diff := cmp.Diff(wantc, got); diff != "" {
				t.Errorf("compatibles: mismatch (-want, +got)\n%s", diff)
			}
		})
	}
}

func splitIntoPackages(t *testing.T, file, dir string) (incompatibles, compatibles []string) {
	// Read the input file line by line.
	// Write a line into the old or new package,
	// dependent on comments.
	// Also collect expected messages.
	f, err := os.Open(file)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()

	if err := os.MkdirAll(filepath.Join(dir, "src", "apidiff"), 0700); err != nil {
		t.Fatal(err)
	}
	goMod := fmt.Sprintf("module apidiff\ngo %s\n", testGoVersion)
	if err := os.WriteFile(filepath.Join(dir, "src", "apidiff", "go.mod"), []byte(goMod), 0600); err != nil {
		t.Fatal(err)
	}

	oldd := filepath.Join(dir, "src/apidiff/old")
	newd := filepath.Join(dir, "src/apidiff/new")
	if err := os.MkdirAll(oldd, 0700); err != nil {
		t.Fatal(err)
	}
	if err := os.Mkdir(newd, 0700); err != nil && !os.IsExist(err) {
		t.Fatal(err)
	}

	oldf, err := os.Create(filepath.Join(oldd, "old.go"))
	if err != nil {
		t.Fatal(err)
	}
	defer func() {
		if err := oldf.Close(); err != nil {
			t.Fatal(err)
		}
	}()

	newf, err := os.Create(filepath.Join(newd, "new.go"))
	if err != nil {
		t.Fatal(err)
	}
	defer func() {
		if err := newf.Close(); err != nil {
			t.Fatal(err)
		}
	}()

	wl := func(f *os.File, line string) {
		if _, err := fmt.Fprintln(f, line); err != nil {
			t.Fatal(err)
		}
	}
	writeBoth := func(line string) { wl(oldf, line); wl(newf, line) }
	writeln := writeBoth
	s := bufio.NewScanner(f)
	for s.Scan() {
		line := s.Text()
		tl := strings.TrimSpace(line)
		switch {
		case tl == "// old":
			writeln = func(line string) { wl(oldf, line) }
		case tl == "// new":
			writeln = func(line string) { wl(newf, line) }
		case tl == "// both":
			writeln = writeBoth
		case strings.HasPrefix(tl, "// i "):
			incompatibles = append(incompatibles, strings.TrimSpace(tl[4:]))
		case strings.HasPrefix(tl, "// c "):
			compatibles = append(compatibles, strings.TrimSpace(tl[4:]))
		default:
			writeln(line)
		}
	}
	if s.Err() != nil {
		t.Fatal(s.Err())
	}
	return
}

// Copied from cmd/apidiff/main.go.
func loadModule(t *testing.T, cfg *packages.Config, modulePath string) (*Module, error) {
	needsGoPackages(t)

	cfg.Mode = cfg.Mode | packages.LoadTypes
	loaded, err := packages.Load(cfg, fmt.Sprintf("%s/...", modulePath))
	if err != nil {
		return nil, err
	}
	if len(loaded) == 0 {
		return nil, fmt.Errorf("found no packages for module %s", modulePath)
	}
	var tpkgs []*types.Package
	for _, p := range loaded {
		if len(p.Errors) > 0 {
			// TODO: use errors.Join once Go 1.21 is released.
			return nil, p.Errors[0]
		}
		tpkgs = append(tpkgs, p.Types)
	}

	return &Module{Path: modulePath, Packages: tpkgs}, nil
}

func loadPackage(t *testing.T, importPath, goPath string) (*packages.Package, error) {
	needsGoPackages(t)

	cfg := &packages.Config{
		Mode: packages.LoadTypes,
	}
	if goPath != "" {
		cfg.Env = append(os.Environ(), "GOPATH="+goPath)
		cfg.Dir = filepath.Join(goPath, "src", filepath.FromSlash(importPath))
	}
	pkgs, err := packages.Load(cfg, importPath)
	if err != nil {
		return nil, err
	}
	if len(pkgs[0].Errors) > 0 {
		return nil, pkgs[0].Errors[0]
	}
	return pkgs[0], nil
}

func TestExportedFields(t *testing.T) {
	pkg, err := loadPackage(t, "golang.org/x/exp/apidiff/testdata/exported_fields", "")
	if err != nil {
		t.Fatal(err)
	}
	typeof := func(name string) types.Type {
		return pkg.Types.Scope().Lookup(name).Type()
	}

	s := typeof("S")
	su := s.(*types.Named).Underlying().(*types.Struct)

	ef := exportedSelectableFields(su)
	wants := []struct {
		name string
		typ  types.Type
	}{
		{"A1", typeof("A1")},
		{"D", types.Typ[types.Bool]},
		{"E", types.Typ[types.Int]},
		{"F", typeof("F")},
		{"S", types.NewPointer(s)},
	}

	if got, want := len(ef), len(wants); got != want {
		t.Errorf("got %d fields, want %d\n%+v", got, want, ef)
	}
	for _, w := range wants {
		if got := ef[w.name]; got != nil && !types.Identical(got.Type(), w.typ) {
			t.Errorf("%s: got %v, want %v", w.name, got.Type(), w.typ)
		}
	}
}

// needsGoPackages skips t if the go/packages driver (or 'go' tool) implied by
// the current process environment is not present in the path.
//
// Copied and adapted from golang.org/x/tools/internal/testenv.
func needsGoPackages(t *testing.T) {
	t.Helper()

	tool := os.Getenv("GOPACKAGESDRIVER")
	switch tool {
	case "off":
		// "off" forces go/packages to use the go command.
		tool = "go"
	case "":
		if _, err := exec.LookPath("gopackagesdriver"); err == nil {
			tool = "gopackagesdriver"
		} else {
			tool = "go"
		}
	}

	needsTool(t, tool)
}

// needsTool skips t if the named tool is not present in the path.
//
// Copied and adapted from golang.org/x/tools/internal/testenv.
func needsTool(t *testing.T, tool string) {
	_, err := exec.LookPath(tool)
	if err == nil {
		return
	}

	t.Helper()
	if allowMissingTool(tool) {
		t.Skipf("skipping because %s tool not available: %v", tool, err)
	} else {
		t.Fatalf("%s tool not available: %v", tool, err)
	}
}

func allowMissingTool(tool string) bool {
	if runtime.GOOS == "android" {
		// Android builds generally run tests on a separate machine from the build,
		// so don't expect any external tools to be available.
		return true
	}

	if tool == "go" && os.Getenv("GO_BUILDER_NAME") == "illumos-amd64-joyent" {
		// Work around a misconfigured builder (see https://golang.org/issue/33950).
		return true
	}

	// If a developer is actively working on this test, we expect them to have all
	// of its dependencies installed. However, if it's just a dependency of some
	// other module (for example, being run via 'go test all'), we should be more
	// tolerant of unusual environments.
	return !packageMainIsDevel()
}

// packageMainIsDevel reports whether the module containing package main
// is a development version (if module information is available).
//
// Builds in GOPATH mode and builds that lack module information are assumed to
// be development versions.
var packageMainIsDevel = func() bool { return true }
