//go:build go1.20
// +build go1.20

package rest_test

import (
	"bufio"
	"context"
	"fmt"
	"net/url"
	"os"
	"os/exec"
	"regexp"
	"strings"
	"syscall"
	"testing"
	"time"

	"github.com/restic/restic/internal/backend/rest"
	"github.com/restic/restic/internal/backend/test"
	rtest "github.com/restic/restic/internal/test"
)

var (
	serverStartedRE = regexp.MustCompile("^start server on (.*)$")
)

func runRESTServer(ctx context.Context, t testing.TB, dir, reqListenAddr string) (*url.URL, func()) {
	srv, err := exec.LookPath("rest-server")
	if err != nil {
		t.Skip(err)
	}

	// create our own context, so that our cleanup can cancel and wait for completion
	// this will ensure any open ports, open unix sockets etc are properly closed
	processCtx, cancel := context.WithCancel(ctx)
	cmd := exec.CommandContext(processCtx, srv, "--no-auth", "--path", dir, "--listen", reqListenAddr)

	// this cancel func is called by when the process context is done
	cmd.Cancel = func() error {
		// we execute in a Go-routine as we know the caller will
		// be waiting on a .Wait() regardless
		go func() {
			// try to send a graceful termination signal
			if cmd.Process.Signal(syscall.SIGTERM) == nil {
				// if we succeed, then wait a few seconds
				time.Sleep(2 * time.Second)
			}
			// and then make sure it's killed either way, ignoring any error code
			_ = cmd.Process.Kill()
		}()
		return nil
	}

	// this is the cleanup function that we return the caller,
	// which will cancel our process context, and then wait for it to finish
	cleanup := func() {
		cancel()
		_ = cmd.Wait()
	}

	// but in-case we don't finish this method, e.g. by calling t.Fatal()
	// we also defer a call to clean it up ourselves, guarded by a flag to
	// indicate that we returned the function to the caller to deal with.
	callerWillCleanUp := false
	defer func() {
		if !callerWillCleanUp {
			cleanup()
		}
	}()

	// send stdout to our std out
	cmd.Stdout = os.Stdout

	// capture stderr with a pipe, as we want to examine this output
	// to determine when the server is started and listening.
	cmdErr, err := cmd.StderrPipe()
	if err != nil {
		t.Fatal(err)
	}

	// start the rest-server
	if err := cmd.Start(); err != nil {
		t.Fatal(err)
	}

	// create a channel to receive the actual listen address on
	listenAddrCh := make(chan string)
	go func() {
		defer close(listenAddrCh)
		matched := false
		br := bufio.NewReader(cmdErr)
		for {
			line, err := br.ReadString('\n')
			if err != nil {
				// we ignore errors, as code that relies on this
				// will happily fail via timeout and empty closed
				// channel.
				return
			}

			line = strings.Trim(line, "\r\n")
			if !matched {
				// look for the server started message, and return the address
				// that it's listening on
				matchedServerListen := serverStartedRE.FindSubmatch([]byte(line))
				if len(matchedServerListen) == 2 {
					listenAddrCh <- string(matchedServerListen[1])
					matched = true
				}
			}
			fmt.Fprintln(os.Stdout, line) // print all output to console
		}
	}()

	// wait for us to get an address,
	// or the parent context to cancel,
	// or for us to timeout
	var actualListenAddr string
	select {
	case <-processCtx.Done():
		t.Fatal(context.Canceled)
	case <-time.NewTimer(2 * time.Second).C:
		t.Fatal(context.DeadlineExceeded)
	case a, ok := <-listenAddrCh:
		if !ok {
			t.Fatal(context.Canceled)
		}
		actualListenAddr = a
	}

	// this translate the address that the server is listening on
	// to a URL suitable for us to connect to
	var addrToConnectTo string
	if strings.HasPrefix(reqListenAddr, "unix:") {
		addrToConnectTo = fmt.Sprintf("http+unix://%s:/restic-test/", actualListenAddr)
	} else {
		// while we may listen on 0.0.0.0, we connect to localhost
		addrToConnectTo = fmt.Sprintf("http://%s/restic-test/", strings.Replace(actualListenAddr, "0.0.0.0", "localhost", 1))
	}

	// parse to a URL
	url, err := url.Parse(addrToConnectTo)
	if err != nil {
		t.Fatal(err)
	}

	// indicate that we've completed successfully, and that the caller
	// is responsible for calling cleanup
	callerWillCleanUp = true
	return url, cleanup
}

func newTestSuite(url *url.URL, minimalData bool) *test.Suite[rest.Config] {
	return &test.Suite[rest.Config]{
		MinimalData: minimalData,

		// NewConfig returns a config for a new temporary backend that will be used in tests.
		NewConfig: func() (*rest.Config, error) {
			cfg := rest.NewConfig()
			cfg.URL = url
			return &cfg, nil
		},

		Factory: rest.NewFactory(),
	}
}

func TestBackendREST(t *testing.T) {
	defer func() {
		if t.Skipped() {
			rtest.SkipDisallowed(t, "restic/backend/rest.TestBackendREST")
		}
	}()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	dir := rtest.TempDir(t)
	serverURL, cleanup := runRESTServer(ctx, t, dir, ":0")
	defer cleanup()

	newTestSuite(serverURL, false).RunTests(t)
}

func TestBackendRESTExternalServer(t *testing.T) {
	repostr := os.Getenv("RESTIC_TEST_REST_REPOSITORY")
	if repostr == "" {
		t.Skipf("environment variable %v not set", "RESTIC_TEST_REST_REPOSITORY")
	}

	cfg, err := rest.ParseConfig(repostr)
	if err != nil {
		t.Fatal(err)
	}

	newTestSuite(cfg.URL, true).RunTests(t)
}

func BenchmarkBackendREST(t *testing.B) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	dir := rtest.TempDir(t)
	serverURL, cleanup := runRESTServer(ctx, t, dir, ":0")
	defer cleanup()

	newTestSuite(serverURL, false).RunBenchmarks(t)
}