// Copyright 2016-2020, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package pcl

import (
	"fmt"

	"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/pulumi/pulumi/pkg/v3/codegen"
	"github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/model"
	"github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/syntax"
	"github.com/pulumi/pulumi/pkg/v3/codegen/schema"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
	"github.com/zclconf/go-cty/cty"
)

func getResourceToken(node *Resource) (string, hcl.Range) {
	return node.syntax.Labels[1], node.syntax.LabelRanges[1]
}

func (b *binder) bindResource(node *Resource) hcl.Diagnostics {
	var diagnostics hcl.Diagnostics

	typeDiags := b.bindResourceTypes(node)
	diagnostics = append(diagnostics, typeDiags...)

	bodyDiags := b.bindResourceBody(node)
	diagnostics = append(diagnostics, bodyDiags...)

	return diagnostics
}

func annotateAttributeValue(expr model.Expression, attributeType schema.Type) model.Expression {
	if optionalType, ok := attributeType.(*schema.OptionalType); ok {
		return annotateAttributeValue(expr, optionalType.ElementType)
	}

	switch attrValue := expr.(type) {
	case *model.ObjectConsExpression:
		if schemaObjectType, ok := attributeType.(*schema.ObjectType); ok {
			schemaProperties := make(map[string]schema.Type)
			for _, schemaProperty := range schemaObjectType.Properties {
				schemaProperties[schemaProperty.Name] = schemaProperty.Type
			}

			for _, item := range attrValue.Items {
				// annotate the nested object properties
				// here when the key is a literal such as { key = <inner value> }
				keyLiteral, isLit := item.Key.(*model.LiteralValueExpression)
				if isLit {
					correspondingSchemaType, ok := schemaProperties[keyLiteral.Value.AsString()]
					if ok {
						item.Value = annotateAttributeValue(item.Value, correspondingSchemaType)
					}
				}

				// here when the key is a quoted literal such as { "key" = <inner value> }
				if templateExpression, ok := item.Key.(*model.TemplateExpression); ok && len(templateExpression.Parts) == 1 {
					if literalValue, ok := templateExpression.Parts[0].(*model.LiteralValueExpression); ok {
						if correspondingSchemaType, ok := schemaProperties[literalValue.Value.AsString()]; ok {
							item.Value = annotateAttributeValue(item.Value, correspondingSchemaType)
						}
					}
				}
			}
			return attrValue.WithType(func(attrValueType model.Type) *model.ObjectConsExpression {
				annotateObjectProperties(attrValueType, attributeType)
				return attrValue
			})
		}

		return attrValue

	case *model.TupleConsExpression:
		if schemaArrayType, ok := attributeType.(*schema.ArrayType); ok {
			elementType := schemaArrayType.ElementType
			for _, arrayExpr := range attrValue.Expressions {
				annotateAttributeValue(arrayExpr, elementType)
			}
		}

		return attrValue
	case *model.FunctionCallExpression:
		if attrValue.Name == IntrinsicConvert {
			converterArg := attrValue.Args[0]
			annotateAttributeValue(converterArg, attributeType)
		}

		return attrValue
	default:
		return expr
	}
}

func AnnotateAttributeValue(expr model.Expression, attributeType schema.Type) model.Expression {
	return annotateAttributeValue(expr, attributeType)
}

func AnnotateResourceInputs(node *Resource) {
	if node.Schema == nil {
		// skip annotations for resource which don't have a schema
		return
	}
	resourceProperties := make(map[string]*schema.Property)
	for _, property := range node.Schema.Properties {
		resourceProperties[property.Name] = property
	}

	// add type annotations to the attributes
	// and their nested objects
	for index := range node.Inputs {
		attr := node.Inputs[index]
		if property, ok := resourceProperties[attr.Name]; ok {
			node.Inputs[index] = &model.Attribute{
				Tokens: attr.Tokens,
				Name:   attr.Name,
				Syntax: attr.Syntax,
				Value:  AnnotateAttributeValue(attr.Value, property.Type),
			}
		}
	}
}

// resolveUnionOfObjects takes an object expression and its corresponding schema union type,
// then tries to find out which type from the union is the one that matches the object expression.
// We do this based on the discriminator field in the object expression.
func resolveUnionOfObjects(objectExpr *model.ObjectConsExpression, union *schema.UnionType) schema.Type {
	var discriminatorValue string
	for _, item := range objectExpr.Items {
		if key, ok := item.Key.(*model.LiteralValueExpression); ok {
			if key.Value.AsString() == union.Discriminator {
				if value, ok := item.Value.(*model.LiteralValueExpression); ok {
					discriminatorValue = value.Value.AsString()
					break
				}

				if value, ok := item.Value.(*model.TemplateExpression); ok && len(value.Parts) == 1 {
					if literalValue, ok := value.Parts[0].(*model.LiteralValueExpression); ok {
						discriminatorValue = literalValue.Value.AsString()
						break
					}
				}
			}
		}
	}

	if discriminatorValue == "" {
		return union
	}

	if correspondingTypeToken, ok := union.Mapping[discriminatorValue]; ok {
		for _, schemaType := range union.ElementTypes {
			if schemaObjectType, ok := codegen.UnwrapType(schemaType).(*schema.ObjectType); ok {
				parsedTypeToken, err := tokens.ParseTypeToken(correspondingTypeToken)
				if err != nil {
					continue
				}

				parsedObjectToken, err := tokens.ParseTypeToken(schemaObjectType.Token)
				if err != nil {
					continue
				}

				if string(parsedTypeToken.Name()) == string(parsedObjectToken.Name()) {
					// found the corresponding object type
					return schemaObjectType
				}
			}
		}
	}

	return union
}

// resolveInputUnions will take input expressions and their corresponding schema type,
// if the schema type is a union of objects and the input is an object, we use the
// the discriminator field to determine which object type to use and reduce the union into
// just an object type. This way program generators can easily work with the object type
// directly instead of working out which object type they should be using based on the union
func (b *binder) resolveInputUnions(
	inputs map[string]model.Expression,
	inputProperties []*schema.Property,
) []*schema.Property {
	resolvedProperties := make([]*schema.Property, len(inputProperties))
	for i, property := range inputProperties {
		resolvedType := property.Type
		if value, ok := inputs[property.Name]; ok {
			switch schemaType := codegen.UnwrapType(property.Type).(type) {
			case *schema.UnionType:
				if objectExpr, ok := value.(*model.ObjectConsExpression); ok {
					resolvedType = resolveUnionOfObjects(objectExpr, schemaType)
					switch resolvedType := resolvedType.(type) {
					case *schema.ObjectType:
						// found the corresponding object type for this PCL expression, resolve nested unions if any
						nestedInputs := map[string]model.Expression{}
						for _, item := range objectExpr.Items {
							if key, ok := literalExprValue(item.Key); ok && key.Type().Equals(cty.String) {
								nestedInputs[key.AsString()] = item.Value
							}
						}

						b.resolveInputUnions(nestedInputs, resolvedType.Properties)
					}
				}
			}
		}

		resolvedProperties[i] = &schema.Property{
			Type:                 resolvedType,
			Name:                 property.Name,
			DeprecationMessage:   property.DeprecationMessage,
			ConstValue:           property.ConstValue,
			Secret:               property.Secret,
			Plain:                property.Plain,
			Language:             property.Language,
			Comment:              property.Comment,
			DefaultValue:         property.DefaultValue,
			ReplaceOnChanges:     property.ReplaceOnChanges,
			WillReplaceOnChanges: property.WillReplaceOnChanges,
		}
	}

	return resolvedProperties
}

// rawResourceInputs returns the raw inputs for a resource. This is useful when we need to resolve unions of objects
// and reduce them to just an object when possible. The inputs of a resource contain the discriminator field of which
// the value is used to determine which object type to use and thus reduce unions into objects.
func (b *binder) rawResourceInputs(node *Resource) map[string]model.Expression {
	inputs := map[string]model.Expression{}
	scopes := newResourceScopes(b.root, node, nil, nil)
	block, _ := model.BindBlock(node.syntax, scopes, b.tokens, b.options.modelOptions()...)
	for _, item := range block.Body.Items {
		switch item := item.(type) {
		case *model.Attribute:
			inputs[item.Name] = item.Value
		}
	}

	return inputs
}

// reduceInputUnionTypes reduces the input types of a resource which are unions of objects
// into just an object when possible. We use the actual inputs of the resource to determine which type object we should
// use because objects in a union have a discriminator field which is used to determine which object type to use.
func (b *binder) reduceInputUnionTypes(node *Resource, inputProperties []*schema.Property) []*schema.Property {
	inputs := b.rawResourceInputs(node)
	return b.resolveInputUnions(inputs, inputProperties)
}

// bindResourceTypes binds the input and output types for a resource.
func (b *binder) bindResourceTypes(node *Resource) hcl.Diagnostics {
	// Set the input and output types to dynamic by default.
	node.InputType, node.OutputType = model.DynamicType, model.DynamicType

	// Find the resource's schema.
	token, tokenRange := getResourceToken(node)
	pkg, module, name, diagnostics := DecomposeToken(token, tokenRange)
	if diagnostics.HasErrors() {
		return diagnostics
	}

	makeResourceDynamic := func() {
		// make the inputs and outputs of the resource dynamic
		node.Token = token
		node.OutputType = model.DynamicType
		inferredInputProperties := map[string]model.Type{}
		for _, attr := range node.Inputs {
			inferredInputProperties[attr.Name] = attr.Type()
		}
		node.InputType = model.NewObjectType(inferredInputProperties)
	}

	isProvider := false
	if pkg == "pulumi" && module == "providers" {
		pkg, isProvider = name, true
	}
	var pkgSchema *packageSchema

	// It is important that we call `loadPackageSchema` instead of `getPackageSchema` here
	// because the version may be wrong. When the version should not be empty,
	// `loadPackageSchema` will load the default version while `getPackageSchema` will
	// simply fail. We can't give a populated version field since we have not processed
	// the body, and thus the version yet.
	pkgSchema, err := b.options.packageCache.loadPackageSchema(b.options.loader, pkg, "")
	if err != nil {
		e := unknownPackage(pkg, tokenRange)
		e.Detail = err.Error()

		if b.options.skipResourceTypecheck {
			makeResourceDynamic()
			return hcl.Diagnostics{asWarningDiagnostic(e)}
		}

		return hcl.Diagnostics{e}
	}

	var res *schema.Resource
	var inputProperties, properties []*schema.Property
	if isProvider {
		r, err := pkgSchema.schema.Provider()
		if err != nil {
			if b.options.skipResourceTypecheck {
				makeResourceDynamic()
				return diagnostics
			}
			return hcl.Diagnostics{resourceLoadError(token, err, tokenRange)}
		}
		res = r
	} else {
		r, tk, ok, err := pkgSchema.LookupResource(token)
		if err != nil {
			if b.options.skipResourceTypecheck {
				makeResourceDynamic()
				return diagnostics
			}

			return hcl.Diagnostics{resourceLoadError(token, err, tokenRange)}
		} else if !ok {
			if b.options.skipResourceTypecheck {
				makeResourceDynamic()
				return diagnostics
			}

			return hcl.Diagnostics{unknownResourceType(token, tokenRange)}
		}
		res = r
		token = tk
	}
	node.Schema = res
	inputProperties, properties = res.InputProperties, res.Properties
	node.Token = token

	// Create input and output types for the schema.
	// first reduce property types which are unions of objects into just an object when possible
	inputObjectType := &schema.ObjectType{Properties: b.reduceInputUnionTypes(node, inputProperties)}
	inputType := b.schemaTypeToType(inputObjectType)

	outputProperties := map[string]model.Type{
		"id":  model.NewOutputType(model.StringType),
		"urn": model.NewOutputType(model.StringType),
	}
	for _, prop := range properties {
		outputProperties[prop.Name] = model.NewOutputType(b.schemaTypeToType(prop.Type))
	}
	outputType := model.NewObjectType(outputProperties, &schema.ObjectType{Properties: properties})

	node.InputType, node.OutputType = inputType, outputType

	findTransitivePackageReferences := func(schemaType schema.Type) {
		if objectType, ok := schemaType.(*schema.ObjectType); ok && objectType.PackageReference != nil {
			ref := objectType.PackageReference
			if _, found := b.referencedPackages[ref.Name()]; !found {
				b.referencedPackages[ref.Name()] = ref
			}
		}
	}

	codegen.VisitTypeClosure(inputProperties, findTransitivePackageReferences)
	codegen.VisitTypeClosure(properties, findTransitivePackageReferences)

	return diagnostics
}

type resourceScopes struct {
	root      *model.Scope
	withRange *model.Scope
	resource  *Resource
}

func newResourceScopes(root *model.Scope, resource *Resource, rangeKey, rangeValue model.Type) model.Scopes {
	scopes := &resourceScopes{
		root:      root,
		withRange: root,
		resource:  resource,
	}
	if rangeValue != nil {
		properties := map[string]model.Type{
			"value": rangeValue,
		}
		if rangeKey != nil {
			properties["key"] = rangeKey
		}

		scopes.withRange = root.Push(syntax.None)
		scopes.withRange.Define("range", &model.Variable{
			Name:         "range",
			VariableType: model.NewObjectType(properties),
		})
	}
	return scopes
}

func (s *resourceScopes) GetScopesForBlock(block *hclsyntax.Block) (model.Scopes, hcl.Diagnostics) {
	if block.Type == "options" {
		return &optionsScopes{root: s.root, resource: s.resource}, nil
	}
	return model.StaticScope(s.withRange), nil
}

func (s *resourceScopes) GetScopeForAttribute(attr *hclsyntax.Attribute) (*model.Scope, hcl.Diagnostics) {
	return s.withRange, nil
}

type optionsScopes struct {
	root     *model.Scope
	resource *Resource
}

func (s *optionsScopes) GetScopesForBlock(block *hclsyntax.Block) (model.Scopes, hcl.Diagnostics) {
	return model.StaticScope(s.root), nil
}

func (s *optionsScopes) GetScopeForAttribute(attr *hclsyntax.Attribute) (*model.Scope, hcl.Diagnostics) {
	if attr.Name == "ignoreChanges" {
		obj, ok := model.ResolveOutputs(s.resource.InputType).(*model.ObjectType)
		if !ok {
			return nil, nil
		}
		scope := model.NewRootScope(syntax.None)
		for k, t := range obj.Properties {
			scope.Define(k, &ResourceProperty{
				Path:         hcl.Traversal{hcl.TraverseRoot{Name: k}},
				PropertyType: t,
			})
		}
		return scope, nil
	}
	return s.root, nil
}

func bindResourceOptions(options *model.Block) (*ResourceOptions, hcl.Diagnostics) {
	resourceOptions := &ResourceOptions{}
	var diagnostics hcl.Diagnostics
	for _, item := range options.Body.Items {
		switch item := item.(type) {
		case *model.Attribute:
			var t model.Type
			switch item.Name {
			case "range":
				t = model.NewUnionType(model.BoolType, model.NumberType, model.NewListType(model.DynamicType),
					model.NewMapType(model.DynamicType))
				resourceOptions.Range = item.Value
			case "parent":
				t = model.DynamicType
				resourceOptions.Parent = item.Value
			case "provider":
				t = model.DynamicType
				resourceOptions.Provider = item.Value
			case "dependsOn":
				t = model.NewListType(model.DynamicType)
				resourceOptions.DependsOn = item.Value
			case "protect":
				t = model.BoolType
				resourceOptions.Protect = item.Value
			case "retainOnDelete":
				t = model.BoolType
				resourceOptions.RetainOnDelete = item.Value
			case "ignoreChanges":
				t = model.NewListType(ResourcePropertyType)
				resourceOptions.IgnoreChanges = item.Value
			case "version":
				t = model.StringType
				resourceOptions.Version = item.Value
			case "pluginDownloadURL":
				t = model.StringType
				resourceOptions.PluginDownloadURL = item.Value
			default:
				diagnostics = append(diagnostics, unsupportedAttribute(item.Name, item.Syntax.NameRange))
				continue
			}
			if model.InputType(t).ConversionFrom(item.Value.Type()) == model.NoConversion {
				diagnostics = append(diagnostics, model.ExprNotConvertible(model.InputType(t), item.Value))
			}
		case *model.Block:
			diagnostics = append(diagnostics, unsupportedBlock(item.Type, item.Syntax.TypeRange))
		}
	}
	return resourceOptions, diagnostics
}

// bindResourceBody binds the body of a resource.
func (b *binder) bindResourceBody(node *Resource) hcl.Diagnostics {
	var diagnostics hcl.Diagnostics

	// Allow for lenient traversal when we choose to skip resource type-checking.
	node.LenientTraversal = b.options.skipResourceTypecheck
	node.VariableType = node.OutputType
	// If the resource has a range option, we need to know the type of the collection being ranged over. Pre-bind the
	// range expression now, but ignore the diagnostics.
	var rangeKey, rangeValue model.Type
	for _, block := range node.syntax.Body.Blocks {
		if block.Type == "options" {
			if rng, hasRange := block.Body.Attributes["range"]; hasRange {
				expr, _ := model.BindExpression(rng.Expr, b.root, b.tokens, b.options.modelOptions()...)
				typ := model.ResolveOutputs(expr.Type())

				resourceVar := &model.Variable{
					Name:         "r",
					VariableType: node.VariableType,
				}
				switch {
				case model.InputType(model.BoolType).ConversionFrom(typ) == model.SafeConversion:
					condExpr := &model.ConditionalExpression{
						Condition:  expr,
						TrueResult: model.VariableReference(resourceVar),
						FalseResult: model.ConstantReference(&model.Constant{
							Name:          "null",
							ConstantValue: cty.NullVal(cty.DynamicPseudoType),
						}),
					}
					diags := condExpr.Typecheck(false)
					contract.Assertf(len(diags) == 0, "failed to typecheck conditional expression: %v", diags)

					node.VariableType = condExpr.Type()
				case model.InputType(model.NumberType).ConversionFrom(typ) == model.SafeConversion:
					functions := pulumiBuiltins(b.options)
					rangeArgs := []model.Expression{expr}
					rangeSig, _ := functions["range"].GetSignature(rangeArgs)

					rangeExpr := &model.ForExpression{
						ValueVariable: &model.Variable{
							Name:         "_",
							VariableType: model.NumberType,
						},
						Collection: &model.FunctionCallExpression{
							Name:      "range",
							Signature: rangeSig,
							Args:      rangeArgs,
						},
						Value: model.VariableReference(resourceVar),
					}
					diags := rangeExpr.Typecheck(false)
					contract.Assertf(len(diags) == 0, "failed to typecheck range expression: %v", diags)

					rangeValue = model.IntType

					node.VariableType = rangeExpr.Type()
				default:
					strictCollectionType := !b.options.skipRangeTypecheck
					rk, rv, diags := model.GetCollectionTypes(typ, rng.Range(), strictCollectionType)
					rangeKey, rangeValue, diagnostics = rk, rv, append(diagnostics, diags...)

					iterationExpr := &model.ForExpression{
						ValueVariable: &model.Variable{
							Name:         "_",
							VariableType: rangeValue,
						},
						Collection:                   expr,
						Value:                        model.VariableReference(resourceVar),
						StrictCollectionTypechecking: strictCollectionType,
					}
					diags = iterationExpr.Typecheck(false)
					contract.Ignore(diags) // Any relevant diagnostics were reported by GetCollectionTypes.

					node.VariableType = iterationExpr.Type()
				}
			}
		}
	}

	// Bind the resource's body.
	scopes := newResourceScopes(b.root, node, rangeKey, rangeValue)
	block, blockDiags := model.BindBlock(node.syntax, scopes, b.tokens, b.options.modelOptions()...)
	diagnostics = append(diagnostics, blockDiags...)

	var options *model.Block
	for _, item := range block.Body.Items {
		switch item := item.(type) {
		case *model.Attribute:
			if item.Name == LogicalNamePropertyKey {
				logicalName, lDiags := getStringAttrValue(item)
				if lDiags != nil {
					diagnostics = diagnostics.Append(lDiags)
				} else {
					node.logicalName = logicalName
				}
				continue
			}
			node.Inputs = append(node.Inputs, item)
		case *model.Block:
			switch item.Type {
			case "options":
				if options != nil {
					diagnostics = append(diagnostics, duplicateBlock(item.Type, item.Syntax.TypeRange))
				} else {
					options = item
				}
			default:
				diagnostics = append(diagnostics, unsupportedBlock(item.Type, item.Syntax.TypeRange))
			}
		}
	}

	resourceProperties := make(map[string]schema.Type)
	if node.Schema != nil {
		for _, property := range node.Schema.Properties {
			resourceProperties[property.Name] = property.Type
		}
	}

	// Typecheck the attributes.
	if objectType, ok := node.InputType.(*model.ObjectType); ok {
		diag := func(d *hcl.Diagnostic) {
			if b.options.skipResourceTypecheck && d.Severity == hcl.DiagError {
				d.Severity = hcl.DiagWarning
			}
			diagnostics = append(diagnostics, d)
		}
		attrNames := codegen.StringSet{}
		for _, attr := range node.Inputs {
			attrNames.Add(attr.Name)

			if typ, ok := objectType.Properties[attr.Name]; ok {
				conversion := typ.ConversionFrom(attr.Value.Type())
				if !conversion.Exists() {
					if propertyType, ok := resourceProperties[attr.Name]; ok {
						attributeRange := attr.Value.SyntaxNode().Range()
						diag(&hcl.Diagnostic{
							Severity: hcl.DiagError,
							Subject:  &attributeRange,
							Detail: fmt.Sprintf("Cannot assign value %s to attribute of type %q for resource %q",
								attr.Value.Type().Pretty().String(),
								propertyType.String(),
								node.Token),
						})
					}
				}
			} else {
				diag(unsupportedAttribute(attr.Name, attr.Syntax.NameRange))
			}
		}

		for _, k := range codegen.SortedKeys(objectType.Properties) {
			typ := objectType.Properties[k]
			if model.IsOptionalType(typ) || attrNames.Has(k) {
				// The type is present or optional. No error.
				continue
			}
			if model.IsConstType(objectType.Properties[k]) {
				// The type is const, so the value is implied. No error.
				continue
			}
			diag(missingRequiredAttribute(k, block.Body.Syntax.MissingItemRange()))
		}
	}

	// Typecheck the options block.
	if options != nil {
		resourceOptions, optionsDiags := bindResourceOptions(options)
		diagnostics = append(diagnostics, optionsDiags...)
		node.Options = resourceOptions
	}

	node.Definition = block
	return diagnostics
}