<?php declare(strict_types=1);

namespace YOOtheme\GraphQL\Executor;

use YOOtheme\GraphQL\Error\Error;
use YOOtheme\GraphQL\Language\AST\ArgumentNode;
use YOOtheme\GraphQL\Language\AST\DirectiveNode;
use YOOtheme\GraphQL\Language\AST\EnumTypeDefinitionNode;
use YOOtheme\GraphQL\Language\AST\EnumTypeExtensionNode;
use YOOtheme\GraphQL\Language\AST\EnumValueDefinitionNode;
use YOOtheme\GraphQL\Language\AST\FieldDefinitionNode;
use YOOtheme\GraphQL\Language\AST\FieldNode;
use YOOtheme\GraphQL\Language\AST\FragmentDefinitionNode;
use YOOtheme\GraphQL\Language\AST\FragmentSpreadNode;
use YOOtheme\GraphQL\Language\AST\InlineFragmentNode;
use YOOtheme\GraphQL\Language\AST\InputObjectTypeDefinitionNode;
use YOOtheme\GraphQL\Language\AST\InputObjectTypeExtensionNode;
use YOOtheme\GraphQL\Language\AST\InputValueDefinitionNode;
use YOOtheme\GraphQL\Language\AST\InterfaceTypeDefinitionNode;
use YOOtheme\GraphQL\Language\AST\InterfaceTypeExtensionNode;
use YOOtheme\GraphQL\Language\AST\Node;
use YOOtheme\GraphQL\Language\AST\NodeList;
use YOOtheme\GraphQL\Language\AST\NullValueNode;
use YOOtheme\GraphQL\Language\AST\ObjectTypeDefinitionNode;
use YOOtheme\GraphQL\Language\AST\ObjectTypeExtensionNode;
use YOOtheme\GraphQL\Language\AST\OperationDefinitionNode;
use YOOtheme\GraphQL\Language\AST\ScalarTypeDefinitionNode;
use YOOtheme\GraphQL\Language\AST\ScalarTypeExtensionNode;
use YOOtheme\GraphQL\Language\AST\SchemaExtensionNode;
use YOOtheme\GraphQL\Language\AST\UnionTypeDefinitionNode;
use YOOtheme\GraphQL\Language\AST\UnionTypeExtensionNode;
use YOOtheme\GraphQL\Language\AST\VariableDefinitionNode;
use YOOtheme\GraphQL\Language\AST\VariableNode;
use YOOtheme\GraphQL\Language\Printer;
use YOOtheme\GraphQL\Type\Definition\Directive;
use YOOtheme\GraphQL\Type\Definition\FieldDefinition;
use YOOtheme\GraphQL\Type\Definition\NonNull;
use YOOtheme\GraphQL\Type\Definition\Type;
use YOOtheme\GraphQL\Type\Schema;
use YOOtheme\GraphQL\Utils\AST;
use YOOtheme\GraphQL\Utils\Utils;
use YOOtheme\GraphQL\Utils\Value;

/**
 * @see ArgumentNode - force IDE import
 *
 * @phpstan-import-type ArgumentNodeValue from ArgumentNode
 *
 * @see \GraphQL\Tests\Executor\ValuesTest
 */
class Values
{
    /**
     * Prepares an object map of variables of the correct type based on the provided
     * variable definitions and arbitrary input. If the input cannot be coerced
     * to match the variable definitions, an Error will be thrown.
     *
     * @param NodeList<VariableDefinitionNode> $varDefNodes
     * @param array<string, mixed> $rawVariableValues
     *
     * @throws \Exception
     *
     * @return array{array<int, Error>, null}|array{null, array<string, mixed>}
     */
    public static function getVariableValues(Schema $schema, NodeList $varDefNodes, array $rawVariableValues): array
    {
        $errors = [];
        $coercedValues = [];
        foreach ($varDefNodes as $varDefNode) {
            $varName = $varDefNode->variable->name->value;
            $varType = AST::typeFromAST([$schema, 'getType'], $varDefNode->type);

            if (! Type::isInputType($varType)) {
                // Must use input types for variables. This should be caught during
                // validation, however is checked again here for safety.
                $typeStr = Printer::doPrint($varDefNode->type);
                $errors[] = new Error(
                    "Variable \"\${$varName}\" expected value of type \"{$typeStr}\" which cannot be used as an input type.",
                    [$varDefNode->type]
                );
            } else {
                $hasValue = array_key_exists($varName, $rawVariableValues);
                $value = $hasValue
                    ? $rawVariableValues[$varName]
                    : Utils::undefined();

                if (! $hasValue && ($varDefNode->defaultValue !== null)) {
                    // If no value was provided to a variable with a default value,
                    // use the default value.
                    $coercedValues[$varName] = AST::valueFromAST($varDefNode->defaultValue, $varType);
                } elseif ((! $hasValue || $value === null) && ($varType instanceof NonNull)) {
                    // If no value or a nullish value was provided to a variable with a
                    // non-null type (required), produce an error.
                    $safeVarType = Utils::printSafe($varType);
                    $message = $hasValue
                        ? "Variable \"\${$varName}\" of non-null type \"{$safeVarType}\" must not be null."
                        : "Variable \"\${$varName}\" of required type \"{$safeVarType}\" was not provided.";
                    $errors[] = new Error($message, [$varDefNode]);
                } elseif ($hasValue) {
                    if ($value === null) {
                        // If the explicit value `null` was provided, an entry in the coerced
                        // values must exist as the value `null`.
                        $coercedValues[$varName] = null;
                    } else {
                        // Otherwise, a non-null value was provided, coerce it to the expected
                        // type or report an error if coercion fails.
                        $coerced = Value::coerceInputValue($value, $varType);

                        $coercionErrors = $coerced['errors'];
                        if ($coercionErrors !== null) {
                            foreach ($coercionErrors as $coercionError) {
                                $invalidValue = $coercionError->printInvalidValue();

                                $inputPath = $coercionError->printInputPath();
                                $pathMessage = $inputPath !== null
                                    ? " at \"{$varName}{$inputPath}\""
                                    : '';

                                $errors[] = new Error(
                                    "Variable \"\${$varName}\" got invalid value {$invalidValue}{$pathMessage}; {$coercionError->getMessage()}",
                                    $varDefNode,
                                    $coercionError->getSource(),
                                    $coercionError->getPositions(),
                                    $coercionError->getPath(),
                                    $coercionError,
                                    $coercionError->getExtensions()
                                );
                            }
                        } else {
                            $coercedValues[$varName] = $coerced['value'];
                        }
                    }
                }
            }
        }

        return $errors === []
            ? [null, $coercedValues]
            : [$errors, null];
    }

    /**
     * Prepares an object map of argument values given a directive definition
     * and an AST node which may contain directives. Optionally also accepts a map
     * of variable values.
     *
     * If the directive does not exist on the node, returns undefined.
     *
     * @param EnumTypeDefinitionNode|EnumTypeExtensionNode|EnumValueDefinitionNode|FieldDefinitionNode|FieldNode|FragmentDefinitionNode|FragmentSpreadNode|InlineFragmentNode|InputObjectTypeDefinitionNode|InputObjectTypeExtensionNode|InputValueDefinitionNode|InterfaceTypeDefinitionNode|InterfaceTypeExtensionNode|ObjectTypeDefinitionNode|ObjectTypeExtensionNode|OperationDefinitionNode|ScalarTypeDefinitionNode|ScalarTypeExtensionNode|SchemaExtensionNode|UnionTypeDefinitionNode|UnionTypeExtensionNode|VariableDefinitionNode $node
     * @param array<string, mixed>|null $variableValues
     *
     * @throws \Exception
     * @throws Error
     *
     * @return array<string, mixed>|null
     */
    public static function getDirectiveValues(Directive $directiveDef, Node $node, ?array $variableValues = null): ?array
    {
        $directiveDefName = $directiveDef->name;

        foreach ($node->directives as $directive) {
            if ($directive->name->value === $directiveDefName) {
                return self::getArgumentValues($directiveDef, $directive, $variableValues);
            }
        }

        return null;
    }

    /**
     * Prepares an object map of argument values given a list of argument
     * definitions and list of argument AST nodes.
     *
     * @param FieldDefinition|Directive $def
     * @param FieldNode|DirectiveNode $node
     * @param array<string, mixed>|null $variableValues
     *
     * @throws \Exception
     * @throws Error
     *
     * @return array<string, mixed>
     */
    public static function getArgumentValues($def, Node $node, ?array $variableValues = null): array
    {
        if ($def->args === []) {
            return [];
        }

        /** @var array<string, ArgumentNodeValue> $argumentValueMap */
        $argumentValueMap = [];

        // Might not be defined when an AST from JS is used
        if (isset($node->arguments)) {
            foreach ($node->arguments as $argumentNode) {
                $argumentValueMap[$argumentNode->name->value] = $argumentNode->value;
            }
        }

        return static::getArgumentValuesForMap($def, $argumentValueMap, $variableValues, $node);
    }

    /**
     * @param FieldDefinition|Directive $def
     * @param array<string, ArgumentNodeValue> $argumentValueMap
     * @param array<string, mixed>|null $variableValues
     *
     * @throws \Exception
     * @throws Error
     *
     * @return array<string, mixed>
     */
    public static function getArgumentValuesForMap($def, array $argumentValueMap, ?array $variableValues = null, ?Node $referenceNode = null): array
    {
        /** @var array<string, mixed> $coercedValues */
        $coercedValues = [];

        foreach ($def->args as $argumentDefinition) {
            $name = $argumentDefinition->name;
            $argType = $argumentDefinition->getType();
            $argumentValueNode = $argumentValueMap[$name] ?? null;

            if ($argumentValueNode instanceof VariableNode) {
                $variableName = $argumentValueNode->name->value;
                $hasValue = $variableValues !== null && array_key_exists($variableName, $variableValues);
                $isNull = $hasValue && $variableValues[$variableName] === null;
            } else {
                $hasValue = $argumentValueNode !== null;
                $isNull = $argumentValueNode instanceof NullValueNode;
            }

            if (! $hasValue && $argumentDefinition->defaultValueExists()) {
                // If no argument was provided where the definition has a default value,
                // use the default value.
                $coercedValues[$name] = $argumentDefinition->defaultValue;
            } elseif ((! $hasValue || $isNull) && ($argType instanceof NonNull)) {
                // If no argument or a null value was provided to an argument with a
                // non-null type (required), produce a field error.
                $safeArgType = Utils::printSafe($argType);

                if ($isNull) {
                    throw new Error("Argument \"{$name}\" of non-null type \"{$safeArgType}\" must not be null.", $referenceNode);
                }

                if ($argumentValueNode instanceof VariableNode) {
                    throw new Error("Argument \"{$name}\" of required type \"{$safeArgType}\" was provided the variable \"\${$argumentValueNode->name->value}\" which was not provided a runtime value.", [$argumentValueNode]);
                }

                throw new Error("Argument \"{$name}\" of required type \"{$safeArgType}\" was not provided.", $referenceNode);
            } elseif ($hasValue) {
                assert($argumentValueNode instanceof Node);

                if ($argumentValueNode instanceof NullValueNode) {
                    // If the explicit value `null` was provided, an entry in the coerced
                    // values must exist as the value `null`.
                    $coercedValues[$name] = null;
                } elseif ($argumentValueNode instanceof VariableNode) {
                    $variableName = $argumentValueNode->name->value;
                    // Note: This does no further checking that this variable is correct.
                    // This assumes that this query has been validated and the variable
                    // usage here is of the correct type.
                    $coercedValues[$name] = $variableValues[$variableName] ?? null;
                } else {
                    $coercedValue = AST::valueFromAST($argumentValueNode, $argType, $variableValues);
                    if (Utils::undefined() === $coercedValue) {
                        // Note: ValuesOfCorrectType validation should catch this before
                        // execution. This is a runtime check to ensure execution does not
                        // continue with an invalid argument value.
                        $invalidValue = Printer::doPrint($argumentValueNode);
                        throw new Error("Argument \"{$name}\" has invalid value {$invalidValue}.", [$argumentValueNode]);
                    }

                    $coercedValues[$name] = $coercedValue;
                }
            }
        }

        return $coercedValues;
    }
}
