package s3api

import (
	"context"
	"encoding/hex"
	"encoding/xml"
	"fmt"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
	"github.com/stretchr/testify/assert"
	grpc "google.golang.org/grpc"
)

// TestListObjectsWithVersionedObjects tests that versioned objects are properly listed
// This validates the fix for duplicate versioned objects issue
func TestListObjectsWithVersionedObjects(t *testing.T) {
	now := time.Now().Unix()

	// Create test filer client with versioned objects
	filerClient := &testFilerClient{
		entriesByDir: map[string][]*filer_pb.Entry{
			"/buckets/test-bucket": {
				// Regular directory
				{
					Name:        "folder1",
					IsDirectory: true,
					Attributes: &filer_pb.FuseAttributes{
						Mtime: now,
					},
				},
				// .versions directory with metadata for versioned object
				{
					Name:        "file1.txt" + s3_constants.VersionsFolder,
					IsDirectory: true,
					Attributes: &filer_pb.FuseAttributes{
						Mtime: now,
					},
					Extended: map[string][]byte{
						s3_constants.ExtLatestVersionIdKey:    []byte("v1-abc123"),
						s3_constants.ExtLatestVersionSizeKey:  []byte("1234"),
						s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)),
						s3_constants.ExtLatestVersionETagKey:  []byte(fmt.Sprintf("\"%s\"", hex.EncodeToString([]byte("test-etag-1")))),
					},
				},
				// Another .versions directory
				{
					Name:        "file2.txt" + s3_constants.VersionsFolder,
					IsDirectory: true,
					Attributes: &filer_pb.FuseAttributes{
						Mtime: now,
					},
					Extended: map[string][]byte{
						s3_constants.ExtLatestVersionIdKey:    []byte("v2-def456"),
						s3_constants.ExtLatestVersionSizeKey:  []byte("5678"),
						s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)),
						s3_constants.ExtLatestVersionETagKey:  []byte(fmt.Sprintf("\"%s\"", hex.EncodeToString([]byte("test-etag-2")))),
					},
				},
			},
			"/buckets/test-bucket/folder1": {
				// Versioned object in subdirectory
				{
					Name:        "nested.txt" + s3_constants.VersionsFolder,
					IsDirectory: true,
					Attributes: &filer_pb.FuseAttributes{
						Mtime: now,
					},
					Extended: map[string][]byte{
						s3_constants.ExtLatestVersionIdKey:    []byte("v3-ghi789"),
						s3_constants.ExtLatestVersionSizeKey:  []byte("9012"),
						s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)),
						s3_constants.ExtLatestVersionETagKey:  []byte(fmt.Sprintf("\"%s\"", hex.EncodeToString([]byte("test-etag-3")))),
					},
				},
			},
		},
	}

	s3a := &S3ApiServer{
		option: &S3ApiServerOption{
			BucketsPath: "/buckets",
		},
	}

	tests := []struct {
		name             string
		bucket           string
		prefix           string
		delimiter        string
		expectedCount    int
		expectedKeys     []string
		expectedPrefixes []string
	}{
		{
			name:          "List all objects including versioned (no delimiter)",
			bucket:        "test-bucket",
			prefix:        "",
			delimiter:     "",
			expectedCount: 3, // file1.txt, file2.txt, folder1/nested.txt
			expectedKeys: []string{
				"file1.txt",
				"file2.txt",
				"folder1/nested.txt",
			},
			expectedPrefixes: []string{},
		},
		{
			name:          "List bucket root with delimiter",
			bucket:        "test-bucket",
			prefix:        "",
			delimiter:     "/",
			expectedCount: 2, // file1.txt, file2.txt (folder1/ becomes common prefix)
			expectedKeys: []string{
				"file1.txt",
				"file2.txt",
			},
			expectedPrefixes: []string{
				"folder1/",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Directly call doListFilerEntries with a callback to exercise the versioned objects
			// listing logic. The callback mirrors the production listFilerEntries behavior for
			// path extraction and accumulation so that this test validates the internal listing
			// implementation in isolation from the HTTP layer.
			cursor := &ListingCursor{maxKeys: uint16(tt.expectedCount + 10)}
			contents := []ListEntry{}
			commonPrefixes := []PrefixEntry{}
			bucketPrefix := fmt.Sprintf("%s/%s/", s3a.option.BucketsPath, tt.bucket)

			_, err := s3a.doListFilerEntries(context.Background(), filerClient, listDirectoryRequest{dir: bucketPrefix[:len(bucketPrefix)-1], prefix: tt.prefix, delimiter: tt.delimiter, bucket: tt.bucket}, cursor, func(dir string, entry *filer_pb.Entry) {
				if cursor.maxKeys <= 0 {
					return
				}

				if entry.IsDirectory {
					if tt.delimiter == "/" {
						// Extract relative path from bucket prefix
						relDir := strings.TrimPrefix(dir, bucketPrefix[:len(bucketPrefix)-1])
						if relDir != "" && relDir[0] == '/' {
							relDir = relDir[1:]
						}
						prefix := relDir
						if prefix != "" {
							prefix += "/"
						}
						prefix += entry.Name + "/"

						commonPrefixes = append(commonPrefixes, PrefixEntry{
							Prefix: prefix,
						})
						cursor.maxKeys--
					}
				} else {
					// Extract key from dir and entry name
					relDir := strings.TrimPrefix(dir, bucketPrefix[:len(bucketPrefix)-1])
					if relDir != "" && relDir[0] == '/' {
						relDir = relDir[1:]
					}
					key := entry.Name
					if relDir != "" {
						key = relDir + "/" + entry.Name
					}

					contents = append(contents, ListEntry{
						Key: key,
					})
					cursor.maxKeys--
				}
			})

			assert.NoError(t, err, "doListFilerEntries should not return error")
			assert.Equal(t, tt.expectedCount, len(contents), "Should return correct number of objects")
			assert.Equal(t, len(tt.expectedPrefixes), len(commonPrefixes), "Should return correct number of common prefixes")

			// Verify keys
			actualKeys := make([]string, len(contents))
			for i, entry := range contents {
				actualKeys[i] = entry.Key
			}
			assert.ElementsMatch(t, tt.expectedKeys, actualKeys, "Should return expected keys")

			// Verify common prefixes
			actualPrefixes := make([]string, len(commonPrefixes))
			for i, prefix := range commonPrefixes {
				actualPrefixes[i] = prefix.Prefix
			}
			assert.ElementsMatch(t, tt.expectedPrefixes, actualPrefixes, "Should return expected prefixes")

			// Verify each versioned object has correct version metadata
			for _, entry := range contents {
				assert.NotEmpty(t, entry.Key, "Versioned object should have key")
			}
		})
	}
}

// TestVersionedObjectsNoDuplication ensures that .versions directories are only processed once
// This is the core test for the bug fix - previously versioned objects were duplicated 250x
func TestVersionedObjectsNoDuplication(t *testing.T) {
	now := time.Now().Unix()

	// Create a single .versions directory
	filerClient := &testFilerClient{
		entriesByDir: map[string][]*filer_pb.Entry{
			"/buckets/test-bucket": {
				{
					Name:        "test.txt" + s3_constants.VersionsFolder,
					IsDirectory: true,
					Attributes: &filer_pb.FuseAttributes{
						Mtime: now,
					},
					Extended: map[string][]byte{
						s3_constants.ExtLatestVersionIdKey:    []byte("v1-test"),
						s3_constants.ExtLatestVersionSizeKey:  []byte("100"),
						s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)),
						s3_constants.ExtLatestVersionETagKey:  []byte("\"test-etag\""),
					},
				},
			},
		},
	}

	s3a := &S3ApiServer{
		option: &S3ApiServerOption{
			BucketsPath: "/buckets",
		},
	}

	cursor := &ListingCursor{maxKeys: uint16(1000)}
	contents := []ListEntry{}
	_, err := s3a.doListFilerEntries(context.Background(), filerClient, listDirectoryRequest{dir: "/buckets/test-bucket", bucket: "test-bucket"}, cursor, func(dir string, entry *filer_pb.Entry) {
		if cursor.maxKeys <= 0 {
			return
		}
		contents = append(contents, ListEntry{Key: entry.Name})
		cursor.maxKeys--
	})

	assert.NoError(t, err)
	assert.Equal(t, 1, len(contents), "Should return exactly 1 object (no duplicates)")
	assert.Equal(t, "test.txt", contents[0].Key, "Should return correct key")
}

// TestVersionedObjectsWithDeleteMarker tests that objects with delete markers are not listed
func TestVersionedObjectsWithDeleteMarker(t *testing.T) {
	now := time.Now().Unix()

	filerClient := &testFilerClient{
		entriesByDir: map[string][]*filer_pb.Entry{
			"/buckets/test-bucket": {
				// Active versioned object
				{
					Name:        "active.txt" + s3_constants.VersionsFolder,
					IsDirectory: true,
					Attributes: &filer_pb.FuseAttributes{
						Mtime: now,
					},
					Extended: map[string][]byte{
						s3_constants.ExtLatestVersionIdKey:    []byte("v1-active"),
						s3_constants.ExtLatestVersionSizeKey:  []byte("100"),
						s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)),
						s3_constants.ExtLatestVersionETagKey:  []byte("\"etag-active\""),
					},
				},
				// Deleted object (has delete marker)
				{
					Name:        "deleted.txt" + s3_constants.VersionsFolder,
					IsDirectory: true,
					Attributes: &filer_pb.FuseAttributes{
						Mtime: now,
					},
					Extended: map[string][]byte{
						s3_constants.ExtLatestVersionIdKey:          []byte("v1-deleted"),
						s3_constants.ExtLatestVersionIsDeleteMarker: []byte("true"),
					},
				},
			},
		},
	}

	s3a := &S3ApiServer{
		option: &S3ApiServerOption{
			BucketsPath: "/buckets",
		},
	}

	cursor := &ListingCursor{maxKeys: uint16(1000)}
	contents := []ListEntry{}
	_, err := s3a.doListFilerEntries(context.Background(), filerClient, listDirectoryRequest{dir: "/buckets/test-bucket", bucket: "test-bucket"}, cursor, func(dir string, entry *filer_pb.Entry) {
		if cursor.maxKeys <= 0 {
			return
		}
		contents = append(contents, ListEntry{Key: entry.Name})
		cursor.maxKeys--
	})

	assert.NoError(t, err)
	assert.Equal(t, 1, len(contents), "Should only return active object, not deleted")
	assert.Equal(t, "active.txt", contents[0].Key, "Should return the active object")
}

// TestVersionedObjectsMaxKeys tests pagination with versioned objects
func TestVersionedObjectsMaxKeys(t *testing.T) {
	now := time.Now().Unix()

	// Create 5 versioned objects
	entries := make([]*filer_pb.Entry, 5)
	for i := 0; i < 5; i++ {
		entries[i] = &filer_pb.Entry{
			Name:        fmt.Sprintf("file%d.txt"+s3_constants.VersionsFolder, i),
			IsDirectory: true,
			Attributes: &filer_pb.FuseAttributes{
				Mtime: now,
			},
			Extended: map[string][]byte{
				s3_constants.ExtLatestVersionIdKey:    []byte(fmt.Sprintf("v%d", i)),
				s3_constants.ExtLatestVersionSizeKey:  []byte("100"),
				s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)),
				s3_constants.ExtLatestVersionETagKey:  []byte(fmt.Sprintf("\"etag-%d\"", i)),
			},
		}
	}

	filerClient := &testFilerClient{
		entriesByDir: map[string][]*filer_pb.Entry{
			"/buckets/test-bucket": entries,
		},
	}

	s3a := &S3ApiServer{
		option: &S3ApiServerOption{
			BucketsPath: "/buckets",
		},
	}

	cursor := &ListingCursor{maxKeys: uint16(3)}
	contents := []ListEntry{}
	_, err := s3a.doListFilerEntries(context.Background(), filerClient, listDirectoryRequest{dir: "/buckets/test-bucket", bucket: "test-bucket"}, cursor, func(dir string, entry *filer_pb.Entry) {
		if cursor.maxKeys <= 0 {
			return
		}
		contents = append(contents, ListEntry{Key: entry.Name})
		cursor.maxKeys--
	})

	assert.NoError(t, err)
	assert.Equal(t, 3, len(contents), "Should respect maxKeys limit")
	assert.True(t, cursor.isTruncated, "Should set IsTruncated when there are more results")

	// Verify truncation is properly set when maxKeys is exceeded
	// (The test mock doesn't implement marker-based pagination, but we can verify
	// that the cursor state is correct for actual pagination to work)
	assert.True(t, cursor.isTruncated, "IsTruncated should be true when maxKeys is exhausted with more entries available")
}

// TestVersionsDirectoryNotTraversed ensures .versions directories are never traversed
func TestVersionsDirectoryNotTraversed(t *testing.T) {
	now := time.Now().Unix()
	traversedDirs := make(map[string]bool)

	// Custom filer client that tracks which directories are accessed
	customClient := &customTestFilerClient{
		testFilerClient: testFilerClient{
			entriesByDir: map[string][]*filer_pb.Entry{
				"/buckets/test-bucket": {
					{
						Name:        "object.txt" + s3_constants.VersionsFolder,
						IsDirectory: true,
						Attributes: &filer_pb.FuseAttributes{
							Mtime: now,
						},
						Extended: map[string][]byte{
							s3_constants.ExtLatestVersionIdKey:    []byte("v1"),
							s3_constants.ExtLatestVersionSizeKey:  []byte("100"),
							s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)),
							s3_constants.ExtLatestVersionETagKey:  []byte("\"etag\""),
						},
					},
				},
				// This directory should NEVER be accessed
				"/buckets/test-bucket/object.txt.versions": {
					{
						Name:        "should-not-see-this",
						IsDirectory: false,
					},
				},
			},
		},
		traversedDirs: &traversedDirs,
	}

	s3a := &S3ApiServer{
		option: &S3ApiServerOption{
			BucketsPath: "/buckets",
		},
	}

	cursor := &ListingCursor{maxKeys: uint16(1000)}
	contents := []ListEntry{}
	_, err := s3a.doListFilerEntries(context.Background(), customClient, listDirectoryRequest{dir: "/buckets/test-bucket", bucket: "test-bucket"}, cursor, func(dir string, entry *filer_pb.Entry) {
		if cursor.maxKeys <= 0 {
			return
		}
		contents = append(contents, ListEntry{Key: entry.Name})
		cursor.maxKeys--
	})

	assert.NoError(t, err)
	assert.Equal(t, 1, len(contents))

	// Verify .versions directory was NEVER traversed
	_, wasTraversed := traversedDirs["/buckets/test-bucket/object.txt.versions"]
	assert.False(t, wasTraversed, ".versions directory should never be traversed")
}

// customTestFilerClient tracks which directories are accessed
type customTestFilerClient struct {
	testFilerClient
	traversedDirs *map[string]bool
}

func (c *customTestFilerClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
	(*c.traversedDirs)[in.Directory] = true
	return c.testFilerClient.ListEntries(ctx, in, opts...)
}

// TestListObjectVersionsResult_XMLInterleaving validates that Version and DeleteMarker elements
// are interleaved in the correct sort order in the XML output, matching the S3 API contract.
func TestListObjectVersionsResult_XMLInterleaving(t *testing.T) {
	now := time.Now()
	earlier := now.Add(-24 * time.Hour)

	dm := &DeleteMarkerEntry{
		Key:          "object1.txt",
		VersionId:    "newer-dm",
		IsLatest:     true,
		LastModified: now,
	}
	ver := &VersionEntry{
		Key:          "object1.txt",
		VersionId:    "older-version",
		IsLatest:     false,
		LastModified: earlier,
		ETag:         "\"abc\"",
		Size:         100,
		StorageClass: "STANDARD",
	}

	// Entries are in correct sort order: newest first for same key
	result := &S3ListObjectVersionsResult{
		Name:    "test-bucket",
		MaxKeys: 1000,
		Entries: []VersionListEntry{
			{DeleteMarker: dm},
			{Version: ver},
		},
	}

	xmlBytes, err := xml.Marshal(result)
	assert.NoError(t, err)
	xmlStr := string(xmlBytes)

	// Verify DeleteMarker appears BEFORE Version in the XML output
	dmIdx := strings.Index(xmlStr, "<DeleteMarker>")
	vIdx := strings.Index(xmlStr, "<Version>")
	assert.True(t, dmIdx >= 0, "DeleteMarker element should be present")
	assert.True(t, vIdx >= 0, "Version element should be present")
	assert.True(t, dmIdx < vIdx, "DeleteMarker should appear before Version for same key (newest first), got DM at %d, V at %d", dmIdx, vIdx)
}

// TestListObjectVersionsResult_XMLInterleavingMultipleKeys validates interleaving across multiple keys
func TestListObjectVersionsResult_XMLInterleavingMultipleKeys(t *testing.T) {
	now := time.Now()
	earlier := now.Add(-24 * time.Hour)

	dm := &DeleteMarkerEntry{Key: "aaa", VersionId: "dm1", IsLatest: true, LastModified: now}
	v1 := &VersionEntry{Key: "aaa", VersionId: "v1", IsLatest: false, LastModified: earlier, StorageClass: "STANDARD"}
	v2 := &VersionEntry{Key: "bbb", VersionId: "v2", IsLatest: true, LastModified: now, StorageClass: "STANDARD"}

	// Expected XML order: aaa/DM, aaa/V, bbb/V
	result := &S3ListObjectVersionsResult{
		Name:    "test-bucket",
		MaxKeys: 1000,
		Entries: []VersionListEntry{
			{DeleteMarker: dm},
			{Version: v1},
			{Version: v2},
		},
	}

	xmlBytes, err := xml.Marshal(result)
	assert.NoError(t, err)
	xmlStr := string(xmlBytes)

	// Find positions of each element
	dmIdx := strings.Index(xmlStr, "<DeleteMarker>")
	v1Idx := strings.Index(xmlStr, "<Version><Key>aaa</Key>")
	v2Idx := strings.Index(xmlStr, "<Version><Key>bbb</Key>")

	assert.True(t, dmIdx >= 0, "DeleteMarker should be present")
	assert.True(t, v1Idx >= 0, "Version for aaa should be present")
	assert.True(t, v2Idx >= 0, "Version for bbb should be present")
	assert.True(t, dmIdx < v1Idx, "aaa DeleteMarker should appear before aaa Version")
	assert.True(t, v1Idx < v2Idx, "aaa Version should appear before bbb Version")
}

// TestListObjectVersionsResult_XMLCommonPrefixes validates that CommonPrefixes entries
// are correctly marshaled when interleaved with Version and DeleteMarker entries.
func TestListObjectVersionsResult_XMLCommonPrefixes(t *testing.T) {
	now := time.Now()

	ver := &VersionEntry{Key: "docs/readme.txt", VersionId: "v1", IsLatest: true, LastModified: now, StorageClass: "STANDARD"}

	result := &S3ListObjectVersionsResult{
		Name:      "test-bucket",
		MaxKeys:   1000,
		Delimiter: "/",
		Entries: []VersionListEntry{
			{Version: ver},
			{Prefix: &PrefixEntry{Prefix: "images/"}},
			{Prefix: &PrefixEntry{Prefix: "logs/"}},
		},
	}

	xmlBytes, err := xml.Marshal(result)
	assert.NoError(t, err)
	xmlStr := string(xmlBytes)

	// Verify CommonPrefixes elements are present with correct content
	assert.Contains(t, xmlStr, "<CommonPrefixes><Prefix>images/</Prefix></CommonPrefixes>")
	assert.Contains(t, xmlStr, "<CommonPrefixes><Prefix>logs/</Prefix></CommonPrefixes>")

	// Verify ordering: Version before CommonPrefixes (as placed in Entries)
	vIdx := strings.Index(xmlStr, "<Version>")
	cpIdx := strings.Index(xmlStr, "<CommonPrefixes>")
	assert.True(t, vIdx >= 0, "Version element should be present")
	assert.True(t, cpIdx >= 0, "CommonPrefixes element should be present")
	assert.True(t, vIdx < cpIdx, "Version should appear before CommonPrefixes as ordered in Entries")
}

// TestListObjectVersions_PrefixWithLeadingSlash tests that prefixes with leading slashes work correctly
// This validates the fix for the bug where "/Veeam/Archive/" would fail to match relative paths
func TestListObjectVersions_PrefixWithLeadingSlash(t *testing.T) {
	tests := []struct {
		name               string
		inputPrefix        string
		expectedNormalized string
		entryPath          string
		isDirectory        bool
		shouldMatch        bool
	}{
		{
			name:               "Prefix without leading slash matches file",
			inputPrefix:        "Veeam/Archive/",
			expectedNormalized: "Veeam/Archive/",
			entryPath:          "Veeam/Archive/file.txt",
			isDirectory:        false,
			shouldMatch:        true,
		},
		{
			name:               "Prefix with leading slash (bug fix test) - normalized and matches file",
			inputPrefix:        "/Veeam/Archive/",
			expectedNormalized: "Veeam/Archive/",
			entryPath:          "Veeam/Archive/file.txt",
			isDirectory:        false,
			shouldMatch:        true,
		},
		{
			name:               "Normalized prefix matches subdirectory file",
			inputPrefix:        "/Veeam/",
			expectedNormalized: "Veeam/",
			entryPath:          "Veeam/Backup/file.txt",
			isDirectory:        false,
			shouldMatch:        true,
		},
		{
			name:               "Normalized prefix does not match different path",
			inputPrefix:        "/Veeam/Archive/",
			expectedNormalized: "Veeam/Archive/",
			entryPath:          "Veeam/Backup/file.txt",
			isDirectory:        false,
			shouldMatch:        false,
		},
		{
			name:               "Prefix with leading slash allows descending into directory",
			inputPrefix:        "/Veeam/Archive/",
			expectedNormalized: "Veeam/Archive/",
			entryPath:          "Veeam",
			isDirectory:        true,
			shouldMatch:        true, // canDescend is true
		},
		{
			name:               "Prefix with leading slash matches directory with trailing slash",
			inputPrefix:        "/Veeam/",
			expectedNormalized: "Veeam/",
			entryPath:          "Veeam",
			isDirectory:        true,
			shouldMatch:        true, // isMatch becomes true
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// This is the normalization logic from ListObjectVersionsHandler (the fix)
			normalizedPrefix := strings.TrimPrefix(tt.inputPrefix, "/")

			// Verify normalization worked correctly
			assert.Equal(t, tt.expectedNormalized, normalizedPrefix,
				"Prefix normalization should strip leading slash")

			// This simulates the full matchesPrefixFilter logic used in findVersionsRecursively
			isMatch := strings.HasPrefix(tt.entryPath, normalizedPrefix)
			if !isMatch && tt.isDirectory {
				isMatch = strings.HasPrefix(tt.entryPath+"/", normalizedPrefix)
			}
			canDescend := tt.isDirectory && strings.HasPrefix(normalizedPrefix, tt.entryPath)
			matches := isMatch || canDescend

			assert.Equal(t, tt.shouldMatch, matches,
				"Normalized prefix should correctly match/not match the path based on full filter logic")
		})
	}
}

func TestComputeStartFrom(t *testing.T) {
	tests := []struct {
		name          string
		keyMarker     string
		relativePath  string
		wantStart     string
		wantInclusive bool
	}{
		{"empty marker", "", "", "", false},
		{"empty marker with path", "", "dir", "", false},
		{"root level file", "file1.txt", "", "file1.txt", true},
		{"root level with subpath", "Mailboxes/5ac/file1", "", "Mailboxes", true},
		{"matching subdir", "Mailboxes/5ac/file1", "Mailboxes", "5ac", true},
		{"deeper subdir", "Mailboxes/5ac/ItemsData/file1", "Mailboxes/5ac", "ItemsData", true},
		{"at leaf level", "Mailboxes/5ac/ItemsData/file1", "Mailboxes/5ac/ItemsData", "file1", true},
		{"unrelated directory", "other/path", "Mailboxes", "", false},
		{"marker equals relativePath", "Mailboxes", "Mailboxes", "", false},
		{"marker before directory", "aaa/file", "zzz", "", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			vc := &versionCollector{keyMarker: tt.keyMarker}
			startFrom, inclusive := vc.computeStartFrom(tt.relativePath)
			assert.Equal(t, tt.wantStart, startFrom)
			assert.Equal(t, tt.wantInclusive, inclusive)
		})
	}
}

func TestProcessDirectorySkipsBeforeMarker(t *testing.T) {
	tests := []struct {
		name       string
		keyMarker  string
		entryPath  string
		shouldSkip bool
	}{
		{"no marker", "", "dir_a", false},
		{"dir before marker", "dir_b/file", "dir_a", true},
		{"marker descends into dir", "dir_a/file", "dir_a", false},
		{"dir after marker", "dir_a/file", "dir_b", false},
		{"same prefix different suffix", "dir_a0/file", "dir_a", true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			skip := tt.keyMarker != "" &&
				!strings.HasPrefix(tt.keyMarker, tt.entryPath+"/") &&
				tt.entryPath+"/" < tt.keyMarker
			assert.Equal(t, tt.shouldSkip, skip)
		})
	}
}

// TestVersionedListSkipsMarkerObject covers the exclusive marker on a versioned bucket,
// where the marker names an object but the entry is its ".versions" directory.
func TestVersionedListSkipsMarkerObject(t *testing.T) {
	s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
	client := &testFilerClient{
		entriesByDir: map[string][]*filer_pb.Entry{
			"/buckets/test-bucket": {
				liveVersionsDir("file-1"),
				liveVersionsDir("file-2"),
			},
		},
	}

	cursor := &ListingCursor{maxKeys: 1000}
	var seen []string
	_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/test-bucket", marker: "file-1", bucket: "test-bucket"}, cursor, func(dir string, entry *filer_pb.Entry) {
		seen = append(seen, entry.Name)
		cursor.maxKeys--
	})

	assert.NoError(t, err)
	assert.Equal(t, []string{"file-2"}, seen, "the marker object should not be returned")
}

// TestVersionedListSkipsEchoedVersionsMarker covers a backend that echoes the marker it
// was given, when that marker is a ".versions" directory name.
func TestVersionedListSkipsEchoedVersionsMarker(t *testing.T) {
	s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
	client := &markerEchoFilerClient{
		entriesByDir: map[string][]*filer_pb.Entry{
			"/buckets/test-bucket": {
				liveVersionsDir("file-1"),
				liveVersionsDir("file-2"),
			},
		},
		returnFollowing: true,
	}

	cursor := &ListingCursor{maxKeys: 1000}
	var seen []string
	_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/test-bucket", marker: "file-1" + s3_constants.VersionsFolder, bucket: "test-bucket"}, cursor, func(dir string, entry *filer_pb.Entry) {
		seen = append(seen, entry.Name)
		cursor.maxKeys--
	})

	assert.NoError(t, err)
	assert.Equal(t, []string{"file-2"}, seen, "the echoed marker entry should not be returned")
}
