text: <?php
  
  namespace Kelunik\AcmeClient;
  
  use Amp\Sync\LocalSemaphore;
  use Amp\Sync\Lock;
  use InvalidArgumentException;
  use Kelunik\Acme\AcmeException;
  use Phar;
  use Symfony\Component\Yaml\Exception\ParseException;
  use Symfony\Component\Yaml\Yaml;
  use function Amp\call;
  use function Amp\coroutine;
  
  function concurrentMap(int $concurrency, array $values, callable $functor): array {
      $semaphore = new LocalSemaphore($concurrency);
  
      return \array_map(coroutine(function ($value, $key) use ($semaphore, $functor) {
          /** @var Lock $lock */
          $lock = yield $semaphore->acquire();
  
          try {
              return yield call($functor, $value, $key);
          } finally {
              $lock->release();
          }
      }), $values, \array_keys($values));
  }
  
  /**
   * Suggests a command based on similarity in a list of available commands.
   *
   * @param string $badCommand invalid command
   * @param array  $commands list of available commands
   * @param int    $suggestThreshold similarity threshold
   *
   * @return string suggestion or empty string if no command is similar enough
   */
  function suggestCommand(string $badCommand, array $commands, int $suggestThreshold = 70): string {
      $badCommand = \strtolower($badCommand);
  
      $bestMatch = '';
      $bestMatchPercentage = 0;
      $byRefPercentage = 0;
  
      foreach ($commands as $command) {
          \similar_text($badCommand, \strtolower($command), $byRefPercentage);
  
          if ($byRefPercentage > $bestMatchPercentage) {
              $bestMatchPercentage = $byRefPercentage;
              $bestMatch = $command;
          }
      }
  
      return $bestMatchPercentage >= $suggestThreshold ? $bestMatch : '';
  }
  
  /**
   * Resolves a server to a valid URI. If a valid shortcut is passed, it's resolved to the defined URI. If a URI without
   * protocol is passed, it will default to HTTPS.
   *
   * @param string $uri URI to resolve
   *
   * @return string resolved URI
   */
  function resolveServer(string $uri): string {
      $shortcuts = [
          'letsencrypt' => 'https://acme-v01.api.letsencrypt.org/directory',
          'letsencrypt:production' => 'https://acme-v01.api.letsencrypt.org/directory',
          'letsencrypt:staging' => 'https://acme-staging.api.letsencrypt.org/directory',
      ];
  
      if (isset($shortcuts[$uri])) {
          return $shortcuts[$uri];
      }
  
      if (\strpos($uri, '/') === false) {
          throw new InvalidArgumentException('Invalid server URI: ' . $uri);
      }
  
      $protocol = \substr($uri, 0, \strpos($uri, '://'));
  
      if (!$protocol || $protocol === $uri) {
          return "https://{$uri}";
      }
  
      return $uri;
  }
  
  /**
   * Transforms a directory URI to a valid filename for usage as key file name.
   *
   * @param string $server URI to the directory
   *
   * @return string identifier usable as file name
   */
  function serverToKeyname(string $server): string {
      $server = \substr($server, \strpos($server, '://') + 3);
  
      $keyFile = \str_replace('/', '.', $server);
      $keyFile = \preg_replace('@[^a-z0-9._-]@', '', $keyFile);
      $keyFile = \preg_replace("@\\.+@", '.', $keyFile);
  
      return $keyFile;
  }
  
  /**
   * Checks whether the application is currently running as Phar.
   *
   * @return bool {@code true} if running as Phar, {@code false} otherwise
   */
  function isPhar(): bool {
      if (!\class_exists('Phar')) {
          return false;
      }
  
      return Phar::running() !== '';
  }
  
  /**
   * Normalizes a path. Replaces all backslashes with slashes and removes trailing slashes.
   *
   * @param string $path path to normalize
   *
   * @return string normalized path
   */
  function normalizePath(string $path): string {
      return \rtrim(\str_replace("\\", '/', $path), '/');
  }
  
  /**
   * Gets the most appropriate config path to use.
   *
   * @return string|null Resolves to the config path or null.
   */
  function getConfigPath() {
      $paths = isPhar() ? [\substr(\dirname(Phar::running()), \strlen('phar://')) . '/acme-client.yml'] : [];
  
      if (0 !== \stripos(PHP_OS, 'WIN')) {
          if ($home = \getenv('HOME')) {
              $paths[] = $home . '/.acme-client.yml';
          }
  
          $paths[] = '/etc/acme-client.yml';
      }
  
      do {
          $path = \array_shift($paths);
  
          if (\file_exists($path)) {
              return $path;
          }
      } while (\count($paths));
  
      return null;
  }
  
  /**
   * Returns a consistent argument description for CLIMate. Valid arguments are "server" and "storage".
   *
   * @param string $argument argument name
   *
   * @return array CLIMate argument description
   * @throws AcmeException if the provided acme-client.yml file is invalid
   * @throws ConfigException if the provided configuration file is invalid
   */
  function getArgumentDescription($argument): array {
      $config = [];
  
      if ($configPath = getConfigPath()) {
          $configContent = \file_get_contents($configPath);
  
          try {
              $config = Yaml::parse($configContent);
  
              if (isset($config['server']) && !\is_string($config['server'])) {
                  throw new ConfigException("'server' set, but not a string.");
              }
  
              if (isset($config['storage']) && !\is_string($config['storage'])) {
                  throw new ConfigException("'storage' set, but not a string.");
              }
          } catch (ParseException $e) {
              throw new AcmeException("Unable to parse the configuration ({$configPath}): " . $e->getMessage());
          }
      }
  
      switch ($argument) {
          case 'server':
              $desc = [
                  'prefix' => 's',
                  'longPrefix' => 'server',
                  'description' => 'ACME server to use for registration and issuance of certificates.',
                  'required' => true,
              ];
  
              if (isset($config['server'])) {
                  $desc['required'] = false;
                  $desc['defaultValue'] = $config['server'];
              }
  
              return $desc;
  
          case 'storage':
              $isPhar = isPhar();
  
              $desc = [
                  'longPrefix' => 'storage',
                  'description' => 'Storage directory for account keys and certificates.',
                  'required' => $isPhar,
              ];
  
              if (!$isPhar) {
                  $desc['defaultValue'] = \dirname(__DIR__) . '/data';
              } elseif (isset($config['storage'])) {
                  $desc['required'] = false;
                  $desc['defaultValue'] = $config['storage'];
              }
  
              return $desc;
  
          default:
              throw new InvalidArgumentException('Unknown argument: ' . $argument);
      }
  }
  
  /**
   * Returns the binary that currently runs. Can be included in help texts about other commands.
   *
   * @return string binary callable, shortened based on PATH and CWD
   */
  function getBinary(): string {
      $binary = 'bin/acme';
  
      if (isPhar()) {
          $binary = \substr(Phar::running(), \strlen('phar://'));
  
          $path = \getenv('PATH');
          $locations = \explode(PATH_SEPARATOR, $path);
  
          $binaryPath = \dirname($binary);
  
          foreach ($locations as $location) {
              if ($location === $binaryPath) {
                  return \substr($binary, \strlen($binaryPath) + 1);
              }
          }
  
          $cwd = \getcwd();
  
          if ($cwd && \strpos($binary, $cwd) === 0) {
              $binary = '.' . \substr($binary, \strlen($cwd));
          }
      }
  
      return $binary;
  }
  
  /**
   * Cuts a text to a certain length and appends an ellipsis if necessary.
   *
   * @param string $text text to shorten
   * @param int    $max maximum length
   * @param string $append appendix when too long
   *
   * @return string shortened string
   */
  function ellipsis($text, $max = 70, $append = '…'): string {
      if (\strlen($text) <= $max) {
          return $text;
      }
  
      $out = \substr($text, 0, $max);
  
      if (\strpos($text, ' ') === false) {
          return $out . $append;
      }
  
      return \preg_replace("/\\w+$/", '', $out) . $append;
  }
  