// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package crashmonitor_test

import (
	"bytes"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime/debug"
	"strings"
	"testing"
	"time"

	"golang.org/x/telemetry"
	"golang.org/x/telemetry/internal/counter"
	"golang.org/x/telemetry/internal/crashmonitor"
	"golang.org/x/telemetry/internal/testenv"
)

//
// Add/removed lines from this comment to compensate for minor
// perturbations in line numbers as the code below evolves.
//

func TestMain(m *testing.M) {
	entry := os.Getenv("CRASHMONITOR_TEST_ENTRYPOINT")
	switch entry {
	case "via-stderr.panic", "via-stderr.trap":
		// This mode bypasses Start and debug.SetCrashOutput;
		// the crash is printed to stderr.
		debug.SetTraceback("system")
		crashmonitor.WriteSentinel(os.Stderr)

		if entry == "via-stderr.panic" {
			childPanic() // this line is "TestMain:+10"
		} else {
			childTrap() // this line is "TestMain:+12"
		}
		panic("unreachable")

	case "start.panic", "start.trap", "start.exit":
		// These modes uses Start and debug.SetCrashOutput.
		// We stub the actual telemetry by instead writing to a file.
		crashmonitor.SetIncrementCounter(func(name string) {
			os.WriteFile(os.Getenv("CRASHMONITOR_TELEMETRY_FILE"), []byte(name), 0666)
		})
		crashmonitor.SetChildExitHook(func() {
			os.WriteFile(os.Getenv("CRASHMONITOR_TELEMETRY_EXIT_FILE"), nil, 0666)
		})
		telemetry.Start(telemetry.Config{
			ReportCrashes: true,
			TelemetryDir:  os.Getenv("CRASHMONITOR_TELEMETRY_DIR"),
		})
		switch entry {
		case "start.panic":
			go func() {
				childPanic() // this line is "TestMain.func3:+1"
			}()
			select {} // deadlocks when reached
		case "start.trap":
			go func() {
				childTrap() // this line is "TestMain.func4:+1"
			}()
			select {} // deadlocks when reached
		case "start.exit":
			os.Exit(42)
		}

	default:
		os.Exit(m.Run()) // run tests as normal
	}
}

func childPanic() {
	fmt.Println("hello")
	grandchildPanic() // this line is "childPanic:+2"
}

func grandchildPanic() {
	panic("oops") // this line is "grandchildPanic:=85" (the call from child is inlined)
}

var sinkPtr *int

func childTrap() {
	fmt.Println("hello")
	grandchildTrap(sinkPtr) // this line is "childTrap:+2"
}

func grandchildTrap(i *int) {
	*i = 42 // this line is "grandchildTrap:=96" (the call from childTrap is inlined)
}

// TestViaStderr is an internal test that asserts that the telemetry
// stack generated by the panic in grandchild is correct. It uses
// stderr, and does not rely on [start.Start] or [debug.SetCrashOutput].
func TestViaStderr(t *testing.T) {
	// Standard panic.
	t.Run("panic", func(t *testing.T) {
		_, _, stderr := runSelf(t, "via-stderr.panic")
		got, err := crashmonitor.TelemetryCounterName(stderr)
		if err != nil {
			t.Fatal(err)
		}
		got = sanitize(counter.DecodeStack(got))
		wantRE := regexp.MustCompile(`(?m)crash/crash
runtime.gopanic:--
golang.org/x/telemetry/internal/crashmonitor_test\.grandchildPanic:=85,\+0x.*
golang.org/x/telemetry/internal/crashmonitor_test\.childPanic:\+2,\+0x.*
golang.org/x/telemetry/internal/crashmonitor_test\.TestMain:\+10,\+0x.*
main.main:--
runtime.main:--
runtime.goexit:--`)
		if !wantRE.MatchString(got) {
			t.Errorf("got counter name <<%s>>, want match for <<%s>>", got, wantRE)
		}
	})

	// Panic via trap.
	t.Run("trap", func(t *testing.T) {
		_, _, stderr := runSelf(t, "via-stderr.trap")
		got, err := crashmonitor.TelemetryCounterName(stderr)
		if err != nil {
			t.Fatal(err)
		}
		got = sanitize(counter.DecodeStack(got))
		wantRE := regexp.MustCompile(`(?m)crash/crash
runtime.gopanic:--
runtime.panicmem:--
runtime.sigpanic:--
golang.org/x/telemetry/internal/crashmonitor_test.grandchildTrap:=96,\+0x.*
golang.org/x/telemetry/internal/crashmonitor_test.childTrap:\+2,\+0x.*
golang.org/x/telemetry/internal/crashmonitor_test.TestMain:\+12,\+0x.*
main.main:--
runtime.main:--
runtime.goexit:--`)
		if !wantRE.MatchString(got) {
			t.Errorf("got counter name <<%s>>, want match for <<%s>>", got, wantRE)
		}
	})
}

func waitForExitFile(t *testing.T, exitFile string) {
	deadline := time.Now().Add(10 * time.Second)
	for {
		_, err := os.ReadFile(exitFile)
		if err == nil {
			break // success
		}

		if !os.IsNotExist(err) {
			t.Fatalf("failed to read exit file: %v", err)
		}
		// The crashmonitor has not written the file yet.
		// Allow it more time.
		if time.Now().After(deadline) {
			t.Fatalf("crashmonitor failed to write file in a timely manner")
		}
		time.Sleep(10 * time.Millisecond)
	}
}

// TestStart is an integration test of the crashmonitor feature of [telemetry.Start].
func TestStart(t *testing.T) {
	testenv.SkipIfUnsupportedPlatform(t)

	// Assert that the crash monitor does nothing when the child
	// process merely exits.
	t.Run("exit", func(t *testing.T) {
		telemetryFile, exitFile, _ := runSelf(t, "start.exit")
		waitForExitFile(t, exitFile)
		data, err := os.ReadFile(telemetryFile)
		if err == nil {
			t.Fatalf("telemetry counter <<%s>> was unexpectedly incremented", data)
		}
	})

	// Assert that the crash monitor increments a telemetry counter of the
	// correct name when the child process panics.
	t.Run("panic", func(t *testing.T) {
		// Gather a stack trace from executing the panic statement above.
		telemetryFile, exitFile, _ := runSelf(t, "start.panic")
		waitForExitFile(t, exitFile)
		data, err := os.ReadFile(telemetryFile)
		if err != nil {
			t.Fatalf("failed to read file: %v", err)
		}
		got := sanitize(counter.DecodeStack(string(data)))
		wantRE := regexp.MustCompile(`(?m)crash/crash
runtime.gopanic:--
golang.org/x/telemetry/internal/crashmonitor_test.grandchildPanic:=85,.*
golang.org/x/telemetry/internal/crashmonitor_test.childPanic:\+2,.*
golang.org/x/telemetry/internal/crashmonitor_test.TestMain.func3:\+1,.*
runtime.goexit:--`)
		if !wantRE.MatchString(got) {
			t.Errorf("got counter name <<%s>>, want match for <<%s>>", got, wantRE)
		}
	})

	// Assert that the crash monitor increments a telemetry counter of the
	// correct name when the child process panics via trap.
	t.Run("trap", func(t *testing.T) {
		// Gather a stack trace from executing the panic statement above.
		telemetryFile, exitFile, _ := runSelf(t, "start.trap")
		waitForExitFile(t, exitFile)
		data, err := os.ReadFile(telemetryFile)
		if err != nil {
			t.Fatalf("failed to read file: %v", err)
		}
		got := sanitize(counter.DecodeStack(string(data)))
		wantRE := regexp.MustCompile(`(?m)crash/crash
runtime.gopanic:--
runtime.panicmem:--
runtime.sigpanic:--
golang.org/x/telemetry/internal/crashmonitor_test.grandchildTrap:=96,.*
golang.org/x/telemetry/internal/crashmonitor_test.childTrap:\+2,.*
golang.org/x/telemetry/internal/crashmonitor_test.TestMain.func4:\+1,.*
runtime.goexit:--`)
		if !wantRE.MatchString(got) {
			t.Errorf("got counter name <<%s>>, want match for <<%s>>", got, wantRE)
		}
	})
}

// runSelf fork+exec's this test executable using an alternate entry point.
// It returns the child's stderr, the name of the file
// to which any incremented counter name will be written, and
// the name of the file that will be written to when the crashmonitor
// exits.
func runSelf(t *testing.T, entrypoint string) (string, string, []byte) {
	testenv.MustHaveExec(t)

	exe, err := os.Executable()
	if err != nil {
		t.Fatal(err)
	}

	tmpdir := t.TempDir()

	// Provide the names via the environment of the files the child is stubbed
	// to write to.

	// The exit file is created by the crashmonitor when it is finished.
	telemetryExitFile := filepath.Join(tmpdir, "exit.telemetry")

	// The telemetry file will contain the name of the incremented counter.
	telemetryFile := filepath.Join(tmpdir, "fake.telemetry")

	cmd := exec.Command(exe)
	cmd.Env = append(os.Environ(),
		"CRASHMONITOR_TEST_ENTRYPOINT="+entrypoint,
		"CRASHMONITOR_TELEMETRY_FILE="+telemetryFile,
		"CRASHMONITOR_TELEMETRY_EXIT_FILE="+telemetryExitFile,
		"CRASHMONITOR_TELEMETRY_DIR="+t.TempDir(),
	)
	cmd.Stderr = new(bytes.Buffer)
	cmd.Run() // failure is expected
	stderr := cmd.Stderr.(*bytes.Buffer).Bytes()
	if true { // debugging
		t.Logf("stderr: %s", stderr)
	}
	return telemetryFile, telemetryExitFile, stderr
}

// sanitize redacts the line numbers that we don't control from a counter name.
func sanitize(name string) string {
	lines := strings.Split(name, "\n")
	for i, line := range lines {
		if symbol, _, ok := strings.Cut(line, ":"); ok &&
			!strings.HasPrefix(line, "golang.org/x/telemetry/internal/crashmonitor") {
			lines[i] = symbol + ":--"
		}
	}
	return strings.Join(lines, "\n")
}
