// Copyright 2016-2021, 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/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclsyntax"
	"github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/model"
	"github.com/pulumi/pulumi/pkg/v3/codegen/schema"
	"github.com/zclconf/go-cty/cty"
)

const Invoke = "invoke"

func getInvokeToken(call *hclsyntax.FunctionCallExpr) (string, hcl.Range, bool) {
	if call.Name != Invoke || len(call.Args) < 1 {
		return "", hcl.Range{}, false
	}
	template, ok := call.Args[0].(*hclsyntax.TemplateExpr)
	if !ok || len(template.Parts) != 1 {
		return "", hcl.Range{}, false
	}
	literal, ok := template.Parts[0].(*hclsyntax.LiteralValueExpr)
	if !ok {
		return "", hcl.Range{}, false
	}
	if literal.Val.Type() != cty.String {
		return "", hcl.Range{}, false
	}
	return literal.Val.AsString(), call.Args[0].Range(), true
}

// annotateObjectProperties annotates the properties of an object expression with the
// types of the corresponding properties in the schema. This is used to provide type
// information for invoke calls that didn't have type annotations.
//
// This function will recursively annotate the properties of objects that are nested
// within the object expression type.
func annotateObjectProperties(modelType model.Type, schemaType schema.Type) {
	if optionalType, ok := schemaType.(*schema.OptionalType); ok && optionalType != nil {
		schemaType = optionalType.ElementType
	}

	switch arg := modelType.(type) {
	case *model.ObjectType:
		if schemaObjectType, ok := schemaType.(*schema.ObjectType); ok && schemaObjectType != nil {
			schemaProperties := make(map[string]schema.Type)
			for _, schemaProperty := range schemaObjectType.Properties {
				schemaProperties[schemaProperty.Name] = schemaProperty.Type
			}

			// top-level annotation for the type itself
			arg.Annotations = append(arg.Annotations, schemaType)
			// now for each property, annotate it with the associated type from the schema
			for propertyName, propertyType := range arg.Properties {
				if associatedType, ok := schemaProperties[propertyName]; ok {
					annotateObjectProperties(propertyType, associatedType)
				}
			}
		}
	case *model.ListType:
		underlyingArrayType := arg.ElementType
		if schemaArrayType, ok := schemaType.(*schema.ArrayType); ok && schemaArrayType != nil {
			underlyingSchemaArrayType := schemaArrayType.ElementType
			annotateObjectProperties(underlyingArrayType, underlyingSchemaArrayType)
		}

	case *model.TupleType:
		if schemaArrayType, ok := schemaType.(*schema.ArrayType); ok && schemaArrayType != nil {
			underlyingSchemaArrayType := schemaArrayType.ElementType
			elementTypes := arg.ElementTypes
			for _, elemType := range elementTypes {
				annotateObjectProperties(elemType, underlyingSchemaArrayType)
			}
		}
	case *model.UnionType:
		// sometimes optional schema types are represented as unions: None | T
		// in this case, we want to collapse the union and annotate the underlying type T
		if len(arg.ElementTypes) == 2 && arg.ElementTypes[0] == model.NoneType {
			annotateObjectProperties(arg.ElementTypes[1], schemaType)
		} else if len(arg.ElementTypes) == 2 && arg.ElementTypes[1] == model.NoneType {
			annotateObjectProperties(arg.ElementTypes[0], schemaType)
		} else { //nolint:staticcheck // TODO https://github.com/pulumi/pulumi/issues/10993
			// We need to handle the case where the schema type is a union type.
		}
	}
}

func (b *binder) bindInvokeSignature(args []model.Expression) (model.StaticFunctionSignature, hcl.Diagnostics) {
	if len(args) < 1 {
		return b.zeroSignature(), nil
	}

	template, ok := args[0].(*model.TemplateExpression)
	if !ok || len(template.Parts) != 1 {
		return b.zeroSignature(), hcl.Diagnostics{tokenMustBeStringLiteral(args[0])}
	}
	lit, ok := template.Parts[0].(*model.LiteralValueExpression)
	if !ok || model.StringType.ConversionFrom(lit.Type()) == model.NoConversion {
		return b.zeroSignature(), hcl.Diagnostics{tokenMustBeStringLiteral(args[0])}
	}

	token, tokenRange := lit.Value.AsString(), args[0].SyntaxNode().Range()
	pkg, _, _, diagnostics := DecomposeToken(token, tokenRange)
	if diagnostics.HasErrors() {
		return b.zeroSignature(), diagnostics
	}

	pkgInfo := PackageInfo{
		name: pkg,
	}
	pkgSchema, ok := b.options.packageCache.entries[pkgInfo]
	if !ok {
		if b.options.skipInvokeTypecheck {
			return b.zeroSignature(), nil
		}
		return b.zeroSignature(), hcl.Diagnostics{unknownPackage(pkg, tokenRange)}
	}

	fn, tk, ok, err := pkgSchema.LookupFunction(token)
	if err != nil {
		if b.options.skipInvokeTypecheck {
			return b.zeroSignature(), nil
		}

		return b.zeroSignature(), hcl.Diagnostics{functionLoadError(token, err, tokenRange)}
	} else if !ok {
		if b.options.skipInvokeTypecheck {
			return b.zeroSignature(), nil
		}

		return b.zeroSignature(), hcl.Diagnostics{unknownFunction(token, tokenRange)}
	}

	lit.Value = cty.StringVal(tk)

	if len(args) < 2 {
		return b.zeroSignature(), hcl.Diagnostics{errorf(tokenRange, "missing second arg")}
	}
	sig, err := b.signatureForArgs(fn, args[1])
	if err != nil {
		diag := hcl.Diagnostics{errorf(tokenRange, "Invoke binding error: %v", err)}
		return b.zeroSignature(), diag
	}

	// annotate the input args on the expression with the input type of the function
	if argsObject, isObjectExpression := args[1].(*model.ObjectConsExpression); isObjectExpression {
		if fn.Inputs != nil {
			annotateObjectProperties(argsObject.Type(), fn.Inputs)
		}
	}

	sig.MultiArgumentInputs = fn.MultiArgumentInputs
	return sig, nil
}

func (b *binder) makeSignature(argsType, returnType model.Type) model.StaticFunctionSignature {
	return model.StaticFunctionSignature{
		Parameters: []model.Parameter{
			{
				Name: "token",
				Type: model.StringType,
			},
			{
				Name: "args",
				Type: argsType,
			},
			{
				Name: "provider",
				Type: model.NewOptionalType(model.StringType),
			},
		},
		ReturnType: returnType,
	}
}

func (b *binder) zeroSignature() model.StaticFunctionSignature {
	return b.makeSignature(model.NewOptionalType(model.DynamicType), model.DynamicType)
}

func (b *binder) signatureForArgs(fn *schema.Function, args model.Expression) (model.StaticFunctionSignature, error) {
	if args != nil && b.useOutputVersion(fn, args) {
		return b.outputVersionSignature(fn)
	}
	return b.regularSignature(fn), nil
}

// Heuristic to decide when to use `fnOutput` form of a function. Will
// conservatively prefer `false` unless bind option choose to prefer otherwise.
// It decides to return `true` if doing so avoids the need to introduce an `apply` form to
// accommodate `Output` args (`Promise` args do not count).
func (b *binder) useOutputVersion(fn *schema.Function, args model.Expression) bool {
	if fn.ReturnType == nil {
		// No code emitted for an `fnOutput` form, impossible.
		return false
	}

	if b.options.preferOutputVersionedInvokes {
		return true
	}

	if fn.Inputs == nil || len(fn.Inputs.Properties) == 0 {
		// use the output version when there are actual args to use
		return false
	}

	outputFormParamType := b.schemaTypeToType(fn.Inputs.InputShape)
	regularFormParamType := b.schemaTypeToType(fn.Inputs)
	argsType := args.Type()

	if regularFormParamType.ConversionFrom(argsType) == model.NoConversion &&
		outputFormParamType.ConversionFrom(argsType) == model.SafeConversion &&
		model.ContainsOutputs(argsType) {
		return true
	}

	return false
}

func (b *binder) regularSignature(fn *schema.Function) model.StaticFunctionSignature {
	var argsType model.Type
	if fn.Inputs == nil {
		argsType = model.NewOptionalType(model.NewObjectType(map[string]model.Type{}))
	} else {
		argsType = b.schemaTypeToType(fn.Inputs)
	}

	var returnType model.Type
	if fn.ReturnType == nil {
		returnType = model.NewObjectType(map[string]model.Type{})
	} else {
		returnType = b.schemaTypeToType(fn.ReturnType)
	}

	return b.makeSignature(argsType, model.NewPromiseType(returnType))
}

func (b *binder) outputVersionSignature(fn *schema.Function) (model.StaticFunctionSignature, error) {
	if !fn.NeedsOutputVersion() {
		return model.StaticFunctionSignature{}, fmt.Errorf("Function %s does not have an Output version", fn.Token)
	}

	// Given `fn.NeedsOutputVersion()==true` `fn.ReturnType != nil`.
	var argsType model.Type
	if fn.Inputs != nil {
		argsType = b.schemaTypeToType(fn.Inputs.InputShape)
	} else {
		argsType = model.NewObjectType(map[string]model.Type{})
	}
	returnType := b.schemaTypeToType(fn.ReturnType)
	return b.makeSignature(argsType, model.NewOutputType(returnType)), nil
}

// Detects invoke calls that use an output version of a function.
func IsOutputVersionInvokeCall(call *model.FunctionCallExpression) bool {
	if call.Name == Invoke {
		// Currently binder.bindInvokeSignature will assign
		// either DynamicType, a Promise<T>, or an Output<T>
		// for the return type of an invoke. Output<T> implies
		// that an output version has been picked.
		_, returnsOutput := call.Signature.ReturnType.(*model.OutputType)
		return returnsOutput
	}
	return false
}

// Pattern matches to recognize `__convert(objCons(..))` pattern that
// is used to annotate object constructors with appropriate nominal
// types. If the expression matches, returns true followed by the
// constructor expression and the appropriate type.
func RecognizeTypedObjectCons(theExpr model.Expression) (bool, *model.ObjectConsExpression, model.Type) {
	expr, isFunc := theExpr.(*model.FunctionCallExpression)
	if !isFunc {
		return false, nil, nil
	}

	if expr.Name != IntrinsicConvert {
		return false, nil, nil
	}

	if len(expr.Args) != 1 {
		return false, nil, nil
	}

	objCons, isObjCons := expr.Args[0].(*model.ObjectConsExpression)
	if !isObjCons {
		return false, nil, nil
	}

	return true, objCons, expr.Type()
}

// Pattern matches to recognize an encoded call to an output-versioned
// invoke, such as `invoke(token, __convert(objCons(..)))`. If
// matching, returns the `args` expression and its schema-bound type.
func RecognizeOutputVersionedInvoke(
	expr *model.FunctionCallExpression,
) (bool, *model.ObjectConsExpression, model.Type) {
	if !IsOutputVersionInvokeCall(expr) {
		return false, nil, nil
	}

	if len(expr.Args) < 2 {
		return false, nil, nil
	}

	return RecognizeTypedObjectCons(expr.Args[1])
}