package e2e

import (
	"slices"
	"strconv"
	"strings"
	"testing"

	mapset "github.com/deckarep/golang-set/v2"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/mount"
	"github.com/docker/go-connections/nat"
	machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
	"github.com/psviderski/uncloud/pkg/api"
	"github.com/psviderski/uncloud/pkg/client/deploy"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpec) {
	spec = spec.SetDefaults()
	assert.Equal(t, spec.Name, svc.Name)

	if svc.Mode == api.ServiceModeReplicated {
		assert.Equal(t, api.ServiceModeReplicated, spec.Mode)
		assert.Len(t, svc.Containers, int(spec.Replicas), "Expected %d replicas", spec.Replicas)
	} else {
		assert.Equal(t, spec.Mode, svc.Mode)
	}

	for _, mc := range svc.Containers {
		assertContainerMatchesSpec(t, mc.Container, spec)
	}
}

func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
	spec = spec.SetDefaults()
	status := deploy.EvalContainerSpecChange(ctr.ServiceSpec, spec)
	assert.Equal(t, deploy.ContainerUpToDate, status)

	// Verify labels.
	assert.True(t, api.ValidateServiceID(ctr.Config.Labels[api.LabelServiceID]))
	assert.Equal(t, spec.Name, ctr.Config.Labels[api.LabelServiceName])
	assert.Equal(t, spec.Mode, ctr.Config.Labels[api.LabelServiceMode])
	assert.Contains(t, ctr.Config.Labels, api.LabelManaged)

	// Command and Entrypoint can only be compared if they are set in the spec.
	// Otherwise, the container takes them from the image.
	if spec.Container.Command != nil {
		assert.EqualValues(t, spec.Container.Command, ctr.Config.Cmd)
	}
	if spec.Container.Entrypoint != nil {
		assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint)
	}

	expectedEnvs := spec.Container.Env.ToSlice()
	for _, env := range expectedEnvs {
		assert.Contains(t, ctr.Config.Env, env)
	}

	assert.Equal(t, spec.Container.Image, ctr.Config.Image)
	assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
	assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-"))

	if spec.Container.LogDriver != nil {
		assert.Equal(t, spec.Container.LogDriver.Name, ctr.HostConfig.LogConfig.Type)
		assert.Equal(t, spec.Container.LogDriver.Options, ctr.HostConfig.LogConfig.Config)
	}
	assert.Equal(t, spec.Container.Privileged, ctr.HostConfig.Privileged)
	// Compute resources.
	assert.Equal(t, spec.Container.Resources.CPU, ctr.HostConfig.Resources.NanoCPUs)
	assert.Equal(t, spec.Container.Resources.Memory, ctr.HostConfig.Resources.Memory)
	assert.Equal(t, spec.Container.Resources.MemoryReservation, ctr.HostConfig.Resources.MemoryReservation)

	// If User is not set, the default user in the image is used so we can't easily assert it.
	if spec.Container.User != "" {
		assert.Equal(t, spec.Container.User, ctr.Config.User)
	}

	assert.Empty(t, ctr.HostConfig.Binds, "Expected empty binds as all volumes should be mapped to mounts")
	assert.ElementsMatch(t, spec.Container.Volumes, ctr.HostConfig.Binds)
	assertContainerMountsMatchSpec(t, ctr.HostConfig.Mounts, spec)

	// Compare host ports.
	portBindings := make(nat.PortMap)
	for _, p := range spec.Ports {
		if p.Mode != api.PortModeHost {
			continue
		}

		port, err := nat.NewPort(p.Protocol, strconv.Itoa(int(p.ContainerPort)))
		assert.NoError(t, err)

		binding := nat.PortBinding{HostPort: strconv.Itoa(int(p.PublishedPort))}
		if p.HostIP.IsValid() {
			binding.HostIP = p.HostIP.String()
		}
		portBindings[port] = append(portBindings[port], binding)
	}
	assert.Equal(t, portBindings, ctr.HostConfig.PortBindings)

	assert.Equal(t, container.RestartPolicy{
		Name:              container.RestartPolicyUnlessStopped,
		MaximumRetryCount: 0,
	}, ctr.HostConfig.RestartPolicy)

	// Verify network settings.
	assert.Len(t, ctr.NetworkSettings.Networks, 1)
	assert.Contains(t, ctr.NetworkSettings.Networks, machinedocker.NetworkName)
}

func assertContainerMountsMatchSpec(t *testing.T, mounts []mount.Mount, spec api.ServiceSpec) {
	expectedMounts, err := machinedocker.ToDockerMounts(spec.Volumes, spec.Container.VolumeMounts)
	require.NoError(t, err)

	sortMounts(mounts)
	sortMounts(expectedMounts)

	assert.Len(t, mounts, len(expectedMounts), "Expected %d mounts", len(expectedMounts))
	for i, m := range mounts {
		// The mount generated from the spec may not define the driver which means to use the default driver
		// if creating a new volume or mounting an existing one no matter what its driver is.
		// So skip driver comparison if the driver is not set in the spec.
		if expectedMounts[i].VolumeOptions != nil && expectedMounts[i].VolumeOptions.DriverConfig == nil &&
			m.VolumeOptions != nil {
			expectedMounts[i].VolumeOptions.DriverConfig = m.VolumeOptions.DriverConfig
		}
		assert.Equal(t, expectedMounts[i], m,
			"Expected mount type=%s,src=%s,dst=%s to match spec", m.Type, m.Source, m.Target)
	}
}

func sortMounts(mounts []mount.Mount) {
	slices.SortFunc(mounts, func(a, b mount.Mount) int {
		return strings.Compare(a.Target, b.Target)
	})
}

// serviceContainersByMachine returns a map of machine ID to service containers on that machine.
func serviceContainersByMachine(svc api.Service) map[string][]api.ServiceContainer {
	containers := make(map[string][]api.ServiceContainer)
	for _, c := range svc.Containers {
		containers[c.MachineID] = append(containers[c.MachineID], c.Container)
	}
	return containers
}

func serviceMachines(svc api.Service) mapset.Set[string] {
	machines := mapset.NewSet[string]()
	for _, c := range svc.Containers {
		machines.Add(c.MachineID)
	}

	return machines
}

func serviceContainerIDs(svc api.Service) mapset.Set[string] {
	ids := mapset.NewSet[string]()
	for _, c := range svc.Containers {
		ids.Add(c.Container.ID)
	}
	return ids
}
