<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
declare(strict_types=1);

namespace Magento\Framework\GraphQlSchemaStitching;

use GraphQL\Type\Definition\ScalarType;
use GraphQL\Utils\BuildSchema;
use Magento\Framework\Component\ComponentRegistrar;
use Magento\Framework\Config\FileResolverInterface;
use Magento\Framework\Config\ReaderInterface;
use Magento\Framework\GraphQl\Type\TypeManagement;
use Magento\Framework\GraphQlSchemaStitching\GraphQlReader\TypeMetaReaderInterface as TypeReaderComposite;
use Magento\Framework\GraphQlSchemaStitching\GraphQlReader\Reader\InterfaceType;

/**
 * Reads *.graphqls files from modules and combines the results as array to be used with a library to configure objects
 */
class GraphQlReader implements ReaderInterface
{
    public const GRAPHQL_PLACEHOLDER_FIELD_NAME = 'placeholder_graphql_field';

    public const GRAPHQL_SCHEMA_FILE = 'schema.graphqls';

    /** @deprecated */
    public const GRAPHQL_INTERFACE = 'graphql_interface';

    /**
     * @var FileResolverInterface
     */
    private $fileResolver;

    /**
     * @var TypeReaderComposite
     */
    private $typeReader;

    /**
     * @var string
     */
    private $fileName;

    /**
     * @var string
     */
    private $defaultScope;

    /**
     * @var ComponentRegistrar
     */
    private static $componentRegistrar;

    /**
     * @param FileResolverInterface $fileResolver
     * @param TypeReaderComposite $typeReader
     * @param string $fileName
     * @param string $defaultScope
     */
    public function __construct(
        FileResolverInterface $fileResolver,
        TypeReaderComposite $typeReader,
        $fileName = self::GRAPHQL_SCHEMA_FILE,
        $defaultScope = 'global'
    ) {
        $this->fileResolver = $fileResolver;
        $this->typeReader = $typeReader;
        $this->defaultScope = $defaultScope;
        $this->fileName = $fileName;
        $typeManagement = new TypeManagement();
        $typeManagement->overrideStandardGraphQLTypes();
    }

    /**
     * @inheritdoc
     *
     * @param string|null $scope
     * @return array
     */
    public function read($scope = null): array
    {
        $results = [];
        $scope = $scope ?: $this->defaultScope;
        $schemaFiles = $this->fileResolver->get($this->fileName, $scope);

        if (!count($schemaFiles)) {
            return $results;
        }

        /**
         * Compatible with @see GraphQlReader::parseTypes
         */
        $knownTypes = [];
        foreach ($schemaFiles as $filePath => $partialSchemaContent) {
            $partialSchemaTypes = $this->parseTypes($partialSchemaContent);

            // Keep declarations from current partial schema, add missing declarations from all previously read schemas
            $knownTypes = $partialSchemaTypes + $knownTypes;
            $schemaContent = implode("\n", $knownTypes);

            $partialResults = $this->readPartialTypes($schemaContent);
            $results = array_replace_recursive($results, $partialResults);
            $results = $this->addModuleNameToTypes($results, $filePath);
        }

        return $this->copyInterfaceFieldsToConcreteTypes($results);
    }

    /**
     * Extract types as string from schema as string
     *
     * @param string $graphQlSchemaContent
     * @return string[] [$typeName => $typeDeclaration, ...]
     */
    private function readPartialTypes(string $graphQlSchemaContent): array
    {
        $partialResults = [];

        $graphQlSchemaContent = $this->addPlaceHolderInSchema($graphQlSchemaContent);

        $schema = BuildSchema::build($graphQlSchemaContent, null, ['assumeValid'=> true, 'assumeValidSDL' => true]);

        foreach ($schema->getTypeMap() as $typeName => $typeMeta) {
            // Only process custom types and skip built-in object types
            if ((strpos($typeName, '__') !== 0 && (!$typeMeta instanceof ScalarType))) {
                $type = $this->typeReader->read($typeMeta);
                if (!empty($type)) {
                    $partialResults[$typeName] = $type;
                } else {
                    throw new \LogicException("'{$typeName}' cannot be processed.");
                }
            }
        }

        return $this->removePlaceholderFromResults($partialResults);
    }

    /**
     * Extract types as string from a larger string that represents the graphql schema using regular expressions
     *
     * @param string $graphQlSchemaContent
     * @return string[] [$typeName => $typeDeclaration, ...]
     */
    private function parseTypes(string $graphQlSchemaContent): array
    {
        $typeKindsPattern = '(type|interface|union|enum|input)';
        $typeNamePattern = '([_A-Za-z][_0-9A-Za-z]+)';
        $typeDefinitionPattern = '([^\{]*)(\{[^\}]*\})';
        $spacePattern = '[\s\t\n\r]+';

        preg_match_all(
            "/{$typeKindsPattern}{$spacePattern}{$typeNamePattern}{$spacePattern}{$typeDefinitionPattern}/i",
            $graphQlSchemaContent,
            $matches
        );

        $parsedTypes = [];

        if (!empty($matches)) {
            foreach ($matches[0] as $matchKey => $matchValue) {
                $matches[0][$matchKey] = $this->convertInterfacesToAnnotations($matchValue);
            }

            /**
             * $matches[0] is an indexed array with the whole type definitions
             * $matches[2] is an indexed array with type names
             */
            $parsedTypes = array_combine($matches[2], $matches[0]);
        }
        return $parsedTypes;
    }

    /**
     * Copy interface fields to concrete types
     *
     * @param array $source
     * @return array
     */
    private function copyInterfaceFieldsToConcreteTypes(array $source): array
    {
        foreach ($source as $interface) {
            if ($interface['type'] ?? '' == InterfaceType::GRAPHQL_INTERFACE) {
                foreach ($source as $typeName => $type) {
                    if (isset($type['implements'])
                        && isset($type['implements'][$interface['name']])
                        && isset($type['implements'][$interface['name']]['copyFields'])
                        && $type['implements'][$interface['name']]['copyFields'] === true
                    ) {
                        $source[$typeName]['fields'] = isset($type['fields'])
                            ? array_replace($interface['fields'], $type['fields']) : $interface['fields'];
                    }
                }
            }
        }

        return $source;
    }

    /**
     * Find the implements statement and convert them to annotation to enable copy fields feature
     *
     * @param string $graphQlSchemaContent
     * @return string
     */
    private function convertInterfacesToAnnotations(string $graphQlSchemaContent): string
    {
        $implementsKindsPattern = 'implements';
        $typeNamePattern = '([_A-Za-z][_0-9A-Za-z]+)';
        $spacePattern = '([\s\t\n\r]+)';
        $spacePatternNotMandatory = '[\s\t\n\r]*';
        preg_match_all(
            "/{$spacePattern}{$implementsKindsPattern}{$spacePattern}{$typeNamePattern}"
            . "(,{$spacePatternNotMandatory}$typeNamePattern)*/im",
            $graphQlSchemaContent,
            $allMatchesForImplements
        );

        if (!empty($allMatchesForImplements)) {
            foreach (array_unique($allMatchesForImplements[0]) as $implementsString) {
                $implementsStatementString = preg_replace(
                    "/{$spacePattern}{$implementsKindsPattern}{$spacePattern}/m",
                    '',
                    $implementsString
                );
                preg_match_all(
                    "/{$typeNamePattern}+/im",
                    $implementsStatementString,
                    $implementationsMatches
                );

                if (!empty($implementationsMatches)) {
                    $annotationString = ' @implements(interfaces: [';
                    foreach ($implementationsMatches[0] as $interfaceName) {
                        $annotationString.= "\"{$interfaceName}\", ";
                    }
                    $annotationString = rtrim($annotationString, ', ');
                    $annotationString .= ']) ';
                    $graphQlSchemaContent = str_replace($implementsString, $annotationString, $graphQlSchemaContent);
                }
            }
        }

        return $graphQlSchemaContent;
    }

    /**
     * Add a placeholder field into the schema to allow parser to not throw error on empty types
     * This method is paired with @see self::removePlaceholderFromResults()
     * This is needed so that the placeholder doens't end up in the actual schema
     *
     * @param string $graphQlSchemaContent
     * @return string
     */
    private function addPlaceHolderInSchema(string $graphQlSchemaContent): string
    {
        $placeholderField = self::GRAPHQL_PLACEHOLDER_FIELD_NAME;
        $typesKindsPattern = '(type|interface|input|union)';
        $enumKindsPattern = '(enum)';
        $typeNamePattern = '([_A-Za-z][_0-9A-Za-z]+)';
        $typeDefinitionPattern = '([^\{]*)(\{[\s\t\n\r^\}]*\})';
        $spacePattern = '([\s\t\n\r]+)';

        //add placeholder in empty types
        $graphQlSchemaContent = preg_replace(
            "/{$typesKindsPattern}{$spacePattern}{$typeNamePattern}{$spacePattern}{$typeDefinitionPattern}/im",
            "\$1\$2\$3\$4\$5{\n{$placeholderField}: String\n}",
            $graphQlSchemaContent
        );

        //add placeholder in empty enums
        $graphQlSchemaContent = preg_replace(
            "/{$enumKindsPattern}{$spacePattern}{$typeNamePattern}{$spacePattern}{$typeDefinitionPattern}/im",
            "\$1\$2\$3\$4\$5{\n{$placeholderField}\n}",
            $graphQlSchemaContent
        );
        return $graphQlSchemaContent;
    }

    /**
     * Remove parsed placeholders as these should not be present in final result
     *
     * @param array $partialResults
     * @return array
     */
    private function removePlaceholderFromResults(array $partialResults): array
    {
        $placeholderField = self::GRAPHQL_PLACEHOLDER_FIELD_NAME;
        //remove parsed placeholders
        foreach ($partialResults as $typeKeyName => $partialResultTypeArray) {
            if (isset($partialResultTypeArray['fields'][$placeholderField])) {
                //unset placeholder for fields
                unset($partialResults[$typeKeyName]['fields'][$placeholderField]);
            } elseif (isset($partialResultTypeArray['items'][$placeholderField])) {
                //unset placeholder for enums
                unset($partialResults[$typeKeyName]['items'][$placeholderField]);
            }
        }
        return $partialResults;
    }

    /**
     * Get a module name by file path
     *
     * @param string $file
     * @return string
     */
    private static function getModuleNameForRelevantFile(string $file): string
    {
        if (!isset(self::$componentRegistrar)) {
            self::$componentRegistrar = new ComponentRegistrar();
        }
        $foundModuleName = '';
        foreach (self::$componentRegistrar->getPaths(ComponentRegistrar::MODULE) as $moduleName => $moduleDir) {
            if (strpos($file, $moduleDir . '/') !== false) {
                $foundModuleName = str_replace('_', '\\', $moduleName);
                break;
            }
        }

        return $foundModuleName;
    }

    /**
     * Add a module name to types
     *
     * @param array $source
     * @param string $filePath
     * @return array
     */
    private function addModuleNameToTypes(array $source, string $filePath): array
    {
        foreach ($source as $typeName => $typeDefinition) {
            if (!isset($typeDefinition['module'])) {
                $hasTypeResolver = (bool)($typeDefinition['typeResolver'] ?? false);
                $hasImplements = (bool)($typeDefinition['implements'] ?? false);
                $typeDefinition = (bool)($typeDefinition['type'] ?? false);
                if ((($typeDefinition === InterfaceType::GRAPHQL_INTERFACE && $hasTypeResolver) || $hasImplements)) {
                    $source[$typeName]['module'] = self::getModuleNameForRelevantFile($filePath);
                }
            }
        }

        return $source;
    }
}
