package sysfs

import (
	"errors"
	"io"
	"io/fs"
	"os"
	"path"
	"sort"
	"strings"
	"syscall"
	"testing"
	gofstest "testing/fstest"

	"github.com/tetratelabs/wazero/internal/fstest"
	"github.com/tetratelabs/wazero/internal/platform"
	testfs "github.com/tetratelabs/wazero/internal/testing/fs"
	"github.com/tetratelabs/wazero/internal/testing/require"
)

func TestNewRootFS(t *testing.T) {
	t.Run("empty", func(t *testing.T) {
		rootFS, err := NewRootFS(nil, nil)
		require.NoError(t, err)

		require.Equal(t, UnimplementedFS{}, rootFS)
	})
	t.Run("only root", func(t *testing.T) {
		testFS := NewDirFS(t.TempDir())

		rootFS, err := NewRootFS([]FS{testFS}, []string{""})
		require.NoError(t, err)

		// Should not be a composite filesystem
		require.Equal(t, testFS, rootFS)
	})
	t.Run("only non root", func(t *testing.T) {
		testFS := NewDirFS(".")

		rootFS, err := NewRootFS([]FS{testFS}, []string{"/tmp"})
		require.NoError(t, err)

		// unwrapping returns in original order
		require.Equal(t, []FS{testFS}, rootFS.(*CompositeFS).FS())
		require.Equal(t, []string{"/tmp"}, rootFS.(*CompositeFS).GuestPaths())

		// String is human-readable
		require.Equal(t, "[.:/tmp]", rootFS.String())

		// Guest can look up /tmp
		f, errno := rootFS.OpenFile("/tmp", os.O_RDONLY, 0)
		require.Zero(t, errno)
		require.NoError(t, f.Close())

		// Guest can look up / and see "/tmp" in it
		f, errno = rootFS.OpenFile("/", os.O_RDONLY, 0)
		require.Zero(t, errno)

		dirents, err := f.(fs.ReadDirFile).ReadDir(-1)
		require.NoError(t, err)
		require.Equal(t, 1, len(dirents))
		require.Equal(t, "tmp", dirents[0].Name())
		require.True(t, dirents[0].IsDir())
	})
	t.Run("multiple roots unsupported", func(t *testing.T) {
		testFS := NewDirFS(".")

		_, err := NewRootFS([]FS{testFS, testFS}, []string{"/", "/"})
		require.EqualError(t, err, "multiple root filesystems are invalid: [.:/ .:/]")
	})
	t.Run("virtual paths unsupported", func(t *testing.T) {
		testFS := NewDirFS(".")

		_, err := NewRootFS([]FS{testFS}, []string{"usr/bin"})
		require.EqualError(t, err, "only single-level guest paths allowed: [.:usr/bin]")
	})
	t.Run("multiple matches", func(t *testing.T) {
		tmpDir1 := t.TempDir()
		testFS1 := NewDirFS(tmpDir1)
		require.NoError(t, os.Mkdir(path.Join(tmpDir1, "tmp"), 0o700))
		require.NoError(t, os.WriteFile(path.Join(tmpDir1, "a"), []byte{1}, 0o600))

		tmpDir2 := t.TempDir()
		testFS2 := NewDirFS(tmpDir2)
		require.NoError(t, os.WriteFile(path.Join(tmpDir2, "a"), []byte{2}, 0o600))

		rootFS, err := NewRootFS([]FS{testFS2, testFS1}, []string{"/tmp", "/"})
		require.NoError(t, err)

		// unwrapping returns in original order
		require.Equal(t, []FS{testFS2, testFS1}, rootFS.(*CompositeFS).FS())
		require.Equal(t, []string{"/tmp", "/"}, rootFS.(*CompositeFS).GuestPaths())

		// Should be a composite filesystem
		require.NotEqual(t, testFS1, rootFS)
		require.NotEqual(t, testFS2, rootFS)

		t.Run("last wins", func(t *testing.T) {
			f, errno := rootFS.OpenFile("/tmp/a", os.O_RDONLY, 0)
			require.Zero(t, errno)
			defer f.Close()

			b, err := io.ReadAll(f)
			require.NoError(t, err)
			require.Equal(t, []byte{2}, b)
		})

		// This test is covered by fstest.TestFS, but doing again here
		t.Run("root includes prefix mount", func(t *testing.T) {
			f, errno := rootFS.OpenFile(".", os.O_RDONLY, 0)
			require.Zero(t, errno)
			defer f.Close()

			require.Equal(t, []string{"a", "tmp"}, readDirNames(t, f))
		})
	})
}

func readDirNames(t *testing.T, f fs.File) []string {
	names, errno := platform.Readdirnames(f, -1)
	require.Zero(t, errno)
	sort.Strings(names)
	return names
}

func TestRootFS_String(t *testing.T) {
	tmpFS := NewDirFS(".")
	rootFS := NewDirFS(".")

	testFS, err := NewRootFS([]FS{rootFS, tmpFS}, []string{"/", "/tmp"})
	require.NoError(t, err)

	require.Equal(t, "[.:/ .:/tmp]", testFS.String())
}

func TestRootFS_Open(t *testing.T) {
	tmpDir := t.TempDir()

	// Create a subdirectory, so we can test reads outside the FS root.
	tmpDir = path.Join(tmpDir, t.Name())
	require.NoError(t, os.Mkdir(tmpDir, 0o700))
	require.NoError(t, fstest.WriteTestFiles(tmpDir))

	testRootFS := NewDirFS(tmpDir)
	testDirFS := NewDirFS(t.TempDir())
	testFS, err := NewRootFS([]FS{testRootFS, testDirFS}, []string{"/", "/emptydir"})
	require.NoError(t, err)

	testOpen_Read(t, testFS, true)

	testOpen_O_RDWR(t, tmpDir, testFS)

	t.Run("path outside root valid", func(t *testing.T) {
		_, err := testFS.OpenFile("../foo", os.O_RDONLY, 0)

		// syscall.FS allows relative path lookups
		require.True(t, errors.Is(err, fs.ErrNotExist))
	})
}

func TestRootFS_Stat(t *testing.T) {
	tmpDir := t.TempDir()
	require.NoError(t, fstest.WriteTestFiles(tmpDir))

	tmpFS := NewDirFS(t.TempDir())
	testFS, err := NewRootFS([]FS{NewDirFS(tmpDir), tmpFS}, []string{"/", "/tmp"})
	require.NoError(t, err)
	testStat(t, testFS)
}

func TestRootFS_TestFS(t *testing.T) {
	t.Parallel()

	// Set up the test files
	tmpDir1 := t.TempDir()
	require.NoError(t, fstest.WriteTestFiles(tmpDir1))

	// move one directory outside the other
	tmpDir2 := t.TempDir()
	require.NoError(t, os.Rename(path.Join(tmpDir1, "dir"), path.Join(tmpDir2, "dir")))

	// Create a root mount
	testFS1 := NewDirFS(tmpDir1)

	// Create a dir mount
	testFS2 := NewDirFS(path.Join(tmpDir2, "dir"))

	testFS, err := NewRootFS([]FS{testFS1, testFS2}, []string{"/", "/dir"})
	require.NoError(t, err)

	// Run TestFS via the adapter
	require.NoError(t, fstest.TestFS(testFS.(fs.FS)))
}

func TestRootFS_examples(t *testing.T) {
	tests := []struct {
		name                 string
		fs                   []FS
		guestPaths           []string
		expected, unexpected []string
	}{
		// e.g. from Go project root:
		//	$ GOOS=js GOARCH=wasm bin/go test -c -o template.wasm text/template
		//	$ wazero run -mount=src/text/template:/ template.wasm -test.v
		{
			name: "go test text/template",
			fs: []FS{
				&adapter{fs: testfs.FS{"go-example-stdout-ExampleTemplate-0.txt": &testfs.File{}}},
				&adapter{fs: testfs.FS{"testdata/file1.tmpl": &testfs.File{}}},
			},
			guestPaths: []string{"/tmp", "/"},
			expected:   []string{"/tmp/go-example-stdout-ExampleTemplate-0.txt", "testdata/file1.tmpl"},
			unexpected: []string{"DOES NOT EXIST"},
		},
		// e.g. from TinyGo project root:
		//	$ ./build/tinygo test -target wasi -c -o flate.wasm compress/flate
		//	$ wazero run -mount=$(go env GOROOT)/src/compress/flate:/ flate.wasm -test.v
		{
			name: "tinygo test compress/flate",
			fs: []FS{
				&adapter{fs: testfs.FS{}},
				&adapter{fs: testfs.FS{"testdata/e.txt": &testfs.File{}}},
				&adapter{fs: testfs.FS{"testdata/Isaac.Newton-Opticks.txt": &testfs.File{}}},
			},
			guestPaths: []string{"/", "../", "../../"},
			expected:   []string{"../testdata/e.txt", "../../testdata/Isaac.Newton-Opticks.txt"},
			unexpected: []string{"../../testdata/e.txt"},
		},
		// e.g. from Go project root:
		//	$ GOOS=js GOARCH=wasm bin/go test -c -o net.wasm ne
		//	$ wazero run -mount=src/net:/ net.wasm -test.v -test.short
		{
			name: "go test net",
			fs: []FS{
				&adapter{fs: testfs.FS{"services": &testfs.File{}}},
				&adapter{fs: testfs.FS{"testdata/aliases": &testfs.File{}}},
			},
			guestPaths: []string{"/etc", "/"},
			expected:   []string{"/etc/services", "testdata/aliases"},
			unexpected: []string{"services"},
		},
		// e.g. from wagi-python project root:
		//	$ GOOS=js GOARCH=wasm bin/go test -c -o net.wasm ne
		//	$ wazero run -hostlogging=filesystem -mount=.:/ -env=PYTHONHOME=/opt/wasi-python/lib/python3.11 \
		//	  -env=PYTHONPATH=/opt/wasi-python/lib/python3.11 opt/wasi-python/bin/python3.wasm
		{
			name: "python",
			fs: []FS{
				&adapter{fs: gofstest.MapFS{ // to allow resolution of "."
					"pybuilddir.txt": &gofstest.MapFile{},
					"opt/wasi-python/lib/python3.11/__phello__/__init__.py": &gofstest.MapFile{},
				}},
			},
			guestPaths: []string{"/"},
			expected: []string{
				".",
				"pybuilddir.txt",
				"opt/wasi-python/lib/python3.11/__phello__/__init__.py",
			},
		},
		// e.g. from Zig project root: TODO: verify this once cli works with multiple mounts
		//	$ zig test --test-cmd wazero --test-cmd run --test-cmd -mount=.:/ -mount=/tmp:/tmp \
		//	  --test-cmd-bin -target wasm32-wasi --zig-lib-dir ./lib ./lib/std/std.zig
		{
			name: "zig",
			fs: []FS{
				&adapter{fs: testfs.FS{"zig-cache": &testfs.File{}}},
				&adapter{fs: testfs.FS{"qSQRrUkgJX9L20mr": &testfs.File{}}},
			},
			guestPaths: []string{"/", "/tmp"},
			expected:   []string{"zig-cache", "/tmp/qSQRrUkgJX9L20mr"},
			unexpected: []string{"/qSQRrUkgJX9L20mr"},
		},
	}

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

		t.Run(tc.name, func(t *testing.T) {
			root, err := NewRootFS(tc.fs, tc.guestPaths)
			require.NoError(t, err)

			for _, p := range tc.expected {
				f, errno := root.OpenFile(p, os.O_RDONLY, 0)
				require.Zero(t, errno, p)
				require.NoError(t, f.Close(), p)
			}

			for _, p := range tc.unexpected {
				_, err := root.OpenFile(p, os.O_RDONLY, 0)
				require.EqualErrno(t, syscall.ENOENT, err)
			}
		})
	}
}

func Test_stripPrefixesAndTrailingSlash(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) {
			pathI, pathLen := stripPrefixesAndTrailingSlash(tc.path)
			require.Equal(t, tc.expected, tc.path[pathI:pathLen])
		})
	}
}

func Test_hasPathPrefix(t *testing.T) {
	tests := []struct {
		name                  string
		path, prefix          string
		expectEq, expectMatch bool
	}{
		{
			name:        "empty prefix",
			path:        "foo",
			prefix:      "",
			expectEq:    false,
			expectMatch: true,
		},
		{
			name:        "equal prefix",
			path:        "foo",
			prefix:      "foo",
			expectEq:    true,
			expectMatch: true,
		},
		{
			name:        "sub path",
			path:        "foo/bar",
			prefix:      "foo",
			expectMatch: true,
		},
		{
			name:        "different sub path",
			path:        "foo/bar",
			prefix:      "bar",
			expectMatch: false,
		},
		{
			name:        "different path same length",
			path:        "foo",
			prefix:      "bar",
			expectMatch: false,
		},
		{
			name:        "longer path",
			path:        "foo",
			prefix:      "foo/bar",
			expectMatch: false,
		},
		{
			name:        "path shorter",
			path:        "foo",
			prefix:      "fooo",
			expectMatch: false,
		},
		{
			name:        "path longer",
			path:        "fooo",
			prefix:      "foo",
			expectMatch: false,
		},
		{
			name:        "shorter path",
			path:        "foo",
			prefix:      "foo/bar",
			expectMatch: false,
		},
		{
			name:        "wrong and shorter path",
			path:        "foo",
			prefix:      "bar/foo",
			expectMatch: false,
		},
		{
			name:        "same relative",
			path:        "../..",
			prefix:      "../..",
			expectEq:    true,
			expectMatch: true,
		},
		{
			name:        "longer relative",
			path:        "..",
			prefix:      "../..",
			expectMatch: false,
		},
	}

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

		t.Run(tc.name, func(t *testing.T) {
			path := "././." + tc.path + "/"
			eq, match := hasPathPrefix(path, 5, 5+len(tc.path), tc.prefix)
			require.Equal(t, tc.expectEq, eq)
			require.Equal(t, tc.expectMatch, match)
		})
	}
}

// BenchmarkHasPrefixVsIterate shows that iteration is faster than re-slicing
// for a prefix match.
func BenchmarkHasPrefixVsIterate(b *testing.B) {
	s := "../../.."
	prefix := "../bar"
	prefixLen := len(prefix)
	b.Run("strings.HasPrefix", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			if strings.HasPrefix(s, prefix) { //nolint
			}
		}
	})
	b.Run("iterate", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			for i := 0; i < prefixLen; i++ {
				if s[i] != prefix[i] {
					break
				}
			}
		}
	})
}
