import { describe, it, expect, beforeEach, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type * as echarts from 'echarts'

// ─── Hoisted mocks ──────────────────────────────────────────────────────────

// Capture the mock chart so each test can introspect setOption / dispose calls.
const mockChart = {
  setOption: vi.fn(),
  dispose: vi.fn(),
  resize: vi.fn(),
  on: vi.fn(),
  off: vi.fn(),
  // Stubbed for the edge-label clamp; tests override per-case.
  convertToPixel: vi.fn((_finder: unknown, index: number) =>
    index === 0 ? 4 : 296,
  ),
  getWidth: vi.fn(() => 300),
}
const initSpy = vi.fn((...args: unknown[]): typeof mockChart => {
  void args
  return mockChart
})

vi.mock('echarts', () => ({
  __esModule: true,
  init: (...args: unknown[]) => initSpy(...args),
  // Fake text measurement: width ∝ character count, enough to make a long
  // edge label overflow a near-edge tick in the clamp tests.
  format: {
    getTextRect: (text: unknown) => ({ width: String(text).length * 10 }),
  },
}))

/** Invoke the `finished` listener the bridge bound on the mock chart. */
function fireFinished(): void {
  for (const [event, handler] of mockChart.on.mock.calls) {
    if (event === 'finished') (handler as () => void)()
  }
}

// Resize observer mock — capture the callback so tests can trigger it.
let resizeCb: (() => void) | null = null
const observeResizeSpy = vi.fn((_node: HTMLElement, cb: () => void) => {
  resizeCb = cb
  return () => {
    resizeCb = null
  }
})
vi.mock('./shared-resize-observer', () => ({
  observeResize: (node: HTMLElement, cb: () => void) =>
    observeResizeSpy(node, cb),
}))

import { EchartUI } from './echart-ui'

beforeEach(() => {
  mockChart.setOption.mockReset()
  mockChart.dispose.mockReset()
  mockChart.resize.mockReset()
  mockChart.on.mockReset()
  mockChart.off.mockReset()
  mockChart.convertToPixel.mockClear()
  mockChart.getWidth.mockClear()
  initSpy.mockClear()
  observeResizeSpy.mockClear()
  resizeCb = null
})

const categoryOption = {
  xAxis: { type: 'category', axisLabel: {} },
  series: [{ type: 'bar', encode: { x: 'name', y: 'value' }, datasetIndex: 0 }],
  dataset: [
    {
      source: [
        { name: 'California', value: 3 },
        { name: 'Texas', value: 2 },
        { name: 'Wyoming', value: 1 },
      ],
    },
  ],
} as unknown as echarts.EChartsOption

describe('<EchartUI>', () => {
  it('initialises echarts on mount with merged init opts and notifies onInstance', () => {
    const onInstance = vi.fn()
    render(
      <EchartUI
        option={{ xAxis: { type: 'category' } }}
        init={{ renderer: 'canvas' }}
        onInstance={onInstance}
      />,
    )
    expect(initSpy).toHaveBeenCalledTimes(1)
    // 3rd arg merges the default `{ renderer: 'svg', height: 304 }` with the
    // caller's init — canvas wins.
    const initOpts = initSpy.mock.calls[0]![2] as Record<string, unknown>
    expect(initOpts.renderer).toBe('canvas')
    expect(initOpts.height).toBe(304)
    expect(onInstance).toHaveBeenCalledWith(mockChart)
  })

  it('applies setOption on mount and adds `series` to replaceMerge on first paint', () => {
    render(<EchartUI option={{ series: [{ type: 'bar', data: [1, 2, 3] }] }} />)
    expect(mockChart.setOption).toHaveBeenCalledTimes(1)
    const args = mockChart.setOption.mock.calls[0]!
    expect(
      (args[1] as { replaceMerge?: readonly string[] }).replaceMerge,
    ).toContain('series')
  })

  it('merges caller-provided replaceMerge keys with structural fingerprints', () => {
    render(
      <EchartUI
        option={{ series: [{ type: 'bar' }] }}
        replaceMerge={['legend', 'grid']}
      />,
    )
    const augmented = (
      mockChart.setOption.mock.calls[0]![1] as {
        replaceMerge?: readonly string[]
      }
    ).replaceMerge!
    expect(augmented).toContain('legend')
    expect(augmented).toContain('grid')
    expect(augmented).toContain('series')
  })

  it('skips `series` in replaceMerge when only style fields change between renders', () => {
    const onInstance = vi.fn()
    const { rerender } = render(
      <EchartUI
        option={{
          series: [{ type: 'bar', name: 'a', data: [1] }],
        }}
        onInstance={onInstance}
      />,
    )
    mockChart.setOption.mockClear()
    rerender(
      <EchartUI
        option={{
          // Same structural fields (type, name) — only itemStyle changed.
          series: [
            {
              type: 'bar',
              name: 'a',
              data: [1],
              itemStyle: { color: 'red' },
            },
          ],
        }}
        onInstance={onInstance}
      />,
    )
    const replaceMerge = (
      mockChart.setOption.mock.calls[0]![1] as {
        replaceMerge?: readonly string[]
      }
    ).replaceMerge
    // No structural change → no `series` flag added; nothing else passed.
    expect(replaceMerge).toBeUndefined()
  })

  it('adds `series` to replaceMerge when a series field changes structurally', () => {
    const { rerender } = render(
      <EchartUI option={{ series: [{ type: 'bar', name: 'a' }] }} />,
    )
    mockChart.setOption.mockClear()
    rerender(<EchartUI option={{ series: [{ type: 'line', name: 'a' }] }} />)
    const replaceMerge = (
      mockChart.setOption.mock.calls[0]![1] as {
        replaceMerge?: readonly string[]
      }
    ).replaceMerge!
    expect(replaceMerge).toContain('series')
  })

  it('adds `dataset` to replaceMerge when dataset length changes', () => {
    const { rerender } = render(
      <EchartUI option={{ dataset: [{ source: [] }] }} />,
    )
    mockChart.setOption.mockClear()
    rerender(
      <EchartUI option={{ dataset: [{ source: [] }, { source: [] }] }} />,
    )
    const replaceMerge = (
      mockChart.setOption.mock.calls[0]![1] as {
        replaceMerge?: readonly string[]
      }
    ).replaceMerge!
    expect(replaceMerge).toContain('dataset')
  })

  it('binds onEvents handlers and unbinds them on unmount', () => {
    const handler = vi.fn()
    const { unmount } = render(
      <EchartUI option={{}} onEvents={{ click: handler }} />,
    )
    expect(mockChart.on).toHaveBeenCalledWith('click', handler)
    unmount()
    expect(mockChart.off).toHaveBeenCalledWith('click', handler)
  })

  it('observes the container for resize and calls chart.resize on resize', () => {
    render(<EchartUI option={{}} />)
    expect(observeResizeSpy).toHaveBeenCalled()
    expect(resizeCb).not.toBeNull()
    act(() => resizeCb?.())
    expect(mockChart.resize).toHaveBeenCalledTimes(1)
  })

  it('disposes the chart and calls onInstance(null) on unmount', () => {
    const onInstance = vi.fn()
    const { unmount } = render(<EchartUI option={{}} onInstance={onInstance} />)
    onInstance.mockClear()
    unmount()
    expect(onInstance).toHaveBeenCalledWith(null)
    expect(mockChart.dispose).toHaveBeenCalledTimes(1)
  })

  it('clamps overflowing edge labels on `finished` (category x-axis)', () => {
    render(<EchartUI option={categoryOption} />)
    mockChart.setOption.mockClear()
    // First tick at x=4, 'California' ~100px wide (half 50) → overflows left.
    act(() => fireFinished())
    expect(mockChart.setOption).toHaveBeenCalledTimes(1)
    const applied = mockChart.setOption.mock.calls[0]![0] as {
      xAxis: { axisLabel: { alignMinLabel: string | null } }
    }
    expect(applied.xAxis.axisLabel.alignMinLabel).toBe('left')
  })

  it('does not clamp again on a second `finished` with the same verdict', () => {
    render(<EchartUI option={categoryOption} />)
    act(() => fireFinished())
    mockChart.setOption.mockClear()
    act(() => fireFinished())
    expect(mockChart.setOption).not.toHaveBeenCalled()
  })

  it('does not clamp a non-category (time) x-axis', () => {
    render(
      <EchartUI
        option={
          {
            xAxis: { type: 'time', axisLabel: {} },
            series: [{ type: 'line', encode: { x: 'name', y: 'value' } }],
            dataset: [{ source: [{ name: 1, value: 2 }] }],
          } as unknown as echarts.EChartsOption
        }
      />,
    )
    mockChart.setOption.mockClear()
    act(() => fireFinished())
    expect(mockChart.setOption).not.toHaveBeenCalled()
  })

  it('treats a non-array series as a single-fingerprint signature', () => {
    // The fingerprint helper handles both array AND object/single-series
    // forms. Re-render with a *fresh* option object that has the same
    // single-series shape — the apply effect re-runs (deps changed) but
    // the fingerprint says nothing structural changed.
    const { rerender } = render(
      <EchartUI
        option={{ series: { type: 'bar' } } as unknown as echarts.EChartsOption}
      />,
    )
    mockChart.setOption.mockClear()
    rerender(
      <EchartUI
        option={{ series: { type: 'bar' } } as unknown as echarts.EChartsOption}
      />,
    )
    const replaceMerge = (
      mockChart.setOption.mock.calls[0]![1] as {
        replaceMerge?: readonly string[]
      }
    ).replaceMerge
    expect(replaceMerge).toBeUndefined()
  })

  it('treats a non-array dataset as a single-fingerprint signature', () => {
    const { rerender } = render(
      <EchartUI
        option={{ dataset: { source: [] } } as unknown as echarts.EChartsOption}
      />,
    )
    mockChart.setOption.mockClear()
    rerender(
      <EchartUI
        option={{ dataset: { source: [] } } as unknown as echarts.EChartsOption}
      />,
    )
    const replaceMerge = (
      mockChart.setOption.mock.calls[0]![1] as {
        replaceMerge?: readonly string[]
      }
    ).replaceMerge
    expect(replaceMerge).toBeUndefined()
  })
})
