// Copyright (c) 2019, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE.md file distributed with the sources of this project regarding your
// rights to use or distribute this software.

// This file has been migrated from cmd/singularity/pull_test.go

package pull

import (
	"fmt"
	"io/ioutil"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/containerd/containerd/reference"
	"github.com/containerd/containerd/remotes/docker"
	"github.com/deislabs/oras/pkg/content"
	"github.com/deislabs/oras/pkg/context"
	"github.com/deislabs/oras/pkg/oras"
	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
	"github.com/pkg/errors"
	"github.com/sylabs/singularity/e2e/internal/e2e"
	"github.com/sylabs/singularity/internal/pkg/util/uri"
)

type ctx struct {
	env e2e.TestEnv
}

type testStruct struct {
	desc             string // case description
	srcURI           string // source URI for image
	library          string // use specific library, XXX(mem): not tested yet
	force            bool   // pass --force
	createDst        bool   // create destination file before pull
	unauthenticated  bool   // pass --allow-unauthenticated
	setImagePath     bool   // pass destination path
	setPullDir       bool   // pass --dir
	expectedExitCode int
	pullDir          string
	imagePath        string
	expectedImage    string
}

var tests = []testStruct{
	{
		desc:             "non existent image",
		srcURI:           "library://sylabs/tests/does_not_exist:0",
		expectedExitCode: 255,
	},

	// --allow-unauthenticated tests
	{
		desc:             "unsigned image allow unauthenticated",
		srcURI:           "library://sylabs/tests/unsigned:1.0.0",
		unauthenticated:  true,
		expectedExitCode: 0,
	},

	// --force tests
	{
		desc:             "force existing file",
		srcURI:           "library://alpine:3.8",
		force:            true,
		createDst:        true,
		unauthenticated:  true,
		expectedExitCode: 0,
	},
	{
		desc:             "force non-existing file",
		srcURI:           "library://alpine:3.8",
		force:            true,
		createDst:        false,
		unauthenticated:  true,
		expectedExitCode: 0,
	},
	{
		// --force should not have an effect on --allow-unauthenticated=false
		desc:             "unsigned image force require authenticated",
		srcURI:           "library://sylabs/tests/unsigned:1.0.0",
		force:            true,
		unauthenticated:  false,
		expectedExitCode: 0,
	},

	// test version specifications
	{
		desc:             "image with specific hash",
		srcURI:           "library://sylabs/tests/signed:sha256.5c439fd262095766693dae95fb81334c3a02a7f0e4dc6291e0648ed4ddc61c6c",
		unauthenticated:  true,
		expectedExitCode: 0,
	},
	{
		desc:             "latest tag",
		srcURI:           "library://alpine:latest",
		unauthenticated:  true,
		expectedExitCode: 0,
	},

	// --dir tests
	{
		desc:             "dir no image path",
		srcURI:           "library://alpine:3.9",
		unauthenticated:  true,
		setPullDir:       true,
		setImagePath:     false,
		expectedExitCode: 0,
	},
	{
		// XXX(mem): this specific test is passing both --path and an image path to
		// singularity pull. The current behavior is that the code is joining both paths and
		// failing to find the image in the expected location indicated by image path
		// because image path is absolute, so after joining /tmp/a/b/c and
		// /tmp/a/b/image.sif, the code expects to find /tmp/a/b/c/tmp/a/b/image.sif. Since
		// the directory /tmp/a/b/c/tmp/a/b does not exist, it fails to create the file
		// image.sif in there.
		desc:             "dir image path",
		srcURI:           "library://alpine:3.9",
		unauthenticated:  true,
		setPullDir:       true,
		setImagePath:     true,
		expectedExitCode: 255,
	},

	// transport tests
	{
		desc:             "bare image name",
		srcURI:           "alpine:3.8",
		force:            true,
		unauthenticated:  true,
		expectedExitCode: 0,
	},

	{
		desc:             "image from docker",
		srcURI:           "docker://alpine:3.8",
		force:            true,
		unauthenticated:  false,
		expectedExitCode: 0,
	},
	// TODO(mem): reenable this; disabled while shub is down
	// {
	// 	desc:            "image from shub",
	// 	srcURI:          "shub://GodloveD/busybox",
	// 	force:           true,
	// 	unauthenticated: false,
	// 	expectSuccess:   true,
	// },
	{
		desc:             "oras transport for SIF from registry",
		srcURI:           "oras://localhost:5000/pull_test_sif:latest", // TODO(mem): obtain registry from context
		force:            true,
		unauthenticated:  false,
		expectedExitCode: 0,
	},

	// pulling of invalid images with oras
	{
		desc:             "oras pull of non SIF file",
		srcURI:           "oras://localhost:5000/pull_test_:latest", // TODO(mem): obtain registry from context
		force:            true,
		expectedExitCode: 255,
	},
	{
		desc:             "oras pull of packed dir",
		srcURI:           "oras://localhost:5000/pull_test_invalid_file:latest", // TODO(mem): obtain registry from context
		force:            true,
		expectedExitCode: 255,
	},

	// pulling with library URI argument
	{
		desc:             "bad library URI",
		srcURI:           "library://busybox",
		library:          "https://bad-library.sylabs.io",
		expectedExitCode: 255,
	},
	{
		desc:             "default library URI",
		srcURI:           "library://busybox",
		library:          "https://library.sylabs.io",
		force:            true,
		expectedExitCode: 0,
	},
}

func (c *ctx) imagePull(t *testing.T, tt testStruct) {
	// We use a string rather than a slice of strings to avoid having an empty
	// element in the slice, which would cause the command to fail, wihtout
	// over-complicating the code.
	argv := ""

	if tt.force {
		argv += "--force "
	}

	if tt.unauthenticated {
		argv += "--allow-unauthenticated "
	}

	if tt.pullDir != "" {
		argv += "--dir " + tt.pullDir + " "
	}

	if tt.library != "" {
		argv += "--library " + tt.library + " "
	}

	if tt.imagePath != "" {
		argv += tt.imagePath + " "
	}

	argv += tt.srcURI

	c.env.RunSingularity(
		t,
		e2e.AsSubtest(tt.desc),
		e2e.WithProfile(e2e.UserProfile),
		e2e.WithCommand("pull"),
		e2e.WithArgs(strings.Split(argv, " ")...),
		e2e.ExpectExit(tt.expectedExitCode))

	checkPullResult(t, tt)
}

func getImageNameFromURI(imgURI string) string {
	// XXX(mem): this function should be part of the code, not the test
	switch transport, ref := uri.Split(imgURI); {
	case ref == "":
		return "" // Invalid URI

	case transport == "":
		imgURI = "library://" + imgURI
	}

	return uri.GetName(imgURI)
}

func (c *ctx) setup(t *testing.T) {
	e2e.EnsureImage(t, c.env)
	e2e.PrepRegistry(t, c.env)

	// setup file and dir to use as invalid images
	orasInvalidDir, err := ioutil.TempDir(c.env.TestDir, "oras_push_dir-")
	if err != nil {
		t.Fatalf("unable to create src dir for push tests: %v", err)
	}

	orasInvalidFile, err := e2e.WriteTempFile(orasInvalidDir, "oras_invalid_image-", "Invalid Image Contents")
	if err != nil {
		t.Fatalf("unable to create src file for push tests: %v", err)
	}

	// prep local registry with oras generated artifacts
	// Note: the image name prevents collisions by using a package specific name
	// as the registry is shared between different test packages
	orasImages := []struct {
		srcPath string
		uri     string
	}{
		{
			srcPath: c.env.ImagePath,
			uri:     fmt.Sprintf("%s/pull_test_sif:latest", c.env.TestRegistry),
		},
		{
			srcPath: orasInvalidDir,
			uri:     fmt.Sprintf("%s/pull_test_dir:latest", c.env.TestRegistry),
		},
		{
			srcPath: orasInvalidFile,
			uri:     fmt.Sprintf("%s/pull_test_invalid_file:latest", c.env.TestRegistry),
		},
	}

	for _, i := range orasImages {
		err = orasPushNoCheck(i.srcPath, i.uri)
		if err != nil {
			t.Fatalf("while prepping registry for oras tests: %v", err)
		}
	}
}

func (c ctx) testPullCmd(t *testing.T) {
	// XXX(mem): this should come from the environment
	sylabsAdminFingerprint := "8883491F4268F173C6E5DC49EDECE4F3F38D871E"
	argv := []string{"key", "pull", sylabsAdminFingerprint}
	out, err := exec.Command(c.env.CmdPath, argv...).CombinedOutput()
	if err != nil {
		t.Fatalf("Cannot pull key %q: %+v\nCommand:\n%s %s\nOutput:\n%s\n",
			sylabsAdminFingerprint,
			err,
			c.env.CmdPath, strings.Join(argv, " "),
			out)
	}

	for _, tt := range tests {
		t.Run(tt.desc, func(t *testing.T) {
			tmpdir, err := ioutil.TempDir(c.env.TestDir, "pull_test.")
			if err != nil {
				t.Fatalf("Failed to create temporary directory for pull test: %+v", err)
			}
			defer os.RemoveAll(tmpdir)

			if tt.setPullDir {
				tt.pullDir, err = ioutil.TempDir(tmpdir, "pull_dir.")
				if err != nil {
					t.Fatalf("Failed to create temporary directory for pull dir: %+v", err)
				}
			}

			if tt.setImagePath {
				tt.imagePath = filepath.Join(tmpdir, "image.sif")
				tt.expectedImage = tt.imagePath
			} else {
				// Since we are not passing an image name, change the current
				// working directory to the temporary directory we just created so
				// that we know it's clean. We don't do this for the other case in
				// order to catch spurious files showing up. Maybe later we can
				// examine the directory and assert that it only contains what we
				// expect.
				oldwd, err := os.Getwd()
				if err != nil {
					t.Fatalf("Failed to get working directory for pull test: %+v", err)
				}
				defer os.Chdir(oldwd)

				os.Chdir(tmpdir)

				// if there's a pullDir, that's where we expect to find the image
				if tt.pullDir != "" {
					os.Chdir(tt.pullDir)
				}

				tt.expectedImage = getImageNameFromURI(tt.srcURI)
			}

			// In order to actually test force, there must already be a file present in
			// the expected location
			if tt.createDst {
				fh, err := os.Create(tt.expectedImage)
				if err != nil {
					t.Fatalf("failed to create file %q: %+v\n", tt.expectedImage, err)
				}
				fh.Close()
			}

			c.imagePull(t, tt)
		})
	}
}

func checkPullResult(t *testing.T, tt testStruct) {
	if tt.expectedExitCode == 0 {
		_, err := os.Stat(tt.expectedImage)
		switch err {
		case nil:
			// PASS
			return

		case os.ErrNotExist:
			// FAIL
			t.Errorf("expecting image at %q, not found: %+v\n", tt.expectedImage, err)

		default:
			// FAIL
			t.Errorf("unable to stat image at %q: %+v\n", tt.expectedImage, err)
		}

		// XXX(mem): This is running a bunch of commands in the downloaded
		// images. Do we really want this here? If yes, we need to have a
		// way to do this in a generic fashion, as it's going to be shared
		// with build as well.

		// imageVerify(t, tt.imagePath, false)
	}
}

// this is a version of the oras push functionality that does not check that given the
// file is a valid SIF, this allows us to push arbitrary objects to the local registry
// to test the pull validation
func orasPushNoCheck(file, ref string) error {
	ref = strings.TrimPrefix(ref, "//")

	spec, err := reference.Parse(ref)
	if err != nil {
		err = errors.Wrapf(err, "parse OCI reference %s", ref)
		return fmt.Errorf("unable to parse oci reference: %+v", err)
	}

	// Hostname() will panic if there is no '/' in the locator
	// explicitly check for this and fail in order to prevent panic
	// this case will only occur for incorrect uris
	if !strings.Contains(spec.Locator, "/") {
		return fmt.Errorf("not a valid oci object uri: %s", ref)
	}

	// append default tag if no object exists
	if spec.Object == "" {
		spec.Object = "latest"
	}

	resolver := docker.NewResolver(docker.ResolverOptions{})

	store := content.NewFileStore("")
	defer store.Close()

	conf, err := store.Add("$config", "application/vnd.sylabs.sif.config.v1+json", "/dev/null")
	if err != nil {
		err = errors.Wrap(err, "adding manifest config to file store")
		return fmt.Errorf("unable to add manifest config to FileStore: %+v", err)
	}
	conf.Annotations = nil

	// use last element of filepath as file name in annotation
	fileName := filepath.Base(file)
	desc, err := store.Add(fileName, "appliciation/vnd.sylabs.sif.layer.tar", file)
	if err != nil {
		err = errors.Wrap(err, "adding manifest SIF file to file store")
		return fmt.Errorf("unable to add SIF file to FileStore: %+v", err)
	}

	descriptors := []ocispec.Descriptor{desc}

	if _, err := oras.Push(context.Background(), resolver, spec.String(), store, descriptors, oras.WithConfig(conf)); err != nil {
		err = errors.Wrap(err, "pushing to oras")
		return fmt.Errorf("unable to push: %+v", err)
	}

	return nil
}

func (c ctx) testPullDisableCacheCmd(t *testing.T) {
	cacheDir, err := ioutil.TempDir("", "e2e-imgcache-")
	if err != nil {
		t.Fatalf("failed to create temporary directory: %s", err)
	}
	defer func() {
		err := os.RemoveAll(cacheDir)
		if err != nil {
			t.Fatalf("failed to delete temporary directory %s: %s", cacheDir, err)
		}
	}()

	c.env.ImgCacheDir = cacheDir
	imgName := "testImg.sif"
	imgPath := filepath.Join(c.env.TestDir, imgName)
	cmdArgs := []string{"--disable-cache", imgPath, "library://alpine:latest"}

	c.env.RunSingularity(
		t,
		e2e.WithProfile(e2e.UserProfile),
		e2e.WithCommand("pull"),
		e2e.WithArgs(cmdArgs...),
		e2e.ExpectExit(0),
	)

	cacheEntryPath := filepath.Join(cacheDir, "cache")
	if _, err := os.Stat(cacheEntryPath); !os.IsNotExist(err) {
		t.Fatalf("cache created while disabled (%s exists)", cacheEntryPath)
	}
}

// E2ETests is the main func to trigger the test suite
func E2ETests(env e2e.TestEnv) func(*testing.T) {
	c := ctx{
		env: env,
	}

	return func(t *testing.T) {
		c.setup(t)
		t.Run("pull", c.testPullCmd)
		t.Run("pullDisableCache", c.testPullDisableCacheCmd)
	}
}
