<?php

namespace Drupal\automatic_updates;

use Composer\Autoload\ClassLoader;
use Drupal\automatic_updates\Event\UpdateEvent;
use Drupal\automatic_updates\Exception\UpdateException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\system\SystemManager;
use PhpTuf\ComposerStager\Domain\BeginnerInterface;
use PhpTuf\ComposerStager\Domain\CleanerInterface;
use PhpTuf\ComposerStager\Domain\CommitterInterface;
use PhpTuf\ComposerStager\Domain\StagerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

/**
 * Defines a service to perform updates.
 */
class Updater {

  use StringTranslationTrait;

  /**
   * The state key in which to store the status of the update.
   *
   * @var string
   */
  public const STATE_KEY = 'AUTOMATIC_UPDATES_CURRENT';

  /**
   * The composer_stager beginner service.
   *
   * @var \Drupal\automatic_updates\ComposerStager\Beginner
   */
  protected $beginner;

  /**
   * The composer_stager stager service.
   *
   * @var \PhpTuf\ComposerStager\Domain\StagerInterface
   */
  protected $stager;

  /**
   * The composer_stager cleaner service.
   *
   * @var \PhpTuf\ComposerStager\Domain\CleanerInterface
   */
  protected $cleaner;

  /**
   * The composer_stager committer service.
   *
   * @var \PhpTuf\ComposerStager\Domain\CommitterInterface
   */
  protected $committer;

  /**
   * The state service.
   *
   * @var \Drupal\Core\State\StateInterface
   */
  protected $state;

  /**
   * The file system service.
   *
   * @var \Drupal\Core\File\FileSystemInterface
   */
  protected $fileSystem;

  /**
   * The event dispatcher service.
   *
   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
   */
  protected $eventDispatcher;

  /**
   * Constructs an Updater object.
   *
   * @param \Drupal\Core\State\StateInterface $state
   *   The state service.
   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
   *   The string translation service.
   * @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner
   *   The Composer Stager's beginner service.
   * @param \PhpTuf\ComposerStager\Domain\StagerInterface $stager
   *   The Composer Stager's stager service.
   * @param \PhpTuf\ComposerStager\Domain\CleanerInterface $cleaner
   *   The Composer Stager's cleaner service.
   * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer
   *   The Composer Stager's committer service.
   * @param \Drupal\Core\File\FileSystemInterface $file_system
   *   The file system service.
   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
   *   The event dispatcher service.
   */
  public function __construct(StateInterface $state, TranslationInterface $translation, BeginnerInterface $beginner, StagerInterface $stager, CleanerInterface $cleaner, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher) {
    $this->state = $state;
    $this->beginner = $beginner;
    $this->stager = $stager;
    $this->cleaner = $cleaner;
    $this->committer = $committer;
    $this->setStringTranslation($translation);
    $this->fileSystem = $file_system;
    $this->eventDispatcher = $event_dispatcher;
  }

  /**
   * Gets the vendor directory.
   *
   * @return string
   *   The absolute path for vendor directory.
   */
  private static function getVendorDirectory(): string {
    $class_loader_reflection = new \ReflectionClass(ClassLoader::class);
    return dirname($class_loader_reflection->getFileName(), 2);
  }

  /**
   * Gets the stage directory.
   *
   * @return string
   *   The absolute path for stage directory.
   */
  public function getStageDirectory(): string {
    return realpath(static::getVendorDirectory() . '/..') . '/.automatic_updates_stage';
  }

  /**
   * Determines if there is an active update in progress.
   *
   * @return bool
   *   TRUE if there is active update, otherwise FALSE.
   */
  public function hasActiveUpdate(): bool {
    $staged_dir = $this->getStageDirectory();
    if (is_dir($staged_dir) || $this->state->get(static::STATE_KEY)) {
      return TRUE;
    }
    return FALSE;
  }

  /**
   * Gets the active directory.
   *
   * @return string
   *   The absolute path for active directory.
   */
  public function getActiveDirectory(): string {
    return realpath(static::getVendorDirectory() . '/..');
  }

  /**
   * Begins the update.
   *
   * @return string
   *   A key for this stage update process.
   */
  public function begin(): string {
    $stage_key = $this->createActiveStage();
    $this->beginner->begin(static::getActiveDirectory(), static::getStageDirectory(), NULL, 120, $this->getExclusions());
    return $stage_key;
  }

  /**
   * Gets the paths that should be excluded from the staging area.
   *
   * @return string[]
   *   The paths relative to the active directory to exclude.
   */
  private function getExclusions(): array {
    $exclusions = [];
    $make_relative = function ($path) {
      return str_replace(static::getActiveDirectory() . '/', '', $path);
    };
    if ($public = $this->fileSystem->realpath('public://')) {
      $exclusions[] = $make_relative($public);
    }
    if ($private = $this->fileSystem->realpath('private://')) {
      $exclusions[] = $make_relative($private);
    }
    /** @var \Drupal\Core\Extension\ModuleHandlerInterface $module_handler */
    $module_handler = \Drupal::service('module_handler');
    $module_path = $this->fileSystem->realpath($module_handler->getModule('automatic_updates')->getPath());
    if (is_dir("$module_path/.git")) {
      // If the current module is git clone. Don't copy it.
      $exclusions[] = $make_relative($module_path);
    }
    $settings_files = ['settings.php', 'settings.local.php', 'services.yml'];
    foreach ($settings_files as $settings_file) {
      $file_path = "sites/default/$settings_file";
      if (file_exists($file_path)) {
        $exclusions[] = $make_relative($this->fileSystem->realpath("sites/default/$settings_file"));
      }
    }
    return $exclusions;
  }

  /**
   * Adds specific project versions to the staging area.
   *
   * @param string[] $project_versions
   *   The project versions to add to the staging area, keyed by package name.
   */
  public function stageVersions(array $project_versions): void {
    $packages = [];
    foreach ($project_versions as $project => $project_version) {
      if ($project === 'drupal') {
        // @todo Determine when to use drupal/core-recommended and when to use
        //   drupal/core
        $packages[] = "drupal/core:$project_version";
      }
      else {
        $packages[] = "drupal/$project:$project_version";
      }
    }
    $this->stagePackages($packages);
  }

  /**
   * Installs Composer packages in the staging area.
   *
   * @param string[] $packages
   *   The versions of the packages to stage, keyed by package name.
   */
  protected function stagePackages(array $packages): void {
    $command = array_merge(['require'], $packages);
    $command[] = '--update-with-all-dependencies';
    $this->stageCommand($command);
    // Store the expected packages to confirm no other Drupal packages were
    // updated.
    $current = $this->state->get(static::STATE_KEY);
    $current['packages'] = $packages;
    $this->state->set(self::STATE_KEY, $current);
  }

  /**
   * Commits the current update.
   */
  public function commit(): void {
    $this->committer->commit($this->getStageDirectory(), static::getActiveDirectory());
  }

  /**
   * Cleans the current update.
   */
  public function clean(): void {
    if (is_dir($this->getStageDirectory())) {
      $this->cleaner->clean($this->getStageDirectory());
    }
    $this->state->delete(static::STATE_KEY);
  }

  /**
   * Stages a Composer command.
   *
   * @param string[] $command
   *   The command array as expected by
   *   \PhpTuf\ComposerStager\Domain\StagerInterface::stage().
   *
   * @see \PhpTuf\ComposerStager\Domain\StagerInterface::stage()
   */
  protected function stageCommand(array $command): void {
    $this->stager->stage($command, $this->getStageDirectory());
  }

  /**
   * Initializes an active update and returns its ID.
   *
   * @return string
   *   The active update ID.
   */
  private function createActiveStage(): string {
    $value = static::STATE_KEY . microtime();
    $this->state->set(static::STATE_KEY, ['id' => $value]);
    return $value;
  }

  /**
   * Dispatches an update event.
   *
   * @param string $event_name
   *   The name of the event to dispatch.
   *
   * @throws \Drupal\automatic_updates\Exception\UpdateException
   *   If any of the event subscribers adds a validation error.
   */
  public function dispatchUpdateEvent(string $event_name): void {
    $event = new UpdateEvent();
    $this->eventDispatcher->dispatch($event, $event_name);
    if ($checker_results = $event->getResults(SystemManager::REQUIREMENT_ERROR)) {
      throw new UpdateException($checker_results,
        "Unable to complete the update because of errors.");
    }
  }

}