import { create } from 'zustand'
import {
  type Node,
  type Edge,
  type NodeChange,
  type EdgeChange,
  type Connection,
  applyNodeChanges,
  applyEdgeChanges,
} from '@xyflow/react'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handleId, SIDES } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils'
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'

type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }

/** Resolve a node's effective parent id from either the RF field or domain data. */
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined

/** Key for the live per-service status overlay. */
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
  `${nodeId}:${port ?? ''}/${protocol ?? ''}`

interface CanvasState {
  nodes: Node<NodeData>[]
  edges: Edge<EdgeData>[]
  hasUnsavedChanges: boolean
  selectedNodeId: string | null
  selectedNodeIds: string[]
  scanEventTs: number
  // Live per-service status overlay (not persisted), keyed via serviceStatusKey.
  serviceStatuses: Record<string, ServiceStatus>

  floorMap: FloorMapConfig | null
  setFloorMap: (config: FloorMapConfig | null) => void
  updateFloorMap: (patch: Partial<FloorMapConfig>) => void
  // Bumped when the user double-clicks the floor plan on the canvas, asking the
  // Sidebar to open the active canvas's edit modal (floor plan section).
  floorMapEditNonce: number
  requestFloorMapEdit: () => void

  // History
  past: HistoryEntry[]
  future: HistoryEntry[]
  snapshotHistory: () => void
  undo: () => void
  redo: () => void

  // Clipboard — survives design switches so nodes can be pasted into another design
  clipboard: Clipboard
  copySelectedNodes: () => void
  /** Paste clipboard into the current canvas. `center` (flow coords) lands the
   *  pasted bounding-box center under the cursor / viewport center. */
  pasteNodes: (center?: { x: number; y: number }) => void

  onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
  onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
  onConnect: (connection: Connection) => void
  setSelectedNode: (id: string | null) => void
  addNode: (node: Node<NodeData>) => void
  updateNode: (id: string, data: Partial<NodeData>) => void
  deleteNode: (id: string) => void
  updateEdge: (id: string, data: Partial<EdgeData>) => void
  reconnectEdge: (id: string, connection: Connection) => void
  deleteEdge: (id: string) => void
  setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
  setNodeZIndex: (id: string, zIndex: number) => void
  setNodeSize: (id: string, size: { width?: number; height?: number }) => void
  editingGroupRectId: string | null
  setEditingGroupRectId: (id: string | null) => void
  editingTextId: string | null
  setEditingTextId: (id: string | null) => void
  toggleNodeCollapsed: (id: string) => void
  createGroup: (nodeIds: string[], name: string) => void
  ungroup: (groupId: string) => void
  addToGroup: (groupId: string, childId: string) => void
  addToContainer: (containerId: string, childId: string) => void
  removeFromGroup: (groupId: string, childId: string) => void
  markSaved: () => void
  markUnsaved: () => void
  loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
  fitViewPending: boolean
  clearFitViewPending: () => void
  notifyScanDeviceFound: () => void
  setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
  hideIp: boolean
  toggleHideIp: () => void
  setHideIp: (value: boolean) => void
  applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
  applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
  applyAllCustomStyles: (def: CustomStyleDef) => void
}

export const useCanvasStore = create<CanvasState>((set) => ({
  nodes: [],
  edges: [],
  hasUnsavedChanges: false,
  selectedNodeId: null,
  selectedNodeIds: [],
  editingGroupRectId: null,
  editingTextId: null,
  hideIp: readHideIp(),
  scanEventTs: 0,
  serviceStatuses: {},
  floorMap: null,
  floorMapEditNonce: 0,
  fitViewPending: false,

  past: [],
  future: [],
  clipboard: { nodes: [], edges: [] },

  snapshotHistory: () =>
    set((state) => ({
      past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
      future: [],
    })),

  undo: () =>
    set((state) => {
      if (state.past.length === 0) return state
      const previous = state.past[state.past.length - 1]
      return {
        nodes: previous.nodes,
        edges: previous.edges,
        past: state.past.slice(0, -1),
        future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)],
        hasUnsavedChanges: true,
      }
    }),

  redo: () =>
    set((state) => {
      if (state.future.length === 0) return state
      const next = state.future[0]
      return {
        nodes: next.nodes,
        edges: next.edges,
        past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
        future: state.future.slice(1),
        hasUnsavedChanges: true,
      }
    }),

  copySelectedNodes: () =>
    set((state) => {
      // Start from explicitly selected nodes, then pull in all descendants so a
      // copied group / container brings its children along.
      const ids = new Set(state.nodes.filter((n) => n.selected).map((n) => n.id))
      if (ids.size === 0) return { clipboard: { nodes: [], edges: [] } }
      let grew = true
      while (grew) {
        grew = false
        for (const n of state.nodes) {
          const pid = parentIdOf(n)
          if (pid && ids.has(pid) && !ids.has(n.id)) {
            ids.add(n.id)
            grew = true
          }
        }
      }
      const nodes = state.nodes.filter((n) => ids.has(n.id))
      // Keep only edges whose both endpoints are inside the copied set.
      const edges = state.edges.filter((e) => ids.has(e.source) && ids.has(e.target))
      return { clipboard: { nodes, edges } }
    }),

  pasteNodes: (center) =>
    set((state) => {
      const clip = state.clipboard
      if (clip.nodes.length === 0) return state

      // Fresh ids for every copied node; edges/parent links are remapped through it.
      const idMap = new Map<string, string>()
      clip.nodes.forEach((n) => idMap.set(n.id, generateUUID()))

      // A "root" is a copied node whose parent was not also copied — these carry
      // absolute positions and receive the paste offset; children move with them.
      const isRoot = (n: Node<NodeData>) => {
        const pid = parentIdOf(n)
        return !pid || !idMap.has(pid)
      }
      const roots = clip.nodes.filter(isRoot)

      // Default cascade offset; when a target center is given, shift the root
      // bounding-box center onto it instead.
      let offsetX = 50
      let offsetY = 50
      if (center && roots.length > 0) {
        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
        for (const n of roots) {
          const w = n.width ?? n.measured?.width ?? 200
          const h = n.height ?? n.measured?.height ?? 80
          minX = Math.min(minX, n.position.x)
          minY = Math.min(minY, n.position.y)
          maxX = Math.max(maxX, n.position.x + w)
          maxY = Math.max(maxY, n.position.y + h)
        }
        offsetX = center.x - (minX + maxX) / 2
        offsetY = center.y - (minY + maxY) / 2
      }

      const pasted = clip.nodes.map((n) => {
        const root = isRoot(n)
        const newParentId = root ? undefined : idMap.get(parentIdOf(n)!)
        return {
          ...n,
          id: idMap.get(n.id)!,
          position: root
            ? { x: n.position.x + offsetX, y: n.position.y + offsetY }
            : { ...n.position },
          selected: true,
          parentId: newParentId,
          extent: newParentId ? ('parent' as const) : undefined,
          data: { ...n.data, parent_id: newParentId },
        }
      })

      const pastedEdges = clip.edges.map((e) => ({
        ...e,
        id: generateUUID(),
        source: idMap.get(e.source)!,
        target: idMap.get(e.target)!,
        selected: false,
      }))

      // React Flow requires parents before children within the appended block.
      const parents = pasted.filter((n) => !n.parentId)
      const children = pasted.filter((n) => !!n.parentId)
      const pastedNodes = [...parents, ...children]

      // Deselect everything already on the canvas so only the paste is selected.
      const existing = state.nodes.map((n) => (n.selected ? { ...n, selected: false } : n))

      return {
        nodes: [...existing, ...pastedNodes],
        edges: [...state.edges, ...pastedEdges],
        selectedNodeId: null,
        selectedNodeIds: pastedNodes.map((n) => n.id),
        past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
        future: [],
        hasUnsavedChanges: true,
      }
    }),

  onNodesChange: (changes) =>
    set((state) => {
      const nodes = applyNodeChanges(changes, state.nodes)
      const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id)
      return {
        nodes,
        selectedNodeIds,
        hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
      }
    }),

  onEdgesChange: (changes) =>
    set((state) => ({
      edges: applyEdgeChanges(changes, state.edges),
      hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
    })),

  onConnect: (connection) =>
    set((state) => {
      const extra = connection as Connection & Partial<EdgeData>
      const edgeType = extra.type ?? 'ethernet'
      // Build the edge with our own unique id and append directly instead of
      // React Flow's addEdge(): addEdge silently drops any new edge whose
      // source+target already match an existing edge when handles are null/equal
      // (connectionExists dedupe). A homelab legitimately has multiple links
      // between the same two devices, so we allow them.
      const newEdge: Edge<EdgeData> = {
        id: `edge-${generateUUID()}`,
        source: connection.source,
        target: connection.target,
        sourceHandle: normalizeHandle(extra.sourceHandle),
        targetHandle: normalizeHandle(extra.targetHandle),
        type: edgeType,
        data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, line_style: extra.line_style, width_mult: extra.width_mult, animated: extra.animated, marker_start: extra.marker_start, marker_end: extra.marker_end },
      }
      return {
        edges: [...state.edges, newEdge],
        hasUnsavedChanges: true,
      }
    }),

  setSelectedNode: (id) => set({
    selectedNodeId: id,
    selectedNodeIds: id ? [id] : [],
  }),

  addNode: (node) =>
    set((state) => {
      const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
      const shouldNestInParent = !!(parent?.data.container_mode)
      const enriched = node.data.parent_id && shouldNestInParent
        ? {
            ...node,
            parentId: node.data.parent_id,
            extent: 'parent' as const,
            position: {
              x: Math.max(10, node.position.x - parent.position.x),
              y: Math.max(10, node.position.y - parent.position.y),
            },
          }
        // Not nesting: strip any parentId/extent a caller may have set so a
        // non-container parent can't trap the node in its bounding box.
        : { ...node, parentId: undefined, extent: undefined }
      // Parents must come before children in the array (React Flow requirement)
      const withoutNew = state.nodes.filter((n) => n.id !== node.id)
      if (enriched.parentId) {
        const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId)
        const insertAt = parentIdx >= 0 ? parentIdx + 1 : withoutNew.length
        const nodes = [...withoutNew.slice(0, insertAt), enriched, ...withoutNew.slice(insertAt)]
        return { nodes, hasUnsavedChanges: true }
      }
      return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
    }),

  updateNode: (id, data) =>
    set((state) => {
      let nodes = state.nodes.map((n) => {
        if (n.id !== id) return n
        const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
        // When properties change, clear stored height so the node auto-sizes to fit new content
        if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect' && n.data.type !== 'group') {
          updated.height = undefined
        }
        if ('parent_id' in data) {
          const newParentId = data.parent_id ?? undefined
          if (!newParentId && n.parentId) {
            // Detaching from a container: convert position back to absolute canvas coords
            const parent = state.nodes.find((p) => p.id === n.parentId)
            if (parent) {
              updated.position = {
                x: parent.position.x + n.position.x,
                y: parent.position.y + n.position.y,
              }
            }
            updated.parentId = undefined
            updated.extent = undefined
          } else if (newParentId && newParentId !== n.parentId) {
            const parent = state.nodes.find((p) => p.id === newParentId)
            if (parent?.data.container_mode) {
              // Attaching to a container-mode Proxmox: nest visually
              updated.parentId = newParentId
              updated.extent = 'parent' as const
              // Convert absolute position to parent-relative (keep node visible inside)
              updated.position = {
                x: Math.max(10, n.position.x - parent.position.x),
                y: Math.max(10, n.position.y - parent.position.y),
              }
            }
          }
        }
        return updated
      })
      // React Flow requires parent nodes to precede their children in the array
      if ('parent_id' in data) {
        const parents = nodes.filter((n) => !n.parentId)
        const children = nodes.filter((n) => !!n.parentId)
        nodes = [...parents, ...children]
      }
      // Remap edges when any side's handle count is reduced so no edge disappears.
      // Removed handles fall back to the side's slot-0 id, or 'bottom' if the
      // side dropped to 0 (its slot-0 id no longer exists).
      let edges = state.edges
      const currentNode = state.nodes.find((n) => n.id === id)
      for (const side of SIDES) {
        const field = handleCountField(side)
        if (!(field in data) || data[field] == null) continue
        const oldCount = currentNode?.data[field] ?? sideDefault(side)
        const newCount = data[field] as number
        if (newCount >= oldCount) continue
        const removed = removedHandleIds(side, oldCount, newCount)
        const fallback = newCount === 0 ? 'bottom' : handleId(side, 0)
        edges = edges.map((e) => {
          if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
            return { ...e, sourceHandle: fallback }
          if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
            return { ...e, targetHandle: fallback }
          return e
        })
      }

      return { nodes, edges, hasUnsavedChanges: true }
    }),

  deleteNode: (id) =>
    set((state) => {
      const idsToRemove = new Set<string>()
      const collect = (nodeId: string) => {
        idsToRemove.add(nodeId)
        state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id))
      }
      collect(id)
      return {
        nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)),
        edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)),
        selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId,
        hasUnsavedChanges: true,
      }
    }),

  updateEdge: (id, data) =>
    set((state) => ({
      edges: state.edges.map((e) =>
        e.id === id ? { ...e, type: data.type ?? e.type, data: { ...e.data, ...data } as EdgeData } : e
      ),
      hasUnsavedChanges: true,
    })),

  reconnectEdge: (id, connection) =>
    set((state) => ({
      edges: state.edges.map((e) =>
        e.id === id
          ? {
              ...e,
              source: connection.source ?? e.source,
              target: connection.target ?? e.target,
              sourceHandle: normalizeHandle(connection.sourceHandle),
              targetHandle: normalizeHandle(connection.targetHandle),
            }
          : e
      ),
      past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
      future: [],
      hasUnsavedChanges: true,
    })),

  deleteEdge: (id) =>
    set((state) => ({
      edges: state.edges.filter((e) => e.id !== id),
      hasUnsavedChanges: true,
    })),

  setProxmoxContainerMode: (proxmoxId, enabled) =>
    set((state) => {
      const parentNode = state.nodes.find((n) => n.id === proxmoxId)
      let nodes = state.nodes.map((n) => {
        if (n.id === proxmoxId) {
          const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
          return enabled
            ? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
            : { ...withMode, width: undefined, height: undefined }
        }
        if (n.data.parent_id === proxmoxId) {
          if (enabled && parentNode) {
            return {
              ...n,
              parentId: proxmoxId,
              extent: 'parent' as const,
              position: {
                x: Math.max(10, n.position.x - parentNode.position.x),
                y: Math.max(10, n.position.y - parentNode.position.y),
              },
            }
          }
          if (!enabled && parentNode) {
            return {
              ...n,
              parentId: undefined,
              extent: undefined,
              position: {
                x: parentNode.position.x + n.position.x,
                y: parentNode.position.y + n.position.y,
              },
            }
          }
          return enabled
            ? { ...n, parentId: proxmoxId, extent: 'parent' as const }
            : { ...n, parentId: undefined, extent: undefined }
        }
        return n
      })
      if (enabled) {
        const parents = nodes.filter((n) => !n.parentId)
        const children = nodes.filter((n) => !!n.parentId)
        nodes = [...parents, ...children]
      }
      return { nodes, hasUnsavedChanges: true }
    }),

  setNodeZIndex: (id, zIndex) =>
    set((state) => ({
      nodes: state.nodes.map((n) => n.id === id ? { ...n, zIndex } : n),
      hasUnsavedChanges: true,
    })),

  // Manual width/height entry. Lets the user type an exact size instead of
  // dragging the resize handle (which lands on fractional content-fit pixels).
  // A clamp matches the NodeResizer minimums so the box can't collapse.
  setNodeSize: (id, size) =>
    set((state) => ({
      nodes: state.nodes.map((n) => {
        if (n.id !== id) return n
        return {
          ...n,
          ...(size.width != null ? { width: Math.max(140, size.width) } : {}),
          ...(size.height != null ? { height: Math.max(50, size.height) } : {}),
        }
      }),
      hasUnsavedChanges: true,
    })),

  setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),

  setEditingTextId: (id) => set({ editingTextId: id }),

  toggleNodeCollapsed: (id) =>
    set((state) => ({
      nodes: state.nodes.map((n) =>
        n.id === id
          ? { ...n, data: { ...n.data, collapsed: !n.data.collapsed } }
          : n
      ),
      hasUnsavedChanges: true,
    })),

  createGroup: (nodeIds, name) =>
    set((state) => {
      const PADDING_H = 24
      const PADDING_TOP = 48
      const PADDING_BOTTOM = 24
      const targets = state.nodes.filter((n) => nodeIds.includes(n.id))
      if (targets.length === 0) return state

      // Bounding box in absolute coordinates
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
      for (const n of targets) {
        const w = n.width ?? 200
        const h = n.height ?? 80
        minX = Math.min(minX, n.position.x)
        minY = Math.min(minY, n.position.y)
        maxX = Math.max(maxX, n.position.x + w)
        maxY = Math.max(maxY, n.position.y + h)
      }

      const groupX = minX - PADDING_H
      const groupY = minY - PADDING_TOP
      const groupW = maxX - minX + PADDING_H * 2
      const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM

      const groupId = generateUUID()
      const groupNode: Node<NodeData> = {
        id: groupId,
        type: 'group',
        position: { x: groupX, y: groupY },
        width: groupW,
        height: groupH,
        data: {
          label: name,
          type: 'group',
          status: 'unknown',
          services: [],
          custom_colors: { show_border: true },
        },
        selected: false,
      }

      // Convert children to relative positions and assign parentId
      const updatedNodes = state.nodes.map((n) => {
        if (!nodeIds.includes(n.id)) return n
        return {
          ...n,
          parentId: groupId,
          extent: 'parent' as const,
          position: {
            x: n.position.x - groupX,
            y: n.position.y - groupY,
          },
          selected: false,
          data: { ...n.data, parent_id: groupId },
        }
      })

      // Group node must come before its children
      const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id))
      const children = updatedNodes.filter((n) => nodeIds.includes(n.id))
      const nodes = [...withoutTargets, groupNode, ...children]

      return {
        nodes,
        selectedNodeIds: [],
        selectedNodeId: null,
        hasUnsavedChanges: true,
        past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
        future: [],
      }
    }),

  ungroup: (groupId) =>
    set((state) => {
      const group = state.nodes.find((n) => n.id === groupId)
      if (!group) return state

      const groupAbsX = group.position.x
      const groupAbsY = group.position.y

      const nodes = state.nodes
        .filter((n) => n.id !== groupId)
        .map((n) => {
          if (n.parentId !== groupId) return n
          return {
            ...n,
            parentId: undefined,
            extent: undefined,
            position: {
              x: n.position.x + groupAbsX,
              y: n.position.y + groupAbsY,
            },
            data: { ...n.data, parent_id: undefined },
          }
        })

      return {
        nodes,
        selectedNodeId: null,
        selectedNodeIds: [],
        hasUnsavedChanges: true,
        past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
        future: [],
      }
    }),

  // Nest an existing top-level node inside a group. Inverse of removeFromGroup.
  addToGroup: (groupId, childId) =>
    set((state) => {
      const group = state.nodes.find((n) => n.id === groupId)
      const child = state.nodes.find((n) => n.id === childId)
      if (!group || !child || group.data.type !== 'group') return state
      if (child.id === groupId || child.parentId === groupId) return state

      const updatedNodes = state.nodes.map((n) => {
        if (n.id !== childId) return n
        return {
          ...n,
          parentId: groupId,
          extent: 'parent' as const,
          // Absolute → group-relative. Clamp so the node stays inside the box.
          position: {
            x: Math.max(8, n.position.x - group.position.x),
            y: Math.max(8, n.position.y - group.position.y),
          },
          selected: false,
          data: { ...n.data, parent_id: groupId },
        }
      })

      // React Flow requires the parent to precede its children in the array.
      const others = updatedNodes.filter((n) => n.id !== childId)
      const movedChild = updatedNodes.find((n) => n.id === childId)!
      const groupIdx = others.findIndex((n) => n.id === groupId)
      const nodes = [
        ...others.slice(0, groupIdx + 1),
        movedChild,
        ...others.slice(groupIdx + 1),
      ]

      return {
        nodes,
        hasUnsavedChanges: true,
        past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
        future: [],
      }
    }),

  // Nest an existing top-level node inside a container node (proxmox /
  // docker_host / … in container_mode). Mirrors addToGroup but the target is
  // any node with data.container_mode === true rather than a group.
  addToContainer: (containerId, childId) =>
    set((state) => {
      const container = state.nodes.find((n) => n.id === containerId)
      const child = state.nodes.find((n) => n.id === childId)
      if (!container || !child || container.data.container_mode !== true) return state
      if (child.id === containerId || child.parentId === containerId) return state

      const updatedNodes = state.nodes.map((n) => {
        if (n.id !== childId) return n
        return {
          ...n,
          parentId: containerId,
          extent: 'parent' as const,
          // Absolute → container-relative. Clamp so the node stays inside.
          position: {
            x: Math.max(8, n.position.x - container.position.x),
            y: Math.max(8, n.position.y - container.position.y),
          },
          selected: false,
          data: { ...n.data, parent_id: containerId },
        }
      })

      // React Flow requires the parent to precede its children in the array.
      const others = updatedNodes.filter((n) => n.id !== childId)
      const movedChild = updatedNodes.find((n) => n.id === childId)!
      const containerIdx = others.findIndex((n) => n.id === containerId)
      const nodes = [
        ...others.slice(0, containerIdx + 1),
        movedChild,
        ...others.slice(containerIdx + 1),
      ]

      return {
        nodes,
        hasUnsavedChanges: true,
        past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
        future: [],
      }
    }),

  // Release a single child from a group back to the canvas. Group stays.
  removeFromGroup: (groupId, childId) =>
    set((state) => {
      const group = state.nodes.find((n) => n.id === groupId)
      const child = state.nodes.find((n) => n.id === childId)
      if (!group || !child || child.parentId !== groupId) return state

      const nodes = state.nodes.map((n) => {
        if (n.id !== childId) return n
        return {
          ...n,
          parentId: undefined,
          extent: undefined,
          position: {
            x: n.position.x + group.position.x,
            y: n.position.y + group.position.y,
          },
          data: { ...n.data, parent_id: undefined },
        }
      })

      return {
        nodes,
        hasUnsavedChanges: true,
        past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
        future: [],
      }
    }),

  markSaved: () => set({ hasUnsavedChanges: false }),

  markUnsaved: () => set({ hasUnsavedChanges: true }),

  notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),

  setServiceStatuses: (nodeId, statuses) =>
    set((state) => {
      // Live overlay only — never touches node data, so it stays out of saves.
      const next = { ...state.serviceStatuses }
      for (const s of statuses) {
        next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
      }
      return { serviceStatuses: next }
    }),

  toggleHideIp: () => set((s) => {
    const hideIp = !s.hideIp
    writeHideIp(hideIp)
    return { hideIp }
  }),

  setHideIp: (value) => {
    writeHideIp(value)
    set({ hideIp: value })
  },

  setFloorMap: (config) => set({ floorMap: config, hasUnsavedChanges: true }),

  updateFloorMap: (patch) =>
    set((state) => ({
      floorMap: state.floorMap ? { ...state.floorMap, ...patch } : null,
      hasUnsavedChanges: true,
    })),

  requestFloorMapEdit: () => set((s) => ({ floorMapEditNonce: s.floorMapEditNonce + 1 })),

  loadCanvas: (nodes, edges) => {
    // React Flow requires parents before children in the array
    const parents = nodes.filter((n) => !n.parentId)
    const children = nodes.filter((n) => !!n.parentId)
    // NOTE: clipboard is intentionally preserved here so nodes copied in one
    // design can be pasted after switching to another design.
    set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
  },

  clearFitViewPending: () => set({ fitViewPending: false }),

  applyTypeNodeStyle: (nodeType, style) =>
    set((state) => ({
      nodes: state.nodes.map((n) => {
        if (n.data.type !== nodeType) return n
        return {
          ...n,
          width: style.width > 0 ? style.width : n.width,
          height: style.height > 0 ? style.height : n.height,
          data: {
            ...n.data,
            custom_colors: {
              ...n.data.custom_colors,
              border: applyOpacity(style.borderColor, style.borderOpacity),
              background: applyOpacity(style.bgColor, style.bgOpacity),
              icon: applyOpacity(style.iconColor, style.iconOpacity),
            },
          },
        }
      }),
      hasUnsavedChanges: true,
    })),

  applyTypeEdgeStyle: (edgeType, style) =>
    set((state) => ({
      edges: state.edges.map((e) => {
        if ((e.data?.type ?? 'ethernet') !== edgeType) return e
        return {
          ...e,
          data: {
            ...e.data,
            type: edgeType,
            custom_color: applyOpacity(style.color, style.opacity),
            path_style: style.pathStyle,
            line_style: style.lineStyle,
            width_mult: style.widthMult,
            animated: style.animated,
            marker_start: style.arrowStart,
            marker_end: style.arrowEnd,
          } as EdgeData,
        }
      }),
      hasUnsavedChanges: true,
    })),

  applyAllCustomStyles: (def) =>
    set((state) => {
      const nodes = state.nodes.map((n) => {
        const style = def.nodes[n.data.type]
        if (!style) return n
        return {
          ...n,
          width: style.width > 0 ? style.width : n.width,
          height: style.height > 0 ? style.height : n.height,
          data: {
            ...n.data,
            custom_colors: {
              ...n.data.custom_colors,
              border: applyOpacity(style.borderColor, style.borderOpacity),
              background: applyOpacity(style.bgColor, style.bgOpacity),
              icon: applyOpacity(style.iconColor, style.iconOpacity),
            },
          },
        }
      })
      const edges = state.edges.map((e) => {
        const edgeType = (e.data?.type ?? 'ethernet') as EdgeType
        const style = def.edges[edgeType]
        if (!style) return e
        return {
          ...e,
          data: {
            ...e.data,
            type: edgeType,
            custom_color: applyOpacity(style.color, style.opacity),
            path_style: style.pathStyle,
            line_style: style.lineStyle,
            width_mult: style.widthMult,
            animated: style.animated,
            marker_start: style.arrowStart,
            marker_end: style.arrowEnd,
          } as EdgeData,
        }
      })
      return { nodes, edges, hasUnsavedChanges: true }
    }),
}))
