package mount

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/seaweedfs/go-fuse/v2/fuse"
	"github.com/seaweedfs/seaweedfs/weed/filer"
	"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
	"github.com/seaweedfs/seaweedfs/weed/pb"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/util"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

// pagingSink collects names, accepting at most limit per round the way a kernel
// reply buffer does.
type pagingSink struct {
	limit   int
	names   []string
	lastOff uint64
	round   int
}

func (s *pagingSink) AddEntry(entry fuse.DirEntry) bool {
	if s.round >= s.limit {
		return false
	}
	s.round++
	s.names = append(s.names, entry.Name)
	s.lastOff = entry.Off
	return true
}

func (s *pagingSink) AddEntryPlus(entry fuse.DirEntry) *fuse.EntryOut { return nil }
func (s *pagingSink) TakesLookupRef() bool                            { return true }

func newPagingWFS(tb testing.TB, dir util.FullPath, names []string, ttlSec int32) *WFS {
	tb.Helper()
	uidGidMapper, err := meta_cache.NewUidGidMapper("", "")
	if err != nil {
		tb.Fatalf("uid/gid mapper: %v", err)
	}
	root := util.FullPath("/")
	wfs := &WFS{
		option: &Option{
			ChunkSizeLimit: 1024, ConcurrentReaders: 1, VolumeServerAccess: "filerProxy",
			FilerAddresses:     []pb.ServerAddress{pb.NewServerAddressWithGrpcPort("127.0.0.1:1", 1)},
			GrpcDialOption:     grpc.WithTransportCredentials(insecure.NewCredentials()),
			FilerMountRootPath: "/", MountUid: 99, MountGid: 100, MountMode: 0o777,
			MountMtime: time.Now(), MountCtime: time.Now(), UidGidMapper: uidGidMapper,
		},
		signature: 1, inodeToPath: NewInodeToPath(root, 0),
		fhMap: NewFileHandleToInode(), dhMap: NewDirectoryHandleToInode(),
		fhLockTable: util.NewLockTable[FileHandleId](), hardLinkLockTable: util.NewLockTable[string](),
	}
	wfs.metaCache = meta_cache.NewMetaCache(
		filepath.Join(tb.TempDir(), "meta"), uidGidMapper, root, false,
		func(p util.FullPath) { wfs.inodeToPath.MarkChildrenCached(p) },
		func(p util.FullPath) bool { return wfs.inodeToPath.IsChildrenCached(p) },
		func(meta_cache.EntryInvalidation) {}, nil,
	)
	tb.Cleanup(wfs.metaCache.Shutdown)

	now := time.Now()
	ctx := context.Background()
	if err := wfs.metaCache.InsertEntry(ctx, &filer.Entry{
		FullPath: dir,
		Attr:     filer.Attr{Mode: os.ModeDir | 0o755, Mtime: now, Crtime: now},
	}, 0); err != nil {
		tb.Fatalf("insert dir: %v", err)
	}
	for _, name := range names {
		child := dir.Child(name)
		attr := filer.Attr{Mode: 0o644, Mtime: now, Crtime: now, FileSize: 1, Inode: child.AsInode(now.Unix())}
		if ttlSec > 0 {
			// Expired well in the past, so the meta cache drops it after the
			// store has already counted it against the limit.
			attr.TtlSec = ttlSec
			attr.Crtime = now.Add(-time.Duration(ttlSec+60) * time.Second)
		}
		if err := wfs.metaCache.InsertEntry(ctx, &filer.Entry{FullPath: child, Attr: attr}, 0); err != nil {
			tb.Fatalf("insert %s: %v", name, err)
		}
	}
	wfs.inodeToPath.MarkChildrenCached(root)
	wfs.inodeToPath.Lookup(dir, now.Unix(), true, false, 0, true)
	wfs.inodeToPath.MarkChildrenCached(dir)
	return wfs
}

// TestReadDirResumePastShrunkDirectory covers a client resuming a fresh handle
// at a cookie from an earlier, larger listing. The directory has since shrunk
// past that offset, and the readdir used to fall back to listing from the first
// child again, handing back names the client had already consumed.
func TestReadDirResumePastShrunkDirectory(t *testing.T) {
	dir := util.FullPath("/d")
	var names []string
	for i := 0; i < 100; i++ {
		names = append(names, fmt.Sprintf("f%03d", i))
	}
	wfs := newPagingWFS(t, dir, names, 0)
	dirInode, _ := wfs.inodeToPath.GetInode(dir)

	dhid, _ := wfs.AcquireDirectoryHandle()
	defer wfs.ReleaseDirectoryHandle(dhid)

	sink := &pagingSink{limit: 4096}
	status := wfs.doReadDirectory(&fuse.ReadIn{
		InHeader: fuse.InHeader{NodeId: dirInode},
		Fh:       uint64(dhid),
		Offset:   152, // past the end of a directory that now holds 100
		Size:     1 << 20,
	}, sink, false)
	if status != fuse.OK {
		t.Fatalf("readdir: %v", status)
	}
	if len(sink.names) != 0 {
		t.Errorf("resuming past the end returned %d entries (first %q), want none", len(sink.names), sink.names[0])
	}
}

// TestReadDirWithExpiredEntries covers a batch the store fills to the limit but
// whose entries the meta cache then drops as expired. The post-filter count used
// to be read as end-of-directory, silently hiding every later child.
func TestReadDirWithExpiredEntries(t *testing.T) {
	dir := util.FullPath("/d")
	// One full store batch of entries that all expire, then live ones behind
	// them. Nothing survives the first batch, which is the case that latched.
	var names []string
	for i := 0; i < batchSize; i++ {
		names = append(names, fmt.Sprintf("a%05d", i))
	}
	wfs := newPagingWFS(t, dir, names, 30)

	live := []string{"z001", "z002", "z003"}
	now := time.Now()
	for _, name := range live {
		child := dir.Child(name)
		if err := wfs.metaCache.InsertEntry(context.Background(), &filer.Entry{
			FullPath: child,
			Attr:     filer.Attr{Mode: 0o644, Mtime: now, Crtime: now, FileSize: 1, Inode: child.AsInode(now.Unix())},
		}, 0); err != nil {
			t.Fatalf("insert %s: %v", name, err)
		}
	}

	dirInode, _ := wfs.inodeToPath.GetInode(dir)
	dhid, _ := wfs.AcquireDirectoryHandle()
	defer wfs.ReleaseDirectoryHandle(dhid)

	sink := &pagingSink{limit: 4096}
	var offset uint64
	for round := 0; round < 20; round++ {
		sink.round = 0
		status := wfs.doReadDirectory(&fuse.ReadIn{
			InHeader: fuse.InHeader{NodeId: dirInode},
			Fh:       uint64(dhid),
			Offset:   offset,
			Size:     1 << 20,
		}, sink, false)
		if status != fuse.OK {
			t.Fatalf("readdir: %v", status)
		}
		if sink.lastOff <= offset {
			break
		}
		offset = sink.lastOff
	}

	var seen []string
	for _, n := range sink.names {
		if n != "." && n != ".." {
			seen = append(seen, n)
		}
	}
	if len(seen) != len(live) {
		t.Fatalf("listed %d entries %v, want the %d live ones %v", len(seen), seen, len(live), live)
	}
}

// TestReadDirTrimsConsumedEntries checks that a walk does not accumulate the
// whole directory in the handle. Offsets index into the stream from
// entryStreamOffset, so the two have to advance together or the listing
// silently misaligns.
func TestReadDirTrimsConsumedEntries(t *testing.T) {
	dir := util.FullPath("/d")
	const total = 5000
	var names []string
	for i := 0; i < total; i++ {
		names = append(names, fmt.Sprintf("f%05d", i))
	}
	wfs := newPagingWFS(t, dir, names, 0)
	dirInode, _ := wfs.inodeToPath.GetInode(dir)

	dhid, dh := wfs.AcquireDirectoryHandle()
	defer wfs.ReleaseDirectoryHandle(dhid)

	// A small sink forces many rounds, which is when the stream would grow.
	sink := &pagingSink{limit: 64}
	var offset uint64
	var seen []string
	peak := 0
	for round := 0; round < 500; round++ {
		sink.round = 0
		before := len(sink.names)
		status := wfs.doReadDirectory(&fuse.ReadIn{
			InHeader: fuse.InHeader{NodeId: dirInode},
			Fh:       uint64(dhid),
			Offset:   offset,
			Size:     1 << 20,
		}, sink, false)
		if status != fuse.OK {
			t.Fatalf("readdir: %v", status)
		}
		if n := len(dh.entryStream); n > peak {
			peak = n
		}
		if len(sink.names) == before || sink.lastOff <= offset {
			break
		}
		offset = sink.lastOff
	}
	for _, n := range sink.names {
		if n != "." && n != ".." {
			seen = append(seen, n)
		}
	}

	if len(seen) != total {
		t.Fatalf("listed %d entries, want %d", len(seen), total)
	}
	for i, name := range seen {
		if want := fmt.Sprintf("f%05d", i); name != want {
			t.Fatalf("entry %d is %q, want %q -- trimming misaligned the offsets", i, name, want)
		}
	}
	// Without trimming the handle ends up holding every entry.
	if peak >= total {
		t.Errorf("handle held %d entries at peak, want well under the %d in the directory", peak, total)
	}
}

// listingFilerServer serves a fixed, sorted child list the way the filer
// paginates one.
type listingFilerServer struct {
	filer_pb.UnimplementedSeaweedFilerServer
	names []string
}

func (s *listingFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream filer_pb.SeaweedFiler_ListEntriesServer) error {
	var sent uint32
	for _, name := range s.names {
		if name < req.StartFromFileName || (name == req.StartFromFileName && !req.InclusiveStartFrom) {
			continue
		}
		if req.Limit > 0 && sent >= req.Limit {
			break
		}
		if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: &filer_pb.Entry{
			Name:       name,
			Attributes: &filer_pb.FuseAttributes{FileMode: 0o644, FileSize: 1},
		}}); err != nil {
			return err
		}
		sent++
	}
	return nil
}

// TestReadDirDirectTrimsConsumedEntries is the same check for a directory read
// through to the filer. That path is taken precisely for directories too big to
// cache, so holding the whole walk in the handle is where a listing turns into
// gigabytes of resident memory.
func TestReadDirDirectTrimsConsumedEntries(t *testing.T) {
	dir := util.FullPath("/d")
	const total = 5000
	var names []string
	for i := 0; i < total; i++ {
		names = append(names, fmt.Sprintf("f%05d", i))
	}
	wfs := newPagingWFS(t, dir, nil, 0)
	startFakeFiler(t, wfs, &listingFilerServer{names: names})
	dirInode, _ := wfs.inodeToPath.GetInode(dir)
	if !wfs.inodeToPath.MarkDirectoryReadThrough(dir, time.Now()) {
		t.Fatal("directory did not enter read-through mode")
	}

	dhid, dh := wfs.AcquireDirectoryHandle()
	defer wfs.ReleaseDirectoryHandle(dhid)

	sink := &pagingSink{limit: 64}
	var offset uint64
	var seen []string
	peak := 0
	for round := 0; round < 500; round++ {
		sink.round = 0
		before := len(sink.names)
		status := wfs.doReadDirectory(&fuse.ReadIn{
			InHeader: fuse.InHeader{NodeId: dirInode},
			Fh:       uint64(dhid),
			Offset:   offset,
			Size:     1 << 20,
		}, sink, false)
		if status != fuse.OK {
			t.Fatalf("readdir: %v", status)
		}
		if n := len(dh.entryStream); n > peak {
			peak = n
		}
		if len(sink.names) == before || sink.lastOff <= offset {
			break
		}
		offset = sink.lastOff
	}
	for _, n := range sink.names {
		if n != "." && n != ".." {
			seen = append(seen, n)
		}
	}

	if len(seen) != total {
		t.Fatalf("listed %d entries, want %d", len(seen), total)
	}
	for i, name := range seen {
		if want := fmt.Sprintf("f%05d", i); name != want {
			t.Fatalf("entry %d is %q, want %q -- trimming misaligned the offsets", i, name, want)
		}
	}
	if peak >= total {
		t.Errorf("handle held %d entries at peak, want well under the %d in the directory", peak, total)
	}
}

// The table has to key on the sanitized name, not the stored bytes.
func TestReadDirPlusSanitizedName(t *testing.T) {
	dir := util.FullPath("/images")
	const rawName = "bad\xffname.jpg"
	wfs := newPagingWFS(t, dir, []string{"good.jpg", rawName}, 0)
	dirInode, _ := wfs.inodeToPath.GetInode(dir)

	sink := &benchSink{plus: true, takesRef: true, sinkLimit: 16}
	if got := walkOnce(t, wfs, dirInode, sink, false); got != 4 {
		t.Fatalf("listed %d entries, want 4 (. .. and two children)", got)
	}

	sanitized := dir.Child(util.SanitizeUTF8Name(rawName))
	if !wfs.inodeToPath.HasPath(sanitized) {
		t.Errorf("%s not in the inode table", sanitized)
	}
	if wfs.inodeToPath.HasPath(dir.Child(rawName)) {
		t.Errorf("%s keyed on the unsanitized name", dir.Child(rawName))
	}
}
