package operations_test

import (
	"context"
	"crypto/rand"
	"errors"
	"fmt"
	"os"
	"path"
	"runtime"
	"sort"
	"strings"
	"testing"

	"github.com/rclone/rclone/fs"
	"github.com/rclone/rclone/fs/accounting"
	"github.com/rclone/rclone/fs/operations"
	"github.com/rclone/rclone/fs/sync"
	"github.com/rclone/rclone/fstest"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestTruncateString(t *testing.T) {
	for _, test := range []struct {
		in   string
		n    int
		want string
	}{
		{
			in:   "",
			n:    0,
			want: "",
		}, {
			in:   "Hello World",
			n:    5,
			want: "Hello",
		}, {
			in:   "ááááá",
			n:    5,
			want: "áá",
		}, {
			in:   "ááááá\xFF\xFF",
			n:    5,
			want: "áá\xc3",
		}, {
			in:   "世世世世世",
			n:    7,
			want: "世世",
		}, {
			in:   "🙂🙂🙂🙂🙂",
			n:    16,
			want: "🙂🙂🙂🙂",
		}, {
			in:   "🙂🙂🙂🙂🙂",
			n:    15,
			want: "🙂🙂🙂",
		}, {
			in:   "🙂🙂🙂🙂🙂",
			n:    14,
			want: "🙂🙂🙂",
		}, {
			in:   "🙂🙂🙂🙂🙂",
			n:    13,
			want: "🙂🙂🙂",
		}, {
			in:   "🙂🙂🙂🙂🙂",
			n:    12,
			want: "🙂🙂🙂",
		}, {
			in:   "🙂🙂🙂🙂🙂",
			n:    11,
			want: "🙂🙂",
		}, {
			in:   "𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
			n:    100,
			want: "𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢ",
		}, {
			in:   "a𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
			n:    100,
			want: "a𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢ",
		}, {
			in:   "aa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
			n:    100,
			want: "aa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱ",
		}, {
			in:   "aaa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
			n:    100,
			want: "aaa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱ",
		}, {
			in:   "aaaa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽⁱˢⁱᵒⁿᵃʳʸ",
			n:    100,
			want: "aaaa𝓝𝓸𝓫𝓸𝓭𝔂 𝓲𝓼 𝓱𝓸𝓶𝓮 ᴬ ⱽⁱˢⁱᵗ ᶠʳᵒᵐ ᵗʰᵉ ⱽ",
		},
	} {
		got := operations.TruncateString(test.in, test.n)
		assert.Equal(t, test.want, got, fmt.Sprintf("In %q", test.in))
		assert.LessOrEqual(t, len(got), test.n)
		assert.GreaterOrEqual(t, len(got), test.n-3)
	}
}

func TestCopyFile(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)

	file1 := r.WriteFile("file1", "file1 contents", t1)
	r.CheckLocalItems(t, file1)

	file2 := file1
	file2.Path = "sub/file2"

	err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)

	err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)

	err = operations.CopyFile(ctx, r.Fremote, r.Fremote, file2.Path, file2.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)
}

// Find the longest file name for writing to local
func maxLengthFileName(t *testing.T, r *fstest.Run) string {
	require.NoError(t, r.Flocal.Mkdir(context.Background(), "")) // create the root
	const maxLen = 16 * 1024
	name := strings.Repeat("A", maxLen)
	i := sort.Search(len(name), func(i int) (fail bool) {
		filePath := path.Join(r.LocalName, name[:i])
		err := os.WriteFile(filePath, []byte{0}, 0777)
		if err != nil {
			return true
		}
		err = os.Remove(filePath)
		if err != nil {
			t.Logf("Failed to remove test file: %v", err)
		}
		return false
	})
	return name[:i-1]
}

// Check we can copy a file of maximum name length
func TestCopyLongFile(t *testing.T) {
	ctx := context.Background()
	r := fstest.NewRun(t)
	if !r.Fremote.Features().IsLocal {
		t.Skip("Test only runs on local")
	}

	// Find the maximum length of file we can write
	name := maxLengthFileName(t, r)
	t.Logf("Max length of file name is %d", len(name))
	file1 := r.WriteFile(name, "file1 contents", t1)
	r.CheckLocalItems(t, file1)

	err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file1.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file1)
}

func TestCopyFileBackupDir(t *testing.T) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	r := fstest.NewRun(t)
	if !operations.CanServerSideMove(r.Fremote) {
		t.Skip("Skipping test as remote does not support server-side move or copy")
	}

	ci.BackupDir = r.FremoteName + "/backup"

	file1 := r.WriteFile("dst/file1", "file1 contents", t1)
	r.CheckLocalItems(t, file1)

	file1old := r.WriteObject(ctx, "dst/file1", "file1 contents old", t1)
	r.CheckRemoteItems(t, file1old)

	err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file1.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	file1old.Path = "backup/dst/file1"
	r.CheckRemoteItems(t, file1old, file1)
}

// Test with CompareDest set
func TestCopyFileCompareDest(t *testing.T) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	r := fstest.NewRun(t)

	ci.CompareDest = []string{r.FremoteName + "/CompareDest"}
	fdst, err := fs.NewFs(ctx, r.FremoteName+"/dst")
	require.NoError(t, err)

	// check empty dest, empty compare
	file1 := r.WriteFile("one", "one", t1)
	r.CheckLocalItems(t, file1)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file1.Path, file1.Path)
	require.NoError(t, err)

	file1dst := file1
	file1dst.Path = "dst/one"

	r.CheckRemoteItems(t, file1dst)

	// check old dest, empty compare
	file1b := r.WriteFile("one", "onet2", t2)
	r.CheckRemoteItems(t, file1dst)
	r.CheckLocalItems(t, file1b)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file1b.Path, file1b.Path)
	require.NoError(t, err)

	file1bdst := file1b
	file1bdst.Path = "dst/one"

	r.CheckRemoteItems(t, file1bdst)

	// check old dest, new compare
	file3 := r.WriteObject(ctx, "dst/one", "one", t1)
	file2 := r.WriteObject(ctx, "CompareDest/one", "onet2", t2)
	file1c := r.WriteFile("one", "onet2", t2)
	r.CheckRemoteItems(t, file2, file3)
	r.CheckLocalItems(t, file1c)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file1c.Path, file1c.Path)
	require.NoError(t, err)

	r.CheckRemoteItems(t, file2, file3)

	// check empty dest, new compare
	file4 := r.WriteObject(ctx, "CompareDest/two", "two", t2)
	file5 := r.WriteFile("two", "two", t2)
	r.CheckRemoteItems(t, file2, file3, file4)
	r.CheckLocalItems(t, file1c, file5)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file5.Path, file5.Path)
	require.NoError(t, err)

	r.CheckRemoteItems(t, file2, file3, file4)

	// check new dest, new compare
	err = operations.CopyFile(ctx, fdst, r.Flocal, file5.Path, file5.Path)
	require.NoError(t, err)

	r.CheckRemoteItems(t, file2, file3, file4)

	// check empty dest, old compare
	file5b := r.WriteFile("two", "twot3", t3)
	r.CheckRemoteItems(t, file2, file3, file4)
	r.CheckLocalItems(t, file1c, file5b)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file5b.Path, file5b.Path)
	require.NoError(t, err)

	file5bdst := file5b
	file5bdst.Path = "dst/two"

	r.CheckRemoteItems(t, file2, file3, file4, file5bdst)
}

// Test with CopyDest set
func TestCopyFileCopyDest(t *testing.T) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	r := fstest.NewRun(t)

	if r.Fremote.Features().Copy == nil {
		t.Skip("Skipping test as remote does not support server-side copy")
	}

	ci.CopyDest = []string{r.FremoteName + "/CopyDest"}

	fdst, err := fs.NewFs(ctx, r.FremoteName+"/dst")
	require.NoError(t, err)

	// check empty dest, empty copy
	file1 := r.WriteFile("one", "one", t1)
	r.CheckLocalItems(t, file1)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file1.Path, file1.Path)
	require.NoError(t, err)

	file1dst := file1
	file1dst.Path = "dst/one"

	r.CheckRemoteItems(t, file1dst)

	// check old dest, empty copy
	file1b := r.WriteFile("one", "onet2", t2)
	r.CheckRemoteItems(t, file1dst)
	r.CheckLocalItems(t, file1b)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file1b.Path, file1b.Path)
	require.NoError(t, err)

	file1bdst := file1b
	file1bdst.Path = "dst/one"

	r.CheckRemoteItems(t, file1bdst)

	// check old dest, new copy, backup-dir

	ci.BackupDir = r.FremoteName + "/BackupDir"

	file3 := r.WriteObject(ctx, "dst/one", "one", t1)
	file2 := r.WriteObject(ctx, "CopyDest/one", "onet2", t2)
	file1c := r.WriteFile("one", "onet2", t2)
	r.CheckRemoteItems(t, file2, file3)
	r.CheckLocalItems(t, file1c)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file1c.Path, file1c.Path)
	require.NoError(t, err)

	file2dst := file2
	file2dst.Path = "dst/one"
	file3.Path = "BackupDir/one"

	r.CheckRemoteItems(t, file2, file2dst, file3)
	ci.BackupDir = ""

	// check empty dest, new copy
	file4 := r.WriteObject(ctx, "CopyDest/two", "two", t2)
	file5 := r.WriteFile("two", "two", t2)
	r.CheckRemoteItems(t, file2, file2dst, file3, file4)
	r.CheckLocalItems(t, file1c, file5)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file5.Path, file5.Path)
	require.NoError(t, err)

	file4dst := file4
	file4dst.Path = "dst/two"

	r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst)

	// check new dest, new copy
	err = operations.CopyFile(ctx, fdst, r.Flocal, file5.Path, file5.Path)
	require.NoError(t, err)

	r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst)

	// check empty dest, old copy
	file6 := r.WriteObject(ctx, "CopyDest/three", "three", t2)
	file7 := r.WriteFile("three", "threet3", t3)
	r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst, file6)
	r.CheckLocalItems(t, file1c, file5, file7)

	err = operations.CopyFile(ctx, fdst, r.Flocal, file7.Path, file7.Path)
	require.NoError(t, err)

	file7dst := file7
	file7dst.Path = "dst/three"

	r.CheckRemoteItems(t, file2, file2dst, file3, file4, file4dst, file6, file7dst)
}

func TestCopyInplace(t *testing.T) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	r := fstest.NewRun(t)

	if !r.Fremote.Features().PartialUploads {
		t.Skip("Partial uploads not supported")
	}

	ci.Inplace = true

	file1 := r.WriteFile("file1", "file1 contents", t1)
	r.CheckLocalItems(t, file1)

	file2 := file1
	file2.Path = "sub/file2"

	err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)

	err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)

	err = operations.CopyFile(ctx, r.Fremote, r.Fremote, file2.Path, file2.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)
}

func TestCopyLongFileName(t *testing.T) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	r := fstest.NewRun(t)

	if !r.Fremote.Features().PartialUploads {
		t.Skip("Partial uploads not supported")
	}

	ci.Inplace = false // the default

	file1 := r.WriteFile("file1", "file1 contents", t1)
	r.CheckLocalItems(t, file1)

	file2 := file1
	file2.Path = "sub/" + strings.Repeat("file2", 30)

	err := operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)

	err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)

	err = operations.CopyFile(ctx, r.Fremote, r.Fremote, file2.Path, file2.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1)
	r.CheckRemoteItems(t, file2)
}

func TestCopyLongFileNameCollision(t *testing.T) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	r := fstest.NewRun(t)

	if !r.Fremote.Features().PartialUploads {
		t.Skip("Partial uploads not supported")
	}

	ci.Inplace = false
	ci.Transfers = 4

	// Write a lot of identical files with long names
	files := make([]fstest.Item, 10)
	namePrefix := strings.Repeat("file1", 30)
	for i := range files {
		files[i] = r.WriteFile(fmt.Sprintf("%s%02d", namePrefix, i), "file1 contents", t1)
	}
	r.CheckLocalItems(t, files...)

	err := sync.CopyDir(ctx, r.Fremote, r.Flocal, false)
	require.NoError(t, err)
	r.CheckLocalItems(t, files...)
	r.CheckRemoteItems(t, files...)
}

func TestCopyFileMaxTransfer(t *testing.T) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	r := fstest.NewRun(t)
	defer accounting.Stats(ctx).ResetCounters()

	const sizeCutoff = 2048

	// Make random incompressible data
	randomData := make([]byte, sizeCutoff)
	_, err := rand.Read(randomData)
	require.NoError(t, err)
	randomString := string(randomData)

	file1 := r.WriteFile("TestCopyFileMaxTransfer/file1", "file1 contents", t1)
	file2 := r.WriteFile("TestCopyFileMaxTransfer/file2", "file2 contents"+randomString, t2)
	file3 := r.WriteFile("TestCopyFileMaxTransfer/file3", "file3 contents"+randomString, t2)
	file4 := r.WriteFile("TestCopyFileMaxTransfer/file4", "file4 contents"+randomString, t2)

	// Cutoff mode: Hard
	ci.MaxTransfer = sizeCutoff
	ci.CutoffMode = fs.CutoffModeHard

	if runtime.GOOS == "darwin" {
		// disable server-side copies as they don't count towards transfer size stats
		r.Flocal.Features().Disable("Copy")
		if r.Fremote.Features().IsLocal {
			r.Fremote.Features().Disable("Copy")
		}
	}

	// file1: Show a small file gets transferred OK
	accounting.Stats(ctx).ResetCounters()
	err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file1.Path, file1.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1, file2, file3, file4)
	r.CheckRemoteItems(t, file1)

	// file2: show a large file does not get transferred
	accounting.Stats(ctx).ResetCounters()
	err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file2.Path, file2.Path)
	require.NotNil(t, err, "Did not get expected max transfer limit error")
	if !errors.Is(err, accounting.ErrorMaxTransferLimitReachedFatal) {
		t.Log("Expecting error to contain accounting.ErrorMaxTransferLimitReachedFatal")
		// Sometimes the backends or their SDKs don't pass the
		// error through properly, so check that it at least
		// has the text we expect in.
		assert.Contains(t, err.Error(), "max transfer limit reached")
	}
	r.CheckLocalItems(t, file1, file2, file3, file4)
	r.CheckRemoteItems(t, file1)

	// Cutoff mode: Cautious
	ci.CutoffMode = fs.CutoffModeCautious

	// file3: show a large file does not get transferred
	accounting.Stats(ctx).ResetCounters()
	err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file3.Path, file3.Path)
	require.NotNil(t, err)
	assert.True(t, errors.Is(err, accounting.ErrorMaxTransferLimitReachedGraceful))
	r.CheckLocalItems(t, file1, file2, file3, file4)
	r.CheckRemoteItems(t, file1)

	if isChunker(r.Fremote) {
		t.Log("skipping remainder of test for chunker as it involves multiple transfers")
		return
	}

	// Cutoff mode: Soft
	ci.CutoffMode = fs.CutoffModeSoft

	// file4: show a large file does get transferred this time
	accounting.Stats(ctx).ResetCounters()
	err = operations.CopyFile(ctx, r.Fremote, r.Flocal, file4.Path, file4.Path)
	require.NoError(t, err)
	r.CheckLocalItems(t, file1, file2, file3, file4)
	r.CheckRemoteItems(t, file1, file4)
}
