<script setup lang="ts">
import ForceGraph3D from '3d-force-graph'
import { CanvasTexture, Group, Mesh, MeshBasicMaterial, Sprite, SpriteMaterial, SphereGeometry } from 'three'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'

import type { KGEdge } from '@/api/graph'

interface BrainGraphNode {
  id: string
  name: string
  type: string
  importance: number
}

interface ForceGraphNode extends BrainGraphNode {
  color?: string
}

interface ForceGraphLink extends KGEdge {
  source: string
  target: string
}

interface CameraPosition {
  x: number
  y: number
  z: number
}

interface ForceGraphInstance {
  graphData: (data: { nodes: ForceGraphNode[]; links: ForceGraphLink[] }) => ForceGraphInstance
  onNodeClick: (callback: (node?: Partial<ForceGraphNode> | null) => void) => ForceGraphInstance
  onBackgroundClick: (callback: () => void) => ForceGraphInstance
  nodeColor: (accessor: (node: ForceGraphNode) => string) => ForceGraphInstance
  nodeLabel?: (accessor: (node: ForceGraphNode) => string) => ForceGraphInstance
  nodeRelSize: (size: number) => ForceGraphInstance
  linkColor: (accessor: (link: ForceGraphLink) => string) => ForceGraphInstance
  linkWidth: (accessor: (link: ForceGraphLink) => number) => ForceGraphInstance
  linkOpacity: (opacity: number) => ForceGraphInstance
  cameraPosition: (position: CameraPosition, lookAt?: { x: number; y: number; z: number }, durationMs?: number) => ForceGraphInstance
  width: (value: number) => ForceGraphInstance
  height: (value: number) => ForceGraphInstance
  cooldownTicks: (value: number) => ForceGraphInstance
  d3AlphaDecay: (value: number) => ForceGraphInstance
  d3VelocityDecay: (value: number) => ForceGraphInstance
  enableNodeDrag: (enabled: boolean) => ForceGraphInstance
  nodeThreeObject?: (accessor: ((node: ForceGraphNode) => unknown) | null) => ForceGraphInstance
  linkDirectionalParticles: (accessor: (link: ForceGraphLink) => number) => ForceGraphInstance
  linkDirectionalParticleWidth: (accessor: (link: ForceGraphLink) => number) => ForceGraphInstance
  linkDirectionalParticleSpeed: (accessor: (link: ForceGraphLink) => number) => ForceGraphInstance
  linkDirectionalParticleColor: (accessor: (link: ForceGraphLink) => string) => ForceGraphInstance
  nodeVisibility: (accessor: (node: ForceGraphNode) => boolean) => ForceGraphInstance
  linkVisibility: (accessor: (link: ForceGraphLink) => boolean) => ForceGraphInstance
  pauseAnimation?: () => ForceGraphInstance
  resumeAnimation?: () => ForceGraphInstance
}

const DEFAULT_CAMERA: CameraPosition = { x: 0, y: 0, z: 220 }
const DEFAULT_LOOK_AT = { x: 0, y: 0, z: 0 }
const DEFAULT_WIDTH = 960
const DEFAULT_HEIGHT = 640
const LINK_OPACITY = 0.12
const BASE_NODE_SIZE = 4.4
const LABEL_TEXT_COLOR = '#e9f7ff'
const LABEL_GLOW_COLOR = '#63b8ff'

const AUTO_ROTATE_INTERVAL_MS = 500
const AUTO_ROTATE_STEP = 0.08
const AUTO_ROTATE_RADIUS_RATIO = 0.05

const props = withDefaults(defineProps<{
  nodes: BrainGraphNode[]
  edges: KGEdge[]
  selectedNodeId?: string | null
  getColor: (type: string) => string
}>(), {
  selectedNodeId: null,
})

const emit = defineEmits<{
  selectNode: [nodeId: string]
  clearSelection: []
}>()

const stageRef = ref<HTMLDivElement | null>(null)
const graphInstance = ref<ForceGraphInstance | null>(null)
const cameraDistance = ref(DEFAULT_CAMERA.z)
const autoRotateAngle = ref(0)
let autoRotateTimer: ReturnType<typeof window.setInterval> | null = null

const adjacencyMap = computed(() => {
  const adjacency = new Map<string, Set<string>>()

  for (const node of props.nodes) {
    adjacency.set(node.id, new Set())
  }

  for (const edge of props.edges) {
    const sourceId = String(edge.source)
    const targetId = String(edge.target)
    adjacency.set(sourceId, new Set([...(adjacency.get(sourceId) || []), targetId]))
    adjacency.set(targetId, new Set([...(adjacency.get(targetId) || []), sourceId]))
  }

  return adjacency
})

const visibleNodeIds = computed(() => new Set(props.nodes.map((node) => node.id)))

const graphData = computed(() => ({
  nodes: props.nodes.map((node) => ({
    ...node,
    color: props.getColor(node.type),
  })),
  links: props.edges.map((edge) => ({
    ...edge,
    source: String(edge.source),
    target: String(edge.target),
  })),
}))

function syncGraphSize() {
  if (!graphInstance.value || !stageRef.value) {
    return
  }

  const width = stageRef.value.clientWidth || DEFAULT_WIDTH
  const height = stageRef.value.clientHeight || DEFAULT_HEIGHT

  graphInstance.value.width(width)
  graphInstance.value.height(height)
}

function syncGraphData() {
  graphInstance.value?.graphData(graphData.value)
}

function isSelectedNode(nodeId: string) {
  return props.selectedNodeId === nodeId
}

function isAdjacentNode(nodeId: string) {
  if (!props.selectedNodeId) {
    return true
  }

  if (isSelectedNode(nodeId)) {
    return true
  }

  return adjacencyMap.value.get(props.selectedNodeId)?.has(nodeId) ?? false
}

function isVisibleLink(link: ForceGraphLink) {
  return visibleNodeIds.value.has(String(link.source)) && visibleNodeIds.value.has(String(link.target))
}

function isAdjacentLink(link: ForceGraphLink) {
  if (!props.selectedNodeId) {
    return true
  }

  return String(link.source) === props.selectedNodeId || String(link.target) === props.selectedNodeId
}

function isCoreNode(node: ForceGraphNode) {
  return node.importance >= 0.86 || (adjacencyMap.value.get(node.id)?.size || 0) >= 2
}

function createLabelSprite(text: string, options: { selected?: boolean; core?: boolean } = {}) {
  const canvas = document.createElement('canvas')
  const context = canvas.getContext('2d')
  if (!context) {
    return new Sprite(new SpriteMaterial({ color: LABEL_TEXT_COLOR, transparent: true, opacity: 0.56 }))
  }

  const fontSize = options.selected ? 16 : options.core ? 13 : 11
  const horizontalPadding = options.selected ? 10 : 8
  const verticalPadding = options.selected ? 6 : 5
  context.font = `${fontSize}px "Segoe UI", "PingFang SC", sans-serif`
  const measuredWidth = Math.ceil(context.measureText(text).width)
  canvas.width = measuredWidth + horizontalPadding * 2
  canvas.height = fontSize + verticalPadding * 2

  context.clearRect(0, 0, canvas.width, canvas.height)
  context.font = `${fontSize}px "Segoe UI", "PingFang SC", sans-serif`
  context.textBaseline = 'middle'
  context.textAlign = 'center'
  context.shadowColor = LABEL_GLOW_COLOR
  context.shadowBlur = options.selected ? 6 : 3
  context.fillStyle = LABEL_TEXT_COLOR
  context.globalAlpha = options.selected ? 0.92 : options.core ? 0.72 : 0.56
  context.fillText(text, canvas.width / 2, canvas.height / 2)

  const texture = new CanvasTexture(canvas)
  texture.needsUpdate = true
  const material = new SpriteMaterial({
    map: texture,
    transparent: true,
    depthWrite: false,
    opacity: options.selected ? 0.82 : options.core ? 0.62 : 0.46,
  })
  const sprite = new Sprite(material)
  const aspectRatio = canvas.width / canvas.height
  const scaleBase = options.selected ? 11 : options.core ? 9 : 8
  sprite.scale.set(scaleBase * aspectRatio, scaleBase, 1)
  sprite.center.set(0.5, 0)
  sprite.position.set(0, options.selected ? 8 : 7, 0)
  return sprite
}

function createNodeVisual(node: ForceGraphNode) {
  const core = isCoreNode(node)
  const selected = isSelectedNode(node.id)
  const color = selected ? '#f7fbff' : props.getColor(node.type)
  const group = new Group()

  const sphere = new Mesh(
    new SphereGeometry(selected ? 7.8 : core ? 6.6 : 5.4, 18, 18),
    new MeshBasicMaterial({
      color,
      transparent: true,
      opacity: selected ? 0.98 : core ? 0.9 : 0.82,
    }),
  )

  group.add(sphere)
  group.add(createLabelSprite(node.name, { selected, core }))
  return group
}

function applySelectionStyling() {
  if (!graphInstance.value) {
    return
  }

  graphInstance.value
    .nodeVisibility((node) => visibleNodeIds.value.has(node.id))
    .linkVisibility((link) => isVisibleLink(link))
    .linkColor((link) => (isAdjacentLink(link) ? '#cfeaff' : 'rgba(100, 160, 255, 0.1)'))
    .linkWidth((link) => {
      if (isAdjacentLink(link)) {
        return 1.15
      }

      return props.selectedNodeId ? 0.22 : 0.48
    })
}

function applyCameraPosition(durationMs = 300) {
  const orbitRadius = cameraDistance.value * AUTO_ROTATE_RADIUS_RATIO
  const position = {
    x: Math.sin(autoRotateAngle.value) * orbitRadius,
    y: Math.cos(autoRotateAngle.value * 0.6) * orbitRadius * 0.22,
    z: cameraDistance.value,
  }

  graphInstance.value?.cameraPosition(position, DEFAULT_LOOK_AT, durationMs)
}

function startAutoRotate() {
  if (autoRotateTimer || !graphInstance.value || typeof window === 'undefined') {
    return
  }

  autoRotateTimer = window.setInterval(() => {
    autoRotateAngle.value += AUTO_ROTATE_STEP
    applyCameraPosition(AUTO_ROTATE_INTERVAL_MS)
  }, AUTO_ROTATE_INTERVAL_MS)
}

function stopAutoRotate() {
  if (!autoRotateTimer || typeof window === 'undefined') {
    return
  }

  window.clearInterval(autoRotateTimer)
  autoRotateTimer = null
}

function zoomBy(multiplier: number) {
  cameraDistance.value = Math.max(120, Math.min(960, cameraDistance.value / multiplier))
  applyCameraPosition(250)
}

function resetViewport() {
  cameraDistance.value = DEFAULT_CAMERA.z
  autoRotateAngle.value = 0
  applyCameraPosition(300)
}

function applyNodePresentation() {
  if (!graphInstance.value) {
    return
  }

  graphInstance.value
    .nodeColor((node) => {
      if (isSelectedNode(node.id)) {
        return '#f8fbff'
      }

      if (isAdjacentNode(node.id)) {
        return props.getColor(node.type)
      }

      return 'rgba(118, 156, 196, 0.22)'
    })
    .nodeLabel?.((node) => `${node.name} · ${node.id}`)

  graphInstance.value
    .nodeThreeObject?.((node) => createNodeVisual(node))
}

function configureGraph() {
  if (!graphInstance.value) {
    return
  }

  applyNodePresentation()

  graphInstance.value
    .nodeRelSize(BASE_NODE_SIZE)
    .linkOpacity(LINK_OPACITY)
    .cooldownTicks(90)
    .d3AlphaDecay(0.06)
    .d3VelocityDecay(0.26)
    .enableNodeDrag(true)
    .linkDirectionalParticles((link) => (isAdjacentLink(link) ? 1 : 0))
    .linkDirectionalParticleWidth((link) => (isAdjacentLink(link) ? 0.48 : 0))
    .linkDirectionalParticleSpeed((link) => (isAdjacentLink(link) ? 0.0038 : 0))
    .linkDirectionalParticleColor((link) => (isAdjacentLink(link) ? '#d6ecff' : 'rgba(0,0,0,0)'))

  applySelectionStyling()
  syncGraphData()
  syncGraphSize()
  resetViewport()
  startAutoRotate()
}

function createGraphInstance(element: HTMLElement) {
  const candidate = ForceGraph3D as unknown as {
    new (target: HTMLElement): ForceGraphInstance
    (target?: HTMLElement): ForceGraphInstance | ((target: HTMLElement) => ForceGraphInstance)
  }

  try {
    return new candidate(element)
  }
  catch {
    const directResult = candidate(element)
    if (directResult && typeof directResult === 'object' && 'graphData' in directResult) {
      return directResult as ForceGraphInstance
    }

    if (typeof directResult === 'function') {
      return directResult(element)
    }

    const factoryResult = candidate()
    if (typeof factoryResult === 'function') {
      return factoryResult(element)
    }

    return factoryResult as ForceGraphInstance
  }
}

onMounted(() => {
  if (!stageRef.value) {
    return
  }

  graphInstance.value = createGraphInstance(stageRef.value)

  graphInstance.value
    .onNodeClick((node) => {
      if (!node?.id) {
        return
      }
      emit('selectNode', String(node.id))
    })
    .onBackgroundClick(() => {
      emit('clearSelection')
    })

  configureGraph()
  graphInstance.value.resumeAnimation?.()
  startAutoRotate()

  if (typeof window !== 'undefined') {
    window.addEventListener('resize', syncGraphSize)
  }
})

watch(graphData, () => {
  syncGraphData()
}, { deep: true })

watch(() => props.selectedNodeId, () => {
  applyNodePresentation()
  applySelectionStyling()
})

onBeforeUnmount(() => {
  if (typeof window !== 'undefined') {
    window.removeEventListener('resize', syncGraphSize)
  }

  stopAutoRotate()
  graphInstance.value?.pauseAnimation?.()
  graphInstance.value = null
})

defineExpose({
  zoomBy,
  resetViewport,
})
</script>

<template>
  <div ref="stageRef" class="brain-graph-canvas" data-testid="brain-graph-canvas"></div>
</template>

<style scoped>
.brain-graph-canvas {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: 24px;
}
</style>
