// Styles
import './VTreeview.sass'

// Types
import { VNode, VNodeChildrenArrayContents, PropType } from 'vue'
import { PropValidator } from 'vue/types/options'
import { TreeviewItemFunction } from 'vuetify/types'

// Components
import VTreeviewNode, { VTreeviewNodeProps } from './VTreeviewNode'

// Mixins
import Themeable from '../../mixins/themeable'
import { provide as RegistrableProvide } from '../../mixins/registrable'

// Utils
import {
  arrayDiff,
  deepEqual,
  getObjectValueByPath,
} from '../../util/helpers'
import mixins from '../../util/mixins'
import { consoleWarn } from '../../util/console'
import {
  filterTreeItems,
  filterTreeItem,
} from './util/filterTreeItems'

type VTreeviewNodeInstance = InstanceType<typeof VTreeviewNode>

type NodeCache = Set<string | number>
type NodeArray = (string | number)[]

type NodeState = {
  parent: number | string | null
  children: (number | string)[]
  vnode: VTreeviewNodeInstance | null
  isActive: boolean
  isSelected: boolean
  isIndeterminate: boolean
  isOpen: boolean
  item: any
}

export default mixins(
  RegistrableProvide('treeview'),
  Themeable
  /* @vue/component */
).extend({
  name: 'v-treeview',

  provide (): object {
    return { treeview: this }
  },

  props: {
    active: {
      type: Array,
      default: () => ([]),
    } as PropValidator<NodeArray>,
    dense: Boolean,
    filter: Function as PropType<TreeviewItemFunction>,
    hoverable: Boolean,
    items: {
      type: Array,
      default: () => ([]),
    } as PropValidator<any[]>,
    multipleActive: Boolean,
    open: {
      type: Array,
      default: () => ([]),
    } as PropValidator<NodeArray>,
    openAll: Boolean,
    returnObject: {
      type: Boolean,
      default: false, // TODO: Should be true in next major
    },
    search: String,
    value: {
      type: Array,
      default: () => ([]),
    } as PropValidator<NodeArray>,
    ...VTreeviewNodeProps,
  },

  data: () => ({
    level: -1,
    activeCache: new Set() as NodeCache,
    nodes: {} as Record<string | number, NodeState>,
    openCache: new Set() as NodeCache,
    selectedCache: new Set() as NodeCache,
  }),

  computed: {
    excludedItems (): Set<string | number> {
      const excluded = new Set<string|number>()

      if (!this.search) return excluded

      for (let i = 0; i < this.items.length; i++) {
        filterTreeItems(
          this.filter || filterTreeItem,
          this.items[i],
          this.search,
          this.itemKey,
          this.itemText,
          this.itemChildren,
          excluded
        )
      }

      return excluded
    },
  },

  watch: {
    items: {
      handler () {
        const oldKeys = Object.keys(this.nodes).map(k => getObjectValueByPath(this.nodes[k].item, this.itemKey))
        const newKeys = this.getKeys(this.items)
        const diff = arrayDiff(newKeys, oldKeys)

        // We only want to do stuff if items have changed
        if (!diff.length && newKeys.length < oldKeys.length) return

        // If nodes are removed we need to clear them from this.nodes
        diff.forEach(k => delete this.nodes[k])

        const oldSelectedCache = [...this.selectedCache]
        this.selectedCache = new Set()
        this.activeCache = new Set()
        this.openCache = new Set()
        this.buildTree(this.items)

        // Only emit selected if selection has changed
        // as a result of items changing. This fixes a
        // potential double emit when selecting a node
        // with dynamic children
        if (!deepEqual(oldSelectedCache, [...this.selectedCache])) this.emitSelected()
      },
      deep: true,
    },
    active (value: (string | number | any)[]) {
      this.handleNodeCacheWatcher(value, this.activeCache, this.updateActive, this.emitActive)
    },
    value (value: (string | number | any)[]) {
      this.handleNodeCacheWatcher(value, this.selectedCache, this.updateSelected, this.emitSelected)
    },
    open (value: (string | number | any)[]) {
      this.handleNodeCacheWatcher(value, this.openCache, this.updateOpen, this.emitOpen)
    },
  },

  created () {
    const getValue = (key: string | number) => this.returnObject ? getObjectValueByPath(key, this.itemKey) : key

    this.buildTree(this.items)

    for (const value of this.value.map(getValue)) {
      this.updateSelected(value, true, true)
    }

    for (const active of this.active.map(getValue)) {
      this.updateActive(active, true)
    }
  },

  mounted () {
    // Save the developer from themselves
    if (this.$slots.prepend || this.$slots.append) {
      consoleWarn('The prepend and append slots require a slot-scope attribute', this)
    }

    if (this.openAll) {
      this.updateAll(true)
    } else {
      this.open.forEach(key => this.updateOpen(this.returnObject ? getObjectValueByPath(key, this.itemKey) : key, true))
      this.emitOpen()
    }
  },

  methods: {
    /** @public */
    updateAll (value: boolean) {
      Object.keys(this.nodes).forEach(key => this.updateOpen(getObjectValueByPath(this.nodes[key].item, this.itemKey), value))
      this.emitOpen()
    },
    getKeys (items: any[], keys: any[] = []) {
      for (let i = 0; i < items.length; i++) {
        const key = getObjectValueByPath(items[i], this.itemKey)
        keys.push(key)
        const children = getObjectValueByPath(items[i], this.itemChildren)
        if (children) {
          keys.push(...this.getKeys(children))
        }
      }

      return keys
    },
    buildTree (items: any[], parent: (string | number | null) = null) {
      for (let i = 0; i < items.length; i++) {
        const item = items[i]
        const key = getObjectValueByPath(item, this.itemKey)
        const children = getObjectValueByPath(item, this.itemChildren, [])
        const oldNode = this.nodes.hasOwnProperty(key) ? this.nodes[key] : {
          isSelected: false, isIndeterminate: false, isActive: false, isOpen: false, vnode: null,
        } as NodeState

        const node: any = {
          vnode: oldNode.vnode,
          parent,
          children: children.map((c: any) => getObjectValueByPath(c, this.itemKey)),
          item,
        }

        this.buildTree(children, key)

        // This fixed bug with dynamic children resetting selected parent state
        if (!this.nodes.hasOwnProperty(key) && parent !== null && this.nodes.hasOwnProperty(parent)) {
          node.isSelected = this.nodes[parent].isSelected
        } else {
          node.isSelected = oldNode.isSelected
          node.isIndeterminate = oldNode.isIndeterminate
        }

        node.isActive = oldNode.isActive
        node.isOpen = oldNode.isOpen

        this.nodes[key] = node

        if (children.length) {
          const { isSelected, isIndeterminate } = this.calculateState(key, this.nodes)

          node.isSelected = isSelected
          node.isIndeterminate = isIndeterminate
        }

        // Don't forget to rebuild cache
        if (this.nodes[key].isSelected && (this.selectionType === 'independent' || node.children.length === 0)) this.selectedCache.add(key)
        if (this.nodes[key].isActive) this.activeCache.add(key)
        if (this.nodes[key].isOpen) this.openCache.add(key)

        this.updateVnodeState(key)
      }
    },
    calculateState (node: string | number, state: Record<string | number, NodeState>) {
      const children = state[node].children
      const counts = children.reduce((counts: number[], child: string | number) => {
        counts[0] += +Boolean(state[child].isSelected)
        counts[1] += +Boolean(state[child].isIndeterminate)

        return counts
      }, [0, 0])

      const isSelected = !!children.length && counts[0] === children.length
      const isIndeterminate = !isSelected && (counts[0] > 0 || counts[1] > 0)

      return {
        isSelected,
        isIndeterminate,
      }
    },
    emitOpen () {
      this.emitNodeCache('update:open', this.openCache)
    },
    emitSelected () {
      this.emitNodeCache('input', this.selectedCache)
    },
    emitActive () {
      this.emitNodeCache('update:active', this.activeCache)
    },
    emitNodeCache (event: string, cache: NodeCache) {
      this.$emit(event, this.returnObject ? [...cache].map(key => this.nodes[key].item) : [...cache])
    },
    handleNodeCacheWatcher (value: any[], cache: NodeCache, updateFn: Function, emitFn: Function) {
      value = this.returnObject ? value.map(v => getObjectValueByPath(v, this.itemKey)) : value
      const old = [...cache]
      if (deepEqual(old, value)) return

      old.forEach(key => updateFn(key, false))
      value.forEach(key => updateFn(key, true))

      emitFn()
    },
    getDescendants (key: string | number, descendants: NodeArray = []) {
      const children = this.nodes[key].children

      descendants.push(...children)

      for (let i = 0; i < children.length; i++) {
        descendants = this.getDescendants(children[i], descendants)
      }

      return descendants
    },
    getParents (key: string | number) {
      let parent = this.nodes[key].parent

      const parents = []
      while (parent !== null) {
        parents.push(parent)
        parent = this.nodes[parent].parent
      }

      return parents
    },
    register (node: VTreeviewNodeInstance) {
      const key = getObjectValueByPath(node.item, this.itemKey)
      this.nodes[key].vnode = node

      this.updateVnodeState(key)
    },
    unregister (node: VTreeviewNodeInstance) {
      const key = getObjectValueByPath(node.item, this.itemKey)
      if (this.nodes[key]) this.nodes[key].vnode = null
    },
    isParent (key: string | number) {
      return this.nodes[key].children && this.nodes[key].children.length
    },
    updateActive (key: string | number, isActive: boolean) {
      if (!this.nodes.hasOwnProperty(key)) return

      if (!this.multipleActive) {
        this.activeCache.forEach(active => {
          this.nodes[active].isActive = false
          this.updateVnodeState(active)
          this.activeCache.delete(active)
        })
      }

      const node = this.nodes[key]
      if (!node) return

      if (isActive) this.activeCache.add(key)
      else this.activeCache.delete(key)

      node.isActive = isActive

      this.updateVnodeState(key)
    },
    updateSelected (key: string | number, isSelected: boolean, isForced = false) {
      if (!this.nodes.hasOwnProperty(key)) return

      const changed = new Map()

      if (this.selectionType !== 'independent') {
        for (const descendant of this.getDescendants(key)) {
          if (!getObjectValueByPath(this.nodes[descendant].item, this.itemDisabled) || isForced) {
            this.nodes[descendant].isSelected = isSelected
            this.nodes[descendant].isIndeterminate = false
            changed.set(descendant, isSelected)
          }
        }

        const calculated = this.calculateState(key, this.nodes)
        this.nodes[key].isSelected = isSelected
        this.nodes[key].isIndeterminate = calculated.isIndeterminate
        changed.set(key, isSelected)

        for (const parent of this.getParents(key)) {
          const calculated = this.calculateState(parent, this.nodes)
          this.nodes[parent].isSelected = calculated.isSelected
          this.nodes[parent].isIndeterminate = calculated.isIndeterminate
          changed.set(parent, calculated.isSelected)
        }
      } else {
        this.nodes[key].isSelected = isSelected
        this.nodes[key].isIndeterminate = false
        changed.set(key, isSelected)
      }

      for (const [key, value] of changed.entries()) {
        this.updateVnodeState(key)

        if (this.selectionType === 'leaf' && this.isParent(key)) continue

        value === true ? this.selectedCache.add(key) : this.selectedCache.delete(key)
      }
    },
    updateOpen (key: string | number, isOpen: boolean) {
      if (!this.nodes.hasOwnProperty(key)) return

      const node = this.nodes[key]
      const children = getObjectValueByPath(node.item, this.itemChildren)

      if (children && !children.length && node.vnode && !node.vnode.hasLoaded) {
        node.vnode.checkChildren().then(() => this.updateOpen(key, isOpen))
      } else if (children && children.length) {
        node.isOpen = isOpen

        node.isOpen ? this.openCache.add(key) : this.openCache.delete(key)

        this.updateVnodeState(key)
      }
    },
    updateVnodeState (key: string | number) {
      const node = this.nodes[key]

      if (node && node.vnode) {
        node.vnode.isSelected = node.isSelected
        node.vnode.isIndeterminate = node.isIndeterminate
        node.vnode.isActive = node.isActive
        node.vnode.isOpen = node.isOpen
      }
    },
    isExcluded (key: string | number) {
      return !!this.search && this.excludedItems.has(key)
    },
  },

  render (h): VNode {
    const children: VNodeChildrenArrayContents = this.items.length
      ? this.items.filter(item => {
        return !this.isExcluded(getObjectValueByPath(item, this.itemKey))
      }).map(item => {
        const genChild = VTreeviewNode.options.methods.genChild.bind(this)

        return genChild(item, getObjectValueByPath(item, this.itemDisabled))
      })
      /* istanbul ignore next */
      : this.$slots.default! // TODO: remove type annotation with TS 3.2

    return h('div', {
      staticClass: 'v-treeview',
      class: {
        'v-treeview--hoverable': this.hoverable,
        'v-treeview--dense': this.dense,
        ...this.themeClasses,
      },
    }, children)
  },
})
