<?php

namespace Drupal\Tests;

use Composer\Semver\Semver;

/**
 * Tests Composer integration.
 *
 * @group Composer
 */
class ComposerIntegrationTest extends UnitTestCase {

  /**
   * The minimum PHP version supported by Drupal.
   *
   * @see https://www.drupal.org/docs/8/system-requirements/web-server
   *
   * @todo Remove as part of https://www.drupal.org/node/2908079
   */
  const MIN_PHP_VERSION = '5.5.9';

  /**
   * Gets human-readable JSON error messages.
   *
   * @return string[]
   *   Keys are JSON_ERROR_* constants.
   */
  protected function getErrorMessages() {
    $messages = [
      0 => 'No errors found',
      JSON_ERROR_DEPTH => 'The maximum stack depth has been exceeded',
      JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
      JSON_ERROR_CTRL_CHAR => 'Control character error, possibly incorrectly encoded',
      JSON_ERROR_SYNTAX => 'Syntax error',
      JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded',
    ];

    if (version_compare(phpversion(), '5.5.0', '>=')) {
      $messages[JSON_ERROR_RECURSION] = 'One or more recursive references in the value to be encoded';
      $messages[JSON_ERROR_INF_OR_NAN] = 'One or more NAN or INF values in the value to be encoded';
      $messages[JSON_ERROR_UNSUPPORTED_TYPE] = 'A value of a type that cannot be encoded was given';
    }

    return $messages;
  }

  /**
   * Gets the paths to the folders that contain the Composer integration.
   *
   * @return string[]
   *   The paths.
   */
  protected function getPaths() {
    return [
      $this->root,
      $this->root . '/core',
      $this->root . '/core/lib/Drupal/Component/Annotation',
      $this->root . '/core/lib/Drupal/Component/Assertion',
      $this->root . '/core/lib/Drupal/Component/Bridge',
      $this->root . '/core/lib/Drupal/Component/ClassFinder',
      $this->root . '/core/lib/Drupal/Component/Datetime',
      $this->root . '/core/lib/Drupal/Component/DependencyInjection',
      $this->root . '/core/lib/Drupal/Component/Diff',
      $this->root . '/core/lib/Drupal/Component/Discovery',
      $this->root . '/core/lib/Drupal/Component/EventDispatcher',
      $this->root . '/core/lib/Drupal/Component/FileCache',
      $this->root . '/core/lib/Drupal/Component/FileSystem',
      $this->root . '/core/lib/Drupal/Component/Gettext',
      $this->root . '/core/lib/Drupal/Component/Graph',
      $this->root . '/core/lib/Drupal/Component/HttpFoundation',
      $this->root . '/core/lib/Drupal/Component/PhpStorage',
      $this->root . '/core/lib/Drupal/Component/Plugin',
      $this->root . '/core/lib/Drupal/Component/ProxyBuilder',
      $this->root . '/core/lib/Drupal/Component/Render',
      $this->root . '/core/lib/Drupal/Component/Serialization',
      $this->root . '/core/lib/Drupal/Component/Transliteration',
      $this->root . '/core/lib/Drupal/Component/Utility',
      $this->root . '/core/lib/Drupal/Component/Uuid',
    ];
  }

  /**
   * Tests composer.json.
   */
  public function testComposerJson() {
    foreach ($this->getPaths() as $path) {
      $json = file_get_contents($path . '/composer.json');
      $result = json_decode($json);
      $this->assertNotNull($result, $this->getErrorMessages()[json_last_error()]);
    }
  }

  /**
   * Tests composer.lock content-hash.
   */
  public function testComposerLockHash() {
    $content_hash = self::getContentHash(file_get_contents($this->root . '/composer.json'));
    $lock = json_decode(file_get_contents($this->root . '/composer.lock'), TRUE);
    $this->assertSame($content_hash, $lock['content-hash']);
  }

  /**
   * Tests composer.json versions.
   *
   * @param string $path
   *   Path to a composer.json to test.
   *
   * @dataProvider providerTestComposerJson
   */
  public function testComposerTilde($path) {
    $content = json_decode(file_get_contents($path), TRUE);
    $composer_keys = array_intersect(['require', 'require-dev'], array_keys($content));
    if (empty($composer_keys)) {
      $this->markTestSkipped("$path has no keys to test");
    }
    foreach ($composer_keys as $composer_key) {
      foreach ($content[$composer_key] as $dependency => $version) {
        // We allow tildes if the dependency is a Symfony component.
        // @see https://www.drupal.org/node/2887000
        if (strpos($dependency, 'symfony/') === 0) {
          continue;
        }
        $this->assertFalse(strpos($version, '~'), "Dependency $dependency in $path contains a tilde, use a caret.");
      }
    }
  }

  /**
   * Data provider for all the composer.json provided by Drupal core.
   *
   * @return array
   */
  public function providerTestComposerJson() {
    $root = realpath(__DIR__ . '/../../../../');
    $tests = [[$root . '/composer.json']];
    $directory = new \RecursiveDirectoryIterator($root . '/core');
    $iterator = new \RecursiveIteratorIterator($directory);
    /** @var \SplFileInfo $file */
    foreach ($iterator as $file) {
      if ($file->getFilename() === 'composer.json' && strpos($file->getPath(), 'core/modules/system/tests/fixtures/HtaccessTest') === FALSE) {
        $tests[] = [$file->getRealPath()];
      }
    }
    return $tests;
  }

  /**
   * Tests core's composer.json replace section.
   *
   * Verify that all core modules are also listed in the 'replace' section of
   * core's composer.json.
   */
  public function testAllModulesReplaced() {
    // Assemble a path to core modules.
    $module_path = $this->root . '/core/modules';

    // Grab the 'replace' section of the core composer.json file.
    $json = json_decode(file_get_contents($this->root . '/core/composer.json'));
    $composer_replace_packages = (array) $json->replace;

    // Get a list of all the files in the module path.
    $folders = scandir($module_path);

    // Make sure we only deal with directories that aren't . or ..
    $module_names = [];
    $discard = ['.', '..'];
    foreach ($folders as $file_name) {
      if ((!in_array($file_name, $discard)) && is_dir($module_path . '/' . $file_name)) {
        $module_names[] = $file_name;
      }
    }

    // Assert that each core module has a corresponding 'replace' in
    // composer.json.
    foreach ($module_names as $module_name) {
      $this->assertArrayHasKey(
        'drupal/' . $module_name,
        $composer_replace_packages,
        'Unable to find ' . $module_name . ' in replace list of composer.json'
      );
    }
  }

  /**
   * Tests package requirements for the minimum supported PHP version by Drupal.
   *
   * @todo This can be removed when DrupalCI supports dependency regression
   *   testing in https://www.drupal.org/node/2874198
   */
  public function testMinPHPVersion() {
    // Check for lockfile in the application root. If the lockfile does not
    // exist, then skip this test.
    $lockfile = $this->root . '/composer.lock';
    if (!file_exists($lockfile)) {
      $this->markTestSkipped('/composer.lock is not available.');
    }

    $lock = json_decode(file_get_contents($lockfile), TRUE);

    // Check the PHP version for each installed non-development  package. The
    // testing infrastructure uses the uses the development packages, and may
    // update them for particular environment configurations. In particular,
    // PHP 7.2+ require an updated version of phpunit, which is incompatible
    // with Drupal's minimum PHP requirement.
    foreach ($lock['packages'] as $package) {
      if (isset($package['require']['php'])) {
        $this->assertTrue(Semver::satisfies(static::MIN_PHP_VERSION, $package['require']['php']), $package['name'] . ' has a PHP dependency requirement of "' . $package['require']['php'] . '"');
      }
    }
  }

  // @codingStandardsIgnoreStart
  /**
   * The following method is copied from \Composer\Package\Locker.
   *
   * @see https://github.com/composer/composer
   */
  /**
   * Returns the md5 hash of the sorted content of the composer file.
   *
   * @param string $composerFileContents The contents of the composer file.
   *
   * @return string
   */
  protected static function getContentHash($composerFileContents)
  {
    $content = json_decode($composerFileContents, true);

    $relevantKeys = array(
      'name',
      'version',
      'require',
      'require-dev',
      'conflict',
      'replace',
      'provide',
      'minimum-stability',
      'prefer-stable',
      'repositories',
      'extra',
    );

    $relevantContent = array();

    foreach (array_intersect($relevantKeys, array_keys($content)) as $key) {
      $relevantContent[$key] = $content[$key];
    }
    if (isset($content['config']['platform'])) {
      $relevantContent['config']['platform'] = $content['config']['platform'];
    }

    ksort($relevantContent);

    return md5(json_encode($relevantContent));
  }
  // @codingStandardsIgnoreEnd

}
