// Copyright 2018 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 gopathwalk

import (
	"os"
	"path/filepath"
	"reflect"
	"runtime"
	"sort"
	"strings"
	"sync"
	"testing"
)

func TestSymlinkTraversal(t *testing.T) {
	t.Parallel()

	gopath := t.TempDir()

	if err := mapToDir(gopath, map[string]string{
		"a/b/c":          "LINK:../../a/d",
		"a/b/pkg/pkg.go": "package pkg",
		"a/d/e":          "LINK:../../a/b",
		"a/d/pkg/pkg.go": "package pkg",
		"a/f/loop":       "LINK:../f",
		"a/f/pkg/pkg.go": "package pkg",
		"a/g/pkg/pkg.go": "LINK:../../f/pkg/pkg.go",
		"a/self":         "LINK:.",
	}); err != nil {
		switch runtime.GOOS {
		case "windows", "plan9":
			t.Skipf("skipping symlink-requiring test on %s", runtime.GOOS)
		}
		t.Fatal(err)
	}

	pkgs := []string{}
	add := func(root Root, dir string) {
		rel, err := filepath.Rel(filepath.Join(root.Path, "src"), dir)
		if err != nil {
			t.Error(err)
		}
		pkgs = append(pkgs, filepath.ToSlash(rel))
	}

	Walk([]Root{{Path: gopath, Type: RootGOPATH}}, add, Options{Logf: t.Logf})

	sort.Strings(pkgs)
	t.Logf("Found packages:\n\t%s", strings.Join(pkgs, "\n\t"))

	got := make(map[string]bool, len(pkgs))
	for _, pkg := range pkgs {
		got[pkg] = true
	}
	tests := []struct {
		path string
		want bool
		why  string
	}{
		{
			path: "a/b/pkg",
			want: true,
			why:  "found via regular directories",
		},
		{
			path: "a/b/c/pkg",
			want: true,
			why:  "found via non-cyclic dir link",
		},
		{
			path: "a/b/c/e/pkg",
			want: true,
			why:  "found via two non-cyclic dir links",
		},
		{
			path: "a/d/e/c/pkg",
			want: true,
			why:  "found via two non-cyclic dir links",
		},
		{
			path: "a/f/loop/pkg",
			want: true,
			why:  "found via a single parent-dir link",
		},
		{
			path: "a/f/loop/loop/pkg",
			want: false,
			why:  "would follow loop symlink twice",
		},
		{
			path: "a/self/b/pkg",
			want: true,
			why:  "follows self-link once",
		},
		{
			path: "a/self/self/b/pkg",
			want: false,
			why:  "would follow self-link twice",
		},
	}
	for _, tc := range tests {
		if got[tc.path] != tc.want {
			if tc.want {
				t.Errorf("MISSING: %s (%s)", tc.path, tc.why)
			} else {
				t.Errorf("UNEXPECTED: %s (%s)", tc.path, tc.why)
			}
		}
	}
}

// TestSkip tests that various goimports rules are followed in non-modules mode.
func TestSkip(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()

	if err := mapToDir(dir, map[string]string{
		"ignoreme/f.go":     "package ignoreme",     // ignored by .goimportsignore
		"node_modules/f.go": "package nodemodules;", // ignored by hardcoded node_modules filter
		"v/f.go":            "package v;",           // ignored by hardcoded vgo cache rule
		"mod/f.go":          "package mod;",         // ignored by hardcoded vgo cache rule
		"shouldfind/f.go":   "package shouldfind;",  // not ignored

		".goimportsignore": "ignoreme\n",
	}); err != nil {
		t.Fatal(err)
	}

	var found []string
	var mu sync.Mutex
	walkDir(Root{filepath.Join(dir, "src"), RootGOPATH},
		func(root Root, dir string) {
			mu.Lock()
			defer mu.Unlock()
			found = append(found, dir[len(root.Path)+1:])
		}, func(root Root, dir string) bool {
			return false
		}, Options{
			ModulesEnabled: false,
			Logf:           t.Logf,
		})
	if want := []string{"shouldfind"}; !reflect.DeepEqual(found, want) {
		t.Errorf("expected to find only %v, got %v", want, found)
	}
}

// TestSkipFunction tests that scan successfully skips directories from user callback.
func TestSkipFunction(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()

	if err := mapToDir(dir, map[string]string{
		"ignoreme/f.go":           "package ignoreme",    // ignored by skip
		"ignoreme/subignore/f.go": "package subignore",   // also ignored by skip
		"shouldfind/f.go":         "package shouldfind;", // not ignored
	}); err != nil {
		t.Fatal(err)
	}

	var found []string
	var mu sync.Mutex
	walkDir(Root{filepath.Join(dir, "src"), RootGOPATH},
		func(root Root, dir string) {
			mu.Lock()
			defer mu.Unlock()
			found = append(found, dir[len(root.Path)+1:])
		}, func(root Root, dir string) bool {
			return strings.HasSuffix(dir, "ignoreme")
		},
		Options{
			ModulesEnabled: false,
			Logf:           t.Logf,
		})
	if want := []string{"shouldfind"}; !reflect.DeepEqual(found, want) {
		t.Errorf("expected to find only %v, got %v", want, found)
	}
}

// TestWalkSymlinkConcurrentDeletion is a regression test for the panic reported
// in https://go.dev/issue/58054#issuecomment-1791513726.
func TestWalkSymlinkConcurrentDeletion(t *testing.T) {
	t.Parallel()

	src := t.TempDir()

	m := map[string]string{
		"dir/readme.txt": "dir is not a go package",
		"dirlink":        "LINK:dir",
	}
	if err := mapToDir(src, m); err != nil {
		switch runtime.GOOS {
		case "windows", "plan9":
			t.Skipf("skipping symlink-requiring test on %s", runtime.GOOS)
		}
		t.Fatal(err)
	}

	done := make(chan struct{})
	go func() {
		if err := os.RemoveAll(src); err != nil {
			t.Log(err)
		}
		close(done)
	}()
	defer func() {
		<-done
	}()

	add := func(root Root, dir string) {
		t.Errorf("unexpected call to add(%q, %q)", root.Path, dir)
	}
	Walk([]Root{{Path: src, Type: RootGOPATH}}, add, Options{Logf: t.Logf})
}

func mapToDir(destDir string, files map[string]string) error {
	var symlinkPaths []string
	for path, contents := range files {
		file := filepath.Join(destDir, "src", path)
		if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil {
			return err
		}
		var err error
		if strings.HasPrefix(contents, "LINK:") {
			// To work around https://go.dev/issue/39183, wait to create symlinks
			// until we have created all non-symlink paths.
			symlinkPaths = append(symlinkPaths, path)
		} else {
			err = os.WriteFile(file, []byte(contents), 0644)
		}
		if err != nil {
			return err
		}
	}

	for _, path := range symlinkPaths {
		file := filepath.Join(destDir, "src", path)
		target := filepath.FromSlash(strings.TrimPrefix(files[path], "LINK:"))
		err := os.Symlink(target, file)
		if err != nil {
			return err
		}
	}

	return nil
}
