package list

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

	"github.com/rclone/rclone/fs"
	"github.com/rclone/rclone/fstest/mockdir"
	"github.com/rclone/rclone/fstest/mockobject"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestSorter(t *testing.T) {
	ctx := context.Background()
	da := mockdir.New("a")
	oA := mockobject.Object("A")
	callback := func(entries fs.DirEntries) error {
		require.Equal(t, fs.DirEntries{oA, da}, entries)
		return nil
	}
	ls, err := NewSorter(ctx, nil, callback, nil)
	require.NoError(t, err)
	assert.Equal(t, fmt.Sprintf("%p", callback), fmt.Sprintf("%p", ls.callback))
	assert.Equal(t, fmt.Sprintf("%p", identityKeyFn), fmt.Sprintf("%p", ls.keyFn))
	assert.Equal(t, fs.DirEntries(nil), ls.entries)

	// Test Add
	err = ls.Add(fs.DirEntries{da})
	require.NoError(t, err)
	assert.Equal(t, fs.DirEntries{da}, ls.entries)
	err = ls.Add(fs.DirEntries{oA})
	require.NoError(t, err)
	assert.Equal(t, fs.DirEntries{da, oA}, ls.entries)

	// Test Send
	err = ls.Send()
	require.NoError(t, err)

	// Test Cleanup
	ls.CleanUp()
	assert.Equal(t, fs.DirEntries(nil), ls.entries)
}

func TestSorterIdentity(t *testing.T) {
	ctx := context.Background()
	cmpFn := func(a, b fs.DirEntry) int {
		return cmp.Compare(a.Remote(), b.Remote())
	}
	callback := func(entries fs.DirEntries) error {
		assert.True(t, slices.IsSortedFunc(entries, cmpFn))
		assert.Equal(t, "a", entries[0].Remote())
		return nil
	}
	ls, err := NewSorter(ctx, nil, callback, nil)
	require.NoError(t, err)
	defer ls.CleanUp()

	// Add things in reverse alphabetical order
	for i := 'z'; i >= 'a'; i-- {
		err = ls.Add(fs.DirEntries{mockobject.Object(string(i))})
		require.NoError(t, err)
	}
	assert.Equal(t, "z", ls.entries[0].Remote())
	assert.False(t, slices.IsSortedFunc(ls.entries, cmpFn))

	// Check they get sorted
	err = ls.Send()
	require.NoError(t, err)
}

func TestSorterKeyFn(t *testing.T) {
	ctx := context.Background()
	keyFn := func(entry fs.DirEntry) string {
		s := entry.Remote()
		return string('z' - s[0])
	}
	cmpFn := func(a, b fs.DirEntry) int {
		return cmp.Compare(keyFn(a), keyFn(b))
	}
	callback := func(entries fs.DirEntries) error {
		assert.True(t, slices.IsSortedFunc(entries, cmpFn))
		assert.Equal(t, "z", entries[0].Remote())
		return nil
	}
	ls, err := NewSorter(ctx, nil, callback, keyFn)
	require.NoError(t, err)
	defer ls.CleanUp()

	// Add things in reverse sorted order
	for i := 'a'; i <= 'z'; i++ {
		err = ls.Add(fs.DirEntries{mockobject.Object(string(i))})
		require.NoError(t, err)
	}
	assert.Equal(t, "a", ls.entries[0].Remote())
	assert.False(t, slices.IsSortedFunc(ls.entries, cmpFn))

	// Check they get sorted
	err = ls.Send()
	require.NoError(t, err)
}

// testFs implements enough of the fs.Fs interface for Sorter
type testFs struct {
	t          *testing.T
	entriesMap map[string]fs.DirEntry
}

// NewObject finds the Object at remote.  If it can't be found
// it returns the error ErrorObjectNotFound.
func (f *testFs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
	entry, ok := f.entriesMap[remote]
	assert.True(f.t, ok, "entry not found")
	if !ok {
		return nil, fs.ErrorObjectNotFound
	}
	obj, ok := entry.(fs.Object)
	assert.True(f.t, ok, "expected entry to be object: %#v", entry)
	if !ok {
		return nil, fs.ErrorObjectNotFound
	}
	return obj, nil
}

// String outputs info about the Fs
func (f *testFs) String() string {
	return "testFs"
}

// used to sort the entries case insensitively
func keyCaseInsensitive(entry fs.DirEntry) string {
	return strings.ToLower(entry.Remote())
}

// Test the external sorting
func testSorterExt(t *testing.T, cutoff, N int, wantExtSort bool, keyFn KeyFn) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	ci.ListCutoff = cutoff

	// Make the directory entries
	entriesMap := make(map[string]fs.DirEntry, N)
	for i := range N {
		remote := fmt.Sprintf("%010d", i)
		prefix := "a"
		if i%3 == 0 {
			prefix = "A"
		}
		remote = prefix + remote
		if i%2 == 0 {
			entriesMap[remote] = mockobject.New(remote)
		} else {
			entriesMap[remote] = mockdir.New(remote)
		}
	}
	assert.Equal(t, N, len(entriesMap))
	f := &testFs{t: t, entriesMap: entriesMap}

	// In the callback delete entries from the map when they are
	// found
	prevKey := ""
	callback := func(entries fs.DirEntries) error {
		for _, gotEntry := range entries {
			remote := gotEntry.Remote()
			key := remote
			if keyFn != nil {
				key = keyFn(gotEntry)
			}
			require.Less(t, prevKey, key, "Not sorted")
			prevKey = key
			wantEntry, ok := entriesMap[remote]
			assert.True(t, ok, "Entry not found %q", remote)
			_, wantDir := wantEntry.(fs.Directory)
			_, gotDir := wantEntry.(fs.Directory)
			_, wantObj := wantEntry.(fs.Object)
			_, gotObj := wantEntry.(fs.Object)
			require.True(t, (wantDir && gotDir) || (wantObj && gotObj), "Wrong types %#v, %#v", wantEntry, gotEntry)
			delete(entriesMap, remote)
		}
		return nil
	}

	ls, err := NewSorter(ctx, f, callback, keyFn)
	require.NoError(t, err)

	// Send the entries in random (map) order
	for _, entry := range entriesMap {
		err = ls.Add(fs.DirEntries{entry})
		require.NoError(t, err)
	}

	// Check we are extsorting if required
	assert.Equal(t, wantExtSort, ls.extSort)

	// Test Send
	err = ls.Send()
	require.NoError(t, err)

	// All the entries should have been seen
	assert.Equal(t, 0, len(entriesMap))

	// Test Cleanup
	ls.CleanUp()
	assert.Equal(t, fs.DirEntries(nil), ls.entries)
}

// Test the external sorting
func TestSorterExt(t *testing.T) {
	for _, test := range []struct {
		cutoff      int
		N           int
		wantExtSort bool
		keyFn       KeyFn
	}{
		{cutoff: 1000, N: 100, wantExtSort: false},
		{cutoff: 100, N: 1000, wantExtSort: true},
		{cutoff: 1000, N: 100, wantExtSort: false, keyFn: keyCaseInsensitive},
		{cutoff: 100, N: 1000, wantExtSort: true, keyFn: keyCaseInsensitive},
		{cutoff: 100001, N: 100000, wantExtSort: false},
		{cutoff: 100000, N: 100001, wantExtSort: true},
		// {cutoff: 100_000, N: 1_000_000, wantExtSort: true},
		// {cutoff: 100_000, N: 10_000_000, wantExtSort: true},
	} {
		t.Run(fmt.Sprintf("cutoff=%d,N=%d,wantExtSort=%v,keyFn=%v", test.cutoff, test.N, test.wantExtSort, test.keyFn != nil), func(t *testing.T) {
			testSorterExt(t, test.cutoff, test.N, test.wantExtSort, test.keyFn)
		})
	}
}

// Test that startExtSort returns an error instead of panicking when
// temp file creation fails (e.g. due to permissions or apparmor).
// See: https://github.com/rclone/rclone/issues/9244
func TestSorterExtTempFileError(t *testing.T) {
	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	ci.ListCutoff = 1 // force ext sort on first Add

	callback := func(entries fs.DirEntries) error {
		return nil
	}
	ls, err := NewSorter(ctx, nil, callback, nil)
	require.NoError(t, err)
	defer ls.CleanUp()

	// Override tempDir to a path where os.MkdirAll will fail,
	// simulating apparmor or permission denial. This makes
	// extsort.Strings return a nil sorter.
	if runtime.GOOS == "windows" {
		// On Windows, create a regular file and use a child path.
		// os.MkdirAll fails because an intermediate component is a file.
		// Windows stat returns ERROR_PATH_NOT_FOUND (treated as
		// IsNotExist) so the extsort library won't fall back to a
		// default directory.
		blocker := filepath.Join(t.TempDir(), "notadir")
		require.NoError(t, os.WriteFile(blocker, []byte("x"), 0644))
		ls.tempDir = filepath.Join(blocker, "sub")
	} else {
		// On Unix, create a read-only parent directory. stat on the
		// child returns ENOENT so the extsort library won't fall
		// back, but os.MkdirAll fails with EACCES.
		parent := filepath.Join(t.TempDir(), "roparent")
		require.NoError(t, os.Mkdir(parent, 0o755))
		require.NoError(t, os.Chmod(parent, 0o555))
		t.Cleanup(func() { _ = os.Chmod(parent, 0o755) })
		ls.tempDir = filepath.Join(parent, "child")
	}

	// Add enough entries to trigger ext sort. Before the fix this
	// would panic with nil pointer dereference.
	err = ls.Add(fs.DirEntries{mockobject.Object("a"), mockobject.Object("b")})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "sorter:")
}

// benchFs implements enough of the fs.Fs interface for Sorter
type benchFs struct{}

// NewObject finds the Object at remote.  If it can't be found
// it returns the error ErrorObjectNotFound.
func (benchFs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
	// Recreate the mock objects
	return mockobject.New(remote), nil
}

// String outputs info about the Fs
func (benchFs) String() string {
	return "benchFs"
}

func BenchmarkSorterExt(t *testing.B) {
	const cutoff = 1000
	const N = 10_000_000

	ctx := context.Background()
	ctx, ci := fs.AddConfig(ctx)
	ci.ListCutoff = cutoff
	keyFn := keyCaseInsensitive

	// In the callback check entries are in order
	prevKey := ""
	entriesReceived := 0
	callback := func(entries fs.DirEntries) error {
		for _, gotEntry := range entries {
			remote := gotEntry.Remote()
			key := remote
			if keyFn != nil {
				key = keyFn(gotEntry)
			}
			require.Less(t, prevKey, key, "Not sorted")
			prevKey = key
			entriesReceived++
		}
		return nil
	}

	f := benchFs{}
	ls, err := NewSorter(ctx, f, callback, keyFn)
	require.NoError(t, err)

	// Send the entries in reverse order in batches of 1000 like the backends do
	var entries = make(fs.DirEntries, 0, 1000)
	for i := N - 1; i >= 0; i-- {
		remote := fmt.Sprintf("%050d", i) // UUID length plus a bit
		prefix := "a"
		if i%3 == 0 {
			prefix = "A"
		}
		remote = prefix + remote
		if i%2 == 0 {
			entries = append(entries, mockobject.New(remote))
		} else {
			entries = append(entries, mockdir.New(remote))
		}
		if len(entries) > 1000 {
			err = ls.Add(entries)
			require.NoError(t, err)
			entries = entries[:0]
		}
	}
	err = ls.Add(entries)
	require.NoError(t, err)

	// Check we are extsorting
	assert.True(t, ls.extSort)

	// Test Send
	err = ls.Send()
	require.NoError(t, err)

	// All the entries should have been seen
	assert.Equal(t, N, entriesReceived)

	// Cleanup
	ls.CleanUp()
}
