<?php

namespace Drupal\Core\Extension;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\State\StateInterface;

/**
 * Provides a list of available themes.
 *
 * @internal
 *   This class is not yet stable and therefore there are no guarantees that the
 *   internal implementations including constructor signature and protected
 *   properties / methods will not change over time. This will be reviewed after
 *   https://www.drupal.org/project/drupal/issues/2940481
 */
class ThemeExtensionList extends ExtensionList {

  /**
   * {@inheritdoc}
   */
  protected $defaults = [
    'engine' => 'twig',
    'regions' => [
      'sidebar_first' => 'Left sidebar',
      'sidebar_second' => 'Right sidebar',
      'content' => 'Content',
      'header' => 'Header',
      'primary_menu' => 'Primary menu',
      'secondary_menu' => 'Secondary menu',
      'footer' => 'Footer',
      'highlighted' => 'Highlighted',
      'help' => 'Help',
      'page_top' => 'Page top',
      'page_bottom' => 'Page bottom',
      'breadcrumb' => 'Breadcrumb',
    ],
    'description' => '',
    // The following array should be kept inline with
    // _system_default_theme_features().
    'features' => [
      'favicon',
      'logo',
      'node_user_picture',
      'comment_user_picture',
      'comment_user_verification',
    ],
    'screenshot' => 'screenshot.png',
    'version' => NULL,
    'php' => \Drupal::MINIMUM_PHP,
    'libraries' => [],
    'libraries_extend' => [],
    'libraries_override' => [],
    'dependencies' => [],
  ];

  /**
   * The config factory.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * The theme engine list needed by this theme list.
   *
   * @var \Drupal\Core\Extension\ThemeEngineExtensionList
   */
  protected $engineList;

  /**
   * The list of installed themes.
   *
   * @var string[]
   */
  protected $installedThemes;

  /**
   * Constructs a new ThemeExtensionList instance.
   *
   * @param string $root
   *   The app root.
   * @param string $type
   *   The extension type.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   The cache.
   * @param \Drupal\Core\Extension\InfoParserInterface $info_parser
   *   The info parser.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   * @param \Drupal\Core\State\StateInterface $state
   *   The state service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   * @param \Drupal\Core\Extension\ThemeEngineExtensionList $engine_list
   *   The theme engine extension listing.
   * @param string $install_profile
   *   The install profile used by the site.
   */
  public function __construct($root, $type, CacheBackendInterface $cache, InfoParserInterface $info_parser, ModuleHandlerInterface $module_handler, StateInterface $state, ConfigFactoryInterface $config_factory, ThemeEngineExtensionList $engine_list, $install_profile) {
    parent::__construct($root, $type, $cache, $info_parser, $module_handler, $state, $install_profile);

    $this->configFactory = $config_factory;
    $this->engineList = $engine_list;
  }

  /**
   * {@inheritdoc}
   */
  protected function doList() {
    // Find themes.
    $themes = parent::doList();

    $engines = $this->engineList->getList();
    // Always get the freshest list of themes (rather than the already cached
    // list in $this->installedThemes) when building the theme listing because a
    // theme could have just been installed or uninstalled.
    $this->installedThemes = $this->configFactory->get('core.extension')->get('theme') ?: [];

    $sub_themes = [];
    // Read info files for each theme.
    foreach ($themes as $name => $theme) {
      // Defaults to 'twig' (see self::defaults above).
      $engine = $theme->info['engine'];
      if (isset($engines[$engine])) {
        $theme->owner = $engines[$engine]->getExtensionPathname();
        $theme->prefix = $engines[$engine]->getName();
      }
      // Add this theme as a sub-theme if it has a base theme.
      if (!empty($theme->info['base theme'])) {
        $sub_themes[] = $name;
      }
      // Add status.
      $theme->status = (int) isset($this->installedThemes[$name]);
    }

    // Build dependencies.
    $themes = $this->moduleHandler->buildModuleDependencies($themes);

    // After establishing the full list of available themes, fill in data for
    // sub-themes.
    $this->fillInSubThemeData($themes, $sub_themes);

    foreach ($themes as $theme) {
      // After $theme is processed by buildModuleDependencies(), there can be a
      // `$theme->requires` array containing both module and base theme
      // dependencies. The module dependencies are copied to their own property
      // so they are available to operations specific to module dependencies.
      if (isset($theme->requires)) {
        $theme->module_dependencies = array_diff_key($theme->requires, $themes);
      }
      else {
        // Even if no requirements are specified, the theme installation process
        // expects the presence of the `requires` and `module_dependencies`
        // properties, so they should be initialized here as empty arrays.
        $theme->requires = [];
        $theme->module_dependencies = [];
      }
    }
    return $themes;
  }

  /**
   * Fills in data for themes that are also sub-themes.
   *
   * @param array $themes
   *   The array of partly processed theme information.
   * @param array $sub_themes
   *   A list of themes from the $theme array that are also sub-themes.
   */
  protected function fillInSubThemeData(array &$themes, array $sub_themes) {
    foreach ($sub_themes as $name) {
      $sub_theme = $themes[$name];
      // The $base_themes property is optional; only set for sub themes.
      // @see ThemeHandlerInterface::listInfo()
      $sub_theme->base_themes = $this->doGetBaseThemes($themes, $name);
      // empty() cannot be used here, since static::doGetBaseThemes() adds
      // the key of a base theme with a value of NULL in case it is not found,
      // in order to prevent needless iterations.
      if (!current($sub_theme->base_themes)) {
        continue;
      }
      // Determine the root base theme.
      $root_key = key($sub_theme->base_themes);
      // Build the list of sub-themes for each of the theme's base themes.
      foreach (array_keys($sub_theme->base_themes) as $base_theme) {
        $themes[$base_theme]->sub_themes[$name] = $sub_theme->info['name'];
      }
      // Add the theme engine info from the root base theme.
      if (isset($themes[$root_key]->owner)) {
        $sub_theme->info['engine'] = $themes[$root_key]->info['engine'];
        $sub_theme->owner = $themes[$root_key]->owner;
        $sub_theme->prefix = $themes[$root_key]->prefix;
      }
    }
  }

  /**
   * Finds all the base themes for the specified theme.
   *
   * Themes can inherit templates and function implementations from earlier
   * themes.
   *
   * @param \Drupal\Core\Extension\Extension[] $themes
   *   An array of available themes.
   * @param string $theme
   *   The name of the theme whose base we are looking for.
   *
   * @return array
   *   Returns an array of all of the theme's ancestors; the first element's
   *   value will be NULL if an error occurred.
   */
  public function getBaseThemes(array $themes, $theme) {
    return $this->doGetBaseThemes($themes, $theme);
  }

  /**
   * Finds the base themes for the specific theme.
   *
   * @param array $themes
   *   An array of available themes.
   * @param string $theme
   *   The name of the theme whose base we are looking for.
   * @param array $used_themes
   *   (optional) A recursion parameter preventing endless loops. Defaults to
   *   an empty array.
   *
   * @return array
   *   An array of base themes.
   */
  protected function doGetBaseThemes(array $themes, $theme, array $used_themes = []) {
    if (!isset($themes[$theme]->info['base theme'])) {
      return [];
    }

    $base_key = $themes[$theme]->info['base theme'];
    // Does the base theme exist?
    if (!isset($themes[$base_key])) {
      return [$base_key => NULL];
    }

    $current_base_theme = [$base_key => $themes[$base_key]->info['name']];

    // Is the base theme itself a child of another theme?
    if (isset($themes[$base_key]->info['base theme'])) {
      // Do we already know the base themes of this theme?
      if (isset($themes[$base_key]->base_themes)) {
        return $themes[$base_key]->base_themes + $current_base_theme;
      }
      // Prevent loops.
      if (!empty($used_themes[$base_key])) {
        return [$base_key => NULL];
      }
      $used_themes[$base_key] = TRUE;
      return $this->doGetBaseThemes($themes, $base_key, $used_themes) + $current_base_theme;
    }
    // If we get here, then this is our parent theme.
    return $current_base_theme;
  }

  /**
   * {@inheritdoc}
   */
  protected function createExtensionInfo(Extension $extension) {
    $info = parent::createExtensionInfo($extension);

    if (!isset($info['base theme'])) {
      throw new InfoParserException(sprintf('Missing required key ("base theme") in %s, see https://www.drupal.org/node/3066038', $extension->getPathname()));
    }

    // Remove the base theme when 'base theme: false' is set in a theme
    // .info.yml file.
    if ($info['base theme'] === FALSE) {
      unset($info['base theme']);
    }

    if (!empty($info['base theme'])) {
      // Add the base theme as a proper dependency.
      $info['dependencies'][] = $info['base theme'];
    }

    // Prefix screenshot with theme path.
    if (!empty($info['screenshot'])) {
      $info['screenshot'] = $extension->getPath() . '/' . $info['screenshot'];
    }
    return $info;
  }

  /**
   * {@inheritdoc}
   */
  protected function getInstalledExtensionNames() {
    // Cache the installed themes to avoid multiple calls to the config system.
    if (!isset($this->installedThemes)) {
      $this->installedThemes = $this->configFactory->get('core.extension')->get('theme') ?: [];
    }
    return array_keys($this->installedThemes);
  }

  /**
   * {@inheritdoc}
   */
  public function reset() {
    parent::reset();
    $this->installedThemes = NULL;
    return $this;
  }

}
