<template>
  <div :class="['plotly_editor', { with_controls: showViewSettings }]">
    <GraphEditorControls v-show="showViewSettings">
      <PanelMenuWrapper>
        <Panel group="Structure" name="Graph">
          <Fold name="Graph">
            <Field>
              Map your result set records to node and edge properties required
              to build a graph. Learn more about result set requirements in the
              <a href="https://sqliteviz.com/docs/graph/" target="_blank">
                documentation</a
              >.
            </Field>
            <Field ref="objectTypeField" label="Object type">
              <Dropdown
                :options="keysOptions"
                :value="settings.structure.objectType"
                className="test_object_type_select"
                @change="updateStructure('objectType', $event)"
              />
              <Field>
                A field indicating if the record is node (value&nbsp;0) or edge
                (value&nbsp;1).
              </Field>
            </Field>

            <Field label="Node Id">
              <Dropdown
                :options="keysOptions"
                :value="settings.structure.nodeId"
                className="test_node_id_select"
                @change="updateStructure('nodeId', $event)"
              />
              <Field> A field keeping unique node identifier. </Field>
            </Field>

            <Field label="Edge source">
              <Dropdown
                :options="keysOptions"
                :value="settings.structure.edgeSource"
                className="test_edge_source_select"
                @change="updateStructure('edgeSource', $event)"
              />
              <Field>
                A field keeping a node identifier where the edge starts.
              </Field>
            </Field>

            <Field label="Edge target">
              <Dropdown
                :options="keysOptions"
                :value="settings.structure.edgeTarget"
                className="test_edge_target_select"
                @change="updateStructure('edgeTarget', $event)"
              />
              <Field>
                A field keeping a node identifier where the edge ends.
              </Field>
            </Field>
          </Fold>
        </Panel>
        <Panel group="Style" name="General">
          <Fold name="General">
            <Field label="Background color">
              <ColorPicker
                :selectedColor="settings.style.backgroundColor"
                @color-change="settings.style.backgroundColor = $event"
              />
            </Field>

            <Field label="Highlight mode">
              <Dropdown
                :options="highlightModeOptions"
                :value="settings.style.highlightMode"
                className="test_highlight_mode_select"
                @change="updateHighlightNodeMode"
              />
            </Field>
          </Fold>
        </Panel>
        <Panel group="Style" name="Nodes">
          <Fold name="Nodes">
            <Field label="Label">
              <Dropdown
                :options="keysOptions"
                :value="settings.style.nodes.label.source"
                className="test_label_select"
                @change="updateNodes('label.source', $event)"
              />
            </Field>

            <Field label="Label color">
              <ColorPicker
                :selectedColor="settings.style.nodes.label.color"
                @color-change="updateNodes('label.color', $event)"
              />
            </Field>

            <NodeSizeSettings
              v-model="settings.style.nodes.size"
              :keyOptions="keysOptions"
              @update:model-value="updateNodes('size', $event)"
            />
            <NodeColorSettings
              v-model="settings.style.nodes.color"
              :keyOptions="keysOptions"
              @update:model-value="updateNodes('color', $event)"
            />
          </Fold>
        </Panel>

        <Panel group="Style" name="Edges">
          <Fold name="Edges">
            <Field
              label="Direction"
              fieldContainerClassName="test_edge_direction"
            >
              <RadioBlocks
                :options="visibilityOptions"
                :activeOption="settings.style.edges.showDirection"
                @option-change="updateEdges('showDirection', $event)"
              />
            </Field>

            <Field label="Label">
              <Dropdown
                :options="keysOptions"
                :value="settings.style.edges.label.source"
                className="test_edge_label_select"
                @change="updateEdges('label.source', $event)"
              />
            </Field>

            <Field label="Label color">
              <ColorPicker
                :selectedColor="settings.style.edges.label.color"
                @color-change="updateEdges('label.color', $event)"
              />
            </Field>

            <EdgeSizeSettings
              v-model="settings.style.edges.size"
              :keyOptions="keysOptions"
              @update:model-value="updateEdges('size', $event)"
            />

            <EdgeColorSettings
              v-model="settings.style.edges.color"
              :keyOptions="keysOptions"
              @update:model-value="updateEdges('color', $event)"
            />
          </Fold>
        </Panel>
        <Panel group="Style" name="Layout">
          <Fold name="Layout">
            <Field label="Algorithm">
              <Dropdown
                :options="layoutOptions"
                :value="settings.layout.type"
                :clearable="false"
                className="test_layout_algorithm_select"
                @change="updateLayout($event)"
              />
            </Field>
            <component
              :is="layoutSettingsComponentMap[settings.layout.type]"
              v-if="settings.layout.type !== 'circular'"
              v-model="settings.layout.options"
              :keyOptions="keysOptions"
              @update:model-value="updateLayout(settings.layout.type)"
            />
          </Fold>
          <template v-if="settings.layout.type === 'forceAtlas2'">
            <Fold name="Seed layout">
              <Field>
                If you already built a graph using another layout, the initial
                algorithm doesn't apply unless you restart it.
              </Field>
              <ForceAtlasSeedLayoutSettings
                v-model="settings.layout.options"
                :keyOptions="keysOptions"
                @update:model-value="updateLayout(settings.layout.type)"
              />
            </Fold>
            <Fold name="Advanced layout settings">
              <AdvancedForceAtlasLayoutSettings
                v-model="settings.layout.options"
                :keyOptions="keysOptions"
                @update:model-value="updateLayout(settings.layout.type)"
              />
            </Fold>
            <div class="force-atlas-buttons">
              <Button
                variant="secondary"
                class="test_fa2_reset"
                title="Set the settings to default or previously saved ones."
                @click="resetFA2LayoutSettings"
              >
                Reset
              </Button>
              <Button
                variant="primary"
                class="test_fa2_toggle"
                @click="toggleFA2Layout"
              >
                <template #node:icon>
                  <div>
                    <RunIcon v-if="!fa2Running" />
                    <PauseIcon v-else />
                  </div>
                </template>
                {{ fa2Running ? 'Pause' : 'Continue' }}
              </Button>
              <Button
                variant="primary"
                class="test_fa2_restart"
                title="Clear node coordinates and run the layout algorithm anew."
                @click="restartFA2Layout"
              >
                <template #node:icon>
                  <div>
                    <RestartIcon />
                  </div>
                </template>
                Restart
              </Button>
            </div>
          </template>
        </Panel>
      </PanelMenuWrapper>
    </GraphEditorControls>

    <div
      ref="graph"
      class="test_graph_output"
      :style="{
        height: '100%',
        width: '100%',
        backgroundColor: settings.style.backgroundColor
      }"
    />
  </div>
</template>

<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import GraphEditorControls from '@/lib/GraphEditorControls.jsx'
import { PanelMenuWrapper, Panel, Fold, Section } from 'react-chart-editor'
import 'react-chart-editor/lib/react-chart-editor.css'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import ColorPicker from 'react-chart-editor/lib/components/widgets/ColorPicker'
import Button from 'react-chart-editor/lib/components/widgets/Button'
import Field from 'react-chart-editor/lib/components/fields/Field'
import RandomLayoutSettings from '@/components/Graph/RandomLayoutSettings.vue'
import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSettings.vue'
import ForceAtlasSeedLayoutSettings from '@/components/Graph/ForceAtlasSeedLayoutSettings.vue'
// eslint-disable-next-line max-len
import AdvancedForceAtlasLayoutSettings from '@/components/Graph/AdvancedForceAtlasLayoutSettings.vue'
import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.vue'
import FA2Layout from 'graphology-layout-forceatlas2/worker'
import * as forceAtlas2 from 'graphology-layout-forceatlas2'
import RunIcon from '@/components/svg/run.vue'
import RestartIcon from '@/components/svg/restart.vue'
import PauseIcon from '@/components/svg/pause.vue'
import { downloadAsPNG, drawOnCanvas } from '@sigma/export-image'
import {
  buildNodes,
  buildEdges,
  updateNodes,
  updateEdges,
  reduceNodes,
  reduceEdges,
  clearNodeCoordinates
} from '@/lib/graphHelper'
import Graph from 'graphology'
import { circular, random, circlepack } from 'graphology-layout'
import Sigma from 'sigma'
import seedrandom from 'seedrandom'
import NodeColorSettings from '@/components/Graph/NodeColorSettings.vue'
import NodeSizeSettings from '@/components/Graph/NodeSizeSettings.vue'
import EdgeSizeSettings from '@/components/Graph/EdgeSizeSettings.vue'
import EdgeColorSettings from '@/components/Graph/EdgeColorSettings.vue'
import events from '@/lib/utils/events'

export default {
  components: {
    GraphEditorControls: applyPureReactInVue(GraphEditorControls),
    PanelMenuWrapper: applyPureReactInVue(PanelMenuWrapper),
    Panel: applyPureReactInVue(Panel),
    PanelSection: applyPureReactInVue(Section),
    Dropdown: applyPureReactInVue(Dropdown),
    RadioBlocks: applyPureReactInVue(RadioBlocks),
    Field: applyPureReactInVue(Field),
    Fold: applyPureReactInVue(Fold),
    Button: applyPureReactInVue(Button),
    ColorPicker: applyPureReactInVue(ColorPicker),
    RunIcon,
    RestartIcon,
    PauseIcon,
    RandomLayoutSettings,
    CirclePackLayoutSettings,
    NodeColorSettings,
    NodeSizeSettings,
    EdgeSizeSettings,
    EdgeColorSettings,
    AdvancedForceAtlasLayoutSettings,
    ForceAtlasSeedLayoutSettings
  },
  inject: ['tabLayout'],
  props: {
    dataSources: Object,
    initOptions: Object,
    showViewSettings: Boolean
  },
  emits: ['update', 'selectItem', 'clearSelection'],
  data() {
    return {
      graph: new Graph({ multi: true, allowSelfLoops: true }),
      renderer: null,
      fa2Layout: null,
      fa2Running: false,
      checkIteration: null,
      visibilityOptions: markRaw([
        { label: 'Show', value: true },
        { label: 'Hide', value: false }
      ]),
      layoutOptions: markRaw([
        { label: 'Circular', value: 'circular' },
        { label: 'Random', value: 'random' },
        { label: 'Circle pack', value: 'circlepack' },
        { label: 'ForceAtlas2', value: 'forceAtlas2' }
      ]),
      layoutSettingsComponentMap: markRaw({
        random: RandomLayoutSettings,
        circlepack: CirclePackLayoutSettings,
        forceAtlas2: ForceAtlasLayoutSettings
      }),
      layoutMethodMap: markRaw({
        circular: this.applyCircularLayout,
        random: this.applyRandomLayout,
        circlepack: this.applyCirclePackLayout,
        forceAtlas2: this.applyFA2Layout
      }),
      selectedNodeId: undefined,
      hoveredNodeId: undefined,
      selectedEdgeId: undefined,
      hoveredEdgeId: undefined,
      settings: this.initOptions
        ? JSON.parse(JSON.stringify(this.initOptions))
        : {
            structure: {
              nodeId: null,
              objectType: null,
              edgeSource: null,
              edgeTarget: null
            },
            style: {
              backgroundColor: 'white',
              highlightMode: 'node_and_neighbors',
              nodes: {
                size: {
                  type: 'constant',
                  value: 10
                },
                color: {
                  type: 'constant',
                  value: '#1F77B4',
                  opacity: 100
                },
                label: {
                  source: null,
                  color: '#444444'
                }
              },
              edges: {
                showDirection: true,
                size: {
                  type: 'constant',
                  value: 2
                },
                color: {
                  type: 'constant',
                  value: '#a2b1c6'
                },
                label: {
                  source: null,
                  color: '#a2b1c6'
                }
              }
            },
            layout: {
              type: 'circular',
              options: null
            }
          },
      layoutOptionsArchive: {
        random: null,
        circlepack: null,
        forceAtlas2: null
      },
      highlightModeOptions: markRaw([
        { label: 'Node alone', value: 'node_alone' },
        { label: 'Node and neighbors', value: 'node_and_neighbors' },
        {
          label: 'Include edges between neighbors',
          value: 'include_neighbor_edges'
        }
      ])
    }
  },
  computed: {
    records() {
      if (!this.dataSources) {
        return []
      }
      const firstColumnName = Object.keys(this.dataSources)[0]
      try {
        return (
          this.dataSources[firstColumnName].map(json => JSON.parse(json)) || []
        )
      } catch {
        return []
      }
    },
    keysOptions() {
      if (!this.dataSources) {
        return []
      }
      const keySet = this.records.reduce((result, currentRecord) => {
        Object.keys(currentRecord).forEach(key => result.add(key))
        return result
      }, new Set())

      return Array.from(keySet)
    },
    neighborsOfSelectedNode() {
      if (this.settings.style.highlightMode === 'node_alone') {
        return undefined
      }
      return this.selectedNodeId
        ? new Set(this.graph.neighbors(this.selectedNodeId))
        : undefined
    },
    neighborsOfHoveredNode() {
      if (this.settings.style.highlightMode === 'node_alone') {
        return undefined
      }
      return this.hoveredNodeId
        ? new Set(this.graph.neighbors(this.hoveredNodeId))
        : undefined
    },
    hoveredEdgeExtremities() {
      return this.hoveredEdgeId
        ? this.graph.extremities(this.hoveredEdgeId)
        : []
    },
    selectedEdgeExtremities() {
      return this.selectedEdgeId
        ? this.graph.extremities(this.selectedEdgeId)
        : []
    },
    interactionState() {
      return {
        selectedNodeId: this.selectedNodeId,
        hoveredNodeId: this.hoveredNodeId,
        selectedEdgeId: this.selectedEdgeId,
        hoveredEdgeId: this.hoveredEdgeId,

        neighborsOfSelectedNode: this.neighborsOfSelectedNode,
        neighborsOfHoveredNode: this.neighborsOfHoveredNode,

        hoveredEdgeExtremities: this.hoveredEdgeExtremities,
        selectedEdgeExtremities: this.selectedEdgeExtremities
      }
    }
  },
  watch: {
    dataSources() {
      if (this.dataSources) {
        this.buildGraph()
      }
    },
    settings: {
      deep: true,
      handler() {
        this.$emit('update')
      }
    },
    'settings.structure': {
      deep: true,
      handler() {
        this.buildGraph()
      }
    },
    'settings.layout.type': {
      immediate: true,
      handler() {
        events.send('viz_graph.render', null, {
          layout: this.settings.layout.type
        })
      }
    },
    tabLayout: {
      deep: true,
      handler() {
        if (this.tabLayout.dataView !== 'hidden' && this.renderer) {
          this.renderer.scheduleRender()
        }
      }
    }
  },
  mounted() {
    if (this.dataSources) {
      this.buildGraph()
    }
  },
  methods: {
    buildGraph() {
      this.clearSelection()
      if (this.renderer) {
        this.renderer.kill()
      }
      this.graph.clear()

      buildNodes(this.graph, this.dataSources, this.settings)
      buildEdges(this.graph, this.dataSources, this.settings)

      // Apply visual settings
      updateNodes(this.graph, this.settings.style.nodes)
      updateEdges(this.graph, this.settings.style.edges)

      this.updateLayout(this.settings.layout.type)
      this.renderer = new Sigma(this.graph, this.$refs.graph, {
        renderEdgeLabels: true,
        allowInvalidContainer: true,
        labelColor: { attribute: 'labelColor', color: '#444444' },
        edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' },
        enableEdgeEvents: true,
        zIndex: true,
        nodeReducer: (node, data) =>
          reduceNodes(node, data, this.interactionState, this.settings),
        edgeReducer: (edge, data) =>
          reduceEdges(
            edge,
            data,
            this.interactionState,
            this.settings,
            this.graph
          )
      })
      this.renderer.on('clickNode', ({ node }) => {
        this.selectedNodeId = node
        this.selectedEdgeId = undefined
        this.$emit('selectItem', this.graph.getNodeAttributes(node).data)
        this.renderer.refresh({
          skipIndexation: true
        })
      })
      this.renderer.on('clickEdge', ({ edge }) => {
        this.selectedEdgeId = edge
        this.selectedNodeId = undefined
        this.$emit('selectItem', this.graph.getEdgeAttributes(edge).data)
        this.renderer.refresh({
          skipIndexation: true
        })
      })
      this.renderer.on('clickStage', () => {
        this.clearSelection()
        this.renderer.refresh({
          skipIndexation: true
        })
      })
      this.renderer.on('enterNode', ({ node }) => {
        this.hoveredNodeId = node
        this.renderer.refresh({
          skipIndexation: true
        })
      })
      this.renderer.on('enterEdge', ({ edge }) => {
        this.hoveredEdgeId = edge
        this.renderer.refresh({
          skipIndexation: true
        })
      })
      this.renderer.on('leaveNode', () => {
        this.hoveredNodeId = undefined
        this.renderer.refresh({
          skipIndexation: true
        })
      })
      this.renderer.on('leaveEdge', () => {
        this.hoveredEdgeId = undefined
        this.renderer.refresh({
          skipIndexation: true
        })
      })

      if (this.settings.layout.type === 'forceAtlas2') {
        this.autoRunFA2Layout()
      }
    },
    clearSelection() {
      this.selectedNodeId = undefined
      this.selectedEdgeId = undefined
      this.$emit('clearSelection')
    },
    updateHighlightNodeMode(mode) {
      this.settings.style.highlightMode = mode

      if (this.renderer) {
        this.renderer.refresh({
          skipIndexation: true
        })
      }
    },
    updateStructure(attributeName, value) {
      this.settings.structure[attributeName] = value
    },
    updateNodes(attributeName, value) {
      const attributePath = attributeName.split('.')
      attributePath.reduce((result, current, index) => {
        if (index === attributePath.length - 1) {
          return (result[current] = value)
        } else {
          return result[current]
        }
      }, this.settings.style.nodes)

      updateNodes(this.graph, {
        [attributePath[0]]: this.settings.style.nodes[attributePath[0]]
      })
    },
    updateEdges(attributeName, value) {
      const attributePath = attributeName.split('.')
      attributePath.reduce((result, current, index) => {
        if (index === attributePath.length - 1) {
          return (result[current] = value)
        } else {
          return result[current]
        }
      }, this.settings.style.edges)

      updateEdges(this.graph, {
        [attributePath[0]]: this.settings.style.edges[attributePath[0]]
      })
    },
    updateLayout(layoutType) {
      const prevLayout = this.settings.layout.type

      // Change layout type? - restore layout settings or set default settings
      if (layoutType !== prevLayout) {
        this.layoutOptionsArchive[prevLayout] = this.settings.layout.options
        this.settings.layout.options = this.layoutOptionsArchive[layoutType]

        if (!this.settings.layout.options) {
          if (layoutType === 'forceAtlas2') {
            this.setRecommendedFA2Settings()
          } else if (['random', 'circlepack'].includes(layoutType)) {
            this.settings.layout.options = {
              seedValue: 1
            }
          }
        }
        this.settings.layout.type = layoutType
      }

      // In any case kill FA2 if it exists
      if (this.fa2Layout) {
        if (this.fa2Layout.isRunning()) {
          this.stopFA2Layout()
        }
        this.fa2Layout.kill()
      }

      this.layoutMethodMap[layoutType]()

      if (layoutType === 'forceAtlas2' && layoutType !== prevLayout) {
        this.autoRunFA2Layout()
      }
    },
    applyCircularLayout() {
      circular.assign(this.graph)
    },
    applyRandomLayout() {
      random.assign(this.graph, {
        rng: seedrandom(this.settings.layout.options.seedValue)
      })
    },
    applyCirclePackLayout() {
      this.graph.forEachNode(nodeId => {
        this.graph.updateNode(nodeId, attributes => {
          const newAttributes = { ...attributes }
          // Delete old hierarchy attributes
          Object.keys(newAttributes)
            .filter(key => key.startsWith('hierarchyAttribute'))
            .forEach(
              hierarchyAttributeKey =>
                delete newAttributes[hierarchyAttributeKey]
            )
          // Set new hierarchy attributes
          this.settings.layout.options.hierarchyAttributes?.forEach(
            (hierarchyAttribute, index) => {
              newAttributes['hierarchyAttribute' + index] =
                attributes.data[hierarchyAttribute]
            }
          )

          return newAttributes
        })
      })

      circlepack.assign(this.graph, {
        hierarchyAttributes:
          this.settings.layout.options.hierarchyAttributes?.map(
            (_, index) => 'hierarchyAttribute' + index
          ) || [],
        rng: seedrandom(this.settings.layout.options.seedValue)
      })
    },
    applyFA2Layout() {
      if (
        !this.graph.someNode(
          (nodeKey, attributes) =>
            typeof attributes.x === 'number' && typeof attributes.y === 'number'
        )
      ) {
        this.layoutMethodMap[this.settings.layout.options.initialAlgorithm]()
      }

      const fa2settings = { ...this.settings.layout.options }
      // delete all custom settings because they can break the algorithm running
      delete fa2settings.initialAlgorithm
      delete fa2settings.seedValue
      delete fa2settings.initialIterationsAmount
      delete fa2settings.hierarchyAttributes
      this.fa2Layout = markRaw(
        new FA2Layout(this.graph, {
          getEdgeWeight: (_, attr) =>
            this.settings.layout.options.weightSource
              ? attr.data[this.settings.layout.options.weightSource]
              : 1,
          settings: fa2settings
        })
      )
    },
    toggleFA2Layout() {
      if (this.fa2Layout.isRunning()) {
        this.stopFA2Layout()
      } else {
        this.fa2Running = true
        this.fa2Layout.start()
      }
    },
    stopFA2Layout() {
      this.fa2Running = false
      this.fa2Layout.stop()
      if (this.checkIteration) {
        this.fa2Layout.worker.removeEventListener(
          'message',
          this.checkIteration
        )
        this.checkIteration = null
      }
    },
    restartFA2Layout() {
      if (this.fa2Layout.isRunning()) {
        this.stopFA2Layout()
      }
      this.fa2Layout.kill()
      clearNodeCoordinates(this.graph)
      this.applyFA2Layout()
      this.autoRunFA2Layout()
    },
    autoRunFA2Layout() {
      let iteration = 1
      this.checkIteration = () => {
        if (
          iteration === this.settings.layout.options.initialIterationsAmount
        ) {
          this.stopFA2Layout()
        }
        iteration++
      }
      this.fa2Layout.worker.addEventListener('message', this.checkIteration)
      this.fa2Running = true
      this.fa2Layout.start()
    },
    setRecommendedFA2Settings() {
      const sensibleSettings = forceAtlas2.default.inferSettings(this.graph)
      this.settings.layout.options = {
        initialAlgorithm: 'circular',
        initialIterationsAmount: 50,
        adjustSizes: false,
        barnesHutOptimize: false,
        barnesHutTheta: 0.5,
        edgeWeightInfluence: 0,
        gravity: 1,
        linLogMode: false,
        outboundAttractionDistribution: false,
        scalingRatio: 1,
        slowDown: 1,
        strongGravityMode: false,
        ...sensibleSettings
      }
      if (
        [Infinity, -Infinity].includes(this.settings.layout.options.slowDown)
      ) {
        this.settings.layout.options.slowDown = 1
      }
    },
    resetFA2LayoutSettings() {
      if (this.initOptions?.layout.type === 'forceAtlas2') {
        this.settings.layout = JSON.parse(
          JSON.stringify(this.initOptions.layout)
        )
      } else {
        this.setRecommendedFA2Settings()
      }
      this.updateLayout(this.settings.layout.type)
    },
    saveAsPng() {
      return downloadAsPNG(this.renderer, {
        backgroundColor: this.settings.style.backgroundColor
      })
    },
    prepareCopy() {
      return drawOnCanvas(this.renderer, {
        backgroundColor: this.settings.style.backgroundColor
      })
    }
  }
}
</script>

<style scoped>
.plotly_editor.with_controls > div {
  display: flex !important;
}

:deep(.customPickerContainer) {
  float: right;
}
.force-atlas-buttons {
  display: flex;
  width: 100%;
  gap: 16px;
}

.force-atlas-buttons :deep(button) {
  flex-grow: 1;
  flex-basis: 0;
}

.force-atlas-buttons :deep(.button__icon > div) {
  padding: 0 3px;
}
</style>
