// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

//go:build linux || darwin
// +build linux darwin

package envoy

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestExecEnvoy(t *testing.T) {
	if testing.Short() {
		t.Skip("too slow for testing.Short")
	}

	cases := []struct {
		Name     string
		Args     []string
		WantArgs []string
	}{
		{
			Name: "default",
			Args: []string{},
			WantArgs: []string{
				"--config-path",
				"{{ got.ConfigPath }}",
				"--disable-hot-restart",
				"--fake-envoy-arg",
			},
		},
		{
			Name: "hot-restart-epoch",
			Args: []string{"--restart-epoch", "1"},
			WantArgs: []string{
				"--config-path",
				// Different platforms produce different file descriptors here so we use the
				// value we got back. This is somewhat tautological but we do sanity check
				// that value further below.
				"{{ got.ConfigPath }}",
				// No --disable-hot-restart
				"--fake-envoy-arg",
				"--restart-epoch",
				"1",
			},
		},
		{
			Name: "hot-restart-version",
			Args: []string{"--drain-time-s", "10"},
			WantArgs: []string{
				"--config-path",
				// Different platforms produce different file descriptors here so we use the
				// value we got back. This is somewhat tautological but we do sanity check
				// that value further below.
				"{{ got.ConfigPath }}",
				// No --disable-hot-restart
				"--fake-envoy-arg",
				// Restart epoch defaults to 0 if not given and not disabled.
				"--drain-time-s",
				"10",
			},
		},
		{
			Name: "hot-restart-version",
			Args: []string{"--parent-shutdown-time-s", "20"},
			WantArgs: []string{
				"--config-path",
				// Different platforms produce different file descriptors here so we use the
				// value we got back. This is somewhat tautological but we do sanity check
				// that value further below.
				"{{ got.ConfigPath }}",
				// No --disable-hot-restart
				"--fake-envoy-arg",
				// Restart epoch defaults to 0 if not given and not disabled.
				"--parent-shutdown-time-s",
				"20",
			},
		},
		{
			Name: "hot-restart-version",
			Args: []string{"--hot-restart-version", "foobar1"},
			WantArgs: []string{
				"--config-path",
				// Different platforms produce different file descriptors here so we use the
				// value we got back. This is somewhat tautological but we do sanity check
				// that value further below.
				"{{ got.ConfigPath }}",
				// No --disable-hot-restart
				"--fake-envoy-arg",
				// Restart epoch defaults to 0 if not given and not disabled.
				"--hot-restart-version",
				"foobar1",
			},
		},
	}

	for _, tc := range cases {
		t.Run(tc.Name, func(t *testing.T) {

			args := append([]string{"exec-fake-envoy"}, tc.Args...)
			cmd, destroy := helperProcess(args...)
			defer destroy()

			cmd.Stderr = os.Stderr
			outBytes, err := cmd.Output()
			require.NoError(t, err)

			var got FakeEnvoyExecData
			require.NoError(t, json.Unmarshal(outBytes, &got))

			expectConfigData := fakeEnvoyTestData

			// Substitute the right FD path
			for idx := range tc.WantArgs {
				tc.WantArgs[idx] = strings.Replace(tc.WantArgs[idx],
					"{{ got.ConfigPath }}", got.ConfigPath, 1)
			}

			require.Equal(t, tc.WantArgs, got.Args)
			require.Equal(t, expectConfigData, got.ConfigData)
			// Sanity check the config path in a non-brittle way since we used it to
			// generate expectation for the args.
			require.Regexp(t, `-bootstrap.json$`, got.ConfigPath)
		})
	}
}

func TestExecEnvoyVersion(t *testing.T) {
	if testing.Short() {
		t.Skip("too slow for testing.Short")
	}

	tests := []struct {
		name           string
		cmdOutput      string
		expectedOutput string
	}{
		{
			name:           "actual-version-output-1-24-1",
			cmdOutput:      `envoy  version: 69958e4fe32da561376d8b1d367b5e6942dfba24/1.24.1/Distribution/RELEASE/BoringSSL`,
			expectedOutput: "1.24.1",
		},
		{
			name:           "format-change",
			cmdOutput:      `envoy  version: (69958e4fe32da561376d8b1d367b5e6942dfba24)__(1.24.1)/Distribution/RELEASE/BoringSSL`,
			expectedOutput: "1.24.1",
		},
		{
			name:           "zeroes",
			cmdOutput:      `envoy  version: 69958e4fe32da561376d8b1d367b5e6942dfba24/0.0.0/Distribution/RELEASE/BoringSSL`,
			expectedOutput: "0.0.0",
		},
		{
			name:           "test-multi-digit",
			cmdOutput:      `envoy  version: 69958e4fe32da561376d8b1d367b5e6942dfba24/1246390.9401081.1238495/Distribution/RELEASE/BoringSSL`,
			expectedOutput: "1246390.9401081.1238495",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			fe := fakeEnvoy{
				desiredOutput: tc.cmdOutput,
			}
			execCommand = fe.ExecCommand
			// Reset back to base exec.Command
			defer func() { execCommand = exec.Command }()
			version, err := execEnvoyVersion("fake-envoy")

			require.NoError(t, err)

			assert.Equal(t, tc.expectedOutput, version)
		})
	}
}

type fakeEnvoy struct {
	desiredOutput string
}

func (fe fakeEnvoy) ExecCommand(command string, args ...string) *exec.Cmd {
	cs := []string{"-test.run=TestEnvoyExecHelperProcess", "--", command}
	cs = append(cs, args...)
	// last argument will be the output
	cs = append(cs, fe.desiredOutput)
	cmd := exec.Command(os.Args[0], cs...)
	cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"}
	return cmd
}

func TestEnvoyExecHelperProcess(t *testing.T) {
	if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
		return
	}

	output := os.Args[len(os.Args)-1]
	fmt.Fprint(os.Stdout, output)
	os.Exit(0)
}

type FakeEnvoyExecData struct {
	Args       []string `json:"args"`
	ConfigPath string   `json:"configPath"`
	ConfigData string   `json:"configData"`
}

// helperProcessSentinel is a sentinel value that is put as the first
// argument following "--" and is used to determine if TestHelperProcess
// should run.
const helperProcessSentinel = "GO_WANT_HELPER_PROCESS"

// helperProcess returns an *exec.Cmd that can be used to execute the
// TestHelperProcess function below. This can be used to test multi-process
// interactions.
func helperProcess(s ...string) (*exec.Cmd, func()) {
	cs := []string{"-test.run=TestHelperProcess", "--", helperProcessSentinel}
	cs = append(cs, s...)

	cmd := exec.Command(os.Args[0], cs...)
	destroy := func() {
		if p := cmd.Process; p != nil {
			p.Kill()
		}
	}

	return cmd, destroy
}

const fakeEnvoyTestData = "pahx9eiPoogheb4haeb2abeem1QuireWahtah1Udi5ae4fuD0c"

// This is not a real test. This is just a helper process kicked off by tests
// using the helperProcess helper function.
func TestHelperProcess(t *testing.T) {
	args := os.Args
	for len(args) > 0 {
		if args[0] == "--" {
			args = args[1:]
			break
		}

		args = args[1:]
	}

	if len(args) == 0 || args[0] != helperProcessSentinel {
		return
	}

	defer os.Exit(0)
	args = args[1:] // strip sentinel value
	cmd, args := args[0], args[1:]

	switch cmd {
	case "exec-fake-envoy":
		// this will just exec the "fake-envoy" flavor below

		limitProcessLifetime(2 * time.Minute)

		patchExecArgs(t)
		err := execEnvoy(
			os.Args[0],
			[]string{
				"-test.run=TestHelperProcess",
				"--",
				helperProcessSentinel,
				"fake-envoy",
			},
			append([]string{"--fake-envoy-arg"}, args...),
			[]byte(fakeEnvoyTestData),
		)
		if err != nil {
			fmt.Fprintf(os.Stderr, "fake envoy process failed to exec: %v\n", err)
			os.Exit(1)
		}

	case "fake-envoy":
		// This subcommand is instrumented to verify some settings
		// survived an exec.

		limitProcessLifetime(2 * time.Minute)

		data := FakeEnvoyExecData{
			Args: args,
		}

		// Dump all of the args.
		var captureNext bool
		for _, arg := range args {
			if arg == "--config-path" {
				captureNext = true
			} else if captureNext {
				data.ConfigPath = arg
				captureNext = false
			}
		}

		if data.ConfigPath == "" {
			fmt.Fprintf(os.Stderr, "did not detect a --config-path argument passed through\n")
			os.Exit(1)
		}

		d, err := os.ReadFile(data.ConfigPath)
		if err != nil {
			fmt.Fprintf(os.Stderr, "could not read provided --config-path file %q: %v\n", data.ConfigPath, err)
			os.Exit(1)
		}
		data.ConfigData = string(d)

		enc := json.NewEncoder(os.Stdout)
		if err := enc.Encode(&data); err != nil {
			fmt.Fprintf(os.Stderr, "could not dump results to stdout: %v", err)
			os.Exit(1)

		}

	default:
		fmt.Fprintf(os.Stderr, "Unknown command: %q\n", cmd)
		os.Exit(2)
	}
}

// limitProcessLifetime installs a background goroutine that self-exits after
// the specified duration elapses to prevent leaking processes from tests that
// may spawn them.
func limitProcessLifetime(dur time.Duration) {
	go time.AfterFunc(dur, func() {
		os.Exit(99)
	})
}

// patchExecArgs to use a version that will execute the commands using 'go run'.
// Also sets up a cleanup function to revert the patch when the test exits.
func patchExecArgs(t *testing.T) {
	orig := execArgs
	// go run will run the consul source from the root of the repo. The relative
	// path is necessary because `go test` always sets the working directory to
	// the directory of the package being tested.
	execArgs = func(args ...string) (string, []string, error) {
		args = append([]string{"run", "../../.."}, args...)
		return "go", args, nil
	}
	t.Cleanup(func() {
		execArgs = orig
	})
}

func TestMakeBootstrapPipe_DoesNotBlockOnAFullPipe(t *testing.T) {
	// A named pipe can buffer up to 64k, use a value larger than that
	bootstrap := bytes.Repeat([]byte("a"), 66000)

	patchExecArgs(t)
	pipe, err := makeBootstrapPipe(bootstrap)
	require.NoError(t, err)

	// Read everything from the named pipe, to allow the sub-process to exit
	f, err := os.Open(pipe)
	require.NoError(t, err)

	var buf bytes.Buffer
	_, err = io.Copy(&buf, f)
	require.NoError(t, err)
	require.Equal(t, bootstrap, buf.Bytes())
}
