package config

import (
	"fmt"
	"strconv"
	"strings"

	"github.com/docker/libcompose/utils"
	"github.com/xeipuuv/gojsonschema"
)

func serviceNameFromErrorField(field string) string {
	splitKeys := strings.Split(field, ".")
	return splitKeys[0]
}

func keyNameFromErrorField(field string) string {
	splitKeys := strings.Split(field, ".")

	if len(splitKeys) > 0 {
		return splitKeys[len(splitKeys)-1]
	}

	return ""
}

func containsTypeError(resultError gojsonschema.ResultError) bool {
	contextSplit := strings.Split(resultError.Context().String(), ".")
	_, err := strconv.Atoi(contextSplit[len(contextSplit)-1])
	return err == nil
}

func addArticle(s string) string {
	switch s[0] {
	case 'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U':
		return "an " + s
	default:
		return "a " + s
	}
}

// Gets the value in a service map at a given error context
func getValue(val interface{}, context string) string {
	keys := strings.Split(context, ".")

	if keys[0] == "(root)" {
		keys = keys[1:]
	}

	for i, k := range keys {
		switch typedVal := (val).(type) {
		case string:
			return typedVal
		case []interface{}:
			if index, err := strconv.Atoi(k); err == nil {
				val = typedVal[index]
			}
		case RawServiceMap:
			val = typedVal[k]
		case RawService:
			val = typedVal[k]
		case map[interface{}]interface{}:
			val = typedVal[k]
		}

		if i == len(keys)-1 {
			return fmt.Sprint(val)
		}
	}

	return ""
}

func convertServiceMapKeysToStrings(serviceMap RawServiceMap) RawServiceMap {
	newServiceMap := make(RawServiceMap)
	for k, v := range serviceMap {
		newServiceMap[k] = convertServiceKeysToStrings(v)
	}
	return newServiceMap
}

func convertServiceKeysToStrings(service RawService) RawService {
	newService := make(RawService)
	for k, v := range service {
		newService[k] = utils.ConvertKeysToStrings(v)
	}
	return newService
}

var dockerConfigHints = map[string]string{
	"cpu_share":   "cpu_shares",
	"add_host":    "extra_hosts",
	"hosts":       "extra_hosts",
	"extra_host":  "extra_hosts",
	"device":      "devices",
	"link":        "links",
	"memory_swap": "memswap_limit",
	"port":        "ports",
	"privilege":   "privileged",
	"priviliged":  "privileged",
	"privilige":   "privileged",
	"volume":      "volumes",
	"workdir":     "working_dir",
}

func unsupportedConfigMessage(key string, nextErr gojsonschema.ResultError) string {
	service := serviceNameFromErrorField(nextErr.Field())

	message := fmt.Sprintf("Unsupported config option for %s service: '%s'", service, key)
	if val, ok := dockerConfigHints[key]; ok {
		message += fmt.Sprintf(" (did you mean '%s'?)", val)
	}

	return message
}

func oneOfMessage(serviceMap RawServiceMap, schema map[string]interface{}, err, nextErr gojsonschema.ResultError) string {
	switch nextErr.Type() {
	case "additional_property_not_allowed":
		property := nextErr.Details()["property"]

		return fmt.Sprintf("contains unsupported option: '%s'", property)
	case "invalid_type":
		if containsTypeError(nextErr) {
			expectedType := addArticle(nextErr.Details()["expected"].(string))

			return fmt.Sprintf("contains %s, which is an invalid type, it should be %s", getValue(serviceMap, nextErr.Context().String()), expectedType)
		}

		validTypes := parseValidTypesFromSchema(schema, err.Context().String())

		validTypesMsg := addArticle(strings.Join(validTypes, " or "))

		return fmt.Sprintf("contains an invalid type, it should be %s", validTypesMsg)
	case "unique":
		contextWithDuplicates := getValue(serviceMap, nextErr.Context().String())

		return fmt.Sprintf("contains non unique items, please remove duplicates from %s", contextWithDuplicates)
	}

	return ""
}

func invalidTypeMessage(service, key string, err gojsonschema.ResultError) string {
	expectedTypesString := err.Details()["expected"].(string)
	var expectedTypes []string

	if strings.Contains(expectedTypesString, ",") {
		expectedTypes = strings.Split(expectedTypesString[1:len(expectedTypesString)-1], ",")
	} else {
		expectedTypes = []string{expectedTypesString}
	}

	validTypesMsg := addArticle(strings.Join(expectedTypes, " or "))

	return fmt.Sprintf("Service '%s' configuration key '%s' contains an invalid type, it should be %s.", service, key, validTypesMsg)
}

func validate(serviceMap RawServiceMap) error {
	if err := setupSchemaLoaders(schemaDataV1, &schemaV1, &schemaLoaderV1, &constraintSchemaLoaderV1); err != nil {
		return err
	}

	serviceMap = convertServiceMapKeysToStrings(serviceMap)

	dataLoader := gojsonschema.NewGoLoader(serviceMap)

	result, err := gojsonschema.Validate(schemaLoaderV1, dataLoader)
	if err != nil {
		return err
	}

	return generateErrorMessages(serviceMap, schemaV1, result)
}

func validateV2(serviceMap RawServiceMap) error {
	if err := setupSchemaLoaders(servicesSchemaDataV2, &schemaV2, &schemaLoaderV2, &constraintSchemaLoaderV2); err != nil {
		return err
	}

	serviceMap = convertServiceMapKeysToStrings(serviceMap)

	dataLoader := gojsonschema.NewGoLoader(serviceMap)

	result, err := gojsonschema.Validate(schemaLoaderV2, dataLoader)
	if err != nil {
		return err
	}

	return generateErrorMessages(serviceMap, schemaV2, result)
}

func generateErrorMessages(serviceMap RawServiceMap, schema map[string]interface{}, result *gojsonschema.Result) error {
	var validationErrors []string

	// gojsonschema can create extraneous "additional_property_not_allowed" errors in some cases
	// If this is set, and the error is at root level, skip over that error
	skipRootAdditionalPropertyError := false

	if !result.Valid() {
		for i := 0; i < len(result.Errors()); i++ {
			err := result.Errors()[i]

			if skipRootAdditionalPropertyError && err.Type() == "additional_property_not_allowed" && err.Context().String() == "(root)" {
				skipRootAdditionalPropertyError = false
				continue
			}

			if err.Context().String() == "(root)" {
				switch err.Type() {
				case "additional_property_not_allowed":
					validationErrors = append(validationErrors, fmt.Sprintf("Invalid service name '%s' - only [a-zA-Z0-9\\._\\-] characters are allowed", err.Field()))
				default:
					validationErrors = append(validationErrors, err.Description())
				}
			} else {
				skipRootAdditionalPropertyError = true

				serviceName := serviceNameFromErrorField(err.Field())
				key := keyNameFromErrorField(err.Field())

				switch err.Type() {
				case "additional_property_not_allowed":
					validationErrors = append(validationErrors, unsupportedConfigMessage(key, result.Errors()[i+1]))
				case "number_one_of":
					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key '%s' %s", serviceName, key, oneOfMessage(serviceMap, schema, err, result.Errors()[i+1])))

					// Next error handled in oneOfMessage, skip over it
					i++
				case "invalid_type":
					validationErrors = append(validationErrors, invalidTypeMessage(serviceName, key, err))
				case "required":
					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' option '%s' is invalid, %s", serviceName, key, err.Description()))
				case "missing_dependency":
					dependency := err.Details()["dependency"].(string)
					validationErrors = append(validationErrors, fmt.Sprintf("Invalid configuration for '%s' service: dependency '%s' is not satisfied", serviceName, dependency))
				case "unique":
					contextWithDuplicates := getValue(serviceMap, err.Context().String())
					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key '%s' value %s has non-unique elements", serviceName, key, contextWithDuplicates))
				default:
					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' configuration key %s value %s", serviceName, key, err.Description()))
				}
			}
		}

		return fmt.Errorf(strings.Join(validationErrors, "\n"))
	}

	return nil
}

func validateServiceConstraints(service RawService, serviceName string) error {
	if err := setupSchemaLoaders(schemaDataV1, &schemaV1, &schemaLoaderV1, &constraintSchemaLoaderV1); err != nil {
		return err
	}

	service = convertServiceKeysToStrings(service)

	var validationErrors []string

	dataLoader := gojsonschema.NewGoLoader(service)

	result, err := gojsonschema.Validate(constraintSchemaLoaderV1, dataLoader)
	if err != nil {
		return err
	}

	if !result.Valid() {
		for _, err := range result.Errors() {
			if err.Type() == "number_any_of" {
				_, containsImage := service["image"]
				_, containsBuild := service["build"]
				_, containsDockerfile := service["dockerfile"]

				if containsImage && containsBuild {
					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has both an image and build path specified. A service can either be built to image or use an existing image, not both.", serviceName))
				} else if !containsImage && !containsBuild {
					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has neither an image nor a build path specified. Exactly one must be provided.", serviceName))
				} else if containsImage && containsDockerfile {
					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has both an image and alternate Dockerfile. A service can either be built to image or use an existing image, not both.", serviceName))
				}
			}
		}

		return fmt.Errorf(strings.Join(validationErrors, "\n"))
	}

	return nil
}

func validateServiceConstraintsv2(service RawService, serviceName string) error {
	if err := setupSchemaLoaders(servicesSchemaDataV2, &schemaV2, &schemaLoaderV2, &constraintSchemaLoaderV2); err != nil {
		return err
	}

	service = convertServiceKeysToStrings(service)

	var validationErrors []string

	dataLoader := gojsonschema.NewGoLoader(service)

	result, err := gojsonschema.Validate(constraintSchemaLoaderV2, dataLoader)
	if err != nil {
		return err
	}

	if !result.Valid() {
		for _, err := range result.Errors() {
			if err.Type() == "required" {
				_, containsImage := service["image"]
				_, containsBuild := service["build"]

				if containsBuild || !containsImage && !containsBuild {
					validationErrors = append(validationErrors, fmt.Sprintf("Service '%s' has neither an image nor a build context specified. At least one must be provided.", serviceName))
				}
			}
		}
		return fmt.Errorf(strings.Join(validationErrors, "\n"))
	}

	return nil
}
