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

package conversions

import (
	"fmt"
	"go/token"

	"github.com/dave/dst"
	"github.com/rotisserie/eris"

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

// PropertyConversion generates the AST for a given property conversion.
// reader is an expression to read the original value.
// writer is a function that accepts an expression for reading a value and creates one or more
// statements to write that value.
// Both of these might be complex expressions, possibly involving indexing into arrays or maps.
type PropertyConversion func(
	reader dst.Expr,
	writer func(dst.Expr) []dst.Stmt,
	knownLocals *astmodel.KnownLocalsSet,
	generationContext *astmodel.CodeGenerationContext,
) ([]dst.Stmt, error)

// PropertyConversionFactory represents factory methods that can be used to create a PropertyConversion for a specific
// pair of properties
// source is the property conversion endpoint that will be read
// destination is the property conversion endpoint that will be written
// ctx contains additional information that may be needed when creating the property conversion
//
// The factory should return one of three result sets:
//   - For a fatal error, one that guarantees no conversion can be generated, return (nil, error)
//     This will abort the conversion process and return an error for logging.
//   - For a valid conversion, return (conversion, nil)
//   - When no conversion could be generated by this factory, return (nil, nil) to delegate to another factory
//
// Each conversion should be written with lead predicates to make sure that it only fires in the correct circumstances.
// This requires, in particular, that most conversions check for optionality and bag items and exit early when those are
// found.
// Phrased another way, conversions should not rely on the order of listing in propertyConversionFactories in order to
// generate the correct code; any conversion that relies on being "protected" from particular situations by having other
// conversions earlier in the list held by propertyConversionFactories is brittle and likely to generate the incorrect
// code if the order of items in the list is modified.
type PropertyConversionFactory func(
	source *TypedConversionEndpoint,
	destination *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext) (PropertyConversion, error)

// A list of all known type conversion factory methods
var propertyConversionFactories []PropertyConversionFactory

func init() {
	propertyConversionFactories = []PropertyConversionFactory{
		// Property bag items
		pullFromBagItem,
		writeToBagItem,
		// Primitive definitions and aliases
		assignPrimitiveFromPrimitive,
		assignAliasedPrimitiveFromAliasedPrimitive,
		// Handcrafted implementations in genruntime
		assignHandcraftedImplementations,
		// Some conversions are forbidden and we just skip them
		neuterForbiddenConversions,
		// Collection Types
		assignArrayFromArray,
		assignMapFromMap,
		// Enumerations
		assignEnumFromEnum,
		assignPrimitiveFromEnum,
		// Complex object definitions
		assignObjectDirectlyFromObject,
		assignObjectDirectlyToObject,
		assignInlineObjectsViaIntermediateObject,
		assignNonInlineObjectsViaPivotObject,
		// Known definitions
		assignUserAssignedIdentityMapFromArray,
		copyKnownType(astmodel.KnownResourceReferenceType, "Copy", returnsValue),
		copyKnownType(astmodel.ResourceReferenceType, "Copy", returnsValue),
		copyKnownType(astmodel.WellKnownResourceReferenceType, "Copy", returnsValue),
		copyKnownType(astmodel.SecretReferenceType, "Copy", returnsValue),
		copyKnownType(astmodel.SecretMapReferenceType, "Copy", returnsValue),
		copyKnownType(astmodel.SecretDestinationType, "Copy", returnsValue),
		copyKnownType(astmodel.ConfigMapReferenceType, "Copy", returnsValue),
		copyKnownType(astmodel.ConfigMapDestinationType, "Copy", returnsValue),
		copyKnownType(astmodel.DestinationExpressionType, "DeepCopy", returnsReference),
		copyKnownType(astmodel.ArbitraryOwnerReference, "Copy", returnsValue),
		copyKnownType(astmodel.ConditionType, "Copy", returnsValue),
		copyKnownType(astmodel.JSONType, "DeepCopy", returnsReference),
		copyKnownType(astmodel.ObjectMetaType, "DeepCopy", returnsReference),
		// Meta-conversions
		assignFromOptional,
		assignToOptional,
		assignToEnumeration,
		assignFromAliasedType,
		assignToAliasedType,
	}
}

// CreateTypeConversion tries to create a type conversion between the two provided definitions, using
// all of the available type conversion functions in priority order to do so.
//
// The method works by considering the conversion requested by sourceEndpoint & destinationEndpoint,
// with recursive calls breaking the conversion down into multiple steps that are then combined.
//
// Example:
//
// CreateTypeConversion() is called to create a conversion from an optional string to an optional
// Sku, where Sku is a new type based on string:
//
// source *string => destination *Sku
//
// assuming
//
//	type Sku string
//
// assignFromOptional can handle the optionality of sourceEndpoint and makes a recursive call
// to CreateTypeConversion() with the simpler target:
//
// source string => destination *Sku
//
//	assignToOptional can handle the optionality of destinationEndpoint and makes a recursive
//	call to CreateTypeConversion() with a simpler target:
//
//	source string => destination Sku
//
//	    assignToAliasedPrimitive can handle the type conversion of string to Sku, and makes
//	    a recursive call to CreateTypeConversion() with a simpler target:
//
//	    source string => destination string
//
//	        assignPrimitiveFromPrimitive can handle primitive values, and generates a
//	        conversion that does a simple assignment:
//
//	        destination = source
//
//	    assignToAliasedPrimitive injects the necessary type conversion:
//
//	    destination = Sku(source)
//
//	assignToOptional injects a local variable and takes it's address
//
//	sku := Sku(source)
//	destination = &sku
//
// finally, assignFromOptional injects the check to see if we have a value to assign in the
// first place, assigning a suitable zero value if we don't:
//
//	if source != nil {
//	    sku := Sku(source)
//	    destination := &sku
//	} else {
//
//	    destination := ""
//	}
func CreateTypeConversion(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	var result PropertyConversion
	var err error
	for _, f := range propertyConversionFactories {
		result, err = f(sourceEndpoint, destinationEndpoint, conversionContext)
		if err != nil {
			// Fatal error, no conversion possible
			break
		}

		if result != nil {
			// Conversion found, return it
			return result, nil
		}
	}

	// No conversion found, we need to generate a useful error message, wrapping any existing error.
	// If the endpoints are in different packages, we want both to be qualfied with their package name.
	// We finesse this by cross wiring the packges passed so that we only get simplified descriptions if they are
	// in the same package.
	describe := func(subject astmodel.Type, ref astmodel.Type) string {
		if tn, ok := astmodel.AsInternalTypeName(ref); ok {
			return astmodel.DebugDescription(subject, tn.InternalPackageReference())
		}

		return astmodel.DebugDescription(subject)
	}

	srcType := sourceEndpoint.Type()
	dstType := destinationEndpoint.Type()
	msg := fmt.Sprintf("no conversion found to assign %q from %q",
		describe(dstType, srcType),
		describe(srcType, dstType))

	if err != nil {
		err = eris.Wrap(err, msg)
	} else {
		err = eris.New(msg)
	}

	return nil, err
}

// NameOfPropertyAssignmentFunction returns the name of the property assignment function
func NameOfPropertyAssignmentFunction(
	baseName string,
	parameterType astmodel.TypeName,
	direction Direction,
	idFactory astmodel.IdentifierFactory,
) string {
	nameOfOtherType := idFactory.CreateIdentifier(parameterType.Name(), astmodel.Exported)
	dir := direction.SelectString("From", "To")
	return fmt.Sprintf("%s_%s_%s", baseName, dir, nameOfOtherType)
}

// directAssignmentPropertyConversion is a helper function for creating a conversion that does a direct assignment
func directAssignmentPropertyConversion(
	reader dst.Expr,
	writer func(dst.Expr) []dst.Stmt,
	_ *astmodel.KnownLocalsSet,
	_ *astmodel.CodeGenerationContext,
) ([]dst.Stmt, error) {
	return writer(reader), nil
}

// writeToBagItem will generate a conversion where the destination is in our property bag
//
// # For non-optional sources, the value is directly added
//
// <propertyBag>.Add(<propertyName>, <source>)
//
// For optional sources, the value is only added if non-nil; if nil, we remove any existing item
//
//	if <source> != nil {
//	    <propertyBag>.Add(<propertyName>, *<source>)
//	} else {
//
//	   <propertyBag>.Remove(<propertyName>)
//	}
//
// For slice and slice sources, the value is only added if it is non-empty; if empty we remove any existing item
//
//	if len(<source>) > 0 {
//		   <propertyBag>.Add(<propertyName>, <source>)
//	} else {
//
//		   <propertyBag>.Remove(<propertyName>)
//	}
//
// If the type within the property bag differs from the source type, a type conversion is recursively sought
func writeToBagItem(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require destination to be a property bag item
	destinationBagItem, destinationIsBagItem := AsPropertyBagMemberType(destinationEndpoint.Type())
	if !destinationIsBagItem {
		// Destination is not optional
		return nil, nil
	}

	// Work out our source type, and whether it's optional
	actualSourceType := sourceEndpoint.Type()
	sourceOptional, sourceIsOptional := astmodel.AsOptionalType(actualSourceType)
	if sourceIsOptional {
		actualSourceType = sourceOptional.BaseType()
		sourceEndpoint = sourceEndpoint.WithType(actualSourceType)
	}

	// If the item in the bag is exactly the same type as our source, we don't need any other conversion.
	// We don't want to recursively look for more expensive conversions if we don't need to.
	// Plus, conversions are designed to isolate the source and destination from each other (so that changes to one
	// don't impact the other), but with the property bag everything gets immediately serialized so everything is
	// already nicely isolated.
	// On the other hand, if the types are different, we need to look for a conversion.
	conversion := directAssignmentPropertyConversion
	if !astmodel.TypeEquals(destinationBagItem.Element(), actualSourceType) {
		// Look for a conversion between the bag item and our source
		bagItemEndpoint := destinationEndpoint.WithType(destinationBagItem.Element())
		c, err := CreateTypeConversion(sourceEndpoint, bagItemEndpoint, conversionContext)
		if err != nil {
			return nil, err
		}

		if c == nil {
			return nil, nil
		}

		conversion = c
	}

	_, sourceIsMap := astmodel.AsMapType(actualSourceType)
	_, sourceIsSlice := astmodel.AsArrayType(actualSourceType)

	return func(
		reader dst.Expr,
		_ func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		// propertyBag.Add(<propertyName>, <source>)
		createAddToBag := func(expr dst.Expr) []dst.Stmt {
			addToBag := astbuilder.CallQualifiedFuncAsStmt(
				conversionContext.PropertyBagName(),
				"Add",
				astbuilder.StringLiteral(destinationEndpoint.Name()),
				expr)

			return astbuilder.Statements(addToBag)
		}

		// propertyBag.Remove(<propertyName>)
		removeFromBag := astbuilder.CallQualifiedFuncAsStmt(
			conversionContext.PropertyBagName(),
			"Remove",
			astbuilder.StringLiteral(destinationEndpoint.Name()))

		// condition is a test to use to see whether we have a value to write to the property bag
		// If we unilaterally write to the bag, this will be nil
		var condition dst.Expr

		// If optional source, check for nil and only store if we have a value
		if sourceIsOptional {
			// if <reader> != nil {
			condition = astbuilder.NotNil(reader)
			// To read the actual value, we need to dereference the pointer
			reader = astbuilder.Dereference(reader)
			// We're wrapping the conversion in a nested block, so any locals are independent
			knownLocals = knownLocals.Clone()
		}

		// If slice or map, check for non-empty and only store if we have a value
		if sourceIsSlice || sourceIsMap {
			// if len(<mapOrSlice>) > 0 {
			condition = astbuilder.NotEmpty(reader)
			// We're wrapping the conversion in a nested block, so any locals are independent
			knownLocals = knownLocals.Clone()
		}

		// Create the conversion to use to write to the bag
		addToBag, err := conversion(
			reader,
			createAddToBag,
			knownLocals,
			generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"unable to convert %s to %s writing property bag",
				sourceEndpoint.Name(),
				destinationEndpoint.Name())
		}

		// If we only conditionally write to the bag, we need wrap with an if statement
		if condition != nil {
			writer := astbuilder.SimpleIfElse(
				condition,
				addToBag,
				astbuilder.Statements(removeFromBag))
			return astbuilder.Statements(writer), nil
		}

		// Otherwise, just add the value to the bag
		return astbuilder.Statements(addToBag), nil
	}, nil
}

// assignToOptional will generate a conversion where the destination is optional, if the
// underlying type of the destination is compatible with the source.
//
// <destination> = &<source>
func assignToOptional(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require destination to not be a bag item
	if destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require destination to be optional
	destinationOptional, destinationIsOptional := astmodel.AsOptionalType(destinationEndpoint.Type())
	if !destinationIsOptional {
		// Destination is not optional
		return nil, nil
	}

	// Require source to be non-optional
	// (to ensure that assignFromOptional triggers first when handling option to optional conversion)
	if sourceEndpoint.IsOptional() {
		return nil, nil
	}

	// Require a conversion between the unwrapped type and our source
	unwrappedEndpoint := destinationEndpoint.WithType(destinationOptional.Element())
	conversion, err := CreateTypeConversion(sourceEndpoint, unwrappedEndpoint, conversionContext)
	if err != nil {
		return nil, err
	}
	if conversion == nil {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		// Create a writer that uses the address of the passed expression
		// If expr isn't a plain identifier (implying a local variable), we introduce one
		// This both allows us to avoid aliasing and complies with Go language semantics
		addrOfWriter := func(expr dst.Expr) []dst.Stmt {
			if _, ok := expr.(*dst.Ident); ok {
				return writer(astbuilder.AddrOf(expr))
			}

			// Only obtain our local variable name after we know we need it
			// (this avoids reserving the name and not using it, which can interact with other conversions)
			local := knownLocals.CreateSingularLocal(destinationEndpoint.Name(), "", "Temp")

			assignment := astbuilder.ShortDeclaration(local, expr)

			writing := writer(astbuilder.AddrOf(dst.NewIdent(local)))

			return astbuilder.Statements(assignment, writing)
		}

		return conversion(reader, addrOfWriter, knownLocals, generationContext)
	}, nil
}

// pullFromBagItem will populate a property from a property bag
//
//	if <propertyBag>.Contains(<sourceName>) {
//	    var <value> <destinationType>
//	    err := <propertyBag>.Pull(<sourceName>, &<value>)
//	    if err != nil {
//	        return errors.Wrapf(err, ...)
//	    }
//
//	    <destination> = <value>
//	} else {
//
//	    <destination> = <zero>
//	}
//
// If the type within the property bag differs from the destination type, a type conversion is recursively sought
func pullFromBagItem(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require source to be a bag item
	sourceBagItem, sourceIsBagItem := AsPropertyBagMemberType(sourceEndpoint.Type())
	if !sourceIsBagItem {
		return nil, nil
	}

	// Work out our destination type, and whether it's optional
	actualDestinationType := destinationEndpoint.Type()
	destinationOptional, destinationIsOptional := astmodel.AsOptionalType(actualDestinationType)
	if destinationIsOptional {
		actualDestinationType = destinationOptional.BaseType()
	}

	// If the item in the bag is exactly the same type as our destination, we don't need any other conversion
	// We don't want to recursively look for more expensive conversions if we don't need to.
	// Plus, conversions are designed to isolate the source and destination from each other (so that changes to one
	// don't impact the other), but with the property bag everything gets immediately serialized so everything is
	// already nicely isolated.
	// On the other hand, if the types are different, we need to look for a conversion
	conversion := directAssignmentPropertyConversion
	typesDiffer := !astmodel.TypeEquals(sourceBagItem.Element(), actualDestinationType)
	if typesDiffer {
		// Look for a conversion between the bag item and our source
		bagItemEndpoint := sourceEndpoint.WithType(sourceBagItem.Element())
		c, err := CreateTypeConversion(bagItemEndpoint, destinationEndpoint, conversionContext)
		if err != nil {
			return nil, err
		}

		if c == nil {
			return nil, nil
		}

		conversion = c
	}

	errIdent := dst.NewIdent("err")

	return func(
		_ dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		// our first parameter is an expression to read the value from our original instance, but in this case we're
		// going to read from the property bag, so we're ignoring it.

		// Work out a name for our local variable
		// We use different defaults when doing a conversion to make the local naming clearer in the generated code
		var local string
		if typesDiffer {
			local = knownLocals.CreateSingularLocal(sourceEndpoint.Name(), "FromBag", "ReadFromBag")
		} else {
			local = knownLocals.CreateSingularLocal(sourceEndpoint.Name(), "", "Read")
		}

		errorsPkg := generationContext.MustGetImportedPackageName(astmodel.ErisReference)

		// propertyBag.Contains("<sourceName>")
		condition := astbuilder.CallQualifiedFunc(
			conversionContext.PropertyBagName(),
			"Contains",
			astbuilder.StringLiteral(sourceEndpoint.Name()))

		// var <local> <sourceBagItemType>
		sourceBagItemExpr, err := sourceBagItem.AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"converting %s to %s reading property bag",
				sourceEndpoint.Name(),
				destinationEndpoint.Name())
		}

		declare := astbuilder.NewVariableWithType(
			local,
			sourceBagItemExpr)

		// We're wrapping the conversion in a nested block, so any locals are independent
		knownLocals = knownLocals.Clone()

		// We have to do this at render time in order to ensure the first conversion generated
		// declares 'err', not a later one
		tok := token.ASSIGN
		if knownLocals.TryCreateLocal("err") {
			tok = token.DEFINE
		}

		// err := <propertyBag>.Pull(<sourceName>, &<local>)
		pull := astbuilder.AssignmentStatement(
			dst.NewIdent("err"),
			tok,
			astbuilder.CallQualifiedFunc(
				conversionContext.PropertyBagName(),
				"Pull",
				astbuilder.StringLiteral(sourceEndpoint.Name()),
				astbuilder.AddrOf(dst.NewIdent(local))))

		// if err != nil {
		//     return ...
		// }
		returnIfErr := astbuilder.ReturnIfNotNil(
			errIdent,
			astbuilder.WrappedErrorf(
				errorsPkg,
				"pulling '%s' from propertyBag",
				sourceEndpoint.Name()))
		returnIfErr.Decorations().After = dst.EmptyLine

		var reader dst.Expr
		if destinationIsOptional {
			reader = astbuilder.AddrOf(dst.NewIdent(local))
		} else {
			reader = dst.NewIdent(local)
		}

		// Create the actual code to store the value
		assignValue, err := conversion(reader, writer, knownLocals, generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"unable to convert %s to %s reading property bag",
				sourceEndpoint.Name(),
				destinationEndpoint.Name())
		}

		// Generate code to clear the value if we don't have one
		assignZero := writer(
			destinationEndpoint.Type().AsZero(conversionContext.Types(),
				generationContext))

		// if <condition> {
		//   <declare, pull, returnIfErr, assignValue>
		// } else {
		//   <assignZero>
		// }
		ifStatement := astbuilder.SimpleIfElse(
			condition,
			astbuilder.Statements(declare, pull, returnIfErr, assignValue),
			assignZero)

		return astbuilder.Statements(ifStatement), nil
	}, nil
}

// assignFromOptional will handle the case where the source type may be missing (nil)
//
// <original> := <source>
//
//	if <original> != nil {
//	   <destination> = *<original>
//	} else {
//
//	   <destination> = <zero>
//	}
//
// Must trigger before assignToOptional so we generate the right zero values; to enforce this, assignToOptional includes
// a predicate check that the source is NOT optional, allowing this conversion to trigger first.
func assignFromOptional(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require source to not be a bag item
	if sourceEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require source to be optional
	sourceOptional, sourceIsOptional := astmodel.AsOptionalType(sourceEndpoint.Type())
	if !sourceIsOptional {
		return nil, nil
	}

	// Require a conversion between the unwrapped type and our source
	unwrappedEndpoint := sourceEndpoint.WithType(sourceOptional.Element())
	conversion, err := CreateTypeConversion(
		unwrappedEndpoint,
		destinationEndpoint,
		conversionContext)
	if err != nil {
		return nil, err
	}
	if conversion == nil {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		var cacheOriginal dst.Stmt
		var actualReader dst.Expr

		// If the value we're reading is a local or a field, it's cheap to read and we can skip
		// using a local (which makes the generated code easier to read). In other cases, we want
		// to cache the value in a local to avoid repeating any expensive conversion.

		switch reader.(type) {
		case *dst.Ident, *dst.SelectorExpr:
			// reading a local variable or a field
			cacheOriginal = nil
			actualReader = reader
		default:
			// Something else, so we cache the original
			local := knownLocals.CreateSingularLocal(sourceEndpoint.Name(), "", "AsRead")
			cacheOriginal = astbuilder.ShortDeclaration(local, reader)
			actualReader = dst.NewIdent(local)
		}

		checkForNil := astbuilder.AreNotEqual(actualReader, astbuilder.Nil())

		// If we have a value, need to convert it to our destination type
		// We use a cloned knownLocals as the Write is within our if statement, and we don't want locals to leak
		writeActualValue, err := conversion(
			astbuilder.Dereference(actualReader),
			writer,
			knownLocals.Clone(),
			generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"unable to convert %s to %s",
				sourceEndpoint.Name(),
				destinationEndpoint.Name())
		}

		writeZeroValue := writer(
			destinationEndpoint.Type().AsZero(conversionContext.Types(), generationContext))

		stmt := astbuilder.SimpleIfElse(
			checkForNil,
			writeActualValue,
			writeZeroValue)

		return astbuilder.Statements(cacheOriginal, stmt), nil
	}, nil
}

// assignToEnumeration will generate a conversion where the destination is an enumeration if
// the source is type compatible with the base type of the enumeration
//
// <destination> = <enumeration-cast>(<source>)
func assignToEnumeration(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require destination to not be a bag item
	if destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require destination to be non-optional
	if destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require destination to be an enumeration
	dstName, dstType, ok := conversionContext.ResolveType(destinationEndpoint.Type())
	if !ok {
		return nil, nil
	}
	dstEnum, ok := astmodel.AsEnumType(dstType)
	if !ok {
		return nil, nil
	}

	// Require a conversion between the base type of the enumeration and our source
	dstEp := destinationEndpoint.WithType(dstEnum.BaseType())
	conversion, err := CreateTypeConversion(sourceEndpoint, dstEp, conversionContext)
	if err != nil {
		return nil, err
	}
	if conversion == nil {
		return nil, nil
	}

	conversionContext.AddPackageReference(astmodel.StringsReference)

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		// If the enum is NOT based on a string, we can just do a direct cast and keep things simple
		if dstEnum.BaseType() != astmodel.StringType {
			return writer(astbuilder.CallFunc(dstName.Name(), reader)), nil
		}

		var cacheOriginal dst.Stmt
		var actualReader dst.Expr

		// If the value we're reading is a local or a field, it's cheap to read and we can skip
		// using a local (which makes the generated code easier to read). In other cases, we want
		// to cache the value in a local to avoid repeating any expensive conversion.

		switch reader.(type) {
		case *dst.Ident, *dst.SelectorExpr:
			// reading a local variable or a field
			cacheOriginal = nil
			actualReader = reader
		default:
			// Something else, so we cache the original
			local := knownLocals.CreateSingularLocal(sourceEndpoint.Name(), "", "Value", "Cache")
			cacheOriginal = astbuilder.ShortDeclaration(local, reader)
			actualReader = dst.NewIdent(local)
		}

		dstNameExpr, err := dstName.AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"unable to convert %s to %s",
				sourceEndpoint.Name(),
				destinationEndpoint.Name())
		}

		if dstEnum.NeedsMappingConversion(dstName) {
			// We need to use the values mapping to convert the value in a case-insensitive way

			mapperID := dstEnum.MapperVariableName(dstName)
			genruntimePkg := generationContext.MustGetImportedPackageName(astmodel.GenRuntimeReference)

			// genruntime.ToEnum(<actualReader>, <mapperId>)
			toEnum := astbuilder.CallQualifiedFunc(
				genruntimePkg,
				"ToEnum",
				actualReader,
				dst.NewIdent(mapperID))

			convert, err := conversion(
				toEnum,
				writer,
				knownLocals,
				generationContext)
			if err != nil {
				return nil, eris.Wrapf(
					err,
					"unable to convert %s to %s",
					sourceEndpoint.Name(),
					destinationEndpoint.Name())
			}

			return astbuilder.Statements(cacheOriginal, convert), nil
		}

		// Otherwise we just do a direct cast
		castingWriter := func(expr dst.Expr) []dst.Stmt {
			cast := &dst.CallExpr{
				Fun:  dstNameExpr,
				Args: []dst.Expr{expr},
			}
			return writer(cast)
		}

		return conversion(
			reader,
			castingWriter,
			knownLocals,
			generationContext)
	}, nil
}

// assignPrimitiveFromPrimitive will generate a direct assignment if both definitions have the
// same primitive type and are not optional
//
// <destination> = <source>
func assignPrimitiveFromPrimitive(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	_ *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require both source and destination to be non-optional
	if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be a primitive type
	sourcePrimitive, sourceIsPrimitive := astmodel.AsPrimitiveType(sourceEndpoint.Type())
	if !sourceIsPrimitive {
		return nil, nil
	}

	// Require destination to be a primitive type
	destinationPrimitive, destinationIsPrimitive := astmodel.AsPrimitiveType(destinationEndpoint.Type())
	if !destinationIsPrimitive {
		return nil, nil
	}

	// Require both properties to have the same primitive type
	if !astmodel.TypeEquals(sourcePrimitive, destinationPrimitive) {
		return nil, nil
	}

	return directAssignmentPropertyConversion, nil
}

// assignAliasedPrimitiveFromAliasedPrimitive will generate a direct assignment if both
// definitions have the same underlying primitive type and are not optional
//
// <destination> = <cast>(<source>)
func assignAliasedPrimitiveFromAliasedPrimitive(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require both source and destination to be non-optional
	if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be a name that resolves to a primitive type
	_, sourceType, ok := conversionContext.ResolveType(sourceEndpoint.Type())
	if !ok {
		return nil, nil
	}
	sourcePrimitive, sourceIsPrimitive := astmodel.AsPrimitiveType(sourceType)
	if !sourceIsPrimitive {
		return nil, nil
	}

	// Require destination to be a name the resolves to a primitive type
	destinationName, destinationType, ok := conversionContext.ResolveType(destinationEndpoint.Type())
	if !ok {
		return nil, nil
	}
	destinationPrimitive, destinationIsPrimitive := astmodel.AsPrimitiveType(destinationType)
	if !destinationIsPrimitive {
		return nil, nil
	}

	// Require both properties to have the same primitive type
	if !astmodel.TypeEquals(sourcePrimitive, destinationPrimitive) {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		destinationNameExpr, err := destinationName.AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(err, "creating destination expression")
		}

		return writer(&dst.CallExpr{
			Fun:  destinationNameExpr,
			Args: []dst.Expr{reader},
		}), nil
	}, nil
}

// assignFromAliasedType will convert an alias of a type into that type
// type as long as it is not optional and is not an alias to an object type.
func assignFromAliasedType(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require source to not be a bag item
	if sourceEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require source to be non-optional
	if sourceEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be a name that resolves to a non-object type
	_, sourceType, ok := conversionContext.ResolveType(sourceEndpoint.Type())
	if !ok {
		return nil, nil
	}
	if _, ok = astmodel.AsObjectType(sourceType); ok {
		// Don't match objects, only other aliases - no objects aliases exist because they are removed by
		// the RemoveTypeAliases pipeline stage, so if ResolveType results in an object then we have a normal
		// TypeName -> Object, which is not an alias at all.
		return nil, nil
	}

	// Require a conversion for the underlying type
	sourceType = astmodel.Unwrap(sourceType) // Unwrap to avoid any validations
	updatedSourceEndpoint := sourceEndpoint.WithType(sourceType)
	conversion, err := CreateTypeConversion(updatedSourceEndpoint, destinationEndpoint, conversionContext)
	if err != nil {
		return nil, err
	}
	if conversion == nil {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		sourceTypeExpr, err := sourceType.AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(err, "creating source expression")
		}

		actualReader := &dst.CallExpr{
			Fun:  sourceTypeExpr,
			Args: []dst.Expr{reader},
		}

		return conversion(actualReader, writer, knownLocals, generationContext)
	}, nil
}

// assignToAliasedType will convert a value into the aliased type as long as it
// is not optional and the alias is not to an object type.
//
// <destination> = <cast>(<source>)
func assignToAliasedType(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require destination to not be a bag item
	if destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require destination to be non-optional
	if destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require destination to be a name the resolves to a non-object type
	destinationName, destinationType, ok := conversionContext.ResolveType(destinationEndpoint.Type())
	if !ok {
		return nil, nil
	}
	if _, ok = astmodel.AsObjectType(destinationType); ok {
		// Don't match objects, only other aliases - no objects aliases exist because they are removed by
		// the RemoveTypeAliases pipeline stage, so if ResolveType results in an object then we have a normal
		// TypeName -> Object, which is not an alias at all.
		return nil, nil
	}

	// Require a conversion for the underlying type
	destinationType = astmodel.Unwrap(destinationType) // Unwrap to avoid any validations
	updatedDestinationEndpoint := destinationEndpoint.WithType(destinationType)
	conversion, err := CreateTypeConversion(sourceEndpoint, updatedDestinationEndpoint, conversionContext)
	if err != nil {
		return nil, err
	}
	if conversion == nil {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		destinationNameExpr, err := destinationName.AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(err, "creating destination expression")
		}

		actualWriter := func(expr dst.Expr) []dst.Stmt {
			castToAlias := &dst.CallExpr{
				Fun:  destinationNameExpr,
				Args: []dst.Expr{expr},
			}

			return writer(castToAlias)
		}

		return conversion(reader, actualWriter, knownLocals, generationContext)
	}, nil
}

// handCraftedConversion represents a hand-coded conversion
// this can be used to share code for common conversions (e.g. []string → []string)
type handCraftedConversion struct {
	fromType astmodel.Type
	toType   astmodel.Type

	implPackage astmodel.PackageReference
	implFunc    string
}

var handCraftedConversions = []handCraftedConversion{
	{
		fromType:    astmodel.NewMapType(astmodel.StringType, astmodel.StringType),
		toType:      astmodel.NewMapType(astmodel.StringType, astmodel.StringType),
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "CloneMapOfStringToString",
	},
	{
		fromType:    astmodel.NewArrayType(astmodel.StringType),
		toType:      astmodel.NewArrayType(astmodel.StringType),
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "CloneSliceOfString",
	},
	{
		fromType:    astmodel.NewArrayType(astmodel.ConditionType),
		toType:      astmodel.NewArrayType(astmodel.ConditionType),
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "CloneSliceOfCondition",
	},
	{
		fromType:    astmodel.OptionalIntType,
		toType:      astmodel.OptionalIntType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "ClonePointerToInt",
	},
	{
		fromType:    astmodel.OptionalStringType,
		toType:      astmodel.OptionalStringType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "ClonePointerToString",
	},
	{
		fromType:    astmodel.OptionalStringType,
		toType:      astmodel.StringType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "GetOptionalStringValue",
	},
	{
		fromType:    astmodel.OptionalIntType,
		toType:      astmodel.IntType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "GetOptionalIntValue",
	},
	{
		fromType:    astmodel.StringType,
		toType:      astmodel.ResourceReferenceType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "CreateResourceReferenceFromARMID",
	},
	{
		fromType:    astmodel.StringType,
		toType:      astmodel.WellKnownResourceReferenceType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "CreateWellKnownResourceReferenceFromARMID",
	},
	{
		fromType:    astmodel.FloatType,
		toType:      astmodel.IntType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "GetIntFromFloat",
	},
	{
		fromType:    astmodel.JSONType,
		toType:      astmodel.StringType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "ConvertJSONToString",
	},
	{
		fromType:    astmodel.StringType,
		toType:      astmodel.JSONType,
		implPackage: astmodel.GenRuntimeReference,
		implFunc:    "ConvertStringToJSON",
	},
}

func assignHandcraftedImplementations(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Search for a handcrafted conversion to use
	conversionFound := false
	var conversion handCraftedConversion
	for _, impl := range handCraftedConversions {
		sourceType := sourceEndpoint.Type()
		if vt, ok := astmodel.AsValidatedType(sourceType); ok {
			// If the source is a validated type, we need to use the underlying type
			sourceType = vt.ElementType()
		}

		destinationType := destinationEndpoint.Type()
		if vt, ok := astmodel.AsValidatedType(destinationType); ok {
			// If the destination is a validated type, we need to use the underlying type
			destinationType = vt.ElementType()
		}

		if astmodel.TypeEquals(sourceType, impl.fromType) &&
			astmodel.TypeEquals(destinationType, impl.toType) {
			conversion = impl
			conversionFound = true
			break
		}
	}

	if !conversionFound {
		// No handcrafted conversion found
		return nil, nil
	}

	// Make sure all the necessary packages are referenced
	if ftn, ok := astmodel.AsTypeName(conversion.fromType); ok {
		// Include a reference to the package our from type is found in
		conversionContext.AddPackageReference(ftn.PackageReference())
	}

	if ttn, ok := astmodel.AsTypeName(conversion.toType); ok {
		// Include a reference to the package our to type is found in
		conversionContext.AddPackageReference(ttn.PackageReference())
	}

	// Include a reference to the package our implementation is found in
	conversionContext.AddPackageReference(conversion.implPackage)

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		pkg := generationContext.MustGetImportedPackageName(conversion.implPackage)
		return writer(astbuilder.CallQualifiedFunc(pkg, conversion.implFunc, reader)), nil
	}, nil
}

// forbiddenConversion represents a conversion that we know we shouldn't even attempt to do
// Encountering one isn't a fatal error, but it does mean we can't generate a conversion
type forbiddenConversion struct {
	fromType astmodel.Type
	toType   astmodel.Type
}

var forbiddenConversions = []forbiddenConversion{
	{
		// Can't use a string (the value of a secret) to initialize a secret reference (pointing to the value source)
		// We encounter this when initializing the spec of a resource from its status
		fromType: astmodel.StringType,
		toType:   astmodel.SecretReferenceType,
	},
	{
		// Can't use a map[string]string (the value of a secret) to initialize a secret reference (pointing to the value source)
		// We encounter this when initializing the spec of a resource from its status
		fromType: astmodel.MapOfStringStringType,
		toType:   astmodel.SecretMapReferenceType,
	},
}

// neuterForbiddenConversions is a conversion factory that will return a conversion that does nothing if we encounter a
// forbidden conversion
func neuterForbiddenConversions(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	_ *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	for _, forbidden := range forbiddenConversions {
		if astmodel.TypeEquals(sourceEndpoint.Type(), forbidden.fromType) &&
			astmodel.TypeEquals(destinationEndpoint.Type(), forbidden.toType) {
			return func(
				reader dst.Expr,
				writer func(dst.Expr) []dst.Stmt,
				knownLocals *astmodel.KnownLocalsSet,
				generationContext *astmodel.CodeGenerationContext,
			) ([]dst.Stmt, error) {
				return nil, nil
			}, nil
		}
	}

	return nil, nil
}

// assignArrayFromArray will generate a code fragment to populate an array, assuming the
// underlying definitions of the two arrays are compatible
//
// <arr> := make([]<type>, len(<reader>))
//
//	for <index>, <value> := range <reader> {
//	    <arr>[<index>] := <value> // Or other conversion as required
//	}
//
// <writer> = <arr>
func assignArrayFromArray(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require source to be an array type
	sourceArray, sourceIsArray := astmodel.AsArrayType(sourceEndpoint.Type())
	if !sourceIsArray {
		return nil, nil
	}

	// Require destination to be an array type
	destinationArray, destinationIsArray := astmodel.AsArrayType(destinationEndpoint.Type())
	if !destinationIsArray {
		return nil, nil
	}

	// Require a conversion between the array definitions
	unwrappedSourceEndpoint := sourceEndpoint.WithType(sourceArray.Element())
	unwrappedDestinationEndpoint := destinationEndpoint.WithType(destinationArray.Element())
	conversion, err := CreateTypeConversion(
		unwrappedSourceEndpoint,
		unwrappedDestinationEndpoint,
		conversionContext)
	if err != nil {
		return nil, eris.Wrapf(
			err,
			"finding array conversion from %s to %s",
			astmodel.DebugDescription(sourceEndpoint.Type()),
			astmodel.DebugDescription(destinationEndpoint.Type()))
	}
	if conversion == nil {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		var cacheOriginal dst.Stmt
		var actualReader dst.Expr

		// If the value we're reading is a local or a field, it's cheap to read and we can skip
		// using a local (which makes the generated code easier to read). In other cases, we want
		// to cache the value in a local to avoid repeating any expensive conversion.

		switch reader.(type) {
		case *dst.Ident, *dst.SelectorExpr:
			// reading a local variable or a field
			cacheOriginal = nil
			actualReader = reader
		default:
			// Something else, so we cache the original
			local := knownLocals.CreateSingularLocal(sourceEndpoint.Name(), "", "Cache")
			cacheOriginal = astbuilder.ShortDeclaration(local, reader)
			actualReader = dst.NewIdent(local)
		}

		checkForNil := astbuilder.AreNotEqual(actualReader, astbuilder.Nil())

		// We create three obviously related identifiers to use for the array conversion
		// The List is created in the current knownLocals scope because we need it after the loop completes.
		// The other two are created in a nested knownLocals scope because they're only needed within the loop; this
		// ensures any other locals needed for the conversion don't leak out into our main scope.
		// These suffixes must not overlap with those used for map conversion. (If these suffixes overlap, the naming
		// becomes difficult to read when converting maps containing slices or vice versa.)
		branchLocals := knownLocals.Clone()
		tempID := branchLocals.CreateSingularLocal(sourceEndpoint.Name(), "List")
		loopLocals := branchLocals.Clone() // Clone after tempId is created so that it's visible within the loop
		itemID := loopLocals.CreateSingularLocal(sourceEndpoint.Name(), "Item")
		indexID := loopLocals.CreateSingularLocal(sourceEndpoint.Name(), "Index")

		destinationArrayExpr, err := destinationArray.AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"converting %s to %s",
				sourceEndpoint.Name(),
				destinationEndpoint.Name())
		}

		declaration := astbuilder.ShortDeclaration(
			tempID,
			astbuilder.MakeSlice(destinationArrayExpr, astbuilder.CallFunc("len", actualReader)))

		writeToElement := func(expr dst.Expr) []dst.Stmt {
			return astbuilder.Statements(
				astbuilder.SimpleAssignment(
					&dst.IndexExpr{
						X:     dst.NewIdent(tempID),
						Index: dst.NewIdent(indexID),
					},
					expr),
			)
		}

		elemConv, err := conversion(dst.NewIdent(itemID), writeToElement, loopLocals, generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"creating conversion for array element from %s to %s",
				astmodel.DebugDescription(sourceEndpoint.Type()),
				astmodel.DebugDescription(destinationEndpoint.Type()))
		}

		assignValue := writer(dst.NewIdent(tempID))
		loop := astbuilder.IterateOverSliceWithIndex(indexID, itemID, reader, elemConv...)
		trueBranch := astbuilder.Statements(declaration, loop, assignValue)

		assignZero := writer(astbuilder.Nil())

		return astbuilder.Statements(
			cacheOriginal,
			astbuilder.SimpleIfElse(checkForNil, trueBranch, assignZero)), nil
	}, nil
}

// assignMapFromMap will generate a code fragment to populate an array, assuming the
// underlying definitions of the two arrays are compatible
//
//	if <reader> != nil {
//	    <map> := make(map[<key>]<type>)
//	    for key, <item> := range <reader> {
//	        <map>[<key>] := <item>
//	    }
//	    <writer> = <map>
//	} else {
//	    <writer> = <zero>
//	}
func assignMapFromMap(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require source to be a map
	sourceMap, sourceIsMap := astmodel.AsMapType(sourceEndpoint.Type())
	if !sourceIsMap {
		// Source is not a map
		return nil, nil
	}

	// Require destination to be a map
	destinationMap, destinationIsMap := astmodel.AsMapType(destinationEndpoint.Type())
	if !destinationIsMap {
		// Destination is not a map
		return nil, nil
	}

	// Require map keys to be identical
	if !astmodel.TypeEquals(sourceMap.KeyType(), destinationMap.KeyType()) {
		// Keys are different definitions
		return nil, nil
	}

	// Require a conversion between the map items
	unwrappedSourceEndpoint := sourceEndpoint.WithType(sourceMap.ValueType())
	unwrappedDestinationEndpoint := destinationEndpoint.WithType(destinationMap.ValueType())
	conversion, err := CreateTypeConversion(
		unwrappedSourceEndpoint,
		unwrappedDestinationEndpoint,
		conversionContext)
	if err != nil {
		return nil, eris.Wrapf(
			err,
			"finding map conversion from %s to %s",
			astmodel.DebugDescription(sourceEndpoint.Type()),
			astmodel.DebugDescription(destinationEndpoint.Type()))
	}
	if conversion == nil {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		var cacheOriginal dst.Stmt
		var actualReader dst.Expr

		// If the value we're reading is a local or a field, it's cheap to read and we can skip
		// using a local (which makes the generated code easier to read). In other cases, we want
		// to cache the value in a local to avoid repeating any expensive conversion.

		switch reader.(type) {
		case *dst.Ident, *dst.SelectorExpr:
			// reading a local variable or a field
			cacheOriginal = nil
			actualReader = reader
		default:
			// Something else, so we cache the original
			local := knownLocals.CreateSingularLocal(sourceEndpoint.Name(), "", "Cache")
			cacheOriginal = astbuilder.ShortDeclaration(local, reader)
			actualReader = dst.NewIdent(local)
		}

		checkForNil := astbuilder.AreNotEqual(actualReader, astbuilder.Nil())

		// We create three obviously related identifiers to use for the conversion.
		// These are all within the scope of the true branch of our if statement
		// The Map is created in the current knownLocals scope because we need it after the loop completes.
		// The other two are created in a nested knownLocals scope because they're only needed within the loop; this
		// ensures any other locals needed for the conversion don't leak out into our main scope.
		// These suffixes must not overlap with those used for array conversion. (If these suffixes overlap, the naming
		// becomes difficult to read when converting maps containing slices or vice versa.)
		branchLocals := knownLocals.Clone()
		tempID := branchLocals.CreateSingularLocal(sourceEndpoint.Name(), "Map")
		loopLocals := branchLocals.Clone() // Clone after tempId is created so that it's visible within the loop
		itemID := loopLocals.CreateSingularLocal(sourceEndpoint.Name(), "Value")
		keyID := loopLocals.CreateSingularLocal(sourceEndpoint.Name(), "Key")

		keyTypeExpr, err := destinationMap.KeyType().AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"creating map key type expression for %s",
				astmodel.DebugDescription(destinationMap.KeyType()))
		}

		valueTypeExpr, err := destinationMap.ValueType().AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"creating map value type expression for %s",
				astmodel.DebugDescription(destinationMap.ValueType()))
		}

		declaration := astbuilder.ShortDeclaration(
			tempID,
			astbuilder.MakeMapWithCapacity(
				keyTypeExpr,
				valueTypeExpr,
				astbuilder.CallFunc("len", actualReader)))

		assignToItem := func(expr dst.Expr) []dst.Stmt {
			return astbuilder.Statements(
				astbuilder.SimpleAssignment(
					&dst.IndexExpr{
						X:     dst.NewIdent(tempID),
						Index: dst.NewIdent(keyID),
					},
					expr),
			)
		}

		elemConv, err := conversion(dst.NewIdent(itemID), assignToItem, loopLocals, generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"creating map item conversion from %s to %s",
				astmodel.DebugDescription(sourceMap.ValueType()),
				astmodel.DebugDescription(destinationMap.ValueType()))
		}

		loop := astbuilder.IterateOverMapWithValue(keyID, itemID, actualReader, elemConv...)
		assignMap := writer(dst.NewIdent(tempID))
		trueBranch := astbuilder.Statements(declaration, loop, assignMap)

		assignNil := writer(astbuilder.Nil())

		return astbuilder.Statements(
			cacheOriginal,
			astbuilder.SimpleIfElse(checkForNil, trueBranch, assignNil)), nil
	}, nil
}

// assignUserAssignedIdentityMapFromArray will generate a code fragment to populate a userAssignedIdentity array from
// a map whose key is the ARM ID of the userAssignedIdentity
//
//	if source.UserAssignedIdentities != nil {
//	    <arr> := make([]<arrType>, 0, len(source.UserAssignedIdentities))
//	    for key, _ := range source.UserAssignedIdentities {
//	        ref := genruntime.CreateResourceReferenceFromARMID(key)
//	        <arr> = append(<arr>, UserAssignedIdentityDetails{Reference: ref})
//	    }
//	    <writer> = <arr>
//	} else {
//	   <writer> = nil
//	}
func assignUserAssignedIdentityMapFromArray(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// There's no conversion in the other direction (array -> map) for this because property_conversions only deals with:
	// 1. Conversions between storage types, where UserAssignedIdentity's are arrays on both sides and don't need
	//    special handling.
	// 2. Conversions from Status -> Spec, which is the direction that this conversion method deals with.

	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require source to be a map type
	sourceMapType, sourceIsMap := astmodel.AsMapType(sourceEndpoint.Type())
	if !sourceIsMap {
		return nil, nil
	}

	// Require destination to be an array type
	destinationArray, destinationIsArray := astmodel.AsArrayType(destinationEndpoint.Type())
	if !destinationIsArray {
		return nil, nil
	}

	// Require the source endpoint to have the expected property name
	if sourceEndpoint.Name() != astmodel.UserAssignedIdentitiesProperty {
		return nil, nil
	}

	// Require the destination endpoint to have the expected property name
	if destinationEndpoint.Name() != astmodel.UserAssignedIdentitiesProperty {
		return nil, nil
	}

	// The destination should be a typeName
	destinationElement := destinationArray.Element()
	_, ok := astmodel.AsTypeName(destinationElement)
	if !ok {
		return nil, nil
	}

	// The source map should be map[string]TypeName
	_, ok = astmodel.AsTypeName(sourceMapType.ValueType())
	if sourceMapType.KeyType() != astmodel.StringType || !ok {
		return nil, nil
	}

	conversion, err := CreateTypeConversion(
		sourceEndpoint.WithType(astmodel.StringType),
		destinationEndpoint.WithType(astmodel.ResourceReferenceType),
		conversionContext)
	if err != nil {
		return nil, eris.Wrapf(
			err,
			"finding UserAssignedIdentities conversion from %s to %s",
			astmodel.DebugDescription(astmodel.StringType),
			astmodel.DebugDescription(astmodel.ResourceReferenceType))
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		// <source>List := make([]<type>, 0, len(<source>)
		tempID := knownLocals.CreateSingularLocal(sourceEndpoint.Name(), "List")
		destinationArrayExpr, err := destinationArray.AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"creating UserAssignedIdentities conversion from %s to %s",
				astmodel.DebugDescription(astmodel.StringType),
				astmodel.DebugDescription(astmodel.ResourceReferenceType))
		}

		declaration := astbuilder.ShortDeclaration(
			tempID,
			astbuilder.MakeEmptySlice(destinationArrayExpr, astbuilder.CallFunc("len", reader)))

		loopLocals := knownLocals.Clone()
		keyID := loopLocals.CreateLocal(sourceEndpoint.Name(), "Key")

		intermediateDestination := loopLocals.CreateLocal(destinationEndpoint.Name(), "Ref")
		destinationTypeExpr, err := destinationElement.AsTypeExpr(generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"creating UserAssignedIdentities conversion from %s to %s",
				astmodel.DebugDescription(astmodel.StringType),
				astmodel.DebugDescription(astmodel.ResourceReferenceType))
		}

		uaiBuilder := astbuilder.NewCompositeLiteralBuilder(destinationTypeExpr)
		uaiBuilder.AddField("Reference", dst.NewIdent(intermediateDestination))

		writeToElement := func(expr dst.Expr) []dst.Stmt {
			return astbuilder.Statements(
				astbuilder.ShortDeclaration(intermediateDestination, expr))
		}

		//	for key, _ := range source.UserAssignedIdentities {
		//	   ref := genruntime.CreateResourceReferenceFromARMID(key)
		//	   <arr> = append(<arr>, UserAssignedIdentityDetails{Reference: ref})
		//	}
		elemConv, err := conversion(dst.NewIdent(keyID), writeToElement, loopLocals, generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"creating UserAssignedIdentities conversion from %s to %s",
				astmodel.DebugDescription(astmodel.StringType),
				astmodel.DebugDescription(astmodel.ResourceReferenceType))
		}

		loopBody := astbuilder.Statements(
			elemConv,
			astbuilder.AppendItemToSlice(dst.NewIdent(tempID), uaiBuilder.Build()),
		)
		loop := astbuilder.IterateOverMapWithKey(
			keyID,
			reader,
			loopBody...,
		)

		// if source.UserAssignedIdentities != nil
		checkForNil := astbuilder.AreNotEqual(reader, astbuilder.Nil())

		// <writer> = nil
		assignNil := writer(astbuilder.Nil())

		// <writer> = <arr>
		assignValue := writer(dst.NewIdent(tempID))

		// if source.UserAssignedIdentities != nil {
		//     <loop>
		// } else {
		//     <writer> = nil
		// }
		trueBranch := astbuilder.Statements(
			declaration,
			loop,
			assignValue)

		return astbuilder.Statements(
			astbuilder.SimpleIfElse(checkForNil, trueBranch, assignNil)), nil
	}, nil
}

// assignEnumFromEnum will generate a conversion if both definitions have the same underlying
// primitive type and neither source nor destination is optional
//
// <local> = <baseType>(<source>)
// <destination> = <enum>(<local>)
//
// We don't technically need this one, but it generates nicer code because it bypasses an unnecessary cast.
func assignEnumFromEnum(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require both source and destination to be non-optional
	if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be an enumeration
	_, sourceType, sourceFound := conversionContext.ResolveType(sourceEndpoint.Type())
	if !sourceFound {
		return nil, nil
	}
	sourceEnum, sourceIsEnum := astmodel.AsEnumType(sourceType)
	if !sourceIsEnum {
		return nil, nil
	}

	// Require destination to be an enumeration
	destinationName, destinationType, destinationFound := conversionContext.ResolveType(destinationEndpoint.Type())
	if !destinationFound {
		return nil, nil
	}
	destinationEnum, destinationIsEnum := astmodel.AsEnumType(destinationType)
	if !destinationIsEnum {
		return nil, nil
	}

	// Require enumerations to have the same base definitions
	if !astmodel.TypeEquals(sourceEnum.BaseType(), destinationEnum.BaseType()) {
		return nil, eris.Errorf(
			"no conversion from %s to %s",
			astmodel.DebugDescription(sourceEnum.BaseType()),
			astmodel.DebugDescription(destinationEnum.BaseType()))
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		local := knownLocals.CreateSingularLocal(destinationEndpoint.Name(), "", "As"+destinationName.Name(), "Value")

		var declare dst.Stmt
		if destinationEnum.NeedsMappingConversion(destinationName) {
			// We need to use the values mapping to convert the value in a case-insensitive manner

			mapperID := destinationEnum.MapperVariableName(destinationName)
			genruntimePkg := generationContext.MustGetImportedPackageName(astmodel.GenRuntimeReference)

			// genruntime.ToEnum(<actualReader>, <mapperId>)
			toEnum := astbuilder.CallQualifiedFunc(
				genruntimePkg,
				"ToEnum",
				astbuilder.CallFunc("string", reader),
				dst.NewIdent(mapperID))

			declare = astbuilder.ShortDeclaration(local, toEnum)
		} else {
			// No conversion required
			declare = astbuilder.ShortDeclaration(local, astbuilder.CallFunc(destinationName.Name(), reader))
		}

		write := writer(dst.NewIdent(local))

		return astbuilder.Statements(
			declare,
			write,
		), nil
	}, nil
}

// assignPrimitiveFromEnum will generate a conversion from an enumeration if the
// destination has the underlying base type of the enumeration and neither source nor destination
// is optional
//
// <local> = <baseType>(<source>)
// <destination> = <enum>(<local>)
func assignPrimitiveFromEnum(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require both source and destination to be non-optional
	if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be an enumeration
	_, srcType, ok := conversionContext.ResolveType(sourceEndpoint.Type())
	if !ok {
		return nil, nil
	}
	srcEnum, srcIsEnum := astmodel.AsEnumType(srcType)
	if !srcIsEnum {
		return nil, nil
	}

	// Require destination to be a primitive type
	dstPrimitive, ok := astmodel.AsPrimitiveType(destinationEndpoint.Type())
	if !ok {
		return nil, nil
	}

	// Require enumeration to have the destination as base type
	if !astmodel.TypeEquals(srcEnum.BaseType(), dstPrimitive) {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		return writer(astbuilder.CallFunc(dstPrimitive.Name(), reader)), nil
	}, nil
}

// assignObjectDirectlyFromObject will generate a conversion if both properties are TypeNames referencing ObjectType
// definitions, neither property is optional, and the types are adjacent in our storage conversion graph.
//
// var <local> <destinationType>
// err := <local>.AssignPropertiesFrom(<source>)
//
//	if err != nil {
//	    return errors.Wrap(err, "while calling <local>.AssignPropertiesFrom(<source>)")
//	}
//
// <destination> = <local>
func assignObjectDirectlyFromObject(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require expected direction
	if conversionContext.direction != ConvertFrom {
		return nil, nil
	}

	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require both source and destination to be non-optional
	if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be the name of an object
	sourceName, sourceType, sourceFound := conversionContext.ResolveType(sourceEndpoint.Type())
	if !sourceFound {
		return nil, nil
	}
	if _, sourceIsObject := astmodel.AsObjectType(sourceType); !sourceIsObject {
		return nil, nil
	}

	// Require destination to be the name of an object
	destinationName, destinationType, destinationFound := conversionContext.ResolveType(destinationEndpoint.Type())
	if !destinationFound {
		return nil, nil
	}
	_, destinationIsObject := astmodel.AsObjectType(destinationType)
	if !destinationIsObject {
		return nil, nil
	}

	// If the source and destination types are in different packages, we must consult the conversion graph to make sure
	// this is an expected conversion.
	if !sourceName.PackageReference().Equals(destinationName.PackageReference()) {

		// If our two types are not adjacent in our conversion graph, this is not the conversion you're looking for
		nextType, err := conversionContext.FindNextType(destinationName)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"looking up next type for %s",
				astmodel.DebugDescription(destinationEndpoint.Type()))
		}

		if !nextType.IsEmpty() && !astmodel.TypeEquals(nextType, sourceName) {
			return nil, nil
		}

		// If the two definitions have different names, require an explicit rename from one to the other
		//
		// Challenge: If we can detect incorrect renaming configuration here, why do we need that configuration at all?
		// Answer: Because we need to use that configuration other places (such as ConversionGraph) where we don't have
		// the right information to infer correctly.
		//
		if sourceName.Name() != destinationName.Name() {
			err := conversionContext.validateTypeRename(sourceName, destinationName)
			if err != nil {
				return nil, err
			}
		}
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		copyVar := knownLocals.CreateSingularLocal(destinationEndpoint.Name(), "", "Local", "Copy", "Temp")

		// We have to do this at render time in order to ensure the first conversion generated
		// declares 'err', not a later one
		tok := token.ASSIGN
		if knownLocals.TryCreateLocal("err") {
			tok = token.DEFINE
		}

		localID := dst.NewIdent(copyVar)
		errLocal := dst.NewIdent("err")

		errorsPackageName := generationContext.MustGetImportedPackageName(astmodel.ErisReference)

		declaration := astbuilder.LocalVariableDeclaration(copyVar, createTypeDeclaration(destinationName, generationContext), "")

		functionName := NameOfPropertyAssignmentFunction(
			conversionContext.FunctionBaseName(), sourceName, ConvertFrom, conversionContext.idFactory)

		conversion := astbuilder.AssignmentStatement(
			errLocal,
			tok,
			astbuilder.CallExpr(localID, functionName, astbuilder.AsReference(reader)))

		checkForError := astbuilder.ReturnIfNotNil(
			errLocal,
			astbuilder.WrappedErrorf(
				errorsPackageName,
				"calling %s() to %s",
				functionName,
				describeAssignment(sourceEndpoint, destinationEndpoint)))

		assignment := writer(localID)
		return astbuilder.Statements(declaration, conversion, checkForError, assignment), nil
	}, nil
}

// assignObjectDirectlyToObject will generate a conversion if both properties are TypeNames referencing ObjectType
// definitions, neither property is optional, and the types are adjacent in our storage conversion graph.
//
// var <local> <destinationType>
// err := <source>.AssignPropertiesTo(&<local>)
//
//	if err != nil {
//	    return errors.Wrap(err, "while calling <local>.AssignPropertiesTo(<source>)")
//	}
//
// <destination> = <local>
func assignObjectDirectlyToObject(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require expected direction
	if conversionContext.direction != ConvertTo {
		return nil, nil
	}

	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require both source and destination to be non-optional
	if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be the name of an object
	sourceName, sourceType, sourceFound := conversionContext.ResolveType(sourceEndpoint.Type())
	if !sourceFound {
		return nil, nil
	}
	if _, sourceIsObject := astmodel.AsObjectType(sourceType); !sourceIsObject {
		return nil, nil
	}

	// Require destination to be the name of an object
	destinationName, destinationType, destinationFound := conversionContext.ResolveType(destinationEndpoint.Type())
	if !destinationFound {
		return nil, nil
	}
	_, destinationIsObject := astmodel.AsObjectType(destinationType)
	if !destinationIsObject {
		return nil, nil
	}

	// If the source and destination types are in different packages, we must consult the conversion graph to make sure
	// this is an expected conversion.
	if !sourceName.PackageReference().Equals(destinationName.PackageReference()) {

		// If our two types are not adjacent in our conversion graph, this is not the conversion you're looking for
		// Check that
		nextType, err := conversionContext.FindNextType(sourceName)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"looking up next type for %s",
				astmodel.DebugDescription(sourceEndpoint.Type()))
		}

		if !nextType.IsEmpty() && !astmodel.TypeEquals(nextType, destinationName) {
			return nil, nil
		}

		// If the two definitions have different names, require an explicit rename from one to the other
		//
		// Challenge: If we can detect incorrect renaming configuration here, why do we need that configuration at all?
		// Answer: Because we need to use that configuration other places (such as ConversionGraph) where we don't have
		// the right information to infer correctly.
		//
		if sourceName.Name() != destinationName.Name() {
			err := conversionContext.validateTypeRename(sourceName, destinationName)
			if err != nil {
				return nil, err
			}
		}
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		copyVar := knownLocals.CreateSingularLocal(destinationEndpoint.Name(), "", "Local", "Copy", "Temp")

		// We have to do this at render time in order to ensure the first conversion generated
		// declares 'err', not a later one
		tok := token.ASSIGN
		if knownLocals.TryCreateLocal("err") {
			tok = token.DEFINE
		}

		localID := dst.NewIdent(copyVar)
		errLocal := dst.NewIdent("err")

		errorsPackageName := generationContext.MustGetImportedPackageName(astmodel.ErisReference)

		declaration := astbuilder.LocalVariableDeclaration(copyVar, createTypeDeclaration(destinationName, generationContext), "")

		functionName := NameOfPropertyAssignmentFunction(
			conversionContext.FunctionBaseName(), destinationName, ConvertTo, conversionContext.idFactory)
		conversion := astbuilder.AssignmentStatement(
			errLocal,
			tok,
			astbuilder.CallExpr(reader, functionName, astbuilder.AddrOf(localID)))

		checkForError := astbuilder.ReturnIfNotNil(
			errLocal,
			astbuilder.WrappedErrorf(
				errorsPackageName,
				"calling %s() to %s",
				functionName,
				describeAssignment(sourceEndpoint, destinationEndpoint)))

		assignment := writer(dst.NewIdent(copyVar))
		return astbuilder.Statements(declaration, conversion, checkForError, assignment), nil
	}, nil
}

// assignInlineObjectsViaIntermediateObject will generate a conversion if both properties are TypeNames referencing ObjectType
// definitions, neither property is optional, the types are NOT adjacent in our storage conversion graph, and they are
// inline with each other. The conversion is implemented by assigning properties to an intermediate instance before
// assigning those to our actual destination instance.
//
// For ConvertFrom the generated code will be:
//
// var <local> <intermediateType>
// err := <local>.AssignPropertiesFrom(<source>)
//
//	if err != nil {
//	    return errors.Wrap(err, "while calling <local>.AssignPropertiesFrom(<source>)")
//	}
//
// var <otherlocal> <destinationType>
// err := <otherlocal>.AssignPropertiesFrom(<local>)
//
//	if err != nil {
//	    return errors.Wrap(err, "while calling <otherlocal>.AssignPropertiesFrom(<local>)")
//	}
//
// Note the actual steps are generated by nested conversions; this handler works by finding the two conversions needed
// given our intermediate type and chaining them together.
func assignInlineObjectsViaIntermediateObject(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require both source and destination to be non-optional
	if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be the name of an object
	sourceName, sourceType, sourceFound := conversionContext.ResolveType(sourceEndpoint.Type())
	if !sourceFound {
		return nil, nil
	}
	if _, sourceIsObject := astmodel.AsObjectType(sourceType); !sourceIsObject {
		return nil, nil
	}

	// Require destination to be the name of an object
	destinationName, destinationType, destinationFound := conversionContext.ResolveType(destinationEndpoint.Type())
	if !destinationFound {
		return nil, nil
	}
	_, destinationIsObject := astmodel.AsObjectType(destinationType)
	if !destinationIsObject {
		return nil, nil
	}

	// Require a path from one name to the next, and work out an intermediate step to break down the conversion
	var intermediateName astmodel.InternalTypeName
	if conversionContext.PathExists(sourceName, destinationName) {
		var err error
		intermediateName, err = conversionContext.FindNextType(sourceName)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"looking up next type for %s",
				astmodel.DebugDescription(destinationEndpoint.Type()))
		}
	} else if conversionContext.PathExists(destinationName, sourceName) {
		var err error
		intermediateName, err = conversionContext.FindNextType(destinationName)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"looking up next type for %s",
				astmodel.DebugDescription(destinationEndpoint.Type()))
		}
	} else {
		// No path between the two types, we can't handle the required conversion
		return nil, nil
	}

	// If intermediateName is empty, we didn't find an intermediate to use, so this conversion step doesn't apply.
	// If we found either our source or destination as the intermediate type, then the two types are directly
	// convertible and (again), this conversion step doesn't apply.
	if intermediateName.IsEmpty() ||
		astmodel.TypeEquals(intermediateName, sourceName) ||
		astmodel.TypeEquals(intermediateName, destinationName) {
		return nil, nil
	}

	// Make sure we can reference our intermediate type when needed
	conversionContext.AddPackageReference(intermediateName.PackageReference())

	// Need a pair of conversions, using our intermediate type
	intermediateEndpoint := NewTypedConversionEndpoint(
		intermediateName,
		intermediateName.Name()+"Stash")
	firstConversion, err := CreateTypeConversion(sourceEndpoint, intermediateEndpoint, conversionContext)
	if err != nil {
		return nil, eris.Wrapf(
			err,
			"finding first intermediate conversion, from %s to %s",
			astmodel.DebugDescription(sourceName),
			astmodel.DebugDescription(intermediateName))
	}
	if firstConversion == nil {
		return nil, nil
	}

	secondConversion, err := CreateTypeConversion(intermediateEndpoint, destinationEndpoint, conversionContext)
	if err != nil {
		return nil, eris.Wrapf(
			err,
			"finding second intermediate conversion, from %s to %s",
			astmodel.DebugDescription(intermediateName),
			astmodel.DebugDescription(destinationType))
	}

	if secondConversion == nil {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		// We capture the expression written by the first step pass it to the second step,
		// allowing us to avoid extra local variable (this is a bit sneaky, as we rely on assignObjectDirectlyFromObject
		// and assignObjectDirectlyToObject using a local variable themselves.)
		var capture dst.Expr = nil
		capturingWriter := func(expr dst.Expr) []dst.Stmt {
			capture = expr
			return []dst.Stmt{}
		}

		// Capture the first step
		firstStep, err := firstConversion(reader, capturingWriter, knownLocals, generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"converting from %s to %s",
				astmodel.DebugDescription(sourceName),
				astmodel.DebugDescription(intermediateName))
		}

		secondStep, err := secondConversion(capture, writer, knownLocals, generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"converting from %s to %s",
				astmodel.DebugDescription(intermediateName),
				astmodel.DebugDescription(destinationName))
		}

		return astbuilder.Statements(
			firstStep,
			secondStep), nil
	}, nil
}

// assignNonInlineObjectsViaPivotObject will generate a conversion if both properties are TypeNames referencing Object
// Type definitions, neither property is optional, the types are NOT adjacent in our storage conversion graph, and they
// are NOT inline with each other. The conversion is implemented by assigning properties to an intermediate pivot
// instance before assigning those to our actual destination instance.
//
// A key difference between this and assignInlineObjectsViaIntermediateObject is that this handles the case where
// the types are on different branches of the conversion graph, necessitating discovery of the closest shared type
// to use as a pivot. Using this kind of pivot requires reversing the direction of the second half of the conversion!
//
// For ConvertFrom the generated code will be:
//
// var <pivot> <pivotType>
// err := <pivot>.AssignPropertiesFrom(<source>)
//
//	if err != nil {
//	   return errors.Wrap(err, "while calling <pivot>.AssignPropertiesFrom(<source>)")
//	}
//
// var <otherlocal> <destinationType>
// err := <pviot>.AssignPropertiesTo(<otherLocakl>)
//
//	if err != nil {
//	   return errors.Wrap(err, "while calling <otherlocal>.AssignPropertiesFrom(<local>)")
//	}
//
// Note the actual steps are generated by nested conversions; this handler works by finding the two conversions needed
// given our intermediate type and chaining them together.
func assignNonInlineObjectsViaPivotObject(
	sourceEndpoint *TypedConversionEndpoint,
	destinationEndpoint *TypedConversionEndpoint,
	conversionContext *PropertyConversionContext,
) (PropertyConversion, error) {
	// Require both source and destination to not be bag items
	if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
		return nil, nil
	}

	// Require both source and destination to be non-optional
	if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
		return nil, nil
	}

	// Require source to be the name of an object
	sourceName, sourceType, sourceFound := conversionContext.ResolveType(sourceEndpoint.Type())
	if !sourceFound {
		return nil, nil
	}
	if _, sourceIsObject := astmodel.AsObjectType(sourceType); !sourceIsObject {
		return nil, nil
	}

	// Require destination to be the name of an object
	destinationName, destinationType, destinationFound := conversionContext.ResolveType(destinationEndpoint.Type())
	if !destinationFound {
		return nil, nil
	}
	_, destinationIsObject := astmodel.AsObjectType(destinationType)
	if !destinationIsObject {
		return nil, nil
	}

	// Require that there's no direct conversion path between our source and destination types
	if conversionContext.PathExists(sourceName, destinationName) ||
		conversionContext.PathExists(destinationName, sourceName) {
		return nil, nil
	}

	// Find the pivot type; if we can't find one, this conversion doesn't apply
	pivotName, found := conversionContext.FindPivotType(sourceName, destinationName)
	if !found {
		// No pivot found, do nothing
		return nil, nil
	}

	// Make sure we can reference our intermediate type when needed
	conversionContext.AddPackageReference(pivotName.PackageReference())

	// Need a pair of conversions, using our intermediate type.
	// First convert forward to the pivot.
	// We can't call any methods on the pivot type (as it's later in the conversion graph), so we
	// have to use ConvertTo to write to it
	pivotEndpoint := NewTypedConversionEndpoint(
		pivotName,
		pivotName.Name()+"Pivot")
	firstConversion, err := CreateTypeConversion(
		sourceEndpoint,
		pivotEndpoint,
		conversionContext.WithDirection(ConvertTo))
	if err != nil {
		return nil, eris.Wrapf(
			err,
			"finding first intermediate conversion, from %s to %s",
			astmodel.DebugDescription(sourceName),
			astmodel.DebugDescription(pivotName))
	}
	if firstConversion == nil {
		return nil, nil
	}

	// Second conversion needs to run in the opposite direction, using ConvertFrom to read from the pivot
	secondConversion, err := CreateTypeConversion(
		pivotEndpoint,
		destinationEndpoint,
		conversionContext.WithDirection(ConvertFrom))
	if err != nil {
		return nil, eris.Wrapf(
			err,
			"finding second intermediate conversion, from %s to %s",
			astmodel.DebugDescription(pivotName),
			astmodel.DebugDescription(destinationType))
	}

	if secondConversion == nil {
		return nil, nil
	}

	return func(
		reader dst.Expr,
		writer func(dst.Expr) []dst.Stmt,
		knownLocals *astmodel.KnownLocalsSet,
		generationContext *astmodel.CodeGenerationContext,
	) ([]dst.Stmt, error) {
		// We capture the expression written by the first step pass it to the second step,
		// allowing us to avoid extra local variable (this is a bit sneaky, as we rely on assignObjectDirectlyFromObject
		// and assignObjectDirectlyToObject using a local variable themselves.)
		var capture dst.Expr = nil
		capturingWriter := func(expr dst.Expr) []dst.Stmt {
			capture = expr
			return []dst.Stmt{}
		}

		// Capture the first step
		firstStep, err := firstConversion(reader, capturingWriter, knownLocals, generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"converting from %s to %s",
				astmodel.DebugDescription(sourceName),
				astmodel.DebugDescription(pivotName))
		}

		secondStep, err := secondConversion(capture, writer, knownLocals, generationContext)
		if err != nil {
			return nil, eris.Wrapf(
				err,
				"converting from %s to %s",
				astmodel.DebugDescription(pivotName),
				astmodel.DebugDescription(destinationName))
		}

		return astbuilder.Statements(
			firstStep,
			secondStep), nil
	}, nil
}

// assignKnownType will generate an assignment if both definitions have the specified TypeName
//
// <destination> = <source>
//
//nolint:deadcode,unused
func assignKnownType(name astmodel.TypeName) func(*TypedConversionEndpoint, *TypedConversionEndpoint, *PropertyConversionContext) (PropertyConversion, error) {
	return func(sourceEndpoint *TypedConversionEndpoint, destinationEndpoint *TypedConversionEndpoint, _ *PropertyConversionContext) (PropertyConversion, error) {
		// Require both source and destination to not be bag items
		if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
			return nil, nil
		}

		// Require both source and destination to be non-optional
		if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
			return nil, nil
		}

		// Require source to be a named type
		sourceName, sourceIsName := astmodel.AsTypeName(sourceEndpoint.Type())
		if !sourceIsName {
			return nil, nil
		}

		// Require destination to be a named type
		destinationName, destinationIsName := astmodel.AsTypeName(destinationEndpoint.Type())
		if !destinationIsName {
			return nil, nil
		}

		// Require source to be our specific type
		if !astmodel.TypeEquals(sourceName, name) {
			return nil, nil
		}

		// Require destination to be our specific type
		if !astmodel.TypeEquals(destinationName, name) {
			return nil, nil
		}

		return directAssignmentPropertyConversion, nil
	}
}

type knownTypeMethodReturn int

const (
	returnsReference = 0
	returnsValue     = 1
)

// copyKnownType will generate an assignment with the results of a call on the specified TypeName
//
// <destination> = <source>.<methodName>()
func copyKnownType(name astmodel.TypeName, methodName string, returnKind knownTypeMethodReturn) func(*TypedConversionEndpoint, *TypedConversionEndpoint, *PropertyConversionContext) (PropertyConversion, error) {
	return func(sourceEndpoint *TypedConversionEndpoint, destinationEndpoint *TypedConversionEndpoint, _ *PropertyConversionContext) (PropertyConversion, error) {
		// Require both source and destination to not be bag items
		if sourceEndpoint.IsBagItem() || destinationEndpoint.IsBagItem() {
			return nil, nil
		}

		// Require both source and destination to be non-optional
		if sourceEndpoint.IsOptional() || destinationEndpoint.IsOptional() {
			return nil, nil
		}

		// Require source to be a named type
		sourceName, sourceIsName := astmodel.AsTypeName(sourceEndpoint.Type())
		if !sourceIsName {
			return nil, nil
		}

		// Require destination to be a named type
		destinationName, destinationIsName := astmodel.AsTypeName(destinationEndpoint.Type())
		if !destinationIsName {
			return nil, nil
		}

		// Require source to be our specific type
		if !astmodel.TypeEquals(sourceName, name) {
			return nil, nil
		}

		// Require destination to be our specific type
		if !astmodel.TypeEquals(destinationName, name) {
			return nil, nil
		}

		return func(
			reader dst.Expr,
			writer func(dst.Expr) []dst.Stmt,
			knownLocals *astmodel.KnownLocalsSet,
			generationContext *astmodel.CodeGenerationContext,
		) ([]dst.Stmt, error) {
			// If our writer is dereferencing a value, skip that as we don't need to dereference before a method call
			if star, ok := reader.(*dst.StarExpr); ok {
				reader = star.X
			}

			if returnKind == returnsReference {
				// If the copy method returns a ptr, we need to dereference
				// This dereference is always safe because we ensured that both source and destination are always
				// non-optional. The handler assignToOptional() should do the right thing when this happens.
				return writer(astbuilder.Dereference(astbuilder.CallExpr(reader, methodName))), nil
			}

			return writer(astbuilder.CallExpr(reader, methodName)), nil
		}, nil
	}
}

func createTypeDeclaration(
	name astmodel.InternalTypeName,
	generationContext *astmodel.CodeGenerationContext,
) dst.Expr {
	if name.InternalPackageReference().Equals(generationContext.CurrentPackage()) {
		return dst.NewIdent(name.Name())
	}

	packageName := generationContext.MustGetImportedPackageName(name.InternalPackageReference())
	return astbuilder.Selector(dst.NewIdent(packageName), name.Name())
}

func describeAssignment(sourceEndpoint *TypedConversionEndpoint, destinationEndpoint *TypedConversionEndpoint) string {
	if sourceEndpoint.Name() != destinationEndpoint.Name() {
		return fmt.Sprintf("populate field %s from %s", destinationEndpoint.Name(), sourceEndpoint.Name())
	}

	return fmt.Sprintf("populate field %s", destinationEndpoint.Name())
}
