/* eslint-disable import/no-extraneous-dependencies */
/* cspell:words linkediting linkimageediting linkcommand */
import { Plugin } from 'ckeditor5/src/core';
import { Matcher } from 'ckeditor5/src/engine';
import { toMap } from 'ckeditor5/src/utils';

/**
 * Returns the first drupal-entity element in a given view element.
 *
 * @param {module:engine/view/element~Element} viewElement
 *   The view element.
 *
 * @return {module:engine/view/element~Element|undefined}
 *   The first <drupal-entity> element or undefined if the element doesn't have
 *   <drupal-entity> as a child element.
 */
function getFirstEntityEmbed(viewElement) {
  return Array.from(viewElement.getChildren()).find(
    (child) => child.name === 'drupal-entity'
  );
}

/**
 * Returns a converter that consumes the `href` attribute if a link contains a <drupal-entity>.
 *
 * @return {Function}
 *   A function that adds an event listener to upcastDispatcher.
 */
function upcastEntityEmbedLink() {
  return (dispatcher) => {
    dispatcher.on(
      'element:a',
      (evt, data, conversionApi) => {
        const viewLink = data.viewItem;
        const entityEmbedInLink = getFirstEntityEmbed(viewLink);

        if (!entityEmbedInLink) {
          return;
        }

        // There's an <drupal-entity> inside an <a> element - we consume it so it
        // won't be picked up by the Link plugin.
        const consumableAttributes = { attributes: ['href'], name: true };

        // Consume the `href` attribute so the default one will not convert it to
        // $text attribute.
        if (!conversionApi.consumable.consume(viewLink, consumableAttributes)) {
          // Might be consumed by something else - i.e. other converter with
          // priority=highest - a standard check.
          return;
        }

        const linkHref = viewLink.getAttribute('href');

        // Missing the `href` attribute.
        if (!linkHref) {
          return;
        }

        const conversionResult = conversionApi.convertItem(
          entityEmbedInLink,
          data.modelCursor
        );

        // Set entity embed range as conversion result.
        data.modelRange = conversionResult.modelRange;

        // Continue conversion where <drupal-entity> conversion ends.
        data.modelCursor = conversionResult.modelCursor;

        const modelElement = data.modelCursor.nodeBefore;

        if (modelElement && modelElement.is('element', 'drupalEntity')) {
          // Set the `linkHref` attribute from <a> element on model drupalEntity
          // element.
          conversionApi.writer.setAttribute('linkHref', linkHref, modelElement);
        }
      },
      { priority: 'high' }
    );
  };
}

/**
 * Return a converter that adds the <a> element to view data.
 *
 * @return {Function}
 *   A function that adds an event listener to downcastDispatcher.
 */
function dataDowncastEntityEmbedLink() {
  return (dispatcher) => {
    dispatcher.on(
      'attribute:linkHref:drupalEntity',
      (evt, data, conversionApi) => {
        const { writer } = conversionApi;
        if (!conversionApi.consumable.consume(data.item, evt.name)) {
          return;
        }

        // The drupalEntity will be already converted - so it will be present in
        // the view.
        const entityEmbedElement = conversionApi.mapper.toViewElement(
          data.item
        );

        // If so, update the attribute if it's defined or remove the entire link
        // if the attribute is empty. But if it does not exist. Let's wrap already
        // converted drupalEntity by newly created link element.
        // 1. Create an empty <a> element.
        const linkElement = writer.createContainerElement('a', {
          href: data.attributeNewValue,
        });

        // 2. Insert <a> before the <drupal-entity> element.
        writer.insert(
          writer.createPositionBefore(entityEmbedElement),
          linkElement
        );

        // 3. Move the drupal-entity element inside the <a>.
        writer.move(
          writer.createRangeOn(entityEmbedElement),
          writer.createPositionAt(linkElement, 0)
        );
      },
      { priority: 'high' }
    );
  };
}

/**
 * Return a converter that adds the <a> element to editing view.
 *
 * @return {Function}
 *   A function that adds an event listener to downcastDispatcher.
 *
 * @see https://github.com/ckeditor/ckeditor5/blob/v31.0.0/packages/ckeditor5-link/src/linkimageediting.js#L180
 */
function editingDowncastEntityEmbedLink() {
  return (dispatcher) => {
    dispatcher.on(
      'attribute:linkHref:drupalEntity',
      (evt, data, conversionApi) => {
        const { writer } = conversionApi;
        if (!conversionApi.consumable.consume(data.item, evt.name)) {
          return;
        }

        // The drupalEntity will be already converted - so it will be present in
        // the view.
        const entityEmbedContainer = conversionApi.mapper.toViewElement(
          data.item
        );
        const linkInEntityEmbed = Array.from(
          entityEmbedContainer.getChildren()
        ).find((child) => child.name === 'a');

        // If link already exists, instead of creating new link from scratch,
        // update the existing link. This makes the UI rendering much smoother.
        if (linkInEntityEmbed) {
          // If attribute has a new value, update it. If new value doesn't exist,
          // the link will be removed.
          if (data.attributeNewValue) {
            writer.setAttribute(
              'href',
              data.attributeNewValue,
              linkInEntityEmbed
            );
          } else {
            // This is triggering elementToElement conversion for drupalEntity
            // element which makes caused re-render of the entity embed preview, making
            // the entity embed preview flicker once when entity embed is unlinked.
            // @todo ensure that this doesn't cause flickering after
            //   https://www.drupal.org/i/3304834 has been addressed.
            writer.move(
              writer.createRangeIn(linkInEntityEmbed),
              writer.createPositionAt(entityEmbedContainer, 0)
            );
            writer.remove(linkInEntityEmbed);
          }
        } else {
          const entityEmbedPreview = Array.from(
            entityEmbedContainer.getChildren()
          ).find((child) => child.getAttribute('data-drupal-entity-preview'));
          // 1. Create an empty <a> element.
          const linkElement = writer.createContainerElement('a', {
            href: data.attributeNewValue,
          });

          // 2. Insert <a> inside the entity embed container.
          writer.insert(
            writer.createPositionAt(entityEmbedContainer, 0),
            linkElement
          );

          // 3. Move the entity embed preview inside the <a>.
          writer.move(
            writer.createRangeOn(entityEmbedPreview),
            writer.createPositionAt(linkElement, 0)
          );
        }
      },
      { priority: 'high' }
    );
  };
}

/**
 * Returns a converter that enables manual decorators on linked Drupal Entity.
 *
 * @see \Drupal\editor\EditorXssFilter\Standard
 *
 * @param {module:link/link~LinkDecoratorDefinition} decorator
 *   The link decorator.
 * @return {function}
 *   Function attaching event listener to dispatcher.
 *
 * @private
 */
function downcastEntityEmbedLinkManualDecorator(decorator) {
  return (dispatcher) => {
    dispatcher.on(
      `attribute:${decorator.id}:drupalEntity`,
      (evt, data, conversionApi) => {
        const entityEmbedContainer = conversionApi.mapper.toViewElement(
          data.item
        );

        // Scenario 1: `<figure>` element that contains `<a>`, generated by
        // `dataDowncast`.
        let entityEmbedLink = Array.from(
          entityEmbedContainer.getChildren()
        ).find((child) => child.name === 'a');

        // Scenario 2: `<drupal-entity>` wrapped with `<a>`, generated by
        // `editingDowncast`.
        if (!entityEmbedLink && entityEmbedContainer.is('element', 'a')) {
          entityEmbedLink = entityEmbedContainer;
        } else {
          entityEmbedLink = Array.from(
            entityEmbedContainer.getAncestors()
          ).find((ancestor) => ancestor.name === 'a');
        }

        // The <a> element was removed by the time this converter is executed.
        // It may happen when the base `linkHref` and decorator attributes are
        // removed at the same time.
        if (!entityEmbedLink) {
          return;
        }

        // eslint-disable-next-line no-restricted-syntax
        for (const [key, val] of toMap(decorator.attributes)) {
          conversionApi.writer.setAttribute(key, val, entityEmbedLink);
        }

        if (decorator.classes) {
          conversionApi.writer.addClass(decorator.classes, entityEmbedLink);
        }

        // Add support for `style` attribute in manual decorators to remain
        // consistent with CKEditor 5. This only works with text formats that
        // have no HTMl filtering enabled.
        // eslint-disable-next-line no-restricted-syntax
        for (const key in decorator.styles) {
          if (Object.prototype.hasOwnProperty.call(decorator.styles, key)) {
            conversionApi.writer.setStyle(
              key,
              decorator.styles[key],
              entityEmbedLink
            );
          }
        }
      }
    );
  };
}

/**
 * Returns a converter that applies manual decorators to linked Drupal Entity.
 *
 * @param {module:core/editor/editor~Editor} editor
 *   The editor.
 * @param {module:link/link~LinkDecoratorDefinition} decorator
 *   The link decorator.
 * @return {function}
 *   Function attaching event listener to dispatcher.
 *
 * @private
 */
function upcastEntityEmbedLinkManualDecorator(editor, decorator) {
  return (dispatcher) => {
    dispatcher.on(
      'element:a',
      (evt, data, conversionApi) => {
        const viewLink = data.viewItem;
        const drupalEntityEmbedInLink = getFirstEntityEmbed(viewLink);

        // We need to check whether Drupal Entity is inside a link because the
        // converter handles only manual decorators for linked Drupal Entity.
        if (!drupalEntityEmbedInLink) {
          return;
        }

        const matcher = new Matcher(decorator._createPattern());
        const result = matcher.match(viewLink);

        // The link element does not have required attributes or/and proper
        // values.
        if (!result) {
          return;
        }

        // Check whether we can consume those attributes.
        if (!conversionApi.consumable.consume(viewLink, result.match)) {
          return;
        }

        // At this stage we can assume that we have the `<drupalEntity>` element.
        const modelElement = data.modelCursor.nodeBefore;

        conversionApi.writer.setAttribute(decorator.id, true, modelElement);
      },
      { priority: 'high' }
    );
    // Using the same priority as the entity embed link upcast converter guarantees
    // that the linked `<drupalEntity>` was already converted.
    // @see upcastEntityEmbedLink().
  };
}

/**
 * Model to view and view to model conversions for linked entity embed elements.
 *
 * @private
 *
 * @see https://github.com/ckeditor/ckeditor5/blob/v31.0.0/packages/ckeditor5-link/src/linkimage.js
 */
export default class EntityEmbedLinkEditing extends Plugin {
  /**
   * @inheritdoc
   */
  static get requires() {
    return ['EntityEmbedEditing'];
  }

  /**
   * @inheritdoc
   */
  static get pluginName() {
    return 'EntityEmbedLinkEditing';
  }

  /**
   * @inheritdoc
   */
  init() {
    const { editor } = this;
    editor.model.schema.extend('drupalEntity', {
      allowAttributes: ['linkHref'],
    });

    editor.conversion.for('upcast').add(upcastEntityEmbedLink());
    editor.conversion
      .for('editingDowncast')
      .add(editingDowncastEntityEmbedLink());
    editor.conversion.for('dataDowncast').add(dataDowncastEntityEmbedLink());

    this._enableManualDecorators();

    const linkCommand = editor.commands.get('link');
    if (linkCommand.automaticDecorators.length > 0) {
      throw new Error(
        'The Drupal Entity plugin is not compatible with automatic link decorators. To use Drupal Entity, disable any plugins providing automatic link decorators.'
      );
    }
  }

  /**
   * Processes transformed manual link decorators and attaches proper converters
   * that will work when linking Drupal Entity.
   *
   * @see module:link/linkimageediting~LinkImageEditing
   * @see module:link/linkcommand~LinkCommand
   * @see module:link/utils~ManualDecorator
   *
   * @private
   */
  _enableManualDecorators() {
    const editor = this.editor;
    const command = editor.commands.get('link');

    // eslint-disable-next-line no-restricted-syntax
    for (const decorator of command.manualDecorators) {
      editor.model.schema.extend('drupalEntity', {
        allowAttributes: decorator.id,
      });
      editor.conversion
        .for('downcast')
        .add(downcastEntityEmbedLinkManualDecorator(decorator));
      editor.conversion
        .for('upcast')
        .add(upcastEntityEmbedLinkManualDecorator(editor, decorator));
    }
  }
}
