// +build integration

package integration

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"testing"

	"github.com/docker/go-connections/nat"
	"github.com/google/go-containerregistry/pkg/name"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	testcontainers "github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"

	_ "github.com/aquasecurity/fanal/analyzer"
	testdocker "github.com/aquasecurity/trivy/integration/docker"
	"github.com/aquasecurity/trivy/internal"
	"github.com/aquasecurity/trivy/pkg/report"
)

const (
	registryImage = "registry:2"
	registryPort  = "5443/tcp"

	authImage    = "cesanta/docker_auth:1"
	authPort     = "5001/tcp"
	authUsername = "admin"
	authPassword = "badmin"
)

func setupRegistry(ctx context.Context, baseDir string, authURL *url.URL) (testcontainers.Container, error) {
	req := testcontainers.ContainerRequest{
		Name:         "registry",
		Image:        registryImage,
		ExposedPorts: []string{registryPort},
		Env: map[string]string{
			"REGISTRY_HTTP_ADDR":                 "0.0.0.0:5443",
			"REGISTRY_HTTP_TLS_CERTIFICATE":      "/certs/cert.pem",
			"REGISTRY_HTTP_TLS_KEY":              "/certs/key.pem",
			"REGISTRY_AUTH":                      "token",
			"REGISTRY_AUTH_TOKEN_REALM":          fmt.Sprintf("%s/auth", authURL),
			"REGISTRY_AUTH_TOKEN_SERVICE":        "registry.docker.io",
			"REGISTRY_AUTH_TOKEN_ISSUER":         "Trivy auth server",
			"REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE": "/certs/cert.pem",
		},
		BindMounts: map[string]string{
			filepath.Join(baseDir, "data", "certs"): "/certs",
		},
		WaitingFor: wait.ForLog("listening on [::]:5443"),
	}

	registryC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	return registryC, err
}

func setupAuthServer(ctx context.Context, baseDir string) (testcontainers.Container, error) {
	req := testcontainers.ContainerRequest{
		Name:         "docker_auth",
		Image:        authImage,
		ExposedPorts: []string{authPort},
		BindMounts: map[string]string{
			filepath.Join(baseDir, "data", "auth_config"): "/config",
			filepath.Join(baseDir, "data", "certs"):       "/certs",
		},
		Cmd: []string{"/config/config.yml"},
	}

	authC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	return authC, err
}

func getURL(ctx context.Context, container testcontainers.Container, exposedPort nat.Port) (*url.URL, error) {
	ip, err := container.Host(ctx)
	if err != nil {
		return nil, err
	}

	port, err := container.MappedPort(ctx, exposedPort)
	if err != nil {
		return nil, err
	}

	urlStr := fmt.Sprintf("https://%s:%s", ip, port.Port())
	return url.Parse(urlStr)
}

type registryOption struct {
	AuthURL       *url.URL
	Username      string
	Password      string
	RegistryToken bool
}

func TestRegistry(t *testing.T) {
	ctx := context.Background()

	baseDir, err := filepath.Abs(".")
	require.NoError(t, err)

	// set up auth server
	authC, err := setupAuthServer(ctx, baseDir)
	require.NoError(t, err)
	defer authC.Terminate(ctx)

	authURL, err := getURL(ctx, authC, authPort)
	require.NoError(t, err)

	// set up registry
	registryC, err := setupRegistry(ctx, baseDir, authURL)
	require.NoError(t, err)
	defer registryC.Terminate(ctx)

	registryURL, err := getURL(ctx, registryC, registryPort)
	require.NoError(t, err)

	config := testdocker.RegistryConfig{
		URL:      registryURL,
		Username: authUsername,
		Password: authPassword,
	}

	testCases := []struct {
		name      string
		imageName string
		imageFile string
		option    registryOption
		golden    string
		wantErr   string
	}{
		{
			name:      "happy path with username/password",
			imageName: "alpine:3.10",
			imageFile: "testdata/fixtures/alpine-310.tar.gz",
			option: registryOption{
				AuthURL:  authURL,
				Username: authUsername,
				Password: authPassword,
			},
			golden: "testdata/alpine-310-registry.json.golden",
		},
		{
			name:      "happy path with registry token",
			imageName: "alpine:3.10",
			imageFile: "testdata/fixtures/alpine-310.tar.gz",
			option: registryOption{
				AuthURL:       authURL,
				Username:      authUsername,
				Password:      authPassword,
				RegistryToken: true,
			},
			golden: "testdata/alpine-310-registry.json.golden",
		},
		{
			name:      "sad path",
			imageName: "alpine:3.10",
			imageFile: "testdata/fixtures/alpine-310.tar.gz",
			wantErr:   "unsupported status code 401; body: Auth failed",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			d, err := testdocker.New()
			require.NoError(t, err)

			s := fmt.Sprintf("%s/%s", registryURL.Host, tc.imageName)
			imageRef, err := name.ParseReference(s)
			require.NoError(t, err)

			// 1. Load a test image from the tar file, tag it and push to the test registry.
			err = d.ReplicateImage(ctx, tc.imageName, tc.imageFile, config)
			require.NoError(t, err)

			// 2. Scan it
			resultFile, cleanup, err := scan(imageRef, baseDir, tc.golden, tc.option)

			if tc.wantErr != "" {
				require.NotNil(t, err)
				require.Contains(t, err.Error(), tc.wantErr, err)
				return
			} else {
				require.NoError(t, err)
			}
			defer cleanup()

			// 3. Compare want and got
			golden, err := os.Open(tc.golden)
			assert.NoError(t, err)

			var want report.Results
			err = json.NewDecoder(golden).Decode(&want)
			require.NoError(t, err)

			result, err := os.Open(resultFile)
			assert.NoError(t, err)

			var got report.Results
			err = json.NewDecoder(result).Decode(&got)
			require.NoError(t, err)

			assert.Equal(t, want[0].Vulnerabilities, got[0].Vulnerabilities)
			assert.Equal(t, want[0].Vulnerabilities, got[0].Vulnerabilities)
		})
	}
}

func scan(imageRef name.Reference, baseDir, goldenFile string, opt registryOption) (string, func(), error) {
	cleanup := func() {}

	// Copy DB file
	cacheDir, err := gunzipDB()
	if err != nil {
		return "", cleanup, err
	}
	defer os.RemoveAll(cacheDir)

	// Setup the output file
	var outputFile string
	if *update && goldenFile != "" {
		outputFile = goldenFile
	} else {
		output, err := ioutil.TempFile("", "integration")
		if err != nil {
			return "", cleanup, err
		}
		defer output.Close()

		outputFile = output.Name()
		cleanup = func() {
			os.Remove(outputFile)
		}
	}

	// Setup env
	if err = setupEnv(imageRef, baseDir, opt); err != nil {
		return "", cleanup, err
	}
	defer unsetEnv()

	// Setup CLI App
	app := internal.NewApp("dev")
	app.Writer = ioutil.Discard

	osArgs := []string{"trivy", "--cache-dir", cacheDir, "--format", "json", "--skip-update", "--output", outputFile, imageRef.Name()}

	// Run Trivy
	if err = app.Run(osArgs); err != nil {
		return "", cleanup, err
	}
	return outputFile, cleanup, nil
}

func setupEnv(imageRef name.Reference, baseDir string, opt registryOption) error {
	if err := os.Setenv("TRIVY_INSECURE", "true"); err != nil {
		return err
	}

	if opt.Username != "" && opt.Password != "" {
		if opt.RegistryToken {
			// Get a registry token in advance
			token, err := requestRegistryToken(imageRef, baseDir, opt)
			if err != nil {
				return err
			}
			if err := os.Setenv("TRIVY_REGISTRY_TOKEN", token); err != nil {
				return err
			}
		} else {
			if err := os.Setenv("TRIVY_USERNAME", opt.Username); err != nil {
				return err
			}
			if err := os.Setenv("TRIVY_PASSWORD", opt.Password); err != nil {
				return err
			}
		}
	}
	return nil
}

func unsetEnv() error {
	envs := []string{"TRIVY_INSECURE", "TRIVY_USERNAME", "TRIVY_PASSWORD", "TRIVY_REGISTRY_TOKEN"}
	for _, e := range envs {
		if err := os.Unsetenv(e); err != nil {
			return err
		}
	}
	return nil
}

func requestRegistryToken(imageRef name.Reference, baseDir string, opt registryOption) (string, error) {
	// Create a CA certificate pool and add cert.pem to it
	caCert, err := ioutil.ReadFile(filepath.Join(baseDir, "data", "certs", "cert.pem"))
	if err != nil {
		return "", err
	}
	caCertPool := x509.NewCertPool()
	caCertPool.AppendCertsFromPEM(caCert)

	// Create a HTTPS client and supply the created CA pool
	client := &http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{
				RootCAs: caCertPool,
			},
		},
	}

	// Get a registry token
	req, err := http.NewRequest("GET", fmt.Sprintf("%s/auth", opt.AuthURL), nil)
	if err != nil {
		return "", err
	}

	// Set query parameters
	values := req.URL.Query()
	values.Set("service", "registry.docker.io")
	values.Set("scope", imageRef.Scope("pull"))
	req.URL.RawQuery = values.Encode()

	req.SetBasicAuth(opt.Username, opt.Password)
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	type res struct {
		AccessToken string `json:"access_token"`
	}

	var r res
	if err = json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return "", err
	}

	return r.AccessToken, nil
}
