package viaduct.api.internal

import graphql.schema.GraphQLCompositeType
import graphql.schema.GraphQLEnumType
import graphql.schema.GraphQLFieldDefinition
import graphql.schema.GraphQLList
import graphql.schema.GraphQLObjectType
import graphql.schema.GraphQLScalarType
import graphql.schema.GraphQLType
import graphql.schema.GraphQLTypeUtil
import viaduct.api.ViaductFrameworkException
import viaduct.api.globalid.GlobalID
import viaduct.api.globalid.GlobalIDCodec
import viaduct.engine.api.EngineObject
import viaduct.engine.api.EngineObjectData
import viaduct.engine.api.EngineObjectDataBuilder

/**
 * A wrapper around EngineObjectDataBuilder that allows for setting fields by name for a given GraphQLObjectType.
 * This class is used by [ObjectBase.Builder] and [ViaductObjectBuilder] to build their underlying EngineObjectData.
 * When setting a field, the value is unwrapped to the appropriate type, e.g.,
 * unwrapping from the GRT to the EngineObjectData for GraphQLCompositeType fields.
 */
internal class EODBuilderWrapper(
    private val graphQLObjectType: GraphQLObjectType,
    private val globalIDCodec: GlobalIDCodec
) {
    private val engineObjectDataBuilder = EngineObjectDataBuilder.from(graphQLObjectType)

    fun getEngineObjectData() = engineObjectDataBuilder.build()

    /**
     * Either called by static builders which are code generated by framework,
     * which has no need to do comprehensive type checking, or called by dynamic
     * builders, which type checking has been done at the call site. Therefore,
     * we only use the minimum type checking needed for `unwrap` here for
     * efficiency reason.
     *
     * @param alias - Only used for use cases of unit tests needing to mock aliased results.
     *                Not to be used for production code or normal engine usage!
     */
    fun put(
        fieldName: String,
        value: Any?,
        alias: String? = null
    ) {
        val field = graphQLObjectType.getField(fieldName)
        val type = field.type ?: throw IllegalArgumentException(
            "Field ${field.name} not found on type ${graphQLObjectType.name}"
        )
        engineObjectDataBuilder.put(alias ?: fieldName, unwrap(field, type, value))
    }

    private fun unwrap(
        field: GraphQLFieldDefinition,
        type: GraphQLType,
        value: Any?
    ): Any? {
        if (value == null) {
            if (GraphQLTypeUtil.isNonNull(type)) {
                throw IllegalArgumentException(
                    "Got null builder value for non-null type ${GraphQLTypeUtil.simplePrint(type)}"
                )
            }
            return null
        }

        return when (val unwrappedType = GraphQLTypeUtil.unwrapNonNull(type)) {
            is GraphQLScalarType ->
                if (isGlobalID(field, graphQLObjectType)) {
                    unwrapGlobalID(value)
                } else {
                    value
                }

            is GraphQLEnumType -> unwrapEnum(value)
            is GraphQLList -> unwrapList(field, unwrappedType, value)
            is GraphQLCompositeType -> unwrapObject(value)
            else -> throw ViaductFrameworkException("Unexpected schema type ${GraphQLTypeUtil.simplePrint(unwrappedType)}")
        }
    }

    private fun unwrapEnum(value: Any): String {
        return when (value) {
            is Enum<*> -> value.name
            is String -> value // Allow strings for schema version skew tolerance
            else -> throw IllegalArgumentException("Got non-enum value $value for enum type (expected Enum or String)")
        }
    }

    private fun unwrapGlobalID(value: Any): String = globalIDCodec.serialize(value as GlobalID<*>)

    private fun unwrapList(
        field: GraphQLFieldDefinition,
        type: GraphQLList,
        value: Any
    ): List<*> {
        if (value !is List<*>) {
            throw IllegalArgumentException("Got non-list builder value $value for list type")
        }
        return value.map {
            unwrap(field, GraphQLTypeUtil.unwrapOne(type), it)
        }
    }

    private fun unwrapObject(value: Any): EngineObject {
        return when (value) {
            is ObjectBase -> {
                value.engineObject
            }

            is EngineObjectData -> {
                value
            }

            else -> {
                throw IllegalArgumentException("Expected ObjectBase or EngineObjectData for builder value, got $value")
            }
        }
    }
}
