// Copyright (C) 2016 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at http://mozilla.org/MPL/2.0/.

// +build !solaris,!darwin solaris,cgo darwin,cgo

package fs

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"syscall"
	"testing"
	"time"

	"github.com/syncthing/notify"
)

func TestMain(m *testing.M) {
	if err := os.RemoveAll(testDir); err != nil {
		panic(err)
	}

	dir, err := filepath.Abs(".")
	if err != nil {
		panic("Cannot get absolute path to working dir")
	}

	dir, err = evalSymlinks(dir)
	if err != nil {
		panic("Cannot get real path to working dir")
	}

	testDirAbs = filepath.Join(dir, testDir)
	if runtime.GOOS == "windows" {
		testDirAbs = longFilenameSupport(testDirAbs)
	}

	testFs = NewFilesystem(FilesystemTypeBasic, testDirAbs)

	backendBuffer = 10

	exitCode := m.Run()

	backendBuffer = 500
	os.RemoveAll(testDir)

	os.Exit(exitCode)
}

const (
	testDir = "testdata"
)

var (
	testDirAbs string
	testFs     Filesystem
)

func TestWatchIgnore(t *testing.T) {
	name := "ignore"

	file := "file"
	ignored := "ignored"

	testCase := func() {
		createTestFile(name, file)
		createTestFile(name, ignored)
	}

	expectedEvents := []Event{
		{file, NonRemove},
	}
	allowedEvents := []Event{
		{name, NonRemove},
	}

	testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{ignore: filepath.Join(name, ignored), skipIgnoredDirs: true})
}

func TestWatchInclude(t *testing.T) {
	name := "include"

	file := "file"
	ignored := "ignored"
	testFs.MkdirAll(filepath.Join(name, ignored), 0777)
	included := filepath.Join(ignored, "included")

	testCase := func() {
		createTestFile(name, file)
		createTestFile(name, included)
	}

	expectedEvents := []Event{
		{file, NonRemove},
		{included, NonRemove},
	}
	allowedEvents := []Event{
		{name, NonRemove},
	}

	testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{ignore: filepath.Join(name, ignored), include: filepath.Join(name, included)})
}

func TestWatchRename(t *testing.T) {
	name := "rename"

	old := createTestFile(name, "oldfile")
	new := "newfile"

	testCase := func() {
		renameTestFile(name, old, new)
	}

	destEvent := Event{new, Remove}
	// Only on these platforms the removed file can be differentiated from
	// the created file during renaming
	if runtime.GOOS == "windows" || runtime.GOOS == "linux" || runtime.GOOS == "solaris" {
		destEvent = Event{new, NonRemove}
	}
	expectedEvents := []Event{
		{old, Remove},
		destEvent,
	}
	allowedEvents := []Event{
		{name, NonRemove},
	}

	// set the "allow others" flag because we might get the create of
	// "oldfile" initially
	testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{})
}

// TestWatchOutside checks that no changes from outside the folder make it in
func TestWatchOutside(t *testing.T) {
	outChan := make(chan Event)
	backendChan := make(chan notify.EventInfo, backendBuffer)

	ctx, cancel := context.WithCancel(context.Background())

	// testFs is Filesystem, but we need BasicFilesystem here
	fs := newBasicFilesystem(testDirAbs)

	go func() {
		defer func() {
			if recover() == nil {
				select {
				case <-ctx.Done(): // timed out
				default:
					t.Fatalf("Watch did not panic on receiving event outside of folder")
				}
			}
			cancel()
		}()
		fs.watchLoop(".", testDirAbs, backendChan, outChan, fakeMatcher{}, ctx)
	}()

	backendChan <- fakeEventInfo(filepath.Join(filepath.Dir(testDirAbs), "outside"))

	select {
	case <-time.After(10 * time.Second):
		cancel()
		t.Errorf("Timed out before panicing")
	case <-ctx.Done():
	}
}

func TestWatchSubpath(t *testing.T) {
	outChan := make(chan Event)
	backendChan := make(chan notify.EventInfo, backendBuffer)

	ctx, cancel := context.WithCancel(context.Background())

	// testFs is Filesystem, but we need BasicFilesystem here
	fs := newBasicFilesystem(testDirAbs)

	abs, _ := fs.rooted("sub")
	go fs.watchLoop("sub", testDirAbs, backendChan, outChan, fakeMatcher{}, ctx)

	backendChan <- fakeEventInfo(filepath.Join(abs, "file"))

	timeout := time.NewTimer(2 * time.Second)
	select {
	case <-timeout.C:
		t.Errorf("Timed out before receiving an event")
		cancel()
	case ev := <-outChan:
		if ev.Name != filepath.Join("sub", "file") {
			t.Errorf("While watching a subfolder, received an event with unexpected path %v", ev.Name)
		}
	}

	cancel()
}

// TestWatchOverflow checks that an event at the root is sent when maxFiles is reached
func TestWatchOverflow(t *testing.T) {
	name := "overflow"

	expectedEvents := []Event{
		{".", NonRemove},
	}

	allowedEvents := []Event{
		{name, NonRemove},
	}

	testCase := func() {
		for i := 0; i < 5*backendBuffer; i++ {
			file := "file" + strconv.Itoa(i)
			createTestFile(name, file)
			allowedEvents = append(allowedEvents, Event{file, NonRemove})
		}
	}

	testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{})
}

func TestWatchErrorLinuxInterpretation(t *testing.T) {
	if runtime.GOOS != "linux" {
		t.Skip("testing of linux specific error codes")
	}

	var errTooManyFiles = &os.PathError{
		Op:   "error while traversing",
		Path: "foo",
		Err:  syscall.Errno(24),
	}
	var errNoSpace = &os.PathError{
		Op:   "error while traversing",
		Path: "bar",
		Err:  syscall.Errno(28),
	}

	if !reachedMaxUserWatches(errTooManyFiles) {
		t.Error("Underlying error syscall.Errno(24) should be recognised to be about inotify limits.")
	}
	if !reachedMaxUserWatches(errNoSpace) {
		t.Error("Underlying error syscall.Errno(28) should be recognised to be about inotify limits.")
	}
	err := errors.New("Another error")
	if reachedMaxUserWatches(err) {
		t.Errorf("This error does not concern inotify limits: %#v", err)
	}
}

func TestWatchSymlinkedRoot(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("Involves symlinks")
	}

	name := "symlinkedRoot"
	if err := testFs.MkdirAll(name, 0755); err != nil {
		panic(fmt.Sprintf("Failed to create directory %s: %s", name, err))
	}
	defer testFs.RemoveAll(name)

	root := filepath.Join(name, "root")
	if err := testFs.MkdirAll(root, 0777); err != nil {
		panic(err)
	}
	link := filepath.Join(name, "link")

	if err := testFs.CreateSymlink(filepath.Join(testFs.URI(), root), link); err != nil {
		panic(err)
	}

	linkedFs := NewFilesystem(FilesystemTypeBasic, filepath.Join(testFs.URI(), link))

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	if _, err := linkedFs.Watch(".", fakeMatcher{}, ctx, false); err != nil {
		panic(err)
	}

	if err := linkedFs.MkdirAll("foo", 0777); err != nil {
		panic(err)
	}

	// Give the panic some time to happen
	sleepMs(100)
}

func TestUnrootedChecked(t *testing.T) {
	var unrooted string
	defer func() {
		if recover() == nil {
			t.Fatal("unrootedChecked did not panic on outside path, but returned", unrooted)
		}
	}()
	fs := newBasicFilesystem(testDirAbs)
	unrooted = fs.unrootedChecked("/random/other/path", testDirAbs)
}

func TestWatchIssue4877(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("Windows specific test")
	}

	name := "Issue4877"

	file := "file"

	testCase := func() {
		createTestFile(name, file)
	}

	expectedEvents := []Event{
		{file, NonRemove},
	}
	allowedEvents := []Event{
		{name, NonRemove},
	}

	volName := filepath.VolumeName(testDirAbs)
	if volName == "" {
		t.Fatalf("Failed to get volume name for path %v", testDirAbs)
	}
	origTestFs := testFs
	testFs = NewFilesystem(FilesystemTypeBasic, strings.ToLower(volName)+strings.ToUpper(testDirAbs[len(volName):]))
	defer func() {
		testFs = origTestFs
	}()

	testScenario(t, name, testCase, expectedEvents, allowedEvents, fakeMatcher{})
}

// path relative to folder root, also creates parent dirs if necessary
func createTestFile(name string, file string) string {
	joined := filepath.Join(name, file)
	if err := testFs.MkdirAll(filepath.Dir(joined), 0755); err != nil {
		panic(fmt.Sprintf("Failed to create parent directory for %s: %s", joined, err))
	}
	handle, err := testFs.Create(joined)
	if err != nil {
		panic(fmt.Sprintf("Failed to create test file %s: %s", joined, err))
	}
	handle.Close()
	return file
}

func renameTestFile(name string, old string, new string) {
	old = filepath.Join(name, old)
	new = filepath.Join(name, new)
	if err := testFs.Rename(old, new); err != nil {
		panic(fmt.Sprintf("Failed to rename %s to %s: %s", old, new, err))
	}
}

func sleepMs(ms int) {
	time.Sleep(time.Duration(ms) * time.Millisecond)
}

func testScenario(t *testing.T, name string, testCase func(), expectedEvents, allowedEvents []Event, fm fakeMatcher) {
	if err := testFs.MkdirAll(name, 0755); err != nil {
		panic(fmt.Sprintf("Failed to create directory %s: %s", name, err))
	}
	defer testFs.RemoveAll(name)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	eventChan, err := testFs.Watch(name, fm, ctx, false)
	if err != nil {
		panic(err)
	}

	go testWatchOutput(t, name, eventChan, expectedEvents, allowedEvents, ctx, cancel)

	testCase()

	select {
	case <-time.After(time.Minute):
		t.Errorf("Timed out before receiving all expected events")

	case <-ctx.Done():
	}
}

func testWatchOutput(t *testing.T, name string, in <-chan Event, expectedEvents, allowedEvents []Event, ctx context.Context, cancel context.CancelFunc) {
	var expected = make(map[Event]struct{})
	for _, ev := range expectedEvents {
		ev.Name = filepath.Join(name, ev.Name)
		expected[ev] = struct{}{}
	}

	var received Event
	var last Event
	for {
		if len(expected) == 0 {
			cancel()
			return
		}

		select {
		case <-ctx.Done():
			return
		case received = <-in:
		}

		// apparently the backend sometimes sends repeat events
		if last == received {
			continue
		}

		if _, ok := expected[received]; !ok {
			if len(allowedEvents) > 0 {
				sleepMs(100) // To facilitate overflow
				continue
			}
			t.Errorf("Received unexpected event %v expected one of %v", received, expected)
			cancel()
			return
		}
		delete(expected, received)
		last = received
	}
}

// Matches are done via direct comparison against both ignore and include
type fakeMatcher struct {
	ignore          string
	include         string
	skipIgnoredDirs bool
}

func (fm fakeMatcher) ShouldIgnore(name string) bool {
	return name != fm.include && name == fm.ignore
}

func (fm fakeMatcher) SkipIgnoredDirs() bool {
	return fm.skipIgnoredDirs
}

type fakeEventInfo string

func (e fakeEventInfo) Path() string {
	return string(e)
}

func (e fakeEventInfo) Event() notify.Event {
	return notify.Write
}

func (e fakeEventInfo) Sys() interface{} {
	return nil
}
