<script lang="ts" setup>
import type { D3ZoomEvent, ZoomTransform } from 'd3-zoom'
import { zoom, zoomIdentity } from 'd3-zoom'
import { pointer, select } from 'd3-selection'
import type { CoordinateExtent, D3ZoomHandler, FlowOptions, ViewportTransform } from '../../types'
import { PanOnScrollMode } from '../../types'
import Pane from '../Pane/Pane.vue'
import Transform from './Transform.vue'

const {
  id,
  minZoom,
  maxZoom,
  defaultViewport,
  translateExtent,
  dimensions,
  zoomActivationKeyCode,
  selectionKeyCode,
  panActivationKeyCode,
  panOnScroll,
  panOnScrollMode,
  panOnScrollSpeed,
  panOnDrag,
  zoomOnDoubleClick,
  zoomOnPinch,
  zoomOnScroll,
  preventScrolling,
  noWheelClassName,
  noPanClassName,
  emits,
  connectionStartHandle,
  userSelectionActive,
  paneDragging,
  d3Zoom: storeD3Zoom,
  d3Selection: storeD3Selection,
  d3ZoomHandler: storeD3ZoomHandler,
  viewport,
  viewportRef,
} = useVueFlow()

const viewportEl = ref<HTMLDivElement>()

const selectionKeyPressed = ref(false)

const isZoomingOrPanning = ref(false)

let zoomedWithRightMouseButton = false

let mouseButton = 0

let prevTransform: ViewportTransform = {
  x: 0,
  y: 0,
  zoom: 0,
}

const panKeyPressed = useKeyPress(panActivationKeyCode)

const shouldPanOnDrag = computed(() => !selectionKeyPressed.value && panOnDrag.value && panKeyPressed.value)

const isSelecting = computed(
  () =>
    (selectionKeyCode.value !== true && selectionKeyPressed.value) || (selectionKeyCode.value === true && !shouldPanOnDrag.value),
)

onMounted(() => {
  useResizeObserver(viewportEl, setDimensions)

  const window = useWindow()
  useEventListener(window, 'resize', setDimensions)
})

onMounted(() => {
  const viewportElement = viewportEl.value!
  const bbox = viewportElement.getBoundingClientRect()
  const d3Zoom = zoom<HTMLDivElement, any>().scaleExtent([minZoom.value, maxZoom.value]).translateExtent(translateExtent.value)
  const d3Selection = select(viewportElement).call(d3Zoom)
  const d3ZoomHandler = d3Selection.on('wheel.zoom')

  const updatedTransform = zoomIdentity
    .translate(defaultViewport.value.x ?? 0, defaultViewport.value.y ?? 0)
    .scale(clamp(defaultViewport.value.zoom ?? 1, minZoom.value, maxZoom.value))

  const extent: CoordinateExtent = [
    [0, 0],
    [bbox.width, bbox.height],
  ]

  const constrainedTransform = d3Zoom.constrain()(updatedTransform, extent, translateExtent.value)
  d3Zoom.transform(d3Selection, constrainedTransform)

  storeD3Zoom.value = d3Zoom
  storeD3Selection.value = d3Selection
  storeD3ZoomHandler.value = d3ZoomHandler as D3ZoomHandler
  viewport.value = { x: constrainedTransform.x, y: constrainedTransform.y, zoom: constrainedTransform.k }
  viewportRef.value = viewportElement

  useKeyPress(selectionKeyCode, (keyPress) => {
    selectionKeyPressed.value = keyPress
  })

  const zoomKeyPressed = useKeyPress(zoomActivationKeyCode)

  watchEffect(() => {
    if (selectionKeyPressed.value && userSelectionActive.value && !isZoomingOrPanning.value) {
      d3Zoom.on('zoom', null)
    } else if (!selectionKeyPressed.value && !userSelectionActive.value) {
      d3Zoom.on('zoom', (event: D3ZoomEvent<HTMLDivElement, any>) => {
        viewport.value = { x: event.transform.x, y: event.transform.y, zoom: event.transform.k }

        const flowTransform = eventToFlowTransform(event.transform)

        zoomedWithRightMouseButton = isRightClickPan(panOnDrag.value, mouseButton ?? 0)

        emits.viewportChange(flowTransform)
        emits.move({ event, flowTransform })
      })
    }
  })

  d3Zoom.on('start', (event: D3ZoomEvent<HTMLDivElement, any>) => {
    if (!event.sourceEvent) {
      return null
    }

    // we need to remember it here, because it's always 0 in the "zoom" event
    mouseButton = event.sourceEvent.button

    isZoomingOrPanning.value = true

    const flowTransform = eventToFlowTransform(event.transform)

    if (event.sourceEvent?.type === 'mousedown') {
      paneDragging.value = true
    }

    prevTransform = flowTransform

    emits.viewportChangeStart(flowTransform)
    emits.moveStart({ event, flowTransform })
  })

  d3Zoom.on('end', (event: D3ZoomEvent<HTMLDivElement, any>) => {
    if (!event.sourceEvent) {
      return null
    }

    isZoomingOrPanning.value = false

    paneDragging.value = false

    if (isRightClickPan(panOnDrag.value, mouseButton ?? 0) && !zoomedWithRightMouseButton) {
      emits.paneContextMenu(event.sourceEvent)
    }

    zoomedWithRightMouseButton = false

    if (viewChanged(prevTransform, event.transform)) {
      const flowTransform = eventToFlowTransform(event.transform)

      prevTransform = flowTransform

      emits.viewportChangeEnd(flowTransform)
      emits.moveEnd({ event, flowTransform })
    }
  })

  watchEffect(() => {
    if (panOnScroll.value && !zoomKeyPressed.value && !userSelectionActive.value) {
      d3Selection.on(
        'wheel.zoom',
        (event: WheelEvent) => {
          if (isWrappedWithClass(event, noWheelClassName.value)) {
            return false
          }

          event.preventDefault()
          event.stopImmediatePropagation()

          const currentZoom = d3Selection.property('__zoom').k || 1

          if (event.ctrlKey && zoomOnPinch.value) {
            const point = pointer(event)
            // taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js
            const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10
            const zoom = currentZoom * 2 ** pinchDelta
            d3Zoom.scaleTo(d3Selection, zoom, point)

            return
          }

          // increase scroll speed in firefox
          // firefox: deltaMode === 1; chrome: deltaMode === 0
          const deltaNormalize = event.deltaMode === 1 ? 20 : 1
          const deltaX = panOnScrollMode.value === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize
          const deltaY = panOnScrollMode.value === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize

          d3Zoom.translateBy(
            d3Selection,
            -(deltaX / currentZoom) * panOnScrollSpeed.value,
            -(deltaY / currentZoom) * panOnScrollSpeed.value,
          )
        },
        { passive: false },
      )
    } else if (typeof d3ZoomHandler !== 'undefined') {
      d3Selection.on(
        'wheel.zoom',
        function (event: WheelEvent, d) {
          if (!preventScrolling.value || isWrappedWithClass(event, noWheelClassName.value)) {
            return null
          }

          event.preventDefault()
          // eslint-disable-next-line @typescript-eslint/no-invalid-this
          d3ZoomHandler.call(this, event, d)
        },
        { passive: false },
      )
    }
  })

  d3Zoom.filter((event: MouseEvent) => {
    const zoomScroll = zoomKeyPressed.value || zoomOnScroll.value
    const pinchZoom = zoomOnPinch.value && event.ctrlKey

    if (
      event.button === 1 &&
      event.type === 'mousedown' &&
      ((event.target as HTMLElement)?.closest(`.vue-flow__node`) || (event.target as HTMLElement)?.closest(`.vue-flow__edge`))
    ) {
      return true
    }

    // if all interactions are disabled, we prevent all zoom events
    if (!panOnDrag.value && !zoomScroll && !panOnScroll.value && !zoomOnDoubleClick.value && !zoomOnPinch.value) {
      return false
    }

    // during a selection we prevent all other interactions
    if (userSelectionActive.value) {
      return false
    }

    // if zoom on double click is disabled, we prevent the double click event
    if (!zoomOnDoubleClick.value && event.type === 'dblclick') {
      return false
    }

    // if the target element is inside an element with the nowheel class, we prevent zooming
    if (isWrappedWithClass(event, noWheelClassName.value) && event.type === 'wheel') {
      return false
    }

    // if the target element is inside an element with the nopan class, we prevent panning
    if (isWrappedWithClass(event, noPanClassName.value) && event.type !== 'wheel') {
      return false
    }

    if (!zoomOnPinch.value && event.ctrlKey && event.type === 'wheel') {
      return false
    }

    // when there is no scroll handling enabled, we prevent all wheel events
    if (!zoomScroll && !panOnScroll.value && !pinchZoom && event.type === 'wheel') {
      return false
    }

    // if the pane is not movable, we prevent dragging it with mousestart or touchstart
    if (!panOnDrag.value && (event.type === 'mousedown' || event.type === 'touchstart')) {
      return false
    }

    // if the pane is only movable using allowed clicks
    if (
      Array.isArray(panOnDrag.value) &&
      !panOnDrag.value.includes(event.button) &&
      (event.type === 'mousedown' || event.type === 'touchstart')
    ) {
      return false
    }

    // We only allow right clicks if pan on drag is set to right-click
    const buttonAllowed =
      (Array.isArray(panOnDrag.value) && panOnDrag.value.includes(event.button)) || !event.button || event.button <= 1

    // default filter for d3-zoom
    return (!event.ctrlKey || event.type === 'wheel') && buttonAllowed
  })
})

function isRightClickPan(pan: FlowOptions['panOnDrag'], usedButton: number) {
  return usedButton === 2 && Array.isArray(pan) && pan.includes(2)
}

function viewChanged(prevViewport: ViewportTransform, eventTransform: ZoomTransform) {
  return (
    (prevViewport.x !== eventTransform.x && !isNaN(eventTransform.x)) ||
    (prevViewport.y !== eventTransform.y && !isNaN(eventTransform.y)) ||
    (prevViewport.zoom !== eventTransform.k && !isNaN(eventTransform.k))
  )
}

function eventToFlowTransform(eventTransform: ZoomTransform): ViewportTransform {
  return {
    x: eventTransform.x,
    y: eventTransform.y,
    zoom: eventTransform.k,
  }
}

function setDimensions() {
  if (!viewportEl.value) {
    return
  }

  const { width, height } = getDimensions(viewportEl.value)

  if (width === 0 || height === 0) {
    emits.error(new VueFlowError(ErrorCode.MISSING_VIEWPORT_DIMENSIONS))
  }

  dimensions.value.width = width || 500
  dimensions.value.height = height || 500
}

function isWrappedWithClass(event: Event, className: string | undefined) {
  return (event.target as Element).closest(`.${className}`)
}
</script>

<script lang="ts">
export default {
  name: 'Viewport',
  compatConfig: { MODE: 3 },
}
</script>

<template>
  <div ref="viewportEl" :key="`viewport-${id}`" class="vue-flow__viewport vue-flow__container">
    <Pane
      :is-selecting="isSelecting"
      :class="{ connecting: !!connectionStartHandle, dragging: paneDragging, draggable: !!panOnDrag }"
    >
      <Transform>
        <slot name="zoom-pane" />
      </Transform>
    </Pane>
  </div>

  <slot />
</template>
