<?php declare(strict_types=1);

namespace YOOtheme\GraphQL\Validator;

use YOOtheme\GraphQL\Error\Error;
use YOOtheme\GraphQL\Language\AST\DocumentNode;
use YOOtheme\GraphQL\Language\AST\FieldNode;
use YOOtheme\GraphQL\Language\AST\FragmentDefinitionNode;
use YOOtheme\GraphQL\Language\AST\FragmentSpreadNode;
use YOOtheme\GraphQL\Language\AST\HasSelectionSet;
use YOOtheme\GraphQL\Language\AST\InlineFragmentNode;
use YOOtheme\GraphQL\Language\AST\Node;
use YOOtheme\GraphQL\Language\AST\NodeKind;
use YOOtheme\GraphQL\Language\AST\OperationDefinitionNode;
use YOOtheme\GraphQL\Language\AST\VariableNode;
use YOOtheme\GraphQL\Language\Visitor;
use YOOtheme\GraphQL\Type\Definition\Argument;
use YOOtheme\GraphQL\Type\Definition\CompositeType;
use YOOtheme\GraphQL\Type\Definition\Directive;
use YOOtheme\GraphQL\Type\Definition\FieldDefinition;
use YOOtheme\GraphQL\Type\Definition\InputType;
use YOOtheme\GraphQL\Type\Definition\Type;
use YOOtheme\GraphQL\Type\Schema;
use YOOtheme\GraphQL\Utils\TypeInfo;

/**
 * An instance of this class is passed as the "this" context to all validators,
 * allowing access to commonly useful contextual information from within a
 * validation rule.
 *
 * @phpstan-type VariableUsage array{node: VariableNode, type: (Type&InputType)|null, defaultValue: mixed}
 */
class QueryValidationContext implements ValidationContext
{
    protected Schema $schema;

    protected DocumentNode $ast;

    /** @var list<Error> */
    protected array $errors = [];

    private TypeInfo $typeInfo;

    /** @var array<string, FragmentDefinitionNode> */
    private array $fragments;

    /** @var \SplObjectStorage<HasSelectionSet, array<int, FragmentSpreadNode>> */
    private \SplObjectStorage $fragmentSpreads;

    /** @var \SplObjectStorage<OperationDefinitionNode, array<int, FragmentDefinitionNode>> */
    private \SplObjectStorage $recursivelyReferencedFragments;

    /** @var \SplObjectStorage<HasSelectionSet, array<int, VariableUsage>> */
    private \SplObjectStorage $variableUsages;

    /** @var \SplObjectStorage<HasSelectionSet, array<int, VariableUsage>> */
    private \SplObjectStorage $recursiveVariableUsages;

    public function __construct(Schema $schema, DocumentNode $ast, TypeInfo $typeInfo)
    {
        $this->schema = $schema;
        $this->ast = $ast;
        $this->typeInfo = $typeInfo;

        $this->fragmentSpreads = new \SplObjectStorage();
        $this->recursivelyReferencedFragments = new \SplObjectStorage();
        $this->variableUsages = new \SplObjectStorage();
        $this->recursiveVariableUsages = new \SplObjectStorage();
    }

    public function reportError(Error $error): void
    {
        $this->errors[] = $error;
    }

    /** @return list<Error> */
    public function getErrors(): array
    {
        return $this->errors;
    }

    public function getDocument(): DocumentNode
    {
        return $this->ast;
    }

    public function getSchema(): Schema
    {
        return $this->schema;
    }

    /**
     * @throws \Exception
     *
     * @phpstan-return array<int, VariableUsage>
     */
    public function getRecursiveVariableUsages(OperationDefinitionNode $operation): array
    {
        $usages = $this->recursiveVariableUsages[$operation] ?? null;

        if ($usages === null) {
            $usages = $this->getVariableUsages($operation);
            $fragments = $this->getRecursivelyReferencedFragments($operation);

            $allUsages = [$usages];
            foreach ($fragments as $fragment) {
                $allUsages[] = $this->getVariableUsages($fragment);
            }

            $usages = array_merge(...$allUsages);
            $this->recursiveVariableUsages[$operation] = $usages;
        }

        return $usages;
    }

    /**
     * @param HasSelectionSet&Node $node
     *
     * @throws \Exception
     *
     * @phpstan-return array<int, VariableUsage>
     */
    private function getVariableUsages(HasSelectionSet $node): array
    {
        if (! isset($this->variableUsages[$node])) {
            $usages = [];
            $typeInfo = new TypeInfo($this->schema);
            Visitor::visit(
                $node,
                Visitor::visitWithTypeInfo(
                    $typeInfo,
                    [
                        NodeKind::VARIABLE_DEFINITION => static fn () => Visitor::skipNode(),
                        NodeKind::VARIABLE => static function (VariableNode $variable) use (&$usages, $typeInfo): void {
                            $usages[] = [
                                'node' => $variable,
                                'type' => $typeInfo->getInputType(),
                                'defaultValue' => $typeInfo->getDefaultValue(),
                            ];
                        },
                    ]
                )
            );

            return $this->variableUsages[$node] = $usages;
        }

        return $this->variableUsages[$node];
    }

    /** @return array<int, FragmentDefinitionNode> */
    public function getRecursivelyReferencedFragments(OperationDefinitionNode $operation): array
    {
        $fragments = $this->recursivelyReferencedFragments[$operation] ?? null;

        if ($fragments === null) {
            $fragments = [];
            $collectedNames = [];
            $nodesToVisit = [$operation];
            while ($nodesToVisit !== []) {
                $node = array_pop($nodesToVisit);
                $spreads = $this->getFragmentSpreads($node);
                foreach ($spreads as $spread) {
                    $fragName = $spread->name->value;

                    if ($collectedNames[$fragName] ?? false) {
                        continue;
                    }

                    $collectedNames[$fragName] = true;
                    $fragment = $this->getFragment($fragName);
                    if ($fragment === null) {
                        continue;
                    }

                    $fragments[] = $fragment;
                    $nodesToVisit[] = $fragment;
                }
            }

            $this->recursivelyReferencedFragments[$operation] = $fragments;
        }

        return $fragments;
    }

    /**
     * @param OperationDefinitionNode|FragmentDefinitionNode $node
     *
     * @return array<int, FragmentSpreadNode>
     */
    public function getFragmentSpreads(HasSelectionSet $node): array
    {
        $spreads = $this->fragmentSpreads[$node] ?? null;
        if ($spreads === null) {
            $spreads = [];

            $setsToVisit = [$node->getSelectionSet()];
            while ($setsToVisit !== []) {
                $set = array_pop($setsToVisit);

                foreach ($set->selections as $selection) {
                    if ($selection instanceof FragmentSpreadNode) {
                        $spreads[] = $selection;
                    } else {
                        assert($selection instanceof FieldNode || $selection instanceof InlineFragmentNode);

                        $selectionSet = $selection->selectionSet;
                        if ($selectionSet !== null) {
                            $setsToVisit[] = $selectionSet;
                        }
                    }
                }
            }

            $this->fragmentSpreads[$node] = $spreads;
        }

        return $spreads;
    }

    public function getFragment(string $name): ?FragmentDefinitionNode
    {
        if (! isset($this->fragments)) {
            $fragments = [];
            foreach ($this->getDocument()->definitions as $statement) {
                if ($statement instanceof FragmentDefinitionNode) {
                    $fragments[$statement->name->value] = $statement;
                }
            }

            $this->fragments = $fragments;
        }

        return $this->fragments[$name] ?? null;
    }

    public function getType(): ?Type
    {
        return $this->typeInfo->getType();
    }

    /** @return (CompositeType&Type)|null */
    public function getParentType(): ?CompositeType
    {
        return $this->typeInfo->getParentType();
    }

    /** @return (Type&InputType)|null */
    public function getInputType(): ?InputType
    {
        return $this->typeInfo->getInputType();
    }

    /** @return (Type&InputType)|null */
    public function getParentInputType(): ?InputType
    {
        return $this->typeInfo->getParentInputType();
    }

    public function getFieldDef(): ?FieldDefinition
    {
        return $this->typeInfo->getFieldDef();
    }

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

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