import {
  assign,
  forEach
} from 'min-dash';

import inherits from 'inherits-browser';

import {
  remove as collectionRemove,
  add as collectionAdd
} from 'diagram-js/lib/util/Collections';

import {
  Label
} from 'diagram-js/lib/model';

import {
  getBusinessObject,
  getDi,
  is
} from '../../util/ModelUtil';

import {
  isAny
} from './util/ModelingUtil';

import {
  delta
} from 'diagram-js/lib/util/PositionUtil';

import CommandInterceptor from 'diagram-js/lib/command/CommandInterceptor';

/**
 * A handler responsible for updating the underlying BPMN 2.0 XML + DI
 * once changes on the diagram happen
 */
export default function BpmnUpdater(
    eventBus, bpmnFactory, connectionDocking,
    translate) {

  CommandInterceptor.call(this, eventBus);

  this._bpmnFactory = bpmnFactory;
  this._translate = translate;

  var self = this;



  // connection cropping //////////////////////

  // crop connection ends during create/update
  function cropConnection(e) {
    var context = e.context,
        hints = context.hints || {},
        connection;

    if (!context.cropped && hints.createElementsBehavior !== false) {
      connection = context.connection;
      connection.waypoints = connectionDocking.getCroppedWaypoints(connection);
      context.cropped = true;
    }
  }

  this.executed([
    'connection.layout',
    'connection.create'
  ], cropConnection);

  this.reverted([ 'connection.layout' ], function(e) {
    delete e.context.cropped;
  });



  // BPMN + DI update //////////////////////


  // update parent
  function updateParent(e) {
    var context = e.context;

    self.updateParent(context.shape || context.connection, context.oldParent);
  }

  function reverseUpdateParent(e) {
    var context = e.context;

    var element = context.shape || context.connection,

        // oldParent is the (old) new parent, because we are undoing
        oldParent = context.parent || context.newParent;

    self.updateParent(element, oldParent);
  }

  this.executed([
    'shape.move',
    'shape.create',
    'shape.delete',
    'connection.create',
    'connection.move',
    'connection.delete'
  ], ifBpmn(updateParent));

  this.reverted([
    'shape.move',
    'shape.create',
    'shape.delete',
    'connection.create',
    'connection.move',
    'connection.delete'
  ], ifBpmn(reverseUpdateParent));

  /*
   * ## Updating Parent
   *
   * When morphing a Process into a Collaboration or vice-versa,
   * make sure that both the *semantic* and *di* parent of each element
   * is updated.
   *
   */
  function updateRoot(event) {
    var context = event.context,
        oldRoot = context.oldRoot,
        children = oldRoot.children;

    forEach(children, function(child) {
      if (is(child, 'bpmn:BaseElement')) {
        self.updateParent(child);
      }
    });
  }

  this.executed([ 'canvas.updateRoot' ], updateRoot);
  this.reverted([ 'canvas.updateRoot' ], updateRoot);


  // update bounds
  function updateBounds(e) {
    var shape = e.context.shape;

    if (!is(shape, 'bpmn:BaseElement')) {
      return;
    }

    self.updateBounds(shape);
  }

  this.executed([ 'shape.move', 'shape.create', 'shape.resize' ], ifBpmn(function(event) {

    // exclude labels because they're handled separately during shape.changed
    if (event.context.shape.type === 'label') {
      return;
    }

    updateBounds(event);
  }));

  this.reverted([ 'shape.move', 'shape.create', 'shape.resize' ], ifBpmn(function(event) {

    // exclude labels because they're handled separately during shape.changed
    if (event.context.shape.type === 'label') {
      return;
    }

    updateBounds(event);
  }));

  // Handle labels separately. This is necessary, because the label bounds have to be updated
  // every time its shape changes, not only on move, create and resize.
  eventBus.on('shape.changed', function(event) {
    if (event.element.type === 'label') {
      updateBounds({ context: { shape: event.element } });
    }
  });

  // attach / detach connection
  function updateConnection(e) {
    self.updateConnection(e.context);
  }

  this.executed([
    'connection.create',
    'connection.move',
    'connection.delete',
    'connection.reconnect'
  ], ifBpmn(updateConnection));

  this.reverted([
    'connection.create',
    'connection.move',
    'connection.delete',
    'connection.reconnect'
  ], ifBpmn(updateConnection));


  // update waypoints
  function updateConnectionWaypoints(e) {
    self.updateConnectionWaypoints(e.context.connection);
  }

  this.executed([
    'connection.layout',
    'connection.move',
    'connection.updateWaypoints',
  ], ifBpmn(updateConnectionWaypoints));

  this.reverted([
    'connection.layout',
    'connection.move',
    'connection.updateWaypoints',
  ], ifBpmn(updateConnectionWaypoints));

  // update conditional/default flows
  this.executed('connection.reconnect', ifBpmn(function(event) {
    var context = event.context,
        connection = context.connection,
        oldSource = context.oldSource,
        newSource = context.newSource,
        connectionBo = getBusinessObject(connection),
        oldSourceBo = getBusinessObject(oldSource),
        newSourceBo = getBusinessObject(newSource);

    // remove condition from connection on reconnect to new source
    // if new source can NOT have condional sequence flow
    if (connectionBo.conditionExpression && !isAny(newSourceBo, [
      'bpmn:Activity',
      'bpmn:ExclusiveGateway',
      'bpmn:InclusiveGateway'
    ])) {
      context.oldConditionExpression = connectionBo.conditionExpression;

      delete connectionBo.conditionExpression;
    }

    // remove default from old source flow on reconnect to new source
    // if source changed
    if (oldSource !== newSource && oldSourceBo.default === connectionBo) {
      context.oldDefault = oldSourceBo.default;

      delete oldSourceBo.default;
    }
  }));

  this.reverted('connection.reconnect', ifBpmn(function(event) {
    var context = event.context,
        connection = context.connection,
        oldSource = context.oldSource,
        newSource = context.newSource,
        connectionBo = getBusinessObject(connection),
        oldSourceBo = getBusinessObject(oldSource),
        newSourceBo = getBusinessObject(newSource);

    // add condition to connection on revert reconnect to new source
    if (context.oldConditionExpression) {
      connectionBo.conditionExpression = context.oldConditionExpression;
    }

    // add default to old source on revert reconnect to new source
    if (context.oldDefault) {
      oldSourceBo.default = context.oldDefault;

      delete newSourceBo.default;
    }
  }));

  // update attachments
  function updateAttachment(e) {
    self.updateAttachment(e.context);
  }

  this.executed([ 'element.updateAttachment' ], ifBpmn(updateAttachment));
  this.reverted([ 'element.updateAttachment' ], ifBpmn(updateAttachment));
}

inherits(BpmnUpdater, CommandInterceptor);

BpmnUpdater.$inject = [
  'eventBus',
  'bpmnFactory',
  'connectionDocking',
  'translate'
];


// implementation //////////////////////

BpmnUpdater.prototype.updateAttachment = function(context) {

  var shape = context.shape,
      businessObject = shape.businessObject,
      host = shape.host;

  businessObject.attachedToRef = host && host.businessObject;
};

BpmnUpdater.prototype.updateParent = function(element, oldParent) {

  // do not update BPMN 2.0 label parent
  if (element instanceof Label) {
    return;
  }

  // data stores in collaborations are handled separately by DataStoreBehavior
  if (is(element, 'bpmn:DataStoreReference') &&
      element.parent &&
      is(element.parent, 'bpmn:Collaboration')) {
    return;
  }

  var parentShape = element.parent;

  var businessObject = element.businessObject,
      di = getDi(element),
      parentBusinessObject = parentShape && parentShape.businessObject,
      parentDi = getDi(parentShape);

  if (is(element, 'bpmn:FlowNode')) {
    this.updateFlowNodeRefs(businessObject, parentBusinessObject, oldParent && oldParent.businessObject);
  }

  if (is(element, 'bpmn:DataOutputAssociation')) {
    if (element.source) {
      parentBusinessObject = element.source.businessObject;
    } else {
      parentBusinessObject = null;
    }
  }

  if (is(element, 'bpmn:DataInputAssociation')) {
    if (element.target) {
      parentBusinessObject = element.target.businessObject;
    } else {
      parentBusinessObject = null;
    }
  }

  this.updateSemanticParent(businessObject, parentBusinessObject);

  if (is(element, 'bpmn:DataObjectReference') && businessObject.dataObjectRef) {
    this.updateSemanticParent(businessObject.dataObjectRef, parentBusinessObject);
  }

  this.updateDiParent(di, parentDi);
};


BpmnUpdater.prototype.updateBounds = function(shape) {

  var di = getDi(shape),
      embeddedLabelBounds = getEmbeddedLabelBounds(shape);

  // update embedded label bounds if possible
  if (embeddedLabelBounds) {
    var embeddedLabelBoundsDelta = delta(embeddedLabelBounds, di.get('bounds'));

    assign(embeddedLabelBounds, {
      x: shape.x + embeddedLabelBoundsDelta.x,
      y: shape.y + embeddedLabelBoundsDelta.y
    });
  }

  var target = (shape instanceof Label) ? this._getLabel(di) : di;

  var bounds = target.bounds;

  if (!bounds) {
    bounds = this._bpmnFactory.createDiBounds();
    target.set('bounds', bounds);
  }

  assign(bounds, {
    x: shape.x,
    y: shape.y,
    width: shape.width,
    height: shape.height
  });
};

BpmnUpdater.prototype.updateFlowNodeRefs = function(businessObject, newContainment, oldContainment) {

  if (oldContainment === newContainment) {
    return;
  }

  var oldRefs, newRefs;

  if (is (oldContainment, 'bpmn:Lane')) {
    oldRefs = oldContainment.get('flowNodeRef');
    collectionRemove(oldRefs, businessObject);
  }

  if (is(newContainment, 'bpmn:Lane')) {
    newRefs = newContainment.get('flowNodeRef');
    collectionAdd(newRefs, businessObject);
  }
};


// update existing sourceElement and targetElement di information
BpmnUpdater.prototype.updateDiConnection = function(connection, newSource, newTarget) {
  var connectionDi = getDi(connection),
      newSourceDi = getDi(newSource),
      newTargetDi = getDi(newTarget);

  if (connectionDi.sourceElement && connectionDi.sourceElement.bpmnElement !== getBusinessObject(newSource)) {
    connectionDi.sourceElement = newSource && newSourceDi;
  }

  if (connectionDi.targetElement && connectionDi.targetElement.bpmnElement !== getBusinessObject(newTarget)) {
    connectionDi.targetElement = newTarget && newTargetDi;
  }

};


BpmnUpdater.prototype.updateDiParent = function(di, parentDi) {

  if (parentDi && !is(parentDi, 'bpmndi:BPMNPlane')) {
    parentDi = parentDi.$parent;
  }

  if (di.$parent === parentDi) {
    return;
  }

  var planeElements = (parentDi || di.$parent).get('planeElement');

  if (parentDi) {
    planeElements.push(di);
    di.$parent = parentDi;
  } else {
    collectionRemove(planeElements, di);
    di.$parent = null;
  }
};

function getDefinitions(element) {
  while (element && !is(element, 'bpmn:Definitions')) {
    element = element.$parent;
  }

  return element;
}

BpmnUpdater.prototype.getLaneSet = function(container) {

  var laneSet, laneSets;

  // bpmn:Lane
  if (is(container, 'bpmn:Lane')) {
    laneSet = container.childLaneSet;

    if (!laneSet) {
      laneSet = this._bpmnFactory.create('bpmn:LaneSet');
      container.childLaneSet = laneSet;
      laneSet.$parent = container;
    }

    return laneSet;
  }

  // bpmn:Participant
  if (is(container, 'bpmn:Participant')) {
    container = container.processRef;
  }

  // bpmn:FlowElementsContainer
  laneSets = container.get('laneSets');
  laneSet = laneSets[0];

  if (!laneSet) {
    laneSet = this._bpmnFactory.create('bpmn:LaneSet');
    laneSet.$parent = container;
    laneSets.push(laneSet);
  }

  return laneSet;
};

BpmnUpdater.prototype.updateSemanticParent = function(businessObject, newParent, visualParent) {

  var containment,
      translate = this._translate;

  if (businessObject.$parent === newParent) {
    return;
  }

  if (is(businessObject, 'bpmn:DataInput') || is(businessObject, 'bpmn:DataOutput')) {

    if (is(newParent, 'bpmn:Participant') && 'processRef' in newParent) {
      newParent = newParent.processRef;
    }

    // already in correct ioSpecification
    if ('ioSpecification' in newParent && newParent.ioSpecification === businessObject.$parent) {
      return;
    }
  }

  if (is(businessObject, 'bpmn:Lane')) {

    if (newParent) {
      newParent = this.getLaneSet(newParent);
    }

    containment = 'lanes';
  } else

  if (is(businessObject, 'bpmn:FlowElement')) {

    if (newParent) {

      if (is(newParent, 'bpmn:Participant')) {
        newParent = newParent.processRef;
      } else

      if (is(newParent, 'bpmn:Lane')) {
        do {

          // unwrap Lane -> LaneSet -> (Lane | FlowElementsContainer)
          newParent = newParent.$parent.$parent;
        } while (is(newParent, 'bpmn:Lane'));

      }
    }

    containment = 'flowElements';

  } else

  if (is(businessObject, 'bpmn:Artifact')) {

    while (newParent &&
           !is(newParent, 'bpmn:Process') &&
           !is(newParent, 'bpmn:SubProcess') &&
           !is(newParent, 'bpmn:Collaboration')) {

      if (is(newParent, 'bpmn:Participant')) {
        newParent = newParent.processRef;
        break;
      } else {
        newParent = newParent.$parent;
      }
    }

    containment = 'artifacts';
  } else

  if (is(businessObject, 'bpmn:MessageFlow')) {
    containment = 'messageFlows';

  } else

  if (is(businessObject, 'bpmn:Participant')) {
    containment = 'participants';

    // make sure the participants process is properly attached / detached
    // from the XML document

    var process = businessObject.processRef,
        definitions;

    if (process) {
      definitions = getDefinitions(businessObject.$parent || newParent);

      if (businessObject.$parent) {
        collectionRemove(definitions.get('rootElements'), process);
        process.$parent = null;
      }

      if (newParent) {
        collectionAdd(definitions.get('rootElements'), process);
        process.$parent = definitions;
      }
    }
  } else

  if (is(businessObject, 'bpmn:DataOutputAssociation')) {
    containment = 'dataOutputAssociations';
  } else

  if (is(businessObject, 'bpmn:DataInputAssociation')) {
    containment = 'dataInputAssociations';
  }

  if (!containment) {
    throw new Error(translate(
      'no parent for {element} in {parent}',
      {
        element: businessObject.id,
        parent: newParent.id
      }
    ));
  }

  var children;

  if (businessObject.$parent) {

    // remove from old parent
    children = businessObject.$parent.get(containment);
    collectionRemove(children, businessObject);
  }

  if (!newParent) {
    businessObject.$parent = null;
  } else {

    // add to new parent
    children = newParent.get(containment);
    children.push(businessObject);
    businessObject.$parent = newParent;
  }

  if (visualParent) {
    var diChildren = visualParent.get(containment);

    collectionRemove(children, businessObject);

    if (newParent) {

      if (!diChildren) {
        diChildren = [];
        newParent.set(containment, diChildren);
      }

      diChildren.push(businessObject);
    }
  }
};


BpmnUpdater.prototype.updateConnectionWaypoints = function(connection) {
  var di = getDi(connection);

  di.set('waypoint', this._bpmnFactory.createDiWaypoints(connection.waypoints));
};


BpmnUpdater.prototype.updateConnection = function(context) {
  var connection = context.connection,
      businessObject = getBusinessObject(connection),
      newSource = connection.source,
      newSourceBo = getBusinessObject(newSource),
      newTarget = connection.target,
      newTargetBo = getBusinessObject(connection.target),
      visualParent;

  if (!is(businessObject, 'bpmn:DataAssociation')) {

    var inverseSet = is(businessObject, 'bpmn:SequenceFlow');

    if (businessObject.sourceRef !== newSourceBo) {
      if (inverseSet) {
        collectionRemove(businessObject.sourceRef && businessObject.sourceRef.get('outgoing'), businessObject);

        if (newSourceBo && newSourceBo.get('outgoing')) {
          newSourceBo.get('outgoing').push(businessObject);
        }
      }

      businessObject.sourceRef = newSourceBo;
    }

    if (businessObject.targetRef !== newTargetBo) {
      if (inverseSet) {
        collectionRemove(businessObject.targetRef && businessObject.targetRef.get('incoming'), businessObject);

        if (newTargetBo && newTargetBo.get('incoming')) {
          newTargetBo.get('incoming').push(businessObject);
        }
      }

      businessObject.targetRef = newTargetBo;
    }
  } else

  if (is(businessObject, 'bpmn:DataInputAssociation')) {

    // handle obnoxious isMsome sourceRef
    businessObject.get('sourceRef')[0] = newSourceBo;

    visualParent = context.parent || context.newParent || newTargetBo;

    this.updateSemanticParent(businessObject, newTargetBo, visualParent);
  } else

  if (is(businessObject, 'bpmn:DataOutputAssociation')) {
    visualParent = context.parent || context.newParent || newSourceBo;

    this.updateSemanticParent(businessObject, newSourceBo, visualParent);

    // targetRef = new target
    businessObject.targetRef = newTargetBo;
  }

  this.updateConnectionWaypoints(connection);

  this.updateDiConnection(connection, newSource, newTarget);
};


// helpers //////////////////////

BpmnUpdater.prototype._getLabel = function(di) {
  if (!di.label) {
    di.label = this._bpmnFactory.createDiLabel();
  }

  return di.label;
};


/**
 * Make sure the event listener is only called
 * if the touched element is a BPMN element.
 *
 * @param  {Function} fn
 * @return {Function} guarded function
 */
function ifBpmn(fn) {

  return function(event) {

    var context = event.context,
        element = context.shape || context.connection;

    if (is(element, 'bpmn:BaseElement')) {
      fn(event);
    }
  };
}

/**
 * Return dc:Bounds of bpmndi:BPMNLabel if exists.
 *
 * @param {djs.model.shape} shape
 *
 * @returns {Object|undefined}
 */
function getEmbeddedLabelBounds(shape) {
  if (!is(shape, 'bpmn:Activity')) {
    return;
  }

  var di = getDi(shape);

  if (!di) {
    return;
  }

  var label = di.get('label');

  if (!label) {
    return;
  }

  return label.get('bounds');
}