import {
  useCallback,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
} from 'react'
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
import { createPortal } from 'react-dom'
import type { ECharts } from 'echarts'
import { useWidgetStore, widgetStoreActions } from '../../stores/widget-store'
import { useWidgetSelector } from '../../stores/use-widget-selector'
import type { EchartWidgetState } from '../../echart/types'
import { observeResize } from '../../echart/shared-resize-observer'
import { hitTestRects } from './hit-test'
import type { BrushRect, BrushState } from './types'
import { overlayStyles } from './style'

interface BrushOverlayProps {
  id: string
  multiBrush?: boolean
}

interface PixelRect {
  left: number
  right: number
}

interface PlotRect {
  x: number
  y: number
  width: number
  height: number
}

// Shape of the internal ECharts API we reach into to get the grid rectangle.
// It's undocumented but stable across v5/v6; falling back gracefully if
// ECharts ever changes the shape is handled by a try/catch at the call site.
interface EChartsGridInternals {
  getModel: () => {
    getComponent: (
      type: string,
      index?: number,
    ) => { coordinateSystem?: { getRect: () => PlotRect } } | null
  }
}

// Clicks (tiny drags) are ignored — distinguishes intent to select vs. misclick.
const MIN_DRAG_PX = 2

// Stable empty-array references. Returning `[]` from the Zustand selector
// on every call breaks shallow-equality memoization (each `[]` is a new
// reference) — which ripples into `rects` being a fresh reference on every
// render, which re-runs the re-projection effect, which `setProjectedRects`,
// which re-renders, producing an infinite loop.
const EMPTY_RECTS: readonly BrushRect[] = Object.freeze([])
const EMPTY_PIXEL_RECTS: readonly PixelRect[] = Object.freeze([])

/**
 * Reads the plot-area (grid) rectangle from an ECharts instance. Uses the
 * internal model API because ECharts doesn't expose this publicly; returns
 * `null` if the grid is missing or the API shape has drifted.
 */
function readPlotRect(instance: ECharts): PlotRect | null {
  try {
    const model = (instance as unknown as EChartsGridInternals).getModel()
    const grid = model.getComponent('grid', 0)
    const rect = grid?.coordinateSystem?.getRect()
    if (!rect) return null
    if (
      !Number.isFinite(rect.x) ||
      !Number.isFinite(rect.y) ||
      !Number.isFinite(rect.width) ||
      !Number.isFinite(rect.height)
    ) {
      return null
    }
    return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
  } catch {
    return null
  }
}

function plotRectEquals(a: PlotRect | null, b: PlotRect | null): boolean {
  if (a === b) return true
  if (!a || !b) return false
  return (
    a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
  )
}

/**
 * Portaled overlay rendered inside the ECharts chart `<div>`. Handles brush
 * drawing entirely in DOM, independent of ECharts' built-in brush.
 *
 * Rectangles are stored in x-axis data coords (category indices) in the
 * widget store. The overlay re-projects them to pixel coords on every chart
 * `finished` event and container resize, so `setOption({ notMerge: true })`
 * by the config pipeline is a non-event.
 *
 * Pointer capture and rectangle rendering are both clamped to the ECharts
 * grid (plot area), so the legend, axis labels, and other chart chrome
 * receive their usual events and aren't visually covered.
 *
 * Hit-testing happens on `pointerup` using `hitTestRects` against the widget's
 * data length (derived from `widget.data`). The result is written to
 * `brushSelection` in the store; `BrushToggle` subscribes to that and invokes
 * the consumer's `onBrushSelected` callback.
 */
export function BrushOverlay({ id, multiBrush }: BrushOverlayProps) {
  const { enabled, rects, dataLength } = useWidgetSelector(id, (w) => {
    const brushState = w as BrushState | undefined
    // Bar and histogram both expose data as a nested array of series;
    // all series share the same category count.
    const widgetData = w?.data as unknown[][] | undefined
    const firstSeries = widgetData?.[0]
    const length = Array.isArray(firstSeries) ? firstSeries.length : 0
    return {
      enabled: brushState?.brush ?? false,
      rects: brushState?.brushRects ?? (EMPTY_RECTS as BrushRect[]),
      dataLength: length,
    }
  })

  // Surface the chart DOM element and ECharts instance via `useSyncExternalStore`.
  // Reading `ref.current` inside a `getSnapshot` callback is idiomatic and
  // satisfies React Compiler's "don't read refs during render" guardrail.
  // The store notifies us when EchartUI registers the refs via `setWidget`;
  // `getSnapshot` then resolves the current values.
  const subscribe = useCallback(
    (notify: () => void) => useWidgetStore.subscribe(notify),
    [],
  )
  const chartEl = useSyncExternalStore(
    subscribe,
    useCallback((): HTMLElement | null => {
      return (
        widgetStoreActions.getWidget<EchartWidgetState>(id)?.refUI?.current ??
        null
      )
    }, [id]),
    () => null,
  )
  const instance = useSyncExternalStore(
    subscribe,
    useCallback((): ECharts | null => {
      return (
        widgetStoreActions.getWidget<EchartWidgetState>(id)?.instance
          ?.current ?? null
      )
    }, [id]),
    () => null,
  )

  const plotCaptureRef = useRef<HTMLDivElement | null>(null)
  const dragStartRef = useRef<number | null>(null)
  const pointerIdRef = useRef<number | null>(null)
  const [projectedRects, setProjectedRects] = useState<PixelRect[]>(
    EMPTY_PIXEL_RECTS as PixelRect[],
  )
  const [drawing, setDrawing] = useState<PixelRect | null>(null)
  const [plotRect, setPlotRect] = useState<PlotRect | null>(null)

  // Anchor the absolute-positioned overlay to the chart div. Look the element
  // up by id so React Compiler doesn't flag the style mutation; `chartEl` is
  // a normal state value here so using it as a trigger is safe.
  useEffect(() => {
    if (!chartEl) return
    const el = document.getElementById(id)
    if (!el) return
    const previous = el.style.position
    if (!previous) el.style.position = 'relative'
    return () => {
      if (!previous) el.style.position = ''
    }
  }, [id, chartEl])

  // Re-project stored rects to pixel coords and refresh the plot-area bounds
  // on every chart render (ECharts `finished` event) and on container
  // resize. This is what makes `setOption({ notMerge: true })` a non-event —
  // the store is untouched and the overlay just re-paints.
  useEffect(() => {
    if (!instance || !chartEl) return

    const project = () => {
      // Plot area bounds — if the grid isn't laid out yet we can't project
      // anything meaningfully, so skip this pass and wait for the next
      // `finished` event.
      const nextPlot = readPlotRect(instance)
      setPlotRect((prev) => (plotRectEquals(prev, nextPlot) ? prev : nextPlot))
      if (!nextPlot) return

      const next: PixelRect[] = []
      for (const r of rects) {
        const leftPx = instance.convertToPixel({ xAxisIndex: 0 }, r.xStart)
        const rightPx = instance.convertToPixel({ xAxisIndex: 0 }, r.xEnd)
        if (
          typeof leftPx !== 'number' ||
          typeof rightPx !== 'number' ||
          !Number.isFinite(leftPx) ||
          !Number.isFinite(rightPx)
        ) {
          continue
        }
        // Clamp to plot area horizontally so rectangles never bleed onto
        // adjacent padding/legend.
        const lo = Math.max(nextPlot.x, Math.min(leftPx, rightPx))
        const hi = Math.min(
          nextPlot.x + nextPlot.width,
          Math.max(leftPx, rightPx),
        )
        if (hi <= lo) continue
        next.push({ left: lo, right: hi })
      }
      // Reuse the frozen empty reference so successive "no rectangles"
      // updates hit React's `Object.is` bail-out instead of re-rendering.
      setProjectedRects(
        next.length === 0 ? (EMPTY_PIXEL_RECTS as PixelRect[]) : next,
      )
    }

    project()
    instance.on('finished', project)
    const unobserve = observeResize(chartEl, project)
    return () => {
      instance.off('finished', project)
      unobserve()
    }
  }, [instance, chartEl, rects])

  const handlePointerDown = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      if (!enabled || !chartEl || !plotRect) return
      const capture = plotCaptureRef.current
      if (!capture) return
      e.preventDefault()
      capture.setPointerCapture(e.pointerId)
      pointerIdRef.current = e.pointerId
      // Hide the tooltip so it doesn't overlap the brush selection.
      instance?.dispatchAction({ type: 'hideTip' })
      // Work in chart-container pixel space so conversions via
      // `convertFromPixel` (which expects container-relative coords) stay
      // consistent with the rectangle positions we store.
      const chartBBox = chartEl.getBoundingClientRect()
      const x = clampX(e.clientX - chartBBox.left, plotRect)
      dragStartRef.current = x
      setDrawing({ left: x, right: x })
    },
    [enabled, instance, chartEl, plotRect],
  )

  const handlePointerMove = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      // No active drag — forward the hover to ECharts so its tooltip works.
      if (pointerIdRef.current === null) {
        if (instance && chartEl) {
          const chartBBox = chartEl.getBoundingClientRect()
          instance.dispatchAction({
            type: 'showTip',
            x: e.clientX - chartBBox.left,
            y: e.clientY - chartBBox.top,
          })
        }
        return
      }
      if (pointerIdRef.current !== e.pointerId) return
      const start = dragStartRef.current
      if (start === null || !chartEl || !plotRect) return
      const chartBBox = chartEl.getBoundingClientRect()
      const x = clampX(e.clientX - chartBBox.left, plotRect)
      setDrawing({ left: Math.min(start, x), right: Math.max(start, x) })
    },
    [instance, chartEl, plotRect],
  )

  const handlePointerLeave = useCallback(() => {
    // Don't hide the tooltip during a drag — the pointer may leave the
    // element bounds while captured and we don't want flickering.
    if (pointerIdRef.current !== null) return
    instance?.dispatchAction({ type: 'hideTip' })
  }, [instance])

  const handlePointerUp = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      if (pointerIdRef.current !== e.pointerId) return
      const start = dragStartRef.current
      const capture = plotCaptureRef.current
      pointerIdRef.current = null
      dragStartRef.current = null
      setDrawing(null)

      if (start === null || !capture || !instance || !chartEl || !plotRect) {
        return
      }

      try {
        capture.releasePointerCapture(e.pointerId)
      } catch {
        // releasePointerCapture throws if the pointer is no longer captured;
        // ignore and continue.
      }

      const chartBBox = chartEl.getBoundingClientRect()
      const x = clampX(e.clientX - chartBBox.left, plotRect)
      const leftPx = Math.min(start, x)
      const rightPx = Math.max(start, x)
      if (rightPx - leftPx < MIN_DRAG_PX) return

      const xStart = instance.convertFromPixel({ xAxisIndex: 0 }, leftPx)
      const xEnd = instance.convertFromPixel({ xAxisIndex: 0 }, rightPx)
      if (
        typeof xStart !== 'number' ||
        typeof xEnd !== 'number' ||
        !Number.isFinite(xStart) ||
        !Number.isFinite(xEnd)
      ) {
        return
      }

      const newRect: BrushRect = { xStart, xEnd }
      const nextRects = multiBrush ? [...rects, newRect] : [newRect]
      const selection = {
        dataIndex: hitTestRects(nextRects, dataLength),
        seriesIndex: 0,
      }

      widgetStoreActions.setWidget(id, {
        brushRects: nextRects,
        brushSelection: selection,
        // Single-brush: auto-disable after selection (matches prior UX).
        ...(multiBrush ? {} : { brush: false }),
      })
    },
    [id, instance, multiBrush, rects, dataLength, chartEl, plotRect],
  )

  if (!chartEl) return null

  // Outer container spans the whole chart but never captures pointer events,
  // so the legend / axis labels / other chart chrome remain interactive.
  const containerStyle: CSSProperties = {
    position: 'absolute',
    inset: 0,
    pointerEvents: 'none',
    zIndex: 1,
  }

  // Inner capture layer sits over the plot area only. This is the element
  // that gets the pointer events when brush is active.
  const captureStyle: CSSProperties | undefined = plotRect
    ? {
        position: 'absolute',
        left: plotRect.x,
        top: plotRect.y,
        width: plotRect.width,
        height: plotRect.height,
        pointerEvents: enabled ? 'auto' : 'none',
        cursor: enabled ? 'crosshair' : 'default',
        userSelect: 'none',
        touchAction: 'none',
      }
    : undefined

  return createPortal(
    <div style={containerStyle}>
      {captureStyle && (
        <div
          ref={plotCaptureRef}
          style={captureStyle}
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={handlePointerUp}
          onPointerCancel={handlePointerUp}
          onPointerLeave={handlePointerLeave}
        />
      )}
      {plotRect &&
        projectedRects.map((r, i) => (
          <div
            key={`${r.left}-${r.right}-${i}`}
            style={{
              ...overlayStyles.rect,
              left: r.left,
              width: Math.max(0, r.right - r.left),
              top: plotRect.y,
              height: plotRect.height,
            }}
          />
        ))}
      {plotRect && drawing && (
        <div
          style={{
            ...overlayStyles.rectPreview,
            left: drawing.left,
            width: Math.max(0, drawing.right - drawing.left),
            top: plotRect.y,
            height: plotRect.height,
          }}
        />
      )}
    </div>,
    chartEl,
  )
}

function clampX(x: number, plot: PlotRect): number {
  return Math.max(plot.x, Math.min(plot.x + plot.width, x))
}
