/*
 * Copyright (c) Microsoft Corporation.
 * Licensed under the MIT license.
 */

package config

import (
	"strings"

	"github.com/rotisserie/eris"
	"gopkg.in/yaml.v3"
	kerrors "k8s.io/apimachinery/pkg/util/errors"

	"github.com/Azure/azure-service-operator/v2/internal/util/typo"
	"github.com/Azure/azure-service-operator/v2/tools/generator/internal/astmodel"
)

// TypeConfiguration contains additional information about a specific kind of resource within a version of a group and forms
// part of a hierarchy containing information to supplement the schema and swagger sources consumed by the generator.
//
// ┌──────────────────────────┐       ┌────────────────────┐       ┌──────────────────────┐       ╔═══════════════════╗       ┌───────────────────────┐
// │                          │       │                    │       │                      │       ║                   ║       │                       │
// │ ObjectModelConfiguration │───────│ GroupConfiguration │───────│ VersionConfiguration │───────║ TypeConfiguration ║───────│ PropertyConfiguration │
// │                          │1  1..n│                    │1  1..n│                      │1  1..n║                   ║1  1..n│                       │
// └──────────────────────────┘       └────────────────────┘       └──────────────────────┘       ╚═══════════════════╝       └───────────────────────┘
type TypeConfiguration struct {
	name       string
	properties map[string]*PropertyConfiguration
	advisor    *typo.Advisor
	// Configurable properties here (alphabetical, please)
	AzureGeneratedSecrets    configurable[[]string]                            // A set of strings specifying which secrets are generated by Azure
	DefaultAzureName         configurable[bool]                                // Boolean indicating if the resource should automatically default AzureName
	ExportAs                 configurable[string]                              // String specifying the name to use for a type (implies $export: true)
	GeneratedConfigs         configurable[map[string]string]                   // A map of strings specifying which spec or status properties should be exported to configmap
	Importable               configurable[bool]                                // Boolean specifying whether a resource type is importable via asoctl (defaults to true)
	IsResource               configurable[bool]                                // Boolean specifying whether a particular type is a resource or not.
	ManualConfigs            configurable[[]string]                            // A set of strings specifying which config map fields should be generated (to be filled out by resource extension)
	NameInNextVersion        configurable[string]                              // When a type is renamed, specify the name it will have in the next version
	OperatorSpecProperties   configurable[[]OperatorSpecPropertyConfiguration] // A set of additional properties to inject into the operatorSpec of a resource
	PayloadType              configurable[PayloadType]                         // Specify how this property should be serialized for ARM
	RenameTo                 configurable[string]                              // Give this type a different name in the generated code
	ResourceEmbeddedInParent configurable[string]                              // String specifying resource name of parent
	SupportedFrom            configurable[string]                              // Label specifying the first ASO release supporting the resource
	StripDocumentation       configurable[bool]                                // Boolean directing the generator to strip documentation on the resource and all referenced objects. Only supported on resources.
}

const (
	azureGeneratedSecretsTag    = "$azureGeneratedSecrets"    // A set of strings specifying which secrets are generated by Azure
	defaultAzureNameTag         = "$defaultAzureName"         // Boolean indicating if the resource should automatically default AzureName
	exportAsTag                 = "$exportAs"                 // String specifying the name to use for a type (implies $export: true)
	exportTag                   = "$export"                   // Boolean specifying whether a resource type is exported
	generatedConfigsTag         = "$generatedConfigs"         // A map of strings specifying which spec or status properties should be exported to configmap
	importableTag               = "$importable"               // Boolean specifying whether a resource type is importable via asoctl (defaults to true)
	isResourceTag               = "$isResource"               // Boolean specifying whether a particular type is a resource or not.
	manualConfigsTag            = "$manualConfigs"            // A set of strings specifying which config map fields should be generated (to be filled out by resource extension)
	nameInNextVersionTag        = "$nameInNextVersion"        // String specifying a type or property name change in the next version
	operatorSpecPropertiesTag   = "$operatorSpecProperties"   // A set of additional properties to inject into the operatorSpec of a resource
	renameTo                    = "$renameTo"                 // String specifying the new name of a type
	resourceEmbeddedInParentTag = "$resourceEmbeddedInParent" // String specifying resource name of parent
	stripDocumentationTag       = "$stripDocumentation"       // Boolean directing the generator to strip documentation on the resource and all referenced objects. Only supported on resources.
	supportedFromTag            = "$supportedFrom"            // Label specifying the first ASO release supporting the resource
)

type OperatorSpecPropertyConfiguration struct {
	Name        string `yaml:"name,omitempty"`        // Name of the new property
	Type        string `yaml:"type,omitempty"`        // Primitive type of the new property  (e.g. string, int, etc.)
	Description string `yaml:"description,omitempty"` // Description to include on the property
}

func NewTypeConfiguration(name string) *TypeConfiguration {
	scope := "type " + name
	return &TypeConfiguration{
		name:       name,
		properties: make(map[string]*PropertyConfiguration),
		advisor:    typo.NewAdvisor(),
		// Initialize configurable properties here (alphabetical, please)
		AzureGeneratedSecrets:    makeConfigurable[[]string](azureGeneratedSecretsTag, scope),
		DefaultAzureName:         makeConfigurable[bool](defaultAzureNameTag, scope),
		ExportAs:                 makeConfigurable[string](exportAsTag, scope),
		Importable:               makeConfigurable[bool](importableTag, scope),
		IsResource:               makeConfigurable[bool](isResourceTag, scope),
		GeneratedConfigs:         makeConfigurable[map[string]string](generatedConfigsTag, scope),
		ManualConfigs:            makeConfigurable[[]string](manualConfigsTag, scope),
		NameInNextVersion:        makeConfigurable[string](nameInNextVersionTag, scope),
		OperatorSpecProperties:   makeConfigurable[[]OperatorSpecPropertyConfiguration](operatorSpecPropertiesTag, scope),
		RenameTo:                 makeConfigurable[string](renameTo, scope),
		ResourceEmbeddedInParent: makeConfigurable[string](resourceEmbeddedInParentTag, scope),
		StripDocumentation:       makeConfigurable[bool](stripDocumentationTag, scope),
		SupportedFrom:            makeConfigurable[string](supportedFromTag, scope),
	}
}

// Add includes configuration for the specified property as a part of this type configuration
func (tc *TypeConfiguration) addProperty(name string, property *PropertyConfiguration) error {
	// Indexed by lowercase name of the property to allow case-insensitive lookups
	key := strings.ToLower(name)
	if _, exists := tc.properties[key]; exists {
		return eris.Errorf("duplicate property configuration: %q already exists", name)
	}
	tc.properties[key] = property
	return nil
}

// visitProperty invokes the provided visitor on the specified property if present.
// Returns a NotConfiguredError if the property is not found; otherwise whatever error is returned by the visitor.
func (tc *TypeConfiguration) visitProperty(
	property astmodel.PropertyName,
	visitor *configurationVisitor,
) error {
	pc := tc.findProperty(property)
	if pc == nil {
		return nil
	}

	err := visitor.visitProperty(pc)
	if err != nil {
		return eris.Wrapf(err, "configuration of type %s", tc.name)
	}

	return nil
}

// visitProperties invokes the provided visitor on all properties.
func (tc *TypeConfiguration) visitProperties(visitor *configurationVisitor) error {
	errs := make([]error, 0, len(tc.properties))
	for _, pc := range tc.properties {
		err := visitor.visitProperty(pc)
		err = tc.advisor.Wrapf(err, pc.name, "property %s not seen", pc.name)
		errs = append(errs, err)
	}

	// Both errors.Wrapf() and kerrors.NewAggregate() return nil if nothing went wrong
	return eris.Wrapf(
		kerrors.NewAggregate(errs),
		"type %s",
		tc.name)
}

// findProperty uses the provided property name to work out which nested PropertyConfiguration should be used
// either returns the requested property configuration, or an error saying that it couldn't be found
func (tc *TypeConfiguration) findProperty(property astmodel.PropertyName) *PropertyConfiguration {
	// Store the property id using lowercase,
	// so we can do case-insensitive lookups later
	tc.advisor.AddTerm(string(property))
	p := strings.ToLower(string(property))
	if pc, ok := tc.properties[p]; ok {
		return pc
	}

	return nil
}

// UnmarshalYAML populates our instance from the YAML.
// The slice node.Content contains pairs of nodes, first one for an ID, then one for the value.
func (tc *TypeConfiguration) UnmarshalYAML(value *yaml.Node) error {
	if value.Kind != yaml.MappingNode {
		return eris.New("expected mapping")
	}

	tc.properties = make(map[string]*PropertyConfiguration)
	var lastID string

	for i, c := range value.Content {
		// Grab identifiers and loop to handle the associated value
		if i%2 == 0 {
			lastID = c.Value
			continue
		}

		if strings.EqualFold(lastID, generatedConfigsTag) && c.Kind == yaml.MappingNode {
			azureGeneratedConfigs := make(map[string]string)

			idx := 0
			var key string
			for _, content := range c.Content {
				var val string
				if content.Kind == yaml.ScalarNode {
					val = content.Value
				} else {
					return eris.Errorf(
						"unexpected yam value for %s (line %d col %d)",
						generatedConfigsTag,
						content.Line,
						content.Column)
				}

				// first value is the key
				if idx%2 == 0 {
					key = val
				}
				if idx%2 == 1 {
					if !strings.HasPrefix(val, "$.") {
						return eris.Errorf("%s entry %q must begin with $.", generatedConfigsTag, val)
					}
					azureGeneratedConfigs[key] = val

				}
				idx++
			}

			// TODO: Check we had an even number of nodes

			tc.GeneratedConfigs.Set(azureGeneratedConfigs)
			continue
		}

		// Handle nested property metadata
		if c.Kind == yaml.MappingNode {
			p := NewPropertyConfiguration(lastID)
			err := c.Decode(p)
			if err != nil {
				return eris.Wrapf(err, "decoding yaml for %q", lastID)
			}

			err = tc.addProperty(lastID, p)
			if err != nil {
				return eris.Wrapf(err, "adding property %q", lastID)
			}
			continue
		}

		// $nameInNextVersion: <string>
		if strings.EqualFold(lastID, nameInNextVersionTag) && c.Kind == yaml.ScalarNode {
			tc.NameInNextVersion.Set(c.Value)
			continue
		}

		// $export: <bool> - DEPRECATED
		if strings.EqualFold(lastID, exportTag) && c.Kind == yaml.ScalarNode {
			return eris.Errorf("$export is deprecated, use $exportAs instead (line %d, column %d)", c.Line, c.Column)
		}

		// $exportAs: <string>
		if strings.EqualFold(lastID, exportAsTag) && c.Kind == yaml.ScalarNode {
			tc.ExportAs.Set(c.Value)
			continue
		}

		// $azureGeneratedSecrets:
		// - secret1
		// - secret2
		if strings.EqualFold(lastID, azureGeneratedSecretsTag) && c.Kind == yaml.SequenceNode {
			var azureGeneratedSecrets []string
			for _, content := range c.Content {
				if content.Kind == yaml.ScalarNode {
					azureGeneratedSecrets = append(azureGeneratedSecrets, content.Value)
				} else {
					return eris.Errorf(
						"unexpected yam value for %s (line %d col %d)",
						azureGeneratedSecretsTag,
						content.Line,
						content.Column)
				}
			}

			tc.AzureGeneratedSecrets.Set(azureGeneratedSecrets)
			continue
		}

		// $manualConfigs
		// - config1
		// - config2
		if strings.EqualFold(lastID, manualConfigsTag) && c.Kind == yaml.SequenceNode {
			manualAzureGeneratedConfigs := make([]string, 0, len(c.Content))
			for _, content := range c.Content {
				if content.Kind == yaml.ScalarNode {
					manualAzureGeneratedConfigs = append(manualAzureGeneratedConfigs, content.Value)
				} else {
					return eris.Errorf(
						"unexpected yam value for %s (line %d col %d)",
						manualConfigsTag,
						content.Line,
						content.Column)
				}
			}

			tc.ManualConfigs.Set(manualAzureGeneratedConfigs)
			continue
		}

		// $stripDocumentation
		if strings.EqualFold(lastID, stripDocumentationTag) && c.Kind == yaml.ScalarNode {
			var stripDocs bool
			err := c.Decode(&stripDocs)
			if err != nil {
				return eris.Wrapf(err, "decoding %s", stripDocumentationTag)
			}

			tc.StripDocumentation.Set(stripDocs)
			continue
		}

		// $supportedFrom
		if strings.EqualFold(lastID, supportedFromTag) && c.Kind == yaml.ScalarNode {
			tc.SupportedFrom.Set(c.Value)
			continue
		}

		// $renameTo: <string>
		if strings.EqualFold(lastID, renameTo) && c.Kind == yaml.ScalarNode {
			var renameTo string
			err := c.Decode(&renameTo)
			if err != nil {
				return eris.Wrapf(err, "decoding %s", renameTo)
			}

			tc.RenameTo.Set(renameTo)
			continue
		}

		// $resourceEmbeddedInParent: <string>
		if strings.EqualFold(lastID, resourceEmbeddedInParentTag) && c.Kind == yaml.ScalarNode {
			var resourceEmbeddedInParent string
			err := c.Decode(&resourceEmbeddedInParent)
			if err != nil {
				return eris.Wrapf(err, "decoding %s", resourceEmbeddedInParentTag)
			}

			tc.ResourceEmbeddedInParent.Set(resourceEmbeddedInParent)
			continue
		}

		// $isResource: <bool>
		if strings.EqualFold(lastID, isResourceTag) && c.Kind == yaml.ScalarNode {
			var isResource bool
			err := c.Decode(&isResource)
			if err != nil {
				return eris.Wrapf(err, "decoding %s", isResourceTag)
			}

			tc.IsResource.Set(isResource)
			continue
		}

		// $importable: <bool>
		if strings.EqualFold(lastID, importableTag) && c.Kind == yaml.ScalarNode {
			var importable bool
			err := c.Decode(&importable)
			if err != nil {
				return eris.Wrapf(err, "decoding %s", importableTag)
			}

			tc.Importable.Set(importable)
			continue
		}

		// $defaultAzureName: <bool>
		if strings.EqualFold(lastID, defaultAzureNameTag) && c.Kind == yaml.ScalarNode {
			var defaultAzureName bool
			err := c.Decode(&defaultAzureName)
			if err != nil {
				return eris.Wrapf(err, "decoding %s", defaultAzureNameTag)
			}

			tc.DefaultAzureName.Set(defaultAzureName)
			continue
		}

		// $operatorSpecProperties:
		// - name: <string>
		//   type: <string>
		//   description:
		//   - Line 1
		//   - Line 2
		if strings.EqualFold(lastID, operatorSpecPropertiesTag) && c.Kind == yaml.SequenceNode {
			properties := make([]OperatorSpecPropertyConfiguration, 0, len(c.Content))
			for _, content := range c.Content {
				if content.Kind == yaml.MappingNode {
					var property OperatorSpecPropertyConfiguration
					err := content.Decode(&property)
					if err != nil {
						return eris.Wrapf(err, "decoding %s", operatorSpecPropertiesTag)
					}

					properties = append(properties, property)
				} else {
					return eris.Errorf(
						"unexpected yam value for %s (line %d col %d)",
						operatorSpecPropertiesTag,
						content.Line,
						content.Column)
				}
			}

			tc.OperatorSpecProperties.Set(properties)
			continue
		}

		// No handler for this value, return an error
		return eris.Errorf(
			"type configuration, unexpected yaml value %s: %s (line %d col %d)", lastID, c.Value, c.Line, c.Column)

	}

	return nil
}
