import { useEffect, useEffectEvent, useRef } from 'react'
import { Box, type SxProps, type Theme } from '@mui/material'
import * as echarts from 'echarts'
import {
  clampEdgeLabels,
  CENTERED,
  type EdgeAlignment,
} from './edge-label-clamp'
import { observeResize } from './shared-resize-observer'
import { styles } from './style'

export const DEFAULT_INIT_OPTS = {
  renderer: 'svg',
  height: 304,
} satisfies echarts.EChartsInitOpts

export type EchartsEventHandler = (event: unknown) => void

export interface EchartUIProps {
  option: echarts.EChartsOption
  /**
   * Keys to merge as arrays (replace by index) instead of by id. The middleware
   * memoizes this content-stably, so identical sets don't re-trigger setOption.
   */
  replaceMerge?: readonly string[]
  /**
   * Opaque ECharts event passthrough — handlers fire untransformed.
   *
   * **Must be referentially stable** (memoize it with `useMemo` /
   * `useCallback`, or hoist to module scope). The binding effect depends on
   * the `onEvents` object identity; an inline `{ click: handler }` literal
   * recreates the object on every render and causes every listener to be
   * detached and re-attached each commit.
   */
  onEvents?: Record<string, EchartsEventHandler>
  /**
   * Init options forwarded to echarts.init. Captured at mount only — to change
   * `renderer` or `height` after mount, unmount and remount the chart.
   * Defaults: `{ renderer: 'svg', height: 304 }`.
   */
  init?: echarts.EChartsInitOpts
  /**
   * Optional callback fired once after the ECharts instance is created
   * (`onInstance(chart)`) and once when it's about to be disposed
   * (`onInstance(null)`). Used by `Widget.Echart` to publish the live
   * instance to the per-widget registry so other actions (e.g.
   * `ZoomToggle`'s disable handler) can reach it without DOM lookups.
   * No need to memoize — the bridge wraps it in `useEffectEvent` so an
   * unstable reference does not re-init the chart.
   */
  onInstance?: (chart: echarts.ECharts | null) => void
  className?: string
  sx?: SxProps<Theme>
}

export function EchartUI({
  option,
  replaceMerge,
  onEvents,
  init,
  onInstance,
  className,
  sx,
}: EchartUIProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const chartRef = useRef<echarts.ECharts | null>(null)

  // Structural fingerprint of the last applied series / dataset arrays.
  // Used to decide whether the next `setOption` should *replace* those
  // components (when their shape changed) or *merge* them (when only
  // styling-level fields differ — e.g. a new `itemStyle.color` callback
  // from a selection update). Replacing wipes ECharts' per-series runtime
  // state (hover, animation cache, brush areas), so we want to do it only
  // when the structure actually moved.
  const seriesFingerprintRef = useRef<string | null>(null)
  const datasetFingerprintRef = useRef<string | null>(null)

  // Latest applied option (read by the `finished` clamp listener, which is
  // bound once and must see the current option) + the edge-label alignment
  // currently applied to the chart (so the clamp skips redundant setOptions).
  const optionRef = useRef<echarts.EChartsOption>(option)
  const edgeAlignRef = useRef<EdgeAlignment>(CENTERED)

  // Stable notify wrapper — always reads the latest `onInstance` prop
  // without forcing the init effect below to re-run when the parent
  // passes a fresh function reference. The init effect now only fires
  // when `init` actually changes (documented as mount-only anyway).
  const notifyInstance = useEffectEvent(
    (chart: echarts.ECharts | null): void => {
      onInstance?.(chart)
    },
  )

  // Init / dispose. `init` is captured once at mount.
  useEffect(() => {
    if (!containerRef.current) return undefined
    const chart = echarts.init(containerRef.current, null, {
      ...DEFAULT_INIT_OPTS,
      ...init,
    })
    chartRef.current = chart
    notifyInstance(chart)
    // Edge-label clamp: after each layout settles (option change OR resize both
    // end in a render → `finished`), measure the category x-axis edge labels
    // and anchor them inward only if they'd clip. `finished` (not rAF) so
    // `convertToPixel` sees the flushed `lazyUpdate` layout. Loop-safe: the
    // verdict is anchor-independent, so our own clamp setOption recomputes the
    // same result and the ref guard short-circuits.
    const onFinished = (): void => {
      edgeAlignRef.current = clampEdgeLabels(
        chart,
        optionRef.current,
        edgeAlignRef.current,
      )
    }
    chart.on('finished', onFinished)
    return () => {
      chart.off('finished', onFinished)
      // Notify observers *before* disposal so any queued imperative
      // dispatches (e.g. ZoomToggle's `setOption` cleanup) see the
      // instance disappear before its DOM is torn down.
      notifyInstance(null)
      chart.dispose()
      chartRef.current = null
    }
  }, [init])

  // Apply option / replaceMerge changes via merge mode (no remount required).
  useEffect(() => {
    const seriesFp = computeSeriesFingerprint(option)
    const datasetFp = computeDatasetFingerprint(option)
    const augmented = new Set<string>(replaceMerge ?? [])
    if (seriesFp !== seriesFingerprintRef.current) augmented.add('series')
    if (datasetFp !== datasetFingerprintRef.current) augmented.add('dataset')
    seriesFingerprintRef.current = seriesFp
    datasetFingerprintRef.current = datasetFp
    // Expose the latest option to the `finished` clamp listener (bound once).
    optionRef.current = option
    chartRef.current?.setOption(option, {
      notMerge: false,
      lazyUpdate: true,
      replaceMerge: augmented.size > 0 ? Array.from(augmented) : undefined,
    })
  }, [option, replaceMerge])

  // Resize via shared singleton observer.
  useEffect(() => {
    const node = containerRef.current
    if (!node) return undefined
    return observeResize(node, () => {
      chartRef.current?.resize()
    })
  }, [])

  // Bind / unbind opaque event handlers.
  useEffect(() => {
    const chart = chartRef.current
    if (!chart || !onEvents) return undefined
    for (const [event, handler] of Object.entries(onEvents)) {
      chart.on(event, handler)
    }
    return () => {
      for (const [event, handler] of Object.entries(onEvents)) {
        chart.off(event, handler)
      }
    }
  }, [onEvents])

  return (
    <Box
      ref={containerRef}
      className={className}
      sx={{ ...styles.root, ...sx }}
    />
  )
}

/**
 * Cheap structural digest of the option's `series` array — covers the fields
 * that actually demand a `replaceMerge` (length, type, datasetIndex, name,
 * stack, encode). Excludes runtime-style fields like `itemStyle` (callbacks
 * have unstable identity but don't change structure).
 */
function computeSeriesFingerprint(option: echarts.EChartsOption): string {
  const series = option.series
  if (!Array.isArray(series)) return series ? '1' : ''
  return series
    .map((s) => {
      if (s == null || typeof s !== 'object') return String(s)
      const o = s as Record<string, unknown>
      return [
        o.type,
        o.datasetIndex,
        o.name,
        o.stack,
        // `encode` is small; stringify is cheap and stable for plain objects.
        JSON.stringify(o.encode ?? null),
      ].join('|')
    })
    .join('||')
}

/**
 * Structural digest of the option's `dataset` array — count is the only
 * thing that needs `replaceMerge` here. Row-level changes are picked up by
 * ECharts' default merge.
 */
function computeDatasetFingerprint(option: echarts.EChartsOption): string {
  const dataset = option.dataset
  if (!Array.isArray(dataset)) return dataset ? '1' : ''
  return String(dataset.length)
}
