// 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 (
	"os"
	"path/filepath"

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

// componentVariableType returns the type of the variable of which the value is a component.
// The type is derived from the outputs of the sub-program of the component into an ObjectType
func componentVariableType(program *Program) model.Type {
	properties := map[string]model.Type{}
	for _, node := range program.Nodes {
		switch node := node.(type) {
		case *OutputVariable:
			switch nodeType := node.Type().(type) {
			case *model.OutputType:
				// if the output variable is already an Output<T>, keep it as is
				properties[node.LogicalName()] = nodeType
			default:
				// otherwise, wrap it as an output
				properties[node.LogicalName()] = &model.OutputType{
					ElementType: nodeType,
				}
			}
		}
	}

	return &model.ObjectType{Properties: properties}
}

type componentScopes struct {
	root      *model.Scope
	withRange *model.Scope
	component *Component
}

func newComponentScopes(
	root *model.Scope,
	component *Component,
	rangeKeyType model.Type,
	rangeValueType model.Type,
) model.Scopes {
	scopes := &componentScopes{
		root:      root,
		withRange: root,
		component: component,
	}

	if rangeValueType != nil {
		properties := map[string]model.Type{
			"value": rangeValueType,
		}
		if rangeKeyType != nil {
			properties["key"] = rangeKeyType
		}

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

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

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

type componentInput struct {
	key      string
	required bool
}

func componentInputs(program *Program) map[string]componentInput {
	inputs := map[string]componentInput{}
	for _, node := range program.Nodes {
		switch node := node.(type) {
		case *ConfigVariable:
			inputs[node.LogicalName()] = componentInput{
				required: node.DefaultValue == nil && !node.Nullable,
				key:      node.LogicalName(),
			}
		}
	}

	return inputs
}

func contains(slice []string, item string) bool {
	set := make(map[string]struct{}, len(slice))
	for _, s := range slice {
		set[s] = struct{}{}
	}

	_, ok := set[item]
	return ok
}

// ComponentProgramBinderFromFileSystem returns the default component program binder which uses the file system
// to parse and bind PCL files into a program.
func ComponentProgramBinderFromFileSystem() ComponentProgramBinder {
	return func(args ComponentProgramBinderArgs) (*Program, hcl.Diagnostics, error) {
		var diagnostics hcl.Diagnostics
		binderDirPath := args.BinderDirPath
		componentSource := args.ComponentSource
		nodeRange := args.ComponentNodeRange
		loader := args.BinderLoader
		// bind the component here as if it was a new program
		// this becomes the DirPath for the new binder
		componentSourceDir := filepath.Join(binderDirPath, componentSource)

		parser := syntax.NewParser()
		// Load all .pp files in the components' directory
		files, err := os.ReadDir(componentSourceDir)
		if err != nil {
			diagnostics = diagnostics.Append(errorf(nodeRange, "%s", err.Error()))
			return nil, diagnostics, nil
		}

		if len(files) == 0 {
			diagnostics = diagnostics.Append(errorf(nodeRange, "no files found"))
			return nil, diagnostics, nil
		}

		for _, file := range files {
			if file.IsDir() {
				continue
			}
			fileName := file.Name()
			path := filepath.Join(componentSourceDir, fileName)

			if filepath.Ext(fileName) == ".pp" {
				file, err := os.Open(path)
				if err != nil {
					diagnostics = diagnostics.Append(errorf(nodeRange, "%s", err.Error()))
					return nil, diagnostics, err
				}

				err = parser.ParseFile(file, fileName)
				contract.IgnoreError(file.Close())
				if err != nil {
					diagnostics = diagnostics.Append(errorf(nodeRange, "%s", err.Error()))
					return nil, diagnostics, err
				}

				diags := parser.Diagnostics
				if diags.HasErrors() {
					return nil, diagnostics, err
				}
			}
		}

		opts := []BindOption{
			Loader(loader),
			DirPath(componentSourceDir),
			ComponentBinder(ComponentProgramBinderFromFileSystem()),
		}

		if args.AllowMissingVariables {
			opts = append(opts, AllowMissingVariables)
		}
		if args.AllowMissingProperties {
			opts = append(opts, AllowMissingProperties)
		}
		if args.SkipResourceTypecheck {
			opts = append(opts, SkipResourceTypechecking)
		}
		if args.PreferOutputVersionedInvokes {
			opts = append(opts, PreferOutputVersionedInvokes)
		}
		if args.SkipInvokeTypecheck {
			opts = append(opts, SkipInvokeTypechecking)
		}
		if args.SkipRangeTypecheck {
			opts = append(opts, SkipRangeTypechecking)
		}

		componentProgram, programDiags, err := BindProgram(parser.Files, opts...)

		includeSourceDirectoryInDiagnostics(programDiags, componentSourceDir)

		return componentProgram, programDiags, err
	}
}

// Replaces the Subject of the input diagnostics from just a file name to the full path of the component directory.
func includeSourceDirectoryInDiagnostics(diags hcl.Diagnostics, componentSourceDir string) {
	for _, diag := range diags {
		start := hcl.Pos{}
		end := hcl.Pos{}

		if diag.Subject != nil {
			start = diag.Subject.Start
			end = diag.Subject.End
			componentSourceDir = filepath.Join(componentSourceDir, diag.Subject.Filename)
		}

		diag.Subject = &hcl.Range{
			Filename: componentSourceDir,
			Start:    start,
			End:      end,
		}
	}
}

func (b *binder) bindComponent(node *Component) hcl.Diagnostics {
	// When options { range = <expr> } is present
	// We create a new scope for binding the component.
	// This range expression is potentially a key/value object
	// So here we compute the types for the key and value property of that object
	var rangeKeyType, rangeValueType model.Type

	transformComponentType := func(variableType model.Type) model.Type {
		return variableType
	}

	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())

				switch {
				case model.InputType(model.BoolType).ConversionFrom(typ) == model.SafeConversion:
					// if range expression has a boolean type
					// then variable type T of the component becomes Option<T>
					transformComponentType = func(variableType model.Type) model.Type {
						return model.NewOptionalType(variableType)
					}
				case model.InputType(model.NumberType).ConversionFrom(typ) == model.SafeConversion:
					// if the range expression has a numeric type
					// then value of the iteration is a number
					// and the variable type T of the component becomes List<T>
					rangeValueType = model.IntType
					transformComponentType = func(variableType model.Type) model.Type {
						return model.NewListType(variableType)
					}
				default:
					// for any other generic type iterations
					// we compute the range key and range value types
					// and the variable type T of the component becomes List<T>
					strict := !b.options.skipRangeTypecheck
					rangeKeyType, rangeValueType, _ = model.GetCollectionTypes(typ, rng.Range(), strict)
					transformComponentType = func(variableType model.Type) model.Type {
						return model.NewListType(variableType)
					}
				}
			}
		}
	}

	scopes := newComponentScopes(b.root, node, rangeKeyType, rangeValueType)

	block, diagnostics := model.BindBlock(node.syntax, scopes, b.tokens, b.options.modelOptions()...)
	node.Definition = block

	// check we can use components and load the program
	if b.options.dirPath == "" {
		diagnostics = diagnostics.Append(errorf(node.SyntaxNode().Range(),
			"components require the binder to have set a directory path"))
		return diagnostics
	}

	if b.options.componentProgramBinder == nil {
		diagnostics = diagnostics.Append(errorf(node.SyntaxNode().Range(),
			"components require the binder to have set the component program binder"))
		return diagnostics
	}

	componentProgram, programDiags, err := b.options.componentProgramBinder(ComponentProgramBinderArgs{
		AllowMissingVariables:        b.options.allowMissingVariables,
		AllowMissingProperties:       b.options.allowMissingProperties,
		SkipResourceTypecheck:        b.options.skipResourceTypecheck,
		SkipInvokeTypecheck:          b.options.skipInvokeTypecheck,
		SkipRangeTypecheck:           b.options.skipRangeTypecheck,
		PreferOutputVersionedInvokes: b.options.preferOutputVersionedInvokes,
		BinderLoader:                 b.options.loader,
		BinderDirPath:                b.options.dirPath,
		ComponentSource:              node.source,
		ComponentNodeRange:           node.SyntaxNode().Range(),
	})
	if err != nil {
		diagnostics = diagnostics.Append(errorf(node.SyntaxNode().Range(), "%s", err.Error()))
		node.VariableType = model.DynamicType
		return diagnostics
	}

	if programDiags.HasErrors() || componentProgram == nil {
		diagnostics = diagnostics.Append(errorf(node.SyntaxNode().Range(), "%s", programDiags.Error()))
		node.VariableType = model.DynamicType
		return diagnostics
	}

	node.Program = componentProgram
	programVariableType := componentVariableType(componentProgram)
	node.VariableType = transformComponentType(programVariableType)
	node.dirPath = filepath.Join(b.options.dirPath, node.source)

	componentInputs := componentInputs(componentProgram)
	providedInputs := []string{}

	var options *model.Block
	for _, item := range block.Body.Items {
		switch item := item.(type) {
		case *model.Attribute:
			// logical name is a special attribute
			if item.Name == LogicalNamePropertyKey {
				logicalName, lDiags := getStringAttrValue(item)
				if lDiags != nil {
					diagnostics = diagnostics.Append(lDiags)
				} else {
					node.logicalName = logicalName
				}
				continue
			}
			// all other attributes are part of the inputs
			_, knownInput := componentInputs[item.Name]

			if !knownInput {
				diagnostics = append(diagnostics, unsupportedAttribute(item.Name, item.Syntax.NameRange))
				return diagnostics
			}

			node.Inputs = append(node.Inputs, item)
			providedInputs = append(providedInputs, item.Name)
		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))
			}
		}
	}

	// check that all required inputs are actually set
	for inputKey, componentInput := range componentInputs {
		if componentInput.required && !contains(providedInputs, inputKey) {
			diagnostics = append(diagnostics, missingRequiredAttribute(inputKey, node.SyntaxNode().Range()))
		}
	}

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

	return diagnostics
}