package sys

import (
	"embed"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path"
	"testing"
	gofstest "testing/fstest"

	"github.com/tetratelabs/wazero/experimental/sys"
	"github.com/tetratelabs/wazero/internal/fstest"
	"github.com/tetratelabs/wazero/internal/sysfs"
	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
	"github.com/tetratelabs/wazero/internal/testing/require"
)

//go:embed testdata
var testdata embed.FS

func TestNewFSContext(t *testing.T) {
	embedFS, err := fs.Sub(testdata, "testdata")
	require.NoError(t, err)

	dirfs := sysfs.DirFS(".")

	// Test various usual configuration for the file system.
	tests := []struct {
		name string
		fs   sys.FS
	}{
		{
			name: "embed.FS",
			fs:   &sysfs.AdaptFS{FS: embedFS},
		},
		{
			name: "DirFS",
			// Don't use "testdata" because it may not be present in
			// cross-architecture (a.k.a. scratch) build containers.
			fs: dirfs,
		},
		{
			name: "ReadFS",
			fs:   &sysfs.ReadFS{FS: dirfs},
		},
		{
			name: "fstest.MapFS",
			fs:   &sysfs.AdaptFS{FS: gofstest.MapFS{}},
		},
	}

	for _, tt := range tests {
		tc := tt

		t.Run(tc.name, func(t *testing.T) {
			for _, root := range []string{"/", ""} {
				t.Run(fmt.Sprintf("root = '%s'", root), func(t *testing.T) {
					c := Context{}
					err := c.InitFSContext(nil, nil, nil, []sys.FS{tc.fs}, []string{root}, nil)
					require.NoError(t, err)
					fsc := c.fsc
					defer fsc.Close()

					preopenedDir, _ := fsc.openedFiles.Lookup(FdPreopen)
					require.Equal(t, tc.fs, fsc.rootFS)
					require.NotNil(t, preopenedDir)
					require.Equal(t, "/", preopenedDir.Name)

					// Verify that each call to OpenFile returns a different file
					// descriptor.
					f1, errno := fsc.OpenFile(preopenedDir.FS, preopenedDir.Name, 0, 0)
					require.EqualErrno(t, 0, errno)
					require.NotEqual(t, FdPreopen, f1)

					// Verify that file descriptors are reused.
					//
					// Note that this specific behavior is not required by WASI which
					// only documents that file descriptor numbers will be selected
					// randomly and applications should not rely on them. We added this
					// test to ensure that our implementation properly reuses descriptor
					// numbers but if we were to change the reuse strategy, this test
					// would likely break and need to be updated.
					require.EqualErrno(t, 0, fsc.CloseFile(f1))
					f2, errno := fsc.OpenFile(preopenedDir.FS, preopenedDir.Name, 0, 0)
					require.EqualErrno(t, 0, errno)
					require.Equal(t, f1, f2)
				})
			}
		})

	}
}

func TestFSContext_CloseFile(t *testing.T) {
	embedFS, err := fs.Sub(testdata, "testdata")
	require.NoError(t, err)
	testFS := &sysfs.AdaptFS{FS: embedFS}

	c := Context{}
	err = c.InitFSContext(nil, nil, nil, []sys.FS{testFS}, []string{"/"}, nil)
	require.NoError(t, err)
	fsc := c.fsc
	defer fsc.Close()

	fdToClose, errno := fsc.OpenFile(testFS, "empty.txt", sys.O_RDONLY, 0)
	require.EqualErrno(t, 0, errno)

	fdToKeep, errno := fsc.OpenFile(testFS, "test.txt", sys.O_RDONLY, 0)
	require.EqualErrno(t, 0, errno)

	// Close
	require.EqualErrno(t, 0, fsc.CloseFile(fdToClose))

	// Verify fdToClose is closed and removed from the opened FDs.
	_, ok := fsc.LookupFile(fdToClose)
	require.False(t, ok)

	// Verify fdToKeep is not closed
	_, ok = fsc.LookupFile(fdToKeep)
	require.True(t, ok)

	t.Run("EBADF for an invalid FD", func(t *testing.T) {
		require.EqualErrno(t, sys.EBADF, fsc.CloseFile(42)) // 42 is an arbitrary invalid FD
	})
	t.Run("Can close a pre-open", func(t *testing.T) {
		require.EqualErrno(t, 0, fsc.CloseFile(FdPreopen))
	})
}

func TestFSContext_noPreopens(t *testing.T) {
	c := Context{}
	err := c.InitFSContext(nil, nil, nil, nil, nil, nil)
	require.NoError(t, err)
	testFS := &c.fsc
	require.NoError(t, err)

	expected := &FSContext{}
	noopStdin, _ := stdinFileEntry(nil)
	expected.openedFiles.Insert(noopStdin)
	noopStdout, _ := stdioWriterFileEntry("stdout", nil)
	expected.openedFiles.Insert(noopStdout)
	noopStderr, _ := stdioWriterFileEntry("stderr", nil)
	expected.openedFiles.Insert(noopStderr)

	t.Run("Close closes", func(t *testing.T) {
		err := testFS.Close()
		require.NoError(t, err)

		// Closes opened files
		require.Equal(t, &FSContext{}, testFS)
	})
}

func TestContext_Close(t *testing.T) {
	testFS := &sysfs.AdaptFS{FS: testfs.FS{"foo": &testfs.File{}}}

	c := Context{}
	err := c.InitFSContext(nil, nil, nil, []sys.FS{testFS}, []string{"/"}, nil)
	require.NoError(t, err)
	fsc := c.fsc

	// Verify base case
	require.Equal(t, 1+FdPreopen, int32(fsc.openedFiles.Len()))

	_, errno := fsc.OpenFile(testFS, "foo", sys.O_RDONLY, 0)
	require.EqualErrno(t, 0, errno)
	require.Equal(t, 2+FdPreopen, int32(fsc.openedFiles.Len()))

	// Closing should not err.
	require.NoError(t, fsc.Close())

	// Verify our intended side-effect
	require.Zero(t, fsc.openedFiles.Len())

	// Verify no error closing again.
	require.NoError(t, fsc.Close())
}

func TestContext_Close_Error(t *testing.T) {
	file := &testfs.File{CloseErr: errors.New("error closing")}

	testFS := &sysfs.AdaptFS{FS: testfs.FS{"foo": file}}

	c := Context{}
	err := c.InitFSContext(nil, nil, nil, []sys.FS{testFS}, []string{"/"}, nil)
	require.NoError(t, err)
	fsc := c.fsc

	// open another file
	_, errno := fsc.OpenFile(testFS, "foo", sys.O_RDONLY, 0)
	require.EqualErrno(t, 0, errno)

	// arbitrary errors coerce to EIO
	require.EqualErrno(t, sys.EIO, fsc.Close())

	// Paths should clear even under error
	require.Zero(t, fsc.openedFiles.Len(), "expected no opened files")
}

func TestFSContext_Renumber(t *testing.T) {
	tmpDir := t.TempDir()
	dirFS := sysfs.DirFS(tmpDir)

	const dirName = "dir"
	errno := dirFS.Mkdir(dirName, 0o700)
	require.EqualErrno(t, 0, errno)

	c := Context{}
	err := c.InitFSContext(nil, nil, nil, []sys.FS{dirFS}, []string{"/"}, nil)
	require.NoError(t, err)
	fsc := c.fsc

	defer fsc.Close()

	for _, toFd := range []int32{10, 100, 100} {
		fromFd, errno := fsc.OpenFile(dirFS, dirName, sys.O_RDONLY, 0)
		require.EqualErrno(t, 0, errno)

		prevDirFile, ok := fsc.LookupFile(fromFd)
		require.True(t, ok)

		require.EqualErrno(t, 0, fsc.Renumber(fromFd, toFd))

		renumberedDirFile, ok := fsc.LookupFile(toFd)
		require.True(t, ok)

		require.Equal(t, prevDirFile, renumberedDirFile)

		// Previous file descriptor shouldn't be used.
		_, ok = fsc.LookupFile(fromFd)
		require.False(t, ok)
	}

	t.Run("errors", func(t *testing.T) {
		// Sanity check for 3 being preopen.
		preopen, ok := fsc.LookupFile(3)
		require.True(t, ok)
		require.True(t, preopen.IsPreopen)

		// From is preopen.
		require.Equal(t, sys.ENOTSUP, fsc.Renumber(3, 100))

		// From does not exist.
		require.Equal(t, sys.EBADF, fsc.Renumber(12345, 3))

		// Both are preopen.
		require.Equal(t, sys.ENOTSUP, fsc.Renumber(3, 3))
	})
}

func TestDirentCache_Read(t *testing.T) {
	c := Context{}
	err := c.InitFSContext(nil, nil, nil, []sys.FS{&sysfs.AdaptFS{FS: fstest.FS}}, []string{"/"}, nil)
	require.NoError(t, err)
	fsc := c.fsc
	defer fsc.Close()

	d, errno := sysfs.OpenFSFile(fstest.FS, "dir", 0, 0)
	require.EqualErrno(t, 0, errno)
	defer d.Close()

	testDirents, errno := d.Readdir(-1)
	if errno != 0 {
		panic(errno)
	}
	testDirents = append([]sys.Dirent{
		{Name: ".", Type: fs.ModeDir},
		{Name: "..", Type: fs.ModeDir},
	}, testDirents...)

	tests := []struct {
		name            string
		initialDir      string
		dir             func(fd int32)
		fd              int32
		pos             uint64
		n               uint32
		expectedDirents []sys.Dirent
		expectedErrno   sys.Errno
	}{
		{
			name:            "empty dir has dot entries",
			initialDir:      "emptydir",
			pos:             0,
			n:               100,
			expectedDirents: testDirents[:2],
		},
		{
			name:       "rewind empty directory",
			initialDir: "emptydir",
			dir: func(fd int32) {
				f, _ := fsc.LookupFile(fd)
				rdd, _ := f.DirentCache()
				_, _ = rdd.Read(0, 5)
			},
			pos:             0,
			n:               100,
			expectedDirents: testDirents[:2],
		},
		{
			name:            "full read",
			initialDir:      "dir",
			pos:             0,
			n:               100,
			expectedDirents: testDirents,
		},
		{
			name:            "read first",
			initialDir:      "dir",
			pos:             0,
			n:               1,
			expectedDirents: testDirents[:1],
		},
		{
			name:       "read second",
			initialDir: "dir",
			dir: func(fd int32) {
				f, _ := fsc.LookupFile(fd)
				rdd, _ := f.DirentCache()
				_, _ = rdd.Read(0, 1)
			},
			pos:             1,
			n:               1,
			expectedDirents: testDirents[1:2],
		},
		{
			name:       "read second and third",
			initialDir: "dir",
			dir: func(fd int32) {
				f, _ := fsc.LookupFile(fd)
				rdd, _ := f.DirentCache()
				_, _ = rdd.Read(0, 1)
			},
			pos:             1,
			n:               2,
			expectedDirents: testDirents[1:3],
		},
		{
			name:       "read exactly third",
			initialDir: "dir",
			dir: func(fd int32) {
				f, _ := fsc.LookupFile(fd)
				rdd, _ := f.DirentCache()
				_, _ = rdd.Read(0, 2)
			},
			pos:             2,
			n:               1,
			expectedDirents: testDirents[2:3],
		},
		{
			name:       "read third and beyond",
			initialDir: "dir",
			dir: func(fd int32) {
				f, _ := fsc.LookupFile(fd)
				rdd, _ := f.DirentCache()
				_, _ = rdd.Read(0, 2)
			},
			pos:             2,
			n:               5,
			expectedDirents: testDirents[2:],
		},
		{
			name:       "read exhausted directory",
			initialDir: "dir",
			dir: func(fd int32) {
				f, _ := fsc.LookupFile(fd)
				rdd, _ := f.DirentCache()
				_, _ = rdd.Read(0, 5)
			},
			pos:             5,
			n:               5,
			expectedDirents: nil,
		},
		{
			name:       "rewind directory",
			initialDir: "dir",
			dir: func(fd int32) {
				f, _ := fsc.LookupFile(fd)
				rdd, _ := f.DirentCache()
				_, _ = rdd.Read(0, 5)
			},
			pos:             0,
			n:               5,
			expectedDirents: testDirents,
		},
		{
			name:          "DirentCache: not a dir",
			initialDir:    "dir/-",
			pos:           0,
			n:             1,
			expectedErrno: sys.ENOTDIR,
		},
		{
			name:          "pos invalid when no prior state",
			initialDir:    "dir",
			pos:           1,
			n:             1,
			expectedErrno: sys.ENOENT,
		},
	}

	for _, tt := range tests {
		tc := tt
		t.Run(tc.name, func(t *testing.T) {
			fd, errno := fsc.OpenFile(fsc.RootFS(), tc.initialDir, sys.O_RDONLY, 0)
			require.EqualErrno(t, 0, errno)
			defer fsc.CloseFile(fd) // nolint
			f, _ := fsc.LookupFile(fd)
			dir, errno := f.DirentCache()
			if errno != 0 {
				require.EqualErrno(t, tc.expectedErrno, errno)
				return
			}

			if tc.dir != nil {
				tc.dir(fd)
			}

			dirents, errno := dir.Read(tc.pos, tc.n)
			require.EqualErrno(t, tc.expectedErrno, errno)
			require.Equal(t, tc.expectedDirents, dirents)
		})
	}
}

// This is similar to https://github.com/WebAssembly/wasi-testsuite/blob/ac32f57400cdcdd0425d3085c24fc7fc40011d1c/tests/rust/src/bin/fd_readdir.rs#L120
func TestDirentCache_ReadNewFile(t *testing.T) {
	tmpDir := t.TempDir()

	c := Context{}
	err := c.InitFSContext(nil, nil, nil, []sys.FS{sysfs.DirFS(tmpDir)}, []string{"/"}, nil)
	require.NoError(t, err)
	fsc := c.fsc
	defer fsc.Close()

	fd, errno := fsc.OpenFile(fsc.RootFS(), ".", sys.O_RDONLY, 0)
	require.EqualErrno(t, 0, errno)
	defer fsc.CloseFile(fd) // nolint
	f, _ := fsc.LookupFile(fd)

	dir, errno := f.DirentCache()
	require.EqualErrno(t, 0, errno)

	// Read the empty directory, which should only have the dot entries.
	dirents, errno := dir.Read(0, 5)
	require.EqualErrno(t, 0, errno)
	require.Equal(t, 2, len(dirents))
	require.Equal(t, ".", dirents[0].Name)
	require.Equal(t, "..", dirents[1].Name)

	// Write a new file to the directory
	require.NoError(t, os.WriteFile(path.Join(tmpDir, "file"), nil, 0o0666))

	// Read it again, which should see the new file.
	dirents, errno = dir.Read(0, 5)
	require.EqualErrno(t, 0, errno)
	require.Equal(t, 3, len(dirents))
	require.Equal(t, ".", dirents[0].Name)
	require.Equal(t, "..", dirents[1].Name)
	require.Equal(t, "file", dirents[2].Name)

	// Read it again, using the file position.
	filePos := uint64(2)
	dirents, errno = dir.Read(filePos, 3)
	require.EqualErrno(t, 0, errno)
	require.Equal(t, 1, len(dirents))
	require.Equal(t, "file", dirents[0].Name)
}

func TestStripPrefixesAndTrailingSlash(t *testing.T) {
	tests := []struct {
		path, expected string
	}{
		{
			path:     ".",
			expected: "",
		},
		{
			path:     "/",
			expected: "",
		},
		{
			path:     "./",
			expected: "",
		},
		{
			path:     "./foo",
			expected: "foo",
		},
		{
			path:     ".foo",
			expected: ".foo",
		},
		{
			path:     "././foo",
			expected: "foo",
		},
		{
			path:     "/foo",
			expected: "foo",
		},
		{
			path:     "foo/",
			expected: "foo",
		},
		{
			path:     "//",
			expected: "",
		},
		{
			path:     "../../",
			expected: "../..",
		},
		{
			path:     "./../../",
			expected: "../..",
		},
	}

	for _, tt := range tests {
		tc := tt

		t.Run(tc.path, func(t *testing.T) {
			path := StripPrefixesAndTrailingSlash(tc.path)
			require.Equal(t, tc.expected, path)
		})
	}
}
