<template>
  <div class="graph-view" :class="{ 'graph-view--chrome-collapsed': chromeCollapsed }">
    <GraphHeaderPanel
      v-show="!chromeCollapsed"
      title="Граф связей"
      :show-reset="false"
      :show-physics-toggle="true"
      :physics-enabled="physicsEnabled"
      @toggle-physics="togglePhysics"
    >
      <template #actions>
        <button type="button" class="btn btn-secondary btn-sm" @click="filtersOpen = true">
          Фильтры
        </button>
      </template>
    </GraphHeaderPanel>

    <GraphFiltersModal
      :open="filtersOpen"
      :relation-types="allRelationTypes"
      :active-filters="activeFilters"
      :link-selection-count="linkSelectionCount"
      :link-selection="linkSelection"
      :toolbar-actions="graphToolbarActions"
      @close="filtersOpen = false"
      @toggle="toggleFilter"
      @toolbar-action="runGraphToolbarAction"
    />

    <div class="graph-area" ref="graphArea" @contextmenu.prevent="onGraphAreaContextMenu">
      <div class="graph-chrome-bar">
        <button
          type="button"
          class="graph-chrome-toggle"
          :title="chromeCollapsed ? 'Показать панели' : 'Свернуть панели'"
          @click="toggleChrome"
        >
          <svg
            width="14"
            height="14"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            :class="{ 'graph-chrome-toggle__icon--collapsed': chromeCollapsed }"
          >
            <polyline points="18 15 12 9 6 15"/>
          </svg>
          <span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
        </button>
      </div>
      <div v-if="loading" class="spinner"></div>
      <div v-else-if="nodes.length === 0" class="empty-state card" @contextmenu.prevent="onGraphAreaContextMenu">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
          <circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/>
        </svg>
        <p>Нет контактов. <RouterLink to="/contacts">Добавьте контакты</RouterLink> или <RouterLink to="/import">импортируйте файл</RouterLink>.</p>
      </div>
      <div v-else class="graph-stack" ref="graphStack">
        <div class="graph-view-tools">
          <button
            type="button"
            class="graph-fit-btn btn btn-secondary btn-sm"
            title="По центру"
            @click="fitView"
          >
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
            </svg>
            По центру
          </button>
          <button
            type="button"
            class="graph-fullscreen-btn"
            :title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
            @click="toggleFullscreen"
          >
            <svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <path d="M8 3H5a2 2 0 0 0-2 2v3"/>
              <path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
              <path d="M3 16v3a2 2 0 0 0 2 2h3"/>
              <path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
            </svg>
            <svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <path d="M4 14h6v6"/>
              <path d="M20 10h-6V4"/>
              <path d="M14 10l7-7"/>
              <path d="M3 21l7-7"/>
            </svg>
          </button>
        </div>
        <div id="graph-container" ref="graphContainer"></div>
      </div>
    </div>

    <!-- Node detail panel -->
    <div v-if="selectedNode" class="modal-overlay" @click.self="selectedNode = null">
      <div class="modal">
        <div class="modal-header">
          <h3>{{ selectedNode.label }}</h3>
          <button class="btn btn-secondary btn-sm" @click="selectedNode = null">✕</button>
        </div>
        <div v-if="selectedContact">
          <div class="form-group">
            <label>Email</label>
            <div>{{ selectedContact.email || '—' }}</div>
          </div>
          <div class="form-group">
            <label>Телефон</label>
            <div>{{ selectedContact.phone || '—' }}</div>
          </div>
          <div class="form-group">
            <label>Организация / Должность</label>
            <div>{{ [selectedContact.organization, selectedContact.position].filter(Boolean).join(' · ') || '—' }}</div>
          </div>
          <div class="form-group">
            <label>Заметки</label>
            <div>{{ selectedContact.notes || '—' }}</div>
          </div>
          <div class="form-group">
            <label>Связей</label>
            <div>{{ selectedContact.relations_count }}</div>
          </div>
        </div>
        <div class="modal-footer">
          <RouterLink :to="`/contacts/${selectedNode.id}`" class="btn btn-primary btn-sm">Открыть</RouterLink>
        </div>
      </div>
    </div>

    <GraphNodeContextMenu
      :open="contextMenuOpen"
      :node="contextMenuNode"
      :x="contextMenuX"
      :y="contextMenuY"
      @close="closeContextMenu"
    />

    <GraphEdgeContextMenu
      :open="edgeContextMenuOpen"
      :edge="contextMenuEdge"
      :x="edgeContextMenuX"
      :y="edgeContextMenuY"
      @close="closeEdgeContextMenu"
      @edit="openEditRelation"
    />

    <GraphCanvasContextMenu
      :open="canvasContextMenuOpen"
      :x="canvasContextMenuX"
      :y="canvasContextMenuY"
      @close="closeContextMenu"
      @create-contact="openCreateContact"
    />

    <CreateContactModal
      :open="createContactOpen"
      :show-relation-link="graphContactOptions.length > 0"
      :link-to-options="graphContactOptions"
      :initial-link-to-id="linkSelection[0]?.id"
      @close="createContactOpen = false"
      @created="onContactCreated"
    />

    <EditRelationModal
      :open="editRelationOpen"
      :relation="editRelationTarget"
      @close="closeEditRelation"
      @updated="onRelationUpdated"
      @deleted="onRelationDeleted"
    />

    <CreateRelationModal
      :open="relationModalOpen"
      :source="relationPair?.[0]"
      :target="relationPair?.[1]"
      @close="closeRelationModal"
      @created="onRelationCreated"
    />
  </div>
</template>

<script setup>
defineOptions({ name: 'Graph' })

import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
import { RouterLink, useRouter, onBeforeRouteLeave } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
import { RELATION_COLORS } from '../lib/graph/relationColors'
import { clusterColor } from '../lib/graph/clusterColors'
import { computeClusterMap, updateClusterMapForNodes } from '../lib/graph/clusters'
import { computeGraphSeedPositions } from '../lib/graph/graphLayout'
import { readGraphLayoutCache, writeGraphLayoutCache } from '../lib/graph/graphLayoutCache'
import { applyIntensityToVisEdge } from '../lib/graph/relationIntensity'
import { buildGraphFromStore, edgeFromRelation } from '../application/usecases/graph'
import GraphHeaderPanel from '../components/GraphHeaderPanel.vue'
import GraphFiltersModal from '../components/GraphFiltersModal.vue'
import CreateRelationModal from '../components/CreateRelationModal.vue'
import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import GraphCanvasContextMenu from '../components/GraphCanvasContextMenu.vue'
import CreateContactModal from '../components/CreateContactModal.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { getGraphToolbarActions } from '../core/pluginRegistry'
import { loadTopPanelCollapsed, saveTopPanelCollapsed } from '../lib/ui/topPanelCollapseStorage'

let themeObserver = null
let detachContextHandler = null

const {
  contextMenuOpen,
  contextMenuNode,
  contextMenuX,
  contextMenuY,
  edgeContextMenuOpen,
  contextMenuEdge,
  edgeContextMenuX,
  edgeContextMenuY,
  canvasContextMenuOpen,
  canvasContextMenuX,
  canvasContextMenuY,
  openCanvasContextMenu,
  closeContextMenu,
  closeEdgeContextMenu,
  attachNodeContextHandlers,
} = useGraphNodeContextMenu()

const createContactOpen = ref(false)

function openCreateContact() {
  closeContextMenu()
  createContactOpen.value = true
}

function onGraphAreaContextMenu(event) {
  if (nodes.value.length > 0) return
  openCanvasContextMenu(event)
}

async function onContactCreated(data, mapIds, pluginPayload, relationLink) {
  const created = await store.createContact(data)
  if (mapIds?.length) {
    await mapsStore.setContactMapMemberships(created.id, mapIds)
  }
  const { saveContactPluginData } = await import('../application/services/contactPluginService')
  await saveContactPluginData(created.id, pluginPayload)

  let relation = null
  if (relationLink?.targetId && String(relationLink.targetId) !== String(created.id)) {
    relation = await store.createRelation({
      source: created.id,
      target: relationLink.targetId,
      relation_type: relationLink.type,
      description: relationLink.description || '',
      interaction_intensity: relationLink.intensity,
    })
  }

  createContactOpen.value = false
  clearLinkSelection()
  await ensureGraphReady({ showSpinner: false })
  if (relation) appendRelationEdge(relation)
}

const store = useContactsStore()
const mapsStore = useNetworkMapsStore()
const router = useRouter()
const graphToolbarActions = getGraphToolbarActions()
const graphArea = ref(null)
const graphStack = ref(null)
const graphContainer = ref(null)
const loading = ref(true)
const isFullscreen = ref(false)
const filtersOpen = ref(false)
const chromeCollapsed = ref(loadTopPanelCollapsed('graph'))
const network = ref(null)
const physicsEnabled = ref(true)
const selectedNode = ref(null)
const relationModalOpen = ref(false)
const relationPair = ref(null)
const editRelationOpen = ref(false)
const editRelationTarget = ref(null)

const {
  linkSelection,
  linkSelectionCount,
  clearLinkSelection,
  handleCtrlPickNode,
} = useCtrlLinkSelection({
  onPairSelected(pair) {
    relationPair.value = pair
    relationModalOpen.value = true
  },
})

let nodesDS = null
let edgesDS = null
let initRetryCount = 0
const INIT_RETRY_MAX = 40
let initRetryTimer = null
let resizeObserver = null
let syncedRevision = -1
let graphViewActive = false
let layoutSnapshotOnLeave = null
let initialLayoutDone = false

const nodes = ref([])
const edges = ref([])
const allRelationTypes = ref([])
const activeFilters = ref([])
const clusterMap = ref(new Map())
let prevLinkSelectionIds = new Set()

const selectedContact = computed(() =>
  selectedNode.value ? store.contactById(selectedNode.value.id) : null
)

const graphContactOptions = computed(() =>
  store.contacts.map((c) => ({
    value: String(c.id),
    label: [c.name, c.organization].filter(Boolean).join(' · '),
  }))
)

function cssVar(name, fallback) {
  const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
  return value || fallback
}

function graphPalette() {
  return {
    nodeBackground: cssVar('--surface-alt', '#22263a'),
    nodeBorder: cssVar('--accent', '#5b8dee'),
    nodeLinkBorder: cssVar('--green', '#4ecca3'),
    nodeHighlightBackground: cssVar('--surface', '#1a1d27'),
    nodeHighlightBorder: cssVar('--accent-hover', '#7aa5f5'),
    nodeFont: cssVar('--text', '#e2e6f3'),
    edgeFont: cssVar('--text-muted', '#7b82a6'),
  }
}

function recomputeClusters() {
  clusterMap.value = computeClusterMap(nodes.value, filteredEdges())
}

function updateClustersLocal(seedNodeIds) {
  const map = clusterMap.value
  const affected = updateClusterMapForNodes(
    map,
    nodes.value,
    filteredEdges(),
    seedNodeIds
  )
  clusterMap.value = map
  return affected
}

function refreshNodeStylesForIds(nodeIds) {
  if (!nodesDS || !nodeIds?.size) return
  const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
  const livePositions = network.value?.getPositions() || {}
  const idSet = new Set([...nodeIds].map(String))
  const targets = nodes.value.filter((n) => idSet.has(String(n.id)))
  if (!targets.length) return
  nodesDS.update(
    targets.map((n) => {
      const id = String(n.id)
      const vis = mapGraphNodeToVis(n, linkIds)
      const pos = livePositions[id]
      return pos ? { ...vis, x: pos.x, y: pos.y } : vis
    })
  )
  network.value?.redraw()
}

function refreshNodeStyles() {
  refreshNodeStylesForIds(new Set(nodes.value.map((n) => String(n.id))))
}

function withPhysicsPaused(fn) {
  if (!network.value) {
    fn()
    return
  }

  const savedPositions = network.value.getPositions()
  const savedView = {
    position: network.value.getViewPosition(),
    scale: network.value.getScale(),
  }
  const wasEnabled = physicsEnabled.value

  if (wasEnabled) {
    network.value.setOptions({ physics: physicsOptions(false) })
  }

  try {
    fn()
  } finally {
    if (nodesDS && savedPositions) {
      nodesDS.update(
        Object.entries(savedPositions).map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
      )
    }
    if (wasEnabled) {
      network.value.setOptions({ physics: physicsOptions(true) })
    }
    if (savedView.position) {
      network.value.moveTo({
        position: savedView.position,
        scale: savedView.scale || 1,
        animation: false,
      })
    }
    saveLayoutSnapshot()
  }
}

function applyLocalEdgeChange(seedNodeIds, mutate) {
  withPhysicsPaused(() => {
    mutate()
    const affected = updateClustersLocal(seedNodeIds)
    const styleIds = new Set([...seedNodeIds].map(String))
    affected.forEach((id) => styleIds.add(id))
    refreshNodeStylesForIds(styleIds)
  })
}

function nodeDegree(id) {
  const sid = String(id)
  return filteredEdges().filter(
    (e) => String(e.from) === sid || String(e.to) === sid
  ).length
}

function isDarkTheme() {
  return document.documentElement.getAttribute('data-theme') === 'dark'
}

function nodeVisColor(nodeId, linkIds) {
  const sid = String(nodeId)
  const palette = graphPalette()
  if (linkIds.has(sid)) {
    return {
      background: palette.nodeBackground,
      border: palette.nodeLinkBorder,
      highlight: {
        background: palette.nodeHighlightBackground,
        border: palette.nodeHighlightBorder,
      },
    }
  }
  const cc = clusterColor(clusterMap.value.get(sid), isDarkTheme())
  return {
    background: cc.bg,
    border: cc.border,
    highlight: { background: cc.highlight, border: cc.border },
  }
}

function mapGraphNodeToVis(n, linkIds = new Set()) {
  const palette = graphPalette()
  const degree = nodeDegree(n.id)
  const label = degree <= 12 ? (n.label || String(n.id)) : ''
  return {
    id: String(n.id),
    label,
    title: [n.label, n.title].filter(Boolean).join('\n'),
    color: nodeVisColor(n.id, linkIds),
    font: { color: palette.nodeFont, size: degree > 0 ? 12 : 11 },
    shape: 'dot',
    size: degree > 0 ? 10 + Math.min(degree, 4) * 1.5 : 8,
    borderWidth: linkIds.has(String(n.id)) ? 3 : 2,
  }
}

function applyLinkHighlights() {
  const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
  const affected = new Set([...linkIds, ...prevLinkSelectionIds])
  prevLinkSelectionIds = linkIds
  refreshNodeStylesForIds(affected)
}

function mapGraphEdgeToVis(e) {
  const palette = graphPalette()
  const rc = RELATION_COLORS[e.relation_type] || RELATION_COLORS.other
  const intensity = applyIntensityToVisEdge(e, { color: rc.color, highlight: rc.highlight })
  return {
    id: String(e.id),
    from: String(e.from),
    to: String(e.to),
    relation_type: e.relation_type,
    interaction_intensity: e.interaction_intensity,
    ...intensity,
    hoverWidth: intensity.width + 8,
    selectionWidth: intensity.width + 12,
    font: { color: palette.edgeFont, size: 10, align: 'middle' },
    arrows: { to: { enabled: false } },
    smooth: false,
  }
}

function resolveRelationForEdit(edge) {
  const fromStore = store.relations.find((r) => String(r.id) === String(edge.id))
  if (fromStore) return fromStore
  const fromNode = nodes.value.find((n) => String(n.id) === String(edge.from))
  const toNode = nodes.value.find((n) => String(n.id) === String(edge.to))
  return {
    id: edge.id,
    source: edge.from,
    target: edge.to,
    relation_type: edge.relation_type,
    description: edge.title && edge.title !== edge.relation_type ? edge.title : '',
    interaction_intensity: edge.interaction_intensity || 'intense',
    source_name: fromNode?.label || String(edge.from),
    target_name: toNode?.label || String(edge.to),
  }
}

function openEditRelation(edge) {
  editRelationTarget.value = resolveRelationForEdit(edge)
  editRelationOpen.value = true
}

function closeEditRelation() {
  editRelationOpen.value = false
  editRelationTarget.value = null
}

function updateGraphEdge(relation) {
  const edge = edgeFromRelation(relation)
  const idx = edges.value.findIndex((e) => String(e.id) === String(edge.id))
  if (idx !== -1) edges.value[idx] = edge
  else edges.value.push(edge)

  if (!edgesDS) return
  const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
  if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
  const visible =
    activeFilters.value.length === allRelationTypes.value.length ||
    activeFilters.value.includes(edge.relation_type)
  if (!visible) {
    if (edgesDS.get(String(edge.id))) edgesDS.remove(String(edge.id))
    return
  }
  const visEdge = mapGraphEdgeToVis(edge)
  if (edgesDS.get(String(edge.id))) edgesDS.update(visEdge)
  else edgesDS.add(visEdge)
}

function onRelationUpdated(relation) {
  closeEditRelation()
  syncedRevision = store.dataRevision
  const edge = edgeFromRelation(relation)
  const seedNodeIds = [edge.from, edge.to]
  applyLocalEdgeChange(seedNodeIds, () => {
    updateGraphEdge(relation)
  })
}

function removeGraphEdge(relationId) {
  const sid = String(relationId)
  const removed = edges.value.find((e) => String(e.id) === sid)
  const seedNodeIds = removed ? [removed.from, removed.to] : []
  applyLocalEdgeChange(seedNodeIds, () => {
    edges.value = edges.value.filter((e) => String(e.id) !== sid)
    if (edgesDS?.get(sid)) edgesDS.remove(sid)
  })
}

function onRelationDeleted(relationId) {
  closeEditRelation()
  syncedRevision = store.dataRevision
  removeGraphEdge(relationId)
}

function appendRelationEdge(relation) {
  if (!relation) return
  const edge = edgeFromRelation(relation)
  const seedNodeIds = [edge.from, edge.to]

  applyLocalEdgeChange(seedNodeIds, () => {
    if (!edges.value.some((e) => String(e.id) === String(edge.id))) {
      edges.value.push(edge)
    }
    if (!edgesDS) return

    const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
    if (!nodeIds.has(String(edge.from)) || !nodeIds.has(String(edge.to))) return
    if (
      activeFilters.value.length < allRelationTypes.value.length &&
      !activeFilters.value.includes(edge.relation_type)
    ) {
      return
    }
    if (!edgesDS.get(String(edge.id))) {
      edgesDS.add(mapGraphEdgeToVis(edge))
    }
  })
}

function closeRelationModal() {
  relationModalOpen.value = false
  relationPair.value = null
  clearLinkSelection()
  applyLinkHighlights()
}

function onRelationCreated(relation) {
  syncedRevision = store.dataRevision
  relationModalOpen.value = false
  relationPair.value = null
  clearLinkSelection()
  applyLinkHighlights()
  appendRelationEdge(relation)
}

watch(linkSelection, () => {
  applyLinkHighlights()
}, { deep: true })

function physicsOptions(enabled) {
  return {
    enabled,
    solver: 'forceAtlas2Based',
    forceAtlas2Based: {
      gravitationalConstant: -120,
      centralGravity: 0.002,
      springLength: 200,
      springConstant: 0.035,
      damping: 0.5,
      avoidOverlap: 1,
    },
    stabilization: enabled
      ? { iterations: 200, fit: !initialLayoutDone, updateInterval: 25 }
      : undefined,
    maxVelocity: 20,
  }
}

async function applyGraphDataFromStore() {
  if (!store.contacts.length) await store.fetchContacts()
  if (!store.relations.length) await store.fetchRelations()
  if (!store.relationTypes.length) {
    allRelationTypes.value = await store.fetchRelationTypes()
  } else {
    allRelationTypes.value = store.relationTypes
  }
  const bundle = buildGraphFromStore(store.contacts, store.relations, allRelationTypes.value)
  nodes.value = bundle.nodes
  edges.value = bundle.edges
  if (!activeFilters.value.length && allRelationTypes.value.length) {
    activeFilters.value = allRelationTypes.value.map((r) => r.value)
  }
  recomputeClusters()
}

function saveLayoutSnapshot() {
  if (!network.value) return
  writeGraphLayoutCache({
    positions: network.value.getPositions(),
    scale: network.value.getScale(),
    view: network.value.getViewPosition(),
  })
}

function captureLayoutSnapshot() {
  if (!network.value) return null
  const view = network.value.getViewPosition()
  return {
    positions: { ...network.value.getPositions() },
    scale: network.value.getScale(),
    view: view ? { x: view.x, y: view.y } : null,
  }
}

function applyLayoutSnapshot(snapshot) {
  if (!network.value || !nodesDS || !snapshot) return
  const updates = Object.entries(snapshot.positions || {})
    .filter(([id]) => nodesDS.get(id))
    .map(([id, pos]) => ({ id, x: pos.x, y: pos.y }))
  if (updates.length) nodesDS.update(updates)
  if (snapshot.view) {
    network.value.moveTo({
      position: snapshot.view,
      scale: snapshot.scale || 1,
      animation: false,
    })
  }
  writeGraphLayoutCache(snapshot)
}

function resizeNetworkCanvas() {
  if (!network.value || !graphContainer.value) return
  const { offsetWidth: w, offsetHeight: h } = graphContainer.value
  if (w > 10 && h > 10) {
    network.value.setSize(`${w}px`, `${h}px`)
  }
}

function restoreViewport() {
  if (!network.value) return
  const cache = readGraphLayoutCache()
  if (cache.view) {
    network.value.moveTo({
      position: cache.view,
      scale: cache.scale || 1,
      animation: false,
    })
  }
}

function finishInitialLayout({ fitView = true } = {}) {
  saveLayoutSnapshot()
  initialLayoutDone = true
  if (fitView) {
    network.value?.fit({ animation: false })
  }
  network.value?.redraw()
}

function teardownNetwork() {
  if (initRetryTimer) {
    clearTimeout(initRetryTimer)
    initRetryTimer = null
  }
  detachContextHandler?.()
  detachContextHandler = null
  resizeObserver?.disconnect()
  resizeObserver = null
  network.value?.destroy()
  network.value = null
  nodesDS = null
  edgesDS = null
}

function syncGraphMetadataOnly(lockedPositions) {
  if (!network.value || !nodesDS || !edgesDS) return

  const positions = lockedPositions || network.value.getPositions()
  const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))

  const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
  nodesDS.getIds().forEach((id) => {
    if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
  })

  nodes.value.forEach((n) => {
    const id = String(n.id)
    const vis = mapGraphNodeToVis(n, linkIds)
    const pos = positions[id]
    const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
    if (nodesDS.get(id)) nodesDS.update(payload)
    else nodesDS.add(payload)
  })

  const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
  const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
  edgesDS.getIds().forEach((id) => {
    if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
  })
  nextEdges.forEach((edge) => {
    if (edgesDS.get(edge.id)) edgesDS.update(edge)
    else edgesDS.add(edge)
  })

  recomputeClusters()
}

function syncGraphToNetwork() {
  if (!network.value || !nodesDS || !edgesDS) return

  const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
  const cachedPositions = readGraphLayoutCache().positions || {}
  const livePositions = network.value.getPositions()
  const seeds = computeGraphSeedPositions(nodes.value, filteredEdges())

  const nextNodeIds = new Set(nodes.value.map((n) => String(n.id)))
  nodesDS.getIds().forEach((id) => {
    if (!nextNodeIds.has(String(id))) nodesDS.remove(id)
  })

  nodes.value.forEach((n) => {
    const id = String(n.id)
    const vis = mapGraphNodeToVis(n, linkIds)
    const pos = cachedPositions[id] || livePositions[id] || seeds.get(id)
    const payload = pos ? { ...vis, x: pos.x, y: pos.y } : vis
    if (nodesDS.get(id)) nodesDS.update(payload)
    else nodesDS.add(payload)
  })

  const nextEdges = filteredEdges().map(mapGraphEdgeToVis)
  const nextEdgeIds = new Set(nextEdges.map((e) => String(e.id)))
  edgesDS.getIds().forEach((id) => {
    if (!nextEdgeIds.has(String(id))) edgesDS.remove(id)
  })
  nextEdges.forEach((edge) => {
    if (edgesDS.get(edge.id)) edgesDS.update(edge)
    else edgesDS.add(edge)
  })

  recomputeClusters()
  refreshNodeStyles()
  syncedRevision = store.dataRevision
  restoreViewport()
}

let ensureGraphReadyInFlight = null

async function ensureGraphReady({ showSpinner = true } = {}) {
  if (ensureGraphReadyInFlight) return ensureGraphReadyInFlight
  ensureGraphReadyInFlight = (async () => {
  const reconnecting = Boolean(network.value)
  if (showSpinner && !reconnecting) loading.value = true
  try {
    await applyGraphDataFromStore()
  } finally {
    if (showSpinner && !reconnecting) loading.value = false
  }

  if (nodes.value.length === 0) {
    teardownNetwork()
    syncedRevision = store.dataRevision
    return
  }

  await nextTick()
  await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))

  if (!network.value) {
    initNetwork()
    syncedRevision = store.dataRevision
    return
  }

  if (syncedRevision !== store.dataRevision) {
    syncGraphToNetwork()
  }
  })().finally(() => {
    ensureGraphReadyInFlight = null
  })
  return ensureGraphReadyInFlight
}

function filteredEdges() {
  const nodeIds = new Set(nodes.value.map((n) => String(n.id)))
  let list = edges.value.filter(
    (e) => nodeIds.has(String(e.from)) && nodeIds.has(String(e.to)),
  )
  if (activeFilters.value.length < allRelationTypes.value.length) {
    list = list.filter((e) => activeFilters.value.includes(e.relation_type))
  }
  return list
}

function initNetwork() {
  if (network.value) return
  if (!graphContainer.value) {
    if (initRetryCount >= INIT_RETRY_MAX) return
    initRetryCount += 1
    initRetryTimer = setTimeout(initNetwork, 80)
    return
  }
  const el = graphContainer.value
  let width = el.offsetWidth
  let height = el.offsetHeight
  if (width < 10 || height < 10) {
    if (initRetryCount >= INIT_RETRY_MAX) {
      el.style.height = '400px'
      el.style.width = '100%'
      width = el.offsetWidth
      height = el.offsetHeight
    } else {
      initRetryCount += 1
      initRetryTimer = setTimeout(initNetwork, 80)
      return
    }
  }
  initRetryCount = 0
  if (initRetryTimer) {
    clearTimeout(initRetryTimer)
    initRetryTimer = null
  }

  const linkIds = new Set(linkSelection.value.map((item) => String(item.id)))
  const cache = readGraphLayoutCache()
  const cachedPositions = cache.positions || {}
  const nodeIds = nodes.value.map((n) => String(n.id))
  const cachedCount = nodeIds.filter((id) => cachedPositions[id]).length
  const useCachedLayout = cachedCount > 0 && cachedCount >= nodeIds.length * 0.8

  const seedPositions = computeGraphSeedPositions(nodes.value, filteredEdges())
  const nodeList = nodes.value.map((n) => {
    const vis = mapGraphNodeToVis(n, linkIds)
    const id = String(n.id)
    const pos = cachedPositions[id] || seedPositions.get(id)
    return pos ? { ...vis, x: pos.x, y: pos.y } : vis
  })
  const edgeList = filteredEdges().map(mapGraphEdgeToVis)

  nodesDS = new DataSet(nodeList)
  edgesDS = new DataSet(edgeList)

  if (useCachedLayout) {
    initialLayoutDone = true
  }

  network.value = new Network(
    el,
    { nodes: nodesDS, edges: edgesDS },
    {
      layout: { improvedLayout: false },
      physics: physicsOptions(physicsEnabled.value),
      interaction: {
        tooltipDelay: 200,
        hover: true,
        hideEdgesOnDrag: true,
        selectConnectedEdges: false,
        zoomView: true,
        dragView: true,
        dragNodes: true,
      },
      edges: {
        chosen: { label: false },
      },
      nodes: { borderWidth: 1.5 },
    }
  )

  network.value.on('click', (params) => {
    closeContextMenu()
    if (params.nodes.length > 0) {
      const id = params.nodes[0]
      const node = nodes.value.find((n) => String(n.id) === id)
      const domEvent = params.event?.srcEvent || params.event
      if (domEvent && (domEvent.ctrlKey || domEvent.metaKey)) {
        handleCtrlPickNode(node, domEvent)
      }
    }
  })

  network.value.on('dragEnd', () => {
    saveLayoutSnapshot()
  })

  detachContextHandler?.()
  detachContextHandler = attachNodeContextHandlers(
    network.value,
    () => nodes.value,
    () => edges.value
  )

  if (useCachedLayout) {
    restoreViewport()
    network.value.redraw()
    finishInitialLayout({ fitView: false })
  } else {
    let layoutFinished = false
    const completeLayout = () => {
      if (layoutFinished) return
      layoutFinished = true
      finishInitialLayout({ fitView: true })
    }
    network.value.once('stabilizationIterationsDone', completeLayout)
    network.value.once('stabilized', completeLayout)
    setTimeout(completeLayout, 2500)
  }

  resizeObserver = new ResizeObserver(() => {
    network.value?.redraw()
  })
  resizeObserver.observe(el)
}

function toggleFilter(type) {
  if (activeFilters.value.includes(type)) {
    if (activeFilters.value.length === 1) return
    activeFilters.value = activeFilters.value.filter((f) => f !== type)
  } else {
    activeFilters.value.push(type)
  }
  if (edgesDS) {
    recomputeClusters()
    edgesDS.clear()
    edgesDS.add(filteredEdges().map(mapGraphEdgeToVis))
    refreshNodeStyles()
  }
}

function applyThemeToNetwork() {
  if (!nodesDS || !edgesDS || !network.value) return
  refreshNodeStyles()
  const palette = graphPalette()
  edgesDS.update(
    filteredEdges().map((e) => ({
      id: String(e.id),
      font: { color: palette.edgeFont, size: 10, align: 'middle' },
    }))
  )
  network.value.redraw()
}

function fitView() {
  network.value?.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' }, padding: 56 })
}

function togglePhysics() {
  physicsEnabled.value = !physicsEnabled.value
  network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
}

async function toggleFullscreen() {
  const el = graphStack.value
  if (!el) return
  try {
    if (document.fullscreenElement === el) {
      await document.exitFullscreen()
    } else {
      await el.requestFullscreen()
    }
  } catch {
    // Browser may block fullscreen without user gesture or in unsupported context.
  }
}

function onFullscreenChange() {
  isFullscreen.value = document.fullscreenElement === graphStack.value
  nextTick(() => {
    network.value?.redraw()
    network.value?.fit({ animation: false })
  })
}

function toggleChrome() {
  chromeCollapsed.value = !chromeCollapsed.value
  saveTopPanelCollapsed('graph', chromeCollapsed.value)
  nextTick(() => network.value?.redraw())
}

function runGraphToolbarAction(action) {
  action.onClick?.({ router })
}

watch(() => store.dataRevision, async (revision) => {
  if (!network.value || !graphViewActive) return
  await nextTick()
  if (revision === syncedRevision) return
  await applyGraphDataFromStore()
  if (nodes.value.length === 0) {
    teardownNetwork()
    syncedRevision = revision
    return
  }
  syncGraphToNetwork()
})

onMounted(() => {
  themeObserver = new MutationObserver(() => applyThemeToNetwork())
  themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
  document.addEventListener('fullscreenchange', onFullscreenChange)
  nextTick(() => {
    if (!network.value) ensureGraphReady()
  })
})

onBeforeRouteLeave(() => {
  if (network.value && physicsEnabled.value) {
    network.value.setOptions({ physics: physicsOptions(false) })
  }
  layoutSnapshotOnLeave = captureLayoutSnapshot()
  saveLayoutSnapshot()
})

onActivated(async () => {
  graphViewActive = true
  const snapshot = layoutSnapshotOnLeave
  layoutSnapshotOnLeave = null

  if (network.value) {
    network.value.setOptions({ physics: physicsOptions(false) })
    await nextTick()
    await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
    resizeNetworkCanvas()

    const lockedPositions = snapshot?.positions
      || readGraphLayoutCache().positions
      || {}

    if (syncedRevision !== store.dataRevision) {
      await applyGraphDataFromStore()
      syncGraphMetadataOnly(lockedPositions)
      syncedRevision = store.dataRevision
    }

    if (snapshot) {
      applyLayoutSnapshot(snapshot)
    } else {
      restoreViewport()
    }

    if (physicsEnabled.value) {
      network.value.setOptions({ physics: physicsOptions(true) })
    }
    network.value.redraw()
    return
  }
  await ensureGraphReady()
})

onDeactivated(() => {
  graphViewActive = false
  if (network.value && physicsEnabled.value) {
    network.value.setOptions({ physics: physicsOptions(false) })
  }
  if (!layoutSnapshotOnLeave) {
    layoutSnapshotOnLeave = captureLayoutSnapshot()
  }
  saveLayoutSnapshot()
})

onUnmounted(() => {
  saveLayoutSnapshot()
  closeContextMenu()
  document.removeEventListener('fullscreenchange', onFullscreenChange)
  if (document.fullscreenElement === graphStack.value) {
    document.exitFullscreen().catch(() => {})
  }
  themeObserver?.disconnect()
  teardownNetwork()
})
</script>

<style scoped>
.graph-view {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
.graph-chrome-bar {
  display: flex;
  justify-content: center;
  flex-shrink: 0;
  margin-bottom: 6px;
}
.graph-chrome-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text-muted);
  font-size: 11px;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.graph-chrome-toggle:hover {
  color: var(--text);
  border-color: var(--accent);
  background: var(--surface-alt);
}
.graph-chrome-toggle svg {
  transition: transform 0.2s ease;
}
.graph-chrome-toggle__icon--collapsed {
  transform: rotate(180deg);
}
.graph-view--chrome-collapsed .graph-area {
  padding-top: 4px;
}
.graph-area {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: 0 28px 20px;
}
.graph-stack {
  position: relative;
  flex: 1;
  min-height: 300px;
  width: 100%;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}
.graph-stack:fullscreen {
  border-radius: 0;
  border: none;
  background: var(--bg);
}
.graph-view-tools {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 8px;
}
.graph-fit-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  box-shadow: var(--shadow);
}
.graph-fullscreen-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text-muted);
  cursor: pointer;
  box-shadow: var(--shadow);
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.graph-fullscreen-btn:hover {
  color: var(--text);
  border-color: var(--accent);
  background: var(--surface-alt);
}
.graph-stack:fullscreen .graph-view-tools {
  top: 16px;
  right: 16px;
}
#graph-container {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
</style>
