package chunked

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"path"
	"syscall"
	"testing"

	"github.com/containers/storage/pkg/archive"
	"github.com/containers/storage/pkg/chunked/internal/minimal"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

type nopCloser struct {
	*bytes.Reader
}

func (nopCloser) Close() error {
	return nil
}

func TestSeekableFileGetBlobAt(t *testing.T) {
	content := []byte("Hello, World!")

	br := bytes.NewReader(content)
	reader := nopCloser{br}

	sf := newSeekableFile(reader)
	chunks := []ImageSourceChunk{
		{Offset: 0, Length: 5},
		{Offset: 7, Length: 5},
	}

	streams, errs, err := sf.GetBlobAt(chunks)
	assert.NoError(t, err)

	expectedContents := [][]byte{
		[]byte("Hello"),
		[]byte("World"),
	}

	i := 0
	for stream := range streams {
		buf := new(bytes.Buffer)
		_, err := buf.ReadFrom(stream)
		require.NoError(t, err)

		require.Equal(t, expectedContents[i], buf.Bytes())
		i++
	}

	err, ok := <-errs
	assert.NoError(t, err)
	assert.False(t, ok)
}

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

	srcFile := createTempFile(t, tmpDir, "source")
	defer srcFile.Close()
	srcFd := int(srcFile.Fd())

	destDir := t.TempDir()
	destDirFd, err := syscall.Open(destDir, syscall.O_RDONLY|syscall.O_CLOEXEC, 0)
	require.NoError(t, err)
	defer syscall.Close(destDirFd)

	destBase := "dest-file"
	err = doHardLink(destDirFd, srcFd, destBase)
	require.NoError(t, err)

	// an existing file is unlinked first
	err = doHardLink(destDirFd, srcFd, destBase)
	assert.NoError(t, err)

	err = doHardLink(destDirFd, -1, destBase)
	assert.Error(t, err)

	err = doHardLink(-1, srcFd, destBase)
	assert.Error(t, err)
}

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

	tmpFile := createTempFile(t, tmpDir, "file-with-holes")
	defer tmpFile.Close()

	fd := int(tmpFile.Fd())

	size := int64(1024)

	err := appendHole(fd, tmpFile.Name(), size)
	assert.NoError(t, err, "Appending hole failed")

	fileSize, err := syscall.Seek(fd, 0, io.SeekEnd)
	assert.NoError(t, err)
	assert.Equal(t, size, fileSize, "File size is not as expected")
}

func TestSafeMkdir(t *testing.T) {
	rootDir := t.TempDir()
	dirName := "../dir"

	rootFile, err := os.Open(rootDir)
	require.NoError(t, err)
	defer rootFile.Close()

	rootFd := int(rootFile.Fd())

	metadata := fileMetadata{
		FileMetadata: minimal.FileMetadata{
			Type: minimal.TypeDir,
			Mode: 0o755,
		},
	}
	options := &archive.TarOptions{
		// Allow the test to run without privileges
		IgnoreChownErrors: true,
	}

	err = safeMkdir(rootFd, 0o755, "/", &metadata, options)
	require.NoError(t, err)

	err = safeMkdir(rootFd, 0o755, dirName, &metadata, options)
	require.NoError(t, err)

	dir, err := openFileUnderRoot(rootFd, dirName, syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0)
	require.NoError(t, err)
	err = dir.Close()
	assert.NoError(t, err)
}

func TestSafeLink(t *testing.T) {
	linkName := "a-hard-link"

	rootDir := t.TempDir()

	rootFile, err := os.Open(rootDir)
	require.NoError(t, err)
	defer rootFile.Close()

	rootFd := int(rootFile.Fd())

	file := createTempFile(t, rootDir, "an-existing-file")
	existingFile := path.Base(file.Name())
	err = file.Close()
	assert.NoError(t, err)

	metadata := fileMetadata{
		FileMetadata: minimal.FileMetadata{
			Name: linkName,
			// try to create outside the root
			Linkname: "../../" + existingFile,
			Type:     minimal.TypeReg,
			Mode:     0o755,
		},
	}
	options := &archive.TarOptions{
		// Allow the test to run without privileges
		IgnoreChownErrors: true,
	}

	err = safeLink(rootFd, 0o755, &metadata, options)
	require.NoError(t, err)

	// validate it was created
	newFile, err := openFileUnderRoot(rootFd, linkName, syscall.O_RDONLY, 0)
	require.NoError(t, err)

	st := syscall.Stat_t{}
	err = syscall.Fstat(int(newFile.Fd()), &st)
	assert.NoError(t, err)

	// We need this conversion on ARM64
	assert.Equal(t, uint64(st.Nlink), uint64(2))

	err = newFile.Close()
	assert.NoError(t, err)
}

func TestSafeSymlink(t *testing.T) {
	linkName := "a-hard-link"

	rootDir := t.TempDir()

	rootFile, err := os.Open(rootDir)
	require.NoError(t, err)
	defer rootFile.Close()

	rootFd := int(rootFile.Fd())

	file := createTempFile(t, rootDir, "an-existing-file")
	st := syscall.Stat_t{}
	err = syscall.Fstat(int(file.Fd()), &st)
	assert.NoError(t, err)

	err = file.Close()
	assert.NoError(t, err)

	existingFile := path.Base(file.Name())

	metadata := fileMetadata{
		FileMetadata: minimal.FileMetadata{
			Name: linkName,
			// try to create outside the root
			Linkname: "../../" + existingFile,
			Type:     minimal.TypeReg,
			Mode:     0o755,
		},
	}

	err = safeSymlink(rootFd, &metadata)
	require.NoError(t, err)

	// validate it was created
	newFile, err := openFileUnderRoot(rootFd, linkName, syscall.O_RDONLY, 0)
	require.NoError(t, err)

	st2 := syscall.Stat_t{}
	err = syscall.Fstat(int(newFile.Fd()), &st2)
	require.NoError(t, err)

	// validate that the opened file is the same as the original file that was
	// created earlier.  Compare the inode and device numbers.
	assert.Equal(t, st.Dev, st2.Dev)
	assert.Equal(t, st.Ino, st2.Ino)

	err = newFile.Close()
	assert.NoError(t, err)
}

func TestOpenOrCreateDirUnderRoot(t *testing.T) {
	rootDir := t.TempDir()
	dirName := "dir"

	rootFile, err := os.Open(rootDir)
	require.NoError(t, err)
	defer rootFile.Close()

	rootFd := int(rootFile.Fd())

	// try to create a directory outside the root
	dir, err := openOrCreateDirUnderRoot(rootFd, "../../"+dirName, 0o755)
	require.NoError(t, err)
	err = dir.Close()
	assert.NoError(t, err)

	dir, err = openFileUnderRoot(rootFd, dirName, syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0)
	require.NoError(t, err)
	err = dir.Close()
	require.NoError(t, err)
}

func TestCopyFileContent(t *testing.T) {
	rootDir := t.TempDir()

	rootFile, err := os.Open(rootDir)
	require.NoError(t, err)
	defer rootFile.Close()

	rootFd := int(rootFile.Fd())

	file := createTempFile(t, rootDir, "an-existing-file")
	defer file.Close()

	size, err := file.Write([]byte("Hello, World!"))
	require.NoError(t, err)

	_, err = file.Seek(0, io.SeekStart)
	assert.NoError(t, err)

	st := syscall.Stat_t{}
	err = syscall.Fstat(int(file.Fd()), &st)
	require.NoError(t, err)

	metadata := fileMetadata{
		FileMetadata: minimal.FileMetadata{
			Name: "new-file",
			Type: minimal.TypeDir,
			Mode: 0o755,
		},
	}

	newFile, newSize, err := copyFileContent(int(file.Fd()), &metadata, rootFd, 0o755, false)
	require.NoError(t, err)

	assert.Equal(t, size, int(newSize))

	st2 := syscall.Stat_t{}
	err = syscall.Fstat(int(newFile.Fd()), &st2)
	require.NoError(t, err)

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

	// the file was copied without hard links, the inodes must be different
	assert.Equal(t, st.Dev, st2.Dev)
	assert.NotEqual(t, st.Ino, st2.Ino)

	metadataCopyHardLinks := fileMetadata{
		FileMetadata: minimal.FileMetadata{
			Name: "new-file2",
			Type: minimal.TypeDir,
			Mode: 0o755,
		},
	}

	newFile, newSize, err = copyFileContent(int(file.Fd()), &metadataCopyHardLinks, rootFd, 0o755, true)
	require.NoError(t, err)
	assert.Nil(t, newFile)

	// validate it was created as an inode
	newFile, err = openFileUnderRoot(rootFd, metadataCopyHardLinks.FileMetadata.Name, syscall.O_RDONLY, 0)
	require.NoError(t, err)

	assert.Equal(t, size, int(newSize))

	st2 = syscall.Stat_t{}
	err = syscall.Fstat(int(newFile.Fd()), &st2)
	require.NoError(t, err)

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

	// the file was copied with hard links, the inodes must be equal
	assert.Equal(t, st.Dev, st2.Dev)
	assert.Equal(t, st.Ino, st2.Ino)
}

func createTempFile(t *testing.T, dir, name string) *os.File {
	tmpFile, err := os.CreateTemp(dir, name)
	require.NoError(t, err)
	return tmpFile
}

func TestSplitPath(t *testing.T) {
	tests := []struct {
		path         string
		expectedDir  string
		expectedBase string
	}{
		{"", "/", "."},
		{".", "/", "."},
		{"..", "/", "."},
		{"../..", "/", "."},
		{"../../..", "/", "."},
		{"../../../foo", "/", "foo"},
		{"../../../foo/..", "/", "."},
		{"../../../foo/./../foo/bar/baz", "/foo/bar", "baz"},
		{"../../../foo/bar", "/foo", "bar"},
		{"/", "/", "."},
		{"/.", "/", "."},
		{"/..", "/", "."},
		{"////foo////bar////", "/foo", "bar"},
		{"/foo", "/", "foo"},
		{"/foo/", "/", "foo"},
		{"/foo/bar", "/foo", "bar"},
		{"/foo/bar/", "/foo", "bar"},
		{"/foo/////bar/", "/foo", "bar"},
		{"/home/foo/file.txt", "/home/foo", "file.txt"},
		{"/home/foo////file.txt", "/home/foo", "file.txt"},
		{"file", "/", "file"},
		{"foo/", "/", "foo"},
		{"foo/.", "/", "foo"},
		{"foo/..", "/", "."},
		{"foo/../../bar", "/", "bar"},
		{"foo/bar/", "/foo", "bar"},
		{"foo/bar/..", "/", "foo"},
		{"foo/bar/../baz", "/foo", "baz"},
		{"foo/bar/baz/", "/foo/bar", "baz"},
		{"foo/file.txt", "/foo", "file.txt"},
	}

	for _, test := range tests {
		dir, base, err := splitPath(test.path)
		assert.NoError(t, err)
		assert.Equal(t, test.expectedDir, dir, fmt.Sprintf("path %q: expected dir %q, got %q", test.path, test.expectedDir, dir))
		assert.Equal(t, test.expectedBase, base, fmt.Sprintf("path %q: expected base %q, got %q", test.path, test.expectedBase, base))
	}
}
