package memfs

import (
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"runtime"
	"sync"
	"syscall"
	"testing"
	"time"

	"github.com/go-git/go-billy/v6"
	"github.com/go-git/go-billy/v6/util"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

var _ fs.File = &file{}

func TestRootExists(t *testing.T) {
	fs := New()
	f, err := fs.Stat("/")
	require.NoError(t, err)
	assert.True(t, f.IsDir())
}

func TestCapabilities(t *testing.T) {
	fs := New()
	_, ok := fs.(billy.Capable)
	assert.True(t, ok)

	caps := billy.Capabilities(fs)
	assert.Equal(t, billy.DefaultCapabilities&^billy.LockCapability, caps)
}

func TestModTime(t *testing.T) {
	fs := New()
	_, err := fs.Create("/file1")
	require.NoError(t, err)

	if runtime.GOOS == "windows" {
		time.Sleep(20 * time.Millisecond)
	}

	_, err = fs.Create("/file2")
	require.NoError(t, err)

	fi1a, err := fs.Stat("/file1")
	require.NoError(t, err)

	fi2, err := fs.Stat("/file2")
	require.NoError(t, err)

	fi1b, err := fs.Stat("/file1")
	require.NoError(t, err)

	modtime := fi1a.ModTime()

	// file 1 and file 2 should have different mod times.
	assert.NotEqual(t, modtime, fi2.ModTime())

	// a new file info for the same unmodified file, should still match mod time.
	assert.Equal(t, modtime, fi1b.ModTime())

	// new calls to ModTime() retain existing mod time.
	assert.Equal(t, modtime, fi1a.ModTime())

	// Writing to file should change the mod time
	err = util.WriteFile(fs, "/file1", []byte{0}, 0o666)
	require.NoError(t, err)
	fi1c, err := fs.Stat("/file1")
	require.NoError(t, err)
	assert.NotEqual(t, modtime, fi1c.ModTime())
}

func TestNegativeOffsets(t *testing.T) {
	fs := New()
	f, err := fs.Create("negative")
	require.NoError(t, err)

	buf := make([]byte, 100)
	_, err = f.ReadAt(buf, -100)
	assert.ErrorContains(t, err, "readat negative: negative offset")

	_, err = f.Seek(-100, io.SeekCurrent)
	require.NoError(t, err)
	_, err = f.Write(buf)
	assert.ErrorContains(t, err, "writeat negative: negative offset")
}

func TestExclusive(t *testing.T) {
	fs := New()
	f, err := fs.OpenFile("exclusive", os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o666)
	require.NoError(t, err)

	fmt.Fprint(f, "mememememe")

	err = f.Close()
	require.NoError(t, err)

	_, err = fs.OpenFile("exclusive", os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o666)
	assert.ErrorContains(t, err, os.ErrExist.Error())
}

func TestOrder(t *testing.T) {
	var err error

	files := []string{
		"a",
		"b",
		"c",
	}
	fs := New()
	for _, f := range files {
		_, err = fs.Create(f)
		require.NoError(t, err)
	}

	attempts := 30
	for range attempts {
		actual, err := fs.ReadDir("")
		require.NoError(t, err)

		for i, f := range files {
			assert.Equal(t, actual[i].Name(), f)
		}
	}
}

func TestNotFound(t *testing.T) {
	fs := New()
	files, err := fs.ReadDir("asdf")
	assert.Len(t, files, 0)
	assert.ErrorIs(t, err, os.ErrNotExist)
}

func TestTruncateAppend(t *testing.T) {
	fs := New()
	err := util.WriteFile(fs, "truncate_append", []byte("file-content"), 0o666)
	require.NoError(t, err)

	f, err := fs.OpenFile("truncate_append", os.O_WRONLY|os.O_TRUNC|os.O_APPEND, 0o666)
	require.NoError(t, err)

	n, err := f.Write([]byte("replace"))
	require.NoError(t, err)
	assert.Equal(t, n, len("replace"))

	err = f.Close()
	require.NoError(t, err)

	data, err := util.ReadFile(fs, "truncate_append")
	require.NoError(t, err)
	assert.Equal(t, string(data), "replace")
}

func TestReadlink(t *testing.T) {
	tests := []struct {
		name    string
		link    string
		want    string
		wantErr *error
	}{
		{
			name:    "symlink not found",
			link:    "/404",
			wantErr: &os.ErrNotExist,
		},
		{
			name: "self-targeting symlink",
			link: "/self",
			want: "/self",
		},
		{
			name: "symlink",
			link: "/bar",
			want: "/foo",
		},
		{
			name: "symlink to windows path",
			link: "/win",
			want: "c:\\test\\123",
		},
		{
			name: "symlink to network path",
			link: "/net",
			want: "\\test\\123",
		},
	}

	// Cater for memfs not being os-agnostic.
	if runtime.GOOS == "windows" {
		tests[1].want = "\\self"
		tests[2].want = "\\foo"
		tests[3].want = "\\c:\\test\\123"
	}

	fs := New()

	// arrange fs for tests.
	require.NoError(t, fs.Symlink("/self", "/self"))
	require.NoError(t, fs.Symlink("/foo", "/bar"))
	require.NoError(t, fs.Symlink("c:\\test\\123", "/win"))
	require.NoError(t, fs.Symlink("\\test\\123", "/net"))

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got, err := fs.Readlink(tc.link)

			if tc.wantErr == nil {
				require.NoError(t, err)
				assert.Equal(t, tc.want, got)
			} else {
				assert.ErrorIs(t, err, *tc.wantErr)
			}
		})
	}
}

func TestSymlink2(t *testing.T) {
	tests := []struct {
		name    string
		target  string
		link    string
		want    string
		wantErr string
	}{
		{
			name:   "new symlink unexistent target",
			target: "/bar",
			link:   "/foo",
			want:   "/bar",
		},
		{
			name:   "self-targeting symlink",
			target: "/self",
			link:   "/self",
			want:   "/self",
		},
		{
			name:   "new symlink to file",
			target: "/file",
			link:   "/file-link",
			want:   "/file",
		},
		{
			name:   "new symlink to dir",
			target: "/dir",
			link:   "/dir-link",
			want:   "/dir",
		},
		{
			name:   "new symlink to win",
			target: "c:\\foor\\bar",
			link:   "/win",
			want:   "c:\\foor\\bar",
		},
		{
			name:   "new symlink to net",
			target: "\\net\\bar",
			link:   "/net",
			want:   "\\net\\bar",
		},
		{
			name:   "new symlink to net",
			target: "\\net\\bar",
			link:   "/net",
			want:   "\\net\\bar",
		},
		{
			name:    "duplicate symlink",
			target:  "/bar",
			link:    "/foo",
			wantErr: os.ErrExist.Error(),
		},
		{
			name:    "symlink over existing file",
			target:  "/foo/bar",
			link:    "/file",
			want:    "/file",
			wantErr: os.ErrExist.Error(),
		},
	}

	// Cater for memfs not being os-agnostic.
	if runtime.GOOS == "windows" {
		tests[0].want = "\\bar"
		tests[1].want = "\\self"
		tests[2].want = "\\file"
		tests[3].want = "\\dir"
		tests[4].want = "\\c:\\foor\\bar"
	}

	fs := New()

	// arrange fs for tests.
	err := fs.MkdirAll("/dir", 0o600)
	require.NoError(t, err)
	_, err = fs.Create("/file")
	require.NoError(t, err)

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := fs.Symlink(tc.target, tc.link)

			if tc.wantErr == "" {
				got, err := fs.Readlink(tc.link)
				require.NoError(t, err)
				assert.Equal(t, tc.want, got)
			} else {
				assert.ErrorContains(t, err, tc.wantErr)
			}
		})
	}
}

func TestChrootSymlinkResolution(t *testing.T) {
	fs := New()
	require.NoError(t, util.WriteFile(fs, "file", []byte("outer"), 0o644))
	require.NoError(t, util.WriteFile(fs, "dir/file", []byte("outer-dir"), 0o644))

	chroot, err := fs.Chroot("base")
	require.NoError(t, err)
	require.NoError(t, chroot.MkdirAll("nested", 0o755))
	require.NoError(t, chroot.Symlink("../../file", "nested/file-link"))
	require.NoError(t, chroot.Symlink("../../dir", "nested/dir-link"))

	fi, err := chroot.Lstat("nested/file-link")
	require.NoError(t, err)
	assert.True(t, fi.Mode()&os.ModeSymlink != 0)

	target, err := chroot.Readlink("nested/file-link")
	require.NoError(t, err)
	assert.Equal(t, filepath.FromSlash("../../file"), target)

	tests := []struct {
		name string
		run  func() error
	}{
		{
			name: "create",
			run: func() error {
				f, err := chroot.Create("nested/file-link")
				if err == nil {
					_ = f.Close()
				}
				return err
			},
		},
		{
			name: "open file",
			run: func() error {
				f, err := chroot.OpenFile("nested/file-link", os.O_RDONLY, 0)
				if err == nil {
					_ = f.Close()
				}
				return err
			},
		},
		{
			name: "open",
			run: func() error {
				f, err := chroot.Open("nested/file-link")
				if err == nil {
					_ = f.Close()
				}
				return err
			},
		},
		{
			name: "stat",
			run: func() error {
				_, err := chroot.Stat("nested/file-link")
				return err
			},
		},
		{
			name: "read dir",
			run: func() error {
				_, err := chroot.ReadDir("nested/dir-link")
				return err
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			require.ErrorIs(t, tt.run(), billy.ErrCrossedBoundary)
		})
	}

	require.NoError(t, fs.Symlink("file", "root-link"))
	rootChroot, err := fs.Chroot("root-link")
	require.NoError(t, err)

	_, err = rootChroot.Open("/")
	require.ErrorIs(t, err, billy.ErrCrossedBoundary)

	_, err = rootChroot.Stat("/")
	require.ErrorIs(t, err, billy.ErrCrossedBoundary)

	data, err := util.ReadFile(fs, "file")
	require.NoError(t, err)
	assert.Equal(t, []byte("outer"), data)
}

func TestChrootSymlinkResolutionLoop(t *testing.T) {
	tests := []struct {
		name  string
		setup func(t *testing.T, fs billy.Filesystem)
	}{
		{
			name: "mutual",
			setup: func(t *testing.T, fs billy.Filesystem) {
				t.Helper()
				require.NoError(t, fs.Symlink("b", "a"))
				require.NoError(t, fs.Symlink("a", "b"))
			},
		},
		{
			name: "self",
			setup: func(t *testing.T, fs billy.Filesystem) {
				t.Helper()
				require.NoError(t, fs.Symlink("a", "a"))
			},
		},
		{
			name: "self dot relative",
			setup: func(t *testing.T, fs billy.Filesystem) {
				t.Helper()
				require.NoError(t, fs.Symlink("./a", "a"))
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			fs := New()
			tt.setup(t, fs)

			f, err := fs.Open("a")
			assert.Nil(t, f)
			requireSymlinkLoop(t, err, "open")

			f, err = fs.Create("a")
			assert.Nil(t, f)
			requireSymlinkLoop(t, err, "create")

			fi, err := fs.Stat("a")
			assert.Nil(t, fi)
			requireSymlinkLoop(t, err, "stat")
		})
	}
}

func TestChrootRootSymlinkResolutionLoop(t *testing.T) {
	tests := []struct {
		name   string
		target string
	}{
		{name: "self", target: "root"},
		{name: "self dot relative", target: "./root"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			fs := New()
			require.NoError(t, fs.Symlink(tt.target, "root"))

			chroot, err := fs.Chroot("root")
			require.NoError(t, err)

			f, err := chroot.Open("/")
			assert.Nil(t, f)
			requireSymlinkLoop(t, err, "open")

			fi, err := chroot.Stat("/")
			assert.Nil(t, fi)
			requireSymlinkLoop(t, err, "stat")

			entries, err := chroot.ReadDir("/")
			assert.Nil(t, entries)
			requireSymlinkLoop(t, err, "readdir")
		})
	}
}

func requireSymlinkLoop(t *testing.T, err error, op string) {
	t.Helper()
	require.ErrorIs(t, err, syscall.ELOOP)

	var pathErr *os.PathError
	require.ErrorAs(t, err, &pathErr)
	assert.Equal(t, op, pathErr.Op)
}

func TestJoin(t *testing.T) {
	tests := []struct {
		name string
		elem []string
		want string
	}{
		{name: "empty", elem: []string{""}, want: ""},
		{name: "c:", elem: []string{"C:"}, want: "C:"},
		{name: "simple rel", elem: []string{"a", "b", "c"}, want: "a/b/c"},
		{name: "simple rel backslash", elem: []string{"\\", "a", "b", "c"}, want: "\\/a/b/c"},
		{name: "simple abs slash", elem: []string{"/", "a", "b", "c"}, want: "/a/b/c"},
		{name: "c: rel", elem: []string{"C:\\", "a", "b", "c"}, want: "C:\\/a/b/c"},
		{name: "c: abs", elem: []string{"/C:\\", "a", "b", "c"}, want: "/C:\\/a/b/c"},
		{name: "\\ rel", elem: []string{"\\\\", "a", "b", "c"}, want: "\\\\/a/b/c"},
		{name: "\\ abs", elem: []string{"/\\\\", "a", "b", "c"}, want: "/\\\\/a/b/c"},
	}

	// Cater for memfs not being os-agnostic.
	if runtime.GOOS == "windows" {
		tests[1].want = "C:."
		tests[2].want = "a\\b\\c"
		tests[3].want = "\\a\\b\\c"
		tests[4].want = "\\a\\b\\c"
		tests[5].want = "C:\\a\\b\\c"
		tests[6].want = "\\C:\\a\\b\\c"
		tests[7].want = "\\\\a\\b\\c"
		tests[8].want = "\\\\\\a\\b\\c"
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			got := New().Join(tc.elem...)
			assert.Equal(t, tc.want, got)
		})
	}
}

func TestSymlink(t *testing.T) {
	fs := New()
	err := fs.Symlink("test", "test")
	require.NoError(t, err)

	f, err := fs.Open("test")
	assert.Nil(t, f)
	requireSymlinkLoop(t, err, "open")

	fi, err := fs.ReadDir("test")
	assert.Nil(t, fi)
	requireSymlinkLoop(t, err, "readdir")
}

func TestRenameLeavesSiblingPrefix(t *testing.T) {
	fs := New()
	require.NoError(t, util.WriteFile(fs, "foo/file", []byte("renamed"), 0o644))
	require.NoError(t, util.WriteFile(fs, "foobar/file", []byte("sibling"), 0o644))

	require.NoError(t, fs.Rename("foo", "bar"))

	data, err := util.ReadFile(fs, "bar/file")
	require.NoError(t, err)
	assert.Equal(t, []byte("renamed"), data)

	data, err = util.ReadFile(fs, "foobar/file")
	require.NoError(t, err)
	assert.Equal(t, []byte("sibling"), data)

	_, err = fs.Stat("foo/file")
	assert.ErrorIs(t, err, os.ErrNotExist)
}

func TestThreadSafety(t *testing.T) {
	fs := New()

	var wg sync.WaitGroup
	files := 100

	fnc := func(n int, s string, remove bool) {
		fn := fmt.Sprintf("/file_%d%s", n, s)
		f, err := fs.Create(fn)
		require.NoError(t, err)
		require.NotNil(t, f)

		err = fs.Rename(fn, fn+"2")
		require.NoError(t, err)

		if remove {
			err = fs.Remove(fn + "2")
			require.NoError(t, err)
		}
		wg.Done()
	}

	for i := range files {
		wg.Add(4)

		go fnc(i, "a", false)
		go fnc(i, "b", false)
		go fnc(i, "c", true)
		go fnc(i, "d", true)
	}

	wg.Wait()

	fi, err := fs.ReadDir("/")
	require.NoError(t, err)
	assert.Len(t, fi, files*2)
}

func TestUmask(t *testing.T) {
	tests := []struct {
		name             string
		umask            *uint32
		expectedFileMode os.FileMode
		expectedDirMode  os.FileMode
	}{
		{
			name:             "default umask (0o022)",
			umask:            nil,
			expectedFileMode: 0o644,
			expectedDirMode:  0o755,
		},
		{
			name:             "custom umask (0o077)",
			umask:            func() *uint32 { u := uint32(0o077); return &u }(),
			expectedFileMode: 0o600,
			expectedDirMode:  0o700,
		},
		{
			name:             "zero umask (0o000)",
			umask:            func() *uint32 { u := uint32(0o000); return &u }(),
			expectedFileMode: 0o666,
			expectedDirMode:  0o777,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var fs billy.Filesystem
			if tt.umask != nil {
				fs = New(WithUmask(*tt.umask))
			} else {
				fs = New()
			}

			f, err := fs.Create("file.txt")
			require.NoError(t, err)
			f.Close()

			if runtime.GOOS == "windows" && tt.umask == nil {
				tt.expectedFileMode |= 0o022
				tt.expectedDirMode |= 0o022
			}

			fi, err := fs.Stat("file.txt")
			require.NoError(t, err)
			assert.Equal(t, tt.expectedFileMode, fi.Mode().Perm())

			err = fs.MkdirAll("testdir", 0o777)
			require.NoError(t, err)

			fi, err = fs.Stat("testdir")
			require.NoError(t, err)
			assert.Equal(t, tt.expectedDirMode, fi.Mode().Perm())
		})
	}
}

func TestUmaskOpenFile(t *testing.T) {
	fs := New(WithUmask(0o077))

	f, err := fs.OpenFile("test.txt", os.O_CREATE|os.O_RDWR, 0o666)
	require.NoError(t, err)
	assert.NoError(t, f.Close())

	fi, err := fs.Stat("test.txt")
	require.NoError(t, err)
	assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())

	// Re-do test without the use of os.O_CREATE.
	err = util.WriteFile(fs, "test2.txt", []byte("content"), 0o666)
	require.NoError(t, err)

	fi2, err := fs.Stat("test2.txt")
	require.NoError(t, err)
	assert.Equal(t, os.FileMode(0o600), fi2.Mode().Perm())

	f2, err := fs.OpenFile("test2.txt", os.O_RDWR, 0o777)
	require.NoError(t, err)
	assert.NoError(t, f2.Close())

	fi2, err = fs.Stat("test2.txt")
	require.NoError(t, err)
	// Mode must not be changed by OpenFile without os.O_CREATE.
	assert.Equal(t, os.FileMode(0o600), fi2.Mode().Perm())
}

func TestUmaskChmod(t *testing.T) {
	fs := New()

	f, err := fs.Create("/test.txt")
	require.NoError(t, err)
	assert.NoError(t, f.Close())

	want := 0o644
	if runtime.GOOS == "windows" {
		want |= 0o022
	}

	fi, err := fs.Stat("/test.txt")
	require.NoError(t, err)
	assert.Equal(t, os.FileMode(want), fi.Mode().Perm())

	ch, ok := fs.(billy.Chmod)
	require.True(t, ok, "fs does not implement billy.Chmod")

	err = ch.Chmod("/test.txt", 0o421)
	require.NoError(t, err)

	fi, err = fs.Stat("/test.txt")
	require.NoError(t, err)
	assert.Equal(t, os.FileMode(0o421), fi.Mode().Perm())
}

func TestUmaskRootDirectory(t *testing.T) {
	fs := New(WithUmask(0o077))

	fi, err := fs.Stat("/")
	require.NoError(t, err)
	assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm())
}
