<?php

namespace Drupal\automatic_updates\Validator;

use Drupal\automatic_updates\CronUpdater;
use Drupal\automatic_updates\Event\ReadinessCheckEvent;
use Drupal\automatic_updates\ProjectInfo;
use Drupal\automatic_updates\Updater;
use Drupal\automatic_updates\Validator\VersionPolicy\ForbidDowngrade;
use Drupal\automatic_updates\Validator\VersionPolicy\ForbidMinorUpdates;
use Drupal\automatic_updates\Validator\VersionPolicy\MajorVersionMatch;
use Drupal\automatic_updates\Validator\VersionPolicy\MinorUpdatesEnabled;
use Drupal\automatic_updates\Validator\VersionPolicy\StableReleaseInstalled;
use Drupal\automatic_updates\Validator\VersionPolicy\ForbidDevSnapshot;
use Drupal\automatic_updates\Validator\VersionPolicy\SupportedBranchInstalled;
use Drupal\automatic_updates\Validator\VersionPolicy\TargetSecurityRelease;
use Drupal\automatic_updates\Validator\VersionPolicy\TargetVersionInstallable;
use Drupal\automatic_updates\Validator\VersionPolicy\TargetVersionStable;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\StageEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Validates the installed and target versions of Drupal before an update.
 *
 * @internal
 *   This class is an internal part of the module's update handling and should
 *   not be used by external code.
 */
final class VersionPolicyValidator implements EventSubscriberInterface {

  use StringTranslationTrait;

  /**
   * The class resolver service.
   *
   * @var \Drupal\Core\DependencyInjection\ClassResolverInterface
   */
  private $classResolver;

  /**
   * Constructs a VersionPolicyValidator object.
   *
   * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
   *   The class resolver service.
   */
  public function __construct(ClassResolverInterface $class_resolver) {
    $this->classResolver = $class_resolver;
  }

  /**
   * Validates a target version of Drupal core.
   *
   * @param \Drupal\automatic_updates\Updater $updater
   *   The updater which will perform the update.
   * @param string|null $target_version
   *   The target version of Drupal core, or NULL if it is not known.
   *
   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
   *   The error messages returned from the first policy rule which rejected
   *   the given target version.
   *
   * @see \Drupal\automatic_updates\Validator\VersionPolicy\RuleBase::validate()
   */
  public function validateVersion(Updater $updater, ?string $target_version): array {
    // Check that the installed version of Drupal isn't a dev snapshot.
    $rules = [
      ForbidDevSnapshot::class,
    ];

    // If the target version is known, it must conform to a few basic rules.
    if ($target_version) {
      // The target version must be newer than the installed version...
      $rules[] = ForbidDowngrade::class;
      // ...and in the same major version as the installed version...
      $rules[] = MajorVersionMatch::class;
      // ...and it must be a known, secure, installable release.
      $rules[] = TargetVersionInstallable::class;
    }

    // If this is a cron update, we may need to do additional checks.
    if ($updater instanceof CronUpdater) {
      $mode = $updater->getMode();

      if ($mode !== CronUpdater::DISABLED) {
        // If cron updates are enabled, the installed version must be stable;
        // no alphas, betas, or RCs.
        $rules[] = StableReleaseInstalled::class;
        // It must also be in a supported branch.
        $rules[] = SupportedBranchInstalled::class;

        // If the target version is known, more rules apply.
        if ($target_version) {
          // The target version must be stable too...
          $rules[] = TargetVersionStable::class;
          // ...and it must be in the same minor as the installed version.
          $rules[] = ForbidMinorUpdates::class;

          // If only security updates are allowed during cron, the target
          // version must be a security release.
          if ($mode === CronUpdater::SECURITY) {
            $rules[] = TargetSecurityRelease::class;
          }
        }
      }
    }
    // If this is not a cron update, and we know the target version, minor
    // version updates are allowed if configuration says so.
    elseif ($target_version) {
      $rules[] = MinorUpdatesEnabled::class;
    }

    $installed_version = $this->getInstalledVersion();
    $available_releases = $this->getAvailableReleases($updater);

    // Invoke each rule in the order that they were added to $rules, stopping
    // when one returns error messages.
    // @todo Return all the error messages in https://www.drupal.org/i/3281379.
    foreach ($rules as $rule) {
      $messages = $this->classResolver
        ->getInstanceFromDefinition($rule)
        ->validate($installed_version, $target_version, $available_releases);

      if ($messages) {
        return $messages;
      }
    }
    return [];
  }

  /**
   * Checks that the target version of Drupal is valid.
   *
   * @param \Drupal\package_manager\Event\StageEvent $event
   *   The event object.
   */
  public function checkVersion(StageEvent $event): void {
    $stage = $event->getStage();

    // Only do these checks for automatic updates.
    if (!$stage instanceof Updater) {
      return;
    }
    $target_version = $this->getTargetVersion($event);

    $messages = $this->validateVersion($stage, $target_version);
    if ($messages) {
      $installed_version = $this->getInstalledVersion();

      if ($target_version) {
        $summary = $this->t('Updating from Drupal @installed_version to @target_version is not allowed.', [
          '@installed_version' => $installed_version,
          '@target_version' => $target_version,
        ]);
      }
      else {
        $summary = $this->t('Updating from Drupal @installed_version is not allowed.', [
          '@installed_version' => $installed_version,
        ]);
      }
      $event->addError($messages, $summary);
    }
  }

  /**
   * Returns the target version of Drupal core.
   *
   * @param \Drupal\package_manager\Event\StageEvent $event
   *   The event object.
   *
   * @return string|null
   *   The target version of Drupal core, or NULL if it could not be determined
   *   during a readiness check.
   *
   * @throws \LogicException
   *   Thrown if the target version cannot be determined due to unexpected
   *   conditions. This can happen if, during a stage life cycle event (i.e.,
   *   NOT a readiness check), the event or updater does not have a list of
   *   desired package versions, or the list of package versions does not
   *   include any Drupal core packages.
   */
  private function getTargetVersion(StageEvent $event): ?string {
    $updater = $event->getStage();

    if ($event instanceof ReadinessCheckEvent) {
      $package_versions = $event->getPackageVersions();
    }
    else {
      $package_versions = $updater->getPackageVersions()['production'];
    }

    $unknown_target = new \LogicException('The target version of Drupal core could not be determined.');

    if ($package_versions) {
      $core_package_name = key($updater->getActiveComposer()->getCorePackages());

      if ($core_package_name && array_key_exists($core_package_name, $package_versions)) {
        return $package_versions[$core_package_name];
      }
      else {
        throw $unknown_target;
      }
    }
    elseif ($event instanceof ReadinessCheckEvent) {
      if ($updater instanceof CronUpdater) {
        $target_release = $updater->getTargetRelease();
        if ($target_release) {
          return $target_release->getVersion();
        }
      }
      return NULL;
    }
    // If we got here, something has gone very wrong.
    throw $unknown_target;
  }

  /**
   * Returns the available releases of Drupal core for a given updater.
   *
   * @param \Drupal\automatic_updates\Updater $updater
   *   The updater which will perform the update.
   *
   * @return \Drupal\update\ProjectRelease[]
   *   The available releases of Drupal core, keyed by version number and in
   *   descending order (i.e., newest first). Will be in ascending order (i.e.,
   *   oldest first) if $updater is the cron updater.
   *
   * @see \Drupal\automatic_updates\ProjectInfo::getInstallableReleases()
   */
  private function getAvailableReleases(Updater $updater): array {
    $project_info = new ProjectInfo('drupal');
    $available_releases = $project_info->getInstallableReleases() ?? [];

    if ($updater instanceof CronUpdater) {
      $available_releases = array_reverse($available_releases);
    }
    return $available_releases;
  }

  /**
   * Returns the currently installed version of Drupal core.
   *
   * @return string|null
   *   The currently installed version of Drupal core, or NULL if it could not
   *   be determined.
   */
  private function getInstalledVersion(): ?string {
    return (new ProjectInfo('drupal'))->getInstalledVersion();
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    return [
      ReadinessCheckEvent::class => 'checkVersion',
      PreCreateEvent::class => 'checkVersion',
    ];
  }

}