import Vue from 'vue'
import * as _ from 'lodash'
import { v4 as generateUuid } from 'uuid'

/**
 * Type definitions for node and link data structures
 *
 * @typedef { import('./typedefs').Node } Node
 * @typedef { import('./typedefs').NodeVis } NodeVis
 * @typedef { import('./typedefs').Link } Link
 * @typedef { import('./typedefs').LinkVis } LinkVis
 */

/**
 * Check if given slot is defined and probably contains dataset
 * @param {Object} state vuex
 * @param {number} slotId to ask for
 * @return {boolean} true if initialized
 */
const isSlotInitialized = (state, slotId) =>
  state[slotId] !== undefined && state[slotId].hasOwnProperty('nodes')

/**
 * Asserts that slot is initialized
 * @param {Object} state
 * @param {number} slotId
 *
 * @throws {Error} if slot is not initialized
 */
export function assertSlot(state, slotId) {
  if (!isSlotInitialized(state, slotId))
    throw Error(`Invalid slot chosen: dataset with id '${slotId}' is not initialized`)
}

/**
 * Get node from state
 * @param {Node[]} nodes
 * @param {string} uuid of node
 * @returns {Node} Node instance, or undefined if not found
 */
export const findNodeByUuid = (nodes, uuid) => nodes.find(n => n.uuid === uuid)

/**
 * Check if visible: not in multilink, not an archived multilink, newTarget != newSource
 * @param {Link} l
 *
 * @returns {boolean}
 */
export function isLinkVisible(l) {
  if (isInMultilink(l)) return false
  /**
   * this will be always true, we have to implement other way of checking
   * variables, consider making sources pre defined so we don't need to check for undefined
   */
  if (l.properties) {
    if (l.properties.get('isMultilink') && l.properties.get('isArchived')) return false
    if (
      l.properties.get('newSource') !== undefined &&
      l.properties.get('newSource') === l.properties.get('newTarget')
    )
      return false
  } else {
    if (l.source === l.target) return false
  }
  return true
}

/**
 * @see {@link isLinkVisible} for visiblity definition
 * @param {Link[]} links
 */
export const filterVisibleLinks = links => links.filter(l => isLinkVisible(l))

/**
 * Check if node is not replacedBy or bundledIn
 * @param {Node} node
 *
 * @returns {boolean}
 */
export const isNodeVisible = node =>
  node.properties ||
  (node.properties.get('replacedBy') === undefined &&
    node.properties.get('bundledIn') === undefined)

/**
 * @see {@link isNodeVisible} for visiblity definition
 * @param {Node[]} nodes
 */
export const filterVisibleNodes = nodes => nodes.filter(n => isNodeVisible(n))

export const isNodeAggregated = node => ['group', 'alias'].includes(node.type)

/**
 * Return a sorted array of link ids connected to given node
 * @param {Link[]} links
 * @param {number} nodeId
 * @returns {number[]} link IDs connected to node
 */
export function findConnectedLinks(links, nodeId) {
  const connected = links.filter(
    l => !isInMultilink(l) && (getLinkSource(l) === nodeId || getLinkTarget(l) === nodeId)
  )
  const sorted = _.sortBy(connected, [l => !isMultilink(l), l => l.uuid]) // multilinks first, then ids
  return sorted.map(l => l.uuid)
}

/**
 * Return link.vis.newSource uif set, otherwise just link.source
 * @param {Link} link
 *
 * @return {number} ID of source
 */
export const getLinkSource = link =>
  hasVisAttr(link, 'newSource') ? link.properties.get('newSource') : link.source

/**
 * Return link.vis.newTarget if set, otherwise just link.target
 * @param {Link} link
 *
 * @return {number} ID of target
 */
export const getLinkTarget = link =>
  hasVisAttr(link, 'newTarget') ? link.properties.get('newTarget') : link.target

function isLinkSimilar(link, linkToMatch) {
  const source = getLinkSource(linkToMatch)
  const target = getLinkTarget(linkToMatch)

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

  return (
    (source === link.source && target === link.target) ||
    (target === link.source && source === 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 in arr 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} groupId in order to set new multilink's source always to the group node
 *
 * @returns {Link} multilink, if new one was created during insertion; otherwise null
 */
export function insertLinkWithMultilinkUpdate(link, arr, groupId) {
  if (isMultilink(link))
    throw Error('Bad usage of insertLinkWithMultilinkUpdate. Inserting a multilink is not allowed.')

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

  const source = getLinkSource(link)
  const target = getLinkTarget(link)

  if (isMultilink(similarLink)) {
    similarLink.properties.delete('isArchived')
    setVisAttr(link, 'inMultilink', similarLink.uuid)
    arr.push(_.cloneDeep(link))
    updateMultilink(arr, similarLink)
  } else if (isInMultilink(similarLink)) {
    const ml = arr.find(l => l.uuid === similarLink.properties.get('inMultilink'))
    if (!ml) throw Error('Unexpected.')
    setVisAttr(link, 'inMultilink', similarLink.properties.get('inMultilink'))
    arr.push(_.cloneDeep(link))
    updateMultilink(arr, ml)
  } else {
    const ml = createMultilink(
      groupId === source ? source : target, // source node of multilink should always be the group
      groupId === source ? target : source
    )
    const merging = [similarLink, link]
    merging.forEach(l => setVisAttr(l, 'inMultilink', ml.uuid))
    arr.push(_.cloneDeep(link))
    updateMultilink(arr, ml)
    arr.push(ml)
    if (ml.source === ml.target)
      throw Error(
        `insertLinkWithMultilinkUpdate assertion failed: multilink ${
          ml.uuid
        } has source equal to target: ${JSON.stringify(ml)}`
      )
    return ml
  }
  return null
}

/**
 * Update the aggregated direction of multilink and its size from links in given array
 * @param {Link[]} arr const of links with links belonging to given multilink
 * @param {Link} ml multilink to update
 */
export function updateMultilink(arr, ml) {
  Vue.set(ml, 'oriented', { nonDirectional: false, sourceToTarget: false, targetToSource: false })
  setVisAttr(ml, 'multilinkSize', 0)
  removeVisAttr(ml, 'isTransferred')

  arr
    .filter(l => l.properties && l.properties.get('inMultilink') === ml.uuid)
    .map(l => {
      setVisAttr(ml, 'multilinkSize', ml.properties.get('multilinkSize') + 1)
      if (hasVisAttr(l, 'isTransferred')) setVisAttr(ml, 'isTransferred', true)

      if (hasVisAttr(l, 'isUserDefined')) setVisAttr(ml, 'aggregatesUserDefined', true)

      if (!ml.oriented) {
        ml.oriented.nonDirectional = true
      } else {
        if (l.properties.get('newSource') === ml.source) ml.oriented.sourceToTarget = true
        else ml.oriented.targetToSource = true
      }
    })
}

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

/**
 * Check if node is bundled, behind presentational node
 * @param {Node} node to check
 * @param {String} uuid of presentational node
 */
export const isBundledIn = (node, uuid) =>
  !node.properties ? false : node.properties.get('bundledIn') === uuid

/**
 * Check if node contains non-empty array of bundled nodes
 * @param {Node} node
 */
export const isNodeBundle = node =>
  node.properties &&
  node.properties.get('bundledNodes') !== undefined &&
  Array.isArray(node.properties.get('bundledNodes')) &&
  node.properties.get('bundledNodes').length > 0

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

/**
 * Check if link is in specific multilink
 * @param {Link} link
 * @param {number} mlId
 *
 * @return {boolean}
 */
export function isInMultilinkOfId(link, mlId) {
  if (!link.properties) return false
  return link.properties.get('inMultilink') === mlId
}

/**
 * Check for vis.isMultilink attribute
 * @param {Link} link
 *
 * @return {boolean}
 */
export const isMultilink = link => link?.properties?.get('isMultilink') === true

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

/**
 * Push new element to state
 * @param {Object} arr writes to state.nodes
 * @param {Node} el will be assigned type and new ID
 *
 * @return {String} UUID assigned to the new node
 */
export function pushNewArrElement(arr, el) {
  el.uuid = generateUuid()
  arr.push(_.cloneDeep(el))
  return el.uuid
}

/**
 * Check if given object has vis.attr set
 * @param {Node|Link} el object
 * @param {string} attr vis object attribute name
 */
export function hasVisAttr(el, attr) {
  if (!el.properties) return false
  return el.properties.has(attr)
}

/**
 * Set vis attribute to object
 * @param {Node|Link} el
 * @param {string} attr vis attribute to set
 * @param {*} val value to set
 */
export function setVisAttr(el, attr, val) {
  let newProperties = el.properties || new Map()
  newProperties.set(attr, val)
  Vue.set(el, 'properties', newProperties)
}

/**
 * Set vis attributes to object, merging vis objects
 * @param {Node|Link} el
 * @param {NodeVis|LinkVis} propertiesObject object to merge
 */
export function setVisAttrs(el, propertiesObject) {
  let newProperties = el.properties ? el.properties : new Map()
  Object.keys(propertiesObject).forEach(key => {
    newProperties.set(key, propertiesObject[key])
  })
  Vue.set(el, 'properties', newProperties)
}

/**
 * Merge visAttrs with each element given in arr
 * @param {Node[]|Link[]} arr
 * @param {LinkVis|NodeVis} newProperties
 */
export function arraySetVisAttrs(arr, newProperties) {
  arr.forEach(el => setVisAttrs(el, newProperties))
}

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

/**
 * Get new multilink instance
 * @param {number} source
 * @param {number} target
 */
export function createMultilink(source, target) {
  return {
    uuid: generateUuid(),
    source: source,
    target: target,
    properties: new Map([['isMultilink', true]])
  }
}

/**
 * Check if link is transferred (connected to nodes inside bundle or to a group)
 * @param {Link} link to check
 * @param {Node[]} nodes all nodes for checking connetions
 * @param {Link[]} links to check singlelinks when multilink
 * @return {boolean}
 */
export function isLinkTransferred(link, nodes, links) {
  if (link.properties && link.properties.get('forceTransferred')) return true

  const sourceNode = findNodeByUuid(nodes, getLinkSource(link))
  const targetNode = findNodeByUuid(nodes, getLinkTarget(link))

  switch (sourceNode.type) {
    case 'regular':
    case 'alias':
      switch (targetNode.type) {
        case 'regular':
        case 'alias':
          if (isMultilink(link)) {
            const singlelinks = links.filter(l => isInMultilinkOfId(l, link.uuid))
            for (const l of singlelinks) {
              if (l.properties && l.properties.get('forceTransferred')) return true
              if (
                isBundledIn(findNodeByUuid(nodes, l.source), sourceNode.uuid) ||
                isBundledIn(findNodeByUuid(nodes, l.target), targetNode.uuid)
              )
                return true
            }
          } else {
            // check against original connection
            if (
              isBundledIn(findNodeByUuid(nodes, link.source), sourceNode.uuid) ||
              isBundledIn(findNodeByUuid(nodes, link.target), targetNode.uuid)
            )
              return true
          }
          return false
        case 'group':
          return true
        default:
          throw Error(
            `Cannot compute isTransferred property: unknown link type of '${targetNode.type}'`
          )
      }
    case 'group':
      switch (targetNode.type) {
        case 'regular':
        case 'alias':
        case 'group':
          return true
        default:
          throw Error(
            `Cannot compute isTransferred property: unknown link type of '${targetNode.type}'`
          )
      }
    default:
      throw Error(
        `Cannot compute isTransferred property: unknown link type of '${sourceNode.type}'`
      )
  }
}

/**
 * Convert Node array to Map. Use to speed up aggregation lookups
 * @param {Node[]} nodes
 * @return {Map} of nodes for aggregation lookup
 */
export function makeNodesMap(nodes) {
  return new Map(
    nodes.map(n => {
      if (n.properties) {
        if (n.properties.get('replacedBy') !== undefined)
          return [n.uuid, n.properties.get('replacedBy')]
        if (n.properties.get('bundledIn') !== undefined)
          return [n.uuid, n.properties.get('bundledIn')]
      }
      return [n.uuid, undefined]
    })
  )
}

/**
 * Find last node which replaced given node
 * Nodes map is in form [{id: replacedById | undefined },..]
 * @param {Map} nodesMap @see {@link makeNodesMap} for definition
 * @param {number} nodeId on which to start lookup
 *
 * @returns {number} id of node which replaced given node, id of self otherwise
 */
export 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)
}

/**
 * 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.uuid === undefined) throw Error('Node must have ID assigned: ' + JSON.stringify(n))
    if (nodes.has(n.uuid)) throw Error('ID of node is not unique: ' + JSON.stringify(n))
    nodes.set(n.uuid, { links: [] })
  })
  debug && console.log('Nodes map: ' + JSON.stringify(Array.from(nodes)))

  const links = new Map()
  data.links.forEach(l => {
    if (l.uuid === undefined) throw Error('Link must have ID assigned: ' + JSON.stringify(l))
    if (links.has(l.uuid)) throw Error('ID of link is not unique: ' + JSON.stringify(l))
    links.set(l.uuid, 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')
    if (l.source === l.target)
      throw Error(`Link [id ${l.uuid}] has source [${l.source}] equal to target [${l.target}], 
        however, identity relation is not allowed.`)

    // 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.uuid !== link.uuid)
      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.properties.get('inMultilink') !== ml.uuid)
            throw Error(
              'Link ' + link.uuid + ' is supposed to be in multilink ' + JSON.stringify(ml.uuid)
            )
        }
      } else {
        if (isMultilink(link) && !isMultilinkArchived(link))
          throw Error('Link ' + link.uuid + ' is not supposed to be multilink.')
      }
    })
  })
  console.log('Dataset validated.')
}

/**
 * Return the number of nodes in the bundle or 0
 * @param {Node} node
 * @return {number} bundle size or 0 if not a bundle
 */
export function getBundleSize(node) {
  if (!node) throw Error('Null node')
  return node.properties.has('bundledNodes') ? node.properties.get('bundledNodes').length : 0
}

/**
 * Check if two number arrays contain the same elements
 * @param {number[]} a
 * @param {number[]} b
 * @returns {boolean}
 */
export const arraysEqual = (a, b) => _.xor(a, b).length === 0

/**
 * @brief Convert overlapping singlelinks in array to multilinks for visualization
 * First check that links do not contain multilinks or already aggregated singlelinks
 * @param {Link[]} links
 * @returns {Link[]} converted links or the same links when multilinks are detected
 */
export function aggregateSinglelinksIfNeeded(links) {
  if (!links || links.length < 1) throw Error('Invalid input')

  for (const link of links) {
    link.source = link.left.uuid
    link.target = link.right.uuid

    if (isMultilink(link) || isInMultilink(link)) return links
  }

  const newLinks = []
  for (const link of links) {
    insertLinkWithMultilinkUpdate(link, newLinks, link.source)
  }
  return newLinks
}

/**
 * Generate UUID on every object of array, if none
 * @param {Object[]} arr of objects
 * @returns {Object} arr from input
 */
export function appendUUIDIfNeeded(arr) {
  for (const el of arr) {
    if (!el.hasOwnProperty('uuid')) el.uuid = generateUuid()
  }
  return arr
}

/**
 * Aggregates array item properties to Map
 * @param arr
 * @returns {*}
 */
export function aggregatePropertiesToMap(arr) {
  return arr.map(item => {
    let properties = new Map()
    item.properties.forEach(property => {
      // If first letter of key is #, then parse as array
      if (property.propertyKey[0] === '#') {
        properties.set(property.propertyKey.substring(1), property.valueString.split(','))
      }

      if (property.hasOwnProperty('valueString')) {
        properties.set(property.propertyKey, property.valueString)
      } else if (property.hasOwnProperty('valueDouble')) {
        properties.set(property.propertyKey, property.valueDouble)
      } else if (property.hasOwnProperty('valueLong')) {
        properties.set(property.propertyKey, property.valueLong)
      }
    })
    item.properties = properties
    return item
  })
}
