<?php

declare(strict_types=1);

namespace Drupal\Core\Recipe;

use Drupal\Core\Config\Action\ConfigActionException;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Config\FileStorage;
use Drupal\Core\Config\InstallStorage;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\DefaultContent\Existing;
use Drupal\Core\DefaultContent\Importer;
use Drupal\Core\DefaultContent\Finder;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\PluralTranslatableMarkup;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

/**
 * Applies a recipe.
 *
 * This class is currently static and use \Drupal::service() in order to put off
 * having to solve issues caused by container rebuilds during module install and
 * configuration import.
 *
 * @internal
 *   This API is experimental.
 *
 * @todo https://www.drupal.org/i/3439717 Determine if there is a better to
 *   inject and re-inject services.
 */
final class RecipeRunner {

  /**
   * @param \Drupal\Core\Recipe\Recipe $recipe
   *   The recipe to apply.
   */
  public static function processRecipe(Recipe $recipe): void {
    static::processRecipes($recipe->recipes);
    static::processInstall($recipe->install, $recipe->config->getConfigStorage());
    static::processConfiguration($recipe);
    static::processContent($recipe->content);
    static::triggerEvent($recipe);
  }

  /**
   * Triggers the RecipeAppliedEvent.
   *
   * @param \Drupal\Core\Recipe\Recipe $recipe
   *   The recipe to apply.
   * @param array<mixed>|null $context
   *   The batch context if called by a batch.
   */
  public static function triggerEvent(Recipe $recipe, ?array &$context = NULL): void {
    $event = new RecipeAppliedEvent($recipe);
    \Drupal::service(EventDispatcherInterface::class)->dispatch($event);
    $context['message'] = t('Applied %recipe recipe.', ['%recipe' => $recipe->name]);
    $context['results']['recipe'][] = $recipe->name;
  }

  /**
   * Applies any recipes listed by the recipe.
   *
   * @param \Drupal\Core\Recipe\RecipeConfigurator $recipes
   *   The list of recipes to apply.
   */
  protected static function processRecipes(RecipeConfigurator $recipes): void {
    foreach ($recipes->recipes as $recipe) {
      static::processRecipe($recipe);
    }
  }

  /**
   * Installs the extensions.
   *
   * @param \Drupal\Core\Recipe\InstallConfigurator $install
   *   The list of extensions to install.
   * @param \Drupal\Core\Config\StorageInterface $recipeConfigStorage
   *   The recipe's configuration storage. Used to override extension provided
   *   configuration.
   */
  protected static function processInstall(InstallConfigurator $install, StorageInterface $recipeConfigStorage): void {
    if (!empty($install->modules)) {
      foreach (array_chunk($install->modules, Settings::get('core.multi_module_install_batch_size', 20)) as $modules_chunk) {
        static::installModules($modules_chunk, $recipeConfigStorage);
      }
    }

    // Themes can depend on modules so have to be installed after modules.
    foreach ($install->themes as $name) {
      static::installTheme($name, $recipeConfigStorage);
    }
  }

  /**
   * Creates configuration and applies configuration actions.
   *
   * @param \Drupal\Core\Recipe\Recipe $recipe
   *   The recipe being applied.
   */
  protected static function processConfiguration(Recipe $recipe): void {
    /** @var \Drupal\Core\Config\ConfigManagerInterface $config_manager */
    $config_manager = \Drupal::service(ConfigManagerInterface::class);

    $config_installer = new RecipeConfigInstaller(
      \Drupal::service('config.factory'),
      \Drupal::service('config.storage'),
      \Drupal::service('config.typed'),
      $config_manager,
      \Drupal::service('event_dispatcher'),
      NULL,
      \Drupal::service('extension.path.resolver'));

    $config = $recipe->config;
    // Create configuration that is either supplied by the recipe or listed in
    // the config.import section that does not exist.
    $config_installer->installRecipeConfig($config);

    if (!empty($config->config['actions'])) {
      $values = $recipe->input->getValues();
      // Wrap the replacement strings with `${` and `}`, which is a fairly
      // common style of placeholder.
      $keys = array_map(fn ($k) => sprintf('${%s}', $k), array_keys($values));
      $replace = array_combine($keys, $values);

      // Process the actions.
      /** @var \Drupal\Core\Config\Action\ConfigActionManager $config_action_manager */
      $config_action_manager = \Drupal::service('plugin.manager.config_action');
      foreach ($config->config['actions'] as $config_name => $actions) {
        // If this config name contains an input value, it must begin with the
        // config prefix of a known entity type.
        if (str_contains($config_name, '${') && empty($config_manager->getEntityTypeIdByName($config_name))) {
          throw new ConfigActionException("The entity type for the config name '$config_name' could not be identified.");
        }
        $config_name = str_replace($keys, $replace, $config_name);

        foreach ($actions as $action_id => $data) {
          $config_action_manager->applyAction($action_id, $config_name, static::replaceInputValues($data, $replace));
        }
      }
    }
  }

  /**
   * Creates content contained in a recipe.
   *
   * @param \Drupal\Core\DefaultContent\Finder $content
   *   The content finder object for the recipe.
   */
  protected static function processContent(Finder $content): void {
    /** @var \Drupal\Core\DefaultContent\Importer $importer */
    $importer = \Drupal::service(Importer::class);
    $importer->setLogger(\Drupal::logger('recipe'));
    $importer->importContent($content, Existing::Skip);
  }

  /**
   * Converts a recipe into a series of batch operations.
   *
   * @param \Drupal\Core\Recipe\Recipe $recipe
   *   The recipe to convert to batch operations.
   *
   * @return array<int, array{0: callable, 1: array{mixed}}>
   *   The array of batch operations. Each value is an array with two values.
   *   The first value is a callable and the second value are the arguments to
   *   pass to the callable.
   *
   * @see \Drupal\Core\Batch\BatchBuilder::addOperation()
   */
  public static function toBatchOperations(Recipe $recipe): array {
    $modules = [];
    $themes = [];
    $recipes = [];
    return static::toBatchOperationsRecipe($recipe, $recipes, $modules, $themes);
  }

  /**
   * Helper method to convert a recipe to batch operations.
   *
   * @param \Drupal\Core\Recipe\Recipe $recipe
   *   The recipe to convert to batch operations.
   * @param string[] $recipes
   *   The paths of the recipes that have already been converted to batch
   *   operations.
   * @param string[] $modules
   *   The modules that will already be installed due to previous recipes in the
   *   batch.
   * @param string[] $themes
   *   The themes that will already be installed due to previous recipes in the
   *   batch.
   *
   * @return array<int, array{0: callable, 1: array{mixed}}>
   *   The array of batch operations. Each value is an array with two values.
   *   The first value is a callable and the second value are the arguments to
   *   pass to the callable.
   */
  protected static function toBatchOperationsRecipe(Recipe $recipe, array $recipes, array &$modules, array &$themes): array {
    if (in_array($recipe->path, $recipes, TRUE)) {
      return [];
    }
    $steps = [];
    $recipes[] = $recipe->path;

    foreach ($recipe->recipes->recipes as $sub_recipe) {
      $steps = array_merge($steps, static::toBatchOperationsRecipe($sub_recipe, $recipes, $modules, $themes));
    }
    $steps = array_merge($steps, static::toBatchOperationsInstall($recipe, $modules, $themes));
    if ($recipe->config->hasTasks()) {
      $steps[] = [[RecipeRunner::class, 'installConfig'], [$recipe]];
    }
    if (!empty($recipe->content->data)) {
      $steps[] = [[RecipeRunner::class, 'installContent'], [$recipe]];
    }
    $steps[] = [[RecipeRunner::class, 'triggerEvent'], [$recipe]];

    return $steps;
  }

  /**
   * Converts a recipe's install tasks to batch operations.
   *
   * @param \Drupal\Core\Recipe\Recipe $recipe
   *   The recipe to convert install tasks to batch operations.
   * @param string[] $modules
   *   The modules that will already be installed due to previous recipes in the
   *   batch.
   * @param string[] $themes
   *   The themes that will already be installed due to previous recipes in the
   *   batch.
   *
   * @return array<int, array{0: callable, 1: array{mixed}}>
   *   The array of batch operations. Each value is an array with two values.
   *   The first value is a callable and the second value are the arguments to
   *   pass to the callable.
   */
  protected static function toBatchOperationsInstall(Recipe $recipe, array &$modules, array &$themes): array {
    $new_modules = [];
    foreach ($recipe->install->modules as $name) {
      if (in_array($name, $modules, TRUE)) {
        continue;
      }
      $new_modules[] = $name;
      $modules[] = $name;
    }

    $steps = [];
    if (!empty($new_modules)) {
      foreach (array_chunk($new_modules, Settings::get('core.multi_module_install_batch_size', 20)) as $modules_chunk) {
        $steps[] = [[RecipeRunner::class, 'installModules'], [$modules_chunk, $recipe]];
      }
    }
    foreach ($recipe->install->themes as $name) {
      if (in_array($name, $themes, TRUE)) {
        continue;
      }
      $themes[] = $name;
      $steps[] = [[RecipeRunner::class, 'installTheme'], [$name, $recipe]];
    }
    return $steps;
  }

  /**
   * Installs a module for a recipe.
   *
   * @param string $module
   *   The name of the module to install.
   * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage
   *   The recipe or recipe's config storage.
   * @param array<mixed>|null $context
   *   The batch context if called by a batch.
   *
   * @deprecated in drupal:11.4.0 and is removed from drupal:13.0.0. Use
   *   \Drupal\Core\Recipe\RecipeRunner::installModules() instead.
   *
   * @see https://www.drupal.org/node/3579527
   */
  public static function installModule(string $module, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void {
    @trigger_error(__METHOD__ . '() is deprecated in drupal:11.4.0 and is removed from drupal:13.0.0. Use \Drupal\Core\Recipe\RecipeRunner::installModules() instead. See https://www.drupal.org/node/3579527', E_USER_DEPRECATED);
    static::installModules([$module], $recipeConfigStorage, $context);
  }

  /**
   * Installs modules for a recipe.
   *
   * @param string[] $modules
   *   The names of the modules to install. It is up to the caller to ensure
   *   that the number of modules to install conforms to the
   *   'core.multi_module_install_batch_size' setting.
   * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage
   *   The recipe or recipe's config storage.
   * @param array<mixed>|null $context
   *   The batch context if called by a batch.
   */
  public static function installModules(array $modules, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void {
    if (empty($modules)) {
      throw new \InvalidArgumentException('No modules provided.');
    }
    if ($recipeConfigStorage instanceof Recipe) {
      $recipeConfigStorage = $recipeConfigStorage->config->getConfigStorage();
    }
    // Disable configuration entity install but use the config directories from
    // the modules.
    \Drupal::service('config.installer')->setSyncing(TRUE);

    // Allow the recipe to override simple configuration from the modules.
    $storage = new RecipeOverrideConfigStorage(
      $recipeConfigStorage,
      // Allow the ConfigInstaller to read all the config directories of the
      // modules being installed.
      RecipeMultipleModulesConfigStorage::createFromModuleList(
        $modules,
        \Drupal::service('extension.list.module'),
      )
    );

    \Drupal::service('config.installer')->setSourceStorage($storage);

    \Drupal::service('module_installer')->install($modules);
    \Drupal::service('config.installer')->setSyncing(FALSE);

    $module_list = \Drupal::service('extension.list.module');
    $module_names = array_map($module_list->getName(...), $modules);
    $context['message'] = new PluralTranslatableMarkup(
      count($modules),
      'Installed %modules module.',
      'Installed @count modules: %modules.',
      ['%modules' => implode(', ', $module_names)],
    );

    if (isset($context['results']['module'])) {
      $context['results']['module'] = array_merge($context['results']['module'], $modules);
    }
    else {
      $context['results']['module'] = $modules;
    }
  }

  /**
   * Installs a theme for a recipe.
   *
   * @param string $theme
   *   The name of the theme to install.
   * @param \Drupal\Core\Config\StorageInterface|\Drupal\Core\Recipe\Recipe $recipeConfigStorage
   *   The recipe or recipe's config storage.
   * @param array<mixed>|null $context
   *   The batch context if called by a batch.
   */
  public static function installTheme(string $theme, StorageInterface|Recipe $recipeConfigStorage, ?array &$context = NULL): void {
    if ($recipeConfigStorage instanceof Recipe) {
      $recipeConfigStorage = $recipeConfigStorage->config->getConfigStorage();
    }
    // Disable configuration entity install.
    \Drupal::service('config.installer')->setSyncing(TRUE);
    $default_install_path = \Drupal::service('extension.list.theme')->get($theme)->getPath() . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
    // Allow the recipe to override simple configuration from the theme.
    $storage = new RecipeOverrideConfigStorage(
      $recipeConfigStorage,
      new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION)
    );
    \Drupal::service('config.installer')->setSourceStorage($storage);

    \Drupal::service('theme_installer')->install([$theme]);
    \Drupal::service('config.installer')->setSyncing(FALSE);
    $context['message'] = t('Installed %theme theme.', ['%theme' => \Drupal::service('extension.list.theme')->getName($theme)]);
    $context['results']['theme'][] = $theme;
  }

  /**
   * Installs a config for a recipe.
   *
   * @param \Drupal\Core\Recipe\Recipe $recipe
   *   The recipe to install config for.
   * @param array<mixed>|null $context
   *   The batch context if called by a batch.
   */
  public static function installConfig(Recipe $recipe, ?array &$context = NULL): void {
    static::processConfiguration($recipe);
    $context['message'] = t('Installed configuration for %recipe recipe.', ['%recipe' => $recipe->name]);
    $context['results']['config'][] = $recipe->name;
  }

  /**
   * Installs a content for a recipe.
   *
   * @param \Drupal\Core\Recipe\Recipe $recipe
   *   The recipe to install content for.
   * @param array<mixed>|null $context
   *   The batch context if called by a batch.
   */
  public static function installContent(Recipe $recipe, ?array &$context = NULL): void {
    static::processContent($recipe->content);
    $context['message'] = t('Created content for %recipe recipe.', ['%recipe' => $recipe->name]);
    $context['results']['content'][] = $recipe->name;
  }

  /**
   * @param mixed $data
   *   The data that will have placeholders replaced.
   * @param array<string, mixed> $replace
   *   An array whose keys are the placeholders to be replaced, and whose values
   *   are the replacements.
   *
   * @return mixed
   *   The passed data, with placeholders replaced.
   */
  private static function replaceInputValues(mixed $data, array $replace): mixed {
    if (is_string($data)) {
      $data = str_replace(array_keys($replace), $replace, $data);
    }
    elseif (is_array($data)) {
      foreach ($data as $key => $value) {
        $data[$key] = static::replaceInputValues($value, $replace);
      }
    }
    return $data;
  }

}
