<?php declare(strict_types=1);

namespace YOOtheme\GraphQL\Utils;

use YOOtheme\GraphQL\Error\InvariantViolation;
use YOOtheme\GraphQL\Language\AST\ArgumentNode;
use YOOtheme\GraphQL\Language\AST\DirectiveNode;
use YOOtheme\GraphQL\Language\AST\EnumValueNode;
use YOOtheme\GraphQL\Language\AST\FieldNode;
use YOOtheme\GraphQL\Language\AST\FragmentDefinitionNode;
use YOOtheme\GraphQL\Language\AST\InlineFragmentNode;
use YOOtheme\GraphQL\Language\AST\ListValueNode;
use YOOtheme\GraphQL\Language\AST\Node;
use YOOtheme\GraphQL\Language\AST\ObjectFieldNode;
use YOOtheme\GraphQL\Language\AST\OperationDefinitionNode;
use YOOtheme\GraphQL\Language\AST\SelectionSetNode;
use YOOtheme\GraphQL\Language\AST\VariableDefinitionNode;
use YOOtheme\GraphQL\Type\Definition\Argument;
use YOOtheme\GraphQL\Type\Definition\CompositeType;
use YOOtheme\GraphQL\Type\Definition\Directive;
use YOOtheme\GraphQL\Type\Definition\EnumType;
use YOOtheme\GraphQL\Type\Definition\FieldDefinition;
use YOOtheme\GraphQL\Type\Definition\HasFieldsType;
use YOOtheme\GraphQL\Type\Definition\ImplementingType;
use YOOtheme\GraphQL\Type\Definition\InputObjectType;
use YOOtheme\GraphQL\Type\Definition\InputType;
use YOOtheme\GraphQL\Type\Definition\InterfaceType;
use YOOtheme\GraphQL\Type\Definition\ListOfType;
use YOOtheme\GraphQL\Type\Definition\NamedType;
use YOOtheme\GraphQL\Type\Definition\NonNull;
use YOOtheme\GraphQL\Type\Definition\ObjectType;
use YOOtheme\GraphQL\Type\Definition\Type;
use YOOtheme\GraphQL\Type\Definition\UnionType;
use YOOtheme\GraphQL\Type\Definition\WrappingType;
use YOOtheme\GraphQL\Type\Introspection;
use YOOtheme\GraphQL\Type\Schema;

class TypeInfo
{
    private Schema $schema;

    /** @var array<int, Type|null> */
    private array $typeStack = [];

    /** @var array<int, (CompositeType&Type)|null> */
    private array $parentTypeStack = [];

    /** @var array<int, (InputType&Type)|null> */
    private array $inputTypeStack = [];

    /** @var array<int, FieldDefinition|null> */
    private array $fieldDefStack = [];

    /** @var array<int, mixed> */
    private array $defaultValueStack = [];

    private ?Directive $directive = null;

    private ?Argument $argument = null;

    /** @var mixed */
    private $enumValue;

    public function __construct(Schema $schema)
    {
        $this->schema = $schema;
    }

    /** @return array<int, (CompositeType&Type)|null> */
    public function getParentTypeStack(): array
    {
        return $this->parentTypeStack;
    }

    /** @return array<int, FieldDefinition|null> */
    public function getFieldDefStack(): array
    {
        return $this->fieldDefStack;
    }

    /**
     * Given root type scans through all fields to find nested types.
     *
     * Returns array where keys are for type name
     * and value contains corresponding type instance.
     *
     * Example output:
     * [
     *     'String' => $instanceOfStringType,
     *     'MyType' => $instanceOfMyType,
     *     ...
     * ]
     *
     * @param (Type&NamedType)|(Type&WrappingType) $type
     * @param array<string, Type&NamedType> $typeMap
     *
     * @throws InvariantViolation
     */
    public static function extractTypes(Type $type, array &$typeMap): void
    {
        if ($type instanceof WrappingType) {
            self::extractTypes($type->getInnermostType(), $typeMap);

            return;
        }

        $name = $type->name;
        assert(is_string($name));

        if (isset($typeMap[$name])) {
            if ($typeMap[$name] !== $type) {
                throw new InvariantViolation("Schema must contain unique named types but contains multiple types named \"{$type}\" (see https://webonyx.github.io/graphql-php/type-definitions/#type-registry).");
            }

            return;
        }

        $typeMap[$name] = $type;

        if ($type instanceof UnionType) {
            foreach ($type->getTypes() as $member) {
                self::extractTypes($member, $typeMap);
            }

            return;
        }

        if ($type instanceof InputObjectType) {
            foreach ($type->getFields() as $field) {
                $fieldType = $field->getType();
                assert($fieldType instanceof NamedType || $fieldType instanceof WrappingType);
                self::extractTypes($fieldType, $typeMap);
            }

            return;
        }

        if ($type instanceof ImplementingType) {
            foreach ($type->getInterfaces() as $interface) {
                self::extractTypes($interface, $typeMap);
            }
        }

        if ($type instanceof HasFieldsType) {
            foreach ($type->getFields() as $field) {
                foreach ($field->args as $arg) {
                    $argType = $arg->getType();
                    assert($argType instanceof NamedType || $argType instanceof WrappingType);
                    self::extractTypes($argType, $typeMap);
                }

                $fieldType = $field->getType();
                assert($fieldType instanceof NamedType || $fieldType instanceof WrappingType);
                self::extractTypes($fieldType, $typeMap);
            }
        }
    }

    /**
     * @param array<string, Type&NamedType> $typeMap
     *
     * @throws InvariantViolation
     */
    public static function extractTypesFromDirectives(Directive $directive, array &$typeMap): void
    {
        foreach ($directive->args as $arg) {
            $argType = $arg->getType();
            assert($argType instanceof NamedType || $argType instanceof WrappingType);
            self::extractTypes($argType, $typeMap);
        }
    }

    /** @return (Type&InputType)|null */
    public function getParentInputType(): ?InputType
    {
        return $this->inputTypeStack[count($this->inputTypeStack) - 2] ?? null;
    }

    public function getArgument(): ?Argument
    {
        return $this->argument;
    }

    /** @return mixed */
    public function getEnumValue()
    {
        return $this->enumValue;
    }

    /**
     * @throws \Exception
     * @throws InvariantViolation
     */
    public function enter(Node $node): void
    {
        $schema = $this->schema;

        // Note: many of the types below are explicitly typed as "mixed" to drop
        // any assumptions of a valid schema to ensure runtime types are properly
        // checked before continuing since TypeInfo is used as part of validation
        // which occurs before guarantees of schema and document validity.
        switch (true) {
            case $node instanceof SelectionSetNode:
                $namedType = Type::getNamedType($this->getType());
                $this->parentTypeStack[] = Type::isCompositeType($namedType) ? $namedType : null;
                break;

            case $node instanceof FieldNode:
                $parentType = $this->getParentType();

                $fieldDef = $parentType === null
                    ? null
                    : self::getFieldDefinition($schema, $parentType, $node);

                $fieldType = $fieldDef === null
                    ? null
                    : $fieldDef->getType();

                $this->fieldDefStack[] = $fieldDef;
                $this->typeStack[] = $fieldType;
                break;

            case $node instanceof DirectiveNode:
                $this->directive = $schema->getDirective($node->name->value);
                break;

            case $node instanceof OperationDefinitionNode:
                if ($node->operation === 'query') {
                    $type = $schema->getQueryType();
                } elseif ($node->operation === 'mutation') {
                    $type = $schema->getMutationType();
                } else {
                    // Only other option
                    $type = $schema->getSubscriptionType();
                }

                $this->typeStack[] = Type::isOutputType($type)
                    ? $type
                    : null;
                break;

            case $node instanceof InlineFragmentNode:
            case $node instanceof FragmentDefinitionNode:
                $typeConditionNode = $node->typeCondition;
                $outputType = $typeConditionNode === null
                    ? Type::getNamedType($this->getType())
                    : AST::typeFromAST([$schema, 'getType'], $typeConditionNode);
                $this->typeStack[] = Type::isOutputType($outputType) ? $outputType : null;
                break;

            case $node instanceof VariableDefinitionNode:
                $inputType = AST::typeFromAST([$schema, 'getType'], $node->type);
                $this->inputTypeStack[] = Type::isInputType($inputType) ? $inputType : null; // push
                break;

            case $node instanceof ArgumentNode:
                $fieldOrDirective = $this->getDirective() ?? $this->getFieldDef();
                $argDef = null;
                $argType = null;
                if ($fieldOrDirective !== null) {
                    foreach ($fieldOrDirective->args as $arg) {
                        if ($arg->name === $node->name->value) {
                            $argDef = $arg;
                            $argType = $arg->getType();
                        }
                    }
                }

                $this->argument = $argDef;
                $this->defaultValueStack[] = $argDef !== null && $argDef->defaultValueExists()
                    ? $argDef->defaultValue
                    : Utils::undefined();
                $this->inputTypeStack[] = Type::isInputType($argType) ? $argType : null;
                break;

            case $node instanceof ListValueNode:
                $type = $this->getInputType();
                $listType = $type instanceof NonNull
                    ? $type->getWrappedType()
                    : $type;
                $itemType = $listType instanceof ListOfType
                    ? $listType->getWrappedType()
                    : $listType;
                // List positions never have a default value.
                $this->defaultValueStack[] = Utils::undefined();
                $this->inputTypeStack[] = Type::isInputType($itemType) ? $itemType : null;
                break;

            case $node instanceof ObjectFieldNode:
                $objectType = Type::getNamedType($this->getInputType());
                $inputField = null;
                $inputFieldType = null;
                if ($objectType instanceof InputObjectType) {
                    $tmp = $objectType->getFields();
                    $inputField = $tmp[$node->name->value] ?? null;
                    $inputFieldType = $inputField === null
                        ? null
                        : $inputField->getType();
                }

                $this->defaultValueStack[] = $inputField !== null && $inputField->defaultValueExists()
                    ? $inputField->defaultValue
                    : Utils::undefined();
                $this->inputTypeStack[] = Type::isInputType($inputFieldType)
                    ? $inputFieldType
                    : null;
                break;

            case $node instanceof EnumValueNode:
                $enumType = Type::getNamedType($this->getInputType());

                $this->enumValue = $enumType instanceof EnumType
                    ? $enumType->getValue($node->value)
                    : null;
                break;
        }
    }

    public function getType(): ?Type
    {
        return $this->typeStack[count($this->typeStack) - 1] ?? null;
    }

    /** @return (CompositeType&Type)|null */
    public function getParentType(): ?CompositeType
    {
        return $this->parentTypeStack[count($this->parentTypeStack) - 1] ?? null;
    }

    /**
     * Not exactly the same as the executor's definition of getFieldDef, in this
     * statically evaluated environment we do not always have an Object type,
     * and need to handle Interface and Union types.
     *
     * @throws InvariantViolation
     */
    private static function getFieldDefinition(Schema $schema, Type $parentType, FieldNode $fieldNode): ?FieldDefinition
    {
        $name = $fieldNode->name->value;
        $schemaMeta = Introspection::schemaMetaFieldDef();
        if ($name === $schemaMeta->name && $schema->getQueryType() === $parentType) {
            return $schemaMeta;
        }

        $typeMeta = Introspection::typeMetaFieldDef();
        if ($name === $typeMeta->name && $schema->getQueryType() === $parentType) {
            return $typeMeta;
        }

        $typeNameMeta = Introspection::typeNameMetaFieldDef();
        if ($name === $typeNameMeta->name && $parentType instanceof CompositeType) {
            return $typeNameMeta;
        }

        if (
            $parentType instanceof ObjectType
            || $parentType instanceof InterfaceType
        ) {
            return $parentType->findField($name);
        }

        return null;
    }

    public function getDirective(): ?Directive
    {
        return $this->directive;
    }

    public function getFieldDef(): ?FieldDefinition
    {
        return $this->fieldDefStack[count($this->fieldDefStack) - 1] ?? null;
    }

    /** @return mixed any value is possible */
    public function getDefaultValue()
    {
        return $this->defaultValueStack[count($this->defaultValueStack) - 1] ?? null;
    }

    /** @return (InputType&Type)|null */
    public function getInputType(): ?InputType
    {
        return $this->inputTypeStack[count($this->inputTypeStack) - 1] ?? null;
    }

    public function leave(Node $node): void
    {
        switch (true) {
            case $node instanceof SelectionSetNode:
                array_pop($this->parentTypeStack);
                break;

            case $node instanceof FieldNode:
                array_pop($this->fieldDefStack);
                array_pop($this->typeStack);
                break;

            case $node instanceof DirectiveNode:
                $this->directive = null;
                break;

            case $node instanceof OperationDefinitionNode:
            case $node instanceof InlineFragmentNode:
            case $node instanceof FragmentDefinitionNode:
                array_pop($this->typeStack);
                break;
            case $node instanceof VariableDefinitionNode:
                array_pop($this->inputTypeStack);
                break;
            case $node instanceof ArgumentNode:
                $this->argument = null;
                array_pop($this->defaultValueStack);
                array_pop($this->inputTypeStack);
                break;
            case $node instanceof ListValueNode:
            case $node instanceof ObjectFieldNode:
                array_pop($this->defaultValueStack);
                array_pop($this->inputTypeStack);
                break;
            case $node instanceof EnumValueNode:
                $this->enumValue = null;
                break;
        }
    }
}
