// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package debug

import (
	"archive/tar"
	"bufio"
	"bytes"
	"compress/gzip"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"testing"
	"time"

	"github.com/google/pprof/profile"
	"github.com/mitchellh/cli"
	testifyassert "github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gotest.tools/v3/assert"
	"gotest.tools/v3/assert/cmp"
	"gotest.tools/v3/fs"

	"github.com/hashicorp/consul/agent"
	"github.com/hashicorp/consul/sdk/testutil"
	"github.com/hashicorp/consul/testrpc"
)

func TestDebugCommand_Help_TextContainsNoTabs(t *testing.T) {
	if strings.ContainsRune(New(cli.NewMockUi()).Help(), '\t') {
		t.Fatal("help has tabs")
	}
}

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

	testDir := testutil.TempDir(t, "debug")

	a := agent.NewTestAgent(t, `
	enable_debug = true
	`)

	defer a.Shutdown()
	testrpc.WaitForLeader(t, a.RPC, "dc1")

	ui := cli.NewMockUi()
	cmd := New(ui)
	cmd.validateTiming = false

	it := &incrementalTime{
		base: time.Date(2021, 7, 8, 9, 10, 11, 0, time.UTC),
	}
	cmd.timeNow = it.Now

	outputPath := fmt.Sprintf("%s/debug", testDir)
	args := []string{
		"-http-addr=" + a.HTTPAddr(),
		"-output=" + outputPath,
		"-duration=2s",
		"-interval=1s",
		"-archive=false",
	}

	code := cmd.Run(args)
	require.Equal(t, 0, code)
	require.Equal(t, "", ui.ErrorWriter.String())

	expected := fs.Expected(t,
		fs.WithDir("debug",
			fs.WithFile("index.json", "", fs.MatchFileContent(validIndexJSON)),
			fs.WithFile("agent.json", "", fs.MatchFileContent(validJSON)),
			fs.WithFile("host.json", "", fs.MatchFileContent(validJSON)),
			fs.WithFile("members.json", "", fs.MatchFileContent(validJSON)),
			fs.WithFile("ports.json", "", fs.MatchFileContent(validJSON)),
			fs.WithFile("metrics.json", "", fs.MatchAnyFileContent),
			fs.WithFile("listpeers.json", "", fs.MatchAnyFileContent),
			fs.WithFile("consul.log", "", fs.MatchFileContent(validLogFile)),
			fs.WithFile("profile.prof", "", fs.MatchFileContent(validProfileData)),
			fs.WithFile("trace.out", "", fs.MatchAnyFileContent),
			fs.WithDir("2021-07-08T09-10-12Z",
				fs.WithFile("goroutine.prof", "", fs.MatchFileContent(validProfileData)),
				fs.WithFile("heap.prof", "", fs.MatchFileContent(validProfileData))),
			fs.WithDir("2021-07-08T09-10-13Z",
				fs.WithFile("goroutine.prof", "", fs.MatchFileContent(validProfileData)),
				fs.WithFile("heap.prof", "", fs.MatchFileContent(validProfileData))),
			// Ignore the extra directories, they should be the same as the first two
			fs.MatchExtraFiles))
	assert.Assert(t, fs.Equal(testDir, expected))

	require.Equal(t, "", ui.ErrorWriter.String(), "expected no error output")
}

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

	a := agent.NewTestAgent(t, `
		enable_debug = true
	`)

	defer a.Shutdown()
	testrpc.WaitForLeader(t, a.RPC, "dc1")

	ui := cli.NewMockUi()
	cmd := New(ui)
	cmd.validateTiming = false

	args := []string{
		"-since=1m",
	}

	t.Setenv("CONSUL_HTTP_ADDR", a.HTTPAddr())

	code := cmd.Run(args)
	require.Equal(t, 0, code)
	require.Equal(t, "", ui.ErrorWriter.String())
}

func validLogFile(raw []byte) fs.CompareResult {
	scanner := bufio.NewScanner(bytes.NewReader(raw))
	for scanner.Scan() {
		logLine := scanner.Text()
		if !validateLogLine([]byte(logLine)) {
			return cmp.ResultFailure(fmt.Sprintf("log line is not valid %s", logLine))
		}
	}
	if scanner.Err() != nil {
		return cmp.ResultFailure(scanner.Err().Error())
	}
	return cmp.ResultSuccess
}

func validIndexJSON(raw []byte) fs.CompareResult {
	var target debugIndex
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&target); err != nil {
		return cmp.ResultFailure(err.Error())
	}
	return cmp.ResultSuccess
}

func validJSON(raw []byte) fs.CompareResult {
	var target interface{}
	decoder := json.NewDecoder(bytes.NewReader(raw))
	if err := decoder.Decode(&target); err != nil {
		return cmp.ResultFailure(err.Error())
	}
	return cmp.ResultSuccess
}

func validProfileData(raw []byte) fs.CompareResult {
	if _, err := profile.ParseData(raw); err != nil {
		return cmp.ResultFailure(err.Error())
	}
	return cmp.ResultSuccess
}

type incrementalTime struct {
	base time.Time
	next uint64
}

func (t *incrementalTime) Now() time.Time {
	t.next++
	return t.base.Add(time.Duration(t.next) * time.Second)
}

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

	testDir := testutil.TempDir(t, "debug")

	a := agent.NewTestAgent(t, `
	enable_debug = true
	`)

	defer a.Shutdown()
	testrpc.WaitForLeader(t, a.RPC, "dc1")

	ui := cli.NewMockUi()
	cmd := New(ui)
	cmd.validateTiming = false

	outputPath := fmt.Sprintf("%s/debug", testDir)
	args := []string{
		"-http-addr=" + a.HTTPAddr(),
		"-output=" + outputPath,
		"-capture=agent",
	}

	code := cmd.Run(args)
	require.Equal(t, 0, code)
	require.Equal(t, "", ui.ErrorWriter.String())

	archivePath := outputPath + debugArchiveExtension
	file, err := os.Open(archivePath)
	require.NoError(t, err)

	gz, err := gzip.NewReader(file)
	require.NoError(t, err)
	tr := tar.NewReader(gz)

	for {
		h, err := tr.Next()
		switch {
		case err == io.EOF:
			return
		case err != nil:
			t.Fatalf("failed to read file in archive: %s", err)
		}

		// ignore the outer directory
		if h.Name == "debug" {
			continue
		}

		// should only contain this one capture target
		if h.Name != "debug/agent.json" && h.Name != "debug/index.json" && h.Name != "debug/listpeers.json" && h.Name != "debug/ports.json" {
			t.Fatalf("archive contents do not match: %s", h.Name)
		}
	}
}

func TestDebugCommand_ArgsBad(t *testing.T) {
	ui := cli.NewMockUi()
	cmd := New(ui)

	args := []string{"foo", "bad"}

	if code := cmd.Run(args); code == 0 {
		t.Fatalf("should exit non-zero, got code: %d", code)
	}

	errOutput := ui.ErrorWriter.String()
	if !strings.Contains(errOutput, "Too many arguments") {
		t.Errorf("expected error output, got %q", errOutput)
	}
}

func TestDebugCommand_InvalidFlags(t *testing.T) {
	ui := cli.NewMockUi()
	cmd := New(ui)
	cmd.validateTiming = false

	outputPath := ""
	args := []string{
		"-invalid=value",
		"-output=" + outputPath,
		"-duration=100ms",
		"-interval=50ms",
	}

	if code := cmd.Run(args); code == 0 {
		t.Fatalf("should exit non-zero, got code: %d", code)
	}

	errOutput := ui.ErrorWriter.String()
	if !strings.Contains(errOutput, "==> Error parsing flags: flag provided but not defined:") {
		t.Errorf("expected error output, got %q", errOutput)
	}
}

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

	t.Parallel()

	a := agent.NewTestAgent(t, "")
	defer a.Shutdown()
	testrpc.WaitForLeader(t, a.RPC, "dc1")

	ui := cli.NewMockUi()
	cmd := New(ui)
	cmd.validateTiming = false

	outputPath := ""
	args := []string{
		"-http-addr=" + a.HTTPAddr(),
		"-output=" + outputPath,
		"-duration=100ms",
		"-interval=50ms",
	}

	if code := cmd.Run(args); code == 0 {
		t.Fatalf("should exit non-zero, got code: %d", code)
	}

	errOutput := ui.ErrorWriter.String()
	if !strings.Contains(errOutput, "no such file or directory") {
		t.Errorf("expected error output, got %q", errOutput)
	}
}

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

	testDir := testutil.TempDir(t, "debug")

	a := agent.NewTestAgent(t, "")
	defer a.Shutdown()
	testrpc.WaitForLeader(t, a.RPC, "dc1")

	ui := cli.NewMockUi()
	cmd := New(ui)
	cmd.validateTiming = false

	outputPath := fmt.Sprintf("%s/debug", testDir)
	args := []string{
		"-http-addr=" + a.HTTPAddr(),
		"-output=" + outputPath,
		"-duration=100ms",
		"-interval=50ms",
	}

	// Make a directory that conflicts with the output path
	err := os.Mkdir(outputPath, 0755)
	if err != nil {
		t.Fatalf("duplicate test directory creation failed: %s", err)
	}

	if code := cmd.Run(args); code == 0 {
		t.Fatalf("should exit non-zero, got code: %d", code)
	}

	errOutput := ui.ErrorWriter.String()
	if !strings.Contains(errOutput, "directory already exists") {
		t.Errorf("expected error output, got %q", errOutput)
	}
}

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

	cases := map[string]struct {
		// used in -target param
		targets []string
		// existence verified after execution
		files []string
		// non-existence verified after execution
		excludedFiles []string
	}{
		"single": {
			[]string{"agent"},
			[]string{"agent.json"},
			[]string{"host.json", "members.json"},
		},
		"static": {
			[]string{"agent", "host", "cluster"},
			[]string{"agent.json", "host.json", "members.json"},
			[]string{"metrics.json"},
		},
		"metrics-only": {
			[]string{"metrics"},
			[]string{"metrics.json"},
			[]string{"agent.json", "host.json", "members.json"},
		},
		"all-but-pprof": {
			[]string{
				"metrics",
				"logs",
				"host",
				"agent",
				"cluster",
			},
			[]string{
				"host.json",
				"agent.json",
				"members.json",
				"metrics.json",
				"consul.log",
			},
			[]string{},
		},
	}

	for name, tc := range cases {
		testDir := testutil.TempDir(t, "debug")

		a := agent.NewTestAgent(t, `
		enable_debug = true
		`)

		defer a.Shutdown()
		testrpc.WaitForLeader(t, a.RPC, "dc1")

		ui := cli.NewMockUi()
		cmd := New(ui)
		cmd.validateTiming = false

		outputPath := fmt.Sprintf("%s/debug-%s", testDir, name)
		args := []string{
			"-http-addr=" + a.HTTPAddr(),
			"-output=" + outputPath,
			"-archive=false",
			"-duration=100ms",
			"-interval=50ms",
		}
		for _, t := range tc.targets {
			args = append(args, "-capture="+t)
		}

		if code := cmd.Run(args); code != 0 {
			t.Fatalf("should exit 0, got code: %d", code)
		}

		errOutput := ui.ErrorWriter.String()
		if errOutput != "" {
			t.Errorf("expected no error output, got %q", errOutput)
		}

		// Ensure the debug data was written
		_, err := os.Stat(outputPath)
		if err != nil {
			t.Fatalf("output path should exist: %s", err)
		}

		// Ensure the captured static files exist
		for _, f := range tc.files {
			path := fmt.Sprintf("%s/%s", outputPath, f)
			// Glob ignores file system errors
			fs, _ := filepath.Glob(path)
			if len(fs) <= 0 {
				t.Fatalf("%s: output data should exist for %s", name, f)
			}
		}

		// Ensure any excluded files do not exist
		for _, f := range tc.excludedFiles {
			path := fmt.Sprintf("%s/%s", outputPath, f)
			// Glob ignores file system errors
			fs, _ := filepath.Glob(path)
			if len(fs) > 0 {
				t.Fatalf("%s: output data should not exist for %s", name, f)
			}
		}
	}
}

func validateLogLine(content []byte) bool {
	fields := strings.SplitN(string(content), " ", 2)
	if len(fields) != 2 {
		return false
	}
	const logTimeFormat = "2006-01-02T15:04:05.000"
	t := content[:len(logTimeFormat)]
	_, err := time.Parse(logTimeFormat, string(t))
	if err != nil {
		return false
	}
	re := regexp.MustCompile(`(\[(ERROR|WARN|INFO|DEBUG|TRACE)]) (.*?): (.*)`)
	valid := re.Match([]byte(fields[1]))
	return valid
}

func TestDebugCommand_Prepare_ValidateTiming(t *testing.T) {
	cases := map[string]struct {
		duration string
		interval string
		expected string
	}{
		"both": {
			duration: "20ms",
			interval: "10ms",
			expected: "duration must be longer",
		},
		"short interval": {
			duration: "10s",
			interval: "10ms",
			expected: "interval must be longer",
		},
		"lower duration": {
			duration: "20s",
			interval: "30s",
			expected: "must be longer than interval",
		},
	}

	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			ui := cli.NewMockUi()
			cmd := New(ui)

			args := []string{
				"-duration=" + tc.duration,
				"-interval=" + tc.interval,
			}
			err := cmd.flags.Parse(args)
			require.NoError(t, err)

			_, err = cmd.prepare()
			testutil.RequireErrorContains(t, err, tc.expected)
		})
	}
}

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

	t.Parallel()

	testDir := testutil.TempDir(t, "debug")

	a := agent.NewTestAgent(t, `
	enable_debug = false
	`)
	defer a.Shutdown()
	testrpc.WaitForLeader(t, a.RPC, "dc1")

	ui := cli.NewMockUi()
	cmd := New(ui)
	cmd.validateTiming = false

	outputPath := fmt.Sprintf("%s/debug", testDir)
	args := []string{
		"-http-addr=" + a.HTTPAddr(),
		"-output=" + outputPath,
		"-archive=false",
		// CPU profile has a minimum of 1s
		"-duration=1s",
		"-interval=1s",
	}

	if code := cmd.Run(args); code != 0 {
		t.Fatalf("should exit 0, got code: %d", code)
	}

	profiles := []string{"heap.prof", "profile.prof", "goroutine.prof", "trace.out"}
	// Glob ignores file system errors
	for _, v := range profiles {
		fs, _ := filepath.Glob(fmt.Sprintf("%s/*r/%s", outputPath, v))
		require.True(t, len(fs) == 0)
	}

	errOutput := ui.ErrorWriter.String()
	require.Contains(t, errOutput, "Unable to capture pprof")
}

// TestDebugCommand_PprofScenarios tests the four specific pprof capture scenarios
// based on enable_debug and ACL settings
func TestDebugCommand_PprofScenarios(t *testing.T) {
	if testing.Short() {
		t.Skip("too slow for testing.Short")
	}

	testCases := []struct {
		name             string
		enableDebug      bool
		aclEnabled       bool
		expectPprofFiles bool
		expectWarning    bool
		warningContains  string
	}{
		{
			name:             "enable_debug=true, acl=disabled - pprof captured",
			enableDebug:      true,
			aclEnabled:       false,
			expectPprofFiles: true,
			expectWarning:    false,
		},
		{
			name:             "enable_debug=true, acl=enabled - pprof captured",
			enableDebug:      true,
			aclEnabled:       true,
			expectPprofFiles: true,
			expectWarning:    false,
		},
		{
			name:             "enable_debug=false, acl=enabled - pprof captured",
			enableDebug:      false,
			aclEnabled:       true,
			expectPprofFiles: true,
			expectWarning:    false,
		},
		{
			name:             "enable_debug=false, acl=disabled - pprof NOT captured",
			enableDebug:      false,
			aclEnabled:       false,
			expectPprofFiles: false,
			expectWarning:    true,
			warningContains:  "Unable to capture pprof",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// Note: Not using t.Parallel() because concurrent pprof generation
			// can cause conflicts and test failures

			testDir := testutil.TempDir(t, "debug")

			// Configure agent based on test case
			config := fmt.Sprintf(`
				enable_debug = %t
				acl = {
					enabled = %t
					default_policy = "allow"
				}
			`, tc.enableDebug, tc.aclEnabled)

			a := agent.NewTestAgent(t, config)
			defer a.Shutdown()
			testrpc.WaitForLeader(t, a.RPC, "dc1")

			ui := cli.NewMockUi()
			cmd := New(ui)
			cmd.validateTiming = false

			outputPath := fmt.Sprintf("%s/debug", testDir)
			args := []string{
				"-http-addr=" + a.HTTPAddr(),
				"-output=" + outputPath,
				"-archive=false",
				"-duration=1s",
				"-interval=1s",
			}

			code := cmd.Run(args)
			require.Equal(t, 0, code, "debug command should succeed")

			// Check if pprof files were created
			// Check files in root directory (profile.prof, trace.out)
			rootProfiles := []string{"profile.prof", "trace.out"}
			for _, profile := range rootProfiles {
				profilePath := filepath.Join(outputPath, profile)
				_, err := os.Stat(profilePath)

				if tc.expectPprofFiles {
					require.NoError(t, err,
						"Expected pprof file %s to be created for scenario: %s", profile, tc.name)
				} else {
					require.True(t, os.IsNotExist(err),
						"Expected pprof file %s NOT to be created for scenario: %s", profile, tc.name)
				}
			}

			// Check files in timestamped subdirectories (heap.prof, goroutine.prof)
			subdirProfiles := []string{"heap.prof", "goroutine.prof"}
			for _, profile := range subdirProfiles {
				profileFiles, _ := filepath.Glob(filepath.Join(outputPath, "*", profile))

				if tc.expectPprofFiles {
					require.True(t, len(profileFiles) > 0,
						"Expected pprof file %s to be created in subdirectories for scenario: %s", profile, tc.name)
				} else {
					require.True(t, len(profileFiles) == 0,
						"Expected pprof file %s NOT to be created in subdirectories for scenario: %s", profile, tc.name)
				}
			}

			// Check warning messages
			errOutput := ui.ErrorWriter.String()
			if tc.expectWarning {
				require.Contains(t, errOutput, tc.warningContains,
					"Expected warning message for scenario: %s", tc.name)
			} else {
				require.NotContains(t, errOutput, "Unable to capture pprof",
					"Did not expect pprof warning for scenario: %s", tc.name)
			}
		})
	}
}

func startMockTCPServer(t *testing.T, portsList []string) (cleanup func()) {
	t.Helper()
	var listeners []net.Listener

	for _, port := range portsList {
		listener, err := net.Listen("tcp", "127.0.0.1:"+port)
		if err != nil {
			for _, l := range listeners {
				l.Close()
			}
			t.Fatalf("Failed to start mock TCP server on port %s: %v", port, err)
		}

		listeners = append(listeners, listener)

	}
	cleanup = func() {
		for _, l := range listeners {
			l.Close()
		}
	}

	return cleanup
}

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

	emptyStrArr := []string{}

	testCases := []struct {
		name        string
		portsOpen   []string
		portsClosed []string
	}{
		{
			name:        "All ports closed",
			portsOpen:   emptyStrArr,
			portsClosed: default_ports,
		},
		{
			name:        "All ports opened",
			portsOpen:   default_ports,
			portsClosed: emptyStrArr,
		},
		{
			name:        "Ports 8500, 8501, 8502, 8503 Open",
			portsOpen:   []string{"8500", "8501", "8502", "8503"},
			portsClosed: []string{"8600", "8301", "8302", "8300"},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			cleanup := startMockTCPServer(t, tc.portsOpen)
			defer cleanup()

			testDir := t.TempDir()

			a := agent.NewTestAgent(t, `
			enable_debug = true
			`)

			defer a.Shutdown()
			testrpc.WaitForLeader(t, a.RPC, "dc1")

			ui := cli.NewMockUi()
			cmd := New(ui)
			cmd.validateTiming = false

			outputPath := fmt.Sprintf("%s/debug", testDir)
			args := []string{
				"-http-addr=" + a.HTTPAddr(),
				"-output=" + outputPath,
				"-duration=2s",
				"-interval=1s",
				"-archive=false",
			}

			code := cmd.Run(args)
			require.Equal(t, 0, code)
			require.Equal(t, "", ui.ErrorWriter.String())

			expected := PortStatus{
				Open:   tc.portsOpen,
				Closed: tc.portsClosed,
			}

			fileBytes, err := os.ReadFile(outputPath + "/ports.json")
			if err != nil {
				t.Fatalf("Failed to read output file %q: %v", outputPath, err)
			}

			// Parse JSON into struct
			var actual PortStatus
			if err := json.Unmarshal(fileBytes, &actual); err != nil {
				t.Fatalf("Failed to unmarshal JSON from file: %v", err)
			}

			testifyassert.ElementsMatch(t, expected.Open, actual.Open, "Mismatch in open ports")
			testifyassert.ElementsMatch(t, expected.Closed, actual.Closed, "Mismatch in closed ports")
		})
	}
}
