import Vue from 'vue'
import mockData from '@/datasets/data-harry.js'
import { cloneDeep, merge, sortBy, capitalize } from 'lodash'
import { clone, validate, placeholder } from '@babel/types'
import { type } from 'os'

/**
 * @file Manages data storage/operations for network visualization module
 * @author Marko Rehacek <rehacek@mail.muni.cz>
 * 
 * Guidelines
 * ==========
 * Reuse functionality between mutations with outside functions. Respect referential
 * transparency: always pass the state to the function as an argument.
 */

export const namespaced = true

export const state = {
  // Maps would be better, unfortunately Vuex does not support them.

  /**
   * @type {Node[]}
   */
  nodes: [],
  /**
   * @type {Link[]}
   */
  links: [],
  /**
   * @type {Tag[]}
   */
  tags: []
}

/**
 * @typedef {Object} Node
 *
 * @property {number} id dataset id
 * @property {('regular'|'group'|'bundle'|'alias')} type
 * @property {string} [dataClass] like person, document, etc.
 * @property {string} [name]
 * @property {string} [label]
 *
 * @property {NodeVis} [vis] storage for visualization data
 */

/**
 * @typedef {Object} NodeVis
 * @property {boolean}  [vis.isUserDefined]
 * @property {number}   [vis.replacedBy] id of presentational node which replaced this node in aggregation
 * @property {number}   [vis.bundledIn] id of presentational node behind which this one is bundled
 * @property {number[]}   [vis.bundledNodes] ids of nodes bundled in this node
 */

/**
 * @typedef {Object} Link
 *
 * @property {number} id dataset id
 * @property {number} source id of end node
 * @property {number} target id of end node
 *
 * @property {boolean|object} [direction] boolean for singlelink, object for multilink
 * @property {boolean}  [direction.nonDirectional] specific for multilinks
 * @property {boolean}  [direction.sourceToTarget] specific for multilinks
 * @property {boolean}  [direction.targetToSource] specific for multilinks
 *
 * @property {LinkVis}   [vis] storage for visualization data
 */

/**
 * @typedef {Object} LinkVis
 * @property {boolean}  [vis.isUserDefined]
 * @property {boolean}  [vis.isMultilink] specific for multilinks
 * @property {number}   [vis.inMultilink] id of multilink which it belongs to
 * @property {number}   [vis.newSource] specific for singlelinks:
 * @property {number}   [vis.newTarget]
 * @property {boolean}  [vis.isRemoved]
 * @property {boolean}  [vis.isArchived] specific for multilinks: when link is inactive, waiting to be recovered when ungrouping
 */

/**
 * @typedef {Object} Tag
 * @property {number}   number
 * @property {name}     string
 * @property {boolean}  isVisualized if tag is visible in graph near the nodes
 * @property {string}   color in hexa
 * @property {'aggregation'|'manual'|'link'} type if created automatically by aggregation or manually by user
 */

export const AN_AGGREGATION_TYPES = ['group', 'alias']
Object.freeze(AN_AGGREGATION_TYPES)

/**
 * Get node from state
 * @param {Object} state
 * @param {Node[]} state.nodes
 * @param {number} id of node
 * 
 * @returns {Node} Node instance, or undefined if not found
 */
function findNodeById(state, id) {
  return state.nodes.find(n => n.id === id)
}

function filterVisibleLinks(links) {
  return links.filter(l => isLinkVisible(l))
}

/**
 * Tell if node is visible in graph visualization
 * @param {Node} node
 * 
 * @returns {boolean}
 */
function isNodeVisible(node) {
  return !node.vis || (node.vis.replacedBy === undefined && node.vis.bundledIn === undefined)
}

export function filterVisibleNodes(nodes) {
  return nodes.filter(n => isNodeVisible(n))
}

export function isNodeAggregated(n) {
  return AN_AGGREGATION_TYPES.includes(n.type)
}

export function isNodeBundle(node) {
  return node.vis && node.vis.bundledNodes !== undefined 
        && Array.isArray(node.vis.bundledNodes) && node.vis.bundledNodes.length > 0
}

export function findConnectedLinks(links, id) {
  let connected = []
  links.forEach(l => {
    if (l.target === id || l.source === id) connected.push(l)
  })
  return connected
}

/**
 * Check if visible: not in multilink, not an archived multilink, newTarget != newSource
 * @param {Link} l
 * 
 * @returns {boolean}
 */
function isLinkVisible(l) {
  if (isInMultilink(l)) return false
  if (l.vis) {
    if (l.vis.isMultilink && l.vis.isArchived) return false
    if (l.vis.newSource !== undefined && l.vis.newSource === l.vis.newTarget) return false
  } else {
    if (l.source === l.target) return false
  }
  return true
}

/**
 * Find first available id by iterating over objects in array
 * @param {Object[]} arr of objects, each object has attribute id
 * 
 * @returns {number} id
 */
function arrGetNewId(arr) {
  if (arr === undefined || !Array.isArray(arr)) throw 'Calling arrGetNewId with undefined array'
  if (arr.length === 0) return 0

  return arr.reduce((prev, curr) => (prev.id > curr.id ? prev : curr)).id + 1
}

/**
 * Check if link has set vis.newSource and vis.newTarget
 * @param {Link} link
 * 
 * @returns {boolean}
 */
function isLinkTransferred(link) {
  return link.vis && link.vis.newSource !== undefined && link.vis.newTarget !== undefined
}

function isLinkSimilar(link, linkToMatch) {
  const source = isLinkTransferred(linkToMatch) ? linkToMatch.vis.newSource : linkToMatch.source
  const target = isLinkTransferred(linkToMatch) ? linkToMatch.vis.newTarget : linkToMatch.target

  if (link.vis && link.vis.newSource !== undefined && link.vis.newTarget !== undefined) {
    return (
      (source === link.vis.newSource && target === link.vis.newTarget) ||
      (target === link.vis.newSource && source === link.vis.newTarget)
    )
  }

  return (
    (source === link.source && target === link.target) ||
    (target === link.source && source === link.target)
  )
}

/**
 * Insert a link into array and check, if array contains a link with the same
 * source-target pair. In that case, also insert a multilink. Will set link.id
 *
 * Set multilink id to nodeId+1. Mark associated links as part of the multilink.
 * Update multilink direction.
 * @param {Object[]} arr where link or multilink will be created
 * @param {Link} link to insert
 * @param {Number} groupId so multilink has always source set to group node
 * 
 * @return {Object} multilink copy, if created; null otherwise
 */
function insertUpdatedLink(arr, link, groupId, newId) {
  const similarLink = arr.find(l => isLinkSimilar(l, link))
  if (!similarLink) {
    arr.push(cloneDeep(link))
    return null
  }

  // found link is the new multilink
  if (isMultilink(similarLink)) {
    // FIXME no coverage
    link.vis.inMultilink = similarLink.id
    arr.push(cloneDeep(link))
    multilinkUpdateDirection(arr, similarLink)

    // if found link is in multilink
  } else if (isInMultilink(similarLink)) {
    // it should be the newly created multilink in this arr
    const ml = arr.find(l => l.id === similarLink.vis.inMultilink)
    if (!ml) throw Error('This fucker is no multilink')
    setVisAttr(link, 'inMultilink', similarLink.vis.inMultilink)
    arr.push(cloneDeep(link))
    multilinkUpdateDirection(arr, ml)

    // found link is not in multilink, ergo there is no multilink and we create new
  } else if (isMultilink(link)) {
    // FIXME no coverage
    return null
  } else {
    const isSourceGroup = groupId === getLinkSource(link)
    const ml = createMultilink(
      newId,
      isSourceGroup ? getLinkSource(link) : getLinkTarget(link),
      isSourceGroup ? getLinkTarget(link) : getLinkSource(link)
    )
    const merging = [similarLink, link]
    merging.forEach(l => {
      l.vis = l.vis || {}
      l.vis.inMultilink = ml.id
    })
    arr.push(cloneDeep(link))
    multilinkUpdateDirection(arr, ml)
    arr.push(ml)
    return ml
  }
  return null
}

/**
 * Return link.vis.newSource uif set, otherwise just link.source
 * @param {Link} link
 * 
 * @return {number} ID of source
 */
export function getLinkSource(link) {
  if (link.vis !== undefined && link.vis.newSource !== undefined)
    return link.vis.newSource
  return link.source
}

/**
 * Return link.vis.newTarget if set, otherwise just link.target
 * @param {Link} link
 * 
 * @return {number} ID of target
 */
export function getLinkTarget(link) {
  if (link.vis !== undefined && link.vis.newTarget !== undefined)
    return link.vis.newTarget
  return link.target
}

/**
 * @summary Insert a link in given array, checking for links with similar endpoints, updating the array.
 * If similar link exists, assign the link to a multilink in the array and update the multilink direction.
 * Recover archived multilink in array if possible. Create new multilink if necessary.
 *
 * To enable recovery of archived multilinks, add them into the array before calling this.
 *
 * @param {Link} link to insert; NOT MUTATED
 * @param {Link[]} arr to be updated with the new link
 * @param {number} availableId for creating new multilink
 * 
 * @returns {Link} multilink, if new one was created during insertion; otherwise null
 */
function insertLinkWithMultilinkUpdate(link, arr, availableId) {
  if (isMultilink(link)) throw Error('Bad usage. Inserting a multilink is not allowed.')

  const source = getLinkSource(link)
  const target = getLinkTarget(link)
  const similarLink = arr.find(l => isLinkSimilar(l, link))
  if (!similarLink) {
    arr.push(cloneDeep(link))
    return null
  }

  if (isMultilink(similarLink)) {
    Vue.delete(similarLink.vis, 'isArchived')
    setVisAttr(link, 'inMultilink', similarLink.id)
    arr.push(cloneDeep(link))
    multilinkUpdateDirection(arr, similarLink)
  } else if (isInMultilink(similarLink)) {
    const ml = arr.find(l => l.id === similarLink.vis.inMultilink)
    if (!ml) throw Error('Unexpected.')
    setVisAttr(link, 'inMultilink', similarLink.vis.inMultilink)
    arr.push(cloneDeep(link))
    multilinkUpdateDirection(arr, ml)
  } else {
    const ml = createMultilink(availableId, source, target)
    const merging = [similarLink, link]
    merging.forEach(l => setVisAttr(l, 'inMultilink', ml.id))
    arr.push(cloneDeep(link))
    multilinkUpdateDirection(arr, ml)
    arr.push(ml)
    return ml
  }
  return null
}

/**
 * Update the aggregated direction of multilink from links in given array
 * @param {Link[]} arr of links with links belonging to given multilink
 * @param {Link} ml multilink to update
 */
function multilinkUpdateDirection(/* const */ arr, ml) {
  Vue.set(ml, 'direction', { nonDirectional: false, sourceToTarget: false, targetToSource: false })
  arr
    .filter(l => l.vis && l.vis.inMultilink === ml.id)
    .map(l => {
      if (!l.direction) {
        ml.direction.nonDirectional = true
      } else {
        if (l.vis.newSource === ml.source) ml.direction.sourceToTarget = true
        else ml.direction.targetToSource = true
      }
    })
}

/**
 * Check if node is in aggregation, replaced by presentational node
 * @param {Node} node to check
 * @param {Number} id of presentational node
 */
export function isReplacedBy(node, id) {
  return !node.vis ? false : node.vis.replacedBy === id
}

/**
 * Check if node is bundled, behind presentational node
 * @param {Node} node to check
 * @param {Number} id of presentational node
 */
export function isBundledIn(node, id) {
  return !node.vis ? false : node.vis.bundledIn === id
}

export function isBundle(n) {
  return !n.vis ? false : (n.vis.bundledNodes !== undefined && n.vis.bundledNodes.length > 0)
}

/**
 * Check if link is in multilink
 * @param {Link} link
 * @return {boolean}
 */
function isInMultilink(link) {
  if (!link.vis) return false
  return link.vis.inMultilink !== undefined
}

/**
 * Check if link is in specific multilink
 * @param {Link} link
 * @param {number} mlId
 * 
 * @return {boolean}
 */
function isInMultilinkOfId(link, mlId) {
  if (!Number.isInteger(mlId)) throw Error('Invalid ID')

  if (!link.vis) return false
  return link.vis.inMultilink === mlId
}

/**
 * Check for vis.isMultilink attribute
 * @param {Link} link
 * 
 * @return {boolean}
 */
export function isMultilink(link) {
  if (!link.vis) return false
  return link.vis.isMultilink !== undefined
}

/**
 * Check for vis.isArchived attribute
 * @param {Link} multilink
 * 
 * @return {boolean}
 */
function isMultilinkArchived(multilink) {
  if (!isMultilink(multilink)) return false
  return multilink.vis.isArchived === true
}

/**
 * Push new group node to state
 * @param {Object} state writes to state.nodes
 * @param {Node} node will be assigned type and new ID
 * @param {boolean} isUserDefined true to set this vis attr
 * 
 * @return {number} ID assigned to the new node
 */
function pushNodeToState(state, node, isUserDefined) {
  node.id = arrGetNewId(state.nodes)
  if (isUserDefined) {
    node.vis = node.vis || {}
    node.vis.isUserDefined = true
  }
  state.nodes.push(cloneDeep(node))
  return node.id
}

/**
 * Set vis attribute to object
 * @param {Node|Link} el
 * @param {string} attr vis attribute to set
 * @param {*} val value to set
 */
function setVisAttr(el, attr, val) {
  el.vis = el.vis || {}
  Vue.set(el.vis, attr, cloneDeep(val))
}

/**
 * Set vis attributes to object, merging vis objects
 * @param {Node|Link} el
 * @param {NodeVis|LinkVis} visAttrs vis object to merge
 */
function setVisAttrs(el, visAttrs) {
  el.vis = el.vis || {}
  Vue.set(el, 'vis', merge(el.vis, visAttrs))
}

/**
 * Merge visAttrs with each element given in arr
 * @param {Node[]|Link[]} arr
 * @param {LinkVis|NodeVis} visAttrs
 */
function arraySetVisAttrs(arr, visAttrs) {
  arr.forEach(el => {
    el.vis = el.vis || {}
    Vue.set(el, 'vis', merge(el.vis, visAttrs))
  })
}

/**
 * Removes vis attr if set, then removes whole vis object if empty
 * @param {Node|Link} el
 * @param {string} attr attribute of vis object
 */
function removeVisAttr(el, attr) {
  if (!el.vis) return
  Vue.delete(el.vis, attr)
  if (Object.keys(el.vis).length === 0) Vue.delete(el, 'vis')
}

/**
 * Get new multilink instance
 * @param {number} newId
 * @param {number} source
 * @param {number} target
 */
function createMultilink(newId, source, target) {
  // FIXME: when isUserDefined?
  return {
    id: newId,
    source: source,
    target: target,
    vis: { isMultilink: true }
  }
}

/**
 * Find last node which replaced given node
 * Nodes map is in form [{id: replacedById | undefined },..]
 * @param {Map} nodesMap created with makeNodesMap
 * @param {number} nodeId on which to start lookup
 * 
 * @returns {number} node id
 */
function findReplacementNode(nodesMap, nodeId) {
  if (!nodesMap.has(nodeId)) throw Error('Node not found in nodes map.')

  const replacedBy = nodesMap.get(nodeId)
  if (replacedBy === undefined) return nodeId
  else return findReplacementNode(nodesMap, replacedBy)
}

export const mutations = {
  /**
   * Add new node
   * @param {Object} state
   * @param {Node} node
   */
  ADD_NODE(state, node) {
    node.id = arrGetNewId(state.nodes)
    state.nodes.push(node)
  },

  /**
   * Batch replace node attributes
   * @param {Object} state
   * @param {Object[]} payload const;
   * every diff will have node id and attributes to replace
   * [...{ id: nodeId, ...updateAttr: newVal}]
   * @param {Number} payload[].id
   *
   * @throws {Error} if diff array is empty or undefined
   * @throws {Error} if node is not found in state. Beware that previous valid entries will be commited
   */
  UPDATE_NODES(state, payload) {
    const nodesDiff = payload
    if (!nodesDiff || nodesDiff.length < 1) throw Error('Update array undefined or empty')

    nodesDiff.forEach(ndiff => {
      if (!ndiff.id === undefined) throw Error('Entry has no id')
      const node = state.nodes.find(sn => sn.id === ndiff.id)
      if (!node) throw Error('Node not found')

      Vue.set(state.nodes, state.nodes.indexOf(node), merge(node, ndiff))
    })
  },

  /**
   * Remove multiple nodes and associated links
   * @param {Object}   state
   * @param {Object}   payload
   * @param {Number[]} payload.nodeIds to remove
   * @param {Number[]} [payload.removedNodeIds] OUT: will contain nodes to be removed from graph.
   * @param {Number[]} [payload.removedLinkIds] OUT: will contain links to be removed from graph.
   * Will not include original entries set to isRemoved
   *
   * @throws {Error} when given no nodeIds
   * @throws {Error} when nodes dont exist
   */
  REMOVE_NODES(state, payload) {
    const nodeIds = payload.nodeIds
    const removedNodeIds = (payload.removedNodeIds = [])
    const removedLinkIds = (payload.removedLinkIds = [])
    if (!Array.isArray(nodeIds) || nodeIds.length < 1) throw Error('Invalid input array')

    const filteredNodes = []
    let removedNo = 0
    for (const n of state.nodes) {
      // skip
      if (!nodeIds.includes(n.id)) {
        filteredNodes.push(n)
        continue
      }

      if (n.vis && n.vis.isUserDefined !== true) {
        // TODO: inform user via payload about removing original entries
        n.vis = n.vis || {}
        Vue.set(n.vis, 'isRemoved', true)
        //n.vis.isRemoved = true
        filteredNodes.push(n)
      } else {
        removedNodeIds.push(n.id)
      }
      removedNo++
    }
    if (removedNo !== nodeIds.length)
      throw Error('Some of the nodes marked for removal do not exist.')
    // TODO: what with group? nodes with inAggregation set?
    Vue.set(state, 'nodes', filteredNodes)

    const filteredLinks = []
    for (const l of state.links) {
      // skip irrelevant
      if (
        !nodeIds.includes(l.source) &&
        !nodeIds.includes(l.target) &&
        !isNodeAggregated(l, 8) //FIXME
      ) {
        filteredLinks.push(l)
        continue
      }

      if (!l.vis || l.vis.isUserDefined !== true) {
        l.vis = l.vis || {}
        l.vis.isRemoved = true
        filteredLinks.push(l)
        continue
      }

      if (l.vis && l.vis.inMultilink) continue
      removedLinkIds.push(l.id)
    }
    Vue.set(state, 'links', filteredLinks)
  },

  /**
   * Aggregate nodes in general group, alias or bundle
   * Push new group node/links to state, update nodes/links.
   *
   * Aggregation types
   * ALIAS
   * a regular node (represents one entity, not a group);
   * merge links, attributes, merge conflict should be solved by user
   *
   * BUNDLING
   * hides a set of nodes in a selected node;
   * nodes are hidden, links are transferred (meaning they connect to the bundle node instead)
   *
   * GENERAL GROUP
   * nodes are hidden, links are transferred
   *
   * @param {Object}   state
   * @param {Object}   payload
   * @param {'group'|'alias'|'bundle'} payload.action aggregation type
   * @param {Number[]} payload.nodeIds to aggregate
   * @param {Node}     [payload.targetGroupNode] ref. to the new group node without ID, which will be set
   * @param {Number[]} [payload.removedLinkIds] OUT: will contain links hidden during aggregation, ordered from smallest
   * @param {Object[]} [payload.newVisibleLinks] OUT: will contain new links, ordered by id from smallest
   *
   * @throws {Error} when nodeIds are not given or less than 2
   * @throws {Error} when targetGroupNode already has an ID or is just regular node
   */
  AGGREGATE_NODES(state, payload) {
    const action = payload.action,
      nodesToGroupIds = payload.nodeIds,
      groupNode = payload.targetGroupNode,
      removedLinkIds = [],
      newVisibleLinks = []

    if (action !== 'bundle' && (!nodesToGroupIds || nodesToGroupIds.length < 2))
      throw Error('Grouping requires less then 2 nodes')
    if (!groupNode) throw Error('Group node is undefined')
    if ((action === 'group' || action === 'alias') && groupNode.type === 'regular')
      throw Error('Node is regular.')
    if ((action === 'group' || action === 'alias') && groupNode.id !== undefined)
      throw Error('Group node should not come with an ID')

    const groupId = action === 'bundle' ? groupNode.id : pushNodeToState(state, groupNode, false)
    if (action === 'bundle') {
      const n = findNodeById(state, groupId)
      if (n.vis && n.vis.bundledNodes !== undefined) {
        const newBundledNodes = n.vis.bundledNodes
        nodesToGroupIds.forEach(id => newBundledNodes.push(id))
        setVisAttr(n, 'bundledNodes', newBundledNodes)
      } else {
        setVisAttr(n, 'bundledNodes', nodesToGroupIds)
      }
      arraySetVisAttrs(state.nodes.filter(n => nodesToGroupIds.includes(n.id)), { bundledIn: groupId })
    } else {
      arraySetVisAttrs(state.nodes.filter(n => nodesToGroupIds.includes(n.id)), { replacedBy: groupId })
    }
    
    const updatedLinks = []
    const newLinks = []
    let currentNewLinkId = arrGetNewId(state.links)
    for (const link of state.links) {
      const isGroupTarget = nodesToGroupIds.includes(link.target) || (action === 'bundle' && groupId === link.target) ? true : false,
        isGroupSource = nodesToGroupIds.includes(link.source) || (action === 'bundle' && groupId === link.source) ? true : false,
        isGroupNewTarget =
          link.vis &&
          link.vis.newTarget !== undefined &&
          (nodesToGroupIds.includes(link.vis.newTarget) || (action === 'bundle' && groupId === link.vis.newTarget))
            ? true
            : false,
        isGroupNewSource =
          link.vis &&
          link.vis.newSource !== undefined &&
          (nodesToGroupIds.includes(link.vis.newSource) || (action === 'bundle' && groupId === link.vis.newSource))
            ? true
            : false,
        isSourceAndTargetSame =
          link.vis &&
          link.vis.newTarget !== undefined &&
          link.vis.newSource !== undefined &&
          link.vis.newSource === link.vis.newTarget
            ? true
            : false

      let target = groupId,
        source = groupId
      // ignore links not connected to subgraph
      if (!isGroupTarget && !isGroupSource && !isGroupNewTarget && !isGroupNewSource) {
        continue
      }

      // remove all visible subgraph links from graph
      if (isLinkVisible(link)) removedLinkIds.push(link.id)

      // link was already transferred -> replace newSource, newTarget
      if (isGroupNewTarget || isGroupNewSource) {
        if (isSourceAndTargetSame) continue
        if (isGroupNewTarget && !isGroupNewSource) source = link.vis.newSource
        if (!isGroupNewTarget && isGroupNewSource) target = link.vis.newTarget
        // link is new -> set newSource, newTarget for the first time
      } else if (!isMultilink(link) && (isGroupTarget || isGroupSource)) {
        if (isGroupTarget && !isGroupSource) source = link.source
        if (!isGroupTarget && isGroupSource) target = link.target
      } else if (isMultilink(link)) {
        link.vis.isArchived = true
        continue
      }

      // update link connection
      if (link.source !== source || link.target !== target) {
        setVisAttrs(link, { newSource: source, newTarget: target })
        if (action === 'bundle') setVisAttr(link, 'forceTransferred', true)
      }
      if (isInMultilink(link)) {
        Vue.delete(link.vis, 'inMultilink')
      }
      // ignore if inside the group
      if (source === target) {
        continue
      }

      // check if updated links merge into multilinks
      const ml = insertUpdatedLink(updatedLinks, link, groupId, currentNewLinkId)
      if (ml) {
        newLinks.push(ml)
        currentNewLinkId += 1
      }
    }

    const updatedLinkIds = []
    updatedLinks.forEach(l => {
      updatedLinkIds.push(l.id)
    })
    state.links
      .filter(l => updatedLinkIds.includes(l.id))
      .forEach(l => {
        const newer = updatedLinks.find(ul => ul.id === l.id)
        if (!newer) throw Error('Motherfuck')
        if (isInMultilink(newer)) {
          setVisAttrs(l, { inMultilink: newer.vis.inMultilink })
        }
      })

    newLinks.forEach(l => state.links.push(cloneDeep(l)))

    // updated not-in-multilink links and new multilinks will be shown
    updatedLinks
      .filter(l => !isInMultilink(l))
      .forEach(l => {
        if (isMultilink(l)) {
          newVisibleLinks.push({ id: l.id, source: getLinkSource(l), target: getLinkTarget(l) })
        } else { 
          newVisibleLinks.push({ id: l.id, source: getLinkSource(l), target: getLinkTarget(l) })
        }
      })
    // set payload
    payload.removedLinkIds = sortBy(removedLinkIds, o => o)
    payload.newVisibleLinks = sortBy(newVisibleLinks, ['id'])
  },

  /**
   * Reverse node aggregation for group, alias or bundle
   *
   * New data for graph will be placed in payload object.
   *
   * @param {Object}   state
   * @param {Object}   payload
   * @param {Number}   payload.nodeId group node to ungroup
   * @param {'group'|'bundle'} payload.revertAction
   * @param {boolean}  [payload.removeNode] OUT: will contain instruction whenever to remove the ungroped node
   * @param {Number[]} [payload.removedLinkIds] OUT: will contain removed links, ordered from smallest
   * @param {Object[]} [payload.newVisibleLinks] OUT: will contain replacement links for graph, ordered by ids from smallest
   * @param {Object[]} [payload.newVisibleNodes] OUT: will contain replacement nodes for graph, ordered by ids from smallest
   *
   * @throws {Error} target node does not exist
   * @throws {Error} target is regular node or not a bundle (in case of reverting bundling)
   * @throws {Error} target is a group and also bundles nodes
   */
  DISAGGREGATE_NODE(state, payload) {
    const revertAction = payload.revertAction,
      groupId = payload.nodeId,
      removedVisibleLinkIds = [],
      newVisibleLinks = [],
      newVisibleNodes = []

    if (revertAction === undefined) throw Error('Undefined dissagregation action.')
    if (typeof groupId !== 'number' || groupId < 0) throw Error('Invalid argument.')
    const groupNode = state.nodes.find(n => n.id === groupId)
    if (!groupNode) throw Error('Target node was not found.')
    if (revertAction === 'group') {
      if (!isNodeAggregated(groupNode))
        throw Error('Cannot revert grouping on regular node.')
      if (isNodeBundle(groupNode))
        throw Error('Cannot ungroup a bundled node. Revert bundle first.')
    } else if (revertAction === 'bundle' && !isNodeBundle(groupNode)) {
      throw Error('Cannot unbundle a node without bundled nodes.')
    }
    const removeTargetNode = revertAction !== 'bundle'

    // reveal original nodes
    const revealedNodeIds = []
    if (revertAction === 'bundle') {
      // all types of target node
      const revealedNodes = state.nodes.filter(n => isBundledIn(n, groupId))
    revealedNodes.forEach(n => {
      revealedNodeIds.push(n.id)
        removeVisAttr(n, 'bundledIn')
      newVisibleNodes.push(
        n.vis && n.vis.graph ? { id: n.id, vis: { graph: cloneDeep(n.vis.graph) } } : { id: n.id }
      )
    })
      removeVisAttr(groupNode, 'bundledNodes')
    } else {
      const revealedNodes = state.nodes.filter(n => isReplacedBy(n, groupId))
      revealedNodes.forEach(n => {
        revealedNodeIds.push(n.id)
        removeVisAttr(n, 'replacedBy')
        newVisibleNodes.push(
          n.vis && n.vis.graph ? { id: n.id, vis: { graph: cloneDeep(n.vis.graph) } } : { id: n.id }
        )
      })
      // remove the target node
      Vue.set(state, 'nodes', state.nodes.filter(n => n.id !== groupId))
    }

    // convert state.nodes to hash map, because we will be doing recursive
    // lookups to update the connections of affected links
    const nodesMap = makeNodesMap(state)

    // divide links to two categories:
    //   - links untouched by ungrouping algorithm (for efficiency, as we do not need to iterate over them)
    //   - links that form the subgraph and do not originate from group
    // at the end, we will be merging them and replacing state.links
    // this is efficient, we are only creating vectors of references
    // however, we may want to deep copy these, if we will work with state.links
    const unaffectedLinks = []
    const affectedLinks = []
    const affectedMultilinks = []
    const removedLinkIds = [] // will be used to calculate id for new multilinks, as we have split arrays and cannot know the available id
    for (const l of state.links) {
      // remove links originating from target node, when target is being removed
      // these also account for current multilinks, as they cannot be transferred
      if ((removeTargetNode || isMultilink(l)) && (l.source === groupId || l.target === groupId)) {
        removedLinkIds.push(l.id)
        if (isLinkVisible(l)) removedVisibleLinkIds.push(l.id)
        continue
      }

      // find links transferred to group
      if (l.vis) {
        if (!isMultilink(l) && (getLinkSource(l) === groupId || getLinkTarget(l) === groupId)) {
        // normal links from subgraph
          affectedLinks.push(l)
          continue
        } else if (l.vis.newSource === groupId || l.vis.newTarget === groupId) {
          affectedLinks.push(l)
          continue
        } else if (
          isMultilink(l) &&
          (revealedNodeIds.includes(l.source) || revealedNodeIds.includes(l.target))
        ) {
          // multilinks from subgraph
          affectedMultilinks.push(l)
          continue
        }
      }

      unaffectedLinks.push(l)
    }

    // iterate over subgraph links (those originating from group were already filtered out)
    //   - link is visible: remove from graph
    //   - is multilink, now what???
    //   - calculate newSource, newTarget
    //   - check against all these links, if we can create or recover a multilink
    const updatedAffectedLinks = []
    const linksForIdCalculation = state.links.filter(l => !removedLinkIds.includes(l.id))

    // firstly recover old multilinks, so we then know, if we can just assign the link to them or create new multilinks
    for (const l of affectedMultilinks) {
      if (!isMultilink(l)) throw Error('Expected multilink')
      if (isLinkVisible(l)) removedVisibleLinkIds.push(l.id)
      updatedAffectedLinks.push(cloneDeep(l))
    }

    for (const l of affectedLinks) {
      // for simplicity, we remove all these links from graph and draw new after ungrouping, after multilinks formed
      if (isLinkVisible(l)) removedVisibleLinkIds.push(l.id)

      const updatedSource = findReplacementNode(nodesMap, l.source)
      const updatedTarget = findReplacementNode(nodesMap, l.target)

      removeVisAttr(l, 'inMultilink')
      removeVisAttr(l, 'forceTransferred')

      // check, if we still transfer link or it is in original state
      if (l.source === updatedSource && l.target === updatedTarget) {
        removeVisAttr(l, 'newSource')
        removeVisAttr(l, 'newTarget')
      } else {
        l.vis.newSource = updatedSource
        l.vis.newTarget = updatedTarget
      }

      const ml = insertLinkWithMultilinkUpdate(
        l,
        updatedAffectedLinks,
        arrGetNewId(linksForIdCalculation)
      )
      if (ml) linksForIdCalculation.push(ml)
    }

    updatedAffectedLinks.forEach(l => {
      if (isLinkVisible(l))
        newVisibleLinks.push({
          id: l.id,
          source: l.vis && l.vis.newSource ? l.vis.newSource : l.source,
          target: l.vis && l.vis.newTarget ? l.vis.newTarget : l.target
        })
    })

    // now merge both link arrays and update state.links
    Vue.set(state, 'links', sortBy([...unaffectedLinks, ...updatedAffectedLinks], ['id']))
    // set payload
    payload.removedLinkIds = sortBy(removedVisibleLinkIds, o => o)
    payload.newVisibleLinks = sortBy(newVisibleLinks, ['id'])
    payload.newVisibleNodes = sortBy(newVisibleNodes, ['id'])
    payload.removeNode = removeTargetNode
  },

  INIT(state, data) {
    state.nodes = cloneDeep(data.nodes)
    state.links = cloneDeep(data.links)
  }
}

/**
 * Create new link
 * @param {Object} newId to assign
 * @param {Number} source node id
 * @param {Number} target node id
 * @param {Object} [vis] vis attributes to set
 * @return created link instance
 */
export function createLink(newId, source, target, vis = null) {
  const l = { id: newId, target: target, source: source }
  if (vis !== null) {
    l.vis = l.vis || {}
    merge(l.vis, vis)
  }
  return l
}

/**
 * Create Map object from state.nodes. Use to speed up lookups
 * @param {Object} state
 */
function makeNodesMap(state) {
  return new Map(
    state.nodes.map(n => {
      if (n.vis) {
        if (n.vis.replacedBy !== undefined) return [n.id, n.vis.replacedBy]
        if (n.vis.bundledIn !== undefined) return [n.id, n.vis.bundledIn]
      }
      return [n.id, undefined]
    })
  )
}

/**
 * Validate dataset. Check that:
 * - every node/link has ID assigned
 * - node/link IDs are unique
 * - links are connected to nodes that exist
 * - links are unique between pair of nodes OR they are properly replaced by multilink
 * 
 * @param {Object} dataset
 * @param {Node[]} dataset.nodes
 * @param {Link[]} dataset.links
 */
export function validateDataset(dataset) {
  if (!dataset) throw Error("Invalid input")

  const debug = false
  debug && console.log("Validating dataset: "+JSON.stringify(dataset))
  const data = cloneDeep(dataset)

  if (!data.nodes || !data.links) throw Error("Undefined nodes/links array")

  const nodes = new Map()
  data.nodes.forEach(n => {
    if (n.id === undefined) throw Error("Node must have ID assigned: "+JSON.stringify(n))
    if (nodes.has(n.id)) throw Error("ID of node is not unique: "+JSON.stringify(n))
    nodes.set(n.id, {links: []})
  })
  debug && console.log("Nodes map: "+JSON.stringify(Array.from(nodes)))

  const links = new Map()
  data.links.forEach(l => {
    if (l.id === undefined) throw Error("Link must have ID assigned: "+JSON.stringify(l))
    if (links.has(l.id)) throw Error("ID of link is not unique: "+JSON.stringify(l))
    links.set(l.id, undefined)

    const source = getLinkSource(l), target = getLinkTarget(l)
    if (!nodes.has(source) || !nodes.has(l.source) || !nodes.has(target) || !nodes.has(l.target)) 
      throw Error('Link is connected to nonexistent node')

    // fill nodes map with currently visible links conntected to them
    nodes.get(source).links.push(l)
    nodes.get(target).links.push(l)
  })
  debug && console.log("Nodes map after links: "+JSON.stringify(Array.from(nodes)))
  debug && console.log("Links map: "+JSON.stringify(Array.from(links)))

  // check link connections
  nodes.forEach(function(val, nodeId) {
    debug && console.log("Checking links for node: "+nodeId + ' = ' +JSON.stringify(val.links))
    val.links.forEach(link => {
      debug && console.log("Link: "+JSON.stringify(link))
      const source = getLinkSource(link)
      const target = getLinkTarget(link)

      const similarLinks = val.links.filter(l => isLinkSimilar(l, link) && l.id !== link.id)
      const multilinks = similarLinks.filter(l => isMultilink(l) && !isMultilinkArchived(l))
      const singlelinks = similarLinks.filter(l => !isMultilink(l))
      debug && console.log("Similar: "+JSON.stringify(similarLinks))
      debug && console.log("   ML: "+JSON.stringify(multilinks))
      debug && console.log("   other SL: "+JSON.stringify(singlelinks))
      // there are similarly connected links except the link itself
      if (similarLinks.length > 0) {
        if (source === target) return

        // if its multilink, check that there exists singlelinks which it replaced
        if (isMultilink(link)) {
          // the similar link is ml, so there should be at least one other singlelink
          if (!isMultilinkArchived(link) && singlelinks.length === 0)
            throw Error('Multilink '+JSON.stringify(link)+' was found without singlelinks.')
        } else {
          // archived multilink are not in the array
          if (multilinks.length !== 1)
            throw Error("Nodes ["+source+","+target+"] have similar links "+JSON.stringify(similarLinks)+" with wrong multilinks set")
          const ml = multilinks[0]

          if (link.vis.inMultilink !== ml.id)
            throw Error("Link "+link.id+" is supposed to be in multilink "+JSON.stringify(ml.id))
        }
      } else {
        if (isMultilink(link) && !isMultilinkArchived(link))
          throw Error("Link "+link.id+" is not supposed to be multilink.")
      }
    })
  })
  console.log("Dataset validated.")
}

export const actions = {
  fetchData({ commit }) {
    validateDataset(mockData)
    commit('INIT', mockData)
    // TODO: api call
  },

  import({ commit }, data) {
    validateDataset(data)
    commit('INIT', data)
  },

  createNode({ commit }, node) {
    commit('ADD_NODE', node)
    validateDataset(state)
  },

  updateNodes({ commit }, nodesDiff) {
    commit('UPDATE_NODES', nodesDiff)
  },

  removeNode({ commit }, nodeId) {
    commit('REMOVE_NODES', { nodeIds: [nodeId] })
    validateDataset(state)
  },

  /**
   * @param {Object} _
   * @param {Object} pl
   * @param {number[]} pl.nodes to group
   * @param {string} pl.label to group
   */
  groupNodes({ commit, dispatch }, pl) {
    let groupNode = {
      type: 'group',
      name: `Group of '${pl.label}'`,
      label: capitalize(pl.label)
    }

    commit('AGGREGATE_NODES', {
      action: 'group',
      nodeIds: pl.nodes,
      targetGroupNode: groupNode
    })
    validateDataset(state)
    dispatch('interaction/respondToRemovedNodes', pl.nodes, { root: true })
  },

  /**
   * @param {Object} _
   * @param {Object} pl
   * @param {number[]} pl.nodes to group
   * @param {string} pl.label to group
   */
  aliasNodes({ commit, dispatch }, pl) {
    let groupNode = {
      name: `Alias of '${pl.label}'`,
      type: 'alias',
      label: pl.label
    }

    commit('AGGREGATE_NODES', {
      action: 'alias',
      nodeIds: pl.nodes,
      targetGroupNode: groupNode
    })
    validateDataset(state)
    dispatch('interaction/respondToRemovedNodes', pl.nodes, { root: true })
  },

  /**
   * @param {object} vuexContext
   * @param {object} payload
   * @param {number} payload.representativeNodeId the "bundle" node
   * @param {number[]} payload.nodeIds to bundle inside the representative node
   */
  bundleNodes({ commit, getters, dispatch }, payload) {
    console.log('bundleNodes: ' + JSON.stringify(payload))
    commit('AGGREGATE_NODES', {
      action: 'bundle',
      nodeIds: payload.nodeIds,
      targetGroupNode: getters.getNodeById(payload.representativeNodeId)
    })
    validateDataset(state)
    dispatch('interaction/respondToRemovedNodes', payload.nodeIds, { root: true })
  },

  ungroupNode({ commit, getters, dispatch }, nodeId) {
    // unbundle first if bundle
    if (getters.getNodeBundledNodeCount(nodeId) > 0) {
      commit('DISAGGREGATE_NODE', {
        revertAction: 'bundle',
        nodeId: nodeId
      })
    }

    commit('DISAGGREGATE_NODE', {
      revertAction: 'group',
      nodeId: nodeId
    })
    validateDataset(state)
    dispatch('interaction/respondToRemovedNodes', [nodeId], { root: true })
  },

  unbundleNode({ commit }, nodeId) {
    commit('DISAGGREGATE_NODE', {
      revertAction: 'bundle',
      nodeId: nodeId
    })
    validateDataset(state)
  }
}

export const getters = {
  getState: state => state,
  getNodes: state => state.nodes,
  getLinks: state => state.links,

  getVisibleNodes: state => filterVisibleNodes(state.nodes),
  getVisibleLinks: state => filterVisibleLinks(state.links),

  getNodeById: state => id => findNodeById(state, id),
  getNodesByIds: state => ids => state.nodes.filter(n => ids.includes(n.id)),
  getLinkById: state => id => state.links.find(l => l.id === id),
  getMultilinkLinks: state => multilinkId => state.links.filter(l => isInMultilinkOfId(l, multilinkId)),

  getLinkIdsConnectedToNode: state => id => findConnectedLinks(state.links, id),
  getNodeBundledNodeCount: state => id => {
    const n = findNodeById(state, id)
    if (!n) return 0
    return n.vis && n.vis.bundledNodes !== undefined ? n.vis.bundledNodes.length : 0
  },
  getNodesInAggregation: state => parentId => state.nodes.filter(n => isReplacedBy(n, parentId)),
  getNodesInBundle: state => bundleId => state.nodes.filter(n => isBundledIn(n, bundleId))
}
