package build

import (
	"archive/tar"
	"bytes"
	"io"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"

	"github.com/moby/go-archive"
	"github.com/moby/go-archive/compression"
	"github.com/moby/patternmatcher"
	"gotest.tools/v3/assert"
	is "gotest.tools/v3/assert/cmp"
)

const dockerfileContents = "FROM busybox"

func prepareEmpty(_ *testing.T) string {
	return ""
}

func prepareNoFiles(t *testing.T) string {
	t.Helper()
	return createTestTempDir(t)
}

func prepareOneFile(t *testing.T) string {
	t.Helper()
	contextDir := createTestTempDir(t)
	createTestTempFile(t, contextDir, defaultDockerfileName, dockerfileContents)
	return contextDir
}

func testValidateContextDirectory(t *testing.T, prepare func(t *testing.T) string, excludes []string) {
	t.Helper()
	contextDir := prepare(t)
	err := ValidateContextDirectory(contextDir, excludes)
	assert.NilError(t, err)
}

func TestGetContextFromLocalDirNoDockerfile(t *testing.T) {
	contextDir := createTestTempDir(t)
	_, _, err := GetContextFromLocalDir(contextDir, "")
	assert.ErrorContains(t, err, "Dockerfile")
}

func TestGetContextFromLocalDirNotExistingDir(t *testing.T) {
	contextDir := createTestTempDir(t)
	fakePath := filepath.Join(contextDir, "fake")

	_, _, err := GetContextFromLocalDir(fakePath, "")
	assert.ErrorContains(t, err, "fake")
}

func TestGetContextFromLocalDirNotExistingDockerfile(t *testing.T) {
	contextDir := createTestTempDir(t)
	fakePath := filepath.Join(contextDir, "fake")

	_, _, err := GetContextFromLocalDir(contextDir, fakePath)
	assert.ErrorContains(t, err, "fake")
}

func TestGetContextFromLocalDirWithNoDirectory(t *testing.T) {
	contextDir := createTestTempDir(t)
	createTestTempFile(t, contextDir, defaultDockerfileName, dockerfileContents)

	t.Chdir(contextDir)

	absContextDir, relDockerfile, err := GetContextFromLocalDir(contextDir, "")
	assert.NilError(t, err)

	assert.Check(t, is.Equal(contextDir, absContextDir))
	assert.Check(t, is.Equal(defaultDockerfileName, relDockerfile))
}

func TestGetContextFromLocalDirWithDockerfile(t *testing.T) {
	contextDir := createTestTempDir(t)
	createTestTempFile(t, contextDir, defaultDockerfileName, dockerfileContents)

	absContextDir, relDockerfile, err := GetContextFromLocalDir(contextDir, "")
	assert.NilError(t, err)

	assert.Check(t, is.Equal(contextDir, absContextDir))
	assert.Check(t, is.Equal(defaultDockerfileName, relDockerfile))
}

func TestGetContextFromLocalDirLocalFile(t *testing.T) {
	contextDir := createTestTempDir(t)
	createTestTempFile(t, contextDir, defaultDockerfileName, dockerfileContents)
	testFilename := createTestTempFile(t, contextDir, "tmpTest", "test")

	absContextDir, relDockerfile, err := GetContextFromLocalDir(testFilename, "")

	if err == nil {
		t.Fatalf("Error should not be nil")
	}

	if absContextDir != "" {
		t.Fatalf("Absolute directory path should be empty, got: %s", absContextDir)
	}

	if relDockerfile != "" {
		t.Fatalf("Relative path to Dockerfile should be empty, got: %s", relDockerfile)
	}
}

func TestGetContextFromLocalDirWithCustomDockerfile(t *testing.T) {
	contextDir := createTestTempDir(t)
	t.Chdir(contextDir)

	createTestTempFile(t, contextDir, defaultDockerfileName, dockerfileContents)

	absContextDir, relDockerfile, err := GetContextFromLocalDir(contextDir, defaultDockerfileName)
	assert.NilError(t, err)

	assert.Check(t, is.Equal(contextDir, absContextDir))
	assert.Check(t, is.Equal(defaultDockerfileName, relDockerfile))
}

func TestGetContextFromReaderString(t *testing.T) {
	tarArchive, relDockerfile, err := GetContextFromReader(io.NopCloser(strings.NewReader(dockerfileContents)), "")
	if err != nil {
		t.Fatalf("Error when executing GetContextFromReader: %s", err)
	}

	tarReader := tar.NewReader(tarArchive)

	_, err = tarReader.Next()
	if err != nil {
		t.Fatalf("Error when reading tar archive: %s", err)
	}

	buff := new(bytes.Buffer)
	_, _ = buff.ReadFrom(tarReader)
	contents := buff.String()

	_, err = tarReader.Next()

	if err != io.EOF {
		t.Fatalf("Tar stream too long: %s", err)
	}

	assert.NilError(t, tarArchive.Close())

	if dockerfileContents != contents {
		t.Fatalf("Uncompressed tar archive does not equal: %s, got: %s", dockerfileContents, contents)
	}

	if relDockerfile != defaultDockerfileName {
		t.Fatalf("Relative path not equals %s, got: %s", defaultDockerfileName, relDockerfile)
	}
}

func TestGetContextFromReaderStringConflict(t *testing.T) {
	rdr, relDockerfile, err := GetContextFromReader(io.NopCloser(strings.NewReader(dockerfileContents)), "custom.Dockerfile")
	assert.Check(t, is.Equal(rdr, nil))
	assert.Check(t, is.Equal(relDockerfile, ""))
	assert.Check(t, is.ErrorContains(err, "ambiguous Dockerfile source: both stdin and flag correspond to Dockerfiles"))
}

func TestGetContextFromReaderTar(t *testing.T) {
	contextDir := createTestTempDir(t)
	createTestTempFile(t, contextDir, defaultDockerfileName, dockerfileContents)

	tarStream, err := archive.Tar(contextDir, compression.None)
	assert.NilError(t, err)

	tarArchive, relDockerfile, err := GetContextFromReader(tarStream, defaultDockerfileName)
	assert.NilError(t, err)

	tarReader := tar.NewReader(tarArchive)

	header, err := tarReader.Next()
	assert.NilError(t, err)

	if header.Name != defaultDockerfileName {
		t.Fatalf("Dockerfile name should be: %s, got: %s", defaultDockerfileName, header.Name)
	}

	buff := new(bytes.Buffer)
	_, _ = buff.ReadFrom(tarReader)
	contents := buff.String()

	_, err = tarReader.Next()

	if err != io.EOF {
		t.Fatalf("Tar stream too long: %s", err)
	}

	assert.NilError(t, tarArchive.Close())

	if dockerfileContents != contents {
		t.Fatalf("Uncompressed tar archive does not equal: %s, got: %s", dockerfileContents, contents)
	}

	if relDockerfile != defaultDockerfileName {
		t.Fatalf("Relative path not equals %s, got: %s", defaultDockerfileName, relDockerfile)
	}
}

func TestValidateContextDirectoryEmptyContext(t *testing.T) {
	// This isn't a valid test on Windows. See https://play.golang.org/p/RR6z6jxR81.
	// The test will ultimately end up calling filepath.Abs(""). On Windows,
	// golang will error. On Linux, golang will return /. Due to there being
	// drive letters on Windows, this is probably the correct behaviour for
	// Windows.
	if runtime.GOOS == "windows" {
		t.Skip("Invalid test on Windows")
	}
	testValidateContextDirectory(t, prepareEmpty, []string{})
}

func TestValidateContextDirectoryContextWithNoFiles(t *testing.T) {
	testValidateContextDirectory(t, prepareNoFiles, []string{})
}

func TestValidateContextDirectoryWithOneFile(t *testing.T) {
	testValidateContextDirectory(t, prepareOneFile, []string{})
}

func TestValidateContextDirectoryWithOneFileExcludes(t *testing.T) {
	testValidateContextDirectory(t, prepareOneFile, []string{defaultDockerfileName})
}

// createTestTempDir creates a temporary directory for testing. It returns the
// created path. When an error occurs, it terminates the test.
func createTestTempDir(t *testing.T) string {
	t.Helper()
	path := t.TempDir()

	// Eval Symlinks is needed to account for macOS TMP using symlinks
	path, err := filepath.EvalSymlinks(path)
	assert.NilError(t, err)
	return path
}

// createTestTempFile creates a temporary file within dir with specific contents and permissions.
// When an error occurs, it terminates the test
func createTestTempFile(t *testing.T, dir, filename, contents string) string {
	t.Helper()
	filePath := filepath.Join(dir, filename)
	err := os.WriteFile(filePath, []byte(contents), 0o777)
	assert.NilError(t, err)
	return filePath
}

func TestIsArchive(t *testing.T) {
	tests := []struct {
		doc      string
		header   []byte
		expected bool
	}{
		{
			doc:      "nil is not a valid header",
			header:   nil,
			expected: false,
		},
		{
			doc:      "invalid header bytes",
			header:   []byte{0x00, 0x01, 0x02},
			expected: false,
		},
		{
			doc:      "header for bzip2 archive",
			header:   []byte{0x42, 0x5A, 0x68},
			expected: true,
		},
		{
			doc:      "header for 7zip archive is not supported",
			header:   []byte{0x50, 0x4b, 0x03, 0x04},
			expected: false,
		},
	}
	for _, tc := range tests {
		t.Run(tc.doc, func(t *testing.T) {
			assert.Check(t, is.Equal(tc.expected, isArchive(tc.header)), tc.doc)
		})
	}
}

func TestDetectArchiveReader(t *testing.T) {
	tests := []struct {
		file     string
		desc     string
		expected bool
	}{
		{
			file:     "../testdata/tar.test",
			desc:     "tar file without pax headers",
			expected: true,
		},
		{
			file:     "../testdata/gittar.test",
			desc:     "tar file with pax headers",
			expected: true,
		},
		{
			file:     "../testdata/Dockerfile.test",
			desc:     "not a tar file",
			expected: false,
		},
	}
	for _, tc := range tests {
		t.Run(tc.desc, func(t *testing.T) {
			content, err := os.Open(tc.file)
			assert.NilError(t, err)
			defer func() {
				_ = content.Close()
			}()

			_, isArchive, err := detectArchiveReader(content)
			assert.NilError(t, err)
			assert.Check(t, is.Equal(tc.expected, isArchive), tc.file)
		})
	}
}

func mustPatternMatcher(t *testing.T, patterns []string) *patternmatcher.PatternMatcher {
	t.Helper()
	pm, err := patternmatcher.New(patterns)
	if err != nil {
		t.Fatal("failed to construct pattern matcher: ", err)
	}
	return pm
}

func TestWildcardMatches(t *testing.T) {
	match, _ := filepathMatches(mustPatternMatcher(t, []string{"*"}), "fileutils.go")
	if !match {
		t.Errorf("failed to get a wildcard match, got %v", match)
	}
}

// A simple pattern match should return true.
func TestPatternMatches(t *testing.T) {
	match, _ := filepathMatches(mustPatternMatcher(t, []string{"*.go"}), "fileutils.go")
	if !match {
		t.Errorf("failed to get a match, got %v", match)
	}
}

// An exclusion followed by an inclusion should return true.
func TestExclusionPatternMatchesPatternBefore(t *testing.T) {
	match, _ := filepathMatches(mustPatternMatcher(t, []string{"!fileutils.go", "*.go"}), "fileutils.go")
	if !match {
		t.Errorf("failed to get true match on exclusion pattern, got %v", match)
	}
}

// A folder pattern followed by an exception should return false.
func TestPatternMatchesFolderExclusions(t *testing.T) {
	match, _ := filepathMatches(mustPatternMatcher(t, []string{"docs", "!docs/README.md"}), "docs/README.md")
	if match {
		t.Errorf("failed to get a false match on exclusion pattern, got %v", match)
	}
}

// A folder pattern followed by an exception should return false.
func TestPatternMatchesFolderWithSlashExclusions(t *testing.T) {
	match, _ := filepathMatches(mustPatternMatcher(t, []string{"docs/", "!docs/README.md"}), "docs/README.md")
	if match {
		t.Errorf("failed to get a false match on exclusion pattern, got %v", match)
	}
}

// A folder pattern followed by an exception should return false.
func TestPatternMatchesFolderWildcardExclusions(t *testing.T) {
	match, _ := filepathMatches(mustPatternMatcher(t, []string{"docs/*", "!docs/README.md"}), "docs/README.md")
	if match {
		t.Errorf("failed to get a false match on exclusion pattern, got %v", match)
	}
}

// A pattern followed by an exclusion should return false.
func TestExclusionPatternMatchesPatternAfter(t *testing.T) {
	match, _ := filepathMatches(mustPatternMatcher(t, []string{"*.go", "!fileutils.go"}), "fileutils.go")
	if match {
		t.Errorf("failed to get false match on exclusion pattern, got %v", match)
	}
}

// A filename evaluating to . should return false.
func TestExclusionPatternMatchesWholeDirectory(t *testing.T) {
	match, _ := filepathMatches(mustPatternMatcher(t, []string{"*.go"}), ".")
	if match {
		t.Errorf("failed to get false match on ., got %v", match)
	}
}

// Matches with no patterns
func TestMatchesWithNoPatterns(t *testing.T) {
	matches, err := filepathMatches(mustPatternMatcher(t, []string{}), "/any/path/there")
	if err != nil {
		t.Fatal(err)
	}
	if matches {
		t.Fatalf("Should not have match anything")
	}
}
