package filenotify

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

	qt "github.com/frankban/quicktest"
	"github.com/fsnotify/fsnotify"
	"github.com/gohugoio/hugo/htesting"
)

const (
	subdir1       = "subdir1"
	subdir2       = "subdir2"
	watchWaitTime = 200 * time.Millisecond
)

var (
	isMacOs = runtime.GOOS == "darwin"
	isCI    = htesting.IsCI()
)

func TestPollerAddRemove(t *testing.T) {
	c := qt.New(t)
	w := NewPollingWatcher(watchWaitTime)

	c.Assert(w.Add("foo"), qt.Not(qt.IsNil))
	c.Assert(w.Remove("foo"), qt.Not(qt.IsNil))

	f, err := os.CreateTemp("", "asdf")
	if err != nil {
		t.Fatal(err)
	}
	c.Cleanup(func() {
		c.Assert(w.Close(), qt.IsNil)
		os.Remove(f.Name())
	})
	c.Assert(w.Add(f.Name()), qt.IsNil)
	c.Assert(w.Remove(f.Name()), qt.IsNil)
}

func TestPollerEvent(t *testing.T) {
	t.Skip("flaky test") // TODO(bep)
	c := qt.New(t)

	for _, poll := range []bool{true, false} {
		if !(poll || isMacOs) || isCI {
			// Only run the fsnotify tests on MacOS locally.
			continue
		}
		method := "fsnotify"
		if poll {
			method = "poll"
		}

		c.Run(fmt.Sprintf("%s, Watch dir", method), func(c *qt.C) {
			dir, w := preparePollTest(c, poll)
			subdir := filepath.Join(dir, subdir1)
			c.Assert(w.Add(subdir), qt.IsNil)

			filename := filepath.Join(subdir, "file1")

			// Write to one file.
			c.Assert(os.WriteFile(filename, []byte("changed"), 0o600), qt.IsNil)

			var expected []fsnotify.Event

			if poll {
				expected = append(expected, fsnotify.Event{Name: filename, Op: fsnotify.Write})
				assertEvents(c, w, expected...)
			} else {
				// fsnotify sometimes emits Chmod before Write,
				// which is hard to test, so skip it here.
				drainEvents(c, w)
			}

			// Remove one file.
			filename = filepath.Join(subdir, "file2")
			c.Assert(os.Remove(filename), qt.IsNil)
			assertEvents(c, w, fsnotify.Event{Name: filename, Op: fsnotify.Remove})

			// Add one file.
			filename = filepath.Join(subdir, "file3")
			c.Assert(os.WriteFile(filename, []byte("new"), 0o600), qt.IsNil)
			assertEvents(c, w, fsnotify.Event{Name: filename, Op: fsnotify.Create})

			// Remove entire directory.
			subdir = filepath.Join(dir, subdir2)
			c.Assert(w.Add(subdir), qt.IsNil)

			c.Assert(os.RemoveAll(subdir), qt.IsNil)

			expected = expected[:0]

			// This looks like a bug in fsnotify on MacOS. There are
			// 3 files in this directory, yet we get Remove events
			// for one of them + the directory.
			if !poll {
				expected = append(expected, fsnotify.Event{Name: filepath.Join(subdir, "file2"), Op: fsnotify.Remove})
			}
			expected = append(expected, fsnotify.Event{Name: subdir, Op: fsnotify.Remove})
			assertEvents(c, w, expected...)
		})

		c.Run(fmt.Sprintf("%s, Add should not trigger event", method), func(c *qt.C) {
			dir, w := preparePollTest(c, poll)
			subdir := filepath.Join(dir, subdir1)
			w.Add(subdir)
			assertEvents(c, w)
			// Create a new sub directory and add it to the watcher.
			subdir = filepath.Join(dir, subdir1, subdir2)
			c.Assert(os.Mkdir(subdir, 0o777), qt.IsNil)
			w.Add(subdir)
			// This should create only one event.
			assertEvents(c, w, fsnotify.Event{Name: subdir, Op: fsnotify.Create})
		})

	}
}

func TestPollerClose(t *testing.T) {
	c := qt.New(t)
	w := NewPollingWatcher(watchWaitTime)
	f1, err := os.CreateTemp("", "f1")
	c.Assert(err, qt.IsNil)
	defer os.Remove(f1.Name())
	f2, err := os.CreateTemp("", "f2")
	c.Assert(err, qt.IsNil)
	filename1 := f1.Name()
	filename2 := f2.Name()
	f1.Close()
	f2.Close()

	c.Assert(w.Add(filename1), qt.IsNil)
	c.Assert(w.Add(filename2), qt.IsNil)
	c.Assert(w.Close(), qt.IsNil)
	c.Assert(w.Close(), qt.IsNil)
	c.Assert(os.WriteFile(filename1, []byte("new"), 0o600), qt.IsNil)
	c.Assert(os.WriteFile(filename2, []byte("new"), 0o600), qt.IsNil)
	// No more event as the watchers are closed.
	assertEvents(c, w)

	f2, err = os.CreateTemp("", "f2")
	c.Assert(err, qt.IsNil)

	defer os.Remove(f2.Name())

	c.Assert(w.Add(f2.Name()), qt.Not(qt.IsNil))
}

func TestCheckChange(t *testing.T) {
	c := qt.New(t)

	dir := prepareTestDirWithSomeFiles(c, "check-change")

	stat := func(s ...string) os.FileInfo {
		fi, err := os.Stat(filepath.Join(append([]string{dir}, s...)...))
		c.Assert(err, qt.IsNil)
		return fi
	}

	f0, f1, f2 := stat(subdir2, "file0"), stat(subdir2, "file1"), stat(subdir2, "file2")
	d1 := stat(subdir1)

	// Note that on Windows, only the 0200 bit (owner writable) of mode is used.
	c.Assert(os.Chmod(filepath.Join(filepath.Join(dir, subdir2, "file1")), 0o400), qt.IsNil)
	f1_2 := stat(subdir2, "file1")

	c.Assert(os.WriteFile(filepath.Join(filepath.Join(dir, subdir2, "file2")), []byte("changed"), 0o600), qt.IsNil)
	f2_2 := stat(subdir2, "file2")

	c.Assert(checkChange(f0, nil), qt.Equals, fsnotify.Remove)
	c.Assert(checkChange(nil, f0), qt.Equals, fsnotify.Create)
	c.Assert(checkChange(f1, f1_2), qt.Equals, fsnotify.Chmod)
	c.Assert(checkChange(f2, f2_2), qt.Equals, fsnotify.Write)
	c.Assert(checkChange(nil, nil), qt.Equals, fsnotify.Op(0))
	c.Assert(checkChange(d1, f1), qt.Equals, fsnotify.Op(0))
	c.Assert(checkChange(f1, d1), qt.Equals, fsnotify.Op(0))
}

func BenchmarkPoller(b *testing.B) {
	runBench := func(b *testing.B, item *itemToWatch) {
		b.ResetTimer()
		for b.Loop() {
			evs, err := item.checkForChanges()
			if err != nil {
				b.Fatal(err)
			}
			if len(evs) != 0 {
				b.Fatal("got events")
			}

		}
	}

	b.Run("Check for changes in dir", func(b *testing.B) {
		c := qt.New(b)
		dir := prepareTestDirWithSomeFiles(c, "bench-check")
		item, err := newItemToWatch(dir)
		c.Assert(err, qt.IsNil)
		runBench(b, item)
	})

	b.Run("Check for changes in file", func(b *testing.B) {
		c := qt.New(b)
		dir := prepareTestDirWithSomeFiles(c, "bench-check-file")
		filename := filepath.Join(dir, subdir1, "file1")
		item, err := newItemToWatch(filename)
		c.Assert(err, qt.IsNil)
		runBench(b, item)
	})
}

func prepareTestDirWithSomeFiles(c *qt.C, id string) string {
	dir := c.TB.TempDir()
	c.Assert(os.MkdirAll(filepath.Join(dir, subdir1), 0o777), qt.IsNil)
	c.Assert(os.MkdirAll(filepath.Join(dir, subdir2), 0o777), qt.IsNil)

	for i := range 3 {
		c.Assert(os.WriteFile(filepath.Join(dir, subdir1, fmt.Sprintf("file%d", i)), []byte("hello1"), 0o600), qt.IsNil)
	}

	for i := range 3 {
		c.Assert(os.WriteFile(filepath.Join(dir, subdir2, fmt.Sprintf("file%d", i)), []byte("hello2"), 0o600), qt.IsNil)
	}

	c.Cleanup(func() {
		os.RemoveAll(dir)
	})

	return dir
}

func preparePollTest(c *qt.C, poll bool) (string, FileWatcher) {
	var w FileWatcher
	if poll {
		w = NewPollingWatcher(watchWaitTime)
	} else {
		var err error
		w, err = NewEventWatcher()
		c.Assert(err, qt.IsNil)
	}

	dir := prepareTestDirWithSomeFiles(c, fmt.Sprint(poll))

	c.Cleanup(func() {
		w.Close()
	})
	return dir, w
}

func assertEvents(c *qt.C, w FileWatcher, evs ...fsnotify.Event) {
	c.Helper()
	i := 0
	check := func() error {
		for {
			select {
			case got := <-w.Events():
				if i > len(evs)-1 {
					return fmt.Errorf("got too many event(s): %q", got)
				}
				expected := evs[i]
				i++
				if expected.Name != got.Name {
					return fmt.Errorf("got wrong filename, expected %q: %v", expected.Name, got.Name)
				} else if got.Op&expected.Op != expected.Op {
					return fmt.Errorf("got wrong event type, expected %q: %v", expected.Op, got.Op)
				}
			case e := <-w.Errors():
				return fmt.Errorf("got unexpected error waiting for events %v", e)
			case <-time.After(watchWaitTime + (watchWaitTime / 2)):
				return nil
			}
		}
	}
	c.Assert(check(), qt.IsNil)
	c.Assert(i, qt.Equals, len(evs))
}

func drainEvents(c *qt.C, w FileWatcher) {
	c.Helper()
	check := func() error {
		for {
			select {
			case <-w.Events():
			case e := <-w.Errors():
				return fmt.Errorf("got unexpected error waiting for events %v", e)
			case <-time.After(watchWaitTime * 2):
				return nil
			}
		}
	}
	c.Assert(check(), qt.IsNil)
}
