// Copyright 2016-2018, 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 main

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"syscall"
	"testing"
	"time"

	"github.com/blang/semver"
	"github.com/pulumi/pulumi/sdk/v3/nodejs/npm"
	pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

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

	info := &pulumirpc.ProgramInfo{
		RootDirectory:    "/foo/bar",
		ProgramDirectory: "/foo/bar",
		EntryPoint:       ".",
	}

	t.Run("DryRun-NoArguments", func(t *testing.T) {
		t.Parallel()

		host := &nodeLanguageHost{}
		rr := &pulumirpc.RunRequest{DryRun: true, Info: info}
		args := host.constructArguments(rr, "", "", "")
		assert.Contains(t, args, "--dry-run")
		assert.NotContains(t, args, "true")
	})

	t.Run("OptionalArgs-PassedIfSpecified", func(t *testing.T) {
		t.Parallel()

		host := &nodeLanguageHost{}
		rr := &pulumirpc.RunRequest{Project: "foo", Info: info}
		args := strings.Join(host.constructArguments(rr, "", "", ""), " ")
		assert.Contains(t, args, "--project foo")
	})

	t.Run("OptionalArgs-NotPassedIfNotSpecified", func(t *testing.T) {
		t.Parallel()

		host := &nodeLanguageHost{}
		rr := &pulumirpc.RunRequest{Info: info}
		args := strings.Join(host.constructArguments(rr, "", "", ""), " ")
		assert.NotContains(t, args, "--stack")
	})

	t.Run("DotIfProgramNotSpecified", func(t *testing.T) {
		t.Parallel()

		host := &nodeLanguageHost{}
		rr := &pulumirpc.RunRequest{Info: info}
		args := strings.Join(host.constructArguments(rr, "", "", ""), " ")
		assert.Contains(t, args, ".")
	})

	t.Run("ProgramIfProgramSpecified", func(t *testing.T) {
		t.Parallel()

		host := &nodeLanguageHost{}
		rr := &pulumirpc.RunRequest{
			Program: "foobar",
			Info: &pulumirpc.ProgramInfo{
				RootDirectory:    "/foo/bar",
				ProgramDirectory: "/foo/bar",
				EntryPoint:       "foobar",
			},
		}
		args := strings.Join(host.constructArguments(rr, "", "", ""), " ")
		assert.Contains(t, args, "foobar")
	})
}

func TestConfig(t *testing.T) {
	t.Parallel()
	t.Run("Config-Empty", func(t *testing.T) {
		t.Parallel()

		host := &nodeLanguageHost{}
		rr := &pulumirpc.RunRequest{Project: "foo"}
		str, err := host.constructConfig(rr)
		assert.NoError(t, err)
		assert.JSONEq(t, "{}", str)
	})
}

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

	cases := []struct {
		a          string
		b          string
		compatible bool
		errmsg     string
	}{
		{"0.17.1", "0.16.2", false, "Differing major or minor versions are not supported."},
		{"0.17.1", "1.0.0", true, ""},
		{"1.0.0", "0.17.1", true, ""},
		{"1.13.0", "1.13.0", true, ""},
		{"1.1.1", "1.13.0", true, ""},
		{"1.13.0", "1.1.1", true, ""},
		{"1.1.0", "2.1.0", true, ""},
		{"2.1.0", "1.1.0", true, ""},
		{"1.1.0", "2.0.0-beta1", true, ""},
		{"2.0.0-beta1", "1.1.0", true, ""},
		{"2.1.0", "3.1.0", false, "Differing major versions are not supported."},
		{"0.16.1", "1.0.0", false, "Differing major or minor versions are not supported."},
	}

	for _, c := range cases {
		compatible, errmsg := compatibleVersions(semver.MustParse(c.a), semver.MustParse(c.b))
		assert.Equal(t, c.errmsg, errmsg)
		assert.Equal(t, c.compatible, compatible)
	}
}

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

	dir := t.TempDir()

	files := []struct {
		path    string
		content string
	}{
		{
			filepath.Join(dir, "node_modules", "@pulumi", "foo", "package.json"),
			`{ "name": "@pulumi/foo", "version": "1.2.3", "pulumi": { "resource": true } }`,
		},
		{
			filepath.Join(dir, "node_modules", "@pulumi", "bar", "package.json"),
			`{ "name": "@pulumi/bar", "version": "4.5.6", "pulumi": { "resource": true } }`,
		},
		{
			filepath.Join(dir, "node_modules", "@pulumi", "baz", "package.json"),
			`{ "name": "@pulumi/baz", "version": "4.5.6", "pulumi": { "resource": false } }`,
		},
		{
			filepath.Join(dir, "node_modules", "malformed", "tests", "malformed_test", "package.json"),
			`{`,
		},
	}
	for _, file := range files {
		err := os.MkdirAll(filepath.Dir(file.path), 0o755)
		require.NoError(t, err)
		err = os.WriteFile(file.path, []byte(file.content), 0o600)
		require.NoError(t, err)
	}

	host := &nodeLanguageHost{}
	resp, err := host.GetRequiredPlugins(context.Background(), &pulumirpc.GetRequiredPluginsRequest{
		Program: dir,
		Info: &pulumirpc.ProgramInfo{
			RootDirectory:    dir,
			ProgramDirectory: dir,
			EntryPoint:       ".",
		},
	})
	require.NoError(t, err)

	actual := make(map[string]string)
	for _, plugin := range resp.GetPlugins() {
		actual[plugin.Name] = plugin.Version
	}
	assert.Equal(t, map[string]string{
		"foo": "v1.2.3",
		"bar": "v4.5.6",
	}, actual)
}

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

	dir := t.TempDir()

	files := []struct {
		path    string
		content string
	}{
		{
			filepath.Join(dir, "node_modules", "@pulumi", "foo", "package.json"),
			`{ "name": "@pulumi/foo", "version": "1.2.3", "pulumi": { "resource": true } }`,
		},
		{
			filepath.Join(dir, "node_modules", "@pulumi", "bar", "package.json"),
			`{ "name": "@pulumi/bar", "version": "4.5.6", "pulumi": { "resource": true } }`,
		},
		{
			filepath.Join(dir, "node_modules", "@pulumi", "baz", "package.json"),
			`{ "name": "@pulumi/baz", "version": "4.5.6", "pulumi": { "resource": false } }`,
		},
		{
			filepath.Join(dir, "node_modules", "malformed", "tests", "malformed_test", "package.json"),
			`{`,
		},
	}
	for _, file := range files {
		err := os.MkdirAll(filepath.Dir(file.path), 0o755)
		require.NoError(t, err)
		err = os.WriteFile(file.path, []byte(file.content), 0o600)
		require.NoError(t, err)
	}

	// Add a symlink cycle in
	err := os.Symlink(filepath.Join(dir, "node_modules"), filepath.Join(dir, "node_modules", "@node_modules"))
	require.NoError(t, err)

	host := &nodeLanguageHost{}
	resp, err := host.GetRequiredPlugins(context.Background(), &pulumirpc.GetRequiredPluginsRequest{
		Program: dir,
		Info: &pulumirpc.ProgramInfo{
			RootDirectory:    dir,
			ProgramDirectory: dir,
			EntryPoint:       ".",
		},
	})
	require.NoError(t, err)

	actual := make(map[string]string)
	for _, plugin := range resp.GetPlugins() {
		actual[plugin.Name] = plugin.Version
	}
	assert.Equal(t, map[string]string{
		"foo": "v1.2.3",
		"bar": "v4.5.6",
	}, actual)
}

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

	dir := filepath.Join(t.TempDir(), "testdir")
	err := os.Mkdir(dir, 0o755)
	require.NoError(t, err)

	files := []struct {
		path    string
		content string
	}{
		{
			filepath.Join(dir, "node_modules", "@pulumi", "foo", "package.json"),
			`{ "name": "@pulumi/foo", "version": "1.2.3", "pulumi": { "resource": true } }`,
		},
		{
			filepath.Join(dir, "node_modules", "@pulumi", "bar", "package.json"),
			`{ "name": "@pulumi/bar", "version": "4.5.6", "pulumi": { "resource": true } }`,
		},
		{
			filepath.Join(dir, "node_modules", "@pulumi", "baz", "package.json"),
			`{ "name": "@pulumi/baz", "version": "4.5.6", "pulumi": { "resource": false } }`,
		},
		{
			filepath.Join(dir, "node_modules", "malformed", "tests", "malformed_test", "package.json"),
			`{`,
		},
	}
	for _, file := range files {
		err := os.MkdirAll(filepath.Dir(file.path), 0o755)
		require.NoError(t, err)
		err = os.WriteFile(file.path, []byte(file.content), 0o600)
		require.NoError(t, err)
	}

	// Add a symlink cycle in
	err = os.Symlink(filepath.Join("..", ".."), filepath.Join(dir, "node_modules", "@node_modules"))
	require.NoError(t, err)

	host := &nodeLanguageHost{}
	resp, err := host.GetRequiredPlugins(context.Background(), &pulumirpc.GetRequiredPluginsRequest{
		Program: dir,
		Info: &pulumirpc.ProgramInfo{
			RootDirectory:    dir,
			ProgramDirectory: dir,
			EntryPoint:       ".",
		},
	})
	require.NoError(t, err)

	actual := make(map[string]string)
	for _, plugin := range resp.GetPlugins() {
		actual[plugin.Name] = plugin.Version
	}
	assert.Equal(t, map[string]string{
		"foo": "v1.2.3",
		"bar": "v4.5.6",
	}, actual)
}

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

	dir := filepath.Join(t.TempDir(), "testdir")
	err := os.Mkdir(dir, 0o755)
	require.NoError(t, err)

	files := []struct {
		path    string
		content string
	}{
		{
			filepath.Join(dir, "node_modules", "@pulumi", "foo", "package.json"),
			`{ "name": "@pulumi/foo", "version": "1.2.3", "pulumi": { "resource": true } }`,
		},
		{
			filepath.Join(dir, "node_modules", "@pulumi", "bar", "package.json"),
			`{ "name": "@pulumi/bar", "version": "4.5.6", "pulumi": { "resource": true } }`,
		},
		{
			filepath.Join(dir, "policy", "PulumiPolicy.yaml"),
			`name: my-policy`,
		},
		{
			filepath.Join(dir, "policy", "node_modules", "@pulumi", "baz", "package.json"),
			`{ "name": "@pulumi/baz", "version": "7.8.9", "pulumi": { "resource": true } }`,
		},
	}
	for _, file := range files {
		err := os.MkdirAll(filepath.Dir(file.path), 0o755)
		require.NoError(t, err)
		err = os.WriteFile(file.path, []byte(file.content), 0o600)
		require.NoError(t, err)
	}

	host := &nodeLanguageHost{}
	resp, err := host.GetRequiredPlugins(context.Background(), &pulumirpc.GetRequiredPluginsRequest{
		Program: dir,
		Info: &pulumirpc.ProgramInfo{
			RootDirectory:    dir,
			ProgramDirectory: dir,
			EntryPoint:       ".",
		},
	})
	require.NoError(t, err)

	actual := make(map[string]string)
	for _, plugin := range resp.GetPlugins() {
		actual[plugin.Name] = plugin.Version
	}
	assert.Equal(t, map[string]string{
		"foo": "v1.2.3",
		"bar": "v4.5.6",
		// baz: v7.8.9 is not included because it is in a nested policy pack
	}, actual)
}

type filePathAndContents struct {
	path    string
	content string
}

func setupFiles(t *testing.T, files []filePathAndContents) string {
	dir := filepath.Join(t.TempDir(), "program-dependency-testdir")
	err := os.Mkdir(dir, 0o755)
	require.NoError(t, err)

	for _, file := range files {
		file.path = filepath.Join(dir, file.path)
		err := os.MkdirAll(filepath.Dir(file.path), 0o755)
		require.NoError(t, err)
		err = os.WriteFile(file.path, []byte(file.content), 0o600)
		require.NoError(t, err)
	}
	return dir
}

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

	t.Run("With no package.json, no lock files", func(t *testing.T) {
		t.Parallel()

		testDir := setupFiles(t, []filePathAndContents{
			{
				path:    "Pulumi.yaml",
				content: `name: test`,
			},
		})
		host := &nodeLanguageHost{}
		_, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
			Program: testDir,
			Info: &pulumirpc.ProgramInfo{
				RootDirectory:    testDir,
				ProgramDirectory: testDir,
				EntryPoint:       ".",
			},
		})
		require.ErrorContains(t, err, "no package-lock.json or yarn.lock file found (searching upwards from")
	})

	t.Run("With package.json in project root, no lock files", func(t *testing.T) {
		t.Parallel()

		testDir := setupFiles(t, []filePathAndContents{
			{
				path:    "package.json",
				content: `{ "name": "@pulumi/baz", "dependencies": { "@pulumi/pulumi": "^3.113.0" } }`,
			},
			{
				path:    "Pulumi.yaml",
				content: `name: test`,
			},
		})
		host := &nodeLanguageHost{}
		_, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
			Program: testDir,
			Info: &pulumirpc.ProgramInfo{
				RootDirectory:    testDir,
				ProgramDirectory: testDir,
				EntryPoint:       ".",
			},
		})
		require.ErrorContains(t, err, "no package-lock.json or yarn.lock file found (searching upwards from")
	})

	t.Run("With package.json and yarn.lock in project root", func(t *testing.T) {
		t.Parallel()

		testDir := setupFiles(t, []filePathAndContents{
			{
				path:    "package.json",
				content: `{ "name": "@pulumi/baz", "dependencies": { "@pulumi/pulumi": "^3.113.0" } }`,
			},
			{
				path:    "Pulumi.yaml",
				content: `name: test`,
			},
			{
				path: "yarn.lock",
				content: `"@pulumi/pulumi@^3.0.0", "@pulumi/pulumi@^3.113.0":
  version "3.131.0"
  resolved "https://registry.yarnpkg.com/@pulumi/pulumi/-/pulumi-3.131.0.tgz#6233e5ee5e72907b99415b32be6a9ebf9041f096"
  integrity sha512-QNtQeav3dkU0mRdMe2TVvkBmIGkBevVvbD7/bt0fJlGoX/onzv5tysqi1GWCkXsq0FKtBtGYNpVD6wH0cqMN6g==
  dependencies:
    "@grpc/grpc-js" "^1.10.1"
    "@logdna/tail-file" "^2.0.6"
    "@npmcli/arborist" "^7.3.1"`,
			},
		})
		host := &nodeLanguageHost{}
		resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
			Program: testDir,
			Info: &pulumirpc.ProgramInfo{
				RootDirectory:    testDir,
				ProgramDirectory: testDir,
				EntryPoint:       ".",
			},
		})
		require.NoError(t, err)
		require.Equal(t, len(resp.Dependencies), 1)
		require.Equal(t, resp.Dependencies[0].Name, "@pulumi/pulumi")
		require.Equal(t, resp.Dependencies[0].Version, "3.131.0")
	})

	t.Run("With package.json and yarn.lock in parent dir", func(t *testing.T) {
		t.Parallel()

		testDir := setupFiles(t, []filePathAndContents{
			{
				path:    "package.json",
				content: `{ "name": "@pulumi/baz", "dependencies": { "@pulumi/pulumi": "^3.113.0" } }`,
			},
			{
				path:    filepath.Join("subdir", "Pulumi.yaml"),
				content: `name: test`,
			},
			{
				path: "yarn.lock",
				content: `"@pulumi/pulumi@^3.0.0", "@pulumi/pulumi@^3.113.0":
  version "3.131.0"
  resolved "https://registry.yarnpkg.com/@pulumi/pulumi/-/pulumi-3.131.0.tgz#6233e5ee5e72907b99415b32be6a9ebf9041f096"
  integrity sha512-QNtQeav3dkU0mRdMe2TVvkBmIGkBevVvbD7/bt0fJlGoX/onzv5tysqi1GWCkXsq0FKtBtGYNpVD6wH0cqMN6g==
  dependencies:
    "@grpc/grpc-js" "^1.10.1"
    "@logdna/tail-file" "^2.0.6"
    "@npmcli/arborist" "^7.3.1"`,
			},
		})
		subdir := filepath.Join(testDir, "subdir")
		host := &nodeLanguageHost{}
		resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
			Program: subdir,
			Info: &pulumirpc.ProgramInfo{
				RootDirectory:    subdir,
				ProgramDirectory: subdir,
				EntryPoint:       ".",
			},
		})
		require.NoError(t, err)
		require.Equal(t, len(resp.Dependencies), 1)
		require.Equal(t, resp.Dependencies[0].Name, "@pulumi/pulumi")
		require.Equal(t, resp.Dependencies[0].Version, "3.131.0")
	})

	t.Run("With package.json and yarn.lock in project root", func(t *testing.T) {
		t.Parallel()

		testDir := setupFiles(t, []filePathAndContents{
			{
				path:    "package.json",
				content: `{ "name": "@pulumi/baz", "dependencies": { "@pulumi/pulumi": "^3.113.0" } }`,
			},
			{
				path:    "Pulumi.yaml",
				content: `name: test`,
			},
			{
				path: "yarn.lock",
				content: `"@pulumi/pulumi@^3.0.0", "@pulumi/pulumi@^3.113.0":
  version "3.131.0"
  resolved "https://registry.yarnpkg.com/@pulumi/pulumi/-/pulumi-3.131.0.tgz#6233e5ee5e72907b99415b32be6a9ebf9041f096"
  integrity sha512-QNtQeav3dkU0mRdMe2TVvkBmIGkBevVvbD7/bt0fJlGoX/onzv5tysqi1GWCkXsq0FKtBtGYNpVD6wH0cqMN6g==
  dependencies:
    "@grpc/grpc-js" "^1.10.1"
    "@logdna/tail-file" "^2.0.6"
    "@npmcli/arborist" "^7.3.1"`,
			},
		})
		host := &nodeLanguageHost{}
		resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
			Program: testDir,
			Info: &pulumirpc.ProgramInfo{
				RootDirectory:    testDir,
				ProgramDirectory: testDir,
				EntryPoint:       ".",
			},
		})
		require.NoError(t, err)
		require.Equal(t, len(resp.Dependencies), 1)
		require.Equal(t, resp.Dependencies[0].Name, "@pulumi/pulumi")
		require.Equal(t, resp.Dependencies[0].Version, "3.131.0")
	})

	t.Run("With package.json and package-lock.json in project root", func(t *testing.T) {
		t.Parallel()

		testDir := setupFiles(t, []filePathAndContents{
			{
				path:    "package.json",
				content: `{ "dependencies": { "random": "^5.1.0" } }`,
			},
			{
				path:    "Pulumi.yaml",
				content: `name: test`,
			},
			{
				path: "package-lock.json",
				content: `{
  "name": "pulumi-repos",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "dependencies": {
        "random": "^5.1.0"
      }
    },
    "node_modules/random": {
      "version": "5.1.0",
      "resolved": "https://registry.npmjs.org/random/-/random-5.1.0.tgz",
      "integrity": "sha512-0NGG4HMW9sTstLbignEDasSQJlCGkNQZICIWStZ+h4SzSJfZXpecGKV7qL0AOKcIT8XX9pJ49uZnvI0n/Y+vWA==",
      "engines": {
        "node": ">=18"
      }
    }
  }
}`,
			},
			{
				path:    filepath.Join("node_modules", "random", "package.json"),
				content: `{ "name": "random", "version": "5.1.0", "type": "module" }`,
			},
		})
		host := &nodeLanguageHost{}
		resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
			Program: testDir,
			Info: &pulumirpc.ProgramInfo{
				RootDirectory:    testDir,
				ProgramDirectory: testDir,
				EntryPoint:       ".",
			},
		})
		require.NoError(t, err)
		require.Equal(t, 1, len(resp.Dependencies))
		require.Equal(t, "random", resp.Dependencies[0].Name)
		require.Equal(t, "5.1.0", resp.Dependencies[0].Version)
	})

	t.Run("With package.json and package-lock.json in parent dir", func(t *testing.T) {
		t.Parallel()

		testDir := setupFiles(t, []filePathAndContents{
			{
				path:    "package.json",
				content: `{ "dependencies": { "random": "^5.1.0" } }`,
			},
			{
				path:    filepath.Join("subdir", "Pulumi.yaml"),
				content: `name: test`,
			},
			{
				path: "package-lock.json",
				content: `{
  "name": "pulumi-repos",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "dependencies": {
        "random": "^5.1.0"
      }
    },
    "node_modules/random": {
      "version": "5.1.0",
      "resolved": "https://registry.npmjs.org/random/-/random-5.1.0.tgz",
      "integrity": "sha512-0NGG4HMW9sTstLbignEDasSQJlCGkNQZICIWStZ+h4SzSJfZXpecGKV7qL0AOKcIT8XX9pJ49uZnvI0n/Y+vWA==",
      "engines": {
        "node": ">=18"
      }
    }
  }
}`,
			},
			{
				path:    filepath.Join("node_modules", "random", "package.json"),
				content: `{ "name": "random", "version": "5.1.0", "type": "module" }`,
			},
		})

		subdir := filepath.Join(testDir, "subdir")
		host := &nodeLanguageHost{}
		resp, err := host.GetProgramDependencies(context.Background(), &pulumirpc.GetProgramDependenciesRequest{
			Program: subdir,
			Info: &pulumirpc.ProgramInfo{
				RootDirectory:    subdir,
				ProgramDirectory: subdir,
				EntryPoint:       ".",
			},
		})
		require.NoError(t, err)
		require.Equal(t, 1, len(resp.Dependencies))
		require.Equal(t, "random", resp.Dependencies[0].Name)
		require.Equal(t, "5.1.0", resp.Dependencies[0].Version)
	})
}

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

	opts, err := parseOptions(nil)
	require.NoError(t, err)
	require.Equal(t, npm.AutoPackageManager, opts.packagemanager)

	_, err = parseOptions(map[string]interface{}{
		"typescript": 123,
	})
	require.ErrorContains(t, err, "typescript option must be a boolean")

	_, err = parseOptions(map[string]interface{}{
		"packagemanager": "poetry",
	})
	require.ErrorContains(t, err, "packagemanager option must be one of")

	for _, tt := range []struct {
		input    string
		expected npm.PackageManagerType
	}{
		{"auto", npm.AutoPackageManager},
		{"npm", npm.NpmPackageManager},
		{"yarn", npm.YarnPackageManager},
		{"pnpm", npm.PnpmPackageManager},
	} {
		opts, err = parseOptions(map[string]interface{}{
			"packagemanager": tt.input,
		})
		require.NoError(t, err)
		require.Equal(t, tt.expected, opts.packagemanager)
	}
}

// Nodejs sometimes sets stdout/stderr to non-blocking mode. When a nodejs subprocess is directly
// handed the go process's stdout/stderr file descriptors, nodejs's non-blocking configuration goes
// unnoticed by go, and a write from go can result in an error `write /dev/stdout: resource
// temporarily unavailable`. See runWithOutput for more details.
func TestNonblockingStdout(t *testing.T) {
	// Regression test for https://github.com/pulumi/pulumi/issues/16503
	t.Parallel()

	script := `import os, time
os.set_blocking(1, False) # set stdout to non-blocking
time.sleep(3)
`

	// Create a named pipe to use as stdout
	tmp := os.TempDir()
	p := filepath.Join(tmp, "fake-stdout")
	err := syscall.Mkfifo(p, 0o644)
	defer os.Remove(p)
	require.NoError(t, err)
	// Open fd without O_NONBLOCK, ensuring that os.NewFile does not return a pollable file.
	// When our python script changes the file to non-blocking, Go does not notice and continues to
	// expect the file to be blocking, and we can trigger the bug.
	fd, err := syscall.Open(p, syscall.O_CREAT|syscall.O_RDWR, 0o644)
	require.NoError(t, err)
	fakeStdout := os.NewFile(uintptr(fd), p)
	defer fakeStdout.Close()
	require.NotNil(t, fakeStdout)

	cmd := exec.Command("python3", "-c", script)

	var done bool
	go func() {
		time.Sleep(2 * time.Second)
		for !done {
			s := "....................\n"
			n, err := fakeStdout.Write([]byte(s))
			require.NoError(t, err)
			require.Equal(t, n, len(s))
		}
	}()

	require.NoError(t, runWithOutput(cmd, fakeStdout, os.Stderr))
	done = true
}

type slowWriter struct {
	nWrites *int
}

func (s slowWriter) Write(b []byte) (int, error) {
	time.Sleep(100 * time.Millisecond)
	l := len(b)
	*s.nWrites += l
	return l, nil
}

func TestRunWithOutputDoesNotMissData(t *testing.T) {
	// This test ensures that runWithOutput writes all the data from the command and does not miss
	// any data that might be buffered when the command exits.
	t.Parallel()

	// Write `o` to stdout 100 times at 10 ms interval, followed by `x\n`
	// Write `e` to stderr 100 times at 10 ms interval, followed by `x\n`
	script := `let i = 0;
	let interval = setInterval(() => {
		process.stdout.write("o");
		process.stderr.write("e");
		i++;
		if (i == 100) {
			process.stdout.write("x\n");
			process.stderr.write("x\n");
			clearInterval(interval);
		}
	}, 10)
	`

	cmd := exec.Command("node", "-e", script)
	stdout := slowWriter{nWrites: new(int)}
	stderr := slowWriter{nWrites: new(int)}

	require.NoError(t, runWithOutput(cmd, stdout, stderr))

	require.Equal(t, 100+2 /* "x\n" */, *stdout.nWrites)
	require.Equal(t, 100+2 /* "x\n" */, *stderr.nWrites)
}

//nolint:paralleltest // mutates environment variables
func TestUseFnm(t *testing.T) {
	// Set $PATH to to $TMPDIR/bin so that no `fnm` executable can be found.
	tmpDir := t.TempDir()
	require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "bin"), 0o755))
	t.Setenv("PATH", filepath.Join(tmpDir, "bin"))

	_, err := useFnm(tmpDir)
	require.ErrorIs(t, err, errFnmNotFound)

	// Add a fake fnm binary to $TMPDIR/bin for the rest of the tests.
	//nolint:gosec // we want this file to be executable
	require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "bin", "fnm"), []byte("#!/bin/sh\nexit 0;\n"), 0o700))

	t.Run("no version files", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		_, err := useFnm(tmpDir)
		require.ErrorIs(t, err, errVersionFileNotFound)
	})

	t.Run(".node-version in cwd", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("22.7.3"), 0o600))
		version, err := useFnm(tmpDir)
		require.NoError(t, err)
		require.Equal(t, "22.7.3", version)
	})

	t.Run(".nvmrc in cwd", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".nvmrc"), []byte("20.1.1"), 0o600))
		version, err := useFnm(tmpDir)
		require.NoError(t, err)
		require.Equal(t, "20.1.1", version)
	})

	t.Run(".nvmrc & .node-version in cwd", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		// .nvmrc should take precedence
		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".nvmrc"), []byte("20.1.1"), 0o600))
		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("22.7.3"), 0o600))
		version, err := useFnm(tmpDir)
		require.NoError(t, err)
		require.Equal(t, "20.1.1", version)
	})

	t.Run(".node-version in parent folder", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		tmpDirNested := filepath.Join(tmpDir, "nested")
		require.NoError(t, os.MkdirAll(tmpDirNested, 0o700))
		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("20.1.1"), 0o600))
		version, err := useFnm(tmpDirNested)
		require.NoError(t, err)
		require.Equal(t, "20.1.1", version)
	})

	t.Run(".node-version in cwd & parent", func(t *testing.T) {
		t.Parallel()
		tmpDir := t.TempDir()
		tmpDirNested := filepath.Join(tmpDir, "nested")
		require.NoError(t, os.MkdirAll(tmpDirNested, 0o700))
		require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("20.1.1"), 0o600))
		// This should take precedence over the parent folder's .node-version
		require.NoError(t, os.WriteFile(filepath.Join(tmpDirNested, ".node-version"), []byte("20.7.3"), 0o600))
		version, err := useFnm(tmpDirNested)
		require.NoError(t, err)
		require.Equal(t, "20.7.3", version)
	})
}

//nolint:paralleltest // mutates environment variables
func TestNodeInstall(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip()
	}
	tmpDir := t.TempDir()
	t.Setenv("PATH", filepath.Join(tmpDir, "bin"))

	fmt.Println(os.Getenv("PATH"))

	// There's no fnm executable in PATH, installNodeVersion is a no-op
	stdout := &bytes.Buffer{}
	err := installNodeVersion(tmpDir, stdout)
	require.ErrorIs(t, err, errFnmNotFound)

	// Add a mock fnm executable to $tmp/bin. For each execution, the mock fnm executable will
	// append a line with its arguments to a file. We read back the file to verify that it was
	// called with the expected arguments.
	require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "bin"), 0o755))
	outPath := filepath.Join(tmpDir, "out.txt")
	script := fmt.Sprintf("#!/bin/sh\necho $@ >> %s\n", outPath)
	//nolint:gosec // we want this file to be executable
	require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "bin", "fnm"), []byte(script), 0o700))

	// There's no .node-version or .nvmrc file, so the binary should not be called.
	// We expect the file written by our mock fnm executable to not exist.
	stdout = &bytes.Buffer{}
	err = installNodeVersion(tmpDir, stdout)
	require.Error(t, err, errVersionFileNotFound)

	// Create a .node-version file
	// The mock fnm executable should be called with a command to install the requested version,
	// and a command to set the default version to this version.
	require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".node-version"), []byte("20.1.2"), 0o600))
	stdout = &bytes.Buffer{}
	err = installNodeVersion(tmpDir, stdout)
	require.NoError(t, err)
	b, err := os.ReadFile(outPath)
	require.NoError(t, err)
	commands := strings.Split(strings.TrimSpace(string(b)), "\n")
	require.Equal(t, "install 20.1.2 --progress never", commands[0])
	require.Equal(t, "alias 20.1.2 default", commands[1])
}