<?php declare(strict_types=1);

namespace YOOtheme\GraphQL\Utils;

use YOOtheme\GraphQL\Error\InvariantViolation;
use YOOtheme\GraphQL\Error\SyntaxError;
use YOOtheme\GraphQL\Language\Parser;
use YOOtheme\GraphQL\Type\Definition\CustomScalarType;
use YOOtheme\GraphQL\Type\Definition\Directive;
use YOOtheme\GraphQL\Type\Definition\EnumType;
use YOOtheme\GraphQL\Type\Definition\FieldDefinition;
use YOOtheme\GraphQL\Type\Definition\InputObjectField;
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\OutputType;
use YOOtheme\GraphQL\Type\Definition\ScalarType;
use YOOtheme\GraphQL\Type\Definition\Type;
use YOOtheme\GraphQL\Type\Definition\UnionType;
use YOOtheme\GraphQL\Type\Introspection;
use YOOtheme\GraphQL\Type\Schema;
use YOOtheme\GraphQL\Type\SchemaConfig;
use YOOtheme\GraphQL\Type\TypeKind;

/**
 * @phpstan-import-type UnnamedFieldDefinitionConfig from FieldDefinition
 * @phpstan-import-type UnnamedInputObjectFieldConfig from InputObjectField
 *
 * @phpstan-type Options array{
 *   assumeValid?: bool
 * }
 *
 *    - assumeValid:
 *          When building a schema from a GraphQL service's introspection result, it
 *          might be safe to assume the schema is valid. Set to true to assume the
 *          produced schema is valid.
 *
 *          Default: false
 *
 * @see \GraphQL\Tests\Utils\BuildClientSchemaTest
 */
class BuildClientSchema
{
    /** @var array<string, mixed> */
    private array $introspection;

    /**
     * @var array<string, bool>
     *
     * @phpstan-var Options
     */
    private array $options;

    /** @var array<string, NamedType&Type> */
    private array $typeMap = [];

    /**
     * @param array<string, mixed> $introspectionQuery
     * @param array<string, bool> $options
     *
     * @phpstan-param Options    $options
     */
    public function __construct(array $introspectionQuery, array $options = [])
    {
        $this->introspection = $introspectionQuery;
        $this->options = $options;
    }

    /**
     * Build a schema for use by client tools.
     *
     * Given the result of a client running the introspection query, creates and
     * returns a \GraphQL\Type\Schema instance which can be then used with all graphql-php
     * tools, but cannot be used to execute a query, as introspection does not
     * represent the "resolver", "parse" or "serialize" functions or any other
     * server-internal mechanisms.
     *
     * This function expects a complete introspection result. Don't forget to check
     * the "errors" field of a server response before calling this function.
     *
     * @param array<string, mixed> $introspectionQuery
     * @param array<string, bool> $options
     *
     * @phpstan-param Options $options
     *
     * @api
     *
     * @throws \Exception
     * @throws InvariantViolation
     */
    public static function build(array $introspectionQuery, array $options = []): Schema
    {
        return (new self($introspectionQuery, $options))->buildSchema();
    }

    /**
     * @throws \Exception
     * @throws InvariantViolation
     */
    public function buildSchema(): Schema
    {
        if (! array_key_exists('__schema', $this->introspection)) {
            $missingSchemaIntrospection = Utils::printSafeJson($this->introspection);
            throw new InvariantViolation("Invalid or incomplete introspection result. Ensure that you are passing \"data\" property of introspection response and no \"errors\" was returned alongside: {$missingSchemaIntrospection}.");
        }

        $schemaIntrospection = $this->introspection['__schema'];

        $builtInTypes = array_merge(
            Type::getStandardTypes(),
            Introspection::getTypes()
        );

        foreach ($schemaIntrospection['types'] as $typeIntrospection) {
            if (! isset($typeIntrospection['name'])) {
                throw self::invalidOrIncompleteIntrospectionResult($typeIntrospection);
            }

            $name = $typeIntrospection['name'];
            if (! is_string($name)) {
                throw self::invalidOrIncompleteIntrospectionResult($typeIntrospection);
            }

            // Use the built-in singleton types to avoid reconstruction
            $this->typeMap[$name] = $builtInTypes[$name]
                ?? $this->buildType($typeIntrospection);
        }

        $queryType = isset($schemaIntrospection['queryType'])
            ? $this->getObjectType($schemaIntrospection['queryType'])
            : null;

        $mutationType = isset($schemaIntrospection['mutationType'])
            ? $this->getObjectType($schemaIntrospection['mutationType'])
            : null;

        $subscriptionType = isset($schemaIntrospection['subscriptionType'])
            ? $this->getObjectType($schemaIntrospection['subscriptionType'])
            : null;

        $directives = isset($schemaIntrospection['directives'])
            ? array_map(
                [$this, 'buildDirective'],
                $schemaIntrospection['directives']
            )
            : [];

        return new Schema(
            (new SchemaConfig())
                ->setQuery($queryType)
                ->setMutation($mutationType)
                ->setSubscription($subscriptionType)
                ->setTypes($this->typeMap)
                ->setDirectives($directives)
                ->setAssumeValid($this->options['assumeValid'] ?? false)
        );
    }

    /**
     * @param array<string, mixed> $typeRef
     *
     * @throws InvariantViolation
     */
    private function getType(array $typeRef): Type
    {
        if (isset($typeRef['kind'])) {
            if ($typeRef['kind'] === TypeKind::LIST) {
                if (! isset($typeRef['ofType'])) {
                    throw new InvariantViolation('Decorated type deeper than introspection query.');
                }

                return new ListOfType($this->getType($typeRef['ofType']));
            }

            if ($typeRef['kind'] === TypeKind::NON_NULL) {
                if (! isset($typeRef['ofType'])) {
                    throw new InvariantViolation('Decorated type deeper than introspection query.');
                }

                // @phpstan-ignore-next-line if the type is not a nullable type, schema validation will catch it
                return new NonNull($this->getType($typeRef['ofType']));
            }
        }

        if (! isset($typeRef['name'])) {
            $unknownTypeRef = Utils::printSafeJson($typeRef);
            throw new InvariantViolation("Unknown type reference: {$unknownTypeRef}.");
        }

        return $this->getNamedType($typeRef['name']);
    }

    /**
     * @throws InvariantViolation
     *
     * @return NamedType&Type
     */
    private function getNamedType(string $typeName): NamedType
    {
        if (! isset($this->typeMap[$typeName])) {
            throw new InvariantViolation("Invalid or incomplete schema, unknown type: {$typeName}. Ensure that a full introspection query is used in order to build a client schema.");
        }

        return $this->typeMap[$typeName];
    }

    /** @param array<mixed> $type */
    public static function invalidOrIncompleteIntrospectionResult(array $type): InvariantViolation
    {
        $incompleteType = Utils::printSafeJson($type);

        return new InvariantViolation("Invalid or incomplete introspection result. Ensure that a full introspection query is used in order to build a client schema: {$incompleteType}.");
    }

    /**
     * @param array<string, mixed> $typeRef
     *
     * @throws InvariantViolation
     *
     * @return Type&InputType
     */
    private function getInputType(array $typeRef): InputType
    {
        $type = $this->getType($typeRef);

        if ($type instanceof InputType) {
            return $type;
        }

        $notInputType = Utils::printSafe($type);
        throw new InvariantViolation("Introspection must provide input type for arguments, but received: {$notInputType}.");
    }

    /**
     * @param array<string, mixed> $typeRef
     *
     * @throws InvariantViolation
     */
    private function getOutputType(array $typeRef): OutputType
    {
        $type = $this->getType($typeRef);

        if ($type instanceof OutputType) {
            return $type;
        }

        $notInputType = Utils::printSafe($type);
        throw new InvariantViolation("Introspection must provide output type for fields, but received: {$notInputType}.");
    }

    /**
     * @param array<string, mixed> $typeRef
     *
     * @throws InvariantViolation
     */
    private function getObjectType(array $typeRef): ObjectType
    {
        $type = $this->getType($typeRef);

        return ObjectType::assertObjectType($type);
    }

    /**
     * @param array<string, mixed> $typeRef
     *
     * @throws InvariantViolation
     */
    public function getInterfaceType(array $typeRef): InterfaceType
    {
        $type = $this->getType($typeRef);

        return InterfaceType::assertInterfaceType($type);
    }

    /**
     * @param array<string, mixed> $type
     *
     * @throws InvariantViolation
     *
     * @return Type&NamedType
     */
    private function buildType(array $type): NamedType
    {
        if (! array_key_exists('kind', $type)) {
            throw self::invalidOrIncompleteIntrospectionResult($type);
        }

        switch ($type['kind']) {
            case TypeKind::SCALAR:
                return $this->buildScalarDef($type);
            case TypeKind::OBJECT:
                return $this->buildObjectDef($type);
            case TypeKind::INTERFACE:
                return $this->buildInterfaceDef($type);
            case TypeKind::UNION:
                return $this->buildUnionDef($type);
            case TypeKind::ENUM:
                return $this->buildEnumDef($type);
            case TypeKind::INPUT_OBJECT:
                return $this->buildInputObjectDef($type);
            default:
                $unknownKindType = Utils::printSafeJson($type);
                throw new InvariantViolation("Invalid or incomplete introspection result. Received type with unknown kind: {$unknownKindType}.");
        }
    }

    /**
     * @param array<string, string> $scalar
     *
     * @throws InvariantViolation
     */
    private function buildScalarDef(array $scalar): ScalarType
    {
        return new CustomScalarType([
            'name' => $scalar['name'],
            'description' => $scalar['description'],
            'serialize' => static fn ($value) => $value,
        ]);
    }

    /**
     * @param array<string, mixed> $implementingIntrospection
     *
     * @throws InvariantViolation
     *
     * @return array<int, InterfaceType>
     */
    private function buildImplementationsList(array $implementingIntrospection): array
    {
        // TODO: Temporary workaround until GraphQL ecosystem will fully support 'interfaces' on interface types.
        if (
            array_key_exists('interfaces', $implementingIntrospection)
            && $implementingIntrospection['interfaces'] === null
            && $implementingIntrospection['kind'] === TypeKind::INTERFACE
        ) {
            return [];
        }

        if (! array_key_exists('interfaces', $implementingIntrospection)) {
            $safeIntrospection = Utils::printSafeJson($implementingIntrospection);
            throw new InvariantViolation("Introspection result missing interfaces: {$safeIntrospection}.");
        }

        return array_map(
            [$this, 'getInterfaceType'],
            $implementingIntrospection['interfaces']
        );
    }

    /**
     * @param array<string, mixed> $object
     *
     * @throws InvariantViolation
     */
    private function buildObjectDef(array $object): ObjectType
    {
        return new ObjectType([
            'name' => $object['name'],
            'description' => $object['description'],
            'interfaces' => fn (): array => $this->buildImplementationsList($object),
            'fields' => fn (): array => $this->buildFieldDefMap($object),
        ]);
    }

    /**
     * @param array<string, mixed> $interface
     *
     * @throws InvariantViolation
     */
    private function buildInterfaceDef(array $interface): InterfaceType
    {
        return new InterfaceType([
            'name' => $interface['name'],
            'description' => $interface['description'],
            'fields' => fn (): array => $this->buildFieldDefMap($interface),
            'interfaces' => fn (): array => $this->buildImplementationsList($interface),
        ]);
    }

    /**
     * @param array<string, mixed> $union
     *
     * @throws InvariantViolation
     */
    private function buildUnionDef(array $union): UnionType
    {
        if (! array_key_exists('possibleTypes', $union)) {
            $safeUnion = Utils::printSafeJson($union);
            throw new InvariantViolation("Introspection result missing possibleTypes: {$safeUnion}.");
        }

        return new UnionType([
            'name' => $union['name'],
            'description' => $union['description'],
            'types' => fn (): array => array_map(
                [$this, 'getObjectType'],
                $union['possibleTypes']
            ),
        ]);
    }

    /**
     * @param array<string, mixed> $enum
     *
     * @throws InvariantViolation
     */
    private function buildEnumDef(array $enum): EnumType
    {
        if (! array_key_exists('enumValues', $enum)) {
            $safeEnum = Utils::printSafeJson($enum);
            throw new InvariantViolation("Introspection result missing enumValues: {$safeEnum}.");
        }

        $values = [];
        foreach ($enum['enumValues'] as $value) {
            $values[$value['name']] = [
                'description' => $value['description'],
                'deprecationReason' => $value['deprecationReason'],
            ];
        }

        return new EnumType([
            'name' => $enum['name'],
            'description' => $enum['description'],
            'values' => $values,
        ]);
    }

    /**
     * @param array<string, mixed> $inputObject
     *
     * @throws InvariantViolation
     */
    private function buildInputObjectDef(array $inputObject): InputObjectType
    {
        if (! array_key_exists('inputFields', $inputObject)) {
            $safeInputObject = Utils::printSafeJson($inputObject);
            throw new InvariantViolation("Introspection result missing inputFields: {$safeInputObject}.");
        }

        return new InputObjectType([
            'name' => $inputObject['name'],
            'description' => $inputObject['description'],
            'fields' => fn (): array => $this->buildInputValueDefMap($inputObject['inputFields']),
        ]);
    }

    /**
     * @param array<string, mixed> $typeIntrospection
     *
     * @throws \Exception
     * @throws InvariantViolation
     *
     * @return array<string, UnnamedFieldDefinitionConfig>
     */
    private function buildFieldDefMap(array $typeIntrospection): array
    {
        if (! array_key_exists('fields', $typeIntrospection)) {
            $safeType = Utils::printSafeJson($typeIntrospection);
            throw new InvariantViolation("Introspection result missing fields: {$safeType}.");
        }

        /** @var array<string, UnnamedFieldDefinitionConfig> $map */
        $map = [];
        foreach ($typeIntrospection['fields'] as $field) {
            if (! array_key_exists('args', $field)) {
                $safeField = Utils::printSafeJson($field);
                throw new InvariantViolation("Introspection result missing field args: {$safeField}.");
            }

            $map[$field['name']] = [
                'description' => $field['description'],
                'deprecationReason' => $field['deprecationReason'],
                'type' => $this->getOutputType($field['type']),
                'args' => $this->buildInputValueDefMap($field['args']),
            ];
        }

        // @phpstan-ignore-next-line unless the returned name was numeric, this works
        return $map;
    }

    /**
     * @param array<int, array<string, mixed>> $inputValueIntrospections
     *
     * @throws \Exception
     *
     * @return array<string, UnnamedInputObjectFieldConfig>
     */
    private function buildInputValueDefMap(array $inputValueIntrospections): array
    {
        /** @var array<string, UnnamedInputObjectFieldConfig> $map */
        $map = [];
        foreach ($inputValueIntrospections as $value) {
            $map[$value['name']] = $this->buildInputValue($value);
        }

        return $map;
    }

    /**
     * @param array<string, mixed> $inputValueIntrospection
     *
     * @throws \Exception
     * @throws SyntaxError
     *
     * @return UnnamedInputObjectFieldConfig
     */
    public function buildInputValue(array $inputValueIntrospection): array
    {
        $type = $this->getInputType($inputValueIntrospection['type']);

        $inputValue = [
            'description' => $inputValueIntrospection['description'],
            'type' => $type,
        ];

        if (isset($inputValueIntrospection['defaultValue'])) {
            $inputValue['defaultValue'] = AST::valueFromAST(
                Parser::parseValue($inputValueIntrospection['defaultValue']),
                $type
            );
        }

        return $inputValue;
    }

    /**
     * @param array<string, mixed> $directive
     *
     * @throws \Exception
     * @throws InvariantViolation
     */
    public function buildDirective(array $directive): Directive
    {
        if (! array_key_exists('args', $directive)) {
            $safeDirective = Utils::printSafeJson($directive);
            throw new InvariantViolation("Introspection result missing directive args: {$safeDirective}.");
        }

        if (! array_key_exists('locations', $directive)) {
            $safeDirective = Utils::printSafeJson($directive);
            throw new InvariantViolation("Introspection result missing directive locations: {$safeDirective}.");
        }

        return new Directive([
            'name' => $directive['name'],
            'description' => $directive['description'],
            'args' => $this->buildInputValueDefMap($directive['args']),
            'isRepeatable' => $directive['isRepeatable'] ?? false,
            'locations' => $directive['locations'],
        ]);
    }
}
