// Copyright 2025 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 filewatcher_test

import (
	"cmp"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"slices"
	"testing"
	"time"

	"golang.org/x/sync/errgroup"
	"golang.org/x/tools/gopls/internal/filewatcher"
	"golang.org/x/tools/gopls/internal/protocol"
	"golang.org/x/tools/gopls/internal/util/moremaps"
	"golang.org/x/tools/txtar"
)

func TestFileWatcher(t *testing.T) {
	switch runtime.GOOS {
	case "darwin", "linux", "windows":
	default:
		t.Skip("unsupported OS")
	}

	testCases := []struct {
		name string
		goos []string // if not empty, only run in these OS.
		// If set, sends watch errors for this path to an error channel
		// passed to the 'changes' func.
		watchErrorPath string
		initWorkspace  string
		changes        func(root string, errs chan error) error
		expectedEvents []protocol.FileEvent
	}{
		{
			name: "create file in darwin",
			goos: []string{"darwin"},
			initWorkspace: `
-- foo.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.WriteFile(filepath.Join(root, "bar.go"), []byte("package main"), 0644)
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "bar.go", Type: protocol.Created},
			},
		},
		{
			name: "create file in linux & windows",
			goos: []string{"linux", "windows"},
			initWorkspace: `
-- foo.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.WriteFile(filepath.Join(root, "bar.go"), []byte("package main"), 0644)
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "bar.go", Type: protocol.Created},
				{URI: "bar.go", Type: protocol.Changed},
			},
		},
		{
			name: "modify file",
			initWorkspace: `
-- foo.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.WriteFile(filepath.Join(root, "foo.go"), []byte("package main // modified"), 0644)
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "foo.go", Type: protocol.Changed},
			},
		},
		{
			name: "delete file",
			initWorkspace: `
-- foo.go --
package foo
-- bar.go --
package bar
`,
			changes: func(root string, errs chan error) error {
				return os.Remove(filepath.Join(root, "foo.go"))
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "foo.go", Type: protocol.Deleted},
			},
		},
		{
			name: "rename file in linux & windows",
			goos: []string{"linux", "windows"},
			initWorkspace: `
-- foo.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.Rename(filepath.Join(root, "foo.go"), filepath.Join(root, "bar.go"))
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "foo.go", Type: protocol.Deleted},
				{URI: "bar.go", Type: protocol.Created},
			},
		},
		{
			name: "rename file in darwin",
			goos: []string{"darwin"},
			initWorkspace: `
-- foo.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.Rename(filepath.Join(root, "foo.go"), filepath.Join(root, "bar.go"))
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "bar.go", Type: protocol.Created},
				{URI: "foo.go", Type: protocol.Deleted},
			},
		},
		{
			name: "create directory",
			initWorkspace: `
-- foo.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.Mkdir(filepath.Join(root, "bar"), 0755)
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "bar", Type: protocol.Created},
			},
		},
		{
			name: "delete directory",
			initWorkspace: `
-- foo/bar.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.RemoveAll(filepath.Join(root, "foo"))
			},
			expectedEvents: []protocol.FileEvent{
				// We only assert that the directory deletion event exists,
				// because file system event behavior is inconsistent across
				// platforms when deleting a non-empty directory.
				// e.g. windows-amd64 may only emit a single dir removal event,
				// freebsd-amd64 report dir removal before file removal,
				// linux-amd64 report the reverse order.
				// Therefore, the most reliable and cross-platform compatible
				// signal is the deletion event for the directory itself.
				{URI: "foo", Type: protocol.Deleted},
			},
		},
		{
			name: "rename directory in linux & windows",
			goos: []string{"linux", "windows"},
			initWorkspace: `
-- foo/bar.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.Rename(filepath.Join(root, "foo"), filepath.Join(root, "baz"))
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "foo", Type: protocol.Deleted},
				{URI: "baz", Type: protocol.Created},
			},
		},
		{
			name: "rename directory in darwin",
			goos: []string{"darwin"},
			initWorkspace: `
-- foo/bar.go --
package foo
`,
			changes: func(root string, errs chan error) error {
				return os.Rename(filepath.Join(root, "foo"), filepath.Join(root, "baz"))
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "baz", Type: protocol.Created},
				{URI: "foo", Type: protocol.Deleted},
			},
		},
		{
			name:           "broken symlink in darwin",
			goos:           []string{"darwin"},
			watchErrorPath: "foo",
			changes: func(root string, errs chan error) error {
				// Prepare a dir with with broken symbolic link.
				// foo                        <- 1st
				// └── from.go -> root/to.go  <- 1st
				tmp := filepath.Join(t.TempDir(), "foo")
				if err := os.Mkdir(tmp, 0755); err != nil {
					return err
				}
				from := filepath.Join(tmp, "from.go")

				to := filepath.Join(root, "to.go")
				// Create the symbolic link to a non-existing file. This would
				// cause the watch registration to fail.
				if err := os.Symlink(to, from); err != nil {
					return err
				}

				// Move the directory containing the broken symlink into place
				// to avoids a flaky test where the directory could be watched
				// before the symlink is created. See golang/go#74782.
				if err := os.Rename(tmp, filepath.Join(root, "foo")); err != nil {
					return err
				}

				// root
				// ├── foo                       <- 2nd (Move)
				// │   ├── from.go -> ../to.go   <- 2nd (Move)
				// │   └── foo.go                <- 4th (Create)
				// └── to.go                     <- 3rd (Create)

				// Should be able to capture an error from [fsnotify.Watcher.Add].
				err := <-errs
				if err == nil {
					return fmt.Errorf("did not capture watch registration failure")
				}

				// The file watcher should retry watch registration and
				// eventually succeed after the file got created.
				if err := os.WriteFile(to, []byte("package main"), 0644); err != nil {
					return err
				}

				timer := time.NewTimer(30 * time.Second)
				for {
					var (
						err error
						ok  bool
					)
					select {
					case err, ok = <-errs:
						if !ok {
							return fmt.Errorf("can not register watch for foo")
						}
					case <-timer.C:
						return fmt.Errorf("can not register watch for foo after 30 seconds")
					}

					if err == nil {
						break // watch registration success
					}
				}

				// Once the watch registration is done, file events under the
				// dir should be captured.
				return os.WriteFile(filepath.Join(root, "foo", "foo.go"), []byte("package main"), 0644)
			},
			expectedEvents: []protocol.FileEvent{
				{URI: "foo", Type: protocol.Created},
				// TODO(hxjiang): enable this after implementing retrospectively
				// generate create events.
				// {URI: "foo/from.go", Type: protocol.Created},
				{URI: "to.go", Type: protocol.Created},
				{URI: "foo/foo.go", Type: protocol.Created},
			},
		},
	}

	for _, tt := range testCases {
		t.Run(tt.name, func(t *testing.T) {
			if len(tt.goos) > 0 && !slices.Contains(tt.goos, runtime.GOOS) {
				t.Skipf("skipping on %s", runtime.GOOS)
			}

			root := t.TempDir()

			var errs chan error
			if tt.watchErrorPath != "" {
				errs = make(chan error, 10)
				filewatcher.SetAfterAddHook(func(path string, err error) {
					if path == filepath.Join(root, tt.watchErrorPath) {
						errs <- err
						if err == nil {
							close(errs)
						}
					}
				})
				defer filewatcher.SetAfterAddHook(nil)
			}

			archive := txtar.Parse([]byte(tt.initWorkspace))
			for _, f := range archive.Files {
				path := filepath.Join(root, f.Name)
				if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
					t.Fatal(err)
				}
				if err := os.WriteFile(path, f.Data, 0644); err != nil {
					t.Fatal(err)
				}
			}

			matched := 0
			foundAll := make(chan struct{})
			var gots []protocol.FileEvent
			handler := func(events []protocol.FileEvent, err error) {
				if err != nil {
					t.Errorf("error from watcher: %v", err)
				}
				gots = append(gots, events...)
				// This verifies that the list of wanted events is a subsequence of
				// the received events. It confirms not only that all wanted events
				// are present, but also that their relative order is preserved.
				for _, got := range events {
					if matched == len(tt.expectedEvents) {
						break
					}
					want := protocol.FileEvent{
						URI:  protocol.URIFromPath(filepath.Join(root, string(tt.expectedEvents[matched].URI))),
						Type: tt.expectedEvents[matched].Type,
					}
					if want == got {
						matched++
					}
				}
				if matched == len(tt.expectedEvents) {
					close(foundAll)
				}
			}
			w, err := filewatcher.New(50*time.Millisecond, nil, handler)
			if err != nil {
				t.Fatal(err)
			}

			if err := w.WatchDir(root); err != nil {
				t.Fatal(err)
			}

			if tt.changes != nil {
				if err := tt.changes(root, errs); err != nil {
					t.Fatal(err)
				}
			}

			select {
			case <-foundAll:
			case <-time.After(30 * time.Second):
				if matched < len(tt.expectedEvents) {
					t.Errorf("found %v matching events\nall want: %#v\nall got: %#v", matched, tt.expectedEvents, gots)
				}
			}

			if err := w.Close(); err != nil {
				t.Errorf("failed to close the file watcher: %v", err)
			}
		})
	}
}

func TestStress(t *testing.T) {
	switch runtime.GOOS {
	case "darwin", "linux", "windows":
	default:
		t.Skip("unsupported OS")
	}

	const (
		delay       = 50 * time.Millisecond
		parallelism = 100 // number of parallel instances of each kind of operation
	)

	root := t.TempDir()

	mkdir := func(base string) func() error {
		return func() error {
			return os.Mkdir(filepath.Join(root, base), 0755)
		}
	}
	write := func(base string) func() error {
		return func() error {
			return os.WriteFile(filepath.Join(root, base), []byte("package main"), 0644)
		}
	}
	remove := func(base string) func() error {
		return func() error {
			return os.Remove(filepath.Join(root, base))
		}
	}
	rename := func(old, new string) func() error {
		return func() error {
			return os.Rename(filepath.Join(root, old), filepath.Join(root, new))
		}
	}

	wants := make(map[protocol.FileEvent]bool)
	want := func(base string, t protocol.FileChangeType) {
		wants[protocol.FileEvent{URI: protocol.URIFromPath(filepath.Join(root, base)), Type: t}] = true
	}

	for i := range parallelism {
		// Create files and dirs that will be deleted or renamed later.
		if err := cmp.Or(
			mkdir(fmt.Sprintf("delete-dir-%d", i))(),
			mkdir(fmt.Sprintf("old-dir-%d", i))(),
			write(fmt.Sprintf("delete-file-%d.go", i))(),
			write(fmt.Sprintf("old-file-%d.go", i))(),
		); err != nil {
			t.Fatal(err)
		}

		// Add expected notification events to the "wants" set.
		want(fmt.Sprintf("file-%d.go", i), protocol.Created)
		want(fmt.Sprintf("delete-file-%d.go", i), protocol.Deleted)
		want(fmt.Sprintf("old-file-%d.go", i), protocol.Deleted)
		want(fmt.Sprintf("new-file-%d.go", i), protocol.Created)
		want(fmt.Sprintf("dir-%d", i), protocol.Created)
		want(fmt.Sprintf("delete-dir-%d", i), protocol.Deleted)
		want(fmt.Sprintf("old-dir-%d", i), protocol.Deleted)
		want(fmt.Sprintf("new-dir-%d", i), protocol.Created)
	}

	foundAll := make(chan struct{})
	w, err := filewatcher.New(delay, nil, func(events []protocol.FileEvent, err error) {
		if err != nil {
			t.Errorf("error from watcher: %v", err)
			return
		}
		for _, e := range events {
			delete(wants, e)
		}
		if len(wants) == 0 {
			close(foundAll)
		}
	})
	if err != nil {
		t.Fatal(err)
	}

	if err := w.WatchDir(root); err != nil {
		t.Fatal(err)
	}

	// Spin up multiple goroutines, to perform 6 file system operations i.e.
	// create, delete, rename of file or directory. For deletion and rename,
	// the goroutine deletes / renames files or directories created before the
	// watcher starts.
	var g errgroup.Group
	for id := range parallelism {
		ops := []func() error{
			write(fmt.Sprintf("file-%d.go", id)),
			remove(fmt.Sprintf("delete-file-%d.go", id)),
			rename(fmt.Sprintf("old-file-%d.go", id), fmt.Sprintf("new-file-%d.go", id)),
			mkdir(fmt.Sprintf("dir-%d", id)),
			remove(fmt.Sprintf("delete-dir-%d", id)),
			rename(fmt.Sprintf("old-dir-%d", id), fmt.Sprintf("new-dir-%d", id)),
		}
		for _, f := range ops {
			g.Go(f)
		}
	}
	if err := g.Wait(); err != nil {
		t.Fatal(err)
	}

	select {
	case <-foundAll:
	case <-time.After(30 * time.Second):
		if len(wants) > 0 {
			t.Errorf("missing expected events: %#v", moremaps.KeySlice(wants))
		}
	}

	if err := w.Close(); err != nil {
		t.Errorf("failed to close the file watcher: %v", err)
	}
}
