<?php

namespace Drupal\acquia_contenthub\Commands;

use Drupal\acquia_contenthub\Client\ClientFactory;
use Drupal\acquia_contenthub\Commands\Traits\ColorizedOutputTrait;
use Drupal\acquia_contenthub\PubSubModuleStatusChecker;
use Drupal\acquia_contenthub_subscriber\SubscriberTracker;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Language\LanguageInterface;
use Drush\Commands\DrushCommands;
use Symfony\Component\Console\Helper\Table;

/**
 * Drush command for scanning orphaned entities.
 *
 * @package acquia_contenthub
 */
class AcquiaContentHubOrphanEntityScanCommands extends DrushCommands {

  use ColorizedOutputTrait;

  /**
   * Thirty minutes as scroll time window as there can be a lot of entities.
   */
  public const SCROLL_TIME_WINDOW = '30m';

  /**
   * Scroll size.
   */
  public const SCROLL_SIZE = 1000;

  /**
   * Scroll identifier in scroll api response.
   */
  public const SCROLL_IDENTIFIER = '_scroll_id';

  /**
   * Content hub client.
   *
   * @var \Acquia\ContentHubClient\ContentHubClient|bool
   */
  protected $client;

  /**
   * Module status checker.
   *
   * @var \Drupal\acquia_contenthub\PubSubModuleStatusChecker
   */
  private PubSubModuleStatusChecker $moduleChecker;

  /**
   * The stream wrapper manager.
   *
   * @var \Drupal\Core\Entity\EntityRepositoryInterface
   */
  protected EntityRepositoryInterface $entityRepository;

  /**
   * Keep track of the subscriber tracker table.
   *
   * @var \Drupal\acquia_contenthub_subscriber\SubscriberTracker
   */
  protected SubscriberTracker $subscriberTracker;

  /**
   * AcquiaContentHubOrphanEntityScanCommands constructor.
   *
   * @param \Drupal\acquia_contenthub\PubSubModuleStatusChecker $module_checker
   *   Module checker.
   * @param \Drupal\acquia_contenthub\Client\ClientFactory $client_factory
   *   Client factory.
   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
   *   The Entity Repository service.
   *
   * @throws \Exception
   */
  public function __construct(PubSubModuleStatusChecker $module_checker, ClientFactory $client_factory, EntityRepositoryInterface $entity_repository) {
    $this->moduleChecker = $module_checker;
    $this->client = $client_factory->getClient();
    $this->entityRepository = $entity_repository;
  }

  /**
   * Sets subscriber tracker service.
   *
   * @param \Drupal\acquia_contenthub_subscriber\SubscriberTracker $tracker
   *   Subscriber Tracker.
   */
  public function setSubscriberTracker(SubscriberTracker $tracker): void {
    $this->subscriberTracker = $tracker;
  }

  /**
   * Scans Content Hub for orphaned entities.
   *
   * @param array $opt
   *   An associative array of options whose values come from cli, aliases,
   *   config, etc.
   *
   * @option no-propagate
   *   Prevents entity deletion event from being propagated to subscribers.
   *
   * @command acquia:contenthub:entity-scan:orphaned
   * @aliases ach-eso
   *
   * @usage acquia:contenthub:entity-scan:orphaned
   *   Displays orphaned entities that are not owned by any registered client.
   * @usage acquia:contenthub:entity-scan:orphaned --with-db
   *   Displays orphaned entities that are present
   *   in current site's tracking table.
   * @usage acquia:contenthub:entity-scan:orphaned --interest-list
   *   Displays orphaned entities that are present
   *   in current site's interest list.
   * @usage acquia:contenthub:entity-scan:orphaned --delete
   *   Displays orphaned entities that are present in current site's
   *   interest list and deletes them if it's a single publisher.
   * @usage acquia:contenthub:entity-scan:orphaned --delete --no-propagate
   *    Displays orphaned entities that are present in current site's
   *    interest list, deletes them if it's a single publisher
   *    but do not propagate the entity deletion event to subscriber's.
   * @usage acquia:contenthub:entity-scan:orphaned --json
   *   Displays orphaned entities in json format.
   *
   * @throws \Exception
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function scanOrphanEntities(array $opt = ['json' => 0, 'with-db' => 0, 'interest-list' => 0, 'delete' => 0]): void {
    $client_uuids = $this->getClientUuidsFromRemote();
    try {
      $clients = $this->getClientList($client_uuids);
    }
    catch (\Exception $e) {
      $this->logger->error($e->getMessage());
      return;
    }
    $orphaned_entities = $this->getOrphanedEntities(
      array_merge($clients['publishers'], $clients['subscribers'])
    );
    if (empty($orphaned_entities)) {
      $this->logger()->notice('There are no orphaned entities.');
      return;
    }

    $entities_to_reoriginate = [];
    if ($opt['with-db']) {
      [$orphaned_entities, $entities_to_reoriginate] = $this->filterOrphanedEntitiesWithDb($orphaned_entities);
    }

    if ($opt['interest-list']) {
      $orphaned_entities = $this->filterOrphanedEntitiesWithInterestList($orphaned_entities);
    }

    if (!empty($orphaned_entities)) {
      $this->renderOrphanedEntities($orphaned_entities, $opt['json']);
    }
    else {
      $this->logger()->notice('There are no orphaned entities.');
    }

    // If there are orphaned entities or reoriginable entities
    // then only deletion or reorigination works.
    if ($opt['delete'] && (!empty($orphaned_entities) || !empty($entities_to_reoriginate))) {
      if (!$this->moduleChecker->isPublisher()) {
        $this->logger()->error('acquia:contenthub:entity-scan:orphaned with --delete flag should be run on the publisher.');
      }
      elseif (count($clients['publishers']) > 1) {
        $this->logger()->warning('There are multiple publishers registered, run this command using CHUC.');
      }
      else {
        $this->handleDeletion($orphaned_entities, $entities_to_reoriginate, $opt['with-db'], $opt['json'], !$opt['no-propagate']);
      }
    }
  }

  /**
   * Handles reorigination and deletion of orphaned entities.
   *
   * @param array $orphaned_entities
   *   Orphaned entities that can be deleted.
   * @param array $entities_to_reoriginate
   *   Entites that can be reoriginated.
   * @param bool $filter_db
   *   Whether db filter option was passed in input or not.
   * @param bool $use_json
   *   Whether to render results in json or not.
   * @param bool $no_propagate
   *   Entity deletion propagation flag.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   * @throws \Exception
   */
  private function handleDeletion(array $orphaned_entities, array $entities_to_reoriginate, bool $filter_db, bool $use_json, bool $no_propagate = TRUE): void {
    // Mandatory db filtering with delete option
    // if with-db option is not passed.
    if (!$filter_db) {
      [$orphaned_entities, $entities_to_reoriginate] = $this->filterOrphanedEntitiesWithDb($orphaned_entities);
      // Render again if the option was missing due to recalculation.
      if (!empty($entities_to_reoriginate)) {
        $this->renderReoriginableEntities($entities_to_reoriginate, $use_json);
      }
    }
    $deletable_uuids = array_keys($orphaned_entities);
    if (!empty($deletable_uuids)) {
      $webhooks = $this->client->getWebHooks();
      $this->suppressWebhooks($webhooks);
      try {
        $this->client->deleteEntities($deletable_uuids, $no_propagate);
      }
      catch (\Exception $e) {
        $this->logger->error("Entity deletion has failed: " . $e->getMessage());
      }
      finally {
        $this->unsuppressWebhooks($webhooks);
      }
    }
    if (!empty($entities_to_reoriginate)) {
      $origins = array_unique(array_column($entities_to_reoriginate, 'origin'));
      $this->renderOrigins($origins, $use_json);
    }

    if (!$use_json) {
      $this->logger()->info('All orphaned entities have been deleted from Content Hub service');
    }
  }

  /**
   * Gets all the client cdfs from CH service.
   *
   * And returns list of publishers, subscribers and overall clients.
   *
   * @param array $client_uuids
   *   Client uuids.
   *
   * @return array|array[]
   *   Associative array with format
   *   [
   *   'publishers' => ['uuid1', 'uuid2'],
   *   'subscribers' => ['uuid3', 'uuid4'],
   *   ]
   *
   * @throws \Exception
   */
  protected function getClientList(array $client_uuids): array {
    $client_cdfs = $this->client->getEntities($client_uuids);
    $missing = array_diff($client_uuids, array_keys($client_cdfs->getEntities()));
    if (!empty($missing)) {
      // Currently we don't have a command neither the UI component to generate
      // a missing client CDF.
      // @todo Change wording when a client CDF generator is available.
      throw new \RuntimeException(sprintf(
        'Some client does not have corresponding client CDFs in Content Hub (%s). They need to be generated first.',
        implode(', ', $missing)
      ));
    }
    $client_list = [
      'publishers' => [],
      'subscribers' => [],
    ];
    foreach ($client_cdfs->getEntities() as $client_cdf) {
      $uuid = $client_cdf->getUuid();
      $pub_attribute = $client_cdf->getAttribute('publisher');
      if ($pub_attribute) {
        $is_publisher = $pub_attribute->getValue()[LanguageInterface::LANGCODE_NOT_SPECIFIED] ?? FALSE;
        if ($is_publisher) {
          $client_list['publishers'][] = $uuid;
        }
      }
      $sub_attribute = $client_cdf->getAttribute('subscriber');
      if ($sub_attribute) {
        $is_subscriber = $sub_attribute->getValue()[LanguageInterface::LANGCODE_NOT_SPECIFIED] ?? FALSE;
        if ($is_subscriber) {
          $client_list['subscribers'][] = $uuid;
        }
      }
    }
    return $client_list;
  }

  /**
   * Fetches clients from remote settings and returns uuids.
   *
   * @return array
   *   Array of client uuids.
   *
   * @throws \Exception
   */
  protected function getClientUuidsFromRemote(): array {
    $this->client->cacheRemoteSettings(TRUE);
    $clients = $this->client->getClients();
    return array_column($clients, 'uuid');
  }

  /**
   * Fetches entities from CH service using scroll API and returns their uuids.
   *
   * @return array
   *   Array of entity uuids.
   *
   * @throws \Exception
   */
  protected function getOrphanedEntities(array $client_uuids) : array {
    $uuids = [];
    $clients = implode(' ', $client_uuids);
    $query['query']['query_string']['query'] = "NOT (data.origin: $clients)";
    $matched_data = $this->client->startScroll(self::SCROLL_TIME_WINDOW, self::SCROLL_SIZE, $query);
    $is_final_page = $this->isFinalPage($matched_data);
    $uuids = array_merge($uuids, $this->extractEntityUuids($matched_data));

    $previous_scroll_id = $scroll_id = $matched_data[self::SCROLL_IDENTIFIER] ?? [];
    try {
      while (!$is_final_page) {
        $matched_data = $this->client->continueScroll($scroll_id, self::SCROLL_TIME_WINDOW);
        $uuids = array_merge($uuids, $this->extractEntityUuids($matched_data));

        $scroll_id = $matched_data[self::SCROLL_IDENTIFIER];
        if (!empty($scroll_id)) {
          $previous_scroll_id = $scroll_id;
        }
        $is_final_page = $this->isFinalPage($matched_data);
      }
    } finally {
      if (!empty($previous_scroll_id)) {
        $this->client->cancelScroll($previous_scroll_id);
      }
    }
    return $uuids;
  }

  /**
   * Checks whether scroll response reach the final page.
   *
   * @param array $scroll_response
   *   The response from startScroll or continueScroll.
   *
   * @return bool
   *   If this is the final return TRUE, FALSE otherwise.
   */
  private function isFinalPage(array $scroll_response): bool {
    return empty($scroll_response['hits']['hits']) || !isset($scroll_response[self::SCROLL_IDENTIFIER]);
  }

  /**
   * Extract entity uuids from scroll response.
   *
   * @param array $response
   *   The response from startScroll or continueScroll.
   *
   * @return array
   *   Returns entity UUIDs from scroll response.
   */
  private function extractEntityUuids(array $response): array {
    $uuids = [];

    if ($this->isFinalPage($response)) {
      return [];
    }

    foreach ($response['hits']['hits'] as $item) {
      $uuid = $item['_source']['uuid'];
      $type = $item['_source']['data']['type'];
      if ($type === 'client') {
        continue;
      }
      $uuids[$uuid] = [
        'uuid' => $uuid,
        'type' => $type,
        'origin' => $item['_source']['origin'],
        'entity_type' => $item['_source']['data']['attributes']['entity_type']['value'][LanguageInterface::LANGCODE_NOT_SPECIFIED],
      ];
    }

    return $uuids;
  }

  /**
   * Renders orphaned entities if available.
   *
   * @param array $entites_to_reoriginate
   *   List of orphaned entities.
   * @param bool $use_json
   *   Whether to render entities in json.
   */
  private function renderReoriginableEntities(array $entites_to_reoriginate, bool $use_json): void {
    $rows_mapper = static function ($entites_to_reoriginate, $index) {
      return [
        $index + 1,
        $entites_to_reoriginate['uuid'],
        $entites_to_reoriginate['origin'],
        $entites_to_reoriginate['type'],
        $entites_to_reoriginate['entity_type'],
      ];
    };
    $rows = array_map($rows_mapper, $entites_to_reoriginate, array_keys(array_keys($entites_to_reoriginate)));
    if ($use_json) {
      $this->output->writeln(json_encode(['reoriginable_entities' => $rows]));
    }
    else {
      $this->output()->writeln('Following entities are orphaned but exist on this publisher and can be reoriginated:');
      (new Table($this->output()))
        ->setHeaders(['#', 'UUID', 'Origin', 'Type', 'Entity Type'])
        ->setRows($rows)
        ->render();
    }
  }

  /**
   * Renders orphaned entities if available.
   *
   * @param array $orphaned_entities
   *   List of orphaned entities.
   * @param bool $use_json
   *   Whether to render entities in json.
   */
  private function renderOrphanedEntities(array $orphaned_entities, bool $use_json): void {
    $rows_mapper = static function ($orphaned_entity, $index) {
      return [
        $index + 1,
        $orphaned_entity['uuid'],
        $orphaned_entity['origin'],
        $orphaned_entity['type'],
        $orphaned_entity['entity_type'],
      ];
    };
    $rows = array_map($rows_mapper, $orphaned_entities, array_keys(array_keys($orphaned_entities)));
    if ($use_json) {
      $this->output->writeln(json_encode(['orphaned_entities' => $rows]));
    }
    else {
      $this->output()->writeln('Following entities are orphaned:');
      (new Table($this->output()))
        ->setHeaders(['#', 'UUID', 'Origin', 'Type', 'Entity Type'])
        ->setRows($rows)
        ->render();
    }
  }

  /**
   * Filters orphaned entities.
   *
   * By only returning entities that don't exist on site.
   *
   * @param array $orphaned_entities
   *   List of orphaned entities and entities that can be reoriginated.
   *
   * @return array
   *   Filtered orphaned entities.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  private function filterOrphanedEntitiesWithDb(array $orphaned_entities): array {
    $orphaned_entity_uuids = array_keys($orphaned_entities);
    $entities_to_reoriginate = [];
    if ($this->moduleChecker->isPublisher()) {
      foreach ($orphaned_entity_uuids as $orphaned_entity_uuid) {
        $orphaned_entity_type = $orphaned_entities[$orphaned_entity_uuid]['entity_type'];
        try {
          $orphaned_entity = $this->entityRepository->loadEntityByUuid($orphaned_entity_type, $orphaned_entity_uuid);
        }
        catch (\Exception $e) {
          // The entity type has not been found.
          continue;
        }
        if ($orphaned_entity) {
          $entities_to_reoriginate[$orphaned_entity_uuid] = $orphaned_entities[$orphaned_entity_uuid];
          unset($orphaned_entities[$orphaned_entity_uuid]);
        }
      }
    }
    elseif ($this->moduleChecker->isSubscriber()) {
      foreach ($orphaned_entity_uuids as $orphaned_entity_uuid) {
        if ($this->subscriberTracker->isTracked($orphaned_entity_uuid)) {
          unset($orphaned_entities[$orphaned_entity_uuid]);
        }
      }
    }
    return [$orphaned_entities, $entities_to_reoriginate];
  }

  /**
   * Filters orphaned entities.
   *
   * By only returning entities that don't exist in interest list.
   *
   * @param array $orphaned_entities
   *   Orphaned entities.
   *
   * @return array
   *   Filtered orphaned entities.
   *
   * @throws \Exception
   */
  private function filterOrphanedEntitiesWithInterestList(array $orphaned_entities): array {
    $settings = $this->client->getSettings();
    $webhook_uuid = $settings->getWebhook('uuid');
    if (!$webhook_uuid) {
      throw new \RuntimeException('Your webhook is not properly setup. Please set your webhook up properly and try again.');
    }
    // 2000 is arbitrary number, maximum of 3000 uuids can be fetched.
    $chunks = array_chunk($orphaned_entities, 2000, TRUE);
    $interest_list = [];
    foreach ($chunks as $chunk) {
      $uuids_str = implode(',', array_keys($chunk));
      $interest_list = array_merge($interest_list, $this->client->getInterestList($webhook_uuid, 'subscriber', ['uuids' => $uuids_str]));
    }
    return array_diff_key($orphaned_entities, $interest_list);
  }

  /**
   * Suppresses webhooks before entity deletion.
   *
   * @param \Acquia\ContentHubClient\Webhook[] $webhooks
   *   List of webhooks.
   */
  private function suppressWebhooks(array $webhooks): void {
    foreach ($webhooks as $webhook) {
      $webhook_uuid = $webhook->getUuid();
      $this->client->suppressWebhook($webhook_uuid, self::SCROLL_TIME_WINDOW);
    }
  }

  /**
   * UnSuppresses webhooks after entity deletion.
   *
   * @param \Acquia\ContentHubClient\Webhook[] $webhooks
   *   List of webhooks.
   */
  private function unsuppressWebhooks(array $webhooks): void {
    foreach ($webhooks as $webhook) {
      $webhook_uuid = $webhook->getUuid();
      $this->client->unSuppressWebhook($webhook_uuid);
    }
  }

  /**
   * Renders Origins whose entities can be reoriginated.
   *
   * @param array $origins
   *   Origin uuids.
   * @param bool $use_json
   *   Whether to use json for output or not.
   */
  private function renderOrigins(array $origins, bool $use_json): void {
    $rows_mapper = static function ($origin, $index) {
      return [
        $index + 1,
        $origin,
      ];
    };
    $rows = array_map($rows_mapper, $origins, array_keys(array_keys($origins)));
    if ($use_json) {
      $this->output->writeln(json_encode(['origins' => $rows]));
    }
    else {
      $this->logger->notice('Following origins have entities that are orphaned but exist on this publisher and can be reoriginated.');
      (new Table($this->output()))
        ->setHeaders(['#', 'Origin'])
        ->setRows($rows)
        ->render();
    }
  }

}
