<commit_before>app = angular.module('temba.services', [])

version = new Date().getTime()

quietPeriod = 500
errorRetries = 10

app.service "utils", ->

  isWindow = (obj) ->
    obj and obj.document and obj.location and obj.alert and obj.setInterval

  isScope = (obj) ->
    obj and obj.$evalAsync and obj.$watch

  # our json replacer strips out variables with leading underscores
  toJsonReplacer = (key, value) ->
    val = value
    if typeof key is "string" and (key.charAt(0) is "$" or key.charAt(0) is "_")
      val = undefined
    else if isWindow(value)
      val = "$WINDOW"
    else if value and document is value
      val = "$DOCUMENT"
    else if isScope(value)
      val = "$SCOPE"

    return val

  toJson: (obj, pretty) ->
    if typeof obj == 'undefined'
      return undefined
    return JSON.stringify(obj, toJsonReplacer, pretty ? '  ' : null);

  clone: (obj) ->
    if not obj? or typeof obj isnt 'object'
      return obj

    if obj instanceof Date
      return new Date(obj.getTime())

    if obj instanceof RegExp
      flags = ''
      flags += 'g' if obj.global?
      flags += 'i' if obj.ignoreCase?
      flags += 'm' if obj.multiline?
      flags += 'y' if obj.sticky?
      return new RegExp(obj.source, flags)

    newInstance = new obj.constructor()

    for key of obj
      newInstance[key] = this.clone obj[key]

    return newInstance

  checkCollisions: (ele) ->
    nodes = ele.parent().children('.node')
    collision = false
    for node in nodes
      if node != ele[0]
        if this.collides($(node), ele)
          collision = true
          break

    if collision
      ele.addClass("collision")
    else
      ele.removeClass("collision")

  # does one element collide with another element
  collides: (a, b) ->
    aOffset = a.offset()
    bOffset = b.offset()

    aBox =
      left: aOffset.left
      top: aOffset.top
      bottom: a.outerHeight() + aOffset.top
      right: a.outerWidth() + aOffset.left

    bBox =
      left: bOffset.left
      top: bOffset.top
      bottom: b.outerHeight() + bOffset.top
      right: b.outerWidth() + bOffset.left

    if aBox.bottom < bBox.top
      return false
    if aBox.top > bBox.bottom
      return false
    if aBox.left > bBox.right
      return false
    if aBox.right < bBox.left
      return false
    return true

#============================================================================
# DragHelper is all kinds of bad. This facilitates the little helper cues
# for the user so they learn the mechanics of building a flow. We should
# find a more angular way to do this, but at present there's all kinds of
# DOM inspection and manipulation when using this guy.
#============================================================================
app.service 'DragHelper', ['$rootScope', '$timeout', '$log', ($rootScope, $timeout, $log) ->

  show: (source, message) ->
    sourceOffset = source.offset()

    helper = $('#drag-helper')
    helpText = helper.find('.help-text')

    helper.css('opacity', 0)
    helpText.css('opacity', 0).css('left', -10)
    helper.show()

    if message
      helper.find('.help-text').html(message)

    helper.offset({left:sourceOffset.left - 8, top: sourceOffset.top - 20})
    helper.animate {top: sourceOffset.top + 14, opacity: 1}, complete: ->
      helper.find('.help-text').animate {left: 30, opacity: 1}, duration: 200, complete: ->
        if $rootScope.dragHelperId
          $timeout.cancel($rootScope.dragHelperId)
          $rootScope.dragHelperId = undefined
        $rootScope.dragHelperId = $timeout ->
          helper.fadeOut()
        ,20000

  showSaveResponse: (source) ->
    @show(source, 'To save responses to this message <span class="attn">drag</span> the red box')

  showSendReply: (source) ->
    @show(source, 'To send back a reply <span class="attn">drag</span> the red box')

  hide: ->
    $('#drag-helper').fadeOut()
    if $rootScope.dragHelperId
      $timeout.cancel($rootScope.dragHelperId)
      $rootScope.dragHelperId = undefined

]

#============================================================================
# Plumb service for mananging all the JSPlumb chicanery
#============================================================================
app.service "Plumb", ["$timeout", "$rootScope", "$log", ($timeout, $rootScope, $log) ->

  # Don't worry about drawing until after we've done our initial load
  jsPlumb.setSuspendDrawing(true)
  $('#flow').css('visibility', 'hidden')
  $timeout ->
    $('#flow').css('visibility', 'visible')
    jsPlumb.setSuspendDrawing(false)
    jsPlumb.repaintEverything()
  ,500

  jsPlumb.importDefaults
    DragOptions : { cursor: 'pointer', zIndex:2000 }
    DropOptions : { tolerance:"touch", hoverClass:"drop-hover" }
    Endpoint: "Blank"
    EndpointStyle: { strokeStyle: "transparent" }
    PaintStyle: { lineWidth:5, strokeStyle:"#98C0D9" }
    HoverPaintStyle: { strokeStyle: "#27ae60"}
    HoverClass: "connector-hover"
    ConnectionsDetachable: window.mutable
    Connector:
      [ "Flowchart",
          stub: 12
          midpoint: .85
          alwaysRespectStubs: false
          gap:[0,7]
          cornerRadius: 2
      ]

    ConnectionOverlays : [
      ["PlainArrow", { location:.9999, width: 12, length:12, foldback: 1 }],
    ]

    Container: "flow"

  targetDefaults =
    anchor: [ "Continuous", { faces:["top", "left", "right"] }]
    endpoint: [ "Rectangle", { width: 20, height: 20, hoverClass: 'endpoint-hover' }]
    hoverClass: 'target-hover'
    dropOptions: { tolerance:"touch", hoverClass:"drop-hover" }
    dragAllowedWhenFull: false
    deleteEndpointsOnDetach: true
    isTarget:true

  sourceDefaults =
    anchor: "BottomCenter"
    deleteEndpointsOnDetach: true
    maxConnections:1
    dragAllowedWhenFull:false
    isSource:true
    paintStyle:{ fillStyle:"blue", outlineColor:"black", outlineWidth:1 }

  makeSource: (sourceId, scope) ->
    # we do this in the next cycle to make sure our id is set
    $timeout ->
      jsPlumb.makeSource sourceId, angular.extend({scope:scope}, sourceDefaults)
    ,0

  makeTarget: (targetId, scope) ->
    $timeout ->
      jsPlumb.makeTarget targetId, angular.extend({scope:scope}, targetDefaults)
    ,0

  getSourceConnection: (source) ->
    connections = jsPlumb.getConnections({
      source: source.attr('id'),
      scope: '*'
    });

    if connections and connections.length > 0
      return connections[0]

  detachSingleConnection: (connection) ->
    jsPlumb.detach(connection)

  recalculateOffsets: (nodeId) ->

    # update ourselves
    $timeout ->

      # this reassesses our offsets
      jsPlumb.revalidate(nodeId)

      # this updates the offsets for our child elements
      jsPlumb.recalculateOffsets(nodeId)

      # finally repaint our new hotness
      jsPlumb.repaint(nodeId)
    ,0

  removeElement: (id) ->
    jsPlumb.remove(id)

  disconnectAllConnections: (id) ->

    # reenable any sources connecting to us
    jsPlumb.select({target:id}).each (connection) ->
      jsPlumb.setSourceEnabled(connection.sourceId, true)

    # now disconnect the existing connections
    jsPlumb.detachAllConnections(id)

    $('#' + id + ' .source').each ->
      id = $(this).attr('id')
      jsPlumb.detachAllConnections(id)

  disconnectOutboundConnections: (id) ->
    jsPlumb.detachAllConnections(id)
    if jsPlumb.isSource(id)
      jsPlumb.setSourceEnabled(id, true)

  setSourceEnabled: (source, enabled) ->
    jsPlumb.setSourceEnabled(source, enabled)


  connect: (sourceId, targetId, scope, fireEvent = true) ->

    #$log.debug(sourceId + ' > ' + targetId)

    sourceId += '_source'

    # remove any existing connections for our source first
    Plumb = @
    Plumb.disconnectOutboundConnections(sourceId)

    $('html').scope().plumb = Plumb

    # connect to our new target if we have one
    if targetId?
      existing = jsPlumb.getEndpoints(targetId)
      targetPoint = null
      if existing
        for endpoint in existing
          if endpoint.connections.length == 0
            targetPoint = existing[0]
            break

      if not targetPoint
        targetPoint = jsPlumb.addEndpoint(targetId, { scope: scope }, targetDefaults)

      if jsPlumb.getConnections({source:sourceId, scope:scope}).length == 0

        if jsPlumb.isSource(sourceId)
          Plumb.setSourceEnabled(sourceId, true)

        jsPlumb.connect
          maxConnections:1
          dragAllowedWhenFull:false
          deleteEndpointsOnDetach:true
          editable:false
          source: sourceId
          target: targetPoint
          fireEvent: fireEvent

        $timeout ->
          Plumb.setSourceEnabled(sourceId, false)
          Plumb.repaint(sourceId)
        ,0

  # Update the connections according to the destination. Peforms update
  # after $digest to make sure DOM element is ready for jsPlumb.
  updateConnection: (actionset) ->
    Plumb = @
    $timeout ->
      Plumb.disconnectOutboundConnections(actionset.uuid + '_source')
      if actionset.destination
        Plumb.connect(actionset.uuid, actionset.destination, 'rules')
      Plumb.recalculateOffsets(actionset.uuid)
    ,0

  # Update the connections according to the category targets. Performs update
  # after $digest to make sure DOM elements are ready for jsPlumb.
  updateConnections: (ruleset) ->
    Plumb = @
    $timeout ->
      for category in ruleset._categories
        Plumb.connect(ruleset.uuid + '_' + category.source, category.target, 'actions')
      Plumb.recalculateOffsets(ruleset.uuid)
    ,0

  setPageHeight: ->
    $("#flow").each ->
      pageHeight = 0
      $this = $(this)
      $.each $this.children(), ->
        child = $(this)
        bottom = child.offset().top + child.height()
        if bottom > pageHeight
          pageHeight = bottom + 500
      $this.height(pageHeight)

  repaint: (element=null) ->
    if not window.loaded
      return

    service = @

    $timeout ->

      if element
        jsPlumb.repaint(element)
      else
        jsPlumb.repaintEverything()

      service.setPageHeight()
    , 0

  disconnectRules: (rules) ->
    for rule in rules
      jsPlumb.remove(rule.uuid + '_source')

  getConnectionMap: (selector = {}) ->

    # get the current connections as a map
    connections = {}
    jsPlumb.select(selector).each (connection) ->
      # only count legitimate targets
      if connection.targetId and connection.targetId.length > 24
        # strip off _source suffix
        source = connection.sourceId.substr(0, connection.sourceId.lastIndexOf('_'))
        connections[source] = connection.targetId

    return connections
]

app.factory "Versions", ['$http', '$log', ($http, $log) ->
  new class Versions
    updateVersions: (flowId) ->
      _this = @
      $http.get('/flow/versions/' + flowId + '/').success (data, status, headers) ->
        # only set the versions if we get back json, if we don't have permission we'll get a login page
        if headers('content-type') == 'application/json'
          _this.versions = data

    getVersion: (version) ->
      _this = @
      return $http.get('/flow/versions/' + flowId + '/?definition=' + version.id).success (data, status, headers) ->
        # only set the versions if we get back json, if we don't have permission we'll get a login page
        if headers('content-type') == 'application/json'
          _this.definition = data

]

app.factory 'Flow', ['$rootScope', '$window', '$http', '$timeout', '$interval', '$log', '$modal', 'utils', 'Plumb', 'Versions', 'DragHelper', ($rootScope, $window, $http, $timeout, $interval, $log, $modal, utils, Plumb, Versions, DragHelper) ->

  new class Flow

    TEXT = 'F'
    VOICE = 'V'
    SURVEY = 'S'

    ALL = [TEXT,VOICE,SURVEY]

    constructor: ->

      @actions = [
        { type:'say', name:'Play Message', verbose_name:'Play a message', icon: 'icon-bubble-3', message: true, filter:[VOICE] }
        { type:'play', name:'Play Recording', verbose_name:'Play a contact recording', icon: 'icon-mic', filter:[VOICE]}
        { type:'reply', name:'Send Message', verbose_name:'Send an SMS response', icon: 'icon-bubble-3', message:true, filter:ALL }
        { type:'send', name:'Send Message', verbose_name: 'Send an SMS to somebody else', icon: 'icon-bubble-3', message:true, filter:[TEXT,VOICE] }
        { type:'add_label', name:'Add Label', verbose_name: 'Add a label to a Message', icon: 'icon-tag', filter:ALL }
        { type:'save', name:'Update Contact', verbose_name:'Update the contact', icon: 'icon-user', filter:ALL }
        { type:'add_group', name:'Add to Groups', verbose_name:'Add contact to a group', icon: 'icon-users-2', groups:true, filter:ALL }
        { type:'del_group', name:'Remove from Groups', verbose_name:'Remove contact from a group', icon: 'icon-users-2', groups:true, filter:ALL }
        { type:'api', name:'Webhook', verbose_name:'Make a call to an external server', icon: 'icon-cloud-upload', filter:[TEXT,VOICE] }
        { type:'email', name:'Send Email', verbose_name: 'Send an email', icon: 'icon-bubble-3', filter:[TEXT,VOICE] }
        { type:'lang', name:'Set Language', verbose_name:'Set language for contact', icon: 'icon-language', filter:ALL }
        { type:'flow', name:'Start Another Flow', verbose_name:'Start another flow', icon: 'icon-tree', flows:true, filter:[TEXT,VOICE] }
        { type:'trigger-flow',   name:'Start Someone in a Flow', verbose_name:'Start someone else in a flow', icon: 'icon-tree', flows:true, filter:[TEXT,VOICE] }
      ]

      @rulesets = [

        { type: 'wait_message', name:'Wait for Response', verbose_name: 'Wait for response', split:'message response', filter:[TEXT,SURVEY] },

        # voice flows only
        { type: 'wait_recording', name:'Get Recording', verbose_name: 'Wait for recording', filter:VOICE },
        { type: 'wait_digit', name:'Get Menu Selection', verbose_name: 'Wait for menu selection', filter:VOICE },
        { type: 'wait_digits', name:'Get Digits', verbose_name: 'Wait for multiple digits', split:'digits', filter:VOICE },

        # online flows
        { type: 'webhook', name:'Call Webhook', verbose_name: 'Call webhook', split:'webhook response', filter:[TEXT,VOICE] },

        # all flows
        { type: 'flow_field', name:'Split by Flow Field', verbose_name: 'Split by flow field', filter:ALL },
        { type: 'contact_field', name: 'Split by Contact Field', verbose_name: 'Split by contact field', filter:ALL },
        { type: 'expression', name:'Split by Expression', verbose_name: 'Split by expression', filter:ALL },
        { type: 'form_field', name:'Split by Message Form', verbose_name: 'Split by message form', filter:ALL },

        # Not supported yet
        # { type: 'group', verbose_name: 'Split by group membership', ivr:true, text:true},
        # { type: 'random', verbose_name: 'Split randomly', ivr:true, text:true},
        # { type: 'pause', verbose_name: 'Pause the flow', ivr:true, text:true},
      ]

      @supportsRules = ['wait_message', 'expression', 'flow_field', 'contact_field', 'wait_digits', 'form_field']

      @operators = [
        { type:'contains_any', name:'Contains any', verbose_name:'has any of these words', operands: 1, localized:true }
        { type:'contains', name: 'Contains all', verbose_name:'has all of the words', operands: 1, localized:true }
        { type:'not_empty', name: 'Not empty', verbose_name:'is not empty', operands: 0, localized:true }
        { type:'starts', name: 'Starts with', verbose_name:'starts with', operands: 1, voice:true, localized:true }
        { type:'number', name: 'Has a number', verbose_name:'has a number', operands: 0, voice:true }
        { type:'lt', name: 'Less than', verbose_name:'has a number less than', operands: 1, voice:true }
        { type:'eq', name: 'Equal to', verbose_name:'has a number equal to', operands: 1, voice:true }
        { type:'gt', name: 'More than', verbose_name:'has a number more than', operands: 1, voice:true }
        { type:'between', name: 'Number between', verbose_name:'has a number between', operands: 2, voice:true }
        { type:'date', name: 'Has date', verbose_name:'has a date', operands: 0, validate:'date' }
        { type:'date_before', name: 'Date before', verbose_name:'has a date before', operands: 1, validate:'date' }
        { type:'date_equal', name: 'Date equal to', verbose_name:'has a date equal to', operands: 1, validate:'date' }
        { type:'date_after', name: 'Date after', verbose_name:'has a date after', operands: 1, validate:'date' }
        { type:'phone', name: 'Has a phone', verbose_name:'has a phone number', operands: 0, voice:true }
        { type:'state', name: 'Has a state', verbose_name:'has a state', operands: 0 }
        { type:'district', name: 'Has a district', verbose_name:'has a district', operands: 1, auto_complete: true, placeholder:'@flow.state' }
        { type:'regex', name: 'Regex', verbose_name:'matches regex', operands: 1, voice:true, localized:true }
        { type:'true', name: 'Other', verbose_name:'contains anything', operands: 0 }
      ]

      @opNames =
        'lt': '< '
        'gt': '> '
        'eq': ''
        'between': ''
        'number': ''
        'starts': ''
        'contains': ''
        'contains_any': ''
        'date': ''
        'date_before': ''
        'date_equal': ''
        'date_after': ''
        'regex': ''

    $rootScope.errorDelay = quietPeriod

    determineFlowStart: ->
      topNode = null
      # see if this node is higher than our last one
      checkTop = (node) ->
        if topNode == null || node.y < topNode.y
          topNode = node
        else if topNode == null || topNode.y == node.y
          if node.x < topNode.x
            topNode = node

      # check each node to see if they are the top
      for actionset in @flow.action_sets
        checkTop(actionset)
      for ruleset in @flow.rule_sets
        checkTop(ruleset)

      if topNode
        @flow.entry = topNode.uuid

    Flow = @
    $rootScope.$watch (->Flow.dirty), (current, prev) ->

      # if we just became dirty, trigger a save
      if current

        if not window.mutable
          $rootScope.error = "Your changes cannot be saved. You don't have permission to edit this flow."
          return

        Flow.dirty = false

        # make sure we know our start point
        Flow.determineFlowStart()

        # schedule the save for a bit later in case more dirty events come in quick succession
        if $rootScope.saving
          cancelled = $timeout.cancel($rootScope.saving)

          # If we fail to cancel the current save we need to wait until the previous save completes and try again
          if not cancelled
            $timeout ->
              Flow.dirty = true
            , quietPeriod
            return

        $rootScope.saving = $timeout ->

          $rootScope.error = null

          $log.debug("Saving.")

          $http.post('/flow/json/' + Flow.flowId + '/', utils.toJson(Flow.flow)).error (data, statusCode) ->

            if statusCode == 400
              $rootScope.saving = false
              if UserVoice
                UserVoice.push(['set', 'ticket_custom_fields', {'Error': data.description}]);

              modalInstance = $modal.open
                templateUrl: "/partials/modal?v=" + version
                controller: ModalController
                resolve:
                  type: -> "error"
                  title: -> "Error Saving"
                  body: -> "Sorry, but we were unable to save your flow. Please reload the page and try again, this may clear your latest changes."
                  ok: -> 'Reload'

              modalInstance.result.then (reload) ->
                if reload
                  document.location.reload()
              return

            # we failed, could just be futzy internet, lets retry with backdown
            $rootScope.errorDelay += quietPeriod

            if $rootScope.errorDelay < (quietPeriod * (errorRetries + 1))
              $log.debug("Couldn't save changes, trying again in " + $rootScope.errorDelay)
              $timeout ->
                $rootScope.saving = false
                Flow.dirty = true
              , $rootScope.errorDelay
            else
              $rootScope.saving = false
              $rootScope.error = "Your changes may not be saved. Please check your network connection."
              $rootScope.errorDelay = quietPeriod

          .success (data, statusCode) ->
            $rootScope.error = null
            $rootScope.errorDelay = quietPeriod
            if data.status == 'unsaved'
              modalInstance = $modal.open
                templateUrl: "/partials/modal?v=" + version
                controller: ModalController
                resolve:
                  type: -> "error"
                  title: -> "Editing Conflict"
                  body: -> data.saved_by + " is currently editing this Flow. Your changes will not be saved until the Flow is reloaded."
                  ok: -> 'Reload'

              modalInstance.result.then (reload) ->
                if reload
                  document.location.reload()

            else

              # store our latest revision
              Flow.flow.metadata.revision = data.revision
              Flow.flow.metadata.saved_on = data.saved_on

              # update our auto completion options
              $http.get('/flow/completion/?flow=' + Flow.flowId).success (data) ->
                Flow.completions = data

              Versions.updateVersions(Flow.flowId)

            $rootScope.saving = null

        , quietPeriod


    getNode: (uuid) ->
      for actionset in @flow.action_sets
        if actionset.uuid == uuid
          return actionset

      for ruleset in @flow.rule_sets
        if ruleset.uuid == uuid
          return ruleset

    isPausingRuleset: (node) ->
      if not node?.actions
        return node.ruleset_type in ['wait_message', 'wait_recording', 'wait_digit', 'wait_digits']
      return false

    # check if a potential connection would result in an invalid loop
    detectLoop: (nodeId, targetId, path=[]) ->

      # can't go back on ourselves
      if nodeId == targetId
        throw new Error('Loop detected: ' + nodeId)

      # break out if our target is a pausing ruleset
      node = @getNode(targetId)
      if node and @isPausingRuleset(node)
        return false

      # check if we just ate our tail
      if targetId in path
        throw new Error('Loop detected: ' + path + ',' + targetId)

      # add ourselves
      path = path.slice()
      path.push(targetId)

      # if we have rules, check each one
      if node?.rules
        for rule in node.rules
          if rule.destination
            @detectLoop(node.uuid, rule.destination, path)
      else
        if node?.destination
          @detectLoop(node.uuid, node.destination, path)

    isConnectionAllowed: (sourceId, targetId) ->

      source = sourceId.split('_')[0]
      path = [ source ]

      sourceNode = @getNode(source)
      targetNode = @getNode(targetId)

      if @isPausingRuleset(sourceNode) and @isPausingRuleset(targetNode)
        return false

      try
        @detectLoop(source, targetId, path)
      catch e
        $log.debug(e.message)
        return false
      return true

    # translates a string into a slug
    slugify: (label) ->
      label = label.toString().toLowerCase().replace(/([^a-z0-9]+)/, ' ')
      label = label.replace(/([^a-z0-9]+)/g, '_')
      return label

    # Get an array of current flow fields as:
    # [ { id: 'label_name', name: 'Label Name' } ]
    getFlowFields: (excludeRuleset) ->

      # find our unique set of keys
      flowFields = {}
      if @flow
        for ruleset in @flow.rule_sets
          if ruleset.uuid != excludeRuleset?.uuid
            flowFields[@slugify(ruleset.label)] = ruleset.label

      # as an array
      result = []
      for id, name of flowFields
        result.push({ id: id, text: name})

      return result

    # Takes an operand (@flow.split_on_name) and returns the<commit_msg>remove extra line<commit_after>app = angular.module('temba.services', [])

version = new Date().getTime()

quietPeriod = 500
errorRetries = 10

app.service "utils", ->

  isWindow = (obj) ->
    obj and obj.document and obj.location and obj.alert and obj.setInterval

  isScope = (obj) ->
    obj and obj.$evalAsync and obj.$watch

  # our json replacer strips out variables with leading underscores
  toJsonReplacer = (key, value) ->
    val = value
    if typeof key is "string" and (key.charAt(0) is "$" or key.charAt(0) is "_")
      val = undefined
    else if isWindow(value)
      val = "$WINDOW"
    else if value and document is value
      val = "$DOCUMENT"
    else if isScope(value)
      val = "$SCOPE"

    return val

  toJson: (obj, pretty) ->
    if typeof obj == 'undefined'
      return undefined
    return JSON.stringify(obj, toJsonReplacer, pretty ? '  ' : null);

  clone: (obj) ->
    if not obj? or typeof obj isnt 'object'
      return obj

    if obj instanceof Date
      return new Date(obj.getTime())

    if obj instanceof RegExp
      flags = ''
      flags += 'g' if obj.global?
      flags += 'i' if obj.ignoreCase?
      flags += 'm' if obj.multiline?
      flags += 'y' if obj.sticky?
      return new RegExp(obj.source, flags)

    newInstance = new obj.constructor()

    for key of obj
      newInstance[key] = this.clone obj[key]

    return newInstance

  checkCollisions: (ele) ->
    nodes = ele.parent().children('.node')
    collision = false
    for node in nodes
      if node != ele[0]
        if this.collides($(node), ele)
          collision = true
          break

    if collision
      ele.addClass("collision")
    else
      ele.removeClass("collision")

  # does one element collide with another element
  collides: (a, b) ->
    aOffset = a.offset()
    bOffset = b.offset()

    aBox =
      left: aOffset.left
      top: aOffset.top
      bottom: a.outerHeight() + aOffset.top
      right: a.outerWidth() + aOffset.left

    bBox =
      left: bOffset.left
      top: bOffset.top
      bottom: b.outerHeight() + bOffset.top
      right: b.outerWidth() + bOffset.left

    if aBox.bottom < bBox.top
      return false
    if aBox.top > bBox.bottom
      return false
    if aBox.left > bBox.right
      return false
    if aBox.right < bBox.left
      return false
    return true

#============================================================================
# DragHelper is all kinds of bad. This facilitates the little helper cues
# for the user so they learn the mechanics of building a flow. We should
# find a more angular way to do this, but at present there's all kinds of
# DOM inspection and manipulation when using this guy.
#============================================================================
app.service 'DragHelper', ['$rootScope', '$timeout', '$log', ($rootScope, $timeout, $log) ->

  show: (source, message) ->
    sourceOffset = source.offset()

    helper = $('#drag-helper')
    helpText = helper.find('.help-text')

    helper.css('opacity', 0)
    helpText.css('opacity', 0).css('left', -10)
    helper.show()

    if message
      helper.find('.help-text').html(message)

    helper.offset({left:sourceOffset.left - 8, top: sourceOffset.top - 20})
    helper.animate {top: sourceOffset.top + 14, opacity: 1}, complete: ->
      helper.find('.help-text').animate {left: 30, opacity: 1}, duration: 200, complete: ->
        if $rootScope.dragHelperId
          $timeout.cancel($rootScope.dragHelperId)
          $rootScope.dragHelperId = undefined
        $rootScope.dragHelperId = $timeout ->
          helper.fadeOut()
        ,20000

  showSaveResponse: (source) ->
    @show(source, 'To save responses to this message <span class="attn">drag</span> the red box')

  showSendReply: (source) ->
    @show(source, 'To send back a reply <span class="attn">drag</span> the red box')

  hide: ->
    $('#drag-helper').fadeOut()
    if $rootScope.dragHelperId
      $timeout.cancel($rootScope.dragHelperId)
      $rootScope.dragHelperId = undefined

]

#============================================================================
# Plumb service for mananging all the JSPlumb chicanery
#============================================================================
app.service "Plumb", ["$timeout", "$rootScope", "$log", ($timeout, $rootScope, $log) ->

  # Don't worry about drawing until after we've done our initial load
  jsPlumb.setSuspendDrawing(true)
  $('#flow').css('visibility', 'hidden')
  $timeout ->
    $('#flow').css('visibility', 'visible')
    jsPlumb.setSuspendDrawing(false)
    jsPlumb.repaintEverything()
  ,500

  jsPlumb.importDefaults
    DragOptions : { cursor: 'pointer', zIndex:2000 }
    DropOptions : { tolerance:"touch", hoverClass:"drop-hover" }
    Endpoint: "Blank"
    EndpointStyle: { strokeStyle: "transparent" }
    PaintStyle: { lineWidth:5, strokeStyle:"#98C0D9" }
    HoverPaintStyle: { strokeStyle: "#27ae60"}
    HoverClass: "connector-hover"
    ConnectionsDetachable: window.mutable
    Connector:
      [ "Flowchart",
          stub: 12
          midpoint: .85
          alwaysRespectStubs: false
          gap:[0,7]
          cornerRadius: 2
      ]

    ConnectionOverlays : [
      ["PlainArrow", { location:.9999, width: 12, length:12, foldback: 1 }],
    ]

    Container: "flow"

  targetDefaults =
    anchor: [ "Continuous", { faces:["top", "left", "right"] }]
    endpoint: [ "Rectangle", { width: 20, height: 20, hoverClass: 'endpoint-hover' }]
    hoverClass: 'target-hover'
    dropOptions: { tolerance:"touch", hoverClass:"drop-hover" }
    dragAllowedWhenFull: false
    deleteEndpointsOnDetach: true
    isTarget:true

  sourceDefaults =
    anchor: "BottomCenter"
    deleteEndpointsOnDetach: true
    maxConnections:1
    dragAllowedWhenFull:false
    isSource:true
    paintStyle:{ fillStyle:"blue", outlineColor:"black", outlineWidth:1 }

  makeSource: (sourceId, scope) ->
    # we do this in the next cycle to make sure our id is set
    $timeout ->
      jsPlumb.makeSource sourceId, angular.extend({scope:scope}, sourceDefaults)
    ,0

  makeTarget: (targetId, scope) ->
    $timeout ->
      jsPlumb.makeTarget targetId, angular.extend({scope:scope}, targetDefaults)
    ,0

  getSourceConnection: (source) ->
    connections = jsPlumb.getConnections({
      source: source.attr('id'),
      scope: '*'
    });

    if connections and connections.length > 0
      return connections[0]

  detachSingleConnection: (connection) ->
    jsPlumb.detach(connection)

  recalculateOffsets: (nodeId) ->

    # update ourselves
    $timeout ->

      # this reassesses our offsets
      jsPlumb.revalidate(nodeId)

      # this updates the offsets for our child elements
      jsPlumb.recalculateOffsets(nodeId)

      # finally repaint our new hotness
      jsPlumb.repaint(nodeId)
    ,0

  removeElement: (id) ->
    jsPlumb.remove(id)

  disconnectAllConnections: (id) ->

    # reenable any sources connecting to us
    jsPlumb.select({target:id}).each (connection) ->
      jsPlumb.setSourceEnabled(connection.sourceId, true)

    # now disconnect the existing connections
    jsPlumb.detachAllConnections(id)

    $('#' + id + ' .source').each ->
      id = $(this).attr('id')
      jsPlumb.detachAllConnections(id)

  disconnectOutboundConnections: (id) ->
    jsPlumb.detachAllConnections(id)
    if jsPlumb.isSource(id)
      jsPlumb.setSourceEnabled(id, true)

  setSourceEnabled: (source, enabled) ->
    jsPlumb.setSourceEnabled(source, enabled)


  connect: (sourceId, targetId, scope, fireEvent = true) ->

    #$log.debug(sourceId + ' > ' + targetId)

    sourceId += '_source'

    # remove any existing connections for our source first
    Plumb = @
    Plumb.disconnectOutboundConnections(sourceId)

    $('html').scope().plumb = Plumb

    # connect to our new target if we have one
    if targetId?
      existing = jsPlumb.getEndpoints(targetId)
      targetPoint = null
      if existing
        for endpoint in existing
          if endpoint.connections.length == 0
            targetPoint = existing[0]
            break

      if not targetPoint
        targetPoint = jsPlumb.addEndpoint(targetId, { scope: scope }, targetDefaults)

      if jsPlumb.getConnections({source:sourceId, scope:scope}).length == 0

        if jsPlumb.isSource(sourceId)
          Plumb.setSourceEnabled(sourceId, true)

        jsPlumb.connect
          maxConnections:1
          dragAllowedWhenFull:false
          deleteEndpointsOnDetach:true
          editable:false
          source: sourceId
          target: targetPoint
          fireEvent: fireEvent

        $timeout ->
          Plumb.setSourceEnabled(sourceId, false)
          Plumb.repaint(sourceId)
        ,0

  # Update the connections according to the destination. Peforms update
  # after $digest to make sure DOM element is ready for jsPlumb.
  updateConnection: (actionset) ->
    Plumb = @
    $timeout ->
      Plumb.disconnectOutboundConnections(actionset.uuid + '_source')
      if actionset.destination
        Plumb.connect(actionset.uuid, actionset.destination, 'rules')
      Plumb.recalculateOffsets(actionset.uuid)
    ,0

  # Update the connections according to the category targets. Performs update
  # after $digest to make sure DOM elements are ready for jsPlumb.
  updateConnections: (ruleset) ->
    Plumb = @
    $timeout ->
      for category in ruleset._categories
        Plumb.connect(ruleset.uuid + '_' + category.source, category.target, 'actions')
      Plumb.recalculateOffsets(ruleset.uuid)
    ,0

  setPageHeight: ->
    $("#flow").each ->
      pageHeight = 0
      $this = $(this)
      $.each $this.children(), ->
        child = $(this)
        bottom = child.offset().top + child.height()
        if bottom > pageHeight
          pageHeight = bottom + 500
      $this.height(pageHeight)

  repaint: (element=null) ->
    if not window.loaded
      return

    service = @

    $timeout ->

      if element
        jsPlumb.repaint(element)
      else
        jsPlumb.repaintEverything()

      service.setPageHeight()
    , 0

  disconnectRules: (rules) ->
    for rule in rules
      jsPlumb.remove(rule.uuid + '_source')

  getConnectionMap: (selector = {}) ->

    # get the current connections as a map
    connections = {}
    jsPlumb.select(selector).each (connection) ->
      # only count legitimate targets
      if connection.targetId and connection.targetId.length > 24
        # strip off _source suffix
        source = connection.sourceId.substr(0, connection.sourceId.lastIndexOf('_'))
        connections[source] = connection.targetId

    return connections
]

app.factory "Versions", ['$http', '$log', ($http, $log) ->
  new class Versions
    updateVersions: (flowId) ->
      _this = @
      $http.get('/flow/versions/' + flowId + '/').success (data, status, headers) ->
        # only set the versions if we get back json, if we don't have permission we'll get a login page
        if headers('content-type') == 'application/json'
          _this.versions = data

    getVersion: (version) ->
      _this = @
      return $http.get('/flow/versions/' + flowId + '/?definition=' + version.id).success (data, status, headers) ->
        # only set the versions if we get back json, if we don't have permission we'll get a login page
        if headers('content-type') == 'application/json'
          _this.definition = data

]

app.factory 'Flow', ['$rootScope', '$window', '$http', '$timeout', '$interval', '$log', '$modal', 'utils', 'Plumb', 'Versions', 'DragHelper', ($rootScope, $window, $http, $timeout, $interval, $log, $modal, utils, Plumb, Versions, DragHelper) ->

  new class Flow

    TEXT = 'F'
    VOICE = 'V'
    SURVEY = 'S'

    ALL = [TEXT,VOICE,SURVEY]

    constructor: ->

      @actions = [
        { type:'say', name:'Play Message', verbose_name:'Play a message', icon: 'icon-bubble-3', message: true, filter:[VOICE] }
        { type:'play', name:'Play Recording', verbose_name:'Play a contact recording', icon: 'icon-mic', filter:[VOICE]}
        { type:'reply', name:'Send Message', verbose_name:'Send an SMS response', icon: 'icon-bubble-3', message:true, filter:ALL }
        { type:'send', name:'Send Message', verbose_name: 'Send an SMS to somebody else', icon: 'icon-bubble-3', message:true, filter:[TEXT,VOICE] }
        { type:'add_label', name:'Add Label', verbose_name: 'Add a label to a Message', icon: 'icon-tag', filter:ALL }
        { type:'save', name:'Update Contact', verbose_name:'Update the contact', icon: 'icon-user', filter:ALL }
        { type:'add_group', name:'Add to Groups', verbose_name:'Add contact to a group', icon: 'icon-users-2', groups:true, filter:ALL }
        { type:'del_group', name:'Remove from Groups', verbose_name:'Remove contact from a group', icon: 'icon-users-2', groups:true, filter:ALL }
        { type:'api', name:'Webhook', verbose_name:'Make a call to an external server', icon: 'icon-cloud-upload', filter:[TEXT,VOICE] }
        { type:'email', name:'Send Email', verbose_name: 'Send an email', icon: 'icon-bubble-3', filter:[TEXT,VOICE] }
        { type:'lang', name:'Set Language', verbose_name:'Set language for contact', icon: 'icon-language', filter:ALL }
        { type:'flow', name:'Start Another Flow', verbose_name:'Start another flow', icon: 'icon-tree', flows:true, filter:[TEXT,VOICE] }
        { type:'trigger-flow',   name:'Start Someone in a Flow', verbose_name:'Start someone else in a flow', icon: 'icon-tree', flows:true, filter:[TEXT,VOICE] }
      ]

      @rulesets = [

        { type: 'wait_message', name:'Wait for Response', verbose_name: 'Wait for response', split:'message response', filter:[TEXT,SURVEY] },

        # voice flows only
        { type: 'wait_recording', name:'Get Recording', verbose_name: 'Wait for recording', filter:VOICE },
        { type: 'wait_digit', name:'Get Menu Selection', verbose_name: 'Wait for menu selection', filter:VOICE },
        { type: 'wait_digits', name:'Get Digits', verbose_name: 'Wait for multiple digits', split:'digits', filter:VOICE },

        # online flows
        { type: 'webhook', name:'Call Webhook', verbose_name: 'Call webhook', split:'webhook response', filter:[TEXT,VOICE] },

        # all flows
        { type: 'flow_field', name:'Split by Flow Field', verbose_name: 'Split by flow field', filter:ALL },
        { type: 'contact_field', name: 'Split by Contact Field', verbose_name: 'Split by contact field', filter:ALL },
        { type: 'expression', name:'Split by Expression', verbose_name: 'Split by expression', filter:ALL },
        { type: 'form_field', name:'Split by Message Form', verbose_name: 'Split by message form', filter:ALL },

        # Not supported yet
        # { type: 'group', verbose_name: 'Split by group membership', ivr:true, text:true},
        # { type: 'random', verbose_name: 'Split randomly', ivr:true, text:true},
        # { type: 'pause', verbose_name: 'Pause the flow', ivr:true, text:true},
      ]

      @supportsRules = ['wait_message', 'expression', 'flow_field', 'contact_field', 'wait_digits', 'form_field']

      @operators = [
        { type:'contains_any', name:'Contains any', verbose_name:'has any of these words', operands: 1, localized:true }
        { type:'contains', name: 'Contains all', verbose_name:'has all of the words', operands: 1, localized:true }
        { type:'not_empty', name: 'Not empty', verbose_name:'is not empty', operands: 0, localized:true }
        { type:'starts', name: 'Starts with', verbose_name:'starts with', operands: 1, voice:true, localized:true }
        { type:'number', name: 'Has a number', verbose_name:'has a number', operands: 0, voice:true }
        { type:'lt', name: 'Less than', verbose_name:'has a number less than', operands: 1, voice:true }
        { type:'eq', name: 'Equal to', verbose_name:'has a number equal to', operands: 1, voice:true }
        { type:'gt', name: 'More than', verbose_name:'has a number more than', operands: 1, voice:true }
        { type:'between', name: 'Number between', verbose_name:'has a number between', operands: 2, voice:true }
        { type:'date', name: 'Has date', verbose_name:'has a date', operands: 0, validate:'date' }
        { type:'date_before', name: 'Date before', verbose_name:'has a date before', operands: 1, validate:'date' }
        { type:'date_equal', name: 'Date equal to', verbose_name:'has a date equal to', operands: 1, validate:'date' }
        { type:'date_after', name: 'Date after', verbose_name:'has a date after', operands: 1, validate:'date' }
        { type:'phone', name: 'Has a phone', verbose_name:'has a phone number', operands: 0, voice:true }
        { type:'state', name: 'Has a state', verbose_name:'has a state', operands: 0 }
        { type:'district', name: 'Has a district', verbose_name:'has a district', operands: 1, auto_complete: true, placeholder:'@flow.state' }
        { type:'regex', name: 'Regex', verbose_name:'matches regex', operands: 1, voice:true, localized:true }
        { type:'true', name: 'Other', verbose_name:'contains anything', operands: 0 }
      ]

      @opNames =
        'lt': '< '
        'gt': '> '
        'eq': ''
        'between': ''
        'number': ''
        'starts': ''
        'contains': ''
        'contains_any': ''
        'date': ''
        'date_before': ''
        'date_equal': ''
        'date_after': ''
        'regex': ''

    $rootScope.errorDelay = quietPeriod

    determineFlowStart: ->
      topNode = null
      # see if this node is higher than our last one
      checkTop = (node) ->
        if topNode == null || node.y < topNode.y
          topNode = node
        else if topNode == null || topNode.y == node.y
          if node.x < topNode.x
            topNode = node

      # check each node to see if they are the top
      for actionset in @flow.action_sets
        checkTop(actionset)
      for ruleset in @flow.rule_sets
        checkTop(ruleset)

      if topNode
        @flow.entry = topNode.uuid

    Flow = @
    $rootScope.$watch (->Flow.dirty), (current, prev) ->

      # if we just became dirty, trigger a save
      if current

        if not window.mutable
          $rootScope.error = "Your changes cannot be saved. You don't have permission to edit this flow."
          return

        Flow.dirty = false

        # make sure we know our start point
        Flow.determineFlowStart()

        # schedule the save for a bit later in case more dirty events come in quick succession
        if $rootScope.saving
          cancelled = $timeout.cancel($rootScope.saving)

          # If we fail to cancel the current save we need to wait until the previous save completes and try again
          if not cancelled
            $timeout ->
              Flow.dirty = true
            , quietPeriod
            return

        $rootScope.saving = $timeout ->

          $rootScope.error = null

          $log.debug("Saving.")

          $http.post('/flow/json/' + Flow.flowId + '/', utils.toJson(Flow.flow)).error (data, statusCode) ->

            if statusCode == 400
              $rootScope.saving = false
              if UserVoice
                UserVoice.push(['set', 'ticket_custom_fields', {'Error': data.description}]);

              modalInstance = $modal.open
                templateUrl: "/partials/modal?v=" + version
                controller: ModalController
                resolve:
                  type: -> "error"
                  title: -> "Error Saving"
                  body: -> "Sorry, but we were unable to save your flow. Please reload the page and try again, this may clear your latest changes."
                  ok: -> 'Reload'

              modalInstance.result.then (reload) ->
                if reload
                  document.location.reload()
              return

            # we failed, could just be futzy internet, lets retry with backdown
            $rootScope.errorDelay += quietPeriod

            if $rootScope.errorDelay < (quietPeriod * (errorRetries + 1))
              $log.debug("Couldn't save changes, trying again in " + $rootScope.errorDelay)
              $timeout ->
                $rootScope.saving = false
                Flow.dirty = true
              , $rootScope.errorDelay
            else
              $rootScope.saving = false
              $rootScope.error = "Your changes may not be saved. Please check your network connection."
              $rootScope.errorDelay = quietPeriod

          .success (data, statusCode) ->
            $rootScope.error = null
            $rootScope.errorDelay = quietPeriod
            if data.status == 'unsaved'
              modalInstance = $modal.open
                templateUrl: "/partials/modal?v=" + version
                controller: ModalController
                resolve:
                  type: -> "error"
                  title: -> "Editing Conflict"
                  body: -> data.saved_by + " is currently editing this Flow. Your changes will not be saved until the Flow is reloaded."
                  ok: -> 'Reload'

              modalInstance.result.then (reload) ->
                if reload
                  document.location.reload()

            else

              # store our latest revision
              Flow.flow.metadata.revision = data.revision
              Flow.flow.metadata.saved_on = data.saved_on

              # update our auto completion options
              $http.get('/flow/completion/?flow=' + Flow.flowId).success (data) ->
                Flow.completions = data

              Versions.updateVersions(Flow.flowId)

            $rootScope.saving = null

        , quietPeriod


    getNode: (uuid) ->
      for actionset in @flow.action_sets
        if actionset.uuid == uuid
          return actionset

      for ruleset in @flow.rule_sets
        if ruleset.uuid == uuid
          return ruleset

    isPausingRuleset: (node) ->
      if not node?.actions
        return node.ruleset_type in ['wait_message', 'wait_recording', 'wait_digit', 'wait_digits']
      return false

    # check if a potential connection would result in an invalid loop
    detectLoop: (nodeId, targetId, path=[]) ->

      # can't go back on ourselves
      if nodeId == targetId
        throw new Error('Loop detected: ' + nodeId)

      # break out if our target is a pausing ruleset
      node = @getNode(targetId)
      if node and @isPausingRuleset(node)
        return false

      # check if we just ate our tail
      if targetId in path
        throw new Error('Loop detected: ' + path + ',' + targetId)

      # add ourselves
      path = path.slice()
      path.push(targetId)

      # if we have rules, check each one
      if node?.rules
        for rule in node.rules
          if rule.destination
            @detectLoop(node.uuid, rule.destination, path)
      else
        if node?.destination
          @detectLoop(node.uuid, node.destination, path)

    isConnectionAllowed: (sourceId, targetId) ->

      source = sourceId.split('_')[0]
      path = [ source ]

      sourceNode = @getNode(source)
      targetNode = @getNode(targetId)

      if @isPausingRuleset(sourceNode) and @isPausingRuleset(targetNode)
        return false

      try
        @detectLoop(source, targetId, path)
      catch e
        $log.debug(e.message)
        return false
      return true

    # translates a string into a slug
    slugify: (label) ->
      label = label.toString().toLowerCase().replace(/([^a-z0-9]+)/, ' ')
      return label.replace(/([^a-z0-9]+)/g, '_')

    # Get an array of current flow fields as:
    # [ { id: 'label_name', name: 'Label Name' } ]
    getFlowFields: (excludeRuleset) ->

      # find our unique set of keys
      flowFields = {}
      if @flow
        for ruleset in @flow.rule_sets
          if ruleset.uuid != excludeRuleset?.uuid
            flowFields[@slugify(ruleset.label)] = ruleset.label

      # as an array
      result = []
      for id, name of flowFields
        result.push({ id: id, text: name})

      return result

    # Takes an operand (@flow.split_on_name) and returns the
{'fraction_non_alphanumeric': 0.13455066683285555, 'fraction_numerical': 0.0049856662096472645, 'mean_word_length': 2.629297346200241, 'pattern_counts': {'":': 0, '<': 23, '<?xml version=': 0, '>': 183, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '1540451', 'n_tokens_mistral': 14348, 'n_tokens_neox': 13639, 'n_words': 3997}