// Copyright 2016-2020, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package npm

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"crypto/sha1" //nolint:gosec // this is what NPM wants
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"testing"

	ptesting "github.com/pulumi/pulumi/sdk/v3/go/common/testing"
	"github.com/pulumi/pulumi/sdk/v3/go/common/testing/iotest"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// chdir temporarily changes the current directory of the program.
// It restores it to the original directory when the test is done.
func chdir(t *testing.T, dir string) {
	cwd, err := os.Getwd()
	require.NoError(t, err)
	require.NoError(t, os.Chdir(dir)) // Set directory
	t.Cleanup(func() {
		require.NoError(t, os.Chdir(cwd)) // Restore directory
		restoredDir, err := os.Getwd()
		if assert.NoError(t, err) {
			assert.Equal(t, cwd, restoredDir)
		}
	})
}

//nolint:paralleltest // changes working directory
func TestNPMInstall(t *testing.T) {
	t.Run("development", func(t *testing.T) {
		testInstall(t, "npm", false /*production*/)
	})

	t.Run("production", func(t *testing.T) {
		testInstall(t, "npm", true /*production*/)
	})
}

//nolint:paralleltest // changes working directory
func TestYarnInstall(t *testing.T) {
	t.Run("development", func(t *testing.T) {
		testInstall(t, "yarn", false /*production*/)
	})

	t.Run("production", func(t *testing.T) {
		testInstall(t, "yarn", true /*production*/)
	})
}

//nolint:paralleltest // changes working directory
func TestPnpmInstall(t *testing.T) {
	t.Run("development", func(t *testing.T) {
		testInstall(t, "pnpm", false /*production*/)
	})

	t.Run("production", func(t *testing.T) {
		testInstall(t, "pnpm", true /*production*/)
	})
}

func TestResolvePackageManager(t *testing.T) {
	t.Parallel()
	for _, tt := range []struct {
		name      string
		pm        PackageManagerType
		lockFiles []string
		expected  string
	}{
		{"defaults to npm", AutoPackageManager, []string{}, "npm"},
		{"picks npm", NpmPackageManager, []string{}, "npm"},
		{"picks yarn", YarnPackageManager, []string{}, "yarn"},
		{"picks pnpm", PnpmPackageManager, []string{}, "pnpm"},
		{"picks npm based on lockfile", AutoPackageManager, []string{"npm"}, "npm"},
		{"picks yarn based on lockfile", AutoPackageManager, []string{"yarn"}, "yarn"},
		{"picks pnpm based on lockfile", AutoPackageManager, []string{"pnpm"}, "pnpm"},
		{"yarn > pnpm > npm", AutoPackageManager, []string{"yarn", "pnpm", "npm"}, "yarn"},
		{"pnpm > npm", AutoPackageManager, []string{"pnpm", "npm"}, "pnpm"},
	} {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			dir := t.TempDir()
			for _, lockFile := range tt.lockFiles {
				writeLockFile(t, dir, lockFile)
			}
			pm, err := ResolvePackageManager(tt.pm, dir)
			require.NoError(t, err)
			require.Equal(t, tt.expected, pm.Name())
		})
	}
}

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

	packageJSON := []byte(`{
	    "name": "test-package",
		"version": "1.0"
	}`)

	for _, pm := range []string{"npm", "yarn", "pnpm"} {
		pm := pm
		t.Run(pm, func(t *testing.T) {
			t.Parallel()
			dir := t.TempDir()
			writeLockFile(t, dir, pm)
			packageJSONFilename := filepath.Join(dir, "package.json")
			require.NoError(t, os.WriteFile(packageJSONFilename, packageJSON, 0o600))
			stderr := new(bytes.Buffer)

			artifact, err := Pack(context.Background(), AutoPackageManager, dir, stderr)

			require.NoError(t, err)
			// check that the artifact contains a package.json
			b, err := gzip.NewReader(bytes.NewReader((artifact)))
			require.NoError(t, err)
			tr := tar.NewReader(b)
			for {
				h, err := tr.Next()
				if err == io.EOF {
					require.Fail(t, "package.json not found")
					break
				}
				require.NoError(t, err)
				if h.Name == "package/package.json" {
					break
				}
			}
		})
	}
}

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

	// Missing a version field
	packageJSON := []byte(`{
	    "name": "test-package"
	}`)

	for _, tt := range []struct{ packageManager, expectedErrorMessage string }{
		{"npm", "Invalid package, must have name and version"},
		{"yarn", "Package doesn't have a version"},
		{"pnpm", "Package version is not defined in the package.json"},
	} {
		tt := tt
		t.Run(tt.packageManager, func(t *testing.T) {
			t.Parallel()
			dir := t.TempDir()
			writeLockFile(t, dir, tt.packageManager)
			packageJSONFilename := filepath.Join(dir, "package.json")
			require.NoError(t, os.WriteFile(packageJSONFilename, packageJSON, 0o600))
			stderr := new(bytes.Buffer)

			_, err := Pack(context.Background(), AutoPackageManager, dir, stderr)

			exitErr := new(exec.ExitError)
			require.ErrorAs(t, err, &exitErr)
			assert.NotZero(t, exitErr.ExitCode())
			require.Contains(t, stderr.String(), tt.expectedErrorMessage)
		})
	}
}

// writeLockFile writes a mock lockfile for the selected package manager
func writeLockFile(t *testing.T, dir string, packageManager string) {
	t.Helper()
	switch packageManager {
	case "npm":
		writeFile(t, filepath.Join(dir, "package-lock.json"), "{\"lockfileVersion\": 2}")
	case "yarn":
		writeFile(t, filepath.Join(dir, "yarn.lock"), "# yarn lockfile v1")
	case "pnpm":
		writeFile(t, filepath.Join(dir, "pnpm-lock.yaml"), "lockfileVersion: '6.0'")
	}
}

func testInstall(t *testing.T, packageManager string, production bool) {
	// To test this functionality without actually hitting NPM,
	// we'll spin up a local HTTP server that implements a subset
	// of the NPM registry API.
	//
	// We'll tell NPM to use this server with a ~/.npmrc file
	// containing the line:
	//
	//   registry = <srv.URL>
	//
	// Pnpm reads the same .npmrc file.
	//
	// Similarly, we'll tell Yarn to use this server with a
	// ~/.yarnrc file containing the line:
	//
	//  registry "<srv.URL>"
	home := t.TempDir()
	t.Setenv("HOME", home)
	registryURL := fakeNPMRegistry(t)
	writeFile(t, filepath.Join(home, ".npmrc"),
		"registry="+registryURL)
	writeFile(t, filepath.Join(home, ".yarnrc"),
		"registry "+strconv.Quote(registryURL))

	// Create a new empty test directory and change the current working directory to it.
	tempdir := t.TempDir()
	chdir(t, tempdir)

	// Create a package directory to install dependencies into.
	pkgdir := filepath.Join(tempdir, "package")
	assert.NoError(t, os.Mkdir(pkgdir, 0o700))

	// Write out a minimal package.json file that has at least one dependency.
	packageJSONFilename := filepath.Join(pkgdir, "package.json")
	packageJSON := []byte(`{
	    "name": "test-package",
	    "license": "MIT",
	    "dependencies": {
	        "@pulumi/pulumi": "latest"
	    }
	}`)
	assert.NoError(t, os.WriteFile(packageJSONFilename, packageJSON, 0o600))

	writeLockFile(t, pkgdir, packageManager)

	// Install dependencies, passing nil for stdout and stderr, which connects
	// them to the file descriptor for the null device (os.DevNull).
	ptesting.YarnInstallMutex.Lock()
	defer ptesting.YarnInstallMutex.Unlock()
	out := iotest.LogWriter(t)
	bin, err := Install(context.Background(), AutoPackageManager, pkgdir, production, out, out)
	assert.NoError(t, err)
	assert.Equal(t, packageManager, bin)
}

// fakeNPMRegistry starts up an HTTP server that implements a subset of the NPM registry API
// that is sufficient for the tests in this file.
// The server will shut down when the test is complete.
//
// The server responds with fake information about a single package:
// @pulumi/pulumi.
//
// See https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md for
// details on the protocol.
func fakeNPMRegistry(t testing.TB) string {
	t.Helper()

	// The server needs the tarball's SHA-1 hash so we'll build it in
	// advance.
	tarball, tarballSHA1 := tarballOf(t,
		// The bare minimum files needed by NPM.
		"package/package.json", `{
			"name": "@pulumi/pulumi",
			"license": "MIT"
		}`)

	var srv *httptest.Server
	// Separate assignment so we can access srv.URL in the handler.
	srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t.Logf("[fakeNPMRegistry] %v %v", r.Method, r.URL.Path)

		if r.Method != http.MethodGet {
			// We only expect GET requests.
			http.NotFound(w, r)
			return
		}

		switch r.URL.Path {
		case "/@pulumi/pulumi":
			tarballURL := srv.URL + "/@pulumi/pulumi/-/pulumi-3.0.0.tgz"
			fmt.Fprintf(w, `{
				"name": "@pulumi/pulumi",
				"dist-tags": {"latest": "3.0.0"},
				"versions": {
					"3.0.0": {
						"name": "@pulumi/pulumi",
						"version": "3.0.0",
						"dist": {
							"tarball": %q,
							"shasum": %q
						}
					}
				}
			}`, tarballURL, tarballSHA1)

		case "/@pulumi/pulumi/-/pulumi-3.0.0.tgz":
			w.Header().Set("Content-Type", "application/octet-stream")
			w.Header().Set("Content-Length", strconv.Itoa(len(tarball)))
			_, err := w.Write(tarball)
			if !assert.NoError(t, err) {
				http.Error(w, err.Error(), http.StatusInternalServerError)
			}

		default:
			w.WriteHeader(http.StatusNotFound)
		}
	}))
	t.Cleanup(srv.Close)
	return srv.URL
}

// tarballOf constructs a .tar.gz archive containing the given files
// and returns the raw bytes and the SHA-1 hash of the archive.
//
// The files are specified as a list of pairs of paths and contents.
// The paths are relative to the root of the archive.
func tarballOf(t testing.TB, pairs ...string) (data []byte, sha string) {
	t.Helper()

	require.True(t, len(pairs)%2 == 0, "pairs must be a list of path/contents pairs")

	var buff bytes.Buffer // raw .tar.gz bytes
	hash := sha1.New()    //nolint:gosec // this is what NPM wants

	// Order of which writer wraps which is important here.
	// .tar.gz means we need .gz to be the innermost writer.
	gzipw := gzip.NewWriter(io.MultiWriter(&buff, hash))
	tarw := tar.NewWriter(gzipw)

	for i := 0; i < len(pairs); i += 2 {
		path, contents := pairs[i], pairs[i+1]
		require.NoError(t, tarw.WriteHeader(&tar.Header{
			Name: path,
			Mode: 0o600,
			Size: int64(len(contents)),
		}), "WriteHeader(%q)", path)
		_, err := tarw.Write([]byte(contents))
		require.NoError(t, err, "WriteContents(%q)", path)
	}

	// Closing the writers will flush them and write the final tarball bytes.
	require.NoError(t, tarw.Close())
	require.NoError(t, gzipw.Close())

	return buff.Bytes(), hex.EncodeToString(hash.Sum(nil))
}

// writeFile creates a file at the given path with the given contents.
func writeFile(t testing.TB, path, contents string) {
	t.Helper()

	require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
	require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
}