package local

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"path"
	"path/filepath"
	"runtime"
	"sort"
	"testing"
	"time"

	"github.com/rclone/rclone/fs"
	"github.com/rclone/rclone/fs/accounting"
	"github.com/rclone/rclone/fs/config/configmap"
	"github.com/rclone/rclone/fs/filter"
	"github.com/rclone/rclone/fs/hash"
	"github.com/rclone/rclone/fs/object"
	"github.com/rclone/rclone/fs/operations"
	"github.com/rclone/rclone/fstest"
	"github.com/rclone/rclone/lib/file"
	"github.com/rclone/rclone/lib/readers"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestMain drives the tests
func TestMain(m *testing.M) {
	fstest.TestMain(m)
}

// Test copy with source file that's updating
func TestUpdatingCheck(t *testing.T) {
	r := fstest.NewRun(t)
	filePath := "sub dir/local test"
	r.WriteFile(filePath, "content", time.Now())

	fd, err := file.Open(path.Join(r.LocalName, filePath))
	if err != nil {
		t.Fatalf("failed opening file %q: %v", filePath, err)
	}
	defer func() {
		require.NoError(t, fd.Close())
	}()

	fi, err := fd.Stat()
	require.NoError(t, err)
	o := &Object{size: fi.Size(), modTime: fi.ModTime(), fs: &Fs{}}
	wrappedFd := readers.NewLimitedReadCloser(fd, -1)
	hash, err := hash.NewMultiHasherTypes(hash.Supported())
	require.NoError(t, err)
	in := localOpenFile{
		o:    o,
		in:   wrappedFd,
		hash: hash,
		fd:   fd,
	}

	buf := make([]byte, 1)
	_, err = in.Read(buf)
	require.NoError(t, err)

	r.WriteFile(filePath, "content updated", time.Now())
	_, err = in.Read(buf)
	require.Errorf(t, err, "can't copy - source file is being updated")

	// turn the checking off and try again
	in.o.fs.opt.NoCheckUpdated = true

	r.WriteFile(filePath, "content updated", time.Now())
	_, err = in.Read(buf)
	require.NoError(t, err)
}

// Test corrupted on transfer
// should error due to size/hash mismatch
func TestVerifyCopy(t *testing.T) {
	t.Skip("FIXME this test is unreliable")
	r := fstest.NewRun(t)
	filePath := "sub dir/local test"
	r.WriteFile(filePath, "some content", time.Now())
	src, err := r.Flocal.NewObject(context.Background(), filePath)
	require.NoError(t, err)
	src.(*Object).fs.opt.NoCheckUpdated = true

	for i := range 100 {
		go r.WriteFile(src.Remote(), fmt.Sprintf("some new content %d", i), src.ModTime(context.Background()))
	}
	_, err = operations.Copy(context.Background(), r.Fremote, nil, filePath+"2", src)
	assert.Error(t, err)
}

func TestSymlink(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	f := r.Flocal.(*Fs)
	dir := f.root

	// Write a file
	modTime1 := fstest.Time("2001-02-03T04:05:10.123123123Z")
	file1 := r.WriteFile("file.txt", "hello", modTime1)

	// Write a symlink
	modTime2 := fstest.Time("2002-02-03T04:05:10.123123123Z")
	symlinkPath := filepath.Join(dir, "symlink.txt")
	require.NoError(t, os.Symlink("file.txt", symlinkPath))
	require.NoError(t, lChtimes(symlinkPath, modTime2, modTime2))

	// Object viewed as symlink
	file2 := fstest.NewItem("symlink.txt"+fs.LinkSuffix, "file.txt", modTime2)

	// Object viewed as destination
	file2d := fstest.NewItem("symlink.txt", "hello", modTime1)

	// Check with no symlink flags
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t)

	// Set fs into "-L" mode
	f.opt.FollowSymlinks = true
	f.opt.TranslateSymlinks = false
	f.lstat = os.Stat

	r.CheckLocalItems(t, file1, file2d)
	r.CheckRemoteItems(t)

	// Set fs into "-l" mode
	f.opt.FollowSymlinks = false
	f.opt.TranslateSymlinks = true
	f.lstat = os.Lstat

	fstest.CheckListingWithPrecision(t, r.Flocal, []fstest.Item{file1, file2}, nil, fs.ModTimeNotSupported)
	if haveLChtimes {
		r.CheckLocalItems(t, file1, file2)
	}

	// Create a symlink
	modTime3 := fstest.Time("2002-03-03T04:05:10.123123123Z")
	file3 := r.WriteObjectTo(ctx, r.Flocal, "symlink2.txt"+fs.LinkSuffix, "file.txt", modTime3, false)
	fstest.CheckListingWithPrecision(t, r.Flocal, []fstest.Item{file1, file2, file3}, nil, fs.ModTimeNotSupported)
	if haveLChtimes {
		r.CheckLocalItems(t, file1, file2, file3)
	}

	// Check it got the correct contents
	symlinkPath = filepath.Join(dir, "symlink2.txt")
	fi, err := os.Lstat(symlinkPath)
	require.NoError(t, err)
	assert.False(t, fi.Mode().IsRegular())
	linkText, err := os.Readlink(symlinkPath)
	require.NoError(t, err)
	assert.Equal(t, "file.txt", linkText)

	// Check that NewObject gets the correct object
	o, err := r.Flocal.NewObject(ctx, "symlink2.txt"+fs.LinkSuffix)
	require.NoError(t, err)
	assert.Equal(t, "symlink2.txt"+fs.LinkSuffix, o.Remote())
	assert.Equal(t, int64(8), o.Size())

	// Check that NewObject doesn't see the non suffixed version
	_, err = r.Flocal.NewObject(ctx, "symlink2.txt")
	require.Equal(t, fs.ErrorObjectNotFound, err)

	// Check that NewFs works with the suffixed version and --links
	f2, err := NewFs(ctx, "local", filepath.Join(dir, "symlink2.txt"+fs.LinkSuffix), configmap.Simple{
		"links": "true",
	})
	require.Equal(t, fs.ErrorIsFile, err)
	require.Equal(t, dir, f2.(*Fs).root)

	// Check that NewFs doesn't see the non suffixed version with --links
	f2, err = NewFs(ctx, "local", filepath.Join(dir, "symlink2.txt"), configmap.Simple{
		"links": "true",
	})
	require.Equal(t, errLinksNeedsSuffix, err)
	require.Nil(t, f2)

	// Check reading the object
	in, err := o.Open(ctx)
	require.NoError(t, err)
	contents, err := io.ReadAll(in)
	require.NoError(t, err)
	require.Equal(t, "file.txt", string(contents))
	require.NoError(t, in.Close())

	// Check reading the object with range
	in, err = o.Open(ctx, &fs.RangeOption{Start: 2, End: 5})
	require.NoError(t, err)
	contents, err = io.ReadAll(in)
	require.NoError(t, err)
	require.Equal(t, "file.txt"[2:5+1], string(contents))
	require.NoError(t, in.Close())
}

func TestSymlinkError(t *testing.T) {
	m := configmap.Simple{
		"links":      "true",
		"copy_links": "true",
	}
	_, err := NewFs(context.Background(), "local", "/", m)
	assert.Equal(t, errLinksAndCopyLinks, err)
}

func TestHashWithTypeNone(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	const filePath = "file.txt"
	r.WriteFile(filePath, "content", time.Now())
	f := r.Flocal.(*Fs)

	// Get the object
	o, err := f.NewObject(ctx, filePath)
	require.NoError(t, err)

	// Test the hash is as we expect
	h, err := o.Hash(ctx, hash.None)
	require.Empty(t, h)
	require.NoError(t, err)
}

// Test hashes on updating an object
func TestHashOnUpdate(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	const filePath = "file.txt"
	when := time.Now()
	r.WriteFile(filePath, "content", when)
	f := r.Flocal.(*Fs)

	// Get the object
	o, err := f.NewObject(ctx, filePath)
	require.NoError(t, err)

	// Test the hash is as we expect
	md5, err := o.Hash(ctx, hash.MD5)
	require.NoError(t, err)
	assert.Equal(t, "9a0364b9e99bb480dd25e1f0284c8555", md5)

	// Reupload it with different contents but same size and timestamp
	b := bytes.NewBufferString("CONTENT")
	src := object.NewStaticObjectInfo(filePath, when, int64(b.Len()), true, nil, f)
	err = o.Update(ctx, b, src)
	require.NoError(t, err)

	// Check the hash is as expected
	md5, err = o.Hash(ctx, hash.MD5)
	require.NoError(t, err)
	assert.Equal(t, "45685e95985e20822fb2538a522a5ccf", md5)
}

// Test hashes on deleting an object
func TestHashOnDelete(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	const filePath = "file.txt"
	when := time.Now()
	r.WriteFile(filePath, "content", when)
	f := r.Flocal.(*Fs)

	// Get the object
	o, err := f.NewObject(ctx, filePath)
	require.NoError(t, err)

	// Test the hash is as we expect
	md5, err := o.Hash(ctx, hash.MD5)
	require.NoError(t, err)
	assert.Equal(t, "9a0364b9e99bb480dd25e1f0284c8555", md5)

	// Delete the object
	require.NoError(t, o.Remove(ctx))

	// Test the hash cache is empty
	require.Nil(t, o.(*Object).hashes)

	// Test the hash returns an error
	_, err = o.Hash(ctx, hash.MD5)
	require.Error(t, err)
}

func TestMetadata(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	const filePath = "metafile.txt"
	when := time.Now()
	r.WriteFile(filePath, "metadata file contents", when)
	f := r.Flocal.(*Fs)

	// Set fs into "-l" / "--links" mode
	f.opt.TranslateSymlinks = true

	// Write a symlink to the file
	symlinkPath := "metafile-link.txt"
	osSymlinkPath := filepath.Join(f.root, symlinkPath)
	symlinkPath += fs.LinkSuffix
	require.NoError(t, os.Symlink(filePath, osSymlinkPath))
	symlinkModTime := fstest.Time("2002-02-03T04:05:10.123123123Z")
	require.NoError(t, lChtimes(osSymlinkPath, symlinkModTime, symlinkModTime))

	// Get the object
	obj, err := f.NewObject(ctx, filePath)
	require.NoError(t, err)
	o := obj.(*Object)

	// Get the symlink object
	symlinkObj, err := f.NewObject(ctx, symlinkPath)
	require.NoError(t, err)
	symlinkO := symlinkObj.(*Object)

	// Record metadata for o
	oMeta, err := o.Metadata(ctx)
	require.NoError(t, err)

	// Test symlink first to check it doesn't mess up file
	t.Run("Symlink", func(t *testing.T) {
		testMetadata(t, r, symlinkO, symlinkModTime)
	})

	// Read it again
	oMetaNew, err := o.Metadata(ctx)
	require.NoError(t, err)

	// Check that operating on the symlink didn't change the file it was pointing to
	// See: https://github.com/rclone/rclone/security/advisories/GHSA-hrxh-9w67-g4cv
	assert.Equal(t, oMeta, oMetaNew, "metadata setting on symlink messed up file")

	// Now run the same tests on the file
	t.Run("File", func(t *testing.T) {
		testMetadata(t, r, o, when)
	})
}

func testMetadata(t *testing.T, r *fstest.Run, o *Object, when time.Time) {
	ctx := context.Background()
	whenRFC := when.Format(time.RFC3339Nano)
	const dayLength = len("2001-01-01")

	f := r.Flocal.(*Fs)
	features := f.Features()

	var hasXID, hasAtime, hasBtime, canSetXattrOnLinks bool
	switch runtime.GOOS {
	case "darwin", "freebsd", "netbsd", "linux":
		hasXID, hasAtime, hasBtime = true, true, true
		canSetXattrOnLinks = runtime.GOOS != "linux"
	case "openbsd", "solaris":
		hasXID, hasAtime = true, true
	case "windows":
		hasAtime, hasBtime = true, true
	case "plan9", "js":
		// nada
	default:
		t.Errorf("No test cases for OS %q", runtime.GOOS)
	}

	assert.True(t, features.ReadMetadata)
	assert.True(t, features.WriteMetadata)
	assert.Equal(t, xattrSupported, features.UserMetadata)

	t.Run("Xattr", func(t *testing.T) {
		if !xattrSupported {
			t.Skip()
		}
		m, err := o.getXattr()
		require.NoError(t, err)
		assert.Nil(t, m)

		if !canSetXattrOnLinks && o.translatedLink {
			t.Skip("Skip remainder of test as can't set xattr on symlinks on this OS")
		}

		inM := fs.Metadata{
			"potato":  "chips",
			"cabbage": "soup",
		}
		err = o.setXattr(inM)
		require.NoError(t, err)

		m, err = o.getXattr()
		require.NoError(t, err)
		assert.NotNil(t, m)
		assert.Equal(t, inM, m)
	})

	checkTime := func(m fs.Metadata, key string, when time.Time) {
		t.Helper()
		mt, ok := o.parseMetadataTime(m, key)
		assert.True(t, ok)
		dt := mt.Sub(when)
		precision := time.Second
		assert.True(t, dt >= -precision && dt <= precision, fmt.Sprintf("%s: dt %v outside +/- precision %v want %v got %v", key, dt, precision, mt, when))
	}

	checkInt := func(m fs.Metadata, key string, base int) int {
		t.Helper()
		value, ok := o.parseMetadataInt(m, key, base)
		assert.True(t, ok)
		return value
	}

	t.Run("Read", func(t *testing.T) {
		m, err := o.Metadata(ctx)
		require.NoError(t, err)
		assert.NotNil(t, m)

		// All OSes have these
		checkInt(m, "mode", 8)
		checkTime(m, "mtime", when)

		assert.Equal(t, whenRFC[:dayLength], m["mtime"][:dayLength])

		if hasAtime && !o.translatedLink { // symlinks generally don't record atime
			checkTime(m, "atime", when)
		}
		if hasBtime && !o.translatedLink { // symlinks generally don't record btime
			checkTime(m, "btime", when)
		}
		if hasXID {
			checkInt(m, "uid", 10)
			checkInt(m, "gid", 10)
		}
	})

	t.Run("Write", func(t *testing.T) {
		newAtimeString := "2011-12-13T14:15:16.999999999Z"
		newAtime := fstest.Time(newAtimeString)
		newMtimeString := "2011-12-12T14:15:16.999999999Z"
		newMtime := fstest.Time(newMtimeString)
		newBtimeString := "2011-12-11T14:15:16.999999999Z"
		newBtime := fstest.Time(newBtimeString)
		newM := fs.Metadata{
			"mtime": newMtimeString,
			"atime": newAtimeString,
			"btime": newBtimeString,
			// Can't test uid, gid without being root
			"mode":   "0767",
			"potato": "wedges",
		}
		if !canSetXattrOnLinks && o.translatedLink {
			// Don't change xattr if not supported on symlinks
			delete(newM, "potato")
		}
		err := o.writeMetadata(newM)
		require.NoError(t, err)

		m, err := o.Metadata(ctx)
		require.NoError(t, err)
		assert.NotNil(t, m)

		mode := checkInt(m, "mode", 8)
		if runtime.GOOS != "windows" {
			expectedMode := 0767
			if o.translatedLink && runtime.GOOS == "linux" {
				expectedMode = 0777 // perms of symlinks always read as 0777 on linux
			}
			assert.Equal(t, expectedMode, mode&0777, fmt.Sprintf("mode wrong - expecting 0%o got 0%o", expectedMode, mode&0777))
		}

		checkTime(m, "mtime", newMtime)
		if hasAtime {
			checkTime(m, "atime", newAtime)
		}
		if haveSetBTime {
			checkTime(m, "btime", newBtime)
		}
		if xattrSupported && (canSetXattrOnLinks || !o.translatedLink) {
			assert.Equal(t, "wedges", m["potato"])
		}
	})
}

func TestFilter(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	when := time.Now()
	r.WriteFile("included", "included file", when)
	r.WriteFile("excluded", "excluded file", when)
	f := r.Flocal.(*Fs)

	// Check set up for filtering
	assert.True(t, f.Features().FilterAware)

	// Add a filter
	ctx, fi := filter.AddConfig(ctx)
	require.NoError(t, fi.AddRule("+ included"))
	require.NoError(t, fi.AddRule("- *"))

	// Check listing without use filter flag
	entries, err := f.List(ctx, "")
	require.NoError(t, err)
	sort.Sort(entries)
	require.Equal(t, "[excluded included]", fmt.Sprint(entries))

	// Add user filter flag
	ctx = filter.SetUseFilter(ctx, true)

	// Check listing with use filter flag
	entries, err = f.List(ctx, "")
	require.NoError(t, err)
	sort.Sort(entries)
	require.Equal(t, "[included]", fmt.Sprint(entries))
}

func testFilterSymlink(t *testing.T, copyLinks bool) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	defer r.Finalise()
	when := time.Now()
	f := r.Flocal.(*Fs)

	// Create a file, a directory, a symlink to a file, a symlink to a directory and a dangling symlink
	r.WriteFile("included.file", "included file", when)
	r.WriteFile("included.dir/included.sub.file", "included sub file", when)
	require.NoError(t, os.Symlink("included.file", filepath.Join(r.LocalName, "included.file.link")))
	require.NoError(t, os.Symlink("included.dir", filepath.Join(r.LocalName, "included.dir.link")))
	require.NoError(t, os.Symlink("dangling", filepath.Join(r.LocalName, "dangling.link")))

	defer func() {
		// Reset -L/-l mode
		f.opt.FollowSymlinks = false
		f.opt.TranslateSymlinks = false
		f.lstat = os.Lstat
	}()
	if copyLinks {
		// Set fs into "-L" mode
		f.opt.FollowSymlinks = true
		f.opt.TranslateSymlinks = false
		f.lstat = os.Stat
	} else {
		// Set fs into "-l" mode
		f.opt.FollowSymlinks = false
		f.opt.TranslateSymlinks = true
		f.lstat = os.Lstat
	}

	// Check set up for filtering
	assert.True(t, f.Features().FilterAware)

	// Reset global error count
	accounting.Stats(ctx).ResetErrors()
	assert.Equal(t, int64(0), accounting.Stats(ctx).GetErrors(), "global errors found")

	// Add a filter
	ctx, fi := filter.AddConfig(ctx)
	require.NoError(t, fi.AddRule("+ included.file"))
	require.NoError(t, fi.AddRule("+ included.dir/**"))
	if copyLinks {
		require.NoError(t, fi.AddRule("+ included.file.link"))
		require.NoError(t, fi.AddRule("+ included.dir.link/**"))
	} else {
		require.NoError(t, fi.AddRule("+ included.file.link.rclonelink"))
		require.NoError(t, fi.AddRule("+ included.dir.link.rclonelink"))
	}
	require.NoError(t, fi.AddRule("- *"))

	// Check listing without use filter flag
	entries, err := f.List(ctx, "")
	require.NoError(t, err)

	if copyLinks {
		// Check 1 global errors one for each dangling symlink
		assert.Equal(t, int64(1), accounting.Stats(ctx).GetErrors(), "global errors found")
	} else {
		// Check 0 global errors as dangling symlink copied properly
		assert.Equal(t, int64(0), accounting.Stats(ctx).GetErrors(), "global errors found")
	}
	accounting.Stats(ctx).ResetErrors()

	sort.Sort(entries)
	if copyLinks {
		require.Equal(t, "[included.dir included.dir.link included.file included.file.link]", fmt.Sprint(entries))
	} else {
		require.Equal(t, "[dangling.link.rclonelink included.dir included.dir.link.rclonelink included.file included.file.link.rclonelink]", fmt.Sprint(entries))
	}

	// Add user filter flag
	ctx = filter.SetUseFilter(ctx, true)

	// Check listing with use filter flag
	entries, err = f.List(ctx, "")
	require.NoError(t, err)
	assert.Equal(t, int64(0), accounting.Stats(ctx).GetErrors(), "global errors found")

	sort.Sort(entries)
	if copyLinks {
		require.Equal(t, "[included.dir included.dir.link included.file included.file.link]", fmt.Sprint(entries))
	} else {
		require.Equal(t, "[included.dir included.dir.link.rclonelink included.file included.file.link.rclonelink]", fmt.Sprint(entries))
	}

	// Check listing through a symlink still works
	entries, err = f.List(ctx, "included.dir")
	require.NoError(t, err)
	assert.Equal(t, int64(0), accounting.Stats(ctx).GetErrors(), "global errors found")

	sort.Sort(entries)
	require.Equal(t, "[included.dir/included.sub.file]", fmt.Sprint(entries))
}

func TestFilterSymlinkCopyLinks(t *testing.T) {
	testFilterSymlink(t, true)
}

func TestFilterSymlinkLinks(t *testing.T) {
	testFilterSymlink(t, false)
}

func TestCopySymlink(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	defer r.Finalise()
	when := time.Now()
	f := r.Flocal.(*Fs)

	// Create a file and a symlink to it
	r.WriteFile("src/file.txt", "hello world", when)
	require.NoError(t, os.Symlink("file.txt", filepath.Join(r.LocalName, "src", "link.txt")))
	defer func() {
		// Reset -L/-l mode
		f.opt.FollowSymlinks = false
		f.opt.TranslateSymlinks = false
		f.lstat = os.Lstat
	}()

	// Set fs into "-l/--links" mode
	f.opt.FollowSymlinks = false
	f.opt.TranslateSymlinks = true
	f.lstat = os.Lstat

	// Create dst
	require.NoError(t, f.Mkdir(ctx, "dst"))

	// Do copy from src into dst
	src, err := f.NewObject(ctx, "src/link.txt.rclonelink")
	require.NoError(t, err)
	require.NotNil(t, src)
	dst, err := operations.Copy(ctx, f, nil, "dst/link.txt.rclonelink", src)
	require.NoError(t, err)
	require.NotNil(t, dst)

	// Test that we made a symlink and it has the right contents
	dstPath := filepath.Join(r.LocalName, "dst", "link.txt")
	linkContents, err := os.Readlink(dstPath)
	require.NoError(t, err)
	assert.Equal(t, "file.txt", linkContents)

	// Set fs into "-L/--copy-links" mode
	f.opt.FollowSymlinks = true
	f.opt.TranslateSymlinks = false
	f.lstat = os.Stat

	// Create dst
	require.NoError(t, f.Mkdir(ctx, "dst2"))

	// Do copy from src into dst
	src, err = f.NewObject(ctx, "src/link.txt")
	require.NoError(t, err)
	require.NotNil(t, src)
	dst, err = operations.Copy(ctx, f, nil, "dst2/link.txt", src)
	require.NoError(t, err)
	require.NotNil(t, dst)

	// Test that we made a NON-symlink and it has the right contents
	dstPath = filepath.Join(r.LocalName, "dst2", "link.txt")
	fi, err := os.Lstat(dstPath)
	require.NoError(t, err)
	assert.True(t, fi.Mode()&os.ModeSymlink == 0)
	want := fstest.NewItem("dst2/link.txt", "hello world", when)
	fstest.CompareItems(t, []fs.DirEntry{dst}, []fstest.Item{want}, nil, f.precision, "")

	// Test that copying a normal file also works
	dst, err = operations.Copy(ctx, f, nil, "dst2/file.txt", dst)
	require.NoError(t, err)
	require.NotNil(t, dst)
	want = fstest.NewItem("dst2/file.txt", "hello world", when)
	fstest.CompareItems(t, []fs.DirEntry{dst}, []fstest.Item{want}, nil, f.precision, "")
}
