package compose

import (
	"context"
	"net/netip"
	"path/filepath"
	"slices"
	"strings"
	"testing"

	"github.com/compose-spec/compose-go/v2/loader"
	"github.com/compose-spec/compose-go/v2/types"
	"github.com/docker/docker/api/types/mount"
	"github.com/docker/go-units"
	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/psviderski/uncloud/pkg/api"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// loadProjectFromContent loads a compose project from YAML content
func loadProjectFromContent(t *testing.T, content string) (*types.Project, error) {
	t.Helper()
	ctx := context.Background()

	configDetails := types.ConfigDetails{
		ConfigFiles: []types.ConfigFile{
			{
				Filename: "docker-compose.yml",
				Content:  []byte(content),
			},
		},
	}

	project, err := loader.LoadWithContext(ctx, configDetails, func(o *loader.Options) {
		o.SetProjectName("test", true)
		// Register our custom extensions
		if o.KnownExtensions == nil {
			o.KnownExtensions = map[string]any{}
		}
		o.KnownExtensions[CaddyExtensionKey] = Caddy{}
		o.KnownExtensions[PortsExtensionKey] = PortsSource{}
		o.KnownExtensions[MachinesExtensionKey] = MachinesSource{}
	})
	if err != nil {
		return nil, err
	}

	// Apply extension transformations since we're not using LoadProject.
	if project, err = transformServicesCaddyExtension(project); err != nil {
		return nil, err
	}
	if project, err = transformServicesPortsExtension(project); err != nil {
		return nil, err
	}

	// Validate extension combinations after all transformations.
	if err = validateServicesExtensions(project); err != nil {
		return nil, err
	}

	return project, nil
}

func TestServiceSpecFromCompose(t *testing.T) {
	t.Parallel()

	initTrue := true

	tests := []struct {
		name     string
		filename string
		want     map[string]api.ServiceSpec
	}{
		{
			name:     "minimal",
			filename: "compose-minimal.yaml",
			want: map[string]api.ServiceSpec{
				"test": {
					Name: "test",
					Mode: api.ServiceModeReplicated,
					Container: api.ContainerSpec{
						Image:      "nginx:latest",
						PullPolicy: api.PullPolicyMissing,
					},
				},
			},
		},
		{
			name:     "deploy",
			filename: "compose-deploy.yaml",
			want: map[string]api.ServiceSpec{
				"no-deploy": {
					Name: "no-deploy",
					Mode: api.ServiceModeReplicated,
					Container: api.ContainerSpec{
						Image:      "nginx:latest",
						PullPolicy: api.PullPolicyMissing,
						Resources: api.ContainerResources{
							CPU:               1.5 * api.Core,
							Memory:            100 * units.MiB,
							MemoryReservation: 50 * units.MiB,
						},
					},
					Replicas: 3,
				},
				"deploy": {
					Name: "deploy",
					Mode: api.ServiceModeReplicated,
					Container: api.ContainerSpec{
						Image:      "nginx:latest",
						PullPolicy: api.PullPolicyMissing,
						Resources: api.ContainerResources{
							CPU:               1.5 * api.Core,
							Memory:            100 * units.MiB,
							MemoryReservation: 50 * units.MiB,
						},
					},
					Replicas: 3,
				},
				"both": {
					Name: "both",
					Mode: api.ServiceModeReplicated,
					Container: api.ContainerSpec{
						Image:      "nginx:latest",
						PullPolicy: api.PullPolicyMissing,
						Resources: api.ContainerResources{
							CPU:               2 * api.Core,
							Memory:            100 * units.MiB,
							MemoryReservation: 50 * units.MiB,
						},
					},
					Replicas: 3,
				},
			},
		},
		{
			name:     "full-spec",
			filename: "compose-full-spec.yaml",
			want: map[string]api.ServiceSpec{
				"test": {
					Name: "test",
					Mode: api.ServiceModeReplicated,
					Container: api.ContainerSpec{
						Command:    []string{"nginx", "updated", "command"},
						Entrypoint: []string{"/updated-docker-entrypoint.sh"},
						Env: map[string]string{
							"BOOL":  "true",
							"EMPTY": "",
							"VAR":   "value",
						},
						Image: "nginx:latest",
						Init:  &initTrue,
						LogDriver: &api.LogDriver{
							Name: "json-file",
							Options: map[string]string{
								"max-size": "10m",
								"max-file": "3",
							},
						},
						Privileged: true,
						PullPolicy: api.PullPolicyAlways,
						Resources: api.ContainerResources{
							CPU:               0.5 * api.Core,
							Memory:            100 * units.MiB,
							MemoryReservation: 50 * units.MiB,
						},
						User: "nginx:nginx",
						VolumeMounts: []api.VolumeMount{
							{
								VolumeName:    "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
								ContainerPath: "/host/etc/passwd",
								ReadOnly:      true,
							},
							{
								VolumeName:    "data1",
								ContainerPath: "/data1",
							},
							{
								VolumeName:    "data2-alias",
								ContainerPath: "/data2/long/syntax",
							},
							{
								VolumeName:    "data-external",
								ContainerPath: "/external",
								ReadOnly:      true,
							},
							{
								VolumeName:    "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
								ContainerPath: "/tmpfs",
							},
						},
					},
					Ports: []api.PortSpec{
						{
							Hostname:      "test.example.com",
							ContainerPort: 80,
							Protocol:      api.ProtocolHTTPS,
							Mode:          api.PortModeIngress,
						},
						{
							ContainerPort: 8000,
							Protocol:      api.ProtocolHTTP,
							Mode:          api.PortModeIngress,
						},
						{
							ContainerPort: 3000,
							PublishedPort: 5000,
							Protocol:      "tcp",
							Mode:          api.PortModeHost,
						},
					},
					Replicas: 3,
					Volumes: []api.VolumeSpec{
						{
							Name: "bind-bb6aed1683cea1e0a1ae5cd227aacd0734f2f87f7a78fcf1baeff978ce300b90",
							Type: api.VolumeTypeBind,
							BindOptions: &api.BindOptions{
								HostPath:       "/etc/passwd",
								CreateHostPath: true,
							},
						},
						{
							Name: "data-external",
							Type: api.VolumeTypeVolume,
							VolumeOptions: &api.VolumeOptions{
								Name: "data-external",
							},
						},
						{
							Name: "data1",
							Type: api.VolumeTypeVolume,
							VolumeOptions: &api.VolumeOptions{
								Name: "data1",
							},
						},
						{
							Name: "data2-alias",
							Type: api.VolumeTypeVolume,
							VolumeOptions: &api.VolumeOptions{
								Name: "data2",
								Driver: &mount.Driver{
									Name: "local",
								},
							},
						},
						{
							Name: "tmpfs-efa57ba8b6a1779674ac438de3af8729e2d55900b79eb929431cf9c5b0179542",
							Type: api.VolumeTypeTmpfs,
							TmpfsOptions: &mount.TmpfsOptions{
								SizeBytes: 10 * units.MiB,
							},
						},
					},
				},
				"test-caddy-config": {
					Name: "test-caddy-config",
					Mode: api.ServiceModeReplicated,
					Container: api.ContainerSpec{
						Image:      "myapp:1.2.3",
						PullPolicy: api.PullPolicyMissing,
					},
					Caddy: &api.CaddySpec{
						Config: `test-caddy-config.example.com {
  reverse_proxy {{ upstreams 80 }}
}`,
					},
				},
			},
		},
	}

	for _, tt := range tests {
		ctx := context.Background()

		t.Run(tt.name, func(t *testing.T) {
			project, err := LoadProject(ctx, []string{filepath.Join("testdata", tt.filename)})
			require.NoError(t, err)

			for name, expectedSpec := range tt.want {
				spec, err := ServiceSpecFromCompose(project, name)
				require.NoError(t, err)

				// Due to the use of a map the order of volumes is non-deterministic.
				slices.SortFunc(spec.Volumes, func(a, b api.VolumeSpec) int {
					return strings.Compare(a.Name, b.Name)
				})

				cmpOpts := cmp.Options{cmpopts.EquateEmpty(), cmpopts.EquateComparable(netip.Addr{})}
				assert.True(t, cmp.Equal(spec, expectedSpec, cmpOpts...), cmp.Diff(spec, expectedSpec, cmpOpts...))
			}
		})
	}
}

func TestServiceSpecFromCompose_Caddy(t *testing.T) {
	tests := []struct {
		name        string
		composeYAML string
		want        *api.CaddySpec
	}{
		{
			name: "x-caddy as string",
			composeYAML: `
services:
  web:
    image: nginx
    x-caddy: |
      example.com {
        reverse_proxy web:80
      }
`,
			want: &api.CaddySpec{
				Config: `example.com {
  reverse_proxy web:80
}`,
			},
		},
		{
			name: "x-caddy as string with extra spaces",
			composeYAML: `
services:
  web:
    image: nginx
    x-caddy: |+

      example.com {
        reverse_proxy web:80
      }


`,
			want: &api.CaddySpec{
				Config: `example.com {
  reverse_proxy web:80
}`,
			},
		},
		{
			name: "x-caddy as object with config field",
			composeYAML: `
services:
  web:
    image: nginx
    x-caddy:
      config: |
        example.com {
          reverse_proxy web:80
        }
`,
			want: &api.CaddySpec{
				Config: `example.com {
  reverse_proxy web:80
}`,
			},
		},
		{
			name: "x-caddy as object with config field and extra spaces",
			composeYAML: `
services:
  web:
    image: nginx
    x-caddy:
      config: |+

        example.com {
          reverse_proxy web:80
        }


`,
			want: &api.CaddySpec{
				Config: `example.com {
  reverse_proxy web:80
}`,
			},
		},
		{
			name: "x-caddy with path to Caddyfile",
			composeYAML: `
services:
  web:
    image: nginx
    x-caddy: testdata/Caddyfile
`,
			want: &api.CaddySpec{
				Config: `test.example.com {
  reverse_proxy test:8000
}`,
			},
		},
		{
			name: "x-caddy with empty string",
			composeYAML: `
services:
  web:
    image: nginx
    x-caddy: ""
`,
			want: nil,
		},
		{
			name: "no x-caddy extension",
			composeYAML: `
services:
  web:
    image: nginx
`,
			want: nil,
		},
		{
			name: "x-caddy with empty object",
			composeYAML: `
services:
  web:
    image: nginx
    x-caddy: {}
`,
			want: nil,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			project, err := loadProjectFromContent(t, tt.composeYAML)
			require.NoError(t, err)

			spec, err := ServiceSpecFromCompose(project, "web")
			require.NoError(t, err)

			assert.Equal(t, tt.want, spec.Caddy)
		})
	}
}

func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
	tests := []struct {
		name        string
		composeYAML string
		expected    api.Placement
		expectError bool
	}{
		{
			name: "valid x-machines with string array",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: ["machine-1", "machine-2"]
`,
			expected: api.Placement{
				Machines: []string{"machine-1", "machine-2"},
			},
		},
		{
			name: "valid x-machines with single string",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: my-machine
`,
			expected: api.Placement{
				Machines: []string{"my-machine"},
			},
		},
		{
			name: "valid x-machines with single quoted string",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: "machine-1"
`,
			expected: api.Placement{
				Machines: []string{"machine-1"},
			},
		},
		{
			name: "valid x-machines with numeric string",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: "123"
`,
			expected: api.Placement{
				Machines: []string{"123"},
			},
		},
		{
			name: "valid x-machines with comma-separated string",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: "machine-1,machine-2"
`,
			expected: api.Placement{
				Machines: []string{"machine-1", "machine-2"},
			},
		},
		{
			name: "valid x-machines with comma-separated string and spaces",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: "machine-1, machine-2, machine-3"
`,
			expected: api.Placement{
				Machines: []string{"machine-1", "machine-2", "machine-3"},
			},
		},
		{
			name: "empty x-machines array",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: []
`,
			expected: api.Placement{
				Machines: []string{},
			},
		},
		{
			name: "no x-machines",
			composeYAML: `
services:
  test:
    image: nginx
`,
			expected: api.Placement{},
		},
		{
			name: "empty machine name in x-machines",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: ["machine-1", "", "machine-2"]
`,
			expectError: true,
		},
		{
			name: "empty machine name in comma-separated x-machines",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: "machine-1,,machine-2"
`,
			expectError: true,
		},
		{
			name: "x-machines with whitespace trimming",
			composeYAML: `
services:
  test:
    image: nginx
    x-machines: ["  machine-1  ", "machine-2"]
`,
			expected: api.Placement{
				Machines: []string{"machine-1", "machine-2"},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			project, err := loadProjectFromContent(t, tt.composeYAML)

			if tt.expectError {
				assert.Error(t, err)
				return
			}

			require.NoError(t, err)

			// Convert to ServiceSpec
			spec, err := ServiceSpecFromCompose(project, "test")
			require.NoError(t, err)

			if len(tt.expected.Machines) == 0 && len(spec.Placement.Machines) == 0 {
				// Both are empty, consider them equal
				return
			}
			assert.Equal(t, tt.expected, spec.Placement)
		})
	}
}
