// @ts-check
import Graphics from '../Graphics'
import Utils from '../../utils/Utils'

/**
 * ApexCharts KeyboardNavigation
 *
 * Enables keyboard users to navigate between data points using arrow keys and
 * trigger tooltips without a mouse. Plugs into the existing tooltip pipeline —
 * no new rendering logic is introduced.
 *
 * Key bindings (active when the chart SVG has focus):
 *   ArrowRight / ArrowLeft  — next / previous data point
 *   ArrowUp    / ArrowDown  — next / previous series (skips collapsed)
 *   Home                    — first data point in current series
 *   End                     — last data point in current series
 *   Enter / Space           — fire markerClick event (same as mouse click)
 *   Escape                  — exit keyboard nav, return focus to SVG
 */
export default class KeyboardNavigation {
  /**
   * @param {import('../../types/internal').ChartStateW} w
   * @param {import('../../types/internal').ChartContext} ctx
   */
  constructor(w, ctx) {
    this.w = w
    this.ctx = ctx // needed: ctx.events.addEventListener/removeEventListener

    // Current navigation cursor
    this.seriesIndex = 0
    this.dataPointIndex = 0
    this.active = false

    // True after the user presses Escape once: tooltip is dismissed but the
    // chart still has focus. A second Escape exits keyboard nav entirely.
    // Matches WCAG 1.4.13 technique G194 (dismissible content on focus).
    this._tooltipDismissed = false

    // Previously focused SVG element (for removing the focus class)
    this._focusedEl = null

    // SVG.js wrapper of the currently hovered bar element, so we can call
    // pathMouseLeave when navigating away or losing focus.
    this._hoveredBarEl = null

    // The scatter/bubble marker element that was enlarged by keyboard nav,
    // so we can reset only that one element on the next navigation step
    // (avoids calling resetPointsSize() on all markers which triggers spurious
    // mouseout events on the whole marker set, causing resize jitter).
    this._enlargedScatterMarker = null

    // Bound handlers (stored so we can removeEventListener later)
    this._onKeyDown = this._onKeyDown.bind(this)
    this._onFocus = this._onFocus.bind(this)
    this._onBlur = this._onBlur.bind(this)
    this._onLegendClick = this._onLegendClick.bind(this)
  }

  // ─── Public API ───────────────────────────────────────────────────────────

  /**
   * Called after the chart and tooltip have been fully rendered.
   * Attaches event listeners and makes the SVG keyboard-focusable.
   */
  init() {
    const w = this.w
    const svgEl = w.dom.Paper.node

    if (!svgEl) return

    svgEl.setAttribute('tabindex', '0')
    svgEl.addEventListener('focus', this._onFocus)
    svgEl.addEventListener('blur', this._onBlur)
    // Use a non-passive keydown listener directly on the SVG so that
    // preventDefault() works (required to suppress page scroll on arrow keys).
    // Events.js listens on the ancestor canvas div as passive:true, so it
    // cannot call preventDefault — we handle navigation here instead.
    svgEl.addEventListener('keydown', this._onKeyDown, { passive: false })

    // When the user clicks a legend item (collapse/expand a series), hide the
    // keyboard-nav tooltip so it doesn't remain stuck on screen while the chart
    // re-renders with a different set of visible series.
    this.ctx.events.addEventListener('legendClick', this._onLegendClick)
  }

  /**
   * Removes all event listeners. Called from chart.destroy().
   */
  destroy() {
    const w = this.w
    const svgEl = w.dom.Paper && w.dom.Paper.node

    if (!svgEl) return

    svgEl.removeEventListener('focus', this._onFocus)
    svgEl.removeEventListener('blur', this._onBlur)
    svgEl.removeEventListener('keydown', this._onKeyDown)

    this.ctx.events.removeEventListener('legendClick', this._onLegendClick)
  }

  /**
   * Called from Events.js keydown handler. Navigation keys are already handled
   * by the direct SVG listener (which can call preventDefault). This entry
   * point is intentionally a no-op — Events.js still fires the public keyDown
   * callback and fireEvent('keydown') independently.
   * @param {Event} _e
   */
  handleKey(_e) {
    // No-op: navigation is handled by the non-passive SVG keydown listener
    // added in init(). Keeping this method so Events.js doesn't need changes.
  }

  // ─── Focus / blur ─────────────────────────────────────────────────────────

  _onFocus() {
    if (!this._isNavEnabled()) return
    this.active = true
    // Clamp cursor to valid range in case series/data changed since last focus
    this._clampCursor()
    // If the chart is zoomed, snap the cursor to the first visible data point
    // so the user doesn't start navigating outside the current viewport.
    this._snapToVisibleRange()
    this._showCurrentPoint()
  }

  _onBlur() {
    this.active = false
    this._tooltipDismissed = false
    this._hideFocus()
  }

  // Called when the user clicks a legend item (collapse/expand a series).
  // Hide the keyboard-nav tooltip — the chart is about to re-render and the
  // current position may no longer be valid.
  _onLegendClick() {
    if (!this.active) return
    this.active = false
    this._hideFocus()
  }

  // ─── Key handler ──────────────────────────────────────────────────────────

  /**
   * @param {KeyboardEvent} e
   */
  _onKeyDown(e) {
    if (!this._isNavEnabled() || !this.active) return

    // Shift+Arrow → pan the visible x-range (when zoom is enabled).
    // Has to come before the plain Arrow cases so the modifier wins.
    if (
      e.shiftKey &&
      (e.key === 'ArrowRight' || e.key === 'ArrowLeft') &&
      this._canPan()
    ) {
      e.preventDefault()
      this._panBy(e.key === 'ArrowRight' ? 1 : -1)
      return
    }

    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault()
        this._move(0, 1)
        break
      case 'ArrowLeft':
        e.preventDefault()
        this._move(0, -1)
        break
      case 'ArrowUp':
        e.preventDefault()
        this._move(-1, 0)
        break
      case 'ArrowDown':
        e.preventDefault()
        this._move(1, 0)
        break
      case 'Home':
        e.preventDefault()
        this.dataPointIndex = 0
        this._skipNullForward()
        this._showCurrentPoint()
        break
      case 'End':
        e.preventDefault()
        this.dataPointIndex = this._getDataPointCount(this.seriesIndex) - 1
        this._skipNullBackward()
        this._showCurrentPoint()
        break
      case 'Enter':
      case ' ':
        e.preventDefault()
        this._fireClick()
        break
      case '+':
      case '=':
        // '=' for unshifted '+' on most US keyboards
        if (this._canZoom()) {
          e.preventDefault()
          this.ctx.toolbar?.handleZoomIn()
          this._announce('Zoomed in')
        }
        break
      case '-':
      case '_':
        if (this._canZoom()) {
          e.preventDefault()
          this.ctx.toolbar?.handleZoomOut()
          this._announce('Zoomed out')
        }
        break
      case '0':
        if (this._canZoom() && this.w.interact.zoomed) {
          e.preventDefault()
          this.ctx.toolbar?.handleZoomReset()
          this._announce('Zoom reset')
        }
        break
      case 'Escape':
        e.preventDefault()
        // Two-stage: first Escape dismisses the tooltip but keeps focus on
        // the chart so the user can resume nav; second Escape exits nav.
        if (!this._tooltipDismissed) {
          this._tooltipDismissed = true
          this._hideFocus()
        } else {
          this.active = false
          this._tooltipDismissed = false
          this._hideFocus()
        }
        break
      default:
        break
    }
  }

  // ─── Zoom / pan (keyboard alternatives for drag gestures) ─────────────────

  _canZoom() {
    const w = this.w
    return Boolean(
      w.globals.axisCharts && w.config.chart.zoom && w.config.chart.zoom.enabled,
    )
  }

  _canPan() {
    // Pan only meaningful once the chart is zoomed in (otherwise full data is
    // already visible). We still allow it whenever zoom is enabled — the
    // toolbar's zoomUpdateOptions clamps to data bounds.
    return this._canZoom()
  }

  /**
   * Shift the visible x-range by ~10% in the given direction.
   * @param {number} direction +1 = right, -1 = left
   */
  _panBy(direction) {
    const w = this.w
    const toolbar = this.ctx.toolbar
    if (!toolbar) return

    const minX = Number(w.globals.minX)
    const maxX = Number(w.globals.maxX)
    if (!isFinite(minX) || !isFinite(maxX) || minX === maxX) return

    const span = maxX - minX
    const step = span * 0.1 * direction
    toolbar.zoomUpdateOptions(minX + step, maxX + step)
    this._announce(direction > 0 ? 'Panned right' : 'Panned left')
  }

  // ─── Navigation ───────────────────────────────────────────────────────────

  /**
   * @param {number} dSeries
   * @param {number} dPoint
   */
  _move(dSeries, dPoint) {
    const w = this.w
    const wrapAround =
      w.config.chart.accessibility.keyboard.navigation.wrapAround

    if (dSeries !== 0) {
      // When tooltip.shared = true AND x values actually align across all
      // series at the current index, the tooltip covers all series identically,
      // so ↑/↓ series switching would show the same content — suppress it.
      // For irregular time series the tooltip falls back to individual mode
      // (isXoverlap returns false), so up/down navigation is meaningful there.
      const ttCtx = this.w.globals.tooltip
      if (ttCtx && ttCtx.tConfig && ttCtx.tConfig.shared) {
        const j = this.dataPointIndex
        const isActuallyShared =
          ttCtx.tooltipUtil &&
          ttCtx.tooltipUtil.isXoverlap(j) &&
          ttCtx.tooltipUtil.isInitialSeriesSameLen()
        if (isActuallyShared) return
      }

      // Move between series (↑/↓)
      const total = this._getSeriesCount()
      let si = this.seriesIndex + dSeries

      // Skip collapsed series
      let attempts = 0
      while (attempts < total) {
        if (si < 0) si = wrapAround ? total - 1 : 0
        if (si >= total) si = wrapAround ? 0 : total - 1

        if (!w.globals.collapsedSeriesIndices.includes(si)) break
        si += dSeries
        attempts++
      }

      this.seriesIndex = si

      // Keep dataPointIndex within bounds of the new series
      const dpCount = this._getDataPointCount(si)
      if (this.dataPointIndex >= dpCount) {
        this.dataPointIndex = dpCount - 1
      }
    }

    if (dPoint !== 0) {
      // Move between data points (←/→)
      const dpCount = this._getDataPointCount(this.seriesIndex)
      let di = this.dataPointIndex + dPoint

      if (di < 0) di = wrapAround ? dpCount - 1 : 0
      if (di >= dpCount) di = wrapAround ? 0 : dpCount - 1

      this.dataPointIndex = di

      // Skip null values
      if (dPoint > 0) {
        this._skipNullForward()
      } else {
        this._skipNullBackward()
      }

      // If the chart is zoomed, skip data points outside the visible range
      if (!this._isDataPointVisible(this.seriesIndex, this.dataPointIndex)) {
        this._snapToVisibleRangeInDirection(dPoint)
      }
    }

    this._showCurrentPoint()
  }

  /** Advance dataPointIndex forward past any nulls */
  _skipNullForward() {
    const w = this.w
    const si = this.seriesIndex
    const dpCount = this._getDataPointCount(si)
    let di = this.dataPointIndex
    let attempts = 0

    // Non-axis charts (pie, etc.) have flat numeric series — no nulls to skip
    if (!Array.isArray(w.seriesData.series[si])) return

    while (attempts < dpCount && w.seriesData.series[si][di] === null) {
      di = (di + 1) % dpCount
      attempts++
    }
    this.dataPointIndex = di
  }

  /** Retreat dataPointIndex backward past any nulls */
  _skipNullBackward() {
    const w = this.w
    const si = this.seriesIndex
    const dpCount = this._getDataPointCount(si)
    let di = this.dataPointIndex
    let attempts = 0

    // Non-axis charts (pie, etc.) have flat numeric series — no nulls to skip
    if (!Array.isArray(w.seriesData.series[si])) return

    while (attempts < dpCount && w.seriesData.series[si][di] === null) {
      di = (di - 1 + dpCount) % dpCount
      attempts++
    }
    this.dataPointIndex = di
  }

  // ─── Display ──────────────────────────────────────────────────────────────

  _showCurrentPoint() {
    const { seriesIndex: i, dataPointIndex: j } = this
    const w = this.w
    const ttCtx = w.globals.tooltip

    if (!ttCtx || !ttCtx.ttItems) return

    // Keep globals consistent with the rest of the system
    w.interact.capturedSeriesIndex = i
    w.interact.capturedDataPointIndex = j

    this._applyFocusClass(i, j)
    this._showTooltip(i, j, /** @type {any} */ (ttCtx))
  }

  _hideFocus() {
    const w = this.w
    const ttCtx = /** @type {any} */ (w.globals.tooltip)

    this._removeFocusClass()
    this._leaveHoveredBar()

    if (!ttCtx) return

    // Reset markers
    if (ttCtx.marker) {
      ttCtx.marker.resetPointsSize()
    }
    this._enlargedScatterMarker = null

    // Hide tooltip and crosshairs using the existing cleanup path
    const tooltipEl = ttCtx.getElTooltip()
    if (tooltipEl) {
      tooltipEl.classList.remove('apexcharts-active')
      if (
        w.config.chart.accessibility.enabled &&
        w.config.chart.accessibility.announcements.enabled
      ) {
        tooltipEl.setAttribute('aria-hidden', 'true')
      }
    }

    w.dom.baseEl.classList.remove('apexcharts-tooltip-active')

    const xcrosshairs = ttCtx.getElXCrosshairs()
    if (xcrosshairs) xcrosshairs.classList.remove('apexcharts-active')
  }

  // ─── Tooltip display per chart type ───────────────────────────────────────

  /**
   * @param {number} i
   * @param {number} j
   * @param {import('../tooltip/Tooltip').default} ttCtx
   */
  _showTooltip(i, j, ttCtx) {
    const w = this.w
    const type = w.config.chart.type

    // Make tooltip visible
    const tooltipEl = ttCtx.getElTooltip()
    if (!tooltipEl) return

    // tooltipRect is normally set by seriesHoverByContext on every mouse event.
    // When keyboard nav fires without any prior mouse interaction it is
    // undefined, which makes every positioning helper produce NaN/0 coords.
    // Populate it here from the cached dimensions so the tooltip appears at the
    // correct position even on the very first keyboard interaction.
    const cachedDims = ttCtx.getCachedDimensions()
    ttCtx.tooltipRect = {
      x: 0,
      y: 0,
      ttWidth: cachedDims.ttWidth || 0,
      ttHeight: cachedDims.ttHeight || 0,
    }

    // Several Position methods (moveTooltip, moveDynamicPointsOnHover,
    // moveStickyTooltipOverBars) branch on tooltip.followCursor and read
    // ttCtx.e.clientX/Y when it is true.  In keyboard nav there is no real
    // mouse event, so we synthesise one whose clientX/Y equal the element's
    // viewport centre.  This makes followCursor charts position the tooltip
    // at the data-point element instead of at 0,0 (or crashing on undefined).
    // We restore the original ttCtx.e after positioning so that no downstream
    // code is surprised.
    this._setSyntheticEvent(i, j, ttCtx)

    w.dom.baseEl.classList.add('apexcharts-tooltip-active')
    tooltipEl.classList.add('apexcharts-active')
    if (
      w.config.chart.accessibility.enabled &&
      w.config.chart.accessibility.announcements.enabled
    ) {
      tooltipEl.removeAttribute('aria-hidden')
    }

    if (type === 'pie' || type === 'donut' || type === 'polarArea') {
      this._showTooltipNonAxis(i, j, ttCtx, tooltipEl)
    } else if (type === 'radialBar') {
      this._showTooltipRadialBar(i, j, ttCtx, tooltipEl)
    } else if (type === 'heatmap' || type === 'treemap') {
      this._showTooltipHeatTree(i, j, ttCtx, tooltipEl, type)
    } else if (
      type === 'bar' ||
      type === 'candlestick' ||
      type === 'boxPlot' ||
      type === 'rangeBar'
    ) {
      this._showTooltipBar(i, j, ttCtx)
    } else {
      // line, area, scatter, bubble, radar, rangeArea
      this._showTooltipAxisLine(i, j, ttCtx)
    }
  }

  /**
   * Set ttCtx.e to a synthetic mouse-event-like object whose clientX/Y point
   * to the centre of the current data-point element.  This ensures that any
   * positioning helper that reads ttCtx.e (followCursor path in moveTooltip,
   * moveStickyTooltipOverBars, moveDynamicPointsOnHover, etc.) gets valid
   * coordinates rather than crashing on undefined.
   *
   * For chart types that don't have a concrete SVG element per data point
   * (pie, radialBar) we fall back to the SVG centre.
   * @param {number} i
   * @param {number} j
   * @param {import('../tooltip/Tooltip').default} ttCtx
   */
  _setSyntheticEvent(i, j, ttCtx) {
    const w = this.w
    const type = w.config.chart.type

    let clientX = 0
    let clientY = 0

    // Try to find the element and use its centre as the synthetic position
    const el = this._getFocusableElement(i, j)
    if (el) {
      const rect = el.getBoundingClientRect()
      clientX = rect.left + rect.width / 2
      clientY = rect.top + rect.height / 2
    } else if (
      w.globals.pointsArray &&
      w.globals.pointsArray[i] &&
      w.globals.pointsArray[i][j]
    ) {
      // Axis-line charts: derive from pointsArray pixel coords
      const pt = w.globals.pointsArray[i][j]
      const elGrid = ttCtx.getElGrid && ttCtx.getElGrid()
      if (elGrid) {
        const gridRect = elGrid.getBoundingClientRect()
        clientX = gridRect.left + (pt[0] || 0)
        clientY = gridRect.top + (pt[1] || 0)
      }
    } else {
      // Fallback: SVG element centre
      const svgEl = w.dom.Paper && w.dom.Paper.node
      if (svgEl) {
        const svgRect = svgEl.getBoundingClientRect()
        clientX = svgRect.left + svgRect.width / 2
        clientY = svgRect.top + svgRect.height / 2
      }
    }

    // For line/area/rangeArea: pointsArray gives the most accurate position
    if (
      type === 'line' ||
      type === 'area' ||
      type === 'rangeArea' ||
      type === 'scatter' ||
      type === 'bubble' ||
      type === 'radar'
    ) {
      if (
        w.globals.pointsArray &&
        w.globals.pointsArray[i] &&
        w.globals.pointsArray[i][j]
      ) {
        const pt = w.globals.pointsArray[i][j]
        const elGrid = ttCtx.getElGrid && ttCtx.getElGrid()
        if (elGrid) {
          const gridRect = elGrid.getBoundingClientRect()
          clientX = gridRect.left + (pt[0] || 0)
          clientY = gridRect.top + (pt[1] || 0)
        }
      }
    }

    ttCtx.e = { type: 'mousemove', clientX, clientY }
  }

  /**
   * bar / column / candlestick / boxPlot / rangeBar
   * @param {number} i
   * @param {number} j
   * @param {import('../tooltip/Tooltip').default} ttCtx
   */
  _showTooltipBar(i, j, ttCtx) {
    const w = this.w

    // Mirror the runtime check in handleStickyCapturedSeries: only use shared
    // mode when x values actually align across all series at this index.
    const shared =
      ttCtx.tConfig.shared &&
      (ttCtx.tooltipUtil.isXoverlap(j) || w.globals.isBarHorizontal) &&
      ttCtx.tooltipUtil.isInitialSeriesSameLen()

    // Draw tooltip text content
    const rangeData = /** @type {any} */ (w.rangeData.seriesRange)?.[i]?.[j]
      ?.y?.[0]
    ttCtx.tooltipLabels.drawSeriesTexts({
      ttItems: ttCtx.ttItems,
      i,
      j,
      ...(rangeData?.y1 !== undefined && { y1: rangeData.y1 }),
      ...(rangeData?.y2 !== undefined && { y2: rangeData.y2 }),
      shared,
    })

    // Apply the hover visual state on the bar.
    // Use Paper.findOne() to get the SVG.js wrapper (same as toggleDataPointSelection
    // in UpdateHelpers.js) — querySelector returns a plain DOM node which lacks the
    // .node property that pathMouseEnter requires.
    const parent = `.apexcharts-series[data\\:realIndex='${i}']`
    const elPath = w.dom.Paper.findOne(
      `${parent} path[j='${j}'], ${parent} circle[j='${j}'], ${parent} rect[j='${j}']`,
    )
    if (elPath) {
      // Leave the previous bar before entering the new one
      this._leaveHoveredBar()
      const graphics = new Graphics(this.w, this.ctx)
      graphics.pathMouseEnter(elPath, null)
      this._hoveredBarEl = elPath
    }

    if (w.globals.isBarHorizontal) {
      // Horizontal rangeBar / timeline: position tooltip using viewport-relative
      // coords to avoid grid-space vs wrapper-space confusion.
      const barDomEl = elPath && elPath.node
      if (barDomEl) {
        const wrapRect = w.dom.elWrap.getBoundingClientRect()
        const barRect = barDomEl.getBoundingClientRect()

        // Bar centre in elWrap-relative coordinates
        const barCx = barRect.left - wrapRect.left // left edge of bar
        const barCy = barRect.top - wrapRect.top // top edge of bar
        const bh = barRect.height
        const bw = barRect.width

        const ttWidth = ttCtx.tooltipRect.ttWidth || 0
        const ttHeight = ttCtx.tooltipRect.ttHeight || 0

        // Vertically: centre the tooltip on the bar
        const y = barCy + bh / 2 - ttHeight / 2

        // Horizontally: place tooltip at the bar's right edge (positive values)
        // or left of bar start for negative bars (same logic as Intersect)
        let x = barCx + bw
        const baselineX =
          ttCtx.xyRatios && ttCtx.xyRatios.baseLineInvertedY != null
            ? ttCtx.xyRatios.baseLineInvertedY
            : wrapRect.width / 2
        if (barCx < baselineX) {
          x = barCx - ttWidth
        }

        const tooltipEl = ttCtx.getElTooltip()
        if (tooltipEl) {
          tooltipEl.style.left = x + 'px'
          tooltipEl.style.top = y + 'px'
        }
      }
    } else {
      // Vertical bar / column / candlestick / boxPlot
      ttCtx.tooltipPosition.moveStickyTooltipOverBars(j, i)
    }
  }
  /**
   * line / area / scatter / bubble / radar / rangeArea
   * @param {number} i
   * @param {number} j
   * @param {import('../tooltip/Tooltip').default} ttCtx
   */
  _showTooltipAxisLine(i, j, ttCtx) {
    const w = this.w
    const type = w.config.chart.type

    // Mirror the runtime check in handleStickyCapturedSeries: tooltip.shared
    // only applies when all series have the same x value at index j and the
    // same number of data points.  For irregular time series (different x
    // values across series), fall back to individual (non-shared) tooltip so
    // only the currently focused series is shown — matching mouse behaviour.
    const sharedConfigured = ttCtx.tConfig.shared
    const shared =
      sharedConfigured &&
      ttCtx.tooltipUtil.isXoverlap(j) &&
      ttCtx.tooltipUtil.isInitialSeriesSameLen()

    ttCtx.tooltipLabels.drawSeriesTexts({
      ttItems: ttCtx.ttItems,
      i,
      j,
      shared,
    })

    // Scatter and bubble charts use intersect mode — each data point is a real
    // SVG marker element with cx/cy attributes set by Scatter.js.
    // pointsArray is only populated for null/invalid data points, so
    // moveDynamicPointOnHover fails for valid scatter/bubble data.
    //
    // enlargePoints(j) searches all series for markers where rel===j, which
    // causes two problems for keyboard nav:
    //   1. Multiple bubbles across series enlarge at once (wrong for single selection)
    //   2. Tooltip ends up positioned over the LAST matching marker, not series i
    //
    // Instead we replicate what Intersect.handleMarkerTooltip does: find the
    // specific marker for (i, j), resize only it, and position the tooltip
    // at its cx/cy using the same formula as mouse hover.
    const isScatterLike = type === 'scatter' || type === 'bubble'
    const hasVisibleMarkers = w.globals.markers.largestSize > 0

    if (isScatterLike) {
      this._showScatterBubblePoint(i, j, ttCtx)
    } else if (hasVisibleMarkers) {
      // Line/area with visible permanent markers
      if (shared) {
        ttCtx.marker.enlargePoints(j)
      } else {
        // irregular series — only enlarge the focused series' marker
        ttCtx.tooltipPosition.moveDynamicPointOnHover(j, i)
      }
    } else if (shared) {
      // shared=true, x values match — show dynamic point on all series
      ttCtx.tooltipPosition.moveDynamicPointsOnHover(j)
    } else {
      // shared=false or x values differ — show dynamic point on this series only
      ttCtx.tooltipPosition.moveDynamicPointOnHover(j, i)
    }
  }

  /**
   * Scatter / bubble: find the specific marker element for (seriesIndex i,
   * dataPointIndex j), resize only that element, and position the tooltip at
   * its coordinates — mirroring what Position.moveMarkers does for mouse hover.
   *
   * Unlike enlargePoints(j) which queries ALL series for rel===j (causing
   * multiple bubbles to enlarge and tooltip to land on the wrong one), this
   * method queries by both series index AND data-point index for precision.
   * @param {number} i
   * @param {number} j
   * @param {import('../tooltip/Tooltip').default} ttCtx
   */
  _showScatterBubblePoint(i, j, ttCtx) {
    const baseEl = this.w.dom.baseEl

    // Reset only the previously enlarged marker (not all markers via
    // resetPointsSize()). Calling resetPointsSize() modifies the `d`
    // attribute on every marker element in the DOM, which can trigger
    // synthetic mouseout events on those elements — the Tooltip mouse
    // listeners catch those and call handleMouseOut → resetPointsSize()
    // again, creating a resize ping-pong that appears as jitter.
    if (this._enlargedScatterMarker) {
      ttCtx.marker.oldPointSize(this._enlargedScatterMarker)
      this._enlargedScatterMarker = null
    }

    // Find the marker for this exact series (data:realIndex attr) and data
    // point (rel attr). The element hierarchy is:
    //   .apexcharts-series[data:realIndex='i'] > ... > .apexcharts-marker[rel='j']
    const seriesEl = baseEl.querySelector(
      `.apexcharts-series[data\\:realIndex='${i}']`,
    )
    if (!seriesEl) return

    const markerEl = seriesEl.querySelector(`.apexcharts-marker[rel='${j}']`)
    if (!markerEl) return

    // enlargeCurrentPoint already handles bubble (skips resize since bubble
    // radius encodes a data dimension), reads cx/cy from the element, and
    // delegates positioning to moveTooltip — which correctly adds translateX.
    ttCtx.marker.enlargeCurrentPoint(j, markerEl)

    // Remember which element was enlarged so we can reset only it next time.
    this._enlargedScatterMarker = markerEl
  }
  /**
   * pie / donut / polarArea
   * @param {number} i
   * @param {number} j
   * @param {import('../tooltip/Tooltip').default} ttCtx
   * @param {HTMLElement} tooltipEl
   */
  _showTooltipNonAxis(i, j, ttCtx, tooltipEl) {
    const w = this.w

    // For pie-like charts the series index IS the data point / slice index
    ttCtx.tooltipLabels.drawSeriesTexts({
      ttItems: ttCtx.ttItems,
      i: j,
      shared: false,
    })

    // Refresh tooltip dimensions after content is drawn
    const tooltipBound = tooltipEl.getBoundingClientRect()
    const ttWidth = tooltipBound.width || ttCtx.tooltipRect.ttWidth || 0
    const ttHeight = tooltipBound.height || ttCtx.tooltipRect.ttHeight || 0

    // Use data:cx / data:cy — the arc centroid in SVG/grid-space computed by
    // Pie.js (same values that nonAxisChartsTooltips uses for intersect mode).
    // The path element carries j='${j}' (0-indexed). data:cx/cy are set on
    // the path directly (not on the parent group).
    const sliceEl = w.dom.baseEl.querySelector(`.apexcharts-pie-area[j='${j}']`)
    if (sliceEl) {
      const cx = parseFloat(sliceEl.getAttribute('data:cx') ?? '')
      const cy = parseFloat(sliceEl.getAttribute('data:cy') ?? '')

      if (!isNaN(cx) && !isNaN(cy)) {
        // Convert SVG-space to elWrap-relative (same transform as mouse path)
        const svgBound = w.dom.Paper.node.getBoundingClientRect()
        const wrapBound = w.dom.elWrap.getBoundingClientRect()
        const offsetX = svgBound.left - wrapBound.left
        const offsetY = svgBound.top - wrapBound.top

        tooltipEl.style.left = offsetX + cx - ttWidth / 2 + 'px'
        tooltipEl.style.top = offsetY + cy - ttHeight - 10 + 'px'
      }
    }
  }
  /**
   * radialBar — one ring per series, single value each
   * @param {number} i
   * @param {any} _j
   * @param {import('../tooltip/Tooltip').default} ttCtx
   * @param {HTMLElement} tooltipEl
   */
  _showTooltipRadialBar(i, _j, ttCtx, tooltipEl) {
    const w = this.w

    ttCtx.tooltipLabels.drawSeriesTexts({
      ttItems: ttCtx.ttItems,
      i,
      shared: false,
    })

    const { ttWidth = 0, ttHeight = 0 } = ttCtx.getCachedDimensions()

    // Each radial series is a ring; find the path and use its data:angle to
    // compute the centroid at the midpoint of the arc.
    const arcEl = w.dom.baseEl.querySelector(
      `.apexcharts-radialbar-series[data\\:realIndex='${i}'] path`,
    )
    if (arcEl) {
      const angle = parseFloat(arcEl.getAttribute('data:angle') ?? '') || 0
      // Radial bars start from the top (initialAngle) and sweep clockwise
      const initialAngle = w.config.plotOptions.radialBar.startAngle || 0
      const midAngle = initialAngle + angle / 2

      const centerX = w.layout.gridWidth / 2
      const centerY = w.layout.gridHeight / 2
      const radialSize =
        w.globals.radialSize ||
        Math.min(w.layout.gridWidth, w.layout.gridHeight) / 2

      // Use the outer radius for this particular ring (series i)
      const seriesCount = w.seriesData.series.length
      const trackSize = radialSize / Math.max(seriesCount, 1)
      const outerRadius = radialSize - i * trackSize
      const innerRadius = outerRadius - trackSize
      const ringRadius = (outerRadius + innerRadius) / 2

      const centroid = Utils.polarToCartesian(
        centerX,
        centerY,
        ringRadius,
        midAngle,
      )
      const x = centroid.x + (w.layout.translateX || 0)
      const y = centroid.y + (w.layout.translateY || 0)

      tooltipEl.style.left = x - ttWidth / 2 + 'px'
      tooltipEl.style.top = y - ttHeight - 10 + 'px'
    }
  }
  /**
   * heatmap / treemap — position tooltip using element bounding rect
   * @param {number} i
   * @param {number} j
   * @param {import('../tooltip/Tooltip').default} ttCtx
   * @param {HTMLElement} tooltipEl
   * @param {string} type
   */
  _showTooltipHeatTree(i, j, ttCtx, tooltipEl, type) {
    const w = this.w

    ttCtx.tooltipLabels.drawSeriesTexts({
      ttItems: ttCtx.ttItems,
      i,
      j,
      shared: false,
    })

    // Refresh tooltip dimensions after content is drawn
    const tooltipRect = tooltipEl.getBoundingClientRect()
    const ttWidth = tooltipRect.width || ttCtx.tooltipRect.ttWidth || 0
    const ttHeight = tooltipRect.height || ttCtx.tooltipRect.ttHeight || 0

    const rectClass =
      type === 'heatmap' ? 'apexcharts-heatmap-rect' : 'apexcharts-treemap-rect'

    const cell = w.dom.baseEl.querySelector(`.${rectClass}[i='${i}'][j='${j}']`)
    if (cell) {
      // Use viewport-relative rects so we don't need to worry about SVG
      // translate offsets (cx/cy on these elements are in grid-space).
      const wrapRect = w.dom.elWrap.getBoundingClientRect()
      const cellRect = cell.getBoundingClientRect()

      const cellCx = cellRect.left - wrapRect.left
      const cellCy = cellRect.top - wrapRect.top
      const cellWidth = cellRect.width
      const cellHeight = cellRect.height

      // Move crosshair to horizontal centre of cell
      const cx = parseFloat(cell.getAttribute('cx') ?? '')
      const cellWidthAttr = parseFloat(cell.getAttribute('width') ?? '')
      ttCtx.tooltipPosition.moveXCrosshairs(cx + cellWidthAttr / 2)

      // Position tooltip to the right of the cell, vertically centred;
      // flip left if it would overflow the right half of the grid.
      let x = cellCx + cellWidth + ttWidth / 2
      const y = cellCy + cellHeight / 2 - ttHeight / 2

      if (cellCx + cellWidth > w.layout.gridWidth / 2) {
        x = cellCx - ttWidth / 2
      }

      tooltipEl.style.left = x + 'px'
      tooltipEl.style.top = y + 'px'
    }
  }

  // ─── Focus class management ───────────────────────────────────────────────

  /**
   * @param {number} i
   * @param {number} j
   */
  _applyFocusClass(i, j) {
    this._removeFocusClass()

    const el = this._getFocusableElement(i, j)
    if (el) {
      el.classList.add('apexcharts-keyboard-focused')
      // WCAG 4.1.2 Name, Role, Value: give the focused data point an
      // accessible name so screen readers announce series + value + category
      // when the user navigates via arrow keys. We apply on focus (not on
      // every render) to keep large datasets cheap and avoid noisy SR output.
      el.setAttribute('role', 'img')
      const label = this._buildPointLabel(i, j)
      if (label) el.setAttribute('aria-label', label)
      this._focusedEl = el
    }
  }

  _removeFocusClass() {
    if (this._focusedEl) {
      this._focusedEl.classList.remove('apexcharts-keyboard-focused')
      this._focusedEl.removeAttribute('role')
      this._focusedEl.removeAttribute('aria-label')
      this._focusedEl = null
    }
  }

  /**
   * Build an accessible label for the data point at (i, j) using the same
   * formatters the visible tooltip / axis labels use, so SR output matches
   * the visual presentation.
   * @param {number} i
   * @param {number} j
   * @returns {string}
   */
  _buildPointLabel(i, j) {
    const w = this.w
    const type = w.config.chart.type

    const seriesNames = w.seriesData.seriesNames || []
    const series = w.seriesData.series || []

    // Non-axis charts: i is unused by navigation (single-series), j is the slice
    if (type === 'pie' || type === 'donut' || type === 'polarArea') {
      const sliceLabel = (w.labelData?.labels && w.labelData.labels[j]) ?? ''
      const value = Array.isArray(series) ? series[j] : ''
      return sliceLabel ? `${sliceLabel}: ${value}` : `${value}`
    }

    if (type === 'radialBar') {
      const seriesName = seriesNames[i] || `Series ${i + 1}`
      const value = Array.isArray(series) ? series[i] : ''
      return `${seriesName}: ${value}`
    }

    const seriesName = seriesNames[i] || `Series ${i + 1}`

    // Format the y-value via the same formatter used by axis/tooltip labels.
    const row = Array.isArray(series[i]) ? series[i] : []
    const rawValue = row[j]
    let formattedValue = rawValue == null ? '' : String(rawValue)
    const yFormatter = w.formatters?.yLabelFormatters?.[i]
    if (typeof yFormatter === 'function') {
      try {
        formattedValue = yFormatter(rawValue, {
          seriesIndex: i,
          dataPointIndex: j,
          w,
        })
      } catch {
        // fall back to raw
      }
    }

    // Format the x-category. For category charts, w.labelData.categoryLabels
    // holds the visible labels; otherwise fall back to seriesX numeric values.
    let category = ''
    const categoryLabels = w.labelData?.categoryLabels
    const seriesX = w.seriesData?.seriesX?.[i]
    if (Array.isArray(categoryLabels) && categoryLabels[j] != null) {
      category = String(categoryLabels[j])
    } else if (Array.isArray(seriesX) && seriesX[j] != null) {
      const xFormatter = w.formatters?.xLabelFormatter
      if (typeof xFormatter === 'function') {
        try {
          category = String(
            xFormatter(seriesX[j], { seriesIndex: i, dataPointIndex: j, w }),
          )
        } catch {
          category = String(seriesX[j])
        }
      } else {
        category = String(seriesX[j])
      }
    }

    return category
      ? `${seriesName}: ${formattedValue}, ${category}`
      : `${seriesName}: ${formattedValue}`
  }

  _leaveHoveredBar() {
    if (this._hoveredBarEl) {
      const graphics = new Graphics(this.w, this.ctx)
      graphics.pathMouseLeave(this._hoveredBarEl, null)
      this._hoveredBarEl = null
    }
  }

  /**
   * @param {number} i
   * @param {number} j
   */
  _getFocusableElement(i, j) {
    const w = this.w
    const type = w.config.chart.type
    const baseEl = w.dom.baseEl

    if (type === 'pie' || type === 'donut' || type === 'polarArea') {
      // j is 0-indexed; the path carries j='${j}' (rel is on the parent group)
      return baseEl.querySelector(`.apexcharts-pie-area[j='${j}']`)
    }

    if (type === 'heatmap') {
      return baseEl.querySelector(
        `.apexcharts-heatmap-rect[i='${i}'][j='${j}']`,
      )
    }

    if (type === 'treemap') {
      return baseEl.querySelector(
        `.apexcharts-treemap-rect[i='${i}'][j='${j}']`,
      )
    }

    if (type === 'radialBar') {
      return baseEl.querySelector(
        `.apexcharts-radialbar-series[data\\:realIndex='${i}'] path`,
      )
    }

    if (
      type === 'bar' ||
      type === 'candlestick' ||
      type === 'boxPlot' ||
      type === 'rangeBar'
    ) {
      return baseEl.querySelector(
        `.apexcharts-series[data\\:realIndex='${i}'] path[j='${j}']`,
      )
    }

    // line / area / scatter / bubble / radar — try marker element
    const marker = baseEl.querySelector(
      `.apexcharts-series[data\\:realIndex='${i}'] .apexcharts-marker[rel='${j}']`,
    )
    return marker || null
  }

  // ─── Click / Enter ────────────────────────────────────────────────────────

  _fireClick() {
    const w = this.w
    const ttCtx = w.globals.tooltip
    if (!ttCtx) return

    const syntheticEvent = {
      type: 'mouseup',
      clientX: 0,
      clientY: 0,
    }
    ttCtx.markerClick(syntheticEvent, this.seriesIndex, this.dataPointIndex)
  }

  // ─── Helpers ──────────────────────────────────────────────────────────────

  _isNavEnabled() {
    const a11y = this.w.config.chart.accessibility
    return (
      a11y.enabled && a11y.keyboard.enabled && a11y.keyboard.navigation.enabled
    )
  }

  _getSeriesCount() {
    const w = this.w
    const type = w.config.chart.type
    // Non-axis charts: pie/donut/polarArea navigate slices, not series
    if (type === 'pie' || type === 'donut' || type === 'polarArea') {
      return 1 // single "series" — all navigation is on dataPointIndex
    }
    return w.seriesData.series.length
  }

  /**
   * @param {number} si
   */
  _getDataPointCount(si) {
    const w = this.w
    const type = w.config.chart.type
    // Non-axis charts: globals.series is a flat array of values (one per slice)
    if (type === 'pie' || type === 'donut' || type === 'polarArea') {
      return w.seriesData.series.length
    }
    const series = w.seriesData.series
    return series[si] && Array.isArray(series[si]) ? series[si].length : 0
  }

  _clampCursor() {
    const seriesCount = this._getSeriesCount()
    if (this.seriesIndex >= seriesCount) this.seriesIndex = seriesCount - 1
    if (this.seriesIndex < 0) this.seriesIndex = 0

    const dpCount = this._getDataPointCount(this.seriesIndex)
    if (this.dataPointIndex >= dpCount) this.dataPointIndex = dpCount - 1
    if (this.dataPointIndex < 0) this.dataPointIndex = 0
  }

  /**
   * When the chart is zoomed in, the current dataPointIndex may point to a
   * data point that is outside the visible viewport. Snap the cursor to the
   * first data point whose x-value falls within [minX, maxX].
   *
   * Only adjusts when w.seriesData.seriesX is populated (numeric/datetime axes).
   * Category-only charts (seriesX entries are strings or auto-indices) are
   * unaffected — all points are always visible.
   */
  _snapToVisibleRange() {
    const w = this.w
    const gl = w.globals
    const si = this.seriesIndex

    // No zoom applied — nothing to do
    if (!w.interact.zoomed) return

    const seriesX = w.seriesData.seriesX && w.seriesData.seriesX[si]
    if (!seriesX || !seriesX.length) return

    const minX = gl.minX
    const maxX = gl.maxX

    if (minX === undefined || maxX === undefined) return

    // Check if the current data point is already visible
    const currentX = seriesX[this.dataPointIndex]
    if (currentX >= minX && currentX <= maxX) return

    // Find the first data point within [minX, maxX]
    const dpCount = seriesX.length
    for (let di = 0; di < dpCount; di++) {
      if (seriesX[di] >= minX && seriesX[di] <= maxX) {
        this.dataPointIndex = di
        return
      }
    }
    // If no data point is in range (shouldn't happen in practice), leave cursor as-is
  }

  /**
   * Snap to the nearest visible data point in the given navigation direction.
   * direction > 0 → find the first visible point (left boundary of zoomed range)
   * direction < 0 → find the last visible point (right boundary of zoomed range)
   * @param {number} direction
   */
  _snapToVisibleRangeInDirection(direction) {
    const w = this.w
    const gl = w.globals
    const si = this.seriesIndex

    const seriesX = w.seriesData.seriesX && w.seriesData.seriesX[si]
    if (!seriesX || !seriesX.length) return

    const minX = gl.minX
    const maxX = gl.maxX
    if (minX === undefined || maxX === undefined) return

    const dpCount = seriesX.length

    if (direction >= 0) {
      // Going right: snap to first visible point
      for (let di = 0; di < dpCount; di++) {
        if (seriesX[di] >= minX && seriesX[di] <= maxX) {
          this.dataPointIndex = di
          return
        }
      }
    } else {
      // Going left: snap to last visible point
      for (let di = dpCount - 1; di >= 0; di--) {
        if (seriesX[di] >= minX && seriesX[di] <= maxX) {
          this.dataPointIndex = di
          return
        }
      }
    }
  }

  /**
   * Check whether the data point at (si, di) is within the current visible
   * x-axis range. Used to skip out-of-viewport points during keyboard nav.
   * @param {number} si
   * @param {number} di
   */
  _isDataPointVisible(si, di) {
    const w = this.w
    const gl = w.globals

    if (!w.interact.zoomed) return true

    const seriesX = w.seriesData.seriesX && w.seriesData.seriesX[si]
    if (!seriesX) return true

    const x = seriesX[di]
    if (x === undefined) return true

    return x >= gl.minX && x <= gl.maxX
  }

  /**
   * Push a short status message to the visually-hidden aria-live region so
   * screen readers announce zoom / pan / reset events that have no inherent
   * tooltip update. Silently no-op if the region is missing or announcements
   * are disabled.
   * @param {string} message
   */
  _announce(message) {
    const w = this.w
    if (!w.config.chart.accessibility.announcements.enabled) return

    const baseEl = w.dom.baseEl
    if (!baseEl) return
    const region = baseEl.querySelector('.apexcharts-sr-status')
    if (!region) return

    // Toggling textContent on a polite live region is enough for SRs to
    // re-announce. Same string twice in a row otherwise gets coalesced.
    region.textContent = ''
    // Microtask delay so the empty/then-text sequence is observed.
    setTimeout(() => {
      region.textContent = message
    }, 0)
  }
}
