//go:build linux || darwin

package socketactivation_test

import (
	"net"
	"os"
	"strconv"
	"strings"
	"sync/atomic"
	"testing"
	"time"

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

	"github.com/kopia/kopia/internal/testlogging"
	"github.com/kopia/kopia/internal/testutil"
	"github.com/kopia/kopia/tests/testenv"
)

func TestServerControlSocketActivated(t *testing.T) {
	var port int

	serverExe := os.Getenv("KOPIA_SERVER_EXE")
	if serverExe == "" {
		t.Skip("skipping socket-activation test")
	}

	runner := testenv.NewExeRunnerWithBinary(t, serverExe)
	env := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)

	dir0 := testutil.TempDirectory(t)

	env.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", env.RepoDir, "--override-username=another-user", "--override-hostname=another-host")
	env.RunAndExpectSuccess(t, "snap", "create", dir0)

	// The KOPIA_EXE wrapper will set the LISTEN_PID variable for us
	env.Environment["LISTEN_FDS"] = "1"

	ctx := testlogging.Context(t)
	l1, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")

	require.NoError(t, err, "Failed to open Listener")

	t.Cleanup(func() { l1.Close() })

	port = testutil.EnsureType[*net.TCPAddr](t, l1.Addr()).Port

	t.Logf("Activating socket on port %v", port)

	l1File, err := testutil.EnsureType[*net.TCPListener](t, l1).File()
	require.NoError(t, err, "failed to get filehandle for socket")

	serverStarted := make(chan struct{})
	serverStopped := make(chan struct{})

	var sp testutil.ServerParameters

	go func() {
		runner.ExtraFiles = append(runner.ExtraFiles, l1File)
		wait, _ := env.RunAndProcessStderr(t, sp.ProcessOutput,
			"server", "start", "--insecure", "--random-server-control-password", "--address=127.0.0.1:0")

		l1File.Close()
		close(serverStarted)

		wait()

		close(serverStopped)
	}()

	select {
	case <-serverStarted:
		require.NotEmpty(t, sp.BaseURL, "Failed to start server")
		t.Logf("server started on %v", sp.BaseURL)

	case <-time.After(15 * time.Second):
		t.Fatal("server did not start in time")
	}

	require.Contains(t, sp.BaseURL, ":"+strconv.Itoa(port))

	checkServerStatusFn := func(collect *assert.CollectT) {
		lines := env.RunAndExpectSuccess(t, "server", "status", "--address", "http://127.0.0.1:"+strconv.Itoa(port), "--server-control-password", sp.ServerControlPassword, "--remote")
		require.Len(collect, lines, 1)
		require.Contains(collect, lines, "IDLE: another-user@another-host:"+dir0)
	}

	require.EventuallyWithT(t, checkServerStatusFn, 30*time.Second, 2*time.Second, "could not get server status, perhaps it was not listening on the control endpoint yet?")

	env.RunAndExpectSuccess(t, "server", "shutdown", "--address", sp.BaseURL, "--server-control-password", sp.ServerControlPassword)

	select {
	case <-serverStopped:
		t.Log("server shut down")

	case <-time.After(15 * time.Second):
		t.Fatal("server did not shutdown in time")
	}
}

func TestServerControlSocketActivatedTooManyFDs(t *testing.T) {
	serverExe := os.Getenv("KOPIA_SERVER_EXE")
	if serverExe == "" {
		t.Skip("skipping socket-activation test")
	}

	runner := testenv.NewExeRunnerWithBinary(t, serverExe)
	env := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)

	env.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", env.RepoDir, "--override-username=another-user", "--override-hostname=another-host")

	// create 2 file descriptor for a single socket and pass the descriptors to the server
	ctx := testlogging.Context(t)
	l1, err := (&net.ListenConfig{}).Listen(ctx, "tcp", ":0")

	require.NoError(t, err, "Failed to open Listener")

	t.Cleanup(func() { l1.Close() })

	port := testutil.EnsureType[*net.TCPAddr](t, l1.Addr()).Port

	t.Logf("activation socket port %v", port)

	listener := testutil.EnsureType[*net.TCPListener](t, l1)

	l1File, err := listener.File()
	require.NoError(t, err, "failed to get 1st filehandle for socket")

	t.Cleanup(func() { l1File.Close() })

	l2File, err := listener.File()
	require.NoError(t, err, "failed to get 2nd filehandle for socket")

	t.Cleanup(func() { l2File.Close() })

	runner.ExtraFiles = append(runner.ExtraFiles, l1File, l2File)
	// The KOPIA_EXE wrapper will set the LISTEN_PID variable for us
	env.Environment["LISTEN_FDS"] = "2"

	var gotExpectedErrorMessage atomic.Bool

	stderrAsyncCallback := func(line string) {
		if strings.Contains(line, "Too many activated sockets found.  Expected 1, got 2") {
			gotExpectedErrorMessage.Store(true)
		}
	}

	// although the server is expected to stop quickly with an error, the server's
	// stderr is processed async to avoid test deadlocks if the server continues
	// to run and does not exit.
	wait, kill := env.RunAndProcessStderrAsync(t, func(string) bool { return false }, stderrAsyncCallback, "server", "start", "--insecure", "--random-server-control-password", "--address=127.0.0.1:0")

	t.Cleanup(kill)

	serverStopped := make(chan error)
	go func() {
		defer close(serverStopped)

		serverStopped <- wait()
	}()

	select {
	case err := <-serverStopped:
		require.Error(t, err, "server did not exit with an error")
		t.Log("Done")
	case <-time.After(30 * time.Second):
		t.Fatal("server did not exit in time")
	}

	require.True(t, gotExpectedErrorMessage.Load(), "expected server's stderr to contain a line along the lines of 'Too many activated sockets ...'")
}
