package testhelper

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"testing"

	"gitlab.com/gitlab-org/gitaly/v18/internal/gitaly/storage/mode"
	"gitlab.com/gitlab-org/gitaly/v18/internal/log"
)

var testDirectory string

// RunOption is an option that can be passed to Run.
type RunOption func(*runConfig)

type runConfig struct {
	setup                  func() error
	teardown               func(int) error
	disableGoroutineChecks bool
}

// WithSetup allows the caller of Run to pass a setup function that will be called after global
// test state has been configured.
func WithSetup(setup func() error) RunOption {
	return func(cfg *runConfig) {
		cfg.setup = setup
	}
}

// WithTeardown allows the caller of Run to pass a teardown function that will be called after
// the test has finished. The teardown function receives the exit code.
func WithTeardown(teardown func(int) error) RunOption {
	return func(cfg *runConfig) {
		cfg.teardown = teardown
	}
}

// WithDisabledGoroutineChecker disables checking for leaked Goroutines after tests have run. This
// should ideally only be used as a temporary measure until all Goroutine leaks have been fixed.
//
// Deprecated: This should not be used, but instead you should try to fix all Goroutine leakages.
func WithDisabledGoroutineChecker() RunOption {
	return func(cfg *runConfig) {
		cfg.disableGoroutineChecks = true
	}
}

// Run sets up required testing state and executes the given test suite. It can optionally receive a
// variable number of RunOptions.
func Run(m *testing.M, opts ...RunOption) {
	// Run tests in a separate function such that we can use deferred statements and still
	// (indirectly) call `os.Exit()` in case the test setup failed.
	if err := func() error {
		var cfg runConfig
		for _, opt := range opts {
			opt(&cfg)
		}

		defer mustHaveNoChildProcess()
		if !cfg.disableGoroutineChecks {
			defer mustHaveNoGoroutines()
		}

		cleanup, err := configure()
		if err != nil {
			return fmt.Errorf("test configuration: %w", err)
		}
		defer cleanup()

		if cfg.setup != nil {
			if err := cfg.setup(); err != nil {
				return fmt.Errorf("error calling setup function: %w", err)
			}
		}

		code := m.Run()
		if cfg.teardown != nil {
			if err := cfg.teardown(code); err != nil {
				return fmt.Errorf("error calling teardown function: %w", err)
			}
		}
		return nil
	}(); err != nil {
		fmt.Printf("%s", err)
		os.Exit(1)
	}
}

// configure sets up the global test configuration. On failure,
// terminates the program.
func configure() (_ func(), returnedErr error) {
	logger, err := log.Configure(os.Stdout, "json", "panic")
	if err != nil {
		return nil, fmt.Errorf("configuring logger failed: %w", err)
	}

	// From now on we will refrain from replacing the gRPC logger. The gRPC logger is a global shared resource, so
	// any tests that run in parallel and end up calling `log.Configure()` would potentially try to replace the
	// logger while it is in use.
	log.SkipReplacingGlobalLoggers = true

	for key, value := range map[string]string{
		// We inject the following two variables, which instruct Git to search its
		// configuration in non-default locations in the global and system scope. This is
		// done as a sanity check to verify that we don't ever pick up this configuration
		// but instead filter it out whenever we execute Git. The values are set to the
		// root directory: this directory is guaranteed to exist, and Git is guaranteed to
		// fail parsing those directories as configuration.
		"GIT_CONFIG_GLOBAL": "/",
		"GIT_CONFIG_SYSTEM": "/",
		// Same as above, this value is injected such that we can detect whether any site
		// which executes Git fails to inject the expected Git directory. This is also
		// required such that we don't ever inadvertently execute Git commands in the wrong
		// Git repository.
		"GIT_DIR": "/dev/null",
	} {
		if err := os.Setenv(key, value); err != nil {
			return nil, err
		}
	}

	if refFormat := os.Getenv("GITALY_TEST_REF_FORMAT"); len(refFormat) > 0 {
		if err := os.Setenv("GIT_DEFAULT_REF_FORMAT", refFormat); err != nil {
			return nil, fmt.Errorf("setting default ref format: %w", err)
		}
	}

	// We need to make sure that we're gitconfig-clean: Git should not pick up
	// gitconfig files from anywhere but the repository itself in case they're configured to
	// ignore them. We set that configuration by default in our tests to have a known-good
	// environment.
	//
	// In order to verify that this really works as expected we optionally intercept the user's
	// HOME with a custom home directory that contains gitconfig files that cannot be parsed. So
	// if these were picked up, we'd see errors and thus know that something's going on. We
	// need to make this opt-in though given that some users require HOME to be set, e.g. for
	// Bundler. Our CI does run with this setting enabled though.
	if len(os.Getenv("GITALY_TESTING_INTERCEPT_HOME")) > 0 {
		_, currentFile, _, ok := runtime.Caller(0)
		if !ok {
			return nil, fmt.Errorf("cannot compute intercepted home location")
		}

		homeDir := filepath.Join(filepath.Dir(currentFile), "testdata", "home")
		if _, err := os.Stat(homeDir); err != nil {
			return nil, fmt.Errorf("statting intercepted home location: %w", err)
		}

		if err := os.Setenv("HOME", homeDir); err != nil {
			return nil, fmt.Errorf("setting home: %w", err)
		}
	}

	cleanup, err := configureTestDirectory(logger)
	if err != nil {
		return nil, fmt.Errorf("configuring test directory: %w", err)
	}
	defer func() {
		if returnedErr != nil {
			cleanup()
		}
	}()

	return cleanup, nil
}

func configureTestDirectory(logger log.Logger) (_ func(), returnedErr error) {
	if testDirectory != "" {
		return nil, errors.New("test directory has already been configured")
	}

	// Ideally, we'd just pass "" to `os.MkdirTemp()`, which would then use either the value of
	// `$TMPDIR` or alternatively "/tmp". But given that macOS sets `$TMPDIR` to a user specific
	// temporary directory, resulting paths would be too long and thus cause issues galore. We
	// thus support our own specific variable instead which allows users to override it, with
	// our default being "/tmp".
	tempDirLocation := os.Getenv("TEST_TMP_DIR")
	if tempDirLocation == "" {
		tempDirLocation = "/tmp"
	}

	var err error
	testDirectory, err = os.MkdirTemp(tempDirLocation, "gitaly-")
	if err != nil {
		return nil, fmt.Errorf("creating test directory: %w", err)
	}

	cleanup := func() {
		if err := os.RemoveAll(testDirectory); err != nil {
			logger.WithError(err).Error("cleaning up test directory failed")
		}
	}
	defer func() {
		if returnedErr != nil {
			cleanup()
		}
	}()

	// macOS symlinks /tmp/ to /private/tmp/ which can cause some check to fail. We thus resolve
	// the symlinks to their actual location.
	testDirectory, err = filepath.EvalSymlinks(testDirectory)
	if err != nil {
		return nil, err
	}

	// In many locations throughout Gitaly, we create temporary files and directories. By
	// default, these would clutter the "real" temporary directory with useless cruft that stays
	// around after our tests. To avoid this, we thus set the TMPDIR environment variable to
	// point into a directory inside of out test directory.
	globalTempDir := filepath.Join(testDirectory, "tmp")
	if err := os.Mkdir(globalTempDir, mode.Directory); err != nil {
		return nil, fmt.Errorf("creating global temporary directory: %w", err)
	}
	if err := os.Setenv("TMPDIR", globalTempDir); err != nil {
		return nil, fmt.Errorf("setting global temporary directory: %w", err)
	}

	return cleanup, nil
}
