package integration

import (
	"bytes"
	"context"
	"encoding/json"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"testing"
	"time"

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

	"gitlab.com/gitlab-org/fleeting/fleeting"
	"gitlab.com/gitlab-org/fleeting/fleeting/connector"
	"gitlab.com/gitlab-org/fleeting/fleeting/provider"
)

type Config struct {
	// PluginConfig is the plugin config.
	PluginConfig any

	// ConnectorConfig is the connector config.
	ConnectorConfig provider.ConnectorConfig

	// MaxInstances is the maximum number of instances that is usable
	// by the integration tests. This doesn't mean that they will all be
	// used, but some tests might require 2-3 instances.
	MaxInstances int

	// UseExternalAddr uses the external address of the instance to connect to.
	UseExternalAddr bool
}

func BuildPluginBinary(t *testing.T, target, binaryName string) string {
	if runtime.GOOS == "windows" {
		binaryName += ".exe"
	}

	binaryName = filepath.Join(t.TempDir(), binaryName)
	if absoluteTarget, err := filepath.Abs(target); err == nil {
		target = absoluteTarget
	}

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

	cmd := exec.CommandContext(ctx, "go", "build", "-o", binaryName, target)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	require.NoError(t, cmd.Run())

	return binaryName
}

func TestProvisioning(t *testing.T, plugin string, config Config) {
	configJSON, err := json.Marshal(config.PluginConfig)
	require.NoError(t, err)

	runner, err := fleeting.RunPlugin(plugin, configJSON)
	require.NoError(t, err)
	defer runner.Kill()

	subCh := make(chan fleeting.Instance, 10)
	opts := []fleeting.Option{
		fleeting.WithMaxSize(config.MaxInstances),
		fleeting.WithInstanceGroupSettings(provider.Settings{
			ConnectorConfig: config.ConnectorConfig,
		}),
		// subscribe to instance updates
		fleeting.WithSubscriber(func(instances []fleeting.Instance) {
			for _, inst := range instances {
				subCh <- inst
			}
		}),
	}

	provisioner, err := fleeting.Init(context.Background(), nil, runner.InstanceGroup(), opts...)
	require.NoError(t, err)
	defer provisioner.Shutdown(context.Background())

	delete := 0
	for _, instance := range provisioner.Instances() {
		switch instance.State() {
		case provider.StateRunning, provider.StateDeleting:
			instance.Delete()
			delete++
		}
	}

	// delete any instances
	deleteInstances(t, provisioner, subCh)

	// scale up at least 1 instance
	scaleUpOneToThreeInstances(t, provisioner, config.MaxInstances)

	// scale down to leave only 1 instance
	scaleDownToOneInstance(t, provisioner)

	// execute command
	executeCommand(t, provisioner, config.UseExternalAddr)

	// delete any instances
	deleteInstances(t, provisioner, subCh)
}

func deleteInstances(t *testing.T, provisioner *fleeting.Provisioner, subCh chan fleeting.Instance) {
	var expectedDeleteCount int
	for _, instance := range provisioner.Instances() {
		instance.Delete()
		expectedDeleteCount++
	}

	if expectedDeleteCount == 0 {
		return
	}

	for instance := range subCh {
		if instance.State() == provider.StateDeleted {
			expectedDeleteCount--
			if expectedDeleteCount == 0 {
				return
			}
		}
	}

	require.Equal(t, 0, expectedDeleteCount)
}

func waitForProvisionerLen(t *testing.T, provisioner *fleeting.Provisioner, n int) {
	var total int
	for {
		total = len(provisioner.Instances())
		if total == n {
			break
		}
		time.Sleep(time.Second)
	}

	require.Equal(t, n, total)
}

func scaleUpOneToThreeInstances(t *testing.T, provisioner *fleeting.Provisioner, max int) {
	provisioner.Request(1)
	waitForProvisionerLen(t, provisioner, 1)

	if max == 1 {
		return
	}

	// cap maximum to 3
	max = 3
	provisioner.Request(max - 1)
	waitForProvisionerLen(t, provisioner, max)
}

func scaleDownToOneInstance(t *testing.T, provisioner *fleeting.Provisioner) {
	instances := provisioner.Instances()

	for i := 0; i < len(instances)-1; i++ {
		instances[i].Delete()
	}

	waitForProvisionerLen(t, provisioner, 1)
}

func executeCommand(t *testing.T, provisioner *fleeting.Provisioner, useExternalAddr bool) {
	for _, instance := range provisioner.Instances() {
		// only connect to running instances
		if instance.State() != provider.StateRunning {
			continue
		}

		t.Logf("connectinfo: %v", instance.ID())

		info, err := instance.ConnectInfo(context.Background())
		require.NoError(t, err)
		require.Equal(t, fleeting.CauseRequested, instance.Cause())

		require.NotEmpty(t, info.Username, "username unexpectedly empty from fleeting plugin")
		require.NotEmpty(t, info.ExternalAddr, "external address unexpectedly empty from fleeting plugin")

		t.Logf("connecting: %v, %v, %v", instance.ID(), info.Username, info.ExternalAddr)

		stdoutBuf := new(bytes.Buffer)
		stderrBuf := new(bytes.Buffer)

		err = connector.Run(context.Background(), info, connector.ConnectorOptions{
			RunOptions: connector.RunOptions{
				Command: "echo 'hello world'",
				Stdout:  stdoutBuf,
				Stderr:  stderrBuf,
			},
			DialOptions: connector.DialOptions{
				UseExternalAddr: useExternalAddr,
			},
		})

		assert.NoError(t, err)
		assert.Contains(t, stdoutBuf.String(), "hello world")
		assert.Equal(t, stderrBuf.String(), "")
	}
}
