import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'

const {
  mockAddNodeOnGraph,
  mockConvertEventToCanvasOffset,
  mockSelectItems,
  mockCanvas
} = vi.hoisted(() => {
  const mockConvertEventToCanvasOffset = vi.fn()
  const mockSelectItems = vi.fn()
  return {
    mockAddNodeOnGraph: vi.fn(),
    mockConvertEventToCanvasOffset,
    mockSelectItems,
    mockCanvas: {
      canvas: {
        getBoundingClientRect: vi.fn()
      },
      convertEventToCanvasOffset: mockConvertEventToCanvasOffset,
      selectItems: mockSelectItems
    }
  }
})

vi.mock('@/renderer/core/canvas/canvasStore', () => ({
  useCanvasStore: vi.fn(() => ({
    canvas: mockCanvas
  }))
}))

vi.mock('@/services/litegraphService', () => ({
  useLitegraphService: vi.fn(() => ({
    addNodeOnGraph: mockAddNodeOnGraph
  }))
}))

describe('useNodeDragToCanvas', () => {
  let useNodeDragToCanvas: typeof UseNodeDragToCanvasType

  const mockNodeDef = {
    name: 'TestNode',
    display_name: 'Test Node'
  } as ComfyNodeDefImpl

  beforeEach(async () => {
    vi.resetModules()
    vi.resetAllMocks()

    const module = await import('./useNodeDragToCanvas')
    useNodeDragToCanvas = module.useNodeDragToCanvas
  })

  afterEach(() => {
    const { cleanupGlobalListeners } = useNodeDragToCanvas()
    cleanupGlobalListeners()
    vi.restoreAllMocks()
  })

  describe('startDrag', () => {
    it('should set isDragging to true and store the node definition', () => {
      const { isDragging, draggedNode, startDrag } = useNodeDragToCanvas()

      expect(isDragging.value).toBe(false)
      expect(draggedNode.value).toBeNull()

      startDrag(mockNodeDef)

      expect(isDragging.value).toBe(true)
      expect(draggedNode.value).toBe(mockNodeDef)
    })

    it('should set dragMode to click by default', () => {
      const { dragMode, startDrag } = useNodeDragToCanvas()

      startDrag(mockNodeDef)

      expect(dragMode.value).toBe('click')
    })

    it('should set dragMode to native when specified', () => {
      const { dragMode, startDrag } = useNodeDragToCanvas()

      startDrag(mockNodeDef, 'native')

      expect(dragMode.value).toBe('native')
    })
  })

  describe('cancelDrag', () => {
    it('should reset isDragging and draggedNode', () => {
      const { isDragging, draggedNode, startDrag, cancelDrag } =
        useNodeDragToCanvas()

      startDrag(mockNodeDef)
      expect(isDragging.value).toBe(true)

      cancelDrag()

      expect(isDragging.value).toBe(false)
      expect(draggedNode.value).toBeNull()
    })

    it('should reset dragMode to click', () => {
      const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()

      startDrag(mockNodeDef, 'native')
      expect(dragMode.value).toBe('native')

      cancelDrag()

      expect(dragMode.value).toBe('click')
    })
  })

  describe('setupGlobalListeners', () => {
    it('should add event listeners to document', () => {
      const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
      const { setupGlobalListeners } = useNodeDragToCanvas()

      setupGlobalListeners()

      expect(addEventListenerSpy).toHaveBeenCalledWith(
        'pointermove',
        expect.any(Function)
      )
      expect(addEventListenerSpy).toHaveBeenCalledWith(
        'pointerdown',
        expect.any(Function),
        true
      )
      expect(addEventListenerSpy).toHaveBeenCalledWith(
        'pointerup',
        expect.any(Function),
        true
      )
      expect(addEventListenerSpy).toHaveBeenCalledWith(
        'keydown',
        expect.any(Function)
      )
    })

    it('should only setup listeners once', () => {
      const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
      const { setupGlobalListeners } = useNodeDragToCanvas()

      setupGlobalListeners()
      const callCount = addEventListenerSpy.mock.calls.length

      setupGlobalListeners()

      expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
    })
  })

  describe('cursorPosition', () => {
    it('should update on pointermove', () => {
      const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()

      setupGlobalListeners()

      const pointerEvent = new PointerEvent('pointermove', {
        clientX: 100,
        clientY: 200
      })
      document.dispatchEvent(pointerEvent)

      expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
    })
  })

  describe('endDrag behavior', () => {
    it('should add node when pointer is over canvas', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
      mockConvertEventToCanvasOffset.mockReturnValue([150, 150])

      const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()

      setupGlobalListeners()
      startDrag(mockNodeDef)

      const pointerEvent = new PointerEvent('pointerup', {
        clientX: 250,
        clientY: 250,
        bubbles: true
      })
      document.dispatchEvent(pointerEvent)

      expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
        pos: [150, 150]
      })
    })

    it('should not add node when pointer is outside canvas', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })

      const { startDrag, setupGlobalListeners, isDragging } =
        useNodeDragToCanvas()

      setupGlobalListeners()
      startDrag(mockNodeDef)

      const pointerEvent = new PointerEvent('pointerup', {
        clientX: 600,
        clientY: 250,
        bubbles: true
      })
      document.dispatchEvent(pointerEvent)

      expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
      expect(isDragging.value).toBe(false)
    })

    it('should cancel drag on Escape key', () => {
      const { startDrag, setupGlobalListeners, isDragging } =
        useNodeDragToCanvas()

      setupGlobalListeners()
      startDrag(mockNodeDef)

      expect(isDragging.value).toBe(true)

      const keyEvent = new KeyboardEvent('keydown', { key: 'Escape' })
      document.dispatchEvent(keyEvent)

      expect(isDragging.value).toBe(false)
    })

    it('should not cancel drag on other keys', () => {
      const { startDrag, setupGlobalListeners, isDragging } =
        useNodeDragToCanvas()

      setupGlobalListeners()
      startDrag(mockNodeDef)

      const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
      document.dispatchEvent(keyEvent)

      expect(isDragging.value).toBe(true)
    })

    it('should select the placed node when one is returned from the graph', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
      mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
      const placedNode = { id: 1 }
      mockAddNodeOnGraph.mockReturnValue(placedNode)

      const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
      setupGlobalListeners()
      startDrag(mockNodeDef)

      document.dispatchEvent(
        new PointerEvent('pointerup', {
          clientX: 250,
          clientY: 250,
          bubbles: true
        })
      )

      expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
    })

    it('should not call selectItems when graph returns no node', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
      mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
      mockAddNodeOnGraph.mockReturnValue(null)

      const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
      setupGlobalListeners()
      startDrag(mockNodeDef)

      document.dispatchEvent(
        new PointerEvent('pointerup', {
          clientX: 250,
          clientY: 250,
          bubbles: true
        })
      )

      expect(mockSelectItems).not.toHaveBeenCalled()
    })

    it('should not add node on pointerup when in native drag mode', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
      mockConvertEventToCanvasOffset.mockReturnValue([150, 150])

      const { startDrag, setupGlobalListeners, isDragging } =
        useNodeDragToCanvas()

      setupGlobalListeners()
      startDrag(mockNodeDef, 'native')

      const pointerEvent = new PointerEvent('pointerup', {
        clientX: 250,
        clientY: 250,
        bubbles: true
      })
      document.dispatchEvent(pointerEvent)

      expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
      expect(isDragging.value).toBe(true)
    })
  })

  describe('handleNativeDrop', () => {
    it('should add node when drop position is over canvas', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
      mockConvertEventToCanvasOffset.mockReturnValue([200, 200])

      const { startDrag, handleNativeDrop } = useNodeDragToCanvas()

      startDrag(mockNodeDef, 'native')
      handleNativeDrop(250, 250)

      expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
        pos: [200, 200]
      })
    })

    it('should not add node when drop position is outside canvas', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })

      const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()

      startDrag(mockNodeDef, 'native')
      handleNativeDrop(600, 250)

      expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
      expect(isDragging.value).toBe(false)
    })

    it('should not add node when dragMode is click', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
      mockConvertEventToCanvasOffset.mockReturnValue([200, 200])

      const { startDrag, handleNativeDrop } = useNodeDragToCanvas()

      startDrag(mockNodeDef, 'click')
      handleNativeDrop(250, 250)

      expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
    })

    it('should reset drag state after drop', () => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
      mockConvertEventToCanvasOffset.mockReturnValue([200, 200])

      const { startDrag, handleNativeDrop, isDragging, dragMode } =
        useNodeDragToCanvas()

      startDrag(mockNodeDef, 'native')
      handleNativeDrop(250, 250)

      expect(isDragging.value).toBe(false)
      expect(dragMode.value).toBe('click')
    })
  })

  describe('blockCommitPointerDown', () => {
    function dispatchPointerDown(x: number, y: number) {
      const event = new PointerEvent('pointerdown', {
        clientX: x,
        clientY: y,
        bubbles: true,
        cancelable: true
      })
      const stopSpy = vi.spyOn(event, 'stopImmediatePropagation')
      document.dispatchEvent(event)
      return stopSpy
    }

    beforeEach(() => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
    })

    it('should stop propagation when in click-drag mode over canvas', () => {
      const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
      setupGlobalListeners()
      startDrag(mockNodeDef)

      expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
    })

    it('should not stop propagation when not dragging', () => {
      const { setupGlobalListeners } = useNodeDragToCanvas()
      setupGlobalListeners()

      expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
    })

    it('should not stop propagation in native drag mode', () => {
      const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
      setupGlobalListeners()
      startDrag(mockNodeDef, 'native')

      expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
    })

    it('should not stop propagation when pointer is outside canvas', () => {
      const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
      setupGlobalListeners()
      startDrag(mockNodeDef)

      expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
    })
  })

  describe('native drag position tracking', () => {
    beforeEach(() => {
      mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
        left: 0,
        right: 500,
        top: 0,
        bottom: 500
      })
      mockConvertEventToCanvasOffset.mockReturnValue([300, 300])
    })

    // happy-dom has no DragEvent constructor; MouseEvent works since the
    // handler only reads clientX/clientY.
    function fireDrag(x: number, y: number) {
      document.dispatchEvent(
        new MouseEvent('dragover', { clientX: x, clientY: y, bubbles: true })
      )
    }

    it('should prefer tracked drag position over dragend coordinates', () => {
      const { startDrag, setupGlobalListeners, handleNativeDrop } =
        useNodeDragToCanvas()
      setupGlobalListeners()
      startDrag(mockNodeDef, 'native')

      fireDrag(250, 250)
      // dragend supplies a bad position (the Firefox bug); the tracked one
      // from the last drag event should win.
      handleNativeDrop(1505, 102)

      expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
        clientX: 250,
        clientY: 250
      })
    })

    it('should ignore drag events with (0, 0)', () => {
      const { startDrag, setupGlobalListeners, handleNativeDrop } =
        useNodeDragToCanvas()
      setupGlobalListeners()
      startDrag(mockNodeDef, 'native')

      fireDrag(250, 250)
      fireDrag(0, 0)
      handleNativeDrop(1505, 102)

      expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
        clientX: 250,
        clientY: 250
      })
    })

    it('should fall back to dragend coordinates when no drag fired', () => {
      const { startDrag, setupGlobalListeners, handleNativeDrop } =
        useNodeDragToCanvas()
      setupGlobalListeners()
      startDrag(mockNodeDef, 'native')

      handleNativeDrop(250, 250)

      expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
        clientX: 250,
        clientY: 250
      })
    })

    it('should ignore dragover events fired before startDrag', () => {
      const { startDrag, setupGlobalListeners, handleNativeDrop } =
        useNodeDragToCanvas()
      setupGlobalListeners()

      fireDrag(250, 250)
      startDrag(mockNodeDef, 'native')
      handleNativeDrop(300, 300)

      expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
        clientX: 300,
        clientY: 300
      })
    })

    it('should clear tracked position between drags', () => {
      const { startDrag, setupGlobalListeners, handleNativeDrop } =
        useNodeDragToCanvas()
      setupGlobalListeners()

      startDrag(mockNodeDef, 'native')
      fireDrag(250, 250)
      handleNativeDrop(1505, 102)

      // Second drag - no drag events, so we should fall back to args.
      startDrag(mockNodeDef, 'native')
      handleNativeDrop(300, 300)

      expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({
        clientX: 300,
        clientY: 300
      })
    })
  })
})
