<?php

namespace Drupal\automatic_updates\Services;

use Drupal\Core\Archiver\ArchiverInterface;
use Drupal\Core\Archiver\ArchiverManager;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Url;
use DrupalFinder\DrupalFinder;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use Psr\Log\LoggerInterface;

/**
 * Class to apply in-place updates.
 */
class InPlaceUpdate implements UpdateInterface {

  /**
   * The manifest file that lists all file deletions.
   */
  const DELETION_MANIFEST = 'DELETION_MANIFEST.txt';

  /**
   * The checksum file with hashes of archive file contents.
   */
  const CHECKSUM_LIST = 'checksumlist.csig';

  /**
   * The directory inside the archive for file additions and modifications.
   */
  const ARCHIVE_DIRECTORY = 'files/';

  /**
   * The logger.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * The archive manager.
   *
   * @var \Drupal\Core\Archiver\ArchiverManager
   */
  protected $archiveManager;

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

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

  /**
   * The HTTP client service.
   *
   * @var \GuzzleHttp\Client
   */
  protected $httpClient;

  /**
   * The root file path.
   *
   * @var string
   */
  protected $rootPath;

  /**
   * The vendor file path.
   *
   * @var string
   */
  protected $vendorPath;

  /**
   * The folder where files are backed up.
   *
   * @var string
   */
  protected $backup;

  /**
   * The temporary extract directory.
   *
   * @var string
   */
  protected $tempDirectory;

  /**
   * Constructs an InPlaceUpdate.
   *
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger.
   * @param \Drupal\Core\Archiver\ArchiverManager $archive_manager
   *   The archive manager.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   * @param \Drupal\Core\File\FileSystemInterface $file_system
   *   The filesystem service.
   * @param \GuzzleHttp\ClientInterface $http_client
   *   The HTTP client service.
   * @param \DrupalFinder\DrupalFinder $drupal_finder
   *   The Drupal finder service.
   */
  public function __construct(LoggerInterface $logger, ArchiverManager $archive_manager, ConfigFactoryInterface $config_factory, FileSystemInterface $file_system, ClientInterface $http_client, DrupalFinder $drupal_finder) {
    $this->logger = $logger;
    $this->archiveManager = $archive_manager;
    $this->configFactory = $config_factory;
    $this->fileSystem = $file_system;
    $this->httpClient = $http_client;
    $drupal_finder->locateRoot(getcwd());
    $this->rootPath = $drupal_finder->getDrupalRoot();
    $this->vendorPath = rtrim($drupal_finder->getVendorDir(), '/\\') . DIRECTORY_SEPARATOR;
  }

  /**
   * {@inheritdoc}
   */
  public function update($project_name, $project_type, $from_version, $to_version) {
    $success = FALSE;
    if ($project_name === 'drupal') {
      $project_root = $this->rootPath;
    }
    else {
      $project_root = drupal_get_path($project_type, $project_name);
    }
    if ($archive = $this->getArchive($project_name, $from_version, $to_version)) {
      if ($this->backup($archive, $project_root)) {
        $success = $this->processUpdate($archive, $project_root);
      }
      if (!$success) {
        $this->rollback($project_root);
      }
    }
    return $success;
  }

  /**
   * Get an archive with the quasi-patch contents.
   *
   * @param string $project_name
   *   The project name.
   * @param string $from_version
   *   The current project version.
   * @param string $to_version
   *   The desired next project version.
   *
   * @return \Drupal\Core\Archiver\ArchiverInterface|null
   *   The archive or NULL if download fails.
   */
  protected function getArchive($project_name, $from_version, $to_version) {
    $url = $this->buildUrl($project_name, $this->getQuasiPatchFileName($project_name, $from_version, $to_version));
    $destination = $this->fileSystem->realpath($this->fileSystem->getDestinationFilename("temporary://$project_name.zip", FileSystemInterface::EXISTS_RENAME));
    $this->doGetArchive($url, $destination);
    /** @var \Drupal\Core\Archiver\ArchiverInterface $archive */
    return $this->archiveManager->getInstance(['filepath' => $destination]);
  }

  /**
   * Perform retrieval of archive, with delay if archive is still being created.
   *
   * @param string $url
   *   The URL to retrieve.
   * @param string $destination
   *   The destination to download the archive.
   * @param null|int $delay
   *   The delay, defaults to NULL.
   */
  protected function doGetArchive($url, $destination, $delay = NULL) {
    try {
      $this->httpClient->get($url, [
        'sink' => $destination,
        'delay' => $delay,
      ]);
    }
    catch (RequestException $exception) {
      if ($exception->getResponse()->getStatusCode() === 429 && ($retry = $exception->getResponse()->getHeader('Retry-After'))) {
        $this->doGetArchive($url, $destination, $retry[0] * 1000);
      }
      else {
        $this->logger->error('Retrieval of "@url" failed with: @message', [
          '@url' => $url,
          '@message' => $exception->getMessage(),
        ]);
      }
    }
  }

  /**
   * Process update.
   *
   * @param \Drupal\Core\Archiver\ArchiverInterface $archive
   *   The archive.
   * @param string $project_root
   *   The project root directory.
   *
   * @return bool
   *   Return TRUE if update succeeds, FALSE otherwise.
   */
  protected function processUpdate(ArchiverInterface $archive, $project_root) {
    $archive->extract($this->getTempDirectory());
    foreach ($this->getFilesList($this->getTempDirectory()) as $file) {
      $file_real_path = $this->getFileRealPath($file);
      $file_path = substr($file_real_path, strlen($this->getTempDirectory() . self::ARCHIVE_DIRECTORY));
      $project_real_path = $this->getProjectRealPath($file_path, $project_root);
      try {
        $directory = dirname($project_real_path);
        $this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
        $this->fileSystem->copy($file_real_path, $project_real_path, FileSystemInterface::EXISTS_REPLACE);
        $this->logger->info('"@file" was updated.', ['@file' => $project_real_path]);
      }
      catch (FileException $exception) {
        return FALSE;
      }
    }
    foreach ($this->getDeletions() as $deletion) {
      try {
        $file_deletion = $this->getProjectRealPath($deletion, $project_root);
        $this->fileSystem->delete($file_deletion);
        $this->logger->info('"@file" was deleted.', ['@file' => $file_deletion]);
      }
      catch (FileException $exception) {
        return FALSE;
      }
    }
    return TRUE;
  }

  /**
   * Backup before an update.
   *
   * @param \Drupal\Core\Archiver\ArchiverInterface $archive
   *   The archive.
   * @param string $project_root
   *   The project root directory.
   *
   * @return bool
   *   Return TRUE if backup succeeds, FALSE otherwise.
   */
  protected function backup(ArchiverInterface $archive, $project_root) {
    $backup = $this->fileSystem->createFilename('automatic_updates-backup', 'temporary://');
    $this->fileSystem->prepareDirectory($backup);
    $this->backup = $this->fileSystem->realpath($backup) . DIRECTORY_SEPARATOR;
    if (!$this->backup) {
      return FALSE;
    }
    foreach ($archive->listContents() as $file) {
      // Ignore files that aren't in the files directory.
      if (!$this->stripFileDirectoryPath($file)) {
        continue;
      }
      $success = $this->doBackup($file, $project_root);
      if (!$success) {
        return FALSE;
      }
    }
    $archive->extract($this->getTempDirectory(), [self::DELETION_MANIFEST]);
    foreach ($this->getDeletions() as $deletion) {
      $success = $this->doBackup($deletion, $project_root);
      if (!$success) {
        return FALSE;
      }
    }
    return TRUE;
  }

  /**
   * Remove the files directory path from files from the archive.
   *
   * @param string $file
   *   The file path.
   *
   * @return bool
   *   TRUE if path was removed, else FALSE.
   */
  protected function stripFileDirectoryPath(&$file) {
    if (substr($file, 0, 6) === self::ARCHIVE_DIRECTORY) {
      $file = substr($file, 6);
      return TRUE;
    }
    return FALSE;
  }

  /**
   * Execute file backup.
   *
   * @param string $file
   *   The file to backup.
   * @param string $project_root
   *   The project root directory.
   *
   * @return bool
   *   Return TRUE if backup succeeds, FALSE otherwise.
   */
  protected function doBackup($file, $project_root) {
    $directory = $this->backup . dirname($file);
    if (!file_exists($directory) && !$this->fileSystem->mkdir($directory, NULL, TRUE)) {
      return FALSE;
    }
    $project_real_path = $this->getProjectRealPath($file, $project_root);
    if (file_exists($project_real_path) && !is_dir($project_real_path)) {
      try {
        $this->fileSystem->copy($project_real_path, $this->backup . $file, FileSystemInterface::EXISTS_REPLACE);
        $this->logger->info('"@file" was backed up in preparation for an update.', ['@file' => $project_real_path]);
      }
      catch (FileException $exception) {
        return FALSE;
      }
    }
    return TRUE;
  }

  /**
   * Rollback after a failed update.
   *
   * @param string $project_root
   *   The project root directory.
   */
  protected function rollback($project_root) {
    if (!$this->backup) {
      return;
    }
    foreach ($this->getFilesList($this->backup) as $file) {
      $file_real_path = $this->getFileRealPath($file);
      $file_path = substr($file_real_path, strlen($this->backup));
      try {
        $this->fileSystem->copy($file_real_path, $this->getProjectRealPath($file_path, $project_root), FileSystemInterface::EXISTS_REPLACE);
        $this->logger->info('"@file" was restored due to failure(s) in applying update.', ['@file' => $file_path]);
      }
      catch (FileException $exception) {
        $this->logger->error('@file was not rolled back successfully.', ['@file' => $file_real_path]);
      }
    }
  }

  /**
   * Provide a recursive list of files, excluding directories.
   *
   * @param string $directory
   *   The directory to recurse for files.
   *
   * @return \RecursiveIteratorIterator|\SplFileInfo[]
   *   The iterator of SplFileInfos.
   */
  protected function getFilesList($directory) {
    $filter = function ($file, $file_name, $iterator) {
      /** @var \SplFileInfo $file */
      /** @var string $file_name */
      /** @var \RecursiveDirectoryIterator $iterator */
      if ($iterator->hasChildren() && !in_array($file->getFilename(), ['.git'], TRUE)) {
        return TRUE;
      }
      return $file->isFile() && !in_array($file->getFilename(), [self::DELETION_MANIFEST], TRUE);
    };

    $innerIterator = new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS);
    return new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator($innerIterator, $filter));
  }

  /**
   * Build a project quasi-patch download URL.
   *
   * @param string $project_name
   *   The project name.
   * @param string $file_name
   *   The file name.
   *
   * @return string
   *   The URL endpoint with for an extension.
   */
  protected function buildUrl($project_name, $file_name) {
    $uri = $this->configFactory->get('automatic_updates.settings')->get('download_uri');
    return Url::fromUri($uri . "/$project_name/" . $file_name)->toString();
  }

  /**
   * Get the quasi-patch file name.
   *
   * @param string $project_name
   *   The project name.
   * @param string $from_version
   *   The current project version.
   * @param string $to_version
   *   The desired next project version.
   *
   * @return string
   *   The quasi-patch file name.
   */
  protected function getQuasiPatchFileName($project_name, $from_version, $to_version) {
    return "$project_name-$from_version-to-$to_version.zip";
  }

  /**
   * Get file real path.
   *
   * @param \SplFileInfo $file
   *   The file to retrieve the real path.
   *
   * @return string
   *   The file real path.
   */
  protected function getFileRealPath(\SplFileInfo $file) {
    $real_path = $file->getRealPath();
    if (!$real_path) {
      throw new FileException(sprintf('Could not get real path for "%s"', $file->getFilename()));
    }
    return $real_path;
  }

  /**
   * Get the real path of a file.
   *
   * @param string $file_path
   *   The file path.
   * @param string $project_root
   *   The project root directory.
   *
   * @return string
   *   The real path of a file.
   */
  protected function getProjectRealPath($file_path, $project_root) {
    if (substr($file_path, 0, 6) === 'vendor/') {
      return $this->vendorPath . substr($file_path, 7);
    }
    return rtrim($project_root, '/\\') . DIRECTORY_SEPARATOR . $file_path;
  }

  /**
   * Provides the temporary extraction directory.
   *
   * @return string
   *   The temporary directory.
   */
  protected function getTempDirectory() {
    if (!$this->tempDirectory) {
      $this->tempDirectory = $this->fileSystem->createFilename('automatic_updates-update', 'temporary://');
      $this->fileSystem->prepareDirectory($this->tempDirectory);
      $this->tempDirectory = $this->fileSystem->realpath($this->tempDirectory) . DIRECTORY_SEPARATOR;
    }
    return $this->tempDirectory;
  }

  /**
   * Get an iterator of files to delete.
   *
   * @return \ArrayIterator
   *   Iterator of files to delete.
   */
  protected function getDeletions() {
    $deletions = [];
    if (!file_exists($this->getTempDirectory() . self::DELETION_MANIFEST)) {
      return new \ArrayIterator($deletions);
    }
    $handle = fopen($this->getTempDirectory() . self::DELETION_MANIFEST, 'r');
    if ($handle) {
      while (($deletion = fgets($handle)) !== FALSE) {
        if ($result = trim($deletion)) {
          $deletions[] = $result;
        }
      }
      fclose($handle);
    }
    return new \ArrayIterator($deletions);
  }

}