// Copyright 2016-2022, 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 workspace

import (
	_ "embed"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"sort"
	"strconv"
	"strings"

	"github.com/pulumi/esc/ast"
	"github.com/pulumi/esc/eval"
	"github.com/texttheater/golang-levenshtein/levenshtein"

	"github.com/hashicorp/go-multierror"
	"github.com/pgavlin/fx"
	"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
	"github.com/pulumi/pulumi/sdk/v3/go/common/encoding"
	"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
	"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
	"github.com/santhosh-tekuri/jsonschema/v5"
	"golang.org/x/exp/maps"
	"gopkg.in/yaml.v3"
)

const (
	arrayTypeName   = "array"
	integerTypeName = "integer"
	stringTypeName  = "string"
	booleanTypeName = "boolean"
)

//go:embed project.json
var projectSchema string

var ProjectSchema *jsonschema.Schema

func init() {
	compiler := jsonschema.NewCompiler()
	compiler.LoadURL = func(u string) (io.ReadCloser, error) {
		if u == "blob://project.json" {
			return io.NopCloser(strings.NewReader(projectSchema)), nil
		}
		return jsonschema.LoadURL(u)
	}
	ProjectSchema = compiler.MustCompile("blob://project.json")
}

// Analyzers is a list of analyzers to run on this project.
type Analyzers []tokens.QName

// ProjectTemplate is a Pulumi project template manifest.
type ProjectTemplate struct {
	// DisplayName is an optional user friendly name of the template.
	DisplayName string `json:"displayName,omitempty" yaml:"displayName,omitempty"`
	// Description is an optional description of the template.
	Description string `json:"description,omitempty" yaml:"description,omitempty"`
	// Quickstart contains optional text to be displayed after template creation.
	Quickstart string `json:"quickstart,omitempty" yaml:"quickstart,omitempty"`
	// Config is an optional template config.
	Config map[string]ProjectTemplateConfigValue `json:"config,omitempty" yaml:"config,omitempty"`
	// Important indicates the template is important.
	Important bool `json:"important,omitempty" yaml:"important,omitempty"`
	// Metadata are key/value pairs used to attach additional metadata to a template.
	Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}

// ProjectTemplateConfigValue is a config value included in the project template manifest.
type ProjectTemplateConfigValue struct {
	// Description is an optional description for the config value.
	Description string `json:"description,omitempty" yaml:"description,omitempty"`
	// Default is an optional default value for the config value.
	Default string `json:"default,omitempty" yaml:"default,omitempty"`
	// Secret may be set to true to indicate that the config value should be encrypted.
	Secret bool `json:"secret,omitempty" yaml:"secret,omitempty"`
}

// ProjectBackend is the configuration for where the backend state is stored. If unset, will use the
// system's currently logged-in backend.
//
// Use the same URL format that is passed to "pulumi login", see
// https://www.pulumi.com/docs/cli/commands/pulumi_login/
//
// To explicitly use the Pulumi Cloud backend, use URL "https://api.pulumi.com"
type ProjectBackend struct {
	// URL is optional field to explicitly set backend url
	URL string `json:"url,omitempty" yaml:"url,omitempty"`
}

type ProjectOptions struct {
	// Refresh is the ability to always run a refresh as part of a pulumi update / preview / destroy
	Refresh string `json:"refresh,omitempty" yaml:"refresh,omitempty"`
}

type PluginOptions struct {
	Name    string `json:"name" yaml:"name"`
	Version string `json:"version,omitempty" yaml:"version,omitempty"`
	Path    string `json:"path" yaml:"path"`
}

type Plugins struct {
	Providers []PluginOptions `json:"providers,omitempty" yaml:"providers,omitempty"`
	Languages []PluginOptions `json:"languages,omitempty" yaml:"languages,omitempty"`
	Analyzers []PluginOptions `json:"analyzers,omitempty" yaml:"analyzers,omitempty"`
}

type ProjectConfigItemsType struct {
	Type  string                  `json:"type,omitempty" yaml:"type,omitempty"`
	Items *ProjectConfigItemsType `json:"items,omitempty" yaml:"items,omitempty"`
}

type ProjectConfigType struct {
	Type        *string                 `json:"type,omitempty" yaml:"type,omitempty"`
	Description string                  `json:"description,omitempty" yaml:"description,omitempty"`
	Items       *ProjectConfigItemsType `json:"items,omitempty" yaml:"items,omitempty"`
	Default     interface{}             `json:"default,omitempty" yaml:"default,omitempty"`
	Value       interface{}             `json:"value,omitempty" yaml:"value,omitempty"`
	Secret      bool                    `json:"secret,omitempty" yaml:"secret,omitempty"`
}

// IsExplicitlyTyped returns whether the project config type is explicitly typed.
// When that is the case, we validate stack config values against this type, given that
// the stack config value is namespaced by the project.
func (configType *ProjectConfigType) IsExplicitlyTyped() bool {
	return configType.Type != nil
}

func (configType *ProjectConfigType) TypeName() string {
	if configType.Type != nil {
		return *configType.Type
	}

	return ""
}

// Project is a Pulumi project manifest.
//
// We explicitly add yaml tags (instead of using the default behavior from https://github.com/ghodss/yaml which works
// in terms of the JSON tags) so we can directly marshall and unmarshall this struct using go-yaml an have the fields
// in the serialized object match the order they are defined in this struct.
//
// TODO[pulumi/pulumi#423]: use DOM based marshalling so we can roundtrip the seralized structure perfectly.
type Project struct {
	// Name is a required fully qualified name.
	Name tokens.PackageName `json:"name" yaml:"name"`
	// Runtime is a required runtime that executes code.
	Runtime ProjectRuntimeInfo `json:"runtime" yaml:"runtime"`
	// Main is an optional override for the program's main entry-point location.
	Main string `json:"main,omitempty" yaml:"main,omitempty"`

	// Description is an optional informational description.
	Description *string `json:"description,omitempty" yaml:"description,omitempty"`
	// Author is an optional author that created this project.
	Author *string `json:"author,omitempty" yaml:"author,omitempty"`
	// Website is an optional website for additional info about this project.
	Website *string `json:"website,omitempty" yaml:"website,omitempty"`
	// License is the optional license governing this project's usage.
	License *string `json:"license,omitempty" yaml:"license,omitempty"`

	// Config has been renamed to StackConfigDir.
	Config map[string]ProjectConfigType `json:"config,omitempty" yaml:"config,omitempty"`

	// StackConfigDir indicates where to store the Pulumi.<stack-name>.yaml files, combined with the folder
	// Pulumi.yaml is in.
	StackConfigDir string `json:"stackConfigDir,omitempty" yaml:"stackConfigDir,omitempty"`

	// Template is an optional template manifest, if this project is a template.
	Template *ProjectTemplate `json:"template,omitempty" yaml:"template,omitempty"`

	// Backend is an optional backend configuration
	Backend *ProjectBackend `json:"backend,omitempty" yaml:"backend,omitempty"`

	// Options is an optional set of project options
	Options *ProjectOptions `json:"options,omitempty" yaml:"options,omitempty"`

	Plugins *Plugins `json:"plugins,omitempty" yaml:"plugins,omitempty"`

	// Handle additional keys, albeit in a way that will remove comments and trivia.
	AdditionalKeys map[string]interface{} `yaml:",inline"`

	// The original byte representation of the file, used to attempt trivia-preserving edits
	raw []byte
}

func (proj Project) RawValue() []byte {
	return proj.raw
}

func isPrimitiveValue(value interface{}) bool {
	switch value.(type) {
	case string, int, bool:
		return true
	default:
		return false
	}
}

func isArray(value interface{}) bool {
	_, ok := value.([]interface{})
	return ok
}

// RewriteConfigPathIntoStackConfigDir checks if the project is using the old "config" property
// to declare a path to the stack configuration directory. If that is the case, we rewrite it
// such that the value in config: {value} is moved to stackConfigDir: {value}.
// if the user defines both values as strings, we error out.
func RewriteConfigPathIntoStackConfigDir(project map[string]interface{}) (map[string]interface{}, error) {
	config, hasConfig := project["config"]
	_, hasStackConfigDir := project["stackConfigDir"]

	if hasConfig {
		configText, configIsText := config.(string)
		if configIsText && hasStackConfigDir {
			return nil, errors.New("Should not use both config and stackConfigDir to define the stack directory. " +
				"Use only stackConfigDir instead.")
		} else if configIsText && !hasStackConfigDir {
			// then we have config: {value}. Move this to stackConfigDir: {value}
			project["stackConfigDir"] = configText
			// reset the config property
			project["config"] = nil
			return project, nil
		}
	}

	return project, nil
}

// RewriteShorthandConfigValues rewrites short-hand version of configuration into a configuration type
// for example the following config block definition:
//
//	config:
//		  instanceSize: t3.mirco
//		  aws:region: us-west-2
//
// will be rewritten into a typed value:
//
//	config:
//	   instanceSize:
//	      default: t3.micro
//	   aws:region:
//	      value: us-west-2
//
// Note that short-hand values without namespaces (project config) are turned into a type
// where as short-hand values with namespaces (such as aws:region) are turned into a value.
func RewriteShorthandConfigValues(project map[string]interface{}) map[string]interface{} {
	configMap, foundConfig := project["config"]
	projectName := project["name"].(string)
	if !foundConfig {
		// no config defined, return as is
		return project
	}

	config, ok := configMap.(map[string]interface{})

	if !ok {
		return project
	}

	for key, value := range config {
		if isPrimitiveValue(value) || isArray(value) {
			configTypeDefinition := make(map[string]interface{})
			if configKeyIsNamespacedByProject(projectName, key) {
				// then this is a project namespaced config _type_ with a default value
				configTypeDefinition["default"] = value
			} else {
				// then this is a non-project namespaced config _value_
				configTypeDefinition["value"] = value
			}

			config[key] = configTypeDefinition
			continue
		}
	}

	return project
}

// Cast any map[interface{}] from the yaml decoder to map[string]
func SimplifyMarshalledValue(raw interface{}) (interface{}, error) {
	var cast func(value interface{}) (interface{}, error)
	cast = func(value interface{}) (interface{}, error) {
		if objMap, ok := value.(map[interface{}]interface{}); ok {
			strMap := make(map[string]interface{})
			for key, value := range objMap {
				if strKey, ok := key.(string); ok {
					innerValue, err := cast(value)
					if err != nil {
						return nil, err
					}
					strMap[strKey] = innerValue
				} else {
					return nil, fmt.Errorf("expected only string keys, got '%s'", key)
				}
			}
			return strMap, nil
		} else if objArray, ok := value.([]interface{}); ok {
			strArray := make([]interface{}, len(objArray))
			for key, value := range objArray {
				innerValue, err := cast(value)
				if err != nil {
					return nil, err
				}
				strArray[key] = innerValue
			}
			return strArray, nil
		}
		return value, nil
	}

	return cast(raw)
}

func SimplifyMarshalledProject(raw interface{}) (map[string]interface{}, error) {
	result, err := SimplifyMarshalledValue(raw)
	if err != nil {
		return nil, err
	}

	var ok bool
	var obj map[string]interface{}
	if obj, ok = result.(map[string]interface{}); !ok {
		return nil, fmt.Errorf("expected project to be an object, was '%T'", result)
	}

	return obj, nil
}

func ValidateProject(raw interface{}) error {
	project, err := SimplifyMarshalledProject(raw)
	if err != nil {
		return err
	}

	// Manually validate keys that need more validation than the raw JSON schema
	// can provide.
	name, ok := project["name"]
	if !ok {
		closest := findClosestKey("name", project, maxValidationAttributeDistance)
		if closest != "" {
			return fmt.Errorf(
				"project is missing a 'name' attribute; found '%s' instead",
				closest,
			)
		}

		return errors.New("project is missing a 'name' attribute")
	}

	if strName, ok := name.(string); !ok || strName == "" {
		return errors.New("project is missing a non-empty string 'name' attribute")
	}

	if _, ok := project["runtime"]; !ok {
		closest := findClosestKey("runtime", project, maxValidationAttributeDistance)
		if closest != "" {
			return fmt.Errorf(
				"project is missing a 'runtime' attribute; found '%s' instead",
				closest,
			)
		}
		return errors.New("project is missing a 'runtime' attribute")
	}

	// We'll catch everything else with JSON schema, though we'll still try to
	// suggest fixes for common mistakes.
	if err = ProjectSchema.Validate(project); err == nil {
		return nil
	}
	validationError, ok := err.(*jsonschema.ValidationError)
	if !ok {
		return err
	}

	notAllowedRe := regexp.MustCompile(`'(\w[a-zA-Z0-9_]*)' not allowed$`)

	var errs *multierror.Error
	var appendError func(err *jsonschema.ValidationError)
	appendError = func(err *jsonschema.ValidationError) {
		if err.InstanceLocation != "" && err.Message != "" {
			errorf := func(path, message string, args ...interface{}) error {
				contract.Requiref(path != "", "path", "path must not be empty")
				return fmt.Errorf("%s: %s", path, fmt.Sprintf(message, args...))
			}

			msg := err.Message

			if match := notAllowedRe.FindStringSubmatch(msg); match != nil {
				attrName := match[1]
				attributes := getSchemaPathAttributes(err.InstanceLocation)

				closest := findClosestKey(attrName, attributes, maxValidationAttributeDistance)
				if closest != "" {
					msg = fmt.Sprintf("%s; did you mean '%s'?", msg, closest)
				} else if len(attributes) > 0 {
					valid := make([]string, 0, len(attributes))
					for k := range attributes {
						valid = append(valid, "'"+k+"'")
					}
					if len(valid) > 1 {
						sort.StringSlice.Sort(valid)
						msg = fmt.Sprintf("%s; the allowed attributes are %v and %s",
							msg, strings.Join(valid[:len(valid)-1], ", "), valid[len(valid)-1])
					} else {
						msg = fmt.Sprintf("%s; the only allowed attribute is %s", msg, valid[0])
					}
				}
			}
			errs = multierror.Append(errs, errorf("#"+err.InstanceLocation, "%v", msg))
		}
		for _, err := range err.Causes {
			appendError(err)
		}
	}
	appendError(validationError)

	return errs
}

// maxValidationAttributeDistance is the maximum Levenshtein distance we'll
// tolerate when searching for attribute names the user might have meant to
// type.
const maxValidationAttributeDistance = 2

// findClosestKey finds the closest attribute name in the given map to the
// supplied needle, where "closest" means the name with the smallest Levenshtein
// distance from the needle. The haystack will be sorted so that in the event
// multiple attributes have the same distance, the result will be deterministic
// (and be the first alphabetically).
func findClosestKey(
	needle string,
	haystack map[string]interface{},
	maxDistance int,
) string {
	match := ""
	closest := maxDistance + 1

	keys := maps.Keys(haystack)
	slices.Sort(keys)

	for _, key := range keys {
		d := levenshtein.DistanceForStrings(
			[]rune(strings.ToLower(needle)),
			[]rune(strings.ToLower(key)),
			levenshtein.DefaultOptionsWithSub,
		)

		if d == 0 {
			// We can't do better than 0 so we can short circuit in this case. Note
			// that a distance of 0 is possible since we lowercase the strings prior
			// to checking the Levenshtein distance, so e.g. "Name" and "NAME" will
			// become "name"/"name" and yield a distance of 0.
			return key
		} else if d < closest {
			closest = d
			match = key
		} else {
			continue
		}
	}

	return match
}

// getSchemaPathAttributes walks the given path into the project schema and
// returns a list of attributes that can be subsequently traversed at the end of
// that path.
func getSchemaPathAttributes(path string) map[string]interface{} {
	elements := strings.Split(path, "/")
	isNumber := regexp.MustCompile(`^\d+$`)

	curr := ProjectSchema
	for len(elements) > 0 {
		attr := elements[0]
		elements = elements[1:]

		if attr == "" {
			continue
		}

		// If this schema node references another, continue from there.
		if curr.Ref != nil {
			curr = curr.Ref
		}

		// Check properties for matching attributes.
		if schema, ok := curr.Properties[attr]; ok {
			curr = schema
			continue
		}

		// Check additional properties.
		if curr.AdditionalProperties != nil {
			if additional, ok := curr.AdditionalProperties.(map[string]*jsonschema.Schema); ok {
				if schema, ok := additional[attr]; ok {
					curr = schema
					continue
				}
			}
		}

		// If the attribute is numeric, check for an array that can be indexed.
		if isNumber.MatchString(attr) && curr.Items2020 != nil {
			curr = curr.Items2020
			continue
		}

		// In all other cases we can't traverse the supplied path.
		return nil
	}

	// If we end on a reference, resolve it to the actual element before
	// enumerating attributes.
	if curr.Ref != nil {
		curr = curr.Ref
	}

	knownProperties := make(map[string]interface{})
	for k, v := range curr.Properties {
		knownProperties[k] = v
	}

	if curr.AdditionalProperties != nil {
		if additional, ok := curr.AdditionalProperties.(map[string]*jsonschema.Schema); ok {
			for k, v := range additional {
				knownProperties[k] = v
			}
		}
	}

	return knownProperties
}

func InferFullTypeName(typeName string, itemsType *ProjectConfigItemsType) string {
	if itemsType != nil {
		return fmt.Sprintf("array<%v>", InferFullTypeName(itemsType.Type, itemsType.Items))
	}

	return typeName
}

// ValidateConfig validates the config value against its config type definition.
// We use this to validate the default config values alongside their type definition but
// also to validate config values coming from individual stacks.
func ValidateConfigValue(typeName string, itemsType *ProjectConfigItemsType, value interface{}) bool {
	if typeName == stringTypeName {
		_, ok := value.(string)
		return ok
	}

	if typeName == integerTypeName {
		_, ok := value.(int)
		if ok {
			return true
		}
		// Config values come from YAML which by default will return floats not int. If it's a whole number
		// we'll allow it here though
		f, ok := value.(float64)
		if ok && f == math.Trunc(f) {
			return true
		}
		// Allow strings here if they parse as integers
		valueAsText, isText := value.(string)
		if isText {
			_, integerParseError := strconv.Atoi(valueAsText)
			return integerParseError == nil
		}

		return false
	}

	if typeName == booleanTypeName {
		// check to see if the value is a literal string "true" | "false"
		literalValue, ok := value.(string)
		if ok && (literalValue == "true" || literalValue == "false") {
			return true
		}

		_, ok = value.(bool)
		return ok
	}

	items, isArray := value.([]interface{})

	if !isArray || itemsType == nil {
		return false
	}

	// validate each item
	for _, item := range items {
		itemType := itemsType.Type
		underlyingItems := itemsType.Items
		if !ValidateConfigValue(itemType, underlyingItems, item) {
			return false
		}
	}

	return true
}

func configKeyIsNamespacedByProject(projectName string, configKey string) bool {
	return !strings.Contains(configKey, ":") || strings.HasPrefix(configKey, projectName+":")
}

func (proj *Project) Validate() error {
	if proj.Name == "" {
		return errors.New("project is missing a 'name' attribute")
	}
	if proj.Runtime.Name() == "" {
		return errors.New("project is missing a 'runtime' attribute")
	}

	projectName := proj.Name.String()
	for configKey, configType := range proj.Config {
		if configType.Default != nil && configType.Value != nil {
			return fmt.Errorf("project config '%v' cannot have both a 'default' and 'value' attribute", configKey)
		}

		configTypeName := configType.TypeName()

		if configKeyIsNamespacedByProject(projectName, configKey) {
			// namespaced by project
			if configType.IsExplicitlyTyped() && configType.TypeName() == arrayTypeName && configType.Items == nil {
				return fmt.Errorf("The configuration key '%v' declares an array "+
					"but does not specify the underlying type via the 'items' attribute", configKey)
			}

			// when we have a config _type_ with a schema
			if configType.IsExplicitlyTyped() && configType.Default != nil {
				if !ValidateConfigValue(configTypeName, configType.Items, configType.Default) {
					inferredTypeName := InferFullTypeName(configTypeName, configType.Items)
					return fmt.Errorf("The default value specified for configuration key '%v' is not of the expected type '%v'",
						configKey,
						inferredTypeName)
				}
			}

		} else {
			// when not namespaced by project, there shouldn't be a type, only a value
			if configType.IsExplicitlyTyped() {
				return fmt.Errorf("Configuration key '%v' is not namespaced by the project and should not define a type",
					configKey)
			}

			// default values are part of a type schema
			// when not namespaced by project, there is no type schema, only a value
			if configType.Default != nil {
				return fmt.Errorf("Configuration key '%v' is not namespaced by the project and "+
					"should not define a default value. "+
					"Did you mean to use the 'value' attribute instead of 'default'?", configKey)
			}

			// when not namespaced by project, there should be a value
			if configType.Value == nil {
				return fmt.Errorf("Configuration key '%v' is namespaced and must provide an attribute 'value'", configKey)
			}
		}
	}

	return nil
}

// Save writes a project definition to a file.
func (proj *Project) Save(path string) error {
	contract.Requiref(path != "", "path", "must not be empty")
	contract.Requiref(proj != nil, "proj", "must not be nil")

	err := proj.Validate()
	contract.Requiref(err == nil, "proj", "Validate(): %v", err)

	return save(path, proj, false /*mkDirAll*/)
}

type PolicyPackProject struct {
	// Runtime is a required runtime that executes code.
	Runtime ProjectRuntimeInfo `json:"runtime" yaml:"runtime"`
	// Version specifies the version of the policy pack. If set, it will override the
	// version specified in `package.json` for Node.js policy packs.
	Version string `json:"version,omitempty" yaml:"version,omitempty"`

	// Main is an optional override for the program's main entry-point location.
	Main string `json:"main,omitempty" yaml:"main,omitempty"`

	// Description is an optional informational description.
	Description *string `json:"description,omitempty" yaml:"description,omitempty"`
	// Author is an optional author that created this project.
	Author *string `json:"author,omitempty" yaml:"author,omitempty"`
	// Website is an optional website for additional info about this project.
	Website *string `json:"website,omitempty" yaml:"website,omitempty"`
	// License is the optional license governing this project's usage.
	License *string `json:"license,omitempty" yaml:"license,omitempty"`

	// The original byte representation of the file, used to attempt trivia-preserving edits
	raw []byte
}

func (proj PolicyPackProject) RawValue() []byte {
	return proj.raw
}

func (proj *PolicyPackProject) Validate() error {
	if proj.Runtime.Name() == "" {
		return errors.New("project is missing a 'runtime' attribute")
	}

	return nil
}

// Save writes a project definition to a file.
func (proj *PolicyPackProject) Save(path string) error {
	contract.Requiref(path != "", "path", "must not be empty")
	contract.Requiref(proj != nil, "proj", "must not be nil")
	contract.Requiref(proj.Validate() == nil, "proj", "Validate()")
	return save(path, proj, false /*mkDirAll*/)
}

type PluginProject struct {
	// Runtime is a required runtime that executes code.
	Runtime ProjectRuntimeInfo `json:"runtime" yaml:"runtime"`
}

func (proj *PluginProject) Validate() error {
	if proj.Runtime.Name() == "" {
		return errors.New("project is missing a 'runtime' attribute")
	}

	return nil
}

type Environment struct {
	envs    []string
	message json.RawMessage
	node    *yaml.Node
}

func NewEnvironment(envs []string) *Environment {
	return &Environment{envs: envs}
}

func (e *Environment) Definition() []byte {
	switch {
	case e == nil:
		// If there's no environment, return nil.
		return nil
	case len(e.envs) != 0:
		// If the environment was a list of environments, create an anonymous environment and return it.
		bytes, err := json.Marshal(map[string]any{"imports": e.envs})
		if err != nil {
			return nil
		}
		return bytes
	case e.message != nil:
		// If the environment was encoded as JSON, return the raw JSON.
		return e.message
	case e.node != nil:
		// Re-encode the YAML and return it.
		bytes, err := yaml.Marshal(e.node)
		if err != nil {
			return nil
		}
		return bytes
	default:
		return nil
	}
}

func (e *Environment) Imports() []string {
	def, diags, err := eval.LoadYAMLBytes("yaml", e.Definition())
	if err != nil || len(diags) != 0 || def == nil {
		return nil
	}
	names := fx.ToSlice(fx.Map(fx.IterSlice(def.Imports.GetElements()), func(imp *ast.ImportDecl) string {
		return imp.Environment.GetValue()
	}))
	if len(def.Values.GetEntries()) != 0 {
		names = append(names, "yaml")
	}
	return names
}

func (e *Environment) Append(envs ...string) *Environment {
	switch {
	case e == nil:
		// The stack has no environment block. Create one that imports the named environments.
		return NewEnvironment(envs)
	case e.message != nil:
		// The environment definition is inline JSON. Append the named environments to the import list,
		// creating the list if necessary.
		var m map[string]any
		if err := json.Unmarshal(e.message, &m); err == nil {
			imports, _ := m["imports"].([]any)
			anys := fx.ToSlice(fx.Map(fx.IterSlice(envs), func(e string) any { return e }))
			m["imports"] = append(imports, anys...)
			if new, err := json.Marshal(m); err == nil {
				e.message = new
			}
		}
		return e
	case e.node != nil:
		// The environment definition is inline YAML.
		// - If there is no import list, add one, then append the named envs to the import list
		// - If there is an import list, append the named envs to the import list
		root := e.node
		if root.Kind == yaml.MappingNode {
			var imports *yaml.Node
			for i := 0; i < len(root.Content); i += 2 {
				key := root.Content[i]
				if key.Kind == yaml.ScalarNode && key.Value == "imports" {
					imports = root.Content[i+1]
					break
				}
			}
			if imports == nil {
				root.Content = append([]*yaml.Node{
					{
						Kind:  yaml.ScalarNode,
						Style: root.Style,
						Tag:   "!!str",
						Value: "imports",
					},
					{
						Kind:  yaml.SequenceNode,
						Style: root.Style,
					},
				}, root.Content...)
				imports = root.Content[1]
			}
			if imports.Kind == yaml.SequenceNode {
				nodes := fx.ToSlice(fx.Map(fx.IterSlice(envs), func(env string) *yaml.Node {
					return &yaml.Node{
						Kind:  yaml.ScalarNode,
						Style: imports.Style,
						Tag:   "!!str",
						Value: env,
					}
				}))
				imports.Content = append(imports.Content, nodes...)
				return e
			}
		}
		return e
	default:
		// The environment definition is just a list of environments. Append to the list.
		e.envs = append(e.envs, envs...)
		return e
	}
}

func (e *Environment) Remove(env string) *Environment {
	switch {
	case e == nil:
		// There is no environment block, so there's nothing to remove.
		return nil
	case e.message != nil:
		// The environment definition is inline JSON. Find the last occurrence of the named environment in the import
		// list and remove it.
		var m map[string]any
		if err := json.Unmarshal(e.message, &m); err == nil {
			if imports, ok := m["imports"].([]any); ok {
				for i := len(imports) - 1; i >= 0; i-- {
					match := false
					switch e := imports[i].(type) {
					case string:
						match = e == env
					case map[string]any:
						match = len(e) == 1 && maps.Keys(e)[0] == env
					}
					if match {
						m["imports"] = append(imports[:i], imports[i+1:]...)
						if new, err := json.Marshal(m); err == nil {
							e.message = new
						}
						return e
					}
				}
			}
		}
		return e
	case e.node != nil:
		// The environment definition is inline YAML. Find the last occurrence of the named environment in the import
		// list and remove it.
		root := e.node
		if root.Kind == yaml.MappingNode {
			for i := 0; i < len(root.Content); i += 2 {
				key := root.Content[i]
				if key.Kind == yaml.ScalarNode && key.Value == "imports" {
					value := root.Content[i+1]
					if value.Kind == yaml.SequenceNode {
						for j := len(value.Content) - 1; j >= 0; j-- {
							n := value.Content[j]

							match := false
							switch n.Kind {
							case yaml.ScalarNode:
								match = n.Value == env
							case yaml.MappingNode:
								match = len(n.Content) == 2 && n.Content[0].Value == env
							case yaml.SequenceNode, yaml.AliasNode, yaml.DocumentNode:
								// These nodes never match, so we can ignore them here.
							}
							if match {
								value.Content = append(value.Content[:j], value.Content[j+1:]...)
								if len(value.Content) == 0 {
									root.Content = append(root.Content[:i], root.Content[i+2:]...)
								}
								return e
							}
						}
					}
				}
			}
		}
		return e
	default:
		// The environment definition is just a list of environments. Find the last occurrence of the named environment
		// in the list and remove it.
		for i := len(e.envs) - 1; i >= 0; i-- {
			n := e.envs[i]
			if n == env {
				e.envs = append(e.envs[:i], e.envs[i+1:]...)
				if len(e.envs) == 0 {
					return nil
				}
				return e
			}
		}
		return e
	}
}

func (e Environment) MarshalJSON() ([]byte, error) {
	if e.message == nil {
		return json.Marshal(e.envs)
	}
	return json.Marshal(e.message)
}

func (e *Environment) UnmarshalJSON(b []byte) error {
	if err := json.Unmarshal(b, &e.envs); err == nil {
		return nil
	}
	return json.Unmarshal(b, &e.message)
}

func (e Environment) MarshalYAML() (any, error) {
	if e.node == nil {
		return e.envs, nil
	}
	return e.node, nil
}

func (e *Environment) UnmarshalYAML(n *yaml.Node) error {
	if err := n.Decode(&e.envs); err == nil {
		return nil
	}
	e.node = n
	return nil
}

// ProjectStack holds stack specific information about a project.
type ProjectStack struct {
	// SecretsProvider is this stack's secrets provider.
	SecretsProvider string `json:"secretsprovider,omitempty" yaml:"secretsprovider,omitempty"`
	// EncryptedKey is the KMS-encrypted ciphertext for the data key used for secrets encryption.
	// Only used for cloud-based secrets providers.
	EncryptedKey string `json:"encryptedkey,omitempty" yaml:"encryptedkey,omitempty"`
	// EncryptionSalt is this stack's base64 encoded encryption salt.  Only used for
	// passphrase-based secrets providers.
	EncryptionSalt string `json:"encryptionsalt,omitempty" yaml:"encryptionsalt,omitempty"`
	// Config is an optional config bag.
	Config config.Map `json:"config,omitempty" yaml:"config,omitempty"`
	// Environment is an optional environment definition or list of environments.
	Environment *Environment `json:"environment,omitempty" yaml:"environment,omitempty"`

	// The original byte representation of the file, used to attempt trivia-preserving edits
	raw []byte
}

func (ps ProjectStack) EnvironmentBytes() []byte {
	return ps.Environment.Definition()
}

func (ps ProjectStack) RawValue() []byte {
	return ps.raw
}

// Save writes a project definition to a file.
func (ps *ProjectStack) Save(path string) error {
	contract.Requiref(path != "", "path", "must not be empty")
	contract.Requiref(ps != nil, "ps", "must not be nil")
	return save(path, ps, true /*mkDirAll*/)
}

type ProjectRuntimeInfo struct {
	name    string
	options map[string]interface{}
}

type ProjectStackDeployment struct {
	DeploymentSettings apitype.DeploymentSettings `json:"settings" yaml:"settings"`
}

func (psd *ProjectStackDeployment) Save(path string) error {
	contract.Requiref(path != "", "path", "must not be empty")
	contract.Requiref(psd != nil, "ps", "must not be nil")
	return save(path, psd, true /*mkDirAll*/)
}

func NewProjectRuntimeInfo(name string, options map[string]interface{}) ProjectRuntimeInfo {
	return ProjectRuntimeInfo{
		name:    name,
		options: options,
	}
}

func (info *ProjectRuntimeInfo) Name() string {
	return info.name
}

func (info *ProjectRuntimeInfo) Options() map[string]interface{} {
	return info.options
}

func (info *ProjectRuntimeInfo) SetOption(key string, value interface{}) {
	if info.options == nil {
		info.options = make(map[string]interface{})
	}
	info.options[key] = value
}

func (info ProjectRuntimeInfo) MarshalYAML() (interface{}, error) {
	if info.options == nil || len(info.options) == 0 {
		return info.name, nil
	}

	return map[string]interface{}{
		"name":    info.name,
		"options": info.options,
	}, nil
}

func (info ProjectRuntimeInfo) MarshalJSON() ([]byte, error) {
	if info.options == nil || len(info.options) == 0 {
		return json.Marshal(info.name)
	}

	return json.Marshal(map[string]interface{}{
		"name":    info.name,
		"options": info.options,
	})
}

func (info *ProjectRuntimeInfo) UnmarshalJSON(data []byte) error {
	if err := json.Unmarshal(data, &info.name); err == nil {
		return nil
	}

	var payload struct {
		Name    string                 `json:"name"`
		Options map[string]interface{} `json:"options"`
	}

	if err := json.Unmarshal(data, &payload); err == nil {
		info.name = payload.Name
		info.options = payload.Options
		return nil
	}

	return errors.New("runtime section must be a string or an object with name and options attributes")
}

func (info *ProjectRuntimeInfo) UnmarshalYAML(unmarshal func(interface{}) error) error {
	if err := unmarshal(&info.name); err == nil {
		return nil
	}

	var payload struct {
		Name    string                 `yaml:"name"`
		Options map[string]interface{} `yaml:"options"`
	}

	if err := unmarshal(&payload); err == nil {
		info.name = payload.Name
		info.options = payload.Options
		return nil
	}

	return errors.New("runtime section must be a string or an object with name and options attributes")
}

func marshallerForPath(path string) (encoding.Marshaler, error) {
	ext := filepath.Ext(path)
	m, has := encoding.Marshalers[ext]
	if !has {
		return nil, fmt.Errorf("no marshaler found for file format '%v'", ext)
	}

	return m, nil
}

func save(path string, value interface{}, mkDirAll bool) error {
	contract.Requiref(path != "", "path", "must not be empty")
	contract.Requiref(value != nil, "value", "must not be nil")

	m, err := marshallerForPath(path)
	if err != nil {
		return err
	}

	b, err := m.Marshal(value)
	if err != nil {
		return err
	}

	if mkDirAll {
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			return err
		}
	}

	//nolint:gosec
	return os.WriteFile(path, b, 0o644)
}

// To mitigate an import cycle, we define this here.
const PulumiTagsConfigKey = "pulumi:tags"

// AddConfigStackTags sets the project tags config to the given map of tags.
func (proj *Project) AddConfigStackTags(tags map[string]string) {
	if proj.Config == nil {
		proj.Config = map[string]ProjectConfigType{}
	}
	configTags, has := proj.Config["pulumi:tags"]
	if !has {
		configTags = ProjectConfigType{
			Value: map[string]string{},
		}
	}
	if configTags.Value == nil {
		configTags.Value = map[string]string{}
	}

	tagMap, ok := configTags.Value.(map[string]string)
	if !ok {
		logging.Warningf("overwriting non-object `%s` project config", "pulumi:tags")
		tagMap = map[string]string{}
	}
	for k, v := range tags {
		tagMap[k] = v
	}
	proj.Config["pulumi:tags"] = configTags
}