// 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"
	"path/filepath"
	"strconv"
	"testing"

	pulumi_testing "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) {
	t.Helper()

	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 // mutates environment variables, 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 // mutates environment variables, changes working directory
func TestYarnInstall(t *testing.T) {
	t.Setenv("PULUMI_PREFER_YARN", "true")

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

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

func testInstall(t *testing.T, expectedBin string, production bool) {
	t.Helper()

	// 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>
	//
	// 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))

	// Install dependencies, passing nil for stdout and stderr, which connects
	// them to the file descriptor for the null device (os.DevNull).
	pulumi_testing.YarnInstallMutex.Lock()
	defer pulumi_testing.YarnInstallMutex.Unlock()
	out := iotest.LogWriter(t)
	bin, err := Install(context.Background(), pkgdir, production, out, out)
	assert.NoError(t, err)
	assert.Equal(t, expectedBin, 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(tb testing.TB) string {
	tb.Helper()

	// The server needs the tarball's SHA-1 hash so we'll build it in
	// advance.
	tarball, tarballSHA1 := tarballOf(tb,
		// 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) {
		tb.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(tb, err) {
				http.Error(w, err.Error(), http.StatusInternalServerError)
			}

		default:
			w.WriteHeader(http.StatusNotFound)
		}
	}))
	tb.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(tb testing.TB, pairs ...string) (data []byte, sha string) {
	tb.Helper()

	require.True(tb, 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(tb, tarw.WriteHeader(&tar.Header{
			Name: path,
			Mode: 0o600,
			Size: int64(len(contents)),
		}), "WriteHeader(%q)", path)
		_, err := tarw.Write([]byte(contents))
		require.NoError(tb, err, "WriteContents(%q)", path)
	}

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

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

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

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