package cli_test

import (
	"archive/tar"
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/google/uuid"
	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/cli/clitest"
	"github.com/coder/coder/v2/coderd"
	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/provisioner/echo"
	"github.com/coder/coder/v2/provisionersdk"
	"github.com/coder/coder/v2/provisionersdk/proto"
	"github.com/coder/coder/v2/pty/ptytest"
)

// dirSum calculates a checksum of the files in a directory.
func dirSum(t *testing.T, dir string) string {
	ents, err := os.ReadDir(dir)
	require.NoError(t, err)
	sum := sha256.New()
	for _, e := range ents {
		path := filepath.Join(dir, e.Name())

		stat, err := os.Stat(path)
		require.NoError(t, err)

		byt, err := os.ReadFile(
			path,
		)
		require.NoError(t, err, "mode: %+v", stat.Mode())
		_, _ = sum.Write(byt)
	}
	return hex.EncodeToString(sum.Sum(nil))
}

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

	inv, _ := clitest.New(t, "templates", "pull")
	err := inv.Run()
	require.Error(t, err)
}

// Stdout tests that 'templates pull' pulls down the active template
// and writes it to stdout.
func TestTemplatePull_Stdout(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, &coderdtest.Options{
		IncludeProvisionerDaemon: true,
	})
	owner := coderdtest.CreateFirstUser(t, client)
	templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())

	// Create an initial template bundle.
	source1 := genTemplateVersionSource()
	// Create an updated template bundle. This will be used to ensure
	// that templates are correctly returned in order from latest to oldest.
	source2 := genTemplateVersionSource()

	expected, err := echo.Tar(source2)
	require.NoError(t, err)

	version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)

	template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)

	// Update the template version so that we can assert that templates
	// are being sorted correctly.
	updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID)
	coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion.ID)

	// Verify .tar format
	inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name)
	clitest.SetupConfig(t, templateAdmin, root)

	var buf bytes.Buffer
	inv.Stdout = &buf

	err = inv.Run()
	require.NoError(t, err)
	require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ")

	// Verify .zip format
	tarReader := tar.NewReader(bytes.NewReader(expected))
	expectedZip, err := coderd.CreateZipFromTar(tarReader)
	require.NoError(t, err)

	inv, root = clitest.New(t, "templates", "pull", "--zip", template.Name)
	clitest.SetupConfig(t, templateAdmin, root)
	buf.Reset()
	inv.Stdout = &buf

	err = inv.Run()
	require.NoError(t, err)
	require.True(t, bytes.Equal(expectedZip, buf.Bytes()), "zip files differ")
}

// Stdout tests that 'templates pull' pulls down the non-latest active template
// and writes it to stdout.
func TestTemplatePull_ActiveOldStdout(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, &coderdtest.Options{
		IncludeProvisionerDaemon: true,
	})
	owner := coderdtest.CreateFirstUser(t, client)
	templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())

	source1 := genTemplateVersionSource()
	source2 := genTemplateVersionSource()

	expected, err := echo.Tar(source1)
	require.NoError(t, err)

	version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)

	template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)

	updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID)

	inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name)
	clitest.SetupConfig(t, templateAdmin, root)

	var buf bytes.Buffer
	inv.Stdout = &buf
	var stderr strings.Builder
	inv.Stderr = &stderr

	err = inv.Run()
	require.NoError(t, err)

	require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ")
	require.Contains(t, stderr.String(), "A newer template version than the active version exists.")
}

// Stdout tests that 'templates pull' pulls down the specified template and
// writes it to stdout.
func TestTemplatePull_SpecifiedStdout(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, &coderdtest.Options{
		IncludeProvisionerDaemon: true,
	})
	owner := coderdtest.CreateFirstUser(t, client)
	templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())

	source1 := genTemplateVersionSource()
	source2 := genTemplateVersionSource()
	source3 := genTemplateVersionSource()

	expected, err := echo.Tar(source1)
	require.NoError(t, err)

	version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)

	template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)

	updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID)

	updatedVersion2 := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source3, template.ID)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion2.ID)
	coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion2.ID)

	inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name, "--version", version1.Name)
	clitest.SetupConfig(t, templateAdmin, root)

	var buf bytes.Buffer
	inv.Stdout = &buf

	err = inv.Run()
	require.NoError(t, err)

	require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ")
}

// Stdout tests that 'templates pull' pulls down the latest template
// and writes it to stdout.
func TestTemplatePull_LatestStdout(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, &coderdtest.Options{
		IncludeProvisionerDaemon: true,
	})
	owner := coderdtest.CreateFirstUser(t, client)
	templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())

	source1 := genTemplateVersionSource()
	source2 := genTemplateVersionSource()

	expected, err := echo.Tar(source1)
	require.NoError(t, err)

	version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)

	template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)

	updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID)

	inv, root := clitest.New(t, "templates", "pull", "--tar", template.Name, "latest")
	clitest.SetupConfig(t, templateAdmin, root)

	var buf bytes.Buffer
	inv.Stdout = &buf

	err = inv.Run()
	require.NoError(t, err)

	require.True(t, bytes.Equal(expected, buf.Bytes()), "tar files differ")
}

// ToDir tests that 'templates pull' pulls down the active template
// and writes it to the correct directory.
//
// nolint: paralleltest // The subtests cannot be run in parallel; see the inner loop.
func TestTemplatePull_ToDir(t *testing.T) {
	tests := []struct {
		name           string
		destPath       string
		useDefaultDest bool
	}{
		{
			name:           "absolute path works",
			useDefaultDest: true,
		},
		{
			name:     "relative path to specific dir is sanitized",
			destPath: "./pulltmp",
		},
		{
			name:     "relative path to current dir is sanitized",
			destPath: ".",
		},
		{
			name:     "directory traversal is acceptable",
			destPath: "../mytmpl",
		},
		{
			name:     "empty path falls back to using template name",
			destPath: "",
		},
	}

	// nolint: paralleltest // These tests change the current working dir, and is therefore unsuitable for parallelisation.
	for _, tc := range tests {
		tc := tc

		t.Run(tc.name, func(t *testing.T) {
			dir := t.TempDir()

			cwd, err := os.Getwd()
			require.NoError(t, err)
			t.Cleanup(func() {
				require.NoError(t, os.Chdir(cwd))
			})

			// Change working directory so that relative path tests don't affect the original working directory.
			newWd := filepath.Join(dir, "new-cwd")
			require.NoError(t, os.MkdirAll(newWd, 0o750))
			require.NoError(t, os.Chdir(newWd))

			expectedDest := filepath.Join(dir, "expected")
			actualDest := tc.destPath
			if tc.useDefaultDest {
				actualDest = filepath.Join(dir, "actual")
			}

			client := coderdtest.New(t, &coderdtest.Options{
				IncludeProvisionerDaemon: true,
			})
			owner := coderdtest.CreateFirstUser(t, client)
			templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())

			// Create an initial template bundle.
			source1 := genTemplateVersionSource()
			// Create an updated template bundle. This will be used to ensure
			// that templates are correctly returned in order from latest to oldest.
			source2 := genTemplateVersionSource()

			expected, err := echo.Tar(source2)
			require.NoError(t, err)

			version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1)
			_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)

			template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)

			// Update the template version so that we can assert that templates
			// are being sorted correctly.
			updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID)
			_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID)
			coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion.ID)

			err = provisionersdk.Untar(expectedDest, bytes.NewReader(expected))
			require.NoError(t, err)

			ents, _ := os.ReadDir(actualDest)
			if len(ents) > 0 {
				t.Logf("%s is not empty", actualDest)
				t.FailNow()
			}

			inv, root := clitest.New(t, "templates", "pull", template.Name, actualDest)
			clitest.SetupConfig(t, templateAdmin, root)

			ptytest.New(t).Attach(inv)

			require.NoError(t, inv.Run())

			// Validate behavior of choosing template name in the absence of an output path argument.
			destPath := actualDest
			if destPath == "" {
				destPath = template.Name
			}

			require.Equal(t,
				dirSum(t, expectedDest),
				dirSum(t, destPath),
			)
		})
	}
}

// FolderConflict tests that 'templates pull' fails when a folder with has
// existing
func TestTemplatePull_FolderConflict(t *testing.T) {
	t.Parallel()

	client := coderdtest.New(t, &coderdtest.Options{
		IncludeProvisionerDaemon: true,
	})
	owner := coderdtest.CreateFirstUser(t, client)
	templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())

	// Create an initial template bundle.
	source1 := genTemplateVersionSource()
	// Create an updated template bundle. This will be used to ensure
	// that templates are correctly returned in order from latest to oldest.
	source2 := genTemplateVersionSource()

	expected, err := echo.Tar(source2)
	require.NoError(t, err)

	version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, source1)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)

	template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)

	// Update the template version so that we can assert that templates
	// are being sorted correctly.
	updatedVersion := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, source2, template.ID)
	_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, updatedVersion.ID)
	coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, updatedVersion.ID)

	dir := t.TempDir()

	expectedDest := filepath.Join(dir, "expected")
	conflictDest := filepath.Join(dir, "conflict")

	err = os.MkdirAll(conflictDest, 0o700)
	require.NoError(t, err)

	err = os.WriteFile(
		filepath.Join(conflictDest, "conflict-file"),
		[]byte("conflict"), 0o600,
	)
	require.NoError(t, err)

	err = provisionersdk.Untar(expectedDest, bytes.NewReader(expected))
	require.NoError(t, err)

	inv, root := clitest.New(t, "templates", "pull", template.Name, conflictDest)
	clitest.SetupConfig(t, templateAdmin, root)

	pty := ptytest.New(t).Attach(inv)

	waiter := clitest.StartWithWaiter(t, inv)

	pty.ExpectMatch("not empty")
	pty.WriteLine("no")

	waiter.RequireError()

	ents, err := os.ReadDir(conflictDest)
	require.NoError(t, err)

	require.Len(t, ents, 1, "conflict folder should have single conflict file")
}

// genTemplateVersionSource returns a unique bundle that can be used to create
// a template version source.
func genTemplateVersionSource() *echo.Responses {
	return &echo.Responses{
		Parse: []*proto.Response{
			{
				Type: &proto.Response_Log{
					Log: &proto.Log{
						Output: uuid.NewString(),
					},
				},
			},

			{
				Type: &proto.Response_Parse{
					Parse: &proto.ParseComplete{},
				},
			},
		},
		ProvisionApply: echo.ApplyComplete,
	}
}
