<?php

namespace Drupal\domain\Commands;

use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface;
use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait;
use Consolidation\OutputFormatters\StructuredData\PropertyList;
use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
use Drupal\Core\Config\StorageException;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Component\Utility\Html;
use Drupal\domain\DomainInterface;
use Drush\Commands\DrushCommands;
use GuzzleHttp\Exception\TransferException;

/**
 * Drush commands for the domain module.
 */
class DomainCommands extends DrushCommands implements CustomEventAwareInterface {

  use CustomEventAwareTrait;

  /**
   * The domain entity storage service.
   *
   * @var \Drupal\domain\DomainStorageInterface
   */
  protected $domainStorage = NULL;

  /**
   * Local cache of entity field map, kept for performance.
   *
   * @var array
   */
  protected $entityFieldMap = NULL;

  /**
   * Flag set by the --dryrun cli option.
   *
   * If set prevents changes from being made by code in this class.
   *
   * @var bool
   */
  protected $isDryRun = FALSE;

  /**
   * Static array of special-case policies for reassigning field data.
   *
   * @var string[]
   * */
  protected $reassignmentPolicies = ['prompt', 'default', 'ignore'];

  /**
   * List active domains for the site.
   *
   * @param array $options
   *   The options passed to the command.
   *
   * @option inactive
   *   Show only the domains that are inactive/disabled.
   * @option active
   *   Show only the domains that are active/enabled.
   * @usage drush domain:list
   *   List active domains for the site.
   * @usage drush domains
   *   List active domains for the site.
   *
   * @command domain:list
   * @aliases domains,domain-list
   *
   * @field-labels
   *   weight: Weight
   *   name: Name
   *   hostname: Hostname
   *   response: HTTP Response
   *   scheme: Scheme
   *   status: Status
   *   is_default: Default
   *   domain_id: Domain Id
   *   id: Machine name
   * @default-fields id,name,hostname,scheme,status,is_default,response
   *
   * @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields
   *   Table output.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function listDomains(array $options) {
    // Load all domains:
    $domains = $this->domainStorage()->loadMultipleSorted();

    if (empty($domains)) {
      $this->logger()->warning(dt('No domains have been created. Use "drush domain:add" to create one.'));
      return new RowsOfFields([]);
    }

    $keys = [
      'weight',
      'name',
      'hostname',
      'response',
      'scheme',
      'status',
      'is_default',
      'domain_id',
      'id',
    ];
    $rows = [];
    /** @var \Drupal\domain\DomainInterface $domain */
    foreach ($domains as $domain) {
      $row = [];
      foreach ($keys as $key) {
        switch ($key) {
          case 'response':
            try {
              $v = $this->checkDomain($domain);
            }
            catch (TransferException $ex) {
              $v = dt('500 - Failed');
            }
            catch (Exception $ex) {
              $v = dt('500 - Exception');
            }
            if ($v >= 200 && $v <= 299) {
              $v = dt('200 - OK');
            }
            elseif ($v == 500) {
              $v = dt('500 - No server');
            }
            break;

          case 'status':
            $v = $domain->get($key);
            if (($options['inactive'] && $v) || ($options['active'] && !$v)) {
              continue 3;
            }
            $v = !empty($v) ? dt('Active') : dt('Inactive');
            break;

          case 'is_default':
            $v = $domain->get($key);
            $v = !empty($v) ? dt('Default') : '';
            break;

          default:
            $v = $domain->get($key);
            break;
        }

        $row[$key] = Html::escape($v);
      }
      $rows[] = $row;
    }
    return new RowsOfFields($rows);
  }

  /**
   * List general information about the domains on the site.
   *
   * @usage drush domain:info
   *
   * @command domain:info
   * @aliases domain-info,dinf
   *
   * @return \Consolidation\OutputFormatters\StructuredData\PropertyList
   *   A structured list of domain information.
   *
   * @field-labels
   * count: All Domains
   * count_active: Active Domains
   * default_id: Default Domain ID
   * default_host: Default Domain hostname
   * scheme: Fields in Domain entity
   * domain_admin_entities: Domain admin entities
   * @list-orientation true
   * @format table
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function infoDomains() {
    $default_domain = $this->domainStorage()->loadDefaultDomain();

    // Load all domains:
    $all_domains = $this->domainStorage()->loadMultiple(NULL);
    $active_domains = [];
    foreach ($all_domains as $domain) {
      if ($domain->status()) {
        $active_domains[] = $domain;
      }
    }

    $keys = [
      'count',
      'count_active',
      'default_id',
      'default_host',
      'scheme',
    ];
    $rows = [];
    foreach ($keys as $key) {
      $v = '';
      switch ($key) {
        case 'count':
          $v = count($all_domains);
          break;

        case 'count_active':
          $v = count($active_domains);
          break;

        case 'default_id':
          $v = '-unset-';
          if ($default_domain) {
            $v = $default_domain->id();
          }
          break;

        case 'default_host':
          $v = '-unset-';
          if ($default_domain) {
            $v = $default_domain->getHostname();
          }
          break;

        case 'scheme':
          $v = implode(', ', array_keys($this->domainStorage()->loadSchema()));
          break;
      }

      $rows[$key] = $v;
    }

    // Display which entities are enabled for domain by checking for the fields.
    $rows['domain_admin_entities'] = $this->getFieldEntities(DomainInterface::DOMAIN_ADMIN_FIELD);

    return new PropertyList($rows);
  }

  /**
   * Finds entities that reference a specific field.
   *
   * @param string $field_name
   *   The field name to lookup.
   *
   * @return string
   *   A comma-separated list of entities containing a specific field.
   */
  public function getFieldEntities($field_name) {
    $this->ensureEntityFieldMap();
    $domain_entities = [];
    foreach ($this->entityFieldMap as $type => $fields) {
      if (array_key_exists($field_name, $fields)) {
        $domain_entities[] = $type;
      }
    }
    return implode(', ', $domain_entities);
  }

  /**
   * Add a new domain to the site.
   *
   * @param string $hostname
   *   The domain hostname to register (e.g. example.com).
   * @param string $name
   *   The name of the site (e.g. Domain Two).
   * @param array $options
   *   An associative array of optional values.
   *
   * @option inactive
   *   Set the domain to inactive status if set.
   * @option scheme
   *   Use indicated protocol for this domain, defaults to 'https'. Options:
   *    - http: normal http (no SSL).
   *    - https: secure https (with SSL).
   *    - variable: match the scheme used by the request.
   * @option weight
   *   Set the order (weight) of the domain.
   * @option is_default
   *   Set this domain as the default domain.
   * @option validate
   *   Force a check of the URL response before allowing registration.
   *
   * @usage drush domain-add example.com 'My Test Site'
   * @usage drush domain-add example.com 'My Test Site' --scheme=https --inactive
   * @usage drush domain-add example.com 'My Test Site' --weight=10
   * @usage drush domain-add example.com 'My Test Site' --validate
   *
   * @command domain:add
   * @aliases domain-add
   *
   * @return string
   *   The entity id of the created domain.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function add($hostname, $name, array $options = ['weight' => NULL, 'scheme' => NULL]) {
    // Validate the weight arg.
    if (!empty($options['weight']) && !is_numeric($options['weight'])) {
      throw new DomainCommandException(
        dt('Domain weight "!weight" must be a number',
          ['!weight' => !empty($options['weight']) ? $options['weight'] : ''])
      );
    }

    // Validate the scheme arg.
    if (!empty($options['scheme']) &&
      ($options['scheme'] !== 'http' && $options['scheme'] !== 'https' && $options['scheme'] !== 'variable')
    ) {
      throw new DomainCommandException(
        dt('Scheme name "!scheme" not known',
          ['!scheme' => !empty($options['scheme']) ? $options['scheme'] : ''])
      );
    }

    $domains = $this->domainStorage()->loadMultipleSorted();
    $start_weight = count($domains) + 1;
    $values = [
      'hostname' => $hostname,
      'name' => $name,
      'status' => empty($options['inactive']),
      'scheme' => empty($options['scheme']) ? 'http' : $options['scheme'],
      'weight' => empty($options['weight']) ? $start_weight : $options['weight'],
      'is_default' => !empty($options['is_default']),
      'id' => $this->domainStorage()->createMachineName($hostname),
    ];
    /** @var \Drupal\domain\DomainInterface */
    $domain = $this->domainStorage()->create($values);

    // Check for hostname validity. This is required.
    $valid = $this->validateDomain($domain);
    if (!empty($valid)) {
      throw new DomainCommandException(
        dt('Hostname is not valid. !errors',
          ['!errors' => implode(" ", $valid)])
      );
    }
    // Check for hostname and id uniqueness.
    foreach ($domains as $existing) {
      if ($hostname == $existing->getHostname()) {
        throw new DomainCommandException(
          dt('No domain created. Hostname is a duplicate of !hostname.',
           ['!hostname' => $hostname])
        );
      }
      if ($values['id'] == $existing->id()) {
        throw new DomainCommandException(
          dt('No domain created. Id is a duplicate of !id.',
           ['!id' => $existing->id()])
        );
      }
    }

    $validate_response = (bool) $options['validate'];
    if ($this->createDomain($domain, $validate_response)) {
      return dt('Created the !hostname with machine id !id.', ['!hostname' => $values['hostname'], '!id' => $values['id']]);
    }
    else {
      return dt('No domain created.');
    }
  }

  /**
   * Delete a domain from the site.
   *
   * Deletes the domain from the Drupal configuration and optionally reassign
   * content and/or profiles associated with the deleted domain to another.
   * The domain marked as default cannot be deleted: to achieve this goal,
   * mark another, possibly newly created, domain as the default domain, then
   * delete the old default.
   *
   * The usage example descriptions are based on starting with three domains:
   *   - id:19476, machine: example_com, domain: example.com
   *   - id:29389, machine: example_org, domain: example.org  (default)
   *   - id:91736, machine: example_net, domain: example.net
   *
   * @param string $domain_id
   *   The numeric id, machine name, or hostname of the domain to delete. The
   *   value "all" is taken to mean delete all except the default domain.
   * @param array $options
   *   An associative array of options whose values come from cli, aliases,
   *   config, etc.
   *
   * @usage drush domain:delete example.com
   *   Delete the domain example.com, assigning its content and users to
   *   the default domain, example.org.
   *
   * @usage drush domain:delete --content-assign=ignore example.com
   *   Delete the domain example.com, leaving its content untouched but
   *   assigning its users to the default domain.
   *
   * @usage drush domain:delete --content-assign=example_net --users-assign=example_net
   *   example.com Delete the domain example.com, assigning its content and
   *   users to the example.net domain.
   *
   * @usage drush domain:delete --dryrun 19476
   *   Show the effects of delete the domain example.com and assigning its
   *   content and users to the default domain, example.org, but not doing so.
   *
   * @usage drush domain:delete --chatty example_net
   *   Verbosely Delete the domain example.net and assign its content and users
   *   to the default domain, example.org.
   *
   * @usage drush domain-delete --chatty all
   *   Verbosely Delete the domains example.com and example.net and assign
   *   their content and users to the default domain, example.org.
   *
   * @option chatty
   *   Document each step as it is performed.
   * @option dryrun
   *   Do not do anything, but explain what would be done. Implies --chatty.
   * @option users-assign
   *   Values "prompt", "ignore", "default", <name>, Reassign user accounts
   *   associated with the the domain being deleted to the default domain,
   *   to the domain whose machine name is <name>, or leave the user accounts
   *   alone (and so inaccessible in the normal way). The default value is
   *   'prompt': ask which domain to use.
   *
   * @command domain:delete
   * @aliases domain-delete
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   *
   * @see https://github.com/consolidation/annotated-command#option-event-hook
   */
  public function delete($domain_id, array $options = ['users-assign' => NULL, 'dryrun' => NULL, 'chatty' => NULL]) {
    if (is_null($options['users-assign'])) {
      $policy_users = 'prompt';
    }

    $this->isDryRun = (bool) $options['dryrun'];

    // Get current domain list and perform validation checks.
    $default_domain = $this->domainStorage()->loadDefaultDomain();
    $all_domains = $this->domainStorage()->loadMultipleSorted(NULL);

    if (empty($all_domains)) {
      throw new DomainCommandException('There are no configured domains.');
    }
    if (empty($domain_id)) {
      throw new DomainCommandException('You must specify a domain to delete.');
    }

    // Determine which domains to be deleted.
    if ($domain_id === 'all') {
      $domains = $all_domains;
      if (empty($domains)) {
        $this->logger()->info(dt('There are no domains to delete.'));
        return;
      }
      $really = $this->io()->confirm(dt('This action cannot be undone. Continue?:'), FALSE);
      if (empty($really)) {
        return;
      }
      // TODO: handle deletion of all domains.
      $policy_users = "ignore";
      $message = dt('All domain records have been deleted.');
    }
    elseif ($domain = $this->getDomainFromArgument($domain_id)) {
      if ($domain->isDefault()) {
        throw new DomainCommandException('The primary domain may not be deleted.
          Use drush domain:default to set a new default domain.');
      }
      $domains = [$domain];
      $message = dt('Domain record !domain deleted.',
        ['!domain' => $domain->id()]
      );
    }

    if (!empty($options['users-assign'])) {
      if (in_array($options['users-assign'], $this->reassignmentPolicies, TRUE)) {
        $policy_users = $options['users-assign'];
      }
    }

    $delete_options = [
      'entity_filter' => 'user',
      'policy' => $policy_users,
      'field' => DomainInterface::DOMAIN_ADMIN_FIELD,
    ];

    if ($policy_users !== 'ignore') {
      $messages[] = $this->doReassign($domain, $delete_options);
    }

    // Fire any registered hooks for deletion, passing them current imput.
    $handlers = $this->getCustomEventHandlers('domain-delete');
    $messages = [];
    foreach ($handlers as $handler) {
      $messages[] = $handler($domain, $options);
    }

    $this->deleteDomain($domains, $options);

    if ($messages) {
      $message .= "\n" . implode("\n", $messages);
    }
    $this->logger()->info($message);
    return $message;
  }

  /**
   * Handles reassignment of entities to another domain.
   *
   * This method includes necessary UI elements if the user is prompted to
   * choose a new domain.
   *
   * @param Drupal\domain\DomainInterface $target_domain
   *   The domain selected for deletion.
   * @param array $delete_options
   *   A selection of options for deletion, defined in reassignLinkedEntities().
   */
  public function doReassign(DomainInterface $target_domain, array $delete_options) {
    $policy = $delete_options['policy'];
    $default_domain = $this->domainStorage()->loadDefaultDomain();
    $all_domains = $this->domainStorage()->loadMultipleSorted(NULL);

    // Perform the 'prompt' for a destination domain.
    if ($policy === 'prompt') {
      // Make a list of the eligible destination domains in form id -> name.
      $noassign_domain = [$target_domain->id()];

      $reassign_list = $this->filterDomains($all_domains, $noassign_domain);
      $reassign_base = [
        'ignore' => dt('Do not reassign'),
        'default' => dt('Reassign to default domain'),
      ];
      $reassign_list = array_map(
        function (DomainInterface $d) {
          return $d->getHostname();
        },
        $reassign_list
      );
      $reassign_list = array_merge($reassign_base, $reassign_list);
      $policy = $this->io()->choice(dt('Reassign @type field @field data to:', ['@type' => $delete_options['entity_filter'], '@field' => $delete_options['field']]), $reassign_list);
    }
    elseif ($policy === 'default') {
      $policy = $default_domain->id();
    }
    if ($policy !== 'ignore') {
      $delete_options['policy'] = $policy;
      $target = [$target_domain];
      $count = $this->reassignLinkedEntities($target, $delete_options);
      return dt('@count @type entities updated field @field.',
        [
          '@count' => $count,
          '@type' => $delete_options['entity_filter'],
          '@field' => $delete_options['field'],
        ]
      );
    }
  }

  /**
   * Tests domains for proper response.
   *
   * If run from a subfolder, you must specify the --uri.
   *
   * @param string $domain_id
   *   The machine name or hostname of the domain to make default.
   *
   * @usage drush domain-test
   * @usage drush domain-test example.com
   *
   * @command domain:test
   * @aliases domain-test
   *
   * @field-labels
   *   id: Machine name
   *   url: URL
   *   response: HTTP Response
   * @default-fields id,url,response
   *
   * @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields
   *   Tabled output.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function test($domain_id = NULL) {
    if (is_null($domain_id)) {
      $domains = $this->domainStorage()->loadMultipleSorted();
    }
    else {
      if ($domain = $this->getDomainFromArgument($domain_id)) {
        $domains = [$domain];
      }
      else {
        throw new DomainCommandException(dt('Domain @domain not found.',
          ['@domain' => $options['domain']]));
      }
    }

    $rows = [];
    foreach ($domains as $domain) {
      $rows[] = [
        'id' => $domain->id(),
        'url' => $domain->getPath(),
        'response' => $domain->getResponse(),
      ];
    }

    return new RowsOfFields($rows);
  }

  /**
   * Sets the default domain.
   *
   * @param string $domain_id
   *   The machine name or hostname of the domain to make default.
   * @param array $options
   *   An associative array of options whose values come from cli, aliases,
   *   config, etc.
   * @option validate
   *   Force a check of the URL response before allowing registration.
   * @usage drush domain-default www.example.com
   * @usage drush domain-default example_org
   * @usage drush domain-default www.example.org --validate=1
   *
   * @command domain:default
   * @aliases domain-default
   *
   * @return string
   *   The machine name of the default domain.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function defaultDomain($domain_id, array $options = ['validate' => NULL]) {
    // Resolve the domain.
    if (!empty($domain_id) && $domain = $this->getDomainFromArgument($domain_id)) {
      $validate = ($options['validate']) ? 1 : 0;
      $domain->addProperty('validate_url', $validate);
      if ($error = $this->checkHttpResponse($domain)) {
        throw new DomainCommandException(dt('Unable to verify domain !domain: !error',
          ['!domain' => $domain->getHostname(), '!error' => $error]));
      }
      else {
        $domain->saveDefault();
      }
    }

    // Now, ask for the current default, so we know if it worked.
    $domain = $this->domainStorage()->loadDefaultDomain();
    if ($domain->status()) {
      $this->logger()->info(dt('!domain set to primary domain.',
        ['!domain' => $domain->getHostname()]));
    }
    else {
      $this->logger()->warning(dt('!domain set to primary domain, but is also inactive.',
        ['!domain' => $domain->getHostname()]));
    }
    return $domain->id();
  }

  /**
   * Deactivates the domain.
   *
   * @param string $domain_id
   *   The numeric id or hostname of the domain to disable.
   * @usage drush domain-disable example.com
   * @usage drush domain-disable 1
   *
   * @command domain:disable
   * @aliases domain-disable
   *
   * @return string
   *   Message to print.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function disable($domain_id) {
    // Resolve the domain.
    if ($domain = $this->getDomainFromArgument($domain_id)) {
      if ($domain->status()) {
        $domain->disable();
        $this->logger()->info(dt('!domain has been disabled.',
          ['!domain' => $domain->getHostname()]));
        return dt('Disabled !domain.', ['!domain' => $domain->getHostname()]);
      }
      else {
        $this->logger()->info(dt('!domain is already disabled.',
          ['!domain' => $domain->getHostname()]));
        return dt('!domain is already disabled.',
          ['!domain' => $domain->getHostname()]
        );
      }
    }
    return dt('No matching domain record found.');
  }

  /**
   * Activates the domain.
   *
   * @param string $domain_id
   *   The numeric id or hostname of the domain to enable.
   * @usage drush domain-disable example.com
   * @usage drush domain-enable 1
   *
   * @command domain:enable
   * @aliases domain-enable
   *
   * @return string
   *   The message to print.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function enable($domain_id) {
    // Resolve the domain.
    if ($domain = $this->getDomainFromArgument($domain_id)) {
      if (!$domain->status()) {
        $domain->enable();
        $this->logger()->info(dt('!domain has been enabled.',
          ['!domain' => $domain->getHostname()]));
        return dt('Enabled !domain.', ['!domain' => $domain->getHostname()]);
      }
      else {
        $this->logger()->info(dt('!domain is already enabled.',
          ['!domain' => $domain->getHostname()]));
        return dt('!domain is already enabled.',
          ['!domain' => $domain->getHostname()]
        );
      }
    }
    return dt('No matching domain record found.');
  }

  /**
   * Changes a domain label.
   *
   * @param string $domain_id
   *   The machine name or hostname of the domain to relabel.
   * @param string $name
   *   The name to use for the domain.
   * @usage drush domain-name example.com Foo
   * @usage drush domain-name 1 Foo
   *
   * @command domain:name
   * @aliases domain-name
   *
   * @return string
   *   The message to print.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function renameDomain($domain_id, $name) {
    // Resolve the domain.
    if ($domain = $this->getDomainFromArgument($domain_id)) {
      $domain->saveProperty('name', $name);
      return dt('Renamed !domain to !name.', ['!domain' => $domain->getHostname(), '!name' => $domain->label()]);
    }
    return dt('No matching domain record found.');
  }

  /**
   * Changes a domain scheme.
   *
   * @param string $domain_id
   *   The machine name or hostname of the domain to change.
   * @param string $scheme
   *   The scheme to use for the domain: http, https, or variable.
   *
   * @usage drush domain-scheme example.com http
   * @usage drush domain-scheme example_com https
   *
   * @command domain:scheme
   * @aliases domain-scheme
   *
   * @return string
   *   The message to print.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function scheme($domain_id, $scheme = NULL) {
    $new_scheme = NULL;

    // Resolve the domain.
    if ($domain = $this->getDomainFromArgument($domain_id)) {
      if (!empty($scheme)) {
        // Set with a value.
        $new_scheme = $scheme;
      }
      else {
        // Prompt for selection.
        $new_scheme = $this->io()->choice(dt('Select the default http scheme:'),
          [
            'http' => 'http',
            'https' => 'https',
            'variable' => 'variable',
          ]);
      }

      // If we were asked to change scheme, validate the value and do so.
      if (!empty($new_scheme)) {
        switch ($new_scheme) {
          case 'http':
            $new_scheme = 'http';
            break;

          case 'https':
            $new_scheme = 'https';
            break;

          case 'variable':
            $new_scheme = 'variable';
            break;

          default:
            throw new DomainCommandException(
              dt('Scheme name "!scheme" not known.', ['!scheme' => $new_scheme])
            );
        }
        $domain->saveProperty('scheme', $new_scheme);
      }

      // Return the (new | current) scheme for this domain.
      return dt('Scheme is now to "!scheme." for !domain',
        ['!scheme' => $domain->get('scheme'), '!domain' => $domain->id()]
      );
    }

    // We couldn't find the domain, so fail.
    throw new DomainCommandException(
      dt('Domain name "!domain" not known.', ['!domain' => $domain_id])
    );
  }

  /**
   * Generate domains for testing.
   *
   * @param string $primary
   *   The primary domain to use. This will be created and used for
   *   *.example.com hostnames.
   * @param array $options
   *   An associative array of options whose values come from cli, aliases,
   *   config, etc.
   *
   * @option count
   *   The count of extra domains to generate. Default is 15.
   * @option empty
   *   Pass empty=1 to truncate the {domain} table before creating records.
   * @option scheme
   *   Options are http | https | variable
   * @usage drush domain-generate example.com
   * @usage drush domain-generate example.com --count=25
   * @usage drush domain-generate example.com --count=25 --empty=1
   * @usage drush domain-generate example.com --count=25 --empty=1 --scheme=https
   * @usage drush gend
   * @usage drush gend --count=25
   * @usage drush gend --count=25 --empty=1
   * @usage drush gend --count=25 --empty=1 --scheme=https
   *
   * @command domain:generate
   * @aliases gend,domgen,domain-generate
   *
   * @return string
   *   The message to print.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  public function generate($primary = 'example.com', array $options = ['count' => NULL, 'empty' => NULL, 'scheme' => 'http']) {
    // Check the number of domains to create.
    $count = $options['count'];
    if (is_null($count)) {
      $count = 15;
    }

    $domains = $this->domainStorage()->loadMultiple(NULL);
    if (!empty($options['empty'])) {
      $this->domainStorage()->delete($domains);
      $domains = $this->domainStorage()->loadMultiple(NULL);
    }
    // Ensure we don't duplicate any domains.
    $existing = [];
    if (!empty($domains)) {
      foreach ($domains as $domain) {
        $existing[] = $domain->getHostname();
      }
    }
    // Set up one.* and so on.
    $names = [
      'one',
      'two',
      'three',
      'four',
      'five',
      'six',
      'seven',
      'eight',
      'nine',
      'ten',
      'foo',
      'bar',
      'baz',
    ];
    // Set the creation array.
    $new = [$primary];
    foreach ($names as $name) {
      $new[] = $name . '.' . $primary;
    }
    // Include a non hostname.
    $new[] = 'my' . $primary;
    // Filter against existing so we can count correctly.
    $prepared = [];
    foreach ($new as $key => $value) {
      if (!in_array($value, $existing, TRUE)) {
        $prepared[] = $value;
      }
    }

    // Add any test domains that have numeric prefixes. We don't expect these
    // URLs to work, and mainly use them for testing the user interface.
    $start = 1;
    foreach ($existing as $exists) {
      $name = explode('.', $exists);
      if (substr_count($name[0], 'test') > 0) {
        $num = (int) str_replace('test', '', $name[0]) + 1;
        if ($num > $start) {
          $start = $num;
        }
      }
    }
    $needed = $count - count($prepared) + $start;
    for ($i = $start; $i <= $needed; $i++) {
      $prepared[] = 'test' . $i . '.' . $primary;
    }
    // Get the initial item weight for sorting.
    $start_weight = count($domains);
    $prepared = array_slice($prepared, 0, $count);
    $list = [];

    // Create the domains.
    foreach ($prepared as $key => $item) {
      $hostname = mb_strtolower($item);
      $values = [
        'name' => ($item != $primary) ? ucwords(str_replace(".$primary", '', $item)) : \Drupal::config('system.site')->get('name'),
        'hostname' => $hostname,
        'scheme' => $options['scheme'],
        'status' => 1,
        'weight' => ($item != $primary) ? $key + $start_weight + 1 : -1,
        'is_default' => 0,
        'id' => $this->domainStorage()->createMachineName($hostname),
      ];
      $domain = $this->domainStorage()->create($values);
      $domain->save();
      $list[] = dt('Created @domain.', ['@domain' => $domain->getHostname()]);
    }

    // If nothing created, say so.
    if (empty($prepared)) {
      return dt('No new domains were created.');
    }
    else {
      return dt("Created @count new domains:\n@list",
        ['@count' => count($prepared), '@list' => implode("\n", $list)]);
    }
  }

  /**
   * Gets a domain storage object or throw an exception.
   *
   * Note that domain can run very early in the bootstrap, so we cannot
   * reliably inject this service.
   *
   * @return \Drupal\domain\DomainStorageInterface
   *   The domain storage handler.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  protected function domainStorage() {
    if (!is_null($this->domainStorage)) {
      return $this->domainStorage;
    }

    try {
      $this->domainStorage = \Drupal::entityTypeManager()->getStorage('domain');
    }
    catch (PluginNotFoundException $e) {
      throw new DomainCommandException('Unable to get domain: no storage', $e);
    }
    catch (InvalidPluginDefinitionException $e) {
      throw new DomainCommandException('Unable to get domain: bad storage', $e);
    }

    return $this->domainStorage;
  }

  /**
   * Loads a domain based on a string identifier.
   *
   * @param string $argument
   *   The machine name or the hostname of an existing domain.
   *
   * @return \Drupal\domain\DomainInterface
   *   The domain entity.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  protected function getDomainFromArgument($argument) {

    // Try loading domain assuming arg is a machine name.
    $domain = $this->domainStorage()->load($argument);
    if (!$domain) {
      // Try loading assuming it is a host name.
      $domain = $this->domainStorage()->loadByHostname($argument);
    }

    // domain_id (an INT) is only used internally because the Node Access
    // system demands the use of numeric keys. It should never be used to load
    // or identify domain records. Use the machine_name or hostname instead.
    if (!$domain) {
      throw new DomainCommandException(
        dt('Domain record could not be found from "!a".', ['!a' => $argument])
      );
    }

    return $domain;
  }

  /**
   * Filters a list of domains by specific exclude list.
   *
   * @param \Drupal\domain\DomainInterface[] $domains
   *   List of domains.
   * @param string[] $exclude
   *   List of domain id to exclude from the list.
   * @param \Drupal\domain\DomainInterface[] $initial
   *   Initial value of list that will be returned.
   *
   * @return array
   *   An array of domains.
   */
  protected function filterDomains(array $domains, array $exclude, array $initial = []) {
    foreach ($domains as $domain) {
      // Exclude unwanted domains.
      if (!in_array($domain->id(), $exclude, FALSE)) {
        $initial[$domain->id()] = $domain;
      }
    }
    return $initial;
  }

  /**
   * Checks the domain response.
   *
   * @param \Drupal\domain\DomainInterface $domain
   *   The domain to check.
   * @param bool $validate_url
   *   True to validate this domain by performing a URL lookup; False to skip
   *   the checks.
   *
   * @return bool
   *   True if the domain resolves properly, or we are not checking,
   *   False otherwise.
   */
  protected function checkHttpResponse(DomainInterface $domain, $validate_url = FALSE) {
    // Ensure the url is rebuilt.
    if ($validate_url) {
      $code = $this->checkDomain($domain);
      // Some sort of success:
      return ($code >= 200 && $code <= 299);
    }
    // Not validating, return FALSE.
    return FALSE;
  }

  /**
   * Helper function: check a domain is responsive and create it.
   *
   * @param \Drupal\domain\DomainInterface $domain
   *   The (as yet unsaved) domain to create.
   * @param bool $check_response
   *   Indicates that registration should not be allowed unless the server
   *   returns a 200 response.
   *
   * @return bool
   *   TRUE or FALSE indicating success of the action.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  protected function createDomain(DomainInterface $domain, $check_response = FALSE) {
    if ($check_response) {
      $valid = $this->checkHttpResponse($domain, TRUE);
      if (!$valid) {
        throw new DomainCommandException(
          dt('The server did not return a 200 response for !d. Domain creation failed. Remove the --validate flag to save this domain.', ['!d' => $domain->getHostname()])
        );
      }
    }
    else {
      try {
        $domain->save();
      }
      catch (EntityStorageException $e) {
        throw new DomainCommandException('Unable to save domain', $e);
      }

      if ($domain->getDomainId()) {
        $this->logger()->info(dt('Created @name at @domain.',
          ['@name' => $domain->label(), '@domain' => $domain->getHostname()]));
        return TRUE;
      }
      else {
        $this->logger()->error(dt('The request could not be completed.'));
      }
    }
    return FALSE;
  }

  /**
   * Checks if a domain exists by trying to do an http request to it.
   *
   * @param \Drupal\domain\DomainInterface $domain
   *   The domain to validate for syntax and uniqueness.
   *
   * @return int
   *   The server response code for the request.
   *
   * @see domain_validate()
   */
  protected function checkDomain(DomainInterface $domain) {
    /** @var \Drupal\domain\DomainValidatorInterface $validator */
    $validator = \Drupal::service('domain.validator');
    return $validator->checkResponse($domain);
  }

  /**
   * Validates a domain meets the standards for a hostname.
   *
   * @param \Drupal\domain\DomainInterface $domain
   *   The domain to validate for syntax and uniqueness.
   *
   * @return string[]
   *   Array of strings indicating issues found.
   *
   * @see domain_validate()
   */
  protected function validateDomain(DomainInterface $domain) {
    /** @var \Drupal\domain\DomainValidatorInterface $validator */
    $validator = \Drupal::service('domain.validator');
    return $validator->validate($domain->getHostname());
  }

  /**
   * Deletes a domain record.
   *
   * @param \Drupal\domain\DomainInterface[] $domains
   *   The domains to delete.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   * @throws \UnexpectedValueException
   */
  protected function deleteDomain(array $domains) {
    foreach ($domains as $domain) {
      if (!$domain instanceof DomainInterface) {
        throw new StorageException('deleting domains: value is not a domain');
      }
      $hostname = $domain->getHostname();

      if ($this->isDryRun) {
        $this->logger()->info(dt('DRYRUN: Domain record @domain deleted.',
          ['@domain' => $hostname]));
        continue;
      }

      try {
        $domain->delete();
      }
      catch (EntityStorageException $e) {
        throw new DomainCommandException(dt('Unable to delete domain: @domain',
          ['@domain' => $hostname]), $e);
      }
      $this->logger()->info(dt('Domain record @domain deleted.',
        ['@domain' => $hostname]));
    }
  }

  /**
   * Returns a list of the entity types that are domain enabled.
   *
   * A domain-enabled entity is defined here as an entity type that includes
   * the domain access field(s).
   *
   * @param string $using_field
   *   The specific field name to look for.
   *
   * @return string[]
   *   List of entity machine names that support domain references.
   */
  protected function findDomainEnabledEntities($using_field = DomainInterface::DOMAIN_ADMIN_FIELD) {
    $this->ensureEntityFieldMap();
    $entities = [];
    foreach ($this->entityFieldMap as $type => $fields) {
      if (array_key_exists($using_field, $fields)) {
        $entities[] = $type;
      }
    }
    return $entities;
  }

  /**
   * Determines whether or not a given entity is domain-enabled.
   *
   * @param string $entity_type
   *   The machine name of the entity.
   * @param string $field
   *   The name of the field to check for existence.
   *
   * @return bool
   *   True if this type of entity has a domain field.
   */
  protected function entityHasDomainField($entity_type, $field = DomainInterface::DOMAIN_ADMIN_FIELD) {
    // Try to avoid repeated calls to getFieldMap(), assuming it's expensive.
    $this->ensureEntityFieldMap();
    return array_key_exists($field, $this->entityFieldMap[$entity_type]);
  }

  /**
   * Ensure the local entity field map has been defined.
   *
   * Asking for the entity field map cause a lot of lookup, so we lazily
   * fetch it and then remember it to avoid repeated checks.
   */
  protected function ensureEntityFieldMap() {
    // Try to avoid repeated calls to getFieldMap() assuming it's expensive.
    if (empty($this->entityFieldMap)) {
      $entity_field_manager = \Drupal::service('entity_field.manager');
      $this->entityFieldMap = $entity_field_manager->getFieldMap();
    }
  }

  /**
   * Enumerate entity instances of the supplied type and domain.
   *
   * @param string $entity_type
   *   The entity type name, e.g. 'node'.
   * @param string $domain_id
   *   The machine name of the domain to enumerate.
   * @param string $field
   *   The field to manipulate in the entity, e.g.
   *   DomainAccessManagerInterface::DOMAIN_ACCESS_FIELD.
   * @param bool $just_count
   *   Flag to return a count rather than a list.
   *
   * @return int|string[]
   *   List of entity IDs for the selected domain or a count of domains.
   */
  protected function enumerateDomainEntities($entity_type, $domain_id, $field, $just_count = FALSE) {
    if (!$this->entityHasDomainField($entity_type, $field)) {
      $this->logger()->info('Entity type @entity_type does not have field @field, so none found.',
        [
          '@entity_type' => $entity_type,
          '@field' => $field,
        ]
      );
      return [];
    }

    $efq = \Drupal::entityQuery($entity_type);
    // Don't access check or we wont get all of the possible entities moved.
    $efq->accessCheck(FALSE);
    $efq->condition($field, $domain_id, '=');
    if ($just_count) {
      $efq->count();
    }
    return $efq->execute();
  }

  /**
   * Reassign old_domain entities, of the supplied type, to the new_domain.
   *
   * @param string $entity_type
   *   The entity type name, e.g. 'node'.
   * @param string $field
   *   The field to manipulate in the entity, e.g.
   *   DomainInterface::DOMAIN_ADMIN_FIELD.
   * @param \Drupal\domain\DomainInterface $old_domain
   *   The domain the entities currently belong to. It is not an error for
   *   entity ids to be passed in that are not in this domain, though of course
   *   not very useful.
   * @param \Drupal\domain\DomainInterface $new_domain
   *   The domain the entities should now belong to: When an entity belongs to
   *   the old_domain, this domain replaces it.
   * @param array $ids
   *   List of entity IDs for the selected domain and all of type $entity_type.
   *
   * @return int
   *   A count of the number of entities changed.
   *
   * @throws \Drupal\Component\Plugin\Exception\PluginException
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  protected function reassignEntities($entity_type, $field, DomainInterface $old_domain, DomainInterface $new_domain, array $ids) {
    $entity_storage = \Drupal::entityTypeManager()->getStorage($entity_type);
    $entities = $entity_storage->loadMultiple($ids);

    foreach ($entities as $entity) {
      $changed = FALSE;
      if (!$entity->hasField($field)) {
        continue;
      }
      // Multivalue fields are used, so check each one.
      foreach ($entity->get($field) as $k => $item) {
        if ($item->target_id == $old_domain->id()) {

          if ($this->isDryRun) {
            $this->logger()->info(dt('DRYRUN: Update domain membership for entity @id to @new.',
                [
                  '@id' => $entity->id(),
                  '@new' => $new_domain->id(),
                ]
              )
            );
            // Don't set changed, so don't save either.
            continue;
          }

          $changed = TRUE;
          $item->target_id = $new_domain->id();
        }
      }
      if ($changed) {
        $entity->save();
      }
    }
    return count($entities);
  }

  /**
   * Return the Domain object corresponding to a policy string.
   *
   * @param string $policy
   *   In general one of 'prompt' | 'default' | 'ignore' or a domain entity
   *   machine name, but this function does not process 'prompt'.
   *
   * @return \Drupal\Core\Entity\EntityInterface|\Drupal\domain\DomainInterface|null
   *   The requested domain or NULL if not found.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  protected function getDomainInstanceFromPolicy($policy) {
    switch ($policy) {
      // Use the Default Domain machine name.
      case 'default':
        $new_domain = $this->domainStorage()->loadDefaultDomain();
        break;

      // Ask interactively for a Domain machine name.
      case 'prompt':
      case 'ignore':
        return NULL;

      // Use this (specified) Domain machine name.
      default:
        $new_domain = $this->domainStorage()->load($policy);
        break;
    }

    return $new_domain;
  }

  /**
   * Reassign entities of the supplied type to the $policy domain.
   *
   * @param array $options
   *   Drush options sent to the command. An array such as the following:
   *   [
   *     'entity_filter' => 'node',
   *     'policy' => 'prompt' | 'default' | 'ignore' | {domain_id}
   *     'field' => DomainAccessManagerInterface::DOMAIN_ACCESS_FIELD,
   *   ];
   *   The caller is expected to provide this information.
   * @param array $domains
   *   Array of domain objects to reassign content away from.
   *
   * @return int
   *   The count of updated entities.
   *
   * @throws \Drupal\domain\Commands\DomainCommandException
   */
  protected function reassignLinkedEntities(array $domains, array $options) {
    $count = 0;
    $field = $options['field'];
    $entity_typenames = $this->findDomainEnabledEntities($field);

    $new_domain = $this->getDomainInstanceFromPolicy($options['policy']);
    if (empty($new_domain)) {
      throw new DomainCommandException('invalid destination domain');
    }

    // Loop through each entity type.
    $exceptions = FALSE;
    foreach ($entity_typenames as $name) {
      if (empty($options['entity_filter']) || $options['entity_filter'] === $name) {

        // For each domain being reassigned from...
        foreach ($domains as $domain) {
          $ids = $this->enumerateDomainEntities($name, $domain->id(), $field);
          if (!empty($ids)) {
            try {
              if ($options['chatty']) {
                $this->logger()->info('Reassigning @count @entity_name entities to @domain',
                  [
                    '@entity_name' => '',
                    '@count' => count($ids),
                    '@domain' => $new_domain->id(),
                  ]
                );
              }
              $count = $this->reassignEntities($name, $field, $domain, $new_domain, $ids);
            }
            catch (PluginException $e) {
              $exceptions = TRUE;
              $this->logger()->error('Unable to reassign content to @new_domain: plugin exception: @ex',
                [
                  '@ex' => $e->getMessage(),
                  '@new_domain' => $new_domain->id(),
                ]
              );
            }
            catch (EntityStorageException $e) {
              $exceptions = TRUE;
              $this->logger()->error('Unable to reassign content to @new_domain: storage exception: @ex',
                [
                  '@ex' => $e->getMessage(),
                  '@new_domain' => $new_domain->id(),
                ]
              );
            }
          }
        }
      }
    }
    if ($exceptions) {
      throw new DomainCommandException('Errors encountered during reassign.');
    }

    return $count;
  }

}
