package loader

import (
	"fmt"
	"io/ioutil"
	"os"
	"sort"
	"testing"
	"time"

	"github.com/docker/docker/cli/compose/types"
	"github.com/stretchr/testify/assert"
)

func buildConfigDetails(source types.Dict) 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: nil,
	}
}

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 = types.Dict{
	"version": "3",
	"services": types.Dict{
		"foo": types.Dict{
			"image":    "busybox",
			"networks": types.Dict{"with_me": nil},
		},
		"bar": types.Dict{
			"image":       "busybox",
			"environment": []interface{}{"FOO=1"},
			"networks":    []interface{}{"with_ipam"},
		},
	},
	"volumes": types.Dict{
		"hello": types.Dict{
			"driver": "default",
			"driver_opts": types.Dict{
				"beep": "boop",
			},
		},
	},
	"networks": types.Dict{
		"default": types.Dict{
			"driver": "bridge",
			"driver_opts": types.Dict{
				"beep": "boop",
			},
		},
		"with_ipam": types.Dict{
			"ipam": types.Dict{
				"driver": "default",
				"config": []interface{}{
					types.Dict{
						"subnet": "172.28.0.0/16",
					},
				},
			},
		},
	},
}

var sampleConfig = types.Config{
	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": "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))
	if !assert.NoError(t, err) {
		return
	}
	assert.Equal(t, sampleDict, dict)
}

func TestLoad(t *testing.T) {
	actual, err := Load(buildConfigDetails(sampleDict))
	if !assert.NoError(t, err) {
		return
	}
	assert.Equal(t, serviceSort(sampleConfig.Services), serviceSort(actual.Services))
	assert.Equal(t, sampleConfig.Networks, actual.Networks)
	assert.Equal(t, sampleConfig.Volumes, actual.Volumes)
}

func TestLoadV31(t *testing.T) {
	actual, err := loadYAML(`
version: "3.1"
services:
  foo:
    image: busybox
    secrets: [super]
secrets:
  super:
    external: true
`)
	if !assert.NoError(t, err) {
		return
	}
	assert.Equal(t, len(actual.Services), 1)
	assert.Equal(t, len(actual.Secrets), 1)
}

func TestParseAndLoad(t *testing.T) {
	actual, err := loadYAML(sampleYAML)
	if !assert.NoError(t, err) {
		return
	}
	assert.Equal(t, serviceSort(sampleConfig.Services), serviceSort(actual.Services))
	assert.Equal(t, sampleConfig.Networks, actual.Networks)
	assert.Equal(t, sampleConfig.Volumes, actual.Volumes)
}

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

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

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

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

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

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

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

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

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

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

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

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

func TestV1Unsupported(t *testing.T) {
	_, err := loadYAML(`
foo:
  image: busybox
`)
	assert.Error(t, err)
}

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

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

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

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

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

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

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

func TestValidEnvironment(t *testing.T) {
	config, err := loadYAML(`
version: "3"
services:
  dict-env:
    image: busybox
    environment:
      FOO: "1"
      BAR: 2
      BAZ: 2.5
      QUUX:
  list-env:
    image: busybox
    environment:
      - FOO=1
      - BAR=2
      - BAZ=2.5
      - QUUX=
`)
	assert.NoError(t, err)

	expected := map[string]string{
		"FOO":  "1",
		"BAR":  "2",
		"BAZ":  "2.5",
		"QUUX": "",
	}

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

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

func TestInvalidEnvironmentValue(t *testing.T) {
	_, err := loadYAML(`
version: "3"
services:
  dict-env:
    image: busybox
    environment:
      FOO: ["1"]
`)
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "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.Error(t, err)
	assert.Contains(t, err.Error(), "services.dict-env.environment must be a mapping")
}

func TestEnvironmentInterpolation(t *testing.T) {
	config, err := loadYAML(`
version: "3"
services:
  test:
    image: busybox
    labels:
      - home1=$HOME
      - home2=${HOME}
      - nonexistent=$NONEXISTENT
      - default=${NONEXISTENT-default}
networks:
  test:
    driver: $HOME
volumes:
  test:
    driver: $HOME
`)

	assert.NoError(t, err)

	home := os.Getenv("HOME")

	expectedLabels := map[string]string{
		"home1":       home,
		"home2":       home,
		"nonexistent": "",
		"default":     "default",
	}

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

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

	configDetails := buildConfigDetails(dict)

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

	unsupported := GetUnsupportedProperties(configDetails)
	assert.Equal(t, []string{"build", "links"}, unsupported)
}

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.NoError(t, err)

	configDetails := buildConfigDetails(dict)

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

	deprecated := GetDeprecatedProperties(configDetails)
	assert.Equal(t, 2, len(deprecated))
	assert.Contains(t, deprecated, "container_name")
	assert.Contains(t, 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.Error(t, err)
	assert.IsType(t, &ForbiddenPropertiesError{}, err)
	fmt.Println(err)
	forbidden := err.(*ForbiddenPropertiesError).Properties

	assert.Equal(t, 2, len(forbidden))
	assert.Contains(t, forbidden, "volume_driver")
	assert.Contains(t, forbidden, "extends")
}

func durationPtr(value time.Duration) *time.Duration {
	return &value
}

func int64Ptr(value int64) *int64 {
	return &value
}

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

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

	config, err := loadYAML(string(bytes))
	if !assert.NoError(t, err) {
		return
	}

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

	homeDir := os.Getenv("HOME")
	stopGracePeriod := time.Duration(20 * time.Second)

	expectedServiceConfig := types.ServiceConfig{
		Name: "foo",

		CapAdd:        []string{"ALL"},
		CapDrop:       []string{"NET_ADMIN", "SYS_ADMIN"},
		CgroupParent:  "m-executor-abcd",
		Command:       []string{"bundle", "exec", "thin", "-p", "3000"},
		ContainerName: "my-web-container",
		DependsOn:     []string{"db", "redis"},
		Deploy: types.DeployConfig{
			Mode:     "replicated",
			Replicas: uint64Ptr(6),
			Labels:   map[string]string{"FOO": "BAR"},
			UpdateConfig: &types.UpdateConfig{
				Parallelism:     uint64Ptr(3),
				Delay:           time.Duration(10 * time.Second),
				FailureAction:   "continue",
				Monitor:         time.Duration(60 * time.Second),
				MaxFailureRatio: 0.3,
			},
			Resources: types.Resources{
				Limits: &types.Resource{
					NanoCPUs:    "0.001",
					MemoryBytes: 50 * 1024 * 1024,
				},
				Reservations: &types.Resource{
					NanoCPUs:    "0.0001",
					MemoryBytes: 20 * 1024 * 1024,
				},
			},
			RestartPolicy: &types.RestartPolicy{
				Condition:   "on_failure",
				Delay:       durationPtr(5 * time.Second),
				MaxAttempts: uint64Ptr(3),
				Window:      durationPtr(2 * time.Minute),
			},
			Placement: types.Placement{
				Constraints: []string{"node=foo"},
			},
		},
		Devices:    []string{"/dev/ttyUSB0:/dev/ttyUSB0"},
		DNS:        []string{"8.8.8.8", "9.9.9.9"},
		DNSSearch:  []string{"dc1.example.com", "dc2.example.com"},
		DomainName: "foo.com",
		Entrypoint: []string{"/code/entrypoint.sh", "-p", "3000"},
		Environment: map[string]string{
			"RACK_ENV":       "development",
			"SHOW":           "true",
			"SESSION_SECRET": "",
			"FOO":            "1",
			"BAR":            "2",
			"BAZ":            "3",
		},
		Expose: []string{"3000", "8000"},
		ExternalLinks: []string{
			"redis_1",
			"project_db_1:mysql",
			"project_db_1:postgresql",
		},
		ExtraHosts: map[string]string{
			"otherhost": "50.31.209.229",
			"somehost":  "162.242.195.82",
		},
		HealthCheck: &types.HealthCheckConfig{
			Test: []string{
				"CMD-SHELL",
				"echo \"hello world\"",
			},
			Interval: "10s",
			Timeout:  "1s",
			Retries:  uint64Ptr(5),
		},
		Hostname: "foo",
		Image:    "redis",
		Ipc:      "host",
		Labels: map[string]string{
			"com.example.description": "Accounting webapp",
			"com.example.number":      "42",
			"com.example.empty-label": "",
		},
		Links: []string{
			"db",
			"db:database",
			"redis",
		},
		Logging: &types.LoggingConfig{
			Driver: "syslog",
			Options: map[string]string{
				"syslog-address": "tcp://192.168.0.42:123",
			},
		},
		MacAddress:  "02:42:ac:11:65:43",
		NetworkMode: "container:0cfeab0f748b9a743dc3da582046357c6ef497631c1a016d28d2bf9b4f899f7b",
		Networks: map[string]*types.ServiceNetworkConfig{
			"some-network": {
				Aliases:     []string{"alias1", "alias3"},
				Ipv4Address: "",
				Ipv6Address: "",
			},
			"other-network": {
				Ipv4Address: "172.16.238.10",
				Ipv6Address: "2001:3984:3989::10",
			},
			"other-other-network": nil,
		},
		Pid: "host",
		Ports: []string{
			"3000",
			"3000-3005",
			"8000:8000",
			"9090-9091:8080-8081",
			"49100:22",
			"127.0.0.1:8001:8001",
			"127.0.0.1:5000-5010:5000-5010",
		},
		Privileged: true,
		ReadOnly:   true,
		Restart:    "always",
		SecurityOpt: []string{
			"label=level:s0:c100,c200",
			"label=type:svirt_apache_t",
		},
		StdinOpen:       true,
		StopSignal:      "SIGUSR1",
		StopGracePeriod: &stopGracePeriod,
		Tmpfs:           []string{"/run", "/tmp"},
		Tty:             true,
		Ulimits: map[string]*types.UlimitsConfig{
			"nproc": {
				Single: 65535,
			},
			"nofile": {
				Soft: 20000,
				Hard: 40000,
			},
		},
		User: "someone",
		Volumes: []string{
			"/var/lib/mysql",
			"/opt/data:/var/lib/mysql",
			fmt.Sprintf("%s:/code", workingDir),
			fmt.Sprintf("%s/static:/var/www/html", workingDir),
			fmt.Sprintf("%s/configs:/etc/configs/:ro", homeDir),
			"datavolume:/var/lib/mysql",
		},
		WorkingDir: "/code",
	}

	assert.Equal(t, []types.ServiceConfig{expectedServiceConfig}, config.Services)

	expectedNetworkConfig := map[string]types.NetworkConfig{
		"some-network": {},

		"other-network": {
			Driver: "overlay",
			DriverOpts: map[string]string{
				"foo": "bar",
				"baz": "1",
			},
			Ipam: types.IPAMConfig{
				Driver: "overlay",
				Config: []*types.IPAMPool{
					{Subnet: "172.16.238.0/24"},
					{Subnet: "2001:3984:3989::/64"},
				},
			},
		},

		"external-network": {
			External: types.External{
				Name:     "external-network",
				External: true,
			},
		},

		"other-external-network": {
			External: types.External{
				Name:     "my-cool-network",
				External: true,
			},
		},
	}

	assert.Equal(t, expectedNetworkConfig, config.Networks)

	expectedVolumeConfig := map[string]types.VolumeConfig{
		"some-volume": {},
		"other-volume": {
			Driver: "flocker",
			DriverOpts: map[string]string{
				"foo": "bar",
				"baz": "1",
			},
		},
		"external-volume": {
			External: types.External{
				Name:     "external-volume",
				External: true,
			},
		},
		"other-external-volume": {
			External: types.External{
				Name:     "my-cool-volume",
				External: true,
			},
		},
	}

	assert.Equal(t, expectedVolumeConfig, config.Volumes)
}

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

	return Load(buildConfigDetails(dict))
}

func serviceSort(services []types.ServiceConfig) []types.ServiceConfig {
	sort.Sort(servicesByName(services))
	return services
}

type servicesByName []types.ServiceConfig

func (sbn servicesByName) Len() int           { return len(sbn) }
func (sbn servicesByName) Swap(i, j int)      { sbn[i], sbn[j] = sbn[j], sbn[i] }
func (sbn servicesByName) Less(i, j int) bool { return sbn[i].Name < sbn[j].Name }
