/**
 * Generic Vue Element Tracking System
 *
 * Automatically tracks DOM size and position changes for Vue-rendered elements
 * and syncs them to the layout store. Uses a single shared ResizeObserver for
 * performance, with elements identified by configurable data attributes.
 *
 * Supports different element types (nodes, slots, widgets, etc.) with
 * customizable data attributes and update handlers.
 */
import { getCurrentInstance, onMounted, onUnmounted, watch } from 'vue'

import { useDocumentVisibility } from '@vueuse/core'

import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
  isBoundsEqual,
  isSizeEqual
} from '@/renderer/core/layout/utils/geometry'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'

import {
  scheduleSlotLayoutSync,
  syncNodeSlotLayoutsFromDOM
} from './useSlotElementTracking'

/**
 * Generic update item for element bounds tracking
 */
interface ElementBoundsUpdate {
  /** Element identifier (could be nodeId, widgetId, slotId, etc.) */
  id: string
  /** Updated bounds */
  bounds: Bounds
}

interface CachedNodeMeasurement {
  nodeId: NodeId
  bounds: Bounds
}

/**
 * Configuration for different types of tracked elements
 */
interface ElementTrackingConfig {
  /** Data attribute name (e.g., 'nodeId') */
  dataAttribute: string
  /** Handler for processing bounds updates. Omit for signal-only entries. */
  updateHandler?: (updates: ElementBoundsUpdate[]) => void
}

/**
 * Registry of tracking configurations by element type
 */
const trackingConfigs = new Map<string, ElementTrackingConfig>([
  [
    'node',
    {
      dataAttribute: 'nodeId',
      updateHandler: (updates) => {
        const nodeUpdates = updates.map(({ id, bounds }) => ({
          nodeId: id as NodeId,
          bounds
        }))
        layoutStore.batchUpdateNodeBounds(nodeUpdates)
      }
    }
  ],
  // Signal-only: outer node stays at its persisted min-h floor during
  // widget hydration, so the inner grid's RO is the only slot-drift signal.
  ['widgets-grid', { dataAttribute: 'widgetsGridNodeId' }]
])

// Elements whose ResizeObserver fired while the tab was hidden
const deferredElements = new Set<HTMLElement>()
const elementsNeedingFreshMeasurement = new WeakSet<HTMLElement>()
const cachedNodeMeasurements = new WeakMap<HTMLElement, CachedNodeMeasurement>()
const visibility = useDocumentVisibility()

function markElementForFreshMeasurement(element: HTMLElement) {
  elementsNeedingFreshMeasurement.add(element)
  cachedNodeMeasurements.delete(element)
}

watch(visibility, (state) => {
  if (state !== 'visible' || deferredElements.size === 0) return

  // Re-observe deferred elements to trigger fresh measurements
  for (const element of deferredElements) {
    if (element.isConnected) {
      markElementForFreshMeasurement(element)
      resizeObserver.observe(element)
    }
  }
  deferredElements.clear()
})

// Single ResizeObserver instance for all Vue elements
const resizeObserver = new ResizeObserver((entries) => {
  if (useCanvasStore().linearMode) return

  // Skip measurements when tab is hidden — bounding rects are unreliable
  if (visibility.value === 'hidden') {
    for (const entry of entries) {
      if (entry.target instanceof HTMLElement) {
        deferredElements.add(entry.target)
        markElementForFreshMeasurement(entry.target)
        resizeObserver.unobserve(entry.target)
      }
    }
    return
  }

  // Canvas is ready when this code runs; no defensive guards needed.
  const conv = useSharedCanvasPositionConversion()
  // Group updates by type, then flush via each config's handler
  const updatesByType = new Map<string, ElementBoundsUpdate[]>()
  // Track nodes whose slots should be resynced after node size changes
  const nodesNeedingSlotResync = new Set<NodeId>()

  for (const entry of entries) {
    if (!(entry.target instanceof HTMLElement)) continue
    const element = entry.target

    // Signal-only widgets-grid resize - route the parent node through the
    // slot-layout pipeline and skip bounds processing entirely.
    const widgetsGridParentNodeId = element.dataset.widgetsGridNodeId
    if (widgetsGridParentNodeId) {
      scheduleSlotLayoutSync(widgetsGridParentNodeId as NodeId)
      continue
    }

    // Find which type this element belongs to
    let elementType: string | undefined
    let elementId: string | undefined

    for (const [type, config] of trackingConfigs) {
      const id = element.dataset[config.dataAttribute]
      if (id) {
        elementType = type
        elementId = id
        break
      }
    }

    if (!elementType || !elementId) continue
    const nodeId: NodeId | undefined =
      elementType === 'node' ? elementId : undefined

    // Use borderBoxSize when available; fall back to contentRect for older engines/tests
    // Border box is the border included FULL wxh DOM value.
    const borderBox = Array.isArray(entry.borderBoxSize)
      ? entry.borderBoxSize[0]
      : {
          inlineSize: entry.contentRect.width,
          blockSize: entry.contentRect.height
        }
    const width = Math.max(0, borderBox.inlineSize)
    const height = Math.max(0, borderBox.blockSize)

    const nodeLayout = nodeId
      ? layoutStore.getNodeLayoutRef(nodeId).value
      : null
    const normalizedHeight = removeNodeTitleHeight(height)
    const previousMeasurement = cachedNodeMeasurements.get(element)
    const hasFreshMeasurementPending =
      elementsNeedingFreshMeasurement.has(element)
    const hasMatchingCachedNodeMeasurement =
      previousMeasurement != null &&
      previousMeasurement.nodeId === nodeId &&
      nodeLayout != null &&
      isBoundsEqual(previousMeasurement.bounds, nodeLayout.bounds)

    // ResizeObserver emits entries where nothing changed (e.g. initial observe).
    // Skip expensive DOM reads when this exact element/node already measured at
    // the same normalized bounds and size.
    if (
      nodeLayout &&
      !hasFreshMeasurementPending &&
      isSizeEqual(nodeLayout.size, {
        width,
        height: normalizedHeight
      }) &&
      hasMatchingCachedNodeMeasurement
    ) {
      continue
    }

    // Use existing position from layout store (source of truth) rather than
    // converting screen-space getBoundingClientRect() back to canvas coords.
    // The DOM→canvas conversion depends on the current canvas scale/offset,
    // which can be stale during graph transitions (e.g. entering a subgraph
    // before fitView runs), producing corrupted positions.
    const existingPos = nodeLayout?.position
    let posX: number
    let posY: number
    if (existingPos) {
      posX = existingPos.x
      posY = existingPos.y
    } else {
      const rect = element.getBoundingClientRect()
      const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
      posX = cx
      posY = cy + LiteGraph.NODE_TITLE_HEIGHT
    }
    const bounds: Bounds = {
      x: posX,
      y: posY,
      width,
      height
    }
    const normalizedBounds: Bounds = {
      ...bounds,
      height: normalizedHeight
    }

    elementsNeedingFreshMeasurement.delete(element)
    if (nodeId) {
      cachedNodeMeasurements.set(element, {
        nodeId,
        bounds: normalizedBounds
      })
    }

    if (nodeLayout && isBoundsEqual(nodeLayout.bounds, normalizedBounds)) {
      continue
    }

    let updates = updatesByType.get(elementType)
    if (!updates) {
      updates = []
      updatesByType.set(elementType, updates)
    }
    updates.push({ id: elementId, bounds })

    // If this entry is a node, mark it for slot layout resync
    if (nodeId) {
      nodesNeedingSlotResync.add(nodeId)
    }
  }

  if (updatesByType.size === 0 && nodesNeedingSlotResync.size === 0) return

  if (updatesByType.size > 0) {
    layoutStore.setSource(LayoutSource.DOM)

    // Flush per-type
    for (const [type, updates] of updatesByType) {
      const config = trackingConfigs.get(type)
      if (config?.updateHandler && updates.length) config.updateHandler(updates)
    }
  }

  // After node bounds are updated, refresh slot cached offsets and layouts
  if (nodesNeedingSlotResync.size > 0) {
    for (const nodeId of nodesNeedingSlotResync) {
      syncNodeSlotLayoutsFromDOM(nodeId)
    }
  }
})

/**
 * Tracks DOM element size/position changes for a Vue component and syncs to layout store
 *
 * Sets up automatic ResizeObserver tracking when the component mounts and cleans up
 * when unmounted. The tracked element is identified by a data attribute set on the
 * component's root DOM element.
 *
 * @param appIdentifier - Application-level identifier for this tracked element (not a DOM ID)
 *                       Example: node ID like 'node-123', widget ID like 'widget-456'
 * @param trackingType - Type of element being tracked, determines which tracking config to use
 *                      Example: 'node' for Vue nodes, 'widget' for UI widgets
 *
 * @example
 * ```ts
 * // Track a Vue node component with ID 'my-node-123'
 * useVueElementTracking('my-node-123', 'node')
 *
 * // Would set data-node-id="my-node-123" on the component's root element
 * // and sync size changes to layoutStore.batchUpdateNodeBounds()
 * ```
 */
export function useVueElementTracking(
  appIdentifier: string,
  trackingType: string
) {
  onMounted(() => {
    const element = getCurrentInstance()?.proxy?.$el
    if (!(element instanceof HTMLElement) || !appIdentifier) return

    const config = trackingConfigs.get(trackingType)
    if (!config) return

    // Set the data attribute expected by the RO pipeline for this type
    element.dataset[config.dataAttribute] = appIdentifier
    markElementForFreshMeasurement(element)
    resizeObserver.observe(element)
  })

  onUnmounted(() => {
    const element = getCurrentInstance()?.proxy?.$el
    if (!(element instanceof HTMLElement)) return

    const config = trackingConfigs.get(trackingType)
    if (!config) return

    // Remove the data attribute and observer
    delete element.dataset[config.dataAttribute]
    cachedNodeMeasurements.delete(element)
    elementsNeedingFreshMeasurement.delete(element)
    deferredElements.delete(element)
    resizeObserver.unobserve(element)
  })
}
