/**
 * @license
 * Copyright 2017 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Class that controls updates to connections during drags.
 */
'use strict';

/**
 * Class that controls updates to connections during drags.
 * @class
 */
goog.module('Blockly.InsertionMarkerManager');

const blockAnimations = goog.require('Blockly.blockAnimations');
const common = goog.require('Blockly.common');
const constants = goog.require('Blockly.constants');
const eventUtils = goog.require('Blockly.Events.utils');
const internalConstants = goog.require('Blockly.internalConstants');
/* eslint-disable-next-line no-unused-vars */
const {BlockSvg} = goog.requireType('Blockly.BlockSvg');
const {ComponentManager} = goog.require('Blockly.ComponentManager');
const {ConnectionType} = goog.require('Blockly.ConnectionType');
/* eslint-disable-next-line no-unused-vars */
const {Coordinate} = goog.requireType('Blockly.utils.Coordinate');
/* eslint-disable-next-line no-unused-vars */
const {IDeleteArea} = goog.requireType('Blockly.IDeleteArea');
/* eslint-disable-next-line no-unused-vars */
const {IDragTarget} = goog.requireType('Blockly.IDragTarget');
/* eslint-disable-next-line no-unused-vars */
const {RenderedConnection} = goog.requireType('Blockly.RenderedConnection');
/* eslint-disable-next-line no-unused-vars */
const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg');


/**
 * Class that controls updates to connections during drags.  It is primarily
 * responsible for finding the closest eligible connection and highlighting or
 * unhighlighting it as needed during a drag.
 * @param {!BlockSvg} block The top block in the stack being dragged.
 * @constructor
 * @alias Blockly.InsertionMarkerManager
 */
const InsertionMarkerManager = function(block) {
  common.setSelected(block);

  /**
   * The top block in the stack being dragged.
   * Does not change during a drag.
   * @type {!BlockSvg}
   * @private
   */
  this.topBlock_ = block;

  /**
   * The workspace on which these connections are being dragged.
   * Does not change during a drag.
   * @type {!WorkspaceSvg}
   * @private
   */
  this.workspace_ = block.workspace;

  /**
   * The last connection on the stack, if it's not the last connection on the
   * first block.
   * Set in initAvailableConnections, if at all.
   * @type {RenderedConnection}
   * @private
   */
  this.lastOnStack_ = null;

  /**
   * The insertion marker corresponding to the last block in the stack, if
   * that's not the same as the first block in the stack.
   * Set in initAvailableConnections, if at all
   * @type {BlockSvg}
   * @private
   */
  this.lastMarker_ = null;

  /**
   * The insertion marker that shows up between blocks to show where a block
   * would go if dropped immediately.
   * @type {BlockSvg}
   * @private
   */
  this.firstMarker_ = this.createMarkerBlock_(this.topBlock_);

  /**
   * The connection that this block would connect to if released immediately.
   * Updated on every mouse move.
   * This is not on any of the blocks that are being dragged.
   * @type {RenderedConnection}
   * @private
   */
  this.closestConnection_ = null;

  /**
   * The connection that would connect to this.closestConnection_ if this block
   * were released immediately.
   * Updated on every mouse move.
   * This is on the top block that is being dragged or the last block in the
   * dragging stack.
   * @type {RenderedConnection}
   * @private
   */
  this.localConnection_ = null;

  /**
   * Whether the block would be deleted if it were dropped immediately.
   * Updated on every mouse move.
   * @type {boolean}
   * @private
   */
  this.wouldDeleteBlock_ = false;

  /**
   * Connection on the insertion marker block that corresponds to
   * this.localConnection_ on the currently dragged block.
   * @type {RenderedConnection}
   * @private
   */
  this.markerConnection_ = null;

  /**
   * The block that currently has an input being highlighted, or null.
   * @type {BlockSvg}
   * @private
   */
  this.highlightedBlock_ = null;

  /**
   * The block being faded to indicate replacement, or null.
   * @type {BlockSvg}
   * @private
   */
  this.fadedBlock_ = null;

  /**
   * The connections on the dragging blocks that are available to connect to
   * other blocks.  This includes all open connections on the top block, as well
   * as the last connection on the block stack.
   * Does not change during a drag.
   * @type {!Array<!RenderedConnection>}
   * @private
   */
  this.availableConnections_ = this.initAvailableConnections_();
};

/**
 * An enum describing different kinds of previews the InsertionMarkerManager
 * could display.
 * @enum {number}
 */
InsertionMarkerManager.PREVIEW_TYPE = {
  INSERTION_MARKER: 0,
  INPUT_OUTLINE: 1,
  REPLACEMENT_FADE: 2,
};

/**
 * An error message to throw if the block created by createMarkerBlock_ is
 * missing any components.
 * @type {string}
 * @const
 */
InsertionMarkerManager.DUPLICATE_BLOCK_ERROR = 'The insertion marker ' +
    'manager tried to create a marker but the result is missing %1. If ' +
    'you are using a mutator, make sure your domToMutation method is ' +
    'properly defined.';

/**
 * Sever all links from this object.
 * @package
 */
InsertionMarkerManager.prototype.dispose = function() {
  this.availableConnections_.length = 0;

  eventUtils.disable();
  try {
    if (this.firstMarker_) {
      this.firstMarker_.dispose();
    }
    if (this.lastMarker_) {
      this.lastMarker_.dispose();
    }
  } finally {
    eventUtils.enable();
  }
};

/**
 * Update the available connections for the top block. These connections can
 * change if a block is unplugged and the stack is healed.
 * @package
 */
InsertionMarkerManager.prototype.updateAvailableConnections = function() {
  this.availableConnections_ = this.initAvailableConnections_();
};

/**
 * Return whether the block would be deleted if dropped immediately, based on
 * information from the most recent move event.
 * @return {boolean} True if the block would be deleted if dropped immediately.
 * @package
 */
InsertionMarkerManager.prototype.wouldDeleteBlock = function() {
  return this.wouldDeleteBlock_;
};

/**
 * Return whether the block would be connected if dropped immediately, based on
 * information from the most recent move event.
 * @return {boolean} True if the block would be connected if dropped
 *   immediately.
 * @package
 */
InsertionMarkerManager.prototype.wouldConnectBlock = function() {
  return !!this.closestConnection_;
};

/**
 * Connect to the closest connection and render the results.
 * This should be called at the end of a drag.
 * @package
 */
InsertionMarkerManager.prototype.applyConnections = function() {
  if (this.closestConnection_) {
    // Don't fire events for insertion markers.
    eventUtils.disable();
    this.hidePreview_();
    eventUtils.enable();
    // Connect two blocks together.
    this.localConnection_.connect(this.closestConnection_);
    if (this.topBlock_.rendered) {
      // Trigger a connection animation.
      // Determine which connection is inferior (lower in the source stack).
      const inferiorConnection = this.localConnection_.isSuperior() ?
          this.closestConnection_ :
          this.localConnection_;
      blockAnimations.connectionUiEffect(inferiorConnection.getSourceBlock());
      // Bring the just-edited stack to the front.
      const rootBlock = this.topBlock_.getRootBlock();
      rootBlock.bringToFront();
    }
  }
};

/**
 * Update connections based on the most recent move location.
 * @param {!Coordinate} dxy Position relative to drag start,
 *     in workspace units.
 * @param {?IDragTarget} dragTarget The drag target that the block is
 *     currently over.
 * @package
 */
InsertionMarkerManager.prototype.update = function(dxy, dragTarget) {
  const candidate = this.getCandidate_(dxy);

  this.wouldDeleteBlock_ = this.shouldDelete_(candidate, dragTarget);

  const shouldUpdate =
      this.wouldDeleteBlock_ || this.shouldUpdatePreviews_(candidate, dxy);

  if (shouldUpdate) {
    // Don't fire events for insertion marker creation or movement.
    eventUtils.disable();
    this.maybeHidePreview_(candidate);
    this.maybeShowPreview_(candidate);
    eventUtils.enable();
  }
};

/**
 * Create an insertion marker that represents the given block.
 * @param {!BlockSvg} sourceBlock The block that the insertion marker
 *     will represent.
 * @return {!BlockSvg} The insertion marker that represents the given
 *     block.
 * @private
 */
InsertionMarkerManager.prototype.createMarkerBlock_ = function(sourceBlock) {
  const imType = sourceBlock.type;

  eventUtils.disable();
  let result;
  try {
    result = this.workspace_.newBlock(imType);
    result.setInsertionMarker(true);
    if (sourceBlock.saveExtraState) {
      const state = sourceBlock.saveExtraState();
      if (state) {
        result.loadExtraState(state);
      }
    } else if (sourceBlock.mutationToDom) {
      const oldMutationDom = sourceBlock.mutationToDom();
      if (oldMutationDom) {
        result.domToMutation(oldMutationDom);
      }
    }
    // Copy field values from the other block.  These values may impact the
    // rendered size of the insertion marker.  Note that we do not care about
    // child blocks here.
    for (let i = 0; i < sourceBlock.inputList.length; i++) {
      const sourceInput = sourceBlock.inputList[i];
      if (sourceInput.name === constants.COLLAPSED_INPUT_NAME) {
        continue;  // Ignore the collapsed input.
      }
      const resultInput = result.inputList[i];
      if (!resultInput) {
        throw new Error(InsertionMarkerManager.DUPLICATE_BLOCK_ERROR.replace(
            '%1', 'an input'));
      }
      for (let j = 0; j < sourceInput.fieldRow.length; j++) {
        const sourceField = sourceInput.fieldRow[j];
        const resultField = resultInput.fieldRow[j];
        if (!resultField) {
          throw new Error(InsertionMarkerManager.DUPLICATE_BLOCK_ERROR.replace(
              '%1', 'a field'));
        }
        resultField.setValue(sourceField.getValue());
      }
    }

    result.setCollapsed(sourceBlock.isCollapsed());
    result.setInputsInline(sourceBlock.getInputsInline());

    result.initSvg();
    result.getSvgRoot().setAttribute('visibility', 'hidden');
  } finally {
    eventUtils.enable();
  }

  return result;
};

/**
 * Populate the list of available connections on this block stack.  This should
 * only be called once, at the beginning of a drag.
 * If the stack has more than one block, this function will populate
 * lastOnStack_ and create the corresponding insertion marker.
 * @return {!Array<!RenderedConnection>} A list of available
 *     connections.
 * @private
 */
InsertionMarkerManager.prototype.initAvailableConnections_ = function() {
  const available = this.topBlock_.getConnections_(false);
  // Also check the last connection on this stack
  const lastOnStack = this.topBlock_.lastConnectionInStack(true);
  if (lastOnStack && lastOnStack !== this.topBlock_.nextConnection) {
    available.push(lastOnStack);
    this.lastOnStack_ = lastOnStack;
    if (this.lastMarker_) {
      eventUtils.disable();
      try {
        this.lastMarker_.dispose();
      } finally {
        eventUtils.enable();
      }
    }
    this.lastMarker_ = this.createMarkerBlock_(lastOnStack.getSourceBlock());
  }
  return available;
};

/**
 * Whether the previews (insertion marker and replacement marker) should be
 * updated based on the closest candidate and the current drag distance.
 * @param {!Object} candidate An object containing a local connection, a closest
 *     connection, and a radius.  Returned by getCandidate_.
 * @param {!Coordinate} dxy Position relative to drag start,
 *     in workspace units.
 * @return {boolean} Whether the preview should be updated.
 * @private
 */
InsertionMarkerManager.prototype.shouldUpdatePreviews_ = function(
    candidate, dxy) {
  const candidateLocal = candidate.local;
  const candidateClosest = candidate.closest;
  const radius = candidate.radius;

  // Found a connection!
  if (candidateLocal && candidateClosest) {
    // We're already showing an insertion marker.
    // Decide whether the new connection has higher priority.
    if (this.localConnection_ && this.closestConnection_) {
      // The connection was the same as the current connection.
      if (this.closestConnection_ === candidateClosest &&
          this.localConnection_ === candidateLocal) {
        return false;
      }
      const xDiff = this.localConnection_.x + dxy.x - this.closestConnection_.x;
      const yDiff = this.localConnection_.y + dxy.y - this.closestConnection_.y;
      const curDistance = Math.sqrt(xDiff * xDiff + yDiff * yDiff);
      // Slightly prefer the existing preview over a new preview.
      return !(
          candidateClosest &&
          radius >
              curDistance - internalConstants.CURRENT_CONNECTION_PREFERENCE);
    } else if (!this.localConnection_ && !this.closestConnection_) {
      // We weren't showing a preview before, but we should now.
      return true;
    } else {
      console.error(
          'Only one of localConnection_ and closestConnection_ was set.');
    }
  } else {  // No connection found.
    // Only need to update if we were showing a preview before.
    return !!(this.localConnection_ && this.closestConnection_);
  }

  console.error(
      'Returning true from shouldUpdatePreviews, but it\'s not clear why.');
  return true;
};

/**
 * Find the nearest valid connection, which may be the same as the current
 * closest connection.
 * @param {!Coordinate} dxy Position relative to drag start,
 *     in workspace units.
 * @return {!Object} An object containing a local connection, a closest
 *     connection, and a radius.
 * @private
 */
InsertionMarkerManager.prototype.getCandidate_ = function(dxy) {
  let radius = this.getStartRadius_();
  let candidateClosest = null;
  let candidateLocal = null;

  for (let i = 0; i < this.availableConnections_.length; i++) {
    const myConnection = this.availableConnections_[i];
    const neighbour = myConnection.closest(radius, dxy);
    if (neighbour.connection) {
      candidateClosest = neighbour.connection;
      candidateLocal = myConnection;
      radius = neighbour.radius;
    }
  }
  return {closest: candidateClosest, local: candidateLocal, radius: radius};
};

/**
 * Decide the radius at which to start searching for the closest connection.
 * @return {number} The radius at which to start the search for the closest
 *     connection.
 * @private
 */
InsertionMarkerManager.prototype.getStartRadius_ = function() {
  // If there is already a connection highlighted,
  // increase the radius we check for making new connections.
  // Why? When a connection is highlighted, blocks move around when the
  // insertion marker is created, which could cause the connection became out of
  // range. By increasing radiusConnection when a connection already exists, we
  // never "lose" the connection from the offset.
  if (this.closestConnection_ && this.localConnection_) {
    return internalConstants.CONNECTING_SNAP_RADIUS;
  }
  return internalConstants.SNAP_RADIUS;
};

/**
 * Whether ending the drag would delete the block.
 * @param {!Object} candidate An object containing a local connection, a closest
 *    connection, and a radius.
 * @param {?IDragTarget} dragTarget The drag target that the block is
 *     currently over.
 * @return {boolean} Whether dropping the block immediately would delete the
 *    block.
 * @private
 */
InsertionMarkerManager.prototype.shouldDelete_ = function(
    candidate, dragTarget) {
  if (dragTarget) {
    const componentManager = this.workspace_.getComponentManager();
    const isDeleteArea = componentManager.hasCapability(
        dragTarget.id, ComponentManager.Capability.DELETE_AREA);
    if (isDeleteArea) {
      return (
                 /** @type {!IDeleteArea} */ (dragTarget))
          .wouldDelete(this.topBlock_, candidate && !!candidate.closest);
    }
  }
  return false;
};

/**
 * Show an insertion marker or replacement highlighting during a drag, if
 * needed.
 * At the beginning of this function, this.localConnection_ and
 * this.closestConnection_ should both be null.
 * @param {!Object} candidate An object containing a local connection, a closest
 *     connection, and a radius.
 * @private
 */
InsertionMarkerManager.prototype.maybeShowPreview_ = function(candidate) {
  // Nope, don't add a marker.
  if (this.wouldDeleteBlock_) {
    return;
  }
  const closest = candidate.closest;
  const local = candidate.local;

  // Nothing to connect to.
  if (!closest) {
    return;
  }

  // Something went wrong and we're trying to connect to an invalid connection.
  if (closest === this.closestConnection_ ||
      closest.getSourceBlock().isInsertionMarker()) {
    console.log('Trying to connect to an insertion marker');
    return;
  }
  // Add an insertion marker or replacement marker.
  this.closestConnection_ = closest;
  this.localConnection_ = local;
  this.showPreview_();
};

/**
 * A preview should be shown.  This function figures out if it should be a block
 * highlight or an insertion marker, and shows the appropriate one.
 * @private
 */
InsertionMarkerManager.prototype.showPreview_ = function() {
  const closest = this.closestConnection_;
  const renderer = this.workspace_.getRenderer();
  const method = renderer.getConnectionPreviewMethod(
      /** @type {!RenderedConnection} */ (closest),
      /** @type {!RenderedConnection} */ (this.localConnection_),
      this.topBlock_);

  switch (method) {
    case InsertionMarkerManager.PREVIEW_TYPE.INPUT_OUTLINE:
      this.showInsertionInputOutline_();
      break;
    case InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER:
      this.showInsertionMarker_();
      break;
    case InsertionMarkerManager.PREVIEW_TYPE.REPLACEMENT_FADE:
      this.showReplacementFade_();
      break;
  }

  // Optionally highlight the actual connection, as a nod to previous behaviour.
  if (closest && renderer.shouldHighlightConnection(closest)) {
    closest.highlight();
  }
};

/**
 * Show an insertion marker or replacement highlighting during a drag, if
 * needed.
 * At the end of this function, this.localConnection_ and
 * this.closestConnection_ should both be null.
 * @param {!Object} candidate An object containing a local connection, a closest
 *     connection, and a radius.
 * @private
 */
InsertionMarkerManager.prototype.maybeHidePreview_ = function(candidate) {
  // If there's no new preview, remove the old one but don't bother deleting it.
  // We might need it later, and this saves disposing of it and recreating it.
  if (!candidate.closest) {
    this.hidePreview_();
  } else {
    // If there's a new preview and there was an preview before, and either
    // connection has changed, remove the old preview.
    const hadPreview = this.closestConnection_ && this.localConnection_;
    const closestChanged = this.closestConnection_ !== candidate.closest;
    const localChanged = this.localConnection_ !== candidate.local;

    // Also hide if we had a preview before but now we're going to delete
    // instead.
    if (hadPreview &&
        (closestChanged || localChanged || this.wouldDeleteBlock_)) {
      this.hidePreview_();
    }
  }

  // Either way, clear out old state.
  this.markerConnection_ = null;
  this.closestConnection_ = null;
  this.localConnection_ = null;
};

/**
 * A preview should be hidden.  This function figures out if it is a block
 *  highlight or an insertion marker, and hides the appropriate one.
 * @private
 */
InsertionMarkerManager.prototype.hidePreview_ = function() {
  if (this.closestConnection_ && this.closestConnection_.targetBlock() &&
      this.workspace_.getRenderer().shouldHighlightConnection(
          this.closestConnection_)) {
    this.closestConnection_.unhighlight();
  }
  if (this.fadedBlock_) {
    this.hideReplacementFade_();
  } else if (this.highlightedBlock_) {
    this.hideInsertionInputOutline_();
  } else if (this.markerConnection_) {
    this.hideInsertionMarker_();
  }
};

/**
 * Shows an insertion marker connected to the appropriate blocks (based on
 * manager state).
 * @private
 */
InsertionMarkerManager.prototype.showInsertionMarker_ = function() {
  const local = this.localConnection_;
  const closest = this.closestConnection_;

  const isLastInStack = this.lastOnStack_ && local === this.lastOnStack_;
  const imBlock = isLastInStack ? this.lastMarker_ : this.firstMarker_;
  const imConn = imBlock.getMatchingConnection(local.getSourceBlock(), local);

  if (imConn === this.markerConnection_) {
    throw Error(
        'Made it to showInsertionMarker_ even though the marker isn\'t ' +
        'changing');
  }

  // Render disconnected from everything else so that we have a valid
  // connection location.
  imBlock.render();
  imBlock.rendered = true;
  imBlock.getSvgRoot().setAttribute('visibility', 'visible');

  if (imConn && closest) {
    // Position so that the existing block doesn't move.
    imBlock.positionNearConnection(imConn, closest);
  }
  if (closest) {
    // Connect() also renders the insertion marker.
    imConn.connect(closest);
  }

  this.markerConnection_ = imConn;
};

/**
 * Disconnects and hides the current insertion marker. Should return the blocks
 * to their original state.
 * @private
 */
InsertionMarkerManager.prototype.hideInsertionMarker_ = function() {
  if (!this.markerConnection_) {
    console.log('No insertion marker connection to disconnect');
    return;
  }

  const imConn = this.markerConnection_;
  const imBlock = imConn.getSourceBlock();
  const markerNext = imBlock.nextConnection;
  const markerPrev = imBlock.previousConnection;
  const markerOutput = imBlock.outputConnection;

  const isFirstInStatementStack =
      (imConn === markerNext && !(markerPrev && markerPrev.targetConnection));

  const isFirstInOutputStack = imConn.type === ConnectionType.INPUT_VALUE &&
      !(markerOutput && markerOutput.targetConnection);
  // The insertion marker is the first block in a stack.  Unplug won't do
  // anything in that case.  Instead, unplug the following block.
  if (isFirstInStatementStack || isFirstInOutputStack) {
    imConn.targetBlock().unplug(false);
  } else if (
      imConn.type === ConnectionType.NEXT_STATEMENT && imConn !== markerNext) {
    // Inside of a C-block, first statement connection.
    const innerConnection = imConn.targetConnection;
    innerConnection.getSourceBlock().unplug(false);

    const previousBlockNextConnection =
        markerPrev ? markerPrev.targetConnection : null;

    imBlock.unplug(true);
    if (previousBlockNextConnection) {
      previousBlockNextConnection.connect(innerConnection);
    }
  } else {
    imBlock.unplug(true /* healStack */);
  }

  if (imConn.targetConnection) {
    throw Error(
        'markerConnection_ still connected at the end of ' +
        'disconnectInsertionMarker');
  }

  this.markerConnection_ = null;
  const svg = imBlock.getSvgRoot();
  if (svg) {
    svg.setAttribute('visibility', 'hidden');
  }
};

/**
 * Shows an outline around the input the closest connection belongs to.
 * @private
 */
InsertionMarkerManager.prototype.showInsertionInputOutline_ = function() {
  const closest = this.closestConnection_;
  this.highlightedBlock_ = closest.getSourceBlock();
  this.highlightedBlock_.highlightShapeForInput(closest, true);
};

/**
 * Hides any visible input outlines.
 * @private
 */
InsertionMarkerManager.prototype.hideInsertionInputOutline_ = function() {
  this.highlightedBlock_.highlightShapeForInput(this.closestConnection_, false);
  this.highlightedBlock_ = null;
};

/**
 * Shows a replacement fade affect on the closest connection's target block
 * (the block that is currently connected to it).
 * @private
 */
InsertionMarkerManager.prototype.showReplacementFade_ = function() {
  this.fadedBlock_ = this.closestConnection_.targetBlock();
  this.fadedBlock_.fadeForReplacement(true);
};

/**
 * Hides/Removes any visible fade affects.
 * @private
 */
InsertionMarkerManager.prototype.hideReplacementFade_ = function() {
  this.fadedBlock_.fadeForReplacement(false);
  this.fadedBlock_ = null;
};

/**
 * Get a list of the insertion markers that currently exist.  Drags have 0, 1,
 * or 2 insertion markers.
 * @return {!Array<!BlockSvg>} A possibly empty list of insertion
 *     marker blocks.
 * @package
 */
InsertionMarkerManager.prototype.getInsertionMarkers = function() {
  const result = [];
  if (this.firstMarker_) {
    result.push(this.firstMarker_);
  }
  if (this.lastMarker_) {
    result.push(this.lastMarker_);
  }
  return result;
};

exports.InsertionMarkerManager = InsertionMarkerManager;
