package loader

import (
	"bytes"
	"os"
	"reflect"
	"sort"
	"testing"
	"time"

	"github.com/docker/cli/cli/compose/types"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/sirupsen/logrus"
	"gotest.tools/v3/assert"
	is "gotest.tools/v3/assert/cmp"
)

func buildConfigDetails(source map[string]interface{}, env map[string]string) types.ConfigDetails {
	workingDir, err := os.Getwd()
	if err != nil {
		panic(err)
	}

	return types.ConfigDetails{
		WorkingDir: workingDir,
		ConfigFiles: []types.ConfigFile{
			{Filename: "filename.yml", Config: source},
		},
		Environment: env,
	}
}

func loadYAML(yaml string) (*types.Config, error) {
	return loadYAMLWithEnv(yaml, nil)
}

func loadYAMLWithEnv(yaml string, env map[string]string) (*types.Config, error) {
	dict, err := ParseYAML([]byte(yaml))
	if err != nil {
		return nil, err
	}

	return Load(buildConfigDetails(dict, env))
}

var sampleYAML = `
version: "3"
services:
  foo:
    image: busybox
    networks:
      with_me:
  bar:
    image: busybox
    environment:
      - FOO=1
    networks:
      - with_ipam
volumes:
  hello:
    driver: default
    driver_opts:
      beep: boop
networks:
  default:
    driver: bridge
    driver_opts:
      beep: boop
  with_ipam:
    ipam:
      driver: default
      config:
        - subnet: 172.28.0.0/16
`

var sampleDict = map[string]interface{}{
	"version": "3",
	"services": map[string]interface{}{
		"foo": map[string]interface{}{
			"image":    "busybox",
			"networks": map[string]interface{}{"with_me": nil},
		},
		"bar": map[string]interface{}{
			"image":       "busybox",
			"environment": []interface{}{"FOO=1"},
			"networks":    []interface{}{"with_ipam"},
		},
	},
	"volumes": map[string]interface{}{
		"hello": map[string]interface{}{
			"driver": "default",
			"driver_opts": map[string]interface{}{
				"beep": "boop",
			},
		},
	},
	"networks": map[string]interface{}{
		"default": map[string]interface{}{
			"driver": "bridge",
			"driver_opts": map[string]interface{}{
				"beep": "boop",
			},
		},
		"with_ipam": map[string]interface{}{
			"ipam": map[string]interface{}{
				"driver": "default",
				"config": []interface{}{
					map[string]interface{}{
						"subnet": "172.28.0.0/16",
					},
				},
			},
		},
	},
}

var samplePortsConfig = []types.ServicePortConfig{
	{
		Mode:      "ingress",
		Target:    8080,
		Published: 80,
		Protocol:  "tcp",
	},
	{
		Mode:      "ingress",
		Target:    8081,
		Published: 81,
		Protocol:  "tcp",
	},
	{
		Mode:      "ingress",
		Target:    8082,
		Published: 82,
		Protocol:  "tcp",
	},
	{
		Mode:      "ingress",
		Target:    8090,
		Published: 90,
		Protocol:  "udp",
	},
	{
		Mode:      "ingress",
		Target:    8091,
		Published: 91,
		Protocol:  "udp",
	},
	{
		Mode:      "ingress",
		Target:    8092,
		Published: 92,
		Protocol:  "udp",
	},
	{
		Mode:      "ingress",
		Target:    8500,
		Published: 85,
		Protocol:  "tcp",
	},
	{
		Mode:      "ingress",
		Target:    8600,
		Published: 0,
		Protocol:  "tcp",
	},
	{
		Target:    53,
		Published: 10053,
		Protocol:  "udp",
	},
	{
		Mode:      "host",
		Target:    22,
		Published: 10022,
	},
}

func strPtr(val string) *string {
	return &val
}

var sampleConfig = types.Config{
	Version: "3.0",
	Services: []types.ServiceConfig{
		{
			Name:        "foo",
			Image:       "busybox",
			Environment: map[string]*string{},
			Networks: map[string]*types.ServiceNetworkConfig{
				"with_me": nil,
			},
		},
		{
			Name:        "bar",
			Image:       "busybox",
			Environment: map[string]*string{"FOO": strPtr("1")},
			Networks: map[string]*types.ServiceNetworkConfig{
				"with_ipam": nil,
			},
		},
	},
	Networks: map[string]types.NetworkConfig{
		"default": {
			Driver: "bridge",
			DriverOpts: map[string]string{
				"beep": "boop",
			},
		},
		"with_ipam": {
			Ipam: types.IPAMConfig{
				Driver: "default",
				Config: []*types.IPAMPool{
					{
						Subnet: "172.28.0.0/16",
					},
				},
			},
		},
	},
	Volumes: map[string]types.VolumeConfig{
		"hello": {
			Driver: "default",
			DriverOpts: map[string]string{
				"beep": "boop",
			},
		},
	},
}

func TestParseYAML(t *testing.T) {
	dict, err := ParseYAML([]byte(sampleYAML))
	assert.NilError(t, err)
	assert.Check(t, is.DeepEqual(sampleDict, dict))
}

func TestLoad(t *testing.T) {
	actual, err := Load(buildConfigDetails(sampleDict, nil))
	assert.NilError(t, err)
	assert.Check(t, is.Equal(sampleConfig.Version, actual.Version))
	assert.Check(t, is.DeepEqual(serviceSort(sampleConfig.Services), serviceSort(actual.Services)))
	assert.Check(t, is.DeepEqual(sampleConfig.Networks, actual.Networks))
	assert.Check(t, is.DeepEqual(sampleConfig.Volumes, actual.Volumes))
}

func TestLoadExtras(t *testing.T) {
	actual, err := loadYAML(`
version: "3.7"
services:
  foo:
    image: busybox
    x-foo: bar`)
	assert.NilError(t, err)
	assert.Check(t, is.Len(actual.Services, 1))
	service := actual.Services[0]
	assert.Check(t, is.Equal("busybox", service.Image))
	extras := map[string]interface{}{
		"x-foo": "bar",
	}
	assert.Check(t, is.DeepEqual(extras, service.Extras))
}

func TestLoadV31(t *testing.T) {
	actual, err := loadYAML(`
version: "3.1"
services:
  foo:
    image: busybox
    secrets: [super]
secrets:
  super:
    external: true
`)
	assert.NilError(t, err)
	assert.Check(t, is.Len(actual.Services, 1))
	assert.Check(t, is.Len(actual.Secrets, 1))
}

func TestLoadV33(t *testing.T) {
	actual, err := loadYAML(`
version: "3.3"
services:
  foo:
    image: busybox
    credential_spec:
      file: "/foo"
    configs: [super]
configs:
  super:
    external: true
`)
	assert.NilError(t, err)
	assert.Assert(t, is.Len(actual.Services, 1))
	assert.Check(t, is.Equal(actual.Services[0].CredentialSpec.File, "/foo"))
	assert.Assert(t, is.Len(actual.Configs, 1))
}

func TestLoadV38(t *testing.T) {
	actual, err := loadYAML(`
version: "3.8"
services:
  foo:
    image: busybox
    credential_spec:
      config: "0bt9dmxjvjiqermk6xrop3ekq"
`)
	assert.NilError(t, err)
	assert.Assert(t, is.Len(actual.Services, 1))
	assert.Check(t, is.Equal(actual.Services[0].CredentialSpec.Config, "0bt9dmxjvjiqermk6xrop3ekq"))
}

func TestParseAndLoad(t *testing.T) {
	actual, err := loadYAML(sampleYAML)
	assert.NilError(t, err)
	assert.Check(t, is.DeepEqual(serviceSort(sampleConfig.Services), serviceSort(actual.Services)))
	assert.Check(t, is.DeepEqual(sampleConfig.Networks, actual.Networks))
	assert.Check(t, is.DeepEqual(sampleConfig.Volumes, actual.Volumes))
}

func TestInvalidTopLevelObjectType(t *testing.T) {
	_, err := loadYAML("1")
	assert.ErrorContains(t, err, "Top-level object must be a mapping")

	_, err = loadYAML("\"hello\"")
	assert.ErrorContains(t, err, "Top-level object must be a mapping")

	_, err = loadYAML("[\"hello\"]")
	assert.ErrorContains(t, err, "Top-level object must be a mapping")
}

func TestNonStringKeys(t *testing.T) {
	_, err := loadYAML(`
version: "3"
123:
  foo:
    image: busybox
`)
	assert.ErrorContains(t, err, "Non-string key at top level: 123")

	_, err = loadYAML(`
version: "3"
services:
  foo:
    image: busybox
  123:
    image: busybox
`)
	assert.ErrorContains(t, err, "Non-string key in services: 123")

	_, err = loadYAML(`
version: "3"
services:
  foo:
    image: busybox
networks:
  default:
    ipam:
      config:
        - 123: oh dear
`)
	assert.ErrorContains(t, err, "Non-string key in networks.default.ipam.config[0]: 123")

	_, err = loadYAML(`
version: "3"
services:
  dict-env:
    image: busybox
    environment:
      1: FOO
`)
	assert.ErrorContains(t, err, "Non-string key in services.dict-env.environment: 1")
}

func TestSupportedVersion(t *testing.T) {
	_, err := loadYAML(`
version: "3"
services:
  foo:
    image: busybox
`)
	assert.NilError(t, err)

	_, err = loadYAML(`
version: "3.0"
services:
  foo:
    image: busybox
`)
	assert.NilError(t, err)
}

func TestUnsupportedVersion(t *testing.T) {
	_, err := loadYAML(`
version: "2"
services:
  foo:
    image: busybox
`)
	assert.ErrorContains(t, err, "version")

	_, err = loadYAML(`
version: "2.0"
services:
  foo:
    image: busybox
`)
	assert.ErrorContains(t, err, "version")
}

func TestInvalidVersion(t *testing.T) {
	_, err := loadYAML(`
version: 3
services:
  foo:
    image: busybox
`)
	assert.ErrorContains(t, err, "version must be a string")
}

func TestV1Unsupported(t *testing.T) {
	_, err := loadYAML(`
foo:
  image: busybox
`)
	assert.ErrorContains(t, err, "unsupported Compose file version: 1.0")
}

func TestNonMappingObject(t *testing.T) {
	_, err := loadYAML(`
version: "3"
services:
  - foo:
      image: busybox
`)
	assert.ErrorContains(t, err, "services must be a mapping")

	_, err = loadYAML(`
version: "3"
services:
  foo: busybox
`)
	assert.ErrorContains(t, err, "services.foo must be a mapping")

	_, err = loadYAML(`
version: "3"
networks:
  - default:
      driver: bridge
`)
	assert.ErrorContains(t, err, "networks must be a mapping")

	_, err = loadYAML(`
version: "3"
networks:
  default: bridge
`)
	assert.ErrorContains(t, err, "networks.default must be a mapping")

	_, err = loadYAML(`
version: "3"
volumes:
  - data:
      driver: local
`)
	assert.ErrorContains(t, err, "volumes must be a mapping")

	_, err = loadYAML(`
version: "3"
volumes:
  data: local
`)
	assert.ErrorContains(t, err, "volumes.data must be a mapping")
}

func TestNonStringImage(t *testing.T) {
	_, err := loadYAML(`
version: "3"
services:
  foo:
    image: ["busybox", "latest"]
`)
	assert.ErrorContains(t, err, "services.foo.image must be a string")
}

func TestLoadWithEnvironment(t *testing.T) {
	config, err := loadYAMLWithEnv(`
version: "3"
services:
  dict-env:
    image: busybox
    environment:
      FOO: "1"
      BAR: 2
      BAZ: 2.5
      QUX:
      QUUX:
  list-env:
    image: busybox
    environment:
      - FOO=1
      - BAR=2
      - BAZ=2.5
      - QUX=
      - QUUX
`, map[string]string{"QUX": "qux"})
	assert.NilError(t, err)

	expected := types.MappingWithEquals{
		"FOO":  strPtr("1"),
		"BAR":  strPtr("2"),
		"BAZ":  strPtr("2.5"),
		"QUX":  strPtr("qux"),
		"QUUX": nil,
	}

	assert.Check(t, is.Equal(2, len(config.Services)))

	for _, service := range config.Services {
		assert.Check(t, is.DeepEqual(expected, service.Environment))
	}
}

func TestInvalidEnvironmentValue(t *testing.T) {
	_, err := loadYAML(`
version: "3"
services:
  dict-env:
    image: busybox
    environment:
      FOO: ["1"]
`)
	assert.ErrorContains(t, err, "services.dict-env.environment.FOO must be a string, number or null")
}

func TestInvalidEnvironmentObject(t *testing.T) {
	_, err := loadYAML(`
version: "3"
services:
  dict-env:
    image: busybox
    environment: "FOO=1"
`)
	assert.ErrorContains(t, err, "services.dict-env.environment must be a mapping")
}

func TestLoadWithEnvironmentInterpolation(t *testing.T) {
	home := "/home/foo"
	config, err := loadYAMLWithEnv(`
version: "3"
services:
  test:
    image: busybox
    labels:
      - home1=$HOME
      - home2=${HOME}
      - nonexistent=$NONEXISTENT
      - default=${NONEXISTENT-default}
networks:
  test:
    driver: $HOME
volumes:
  test:
    driver: $HOME
`, map[string]string{
		"HOME": home,
		"FOO":  "foo",
	})

	assert.NilError(t, err)

	expectedLabels := types.Labels{
		"home1":       home,
		"home2":       home,
		"nonexistent": "",
		"default":     "default",
	}

	assert.Check(t, is.DeepEqual(expectedLabels, config.Services[0].Labels))
	assert.Check(t, is.Equal(home, config.Networks["test"].Driver))
	assert.Check(t, is.Equal(home, config.Volumes["test"].Driver))
}

func TestLoadWithInterpolationCastFull(t *testing.T) {
	dict, err := ParseYAML([]byte(`
version: "3.8"
services:
  web:
    configs:
      - source: appconfig
        mode: $theint
    secrets:
      - source: super
        mode: $theint
    healthcheck:
      retries: ${theint}
      disable: $thebool
    deploy:
      replicas: $theint
      update_config:
        parallelism: $theint
        max_failure_ratio: $thefloat
      rollback_config:
        parallelism: $theint
        max_failure_ratio: $thefloat
      restart_policy:
        max_attempts: $theint
      placement:
        max_replicas_per_node: $theint
    ports:
      - $theint
      - "34567"
      - target: $theint
        published: $theint
    ulimits:
      nproc: $theint
      nofile:
        hard: $theint
        soft: $theint
    privileged: $thebool
    read_only: $thebool
    stdin_open: ${thebool}
    tty: $thebool
    volumes:
      - source: data
        type: volume
        read_only: $thebool
        volume:
          nocopy: $thebool

configs:
  appconfig:
    external: $thebool
secrets:
  super:
    external: $thebool
volumes:
  data:
    external: $thebool
networks:
  front:
    external: $thebool
    internal: $thebool
    attachable: $thebool

`))
	assert.NilError(t, err)
	env := map[string]string{
		"theint":   "555",
		"thefloat": "3.14",
		"thebool":  "true",
	}

	config, err := Load(buildConfigDetails(dict, env))
	assert.NilError(t, err)
	expected := &types.Config{
		Filename: "filename.yml",
		Version:  "3.8",
		Services: []types.ServiceConfig{
			{
				Name: "web",
				Configs: []types.ServiceConfigObjConfig{
					{
						Source: "appconfig",
						Mode:   uint32Ptr(555),
					},
				},
				Secrets: []types.ServiceSecretConfig{
					{
						Source: "super",
						Mode:   uint32Ptr(555),
					},
				},
				HealthCheck: &types.HealthCheckConfig{
					Retries: uint64Ptr(555),
					Disable: true,
				},
				Deploy: types.DeployConfig{
					Replicas: uint64Ptr(555),
					UpdateConfig: &types.UpdateConfig{
						Parallelism:     uint64Ptr(555),
						MaxFailureRatio: 3.14,
					},
					RollbackConfig: &types.UpdateConfig{
						Parallelism:     uint64Ptr(555),
						MaxFailureRatio: 3.14,
					},
					RestartPolicy: &types.RestartPolicy{
						MaxAttempts: uint64Ptr(555),
					},
					Placement: types.Placement{
						MaxReplicas: 555,
					},
				},
				Ports: []types.ServicePortConfig{
					{Target: 555, Mode: "ingress", Protocol: "tcp"},
					{Target: 34567, Mode: "ingress", Protocol: "tcp"},
					{Target: 555, Published: 555},
				},
				Ulimits: map[string]*types.UlimitsConfig{
					"nproc":  {Single: 555},
					"nofile": {Hard: 555, Soft: 555},
				},
				Privileged: true,
				ReadOnly:   true,
				StdinOpen:  true,
				Tty:        true,
				Volumes: []types.ServiceVolumeConfig{
					{
						Source:   "data",
						Type:     "volume",
						ReadOnly: true,
						Volume:   &types.ServiceVolumeVolume{NoCopy: true},
					},
				},
				Environment: types.MappingWithEquals{},
			},
		},
		Configs: map[string]types.ConfigObjConfig{
			"appconfig": {External: types.External{External: true}, Name: "appconfig"},
		},
		Secrets: map[string]types.SecretConfig{
			"super": {External: types.External{External: true}, Name: "super"},
		},
		Volumes: map[string]types.VolumeConfig{
			"data": {External: types.External{External: true}, Name: "data"},
		},
		Networks: map[string]types.NetworkConfig{
			"front": {
				External:   types.External{External: true},
				Name:       "front",
				Internal:   true,
				Attachable: true,
			},
		},
	}

	assert.Check(t, is.DeepEqual(expected, config))
}

func TestUnsupportedProperties(t *testing.T) {
	dict, err := ParseYAML([]byte(`
version: "3"
services:
  web:
    image: web
    build:
     context: ./web
    links:
      - bar
    pid: host
  db:
    image: db
    build:
     context: ./db
`))
	assert.NilError(t, err)

	configDetails := buildConfigDetails(dict, nil)

	_, err = Load(configDetails)
	assert.NilError(t, err)

	unsupported := GetUnsupportedProperties(dict)
	assert.Check(t, is.DeepEqual([]string{"build", "links", "pid"}, unsupported))
}

func TestDiscardEnvFileOption(t *testing.T) {
	dict, err := ParseYAML([]byte(`version: "3"
services:
  web:
    image: nginx
    env_file:
     - example1.env
     - example2.env
`))
	expectedEnvironmentMap := types.MappingWithEquals{
		"FOO": strPtr("foo_from_env_file"),
		"BAZ": strPtr("baz_from_env_file"),
		"BAR": strPtr("bar_from_env_file_2"), // Original value is overwritten by example2.env
		"QUX": strPtr("quz_from_env_file_2"),
	}
	assert.NilError(t, err)
	configDetails := buildConfigDetails(dict, nil)

	// Default behavior keeps the `env_file` entries
	configWithEnvFiles, err := Load(configDetails)
	assert.NilError(t, err)
	assert.DeepEqual(t, configWithEnvFiles.Services[0].EnvFile, types.StringList{"example1.env",
		"example2.env"})
	assert.DeepEqual(t, configWithEnvFiles.Services[0].Environment, expectedEnvironmentMap)

	// Custom behavior removes the `env_file` entries
	configWithoutEnvFiles, err := Load(configDetails, WithDiscardEnvFiles)
	assert.NilError(t, err)
	assert.DeepEqual(t, configWithoutEnvFiles.Services[0].EnvFile, types.StringList(nil))
	assert.DeepEqual(t, configWithoutEnvFiles.Services[0].Environment, expectedEnvironmentMap)
}

func TestBuildProperties(t *testing.T) {
	dict, err := ParseYAML([]byte(`
version: "3"
services:
  web:
    image: web
    build: .
    links:
      - bar
  db:
    image: db
    build:
     context: ./db
`))
	assert.NilError(t, err)
	configDetails := buildConfigDetails(dict, nil)
	_, err = Load(configDetails)
	assert.NilError(t, err)
}

func TestDeprecatedProperties(t *testing.T) {
	dict, err := ParseYAML([]byte(`
version: "3"
services:
  web:
    image: web
    container_name: web
  db:
    image: db
    container_name: db
    expose: ["5434"]
`))
	assert.NilError(t, err)

	configDetails := buildConfigDetails(dict, nil)

	_, err = Load(configDetails)
	assert.NilError(t, err)

	deprecated := GetDeprecatedProperties(dict)
	assert.Check(t, is.Len(deprecated, 2))
	assert.Check(t, is.Contains(deprecated, "container_name"))
	assert.Check(t, is.Contains(deprecated, "expose"))
}

func TestForbiddenProperties(t *testing.T) {
	_, err := loadYAML(`
version: "3"
services:
  foo:
    image: busybox
    volumes:
      - /data
    volume_driver: some-driver
  bar:
    extends:
      service: foo
`)

	assert.ErrorType(t, err, reflect.TypeOf(&ForbiddenPropertiesError{}))

	props := err.(*ForbiddenPropertiesError).Properties
	assert.Check(t, is.Len(props, 2))
	assert.Check(t, is.Contains(props, "volume_driver"))
	assert.Check(t, is.Contains(props, "extends"))
}

func TestInvalidResource(t *testing.T) {
	_, err := loadYAML(`
        version: "3"
        services:
          foo:
            image: busybox
            deploy:
              resources:
                impossible:
                  x: 1
`)
	assert.ErrorContains(t, err, "Additional property impossible is not allowed")
}

func TestInvalidExternalAndDriverCombination(t *testing.T) {
	_, err := loadYAML(`
version: "3"
volumes:
  external_volume:
    external: true
    driver: foobar
`)

	assert.ErrorContains(t, err, "conflicting parameters \"external\" and \"driver\" specified for volume")
	assert.ErrorContains(t, err, "external_volume")
}

func TestInvalidExternalAndDirverOptsCombination(t *testing.T) {
	_, err := loadYAML(`
version: "3"
volumes:
  external_volume:
    external: true
    driver_opts:
      beep: boop
`)

	assert.ErrorContains(t, err, "conflicting parameters \"external\" and \"driver_opts\" specified for volume")
	assert.ErrorContains(t, err, "external_volume")
}

func TestInvalidExternalAndLabelsCombination(t *testing.T) {
	_, err := loadYAML(`
version: "3"
volumes:
  external_volume:
    external: true
    labels:
      - beep=boop
`)

	assert.ErrorContains(t, err, "conflicting parameters \"external\" and \"labels\" specified for volume")
	assert.ErrorContains(t, err, "external_volume")
}

func TestLoadVolumeInvalidExternalNameAndNameCombination(t *testing.T) {
	_, err := loadYAML(`
version: "3.4"
volumes:
  external_volume:
    name: user_specified_name
    external:
      name: external_name
`)

	assert.ErrorContains(t, err, "volume.external.name and volume.name conflict; only use volume.name")
	assert.ErrorContains(t, err, "external_volume")
}

func durationPtr(value time.Duration) *types.Duration {
	result := types.Duration(value)
	return &result
}

func uint64Ptr(value uint64) *uint64 {
	return &value
}

func uint32Ptr(value uint32) *uint32 {
	return &value
}

func TestFullExample(t *testing.T) {
	bytes, err := os.ReadFile("full-example.yml")
	assert.NilError(t, err)

	homeDir := "/home/foo"
	env := map[string]string{"HOME": homeDir, "QUX": "qux_from_environment"}
	config, err := loadYAMLWithEnv(string(bytes), env)
	assert.NilError(t, err)

	workingDir, err := os.Getwd()
	assert.NilError(t, err)

	expectedConfig := fullExampleConfig(workingDir, homeDir)

	assert.Check(t, is.DeepEqual(expectedConfig.Services, config.Services))
	assert.Check(t, is.DeepEqual(expectedConfig.Networks, config.Networks))
	assert.Check(t, is.DeepEqual(expectedConfig.Volumes, config.Volumes))
	assert.Check(t, is.DeepEqual(expectedConfig.Secrets, config.Secrets))
	assert.Check(t, is.DeepEqual(expectedConfig.Configs, config.Configs))
	assert.Check(t, is.DeepEqual(expectedConfig.Extras, config.Extras))
}

func TestLoadTmpfsVolume(t *testing.T) {
	config, err := loadYAML(`
version: "3.6"
services:
  tmpfs:
    image: nginx:latest
    volumes:
      - type: tmpfs
        target: /app
        tmpfs:
          size: 10000
`)
	assert.NilError(t, err)

	expected := types.ServiceVolumeConfig{
		Target: "/app",
		Type:   "tmpfs",
		Tmpfs: &types.ServiceVolumeTmpfs{
			Size: int64(10000),
		},
	}

	assert.Assert(t, is.Len(config.Services, 1))
	assert.Check(t, is.Len(config.Services[0].Volumes, 1))
	assert.Check(t, is.DeepEqual(expected, config.Services[0].Volumes[0]))
}

func TestLoadTmpfsVolumeAdditionalPropertyNotAllowed(t *testing.T) {
	_, err := loadYAML(`
version: "3.5"
services:
  tmpfs:
    image: nginx:latest
    volumes:
      - type: tmpfs
        target: /app
        tmpfs:
          size: 10000
`)
	assert.ErrorContains(t, err, "services.tmpfs.volumes.0 Additional property tmpfs is not allowed")
}

func TestLoadBindMountSourceMustNotBeEmpty(t *testing.T) {
	_, err := loadYAML(`
version: "3.5"
services:
  tmpfs:
    image: nginx:latest
    volumes:
      - type: bind
        target: /app
`)
	assert.Error(t, err, `invalid mount config for type "bind": field Source must not be empty`)
}

func TestLoadBindMountSourceIsWindowsAbsolute(t *testing.T) {
	tests := []struct {
		doc      string
		yaml     string
		expected types.ServiceVolumeConfig
	}{
		{
			doc: "Z-drive lowercase",
			yaml: `
version: '3.3'

services:
  windows:
    image: mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2019
    volumes:
      - type: bind
        source: z:\
        target: c:\data
`,
			expected: types.ServiceVolumeConfig{Type: "bind", Source: `z:\`, Target: `c:\data`},
		},
		{
			doc: "Z-drive uppercase",
			yaml: `
version: '3.3'

services:
  windows:
    image: mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2019
    volumes:
      - type: bind
        source: Z:\
        target: C:\data
`,
			expected: types.ServiceVolumeConfig{Type: "bind", Source: `Z:\`, Target: `C:\data`},
		},
		{
			doc: "Z-drive subdirectory",
			yaml: `
version: '3.3'

services:
  windows:
    image: mcr.microsoft.com/windows/servercore/iis:windowsservercore-ltsc2019
    volumes:
      - type: bind
        source: Z:\some-dir
        target: C:\data
`,
			expected: types.ServiceVolumeConfig{Type: "bind", Source: `Z:\some-dir`, Target: `C:\data`},
		},
		{
			doc: "forward-slashes",
			yaml: `
version: '3.3'

services:
  app:
    image: app:latest
    volumes:
      - type: bind
        source: /z/some-dir
        target: /c/data
`,
			expected: types.ServiceVolumeConfig{Type: "bind", Source: `/z/some-dir`, Target: `/c/data`},
		},
	}

	for _, tc := range tests {
		t.Run(tc.doc, func(t *testing.T) {
			config, err := loadYAML(tc.yaml)
			assert.NilError(t, err)
			assert.Check(t, is.Len(config.Services[0].Volumes, 1))
			assert.Check(t, is.DeepEqual(tc.expected, config.Services[0].Volumes[0]))
		})
	}
}

func TestLoadBindMountWithSource(t *testing.T) {
	config, err := loadYAML(`
version: "3.5"
services:
  bind:
    image: nginx:latest
    volumes:
      - type: bind
        target: /app
        source: "."
`)
	assert.NilError(t, err)

	workingDir, err := os.Getwd()
	assert.NilError(t, err)

	expected := types.ServiceVolumeConfig{
		Type:   "bind",
		Source: workingDir,
		Target: "/app",
	}

	assert.Assert(t, is.Len(config.Services, 1))
	assert.Check(t, is.Len(config.Services[0].Volumes, 1))
	assert.Check(t, is.DeepEqual(expected, config.Services[0].Volumes[0]))
}

func TestLoadTmpfsVolumeSizeCanBeZero(t *testing.T) {
	config, err := loadYAML(`
version: "3.6"
services:
  tmpfs:
    image: nginx:latest
    volumes:
      - type: tmpfs
        target: /app
        tmpfs:
          size: 0
`)
	assert.NilError(t, err)

	expected := types.ServiceVolumeConfig{
		Target: "/app",
		Type:   "tmpfs",
		Tmpfs:  &types.ServiceVolumeTmpfs{},
	}

	assert.Assert(t, is.Len(config.Services, 1))
	assert.Check(t, is.Len(config.Services[0].Volumes, 1))
	assert.Check(t, is.DeepEqual(expected, config.Services[0].Volumes[0]))
}

func TestLoadTmpfsVolumeSizeMustBeGTEQZero(t *testing.T) {
	_, err := loadYAML(`
version: "3.6"
services:
  tmpfs:
    image: nginx:latest
    volumes:
      - type: tmpfs
        target: /app
        tmpfs:
          size: -1
`)
	assert.ErrorContains(t, err, "services.tmpfs.volumes.0.tmpfs.size Must be greater than or equal to 0")
}

func TestLoadTmpfsVolumeSizeMustBeInteger(t *testing.T) {
	_, err := loadYAML(`
version: "3.6"
services:
  tmpfs:
    image: nginx:latest
    volumes:
      - type: tmpfs
        target: /app
        tmpfs:
          size: 0.0001
`)
	assert.ErrorContains(t, err, "services.tmpfs.volumes.0.tmpfs.size must be a integer")
}

func serviceSort(services []types.ServiceConfig) []types.ServiceConfig {
	sort.Slice(services, func(i, j int) bool {
		return services[i].Name < services[j].Name
	})
	return services
}

func TestLoadAttachableNetwork(t *testing.T) {
	config, err := loadYAML(`
version: "3.2"
networks:
  mynet1:
    driver: overlay
    attachable: true
  mynet2:
    driver: bridge
`)
	assert.NilError(t, err)

	expected := map[string]types.NetworkConfig{
		"mynet1": {
			Driver:     "overlay",
			Attachable: true,
		},
		"mynet2": {
			Driver:     "bridge",
			Attachable: false,
		},
	}

	assert.Check(t, is.DeepEqual(expected, config.Networks))
}

func TestLoadExpandedPortFormat(t *testing.T) {
	config, err := loadYAML(`
version: "3.2"
services:
  web:
    image: busybox
    ports:
      - "80-82:8080-8082"
      - "90-92:8090-8092/udp"
      - "85:8500"
      - 8600
      - protocol: udp
        target: 53
        published: 10053
      - mode: host
        target: 22
        published: 10022
`)
	assert.NilError(t, err)

	assert.Check(t, is.Len(config.Services, 1))
	assert.Check(t, is.DeepEqual(samplePortsConfig, config.Services[0].Ports))
}

func TestLoadExpandedMountFormat(t *testing.T) {
	config, err := loadYAML(`
version: "3.2"
services:
  web:
    image: busybox
    volumes:
      - type: volume
        source: foo
        target: /target
        read_only: true
volumes:
  foo: {}
`)
	assert.NilError(t, err)

	expected := types.ServiceVolumeConfig{
		Type:     "volume",
		Source:   "foo",
		Target:   "/target",
		ReadOnly: true,
	}

	assert.Assert(t, is.Len(config.Services, 1))
	assert.Check(t, is.Len(config.Services[0].Volumes, 1))
	assert.Check(t, is.DeepEqual(expected, config.Services[0].Volumes[0]))
}

func TestLoadExtraHostsMap(t *testing.T) {
	config, err := loadYAML(`
version: "3.2"
services:
  web:
    image: busybox
    extra_hosts:
      "zulu": "162.242.195.82"
      "alpha": "50.31.209.229"
      "host.docker.internal": "host-gateway"
`)
	assert.NilError(t, err)

	expected := types.HostsList{
		"alpha:50.31.209.229",
		"host.docker.internal:host-gateway",
		"zulu:162.242.195.82",
	}

	assert.Assert(t, is.Len(config.Services, 1))
	assert.Check(t, is.DeepEqual(expected, config.Services[0].ExtraHosts))
}

func TestLoadExtraHostsList(t *testing.T) {
	config, err := loadYAML(`
version: "3.2"
services:
  web:
    image: busybox
    extra_hosts:
      - "zulu:162.242.195.82"
      - "alpha:50.31.209.229"
      - "zulu:ff02::1"
      - "host.docker.internal:host-gateway"
`)
	assert.NilError(t, err)

	expected := types.HostsList{
		"zulu:162.242.195.82",
		"alpha:50.31.209.229",
		"zulu:ff02::1",
		"host.docker.internal:host-gateway",
	}

	assert.Assert(t, is.Len(config.Services, 1))
	assert.Check(t, is.DeepEqual(expected, config.Services[0].ExtraHosts))
}

func TestLoadVolumesWarnOnDeprecatedExternalNameVersion34(t *testing.T) {
	buf, cleanup := patchLogrus()
	defer cleanup()

	source := map[string]interface{}{
		"foo": map[string]interface{}{
			"external": map[string]interface{}{
				"name": "oops",
			},
		},
	}
	volumes, err := LoadVolumes(source, "3.4")
	assert.NilError(t, err)
	expected := map[string]types.VolumeConfig{
		"foo": {
			Name:     "oops",
			External: types.External{External: true},
		},
	}
	assert.Check(t, is.DeepEqual(expected, volumes))
	assert.Check(t, is.Contains(buf.String(), "volume.external.name is deprecated"))

}

func patchLogrus() (*bytes.Buffer, func()) {
	buf := new(bytes.Buffer)
	out := logrus.StandardLogger().Out
	logrus.SetOutput(buf)
	return buf, func() { logrus.SetOutput(out) }
}

func TestLoadVolumesWarnOnDeprecatedExternalNameVersion33(t *testing.T) {
	buf, cleanup := patchLogrus()
	defer cleanup()

	source := map[string]interface{}{
		"foo": map[string]interface{}{
			"external": map[string]interface{}{
				"name": "oops",
			},
		},
	}
	volumes, err := LoadVolumes(source, "3.3")
	assert.NilError(t, err)
	expected := map[string]types.VolumeConfig{
		"foo": {
			Name:     "oops",
			External: types.External{External: true},
		},
	}
	assert.Check(t, is.DeepEqual(expected, volumes))
	assert.Check(t, is.Equal("", buf.String()))
}

func TestLoadV35(t *testing.T) {
	actual, err := loadYAML(`
version: "3.5"
services:
  foo:
    image: busybox
    isolation: process
configs:
  foo:
    name: fooqux
    external: true
  bar:
    name: barqux
    file: ./example1.env
secrets:
  foo:
    name: fooqux
    external: true
  bar:
    name: barqux
    file: ./full-example.yml
`)
	assert.NilError(t, err)
	assert.Check(t, is.Len(actual.Services, 1))
	assert.Check(t, is.Len(actual.Secrets, 2))
	assert.Check(t, is.Len(actual.Configs, 2))
	assert.Check(t, is.Equal("process", actual.Services[0].Isolation))
}

func TestLoadV35InvalidIsolation(t *testing.T) {
	// validation should be done only on the daemon side
	actual, err := loadYAML(`
version: "3.5"
services:
  foo:
    image: busybox
    isolation: invalid
configs:
  super:
    external: true
`)
	assert.NilError(t, err)
	assert.Assert(t, is.Len(actual.Services, 1))
	assert.Check(t, is.Equal("invalid", actual.Services[0].Isolation))
}

func TestLoadSecretInvalidExternalNameAndNameCombination(t *testing.T) {
	_, err := loadYAML(`
version: "3.5"
secrets:
  external_secret:
    name: user_specified_name
    external:
      name: external_name
`)

	assert.ErrorContains(t, err, "secret.external.name and secret.name conflict; only use secret.name")
	assert.ErrorContains(t, err, "external_secret")
}

func TestLoadSecretsWarnOnDeprecatedExternalNameVersion35(t *testing.T) {
	buf, cleanup := patchLogrus()
	defer cleanup()

	source := map[string]interface{}{
		"foo": map[string]interface{}{
			"external": map[string]interface{}{
				"name": "oops",
			},
		},
	}
	details := types.ConfigDetails{
		Version: "3.5",
	}
	secrets, err := LoadSecrets(source, details)
	assert.NilError(t, err)
	expected := map[string]types.SecretConfig{
		"foo": {
			Name:     "oops",
			External: types.External{External: true},
		},
	}
	assert.Check(t, is.DeepEqual(expected, secrets))
	assert.Check(t, is.Contains(buf.String(), "secret.external.name is deprecated"))
}

func TestLoadNetworksWarnOnDeprecatedExternalNameVersion35(t *testing.T) {
	buf, cleanup := patchLogrus()
	defer cleanup()

	source := map[string]interface{}{
		"foo": map[string]interface{}{
			"external": map[string]interface{}{
				"name": "oops",
			},
		},
	}
	networks, err := LoadNetworks(source, "3.5")
	assert.NilError(t, err)
	expected := map[string]types.NetworkConfig{
		"foo": {
			Name:     "oops",
			External: types.External{External: true},
		},
	}
	assert.Check(t, is.DeepEqual(expected, networks))
	assert.Check(t, is.Contains(buf.String(), "network.external.name is deprecated"))

}

func TestLoadNetworksWarnOnDeprecatedExternalNameVersion34(t *testing.T) {
	buf, cleanup := patchLogrus()
	defer cleanup()

	source := map[string]interface{}{
		"foo": map[string]interface{}{
			"external": map[string]interface{}{
				"name": "oops",
			},
		},
	}
	networks, err := LoadNetworks(source, "3.4")
	assert.NilError(t, err)
	expected := map[string]types.NetworkConfig{
		"foo": {
			Name:     "oops",
			External: types.External{External: true},
		},
	}
	assert.Check(t, is.DeepEqual(expected, networks))
	assert.Check(t, is.Equal("", buf.String()))
}

func TestLoadNetworkInvalidExternalNameAndNameCombination(t *testing.T) {
	_, err := loadYAML(`
version: "3.5"
networks:
  foo:
    name: user_specified_name
    external:
      name: external_name
`)

	assert.ErrorContains(t, err, "network.external.name and network.name conflict; only use network.name")
	assert.ErrorContains(t, err, "foo")
}

func TestLoadNetworkWithName(t *testing.T) {
	config, err := loadYAML(`
version: '3.5'
services:
  hello-world:
    image: redis:alpine
    networks:
      - network1
      - network3

networks:
  network1:
    name: network2
  network3:
`)
	assert.NilError(t, err)
	expected := &types.Config{
		Filename: "filename.yml",
		Version:  "3.5",
		Services: types.Services{
			{
				Name:  "hello-world",
				Image: "redis:alpine",
				Networks: map[string]*types.ServiceNetworkConfig{
					"network1": nil,
					"network3": nil,
				},
			},
		},
		Networks: map[string]types.NetworkConfig{
			"network1": {Name: "network2"},
			"network3": {},
		},
	}
	assert.DeepEqual(t, config, expected, cmpopts.EquateEmpty())
}

func TestLoadInit(t *testing.T) {
	booleanTrue := true
	booleanFalse := false

	var testcases = []struct {
		doc  string
		yaml string
		init *bool
	}{
		{
			doc: "no init defined",
			yaml: `
version: '3.7'
services:
  foo:
    image: alpine`,
		},
		{
			doc: "has true init",
			yaml: `
version: '3.7'
services:
  foo:
    image: alpine
    init: true`,
			init: &booleanTrue,
		},
		{
			doc: "has false init",
			yaml: `
version: '3.7'
services:
  foo:
    image: alpine
    init: false`,
			init: &booleanFalse,
		},
	}
	for _, testcase := range testcases {
		testcase := testcase
		t.Run(testcase.doc, func(t *testing.T) {
			config, err := loadYAML(testcase.yaml)
			assert.NilError(t, err)
			assert.Check(t, is.Len(config.Services, 1))
			assert.Check(t, is.DeepEqual(config.Services[0].Init, testcase.init))
		})
	}
}

func TestLoadSysctls(t *testing.T) {
	config, err := loadYAML(`
version: "3.8"
services:
  web:
    image: busybox
    sysctls:
      - net.core.somaxconn=1024
      - net.ipv4.tcp_syncookies=0
      - testing.one.one=
      - testing.one.two
`)
	assert.NilError(t, err)

	expected := types.Mapping{
		"net.core.somaxconn":      "1024",
		"net.ipv4.tcp_syncookies": "0",
		"testing.one.one":         "",
		"testing.one.two":         "",
	}

	assert.Assert(t, is.Len(config.Services, 1))
	assert.Check(t, is.DeepEqual(expected, config.Services[0].Sysctls))

	config, err = loadYAML(`
version: "3.8"
services:
  web:
    image: busybox
    sysctls:
      net.core.somaxconn: 1024
      net.ipv4.tcp_syncookies: 0
      testing.one.one: ""
      testing.one.two:
`)
	assert.NilError(t, err)

	assert.Assert(t, is.Len(config.Services, 1))
	assert.Check(t, is.DeepEqual(expected, config.Services[0].Sysctls))
}

func TestTransform(t *testing.T) {
	var source = []interface{}{
		"80-82:8080-8082",
		"90-92:8090-8092/udp",
		"85:8500",
		8600,
		map[string]interface{}{
			"protocol":  "udp",
			"target":    53,
			"published": 10053,
		},
		map[string]interface{}{
			"mode":      "host",
			"target":    22,
			"published": 10022,
		},
	}
	var ports []types.ServicePortConfig
	err := Transform(source, &ports)
	assert.NilError(t, err)

	assert.Check(t, is.DeepEqual(samplePortsConfig, ports))
}

func TestLoadTemplateDriver(t *testing.T) {
	config, err := loadYAML(`
version: '3.8'
services:
  hello-world:
    image: redis:alpine
    secrets:
      - secret
    configs:
      - config

configs:
  config:
    name: config
    external: true
    template_driver: config-driver

secrets:
  secret:
    name: secret
    external: true
    template_driver: secret-driver
`)
	assert.NilError(t, err)
	expected := &types.Config{
		Filename: "filename.yml",
		Version:  "3.8",
		Services: types.Services{
			{
				Name:  "hello-world",
				Image: "redis:alpine",
				Configs: []types.ServiceConfigObjConfig{
					{
						Source: "config",
					},
				},
				Secrets: []types.ServiceSecretConfig{
					{
						Source: "secret",
					},
				},
			},
		},
		Configs: map[string]types.ConfigObjConfig{
			"config": {
				Name:           "config",
				External:       types.External{External: true},
				TemplateDriver: "config-driver",
			},
		},
		Secrets: map[string]types.SecretConfig{
			"secret": {
				Name:           "secret",
				External:       types.External{External: true},
				TemplateDriver: "secret-driver",
			},
		},
	}
	assert.DeepEqual(t, config, expected, cmpopts.EquateEmpty())
}

func TestLoadSecretDriver(t *testing.T) {
	config, err := loadYAML(`
version: '3.8'
services:
  hello-world:
    image: redis:alpine
    secrets:
      - secret
    configs:
      - config

configs:
  config:
    name: config
    external: true

secrets:
  secret:
    name: secret
    driver: secret-bucket
    driver_opts:
      OptionA: value for driver option A
      OptionB: value for driver option B
`)
	assert.NilError(t, err)
	expected := &types.Config{
		Filename: "filename.yml",
		Version:  "3.8",
		Services: types.Services{
			{
				Name:  "hello-world",
				Image: "redis:alpine",
				Configs: []types.ServiceConfigObjConfig{
					{
						Source: "config",
					},
				},
				Secrets: []types.ServiceSecretConfig{
					{
						Source: "secret",
					},
				},
			},
		},
		Configs: map[string]types.ConfigObjConfig{
			"config": {
				Name:     "config",
				External: types.External{External: true},
			},
		},
		Secrets: map[string]types.SecretConfig{
			"secret": {
				Name:   "secret",
				Driver: "secret-bucket",
				DriverOpts: map[string]string{
					"OptionA": "value for driver option A",
					"OptionB": "value for driver option B",
				},
			},
		},
	}
	assert.DeepEqual(t, config, expected, cmpopts.EquateEmpty())
}
