<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
namespace Magento\Test\Integrity;

use Magento\Framework\App\Bootstrap;
use Magento\Framework\Component\ComponentRegistrar;
use Magento\Framework\Composer\MagentoComponent;

/**
 * A test that enforces validity of composer.json files and any other conventions in Magento components
 */
class ComposerTest extends \PHPUnit\Framework\TestCase
{

    /**
     * @var string
     */
    private static $root;

    /**
     * @var \stdClass
     */
    private static $rootJson;

    /**
     * @var array
     */
    private static $dependencies;

    /**
     * @var \Magento\Framework\ObjectManagerInterface
     */
    private static $objectManager;

    /**
     * @var string[]
     */
    private static $rootComposerModuleBlacklist = [];

    /**
     * @var string[]
     */
    private static $moduleNameBlacklist;

    /**
     * @var string
     */
    private static $magentoFrameworkLibraryName = 'magento/framework';

    public static function setUpBeforeClass(): void
    {
        self::$root = BP;
        self::$rootJson = json_decode(file_get_contents(self::$root . '/composer.json'), true);
        self::$dependencies = [];
        self::$objectManager = Bootstrap::create(BP, $_SERVER)->getObjectManager();
        // A block can be whitelisted and thus not be required to be public
        self::$rootComposerModuleBlacklist = self::getBlacklist(
            __DIR__ . '/_files/blacklist/composer_root_modules*.txt'
        );
        self::$moduleNameBlacklist = self::getBlacklist(__DIR__ . '/_files/blacklist/composer_module_names*.txt');
    }

    /**
     * Return aggregated blacklist
     *
     * @param string $pattern
     * @return string[]
     */
    public static function getBlacklist(string $pattern)
    {
        $blacklist = [];
        foreach (glob($pattern) as $list) {
            //phpcs:ignore Magento2.Performance.ForeachArrayMerge
            $blacklist = array_merge($blacklist, file($list, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
        }
        return $blacklist;
    }

    public function testValidComposerJson()
    {
        $invoker = new \Magento\Framework\App\Utility\AggregateInvoker($this);
        $invoker(
            /**
             * @param string $dir
             * @param string $packageType
             */
            function ($dir, $packageType) {
                $file = $dir . '/composer.json';
                $this->assertFileExists($file);
                $this->validateComposerJsonFile($dir);
                $contents = file_get_contents($file);
                $json = json_decode($contents);
                $this->assertCodingStyle($contents);
                $this->assertMagentoConventions($dir, $packageType, $json);
            },
            $this->validateComposerJsonDataProvider()
        );
    }

    /**
     * @return array
     */
    public function validateComposerJsonDataProvider()
    {
        $root = BP;
        $componentRegistrar = new ComponentRegistrar();
        $result = [];
        foreach ($componentRegistrar->getPaths(ComponentRegistrar::MODULE) as $dir) {
            $result[$dir] = [$dir, 'magento2-module'];
        }
        foreach ($componentRegistrar->getPaths(ComponentRegistrar::LANGUAGE) as $dir) {
            $result[$dir] = [$dir, 'magento2-language'];
        }
        foreach ($componentRegistrar->getPaths(ComponentRegistrar::THEME) as $dir) {
            $result[$dir] = [$dir, 'magento2-theme'];
        }
        foreach ($componentRegistrar->getPaths(ComponentRegistrar::LIBRARY) as $dir) {
            $result[$dir] = [$dir, 'magento2-library'];
        }
        $result[$root] = [$root, 'project'];

        return $result;
    }

    /**
     * Validate a composer.json under the given path
     *
     * @param string $path path to composer.json
     */
    private function validateComposerJsonFile($path)
    {
        /** @var \Magento\Framework\Composer\MagentoComposerApplicationFactory $appFactory */
        $appFactory = self::$objectManager->get(\Magento\Framework\Composer\MagentoComposerApplicationFactory::class);
        $app = $appFactory->create();

        try {
            $app->runComposerCommand(['command' => 'validate'], $path);
        } catch (\RuntimeException $exception) {
            $this->fail($exception->getMessage());
        }
    }

    /**
     * Some of coding style conventions
     *
     * @param string $contents
     */
    private function assertCodingStyle($contents)
    {
        $this->assertDoesNotMatchRegularExpression('/" :\s*["{]/', $contents, 'Coding style: there should be no space before colon.');
        $this->assertDoesNotMatchRegularExpression('/":["{]/', $contents, 'Coding style: a space is necessary after colon.');
    }

    /**
     * Enforce Magento-specific conventions to a composer.json file
     *
     * @param string $dir
     * @param string $packageType
     * @param \StdClass $json
     * @throws \InvalidArgumentException
     */
    private function assertMagentoConventions($dir, $packageType, \StdClass $json)
    {
        $this->assertObjectHasAttribute('name', $json);
        $this->assertObjectHasAttribute('license', $json);
        $this->assertObjectHasAttribute('type', $json);
        $this->assertObjectHasAttribute('require', $json);
        $this->assertEquals($packageType, $json->type);
        if ($packageType !== 'project') {
            self::$dependencies[] = $json->name;
            $this->assertAutoloadRegistrar($json, $dir);
            $this->assertNoMap($json);
        }
        switch ($packageType) {
            case 'magento2-module':
                $xml = simplexml_load_file("$dir/etc/module.xml");
                if ($this->isVendorMagento($json->name)) {
                    $this->assertConsistentModuleName($xml, $json->name);
                }
                $this->assertDependsOnPhp($json->require);
                $this->assertPhpVersionInSync($json->name, $json->require->php);
                $this->assertDependsOnFramework($json->require);
                $this->assertRequireInSync($json);
                $this->assertAutoload($json);
                $this->assertNoVersionSpecified($json);
                break;
            case 'magento2-language':
                $this->assertMatchesRegularExpression('/^magento\/language\-[a-z]{2}_([a-z]{4}_)?[a-z]{2}$/', $json->name);
                $this->assertDependsOnFramework($json->require);
                $this->assertRequireInSync($json);
                $this->assertNoVersionSpecified($json);
                break;
            case 'magento2-theme':
                $this->assertMatchesRegularExpression('/^magento\/theme-(?:adminhtml|frontend)(\-[a-z0-9_]+)+$/', $json->name);
                $this->assertDependsOnPhp($json->require);
                $this->assertPhpVersionInSync($json->name, $json->require->php);
                $this->assertDependsOnFramework($json->require);
                $this->assertRequireInSync($json);
                $this->assertNoVersionSpecified($json);
                break;
            case 'magento2-library':
                $this->assertDependsOnPhp($json->require);
                $this->assertMatchesRegularExpression('/^magento\/framework*/', $json->name);
                $this->assertPhpVersionInSync($json->name, $json->require->php);
                $this->assertRequireInSync($json);
                $this->assertAutoload($json);
                $this->assertNoVersionSpecified($json);
                break;
            case 'project':
                $this->checkProject();
                $this->assertNoVersionSpecified($json);
                break;
            default:
                throw new \InvalidArgumentException("Unknown package type {$packageType}");
        }
    }

    /**
     * Checks if package vendor is Magento.
     *
     * @param string $packageName
     * @return bool
     */
    private function isVendorMagento(string $packageName): bool
    {
        return strpos($packageName, 'magento/') === 0;
    }

    /**
     * Assert that component registrar is autoloaded in composer json
     *
     * @param \StdClass $json
     * @param string $dir
     */
    private function assertAutoloadRegistrar(\StdClass $json, $dir)
    {
        $error = 'There must be an "autoload->files" node in composer.json of each Magento component.';
        $this->assertObjectHasAttribute('autoload', $json, $error);
        $this->assertObjectHasAttribute('files', $json->autoload, $error);
        $this->assertTrue(in_array("registration.php", $json->autoload->files), $error);
        $this->assertFileExists("$dir/registration.php");
    }

    /**
     * Version must not be specified in the root and package composer JSON files in Git.
     *
     * All versions are added by tools during release publication by version setter tool.
     *
     * @param \StdClass $json
     */
    private function assertNoVersionSpecified(\StdClass $json)
    {
        $errorMessage = 'Version must not be specified in the root and package composer JSON files in Git';
        $this->assertObjectNotHasAttribute('version', $json, $errorMessage);
    }

    /**
     * Assert that there is PSR-4 autoload in composer json
     *
     * @param \StdClass $json
     */
    private function assertAutoload(\StdClass $json)
    {
        $errorMessage = 'There must be an "autoload->psr-4" section in composer.json of each Magento component.';
        $this->assertObjectHasAttribute('autoload', $json, $errorMessage);
        $this->assertObjectHasAttribute('psr-4', $json->autoload, $errorMessage);
    }

    /**
     * Assert that there is map in specified composer json
     *
     * @param \StdClass $json
     */
    private function assertNoMap(\StdClass $json)
    {
        $error = 'There is no "extra->map" node in composer.json of each Magento component.';
        $this->assertObjectNotHasAttribute('extra', $json, $error);
    }

    /**
     * Enforce package naming conventions for modules
     *
     * @param \SimpleXMLElement $xml
     * @param string $packageName
     */
    private function assertConsistentModuleName(\SimpleXMLElement $xml, $packageName)
    {
        if (!in_array($packageName, self::$moduleNameBlacklist)) {
            $moduleName = (string)$xml->module->attributes()->name;
            $expectedPackageName = $this->convertModuleToPackageName($moduleName);
            $this->assertEquals(
                $expectedPackageName,
                $packageName,
                "For the module '{$moduleName}', the expected package name is '{$expectedPackageName}'"
            );
        }
    }

    /**
     * Make sure a component depends on php version
     *
     * @param \StdClass $json
     */
    private function assertDependsOnPhp(\StdClass $json)
    {
        $this->assertObjectHasAttribute('php', $json, 'This component is expected to depend on certain PHP version(s)');
    }

    /**
     * Make sure a component depends on magento/framework component
     *
     * @param \StdClass $json
     */
    private function assertDependsOnFramework(\StdClass $json)
    {
        $this->assertObjectHasAttribute(
            self::$magentoFrameworkLibraryName,
            $json,
            'This component is expected to depend on ' . self::$magentoFrameworkLibraryName
        );
    }

    /**
     * Assert that PHP versions in root composer.json and Magento component's composer.json are not out of sync
     *
     * @param string $name
     * @param string $phpVersion
     */
    private function assertPhpVersionInSync($name, $phpVersion)
    {
        if (isset(self::$rootJson['require']['php'])) {
            $composerVersionsPattern = '{\s*\|\|?\s*}';
            $rootPhpVersions = preg_split($composerVersionsPattern, self::$rootJson['require']['php']);
            $modulePhpVersions = preg_split($composerVersionsPattern, $phpVersion);

            $this->assertEmpty(
                array_diff($rootPhpVersions, $modulePhpVersions),
                "PHP version {$phpVersion} in component {$name} is inconsistent with version "
                . self::$rootJson['require']['php'] . ' in root composer.json'
            );
        }
    }

    /**
     * Make sure requirements of components are reflected in root composer.json
     *
     * @param \StdClass $json
     * @return void
     */
    private function assertRequireInSync(\StdClass $json)
    {
        if (preg_match('/magento\/project-*/', self::$rootJson['name']) == 1) {
            return;
        }
        if (!in_array($json->name, self::$rootComposerModuleBlacklist) && isset($json->require)) {
            $this->checkPackageInRootComposer($json);
        }
    }

    /**
     * Check if package is reflected in root composer.json
     *
     * @param \StdClass $json
     * @return void
     */
    private function checkPackageInRootComposer(\StdClass $json)
    {
        $name = $json->name;
        $errors = [];
        foreach (array_keys((array)$json->require) as $depName) {
            if ($depName == 'magento/magento-composer-installer') {
                // Magento Composer Installer is not needed for already existing components
                continue;
            }
            if (!isset(self::$rootJson['require-dev'][$depName]) && !isset(self::$rootJson['require'][$depName])
                && !isset(self::$rootJson['replace'][$depName])) {
                $errors[] = "'$name' depends on '$depName'";
            }
        }
        if (!empty($errors)) {
            $this->fail(
                "The following dependencies are missing in root 'composer.json',"
                . " while declared in child components.\n"
                . "Consider adding them to 'require-dev' section (if needed for child components only),"
                . " to 'replace' section (if they are present in the project),"
                . " to 'require' section (if needed for the skeleton).\n"
                . join("\n", $errors)
            );
        }
    }

    /**
     * Convert a fully qualified module name to a composer package name according to conventions
     *
     * @param string $moduleName
     * @return string
     */
    private function convertModuleToPackageName($moduleName)
    {
        list($vendor, $name) = explode('_', $moduleName, 2);
        $package = 'module';
        foreach (preg_split('/([A-Z\d][a-z]*)/', $name, -1, PREG_SPLIT_DELIM_CAPTURE) as $chunk) {
            $package .= $chunk ? "-{$chunk}" : '';
        }
        return strtolower("{$vendor}/{$package}");
    }

    public function testComponentPathsInRoot()
    {
        if (!isset(self::$rootJson['extra']) || !isset(self::$rootJson['extra']['component_paths'])) {
            $this->markTestSkipped("The root composer.json file doesn't mention any extra component paths information");
        }
        $this->assertArrayHasKey(
            'replace',
            self::$rootJson,
            "If there are any component paths specified, then they must be reflected in 'replace' section"
        );
        $flat = $this->getFlatPathsInfo(self::$rootJson['extra']['component_paths']);
        foreach ($flat as $item) {
            list($component, $path) = $item;
            $this->assertFileExists(
                self::$root . '/' . $path,
                "Missing or invalid component path: {$component} -> {$path}"
            );
            $this->assertArrayHasKey(
                $component,
                self::$rootJson['replace'],
                "The {$component} is specified in 'extra->component_paths', but missing in 'replace' section"
            );
        }
        foreach (array_keys(self::$rootJson['replace']) as $replace) {
            if (!MagentoComponent::matchMagentoComponent($replace)) {
                $this->assertArrayHasKey(
                    $replace,
                    self::$rootJson['extra']['component_paths'],
                    "The {$replace} is specified in 'replace', but missing in 'extra->component_paths' section"
                );
            }
        }
    }

    /**
     * @param array $info
     * @return array
     * @throws \Exception
     */
    private function getFlatPathsInfo(array $info)
    {
        $flat = [];
        foreach ($info as $key => $element) {
            if (is_string($element)) {
                $flat[] = [$key, $element];
            } elseif (is_array($element)) {
                foreach ($element as $path) {
                    $flat[] = [$key, $path];
                }
            } else {
                throw new \Exception("Unexpected element 'in extra->component_paths' section");
            }
        }

        return $flat;
    }

    /**
     * @return void
     */
    private function checkProject()
    {
        sort(self::$dependencies);
        $dependenciesListed = [];
        if (strpos(self::$rootJson['name'], 'magento/project-') !== 0) {
            $this->assertArrayHasKey(
                'replace',
                (array)self::$rootJson,
                'No "replace" section found in root composer.json'
            );
            foreach (array_keys((array)self::$rootJson['replace']) as $key) {
                if (MagentoComponent::matchMagentoComponent($key)) {
                    $dependenciesListed[] = $key;
                }
            }
            sort($dependenciesListed);
            $nonDeclaredDependencies = array_diff(
                self::$dependencies,
                $dependenciesListed,
                self::$rootComposerModuleBlacklist
            );
            $nonexistentDependencies = array_diff($dependenciesListed, self::$dependencies);
            $this->assertEmpty(
                $nonDeclaredDependencies,
                'Following dependencies are not declared in the root composer.json: '
                . join(', ', $nonDeclaredDependencies)
            );
            $this->assertEmpty(
                $nonexistentDependencies,
                'Following dependencies declared in the root composer.json do not exist: '
                . join(', ', $nonexistentDependencies)
            );
        }
    }

    /**
     * Check the correspondence between the root composer file and magento/framework composer file.
     */
    public function testConsistencyOfDeclarationsInComposerFiles()
    {
        if (strpos(self::$rootJson['name'], 'magento/project-') !== false) {
            // The Dependency test is skipped for vendor/magento build
            self::markTestSkipped(
                'The build is running for composer installation. Consistency test for composer files is skipped.'
            );
        }

        $componentRegistrar = new ComponentRegistrar();
        $magentoFrameworkLibraryDir =
            $componentRegistrar->getPath(ComponentRegistrar::LIBRARY, self::$magentoFrameworkLibraryName);
        $magentoFrameworkComposerFile =
            json_decode(
                file_get_contents($magentoFrameworkLibraryDir . DIRECTORY_SEPARATOR . 'composer.json'),
                true
            );

        $inconsistentDependencies = [];
        foreach ($magentoFrameworkComposerFile['require'] as $dependency => $constraint) {
            if (isset(self::$rootJson['require'][$dependency])
                && self::$rootJson['require'][$dependency] !== $constraint
            ) {
                $inconsistentDependencies[] = $dependency;
            }
        }

        $this->assertEmpty(
            $inconsistentDependencies,
            'There is a discrepancy between the declared versions of the following modules in "'
            . self::$magentoFrameworkLibraryName . '" and the root composer.json: '
            . implode(', ', $inconsistentDependencies)
        );
    }
}
