// @ts-check
import Utils from '../utils/Utils'
import { BrowserAPIs } from '../ssr/BrowserAPIs'
import { Environment } from '../utils/Environment'

const SVGNS = 'http://www.w3.org/2000/svg'

/**
 * Cubic ease-out — fast start, decelerates toward the end. Reads as a pen
 * sweeping across the chart and settling, which is the feel we want for the
 * draw effect (ease-in-out's fast middle makes it whip too aggressively).
 * @param {number} t
 */
function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3)
}

/**
 * Cubic ease-out-back — overshoots `1` near the end then settles. Used for the
 * `pop` effect (scatter/bubble markers) so each marker feels like it's
 * landing rather than just appearing.
 * Equivalent to CSS `cubic-bezier(0.34, 1.56, 0.64, 1)`.
 * @param {number} t
 */
function easeOutBack(t) {
  const c1 = 1.70158
  const c3 = c1 + 1
  return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
}

/**
 * Cached reduced-motion query — `matchMedia` evaluation is cheap but the
 * MediaQueryList instance is reused if a chart re-renders.
 * @type {MediaQueryList | null}
 */
let _reducedMotionMql = null

/**
 * @returns {boolean} true when the OS has prefers-reduced-motion: reduce active.
 */
export function prefersReducedMotion() {
  if (!Environment.isBrowser()) return false
  try {
    if (!_reducedMotionMql) {
      _reducedMotionMql = window.matchMedia('(prefers-reduced-motion: reduce)')
    }
    return !!_reducedMotionMql.matches
  } catch (_) {
    return false
  }
}

/**
 * @typedef {Object} StaggerOpts
 * @property {'sequential' | 'group' | 'diagonal' | 'centroid' | 'none'} style
 * @property {number} index - 0-based item index in its group
 * @property {number} [total] - total items (for centroid normalization)
 * @property {number} [baseDelay=40] - delay per stagger step in ms
 * @property {number} [row] - for 'diagonal' (heatmap)
 * @property {number} [col] - for 'diagonal'
 * @property {number} [groupIndex] - for 'group'
 * @property {number} [perGroup] - for 'group'
 * @property {number} [centerDistance] - for 'centroid' (0..1 normalized)
 */

/**
 * Apply the "progressive marker reveal" to an SVG element at grid-x `x`.
 * On initial mount of a line/area/rangeArea chart, each marker (or data label
 * or x-axis/point annotation) snaps in at the moment the line's pen-stroke
 * reaches its x position.
 *
 * Idempotent and SSR-safe — no-op when:
 *   - not running in a browser
 *   - this is a data-update / resize render
 *   - chart.animations.enabled is false
 *   - chart type isn't line/area/rangeArea
 *
 * @param {any} el       - svg.js element (uses el.node.style)
 * @param {number} x     - grid-coordinate x of the element
 * @param {import('../types/internal').ChartStateW} w
 * @returns {boolean}    - true if the reveal was applied
 */
export function applyProgressiveReveal(el, x, w) {
  if (!Environment.isBrowser()) return false
  if (w.globals.dataChanged || w.globals.resized) return false
  const animCfg = w.config.chart.animations
  if (!animCfg || animCfg.enabled === false) return false
  const chartType = w.config.chart.type
  if (chartType !== 'line' && chartType !== 'area' && chartType !== 'rangeArea') {
    return false
  }
  if (!(w.layout.gridWidth > 0)) return false

  // Line/area/radar draw mode internally doubles the configured speed (see
  // Graphics.renderPaths). The reveal must use the same effective duration
  // so markers land in sync with the pen tip.
  const drawSpeed = (animCfg.speed || 800) * 2
  const xRatio = Math.max(0, Math.min(1, x / w.layout.gridWidth))

  // Invert the line's easeOutCubic to find *when* the tip reaches this x.
  // The line covers ground fast at the start and slow at the end, so a marker
  // at xRatio=0.5 is reached at t ≈ 0.21, not t = 0.5. Linear timing was
  // making everything lag by exactly this eased-vs-linear difference.
  //   easeOutCubic(t) = 1 - (1 - t)^3
  //   inverse:          t = 1 - cbrt(1 - y)
  const easedT = 1 - Math.cbrt(1 - xRatio)
  const revealDelay = easedT * drawSpeed

  const node = el.node
  const style = node.style
  style.opacity = '0'

  // Anchor on the first rAF after render — the same frame `animateDraw` uses
  // as its t=0. All rAF callbacks scheduled in the current task receive the
  // same timestamp in the next frame, so reveal and draw share an origin.
  // Then poll rAFs (not setTimeout) so the snap happens on a frame boundary.
  /** @type {number | null} */
  let startAnchor = null
  /** @param {number} now */
  const tick = (now) => {
    if (startAnchor === null) startAnchor = now
    if (now - startAnchor >= revealDelay) {
      // Clear the inline opacity rather than forcing '1' — an inline style
      // would override the element's SVG `opacity` attribute (e.g. an
      // x-axis range annotation rect with `opacity: 0.4`).
      style.opacity = ''
    } else {
      BrowserAPIs.requestAnimationFrame(tick)
    }
  }
  BrowserAPIs.requestAnimationFrame(tick)
  return true
}

/**
 * Apply OS / config-driven animation policy to the chart's effective config.
 * Currently: if prefers-reduced-motion is active AND
 * `chart.animations.respectReducedMotion` is true, disable both initial-mount
 * and data-change animations so the chart renders instantly.
 *
 * Must be called once, after config finalization (Base.init) and before the
 * first render. Idempotent; safe to call again after a config update.
 *
 * @param {import('../types/internal').ChartStateW} w
 */
export function applyAnimationPolicy(w) {
  const anim = w.config.chart.animations
  if (!anim) return
  if (anim.respectReducedMotion !== false && prefersReducedMotion()) {
    anim.enabled = false
    if (anim.dynamicAnimation) anim.dynamicAnimation.enabled = false
  }
}

/**
 * Compute a per-element delay for staggered initial-mount animations.
 *
 * Recognized options on `opts`:
 *   style:          'sequential' | 'group' | 'diagonal' | 'centroid' | 'none'
 *   index:          0-based item index in its group
 *   baseDelay:      delay per stagger step in ms (default 40)
 *   row, col:       for 'diagonal' (heatmap)
 *   groupIndex,
 *   perGroup:       for 'group'
 *   centerDistance: for 'centroid' (0..1 normalized)
 *
 * @param {any} opts
 * @returns {number} delay in ms
 */
export function computeStagger(opts) {
  const style = opts.style
  const index = opts.index || 0
  const baseDelay = typeof opts.baseDelay === 'number' ? opts.baseDelay : 40
  const row = opts.row || 0
  const col = opts.col || 0
  const groupIndex = opts.groupIndex || 0
  const perGroup = opts.perGroup || 1
  const centerDistance = opts.centerDistance || 0
  switch (style) {
    case 'none':
      return 0
    case 'diagonal':
      return (row + col) * baseDelay
    case 'group':
      return groupIndex * baseDelay + (index % perGroup) * (baseDelay / 4)
    case 'centroid':
      return centerDistance * baseDelay * (index + 1)
    case 'sequential':
    default:
      return index * baseDelay
  }
}

/**
 * ApexCharts Animation Class.
 *
 * @module Animations
 **/

export default class Animations {
  /**
   * @param {import('../types/internal').ChartStateW} w
   * @param {import('../types/internal').ChartContext} [ctx]
   */
  constructor(w, ctx) {
    this.w = w
    this.ctx = ctx // kept for animationEnd user callback: chart.events.animationEnd(ctx, …)
  }

  /**
   * @param {any} el
   * @param {Record<string, any>} from
   * @param {Record<string, any>} to
   * @param {object} speed
   */
  animateLine(el, from, to, speed) {
    el.attr(from).animate(speed).attr(to)
  }

  /*
   ** Animate radius of a circle element
   * @param {any} el
   * @param {number} speed
   * @param {string} easing
   * @param {Function} cb
   */
  /** @param {any} el @param {any} speed @param {any} easing @param {any} cb */
  animateMarker(el, speed, easing, cb) {
    el.attr({
      opacity: 0,
    })
      .animate(speed)
      .attr({
        opacity: 1,
      })
      .after(() => {
        cb()
      })
  }

  /**
   * Scale-up "pop" effect for scatter/bubble markers. Animates
   * `transform: scale(0 → 1)` (with `easeOutBack` overshoot) plus opacity,
   * around the marker's own center via `transform-box: fill-box`.
   *
   * Falls back to instant render in SSR / when shouldAnimate is false.
   *
   * @param {any} el
   * @param {{ speed: number, delay?: number, onComplete?: () => void }} params
   */
  animatePop(el, { speed, delay = 0, onComplete }) {
    const w = this.w

    if (!Environment.isBrowser() || !w.globals.shouldAnimate || speed < 1) {
      if (onComplete) onComplete()
      return
    }

    const node = el.node
    const style = node.style
    // SVG2 transform-box puts the origin at the element's geometric center
    // regardless of its (x,y) position, so each marker scales in place.
    style.transformBox = 'fill-box'
    style.transformOrigin = 'center'
    style.transform = 'scale(0)'
    style.opacity = '0'

    const startAt = performance.now() + delay
    /** @param {number} now */
    const step = (now) => {
      const t = Math.max(0, Math.min(1, (now - startAt) / speed))
      style.transform = `scale(${easeOutBack(t)})`
      // Fade in over the first half so the back-out overshoot is visible
      // (rather than blasted in at full opacity from frame 1).
      style.opacity = String(Math.min(1, t * 2))
      if (t < 1) {
        BrowserAPIs.requestAnimationFrame(step)
      } else {
        style.transform = ''
        style.transformOrigin = ''
        style.transformBox = ''
        style.opacity = ''
        if (onComplete) onComplete()
      }
    }
    BrowserAPIs.requestAnimationFrame(step)
  }

  /*
   ** Animate rect properties
   * @param {any} el
   * @param {any} from
   * @param {any} to
   * @param {number} speed
   * @param {Function} fn
   */
  /** @param {any} el @param {any} from @param {any} to @param {any} speed @param {any} fn @param {number} [delay] */
  animateRect(el, from, to, speed, fn, delay = 0) {
    el.attr(from)
      .animate(speed, delay)
      .attr(to)
      .after(() => fn())
  }

  /**
   * @param {Record<string, any>} params
   */
  animatePathsGradually(params) {
    const { el, realIndex, j, fill, pathFrom, pathTo, speed, delay } = params

    const me = this
    const w = this.w

    let delayFactor = 0

    if (w.config.chart.animations.animateGradually.enabled) {
      delayFactor = w.config.chart.animations.animateGradually.delay
    }

    if (
      w.config.chart.animations.dynamicAnimation.enabled &&
      w.globals.dataChanged &&
      w.config.chart.type !== 'bar'
    ) {
      // disabled due to this bug - https://github.com/apexcharts/vue-apexcharts/issues/75
      delayFactor = 0
    }
    me.morphSVG(
      el,
      realIndex,
      j,
      w.config.chart.type === 'line' && !w.globals.comboCharts
        ? 'stroke'
        : fill,
      pathFrom,
      pathTo,
      speed,
      delay * delayFactor,
    )
  }

  /**
   * Opacity-fade reveal (see Graphics.renderPaths `revealViaFade`): hide this
   * path immediately, then reveal the whole series in a single CSS opacity fade
   * once the synchronous draw pass has finished. Used for two cases that both
   * want to avoid per-path morphs: the large-dataset bulk render (>
   * largeDatasetThreshold) and candlestick/boxPlot data-change updates (where an
   * index-based morph would slide candles around on zoom). Every faded path is
   * pushed to delayedElements, but only the first one schedules the reveal — a
   * lone requestAnimationFrame fires after all paths are in the DOM, so N paths
   * cost one frame callback instead of N morph timelines. The guard flag is
   * reset inside the callback (and in initGlobalVars) so subsequent renders
   * re-arm it.
   * @param {any} el
   */
  revealBulk(el) {
    const w = this.w
    el.node.classList.add('apexcharts-element-hidden')
    w.globals.delayedElements.push({ el: el.node })

    if (!Environment.isBrowser() || !w.globals.shouldAnimate) {
      // No animation frame to wait on (SSR / animation off) — reveal at once so
      // the serialized output isn't frozen at opacity 0.
      this.animationCompleted(el)
      return
    }

    if (!w.globals.bulkRevealScheduled) {
      w.globals.bulkRevealScheduled = true
      BrowserAPIs.requestAnimationFrame(() => {
        // chart may have been destroyed before this frame ran (see animateDraw)
        if (w.globals.isDestroyed) return
        w.globals.bulkRevealScheduled = false
        this.animationCompleted(el)
      })
    }
  }

  showDelayedElements() {
    /**
     * @param {object} d
     */
    this.w.globals.delayedElements.forEach((d) => {
      const ele = d.el
      ele.classList.remove('apexcharts-element-hidden')
      ele.classList.add('apexcharts-hidden-element-shown')
    })
  }

  /**
   * @param {any} el
   */
  animationCompleted(el) {
    const w = this.w
    if (w.globals.animationEnded) return

    w.globals.animationEnded = true
    this.showDelayedElements()

    if (typeof w.config.chart.events.animationEnd === 'function') {
      w.config.chart.events.animationEnd(this.ctx, { el, w })
    }
  }

  /**
   * Initial-mount "pen-stroke" draw effect for line / area / rangeArea paths.
   *
   * Stroke paths animate `stroke-dashoffset` from total length → 0.
   * Fill paths animate the width of a per-series SVG `<mask>` rect from 0 → gridWidth,
   * which coexists with the existing `clip-path: gridRectMask` (mask + clip-path
   * are independent SVG attributes).
   *
   * For radar / radial shapes, pass `mask: { type: 'radial', cx, cy, r }` to
   * use a circular mask that blooms from center outward instead of the default
   * left-to-right rect wipe.
   *
   * @param {any} el            - SVG.js path element
   * @param {{realIndex: number, j?: number, isFill: boolean, isLast: boolean, speed: number, delay: number, mask?: {type: 'rect'|'radial', cx?: number, cy?: number, r?: number}}} params
   */
  animateDraw(el, { realIndex, j, isFill, isLast, speed, delay, mask: maskShape }) {
    const w = this.w
    const me = this

    const finalize = () => {
      if (isLast && w.globals.shouldAnimate) {
        me.animationCompleted(el)
      }
      me.showDelayedElements()
    }

    if (!Environment.isBrowser() || !w.globals.shouldAnimate || speed < 1) {
      finalize()
      return
    }

    const node = el.node

    // Mask-based reveal — works for filled paths (area) and for stroked paths that
    // already have a custom dash pattern (forecast, user-set stroke.dashArray).
    const runMaskReveal = () => {
      const pad = 4
      const isRadial = maskShape && maskShape.type === 'radial'
      const targetWidth = w.layout.gridWidth + pad * 2
      const radialCx = (maskShape && maskShape.cx) || 0
      const radialCy = (maskShape && maskShape.cy) || 0
      const targetRadius =
        ((maskShape && maskShape.r) || w.layout.gridWidth / 2) + pad

      const maskId = `apexDrawMask${w.globals.cuid}-${realIndex}-${j ?? 0}-${isFill ? 'f' : 's'}`
      const mask = BrowserAPIs.createElementNS(SVGNS, 'mask')
      mask.setAttribute('id', maskId)
      mask.setAttribute('maskUnits', 'userSpaceOnUse')

      /** @type {Element} */
      let revealEl
      if (isRadial) {
        // Region must cover the full radar bbox (centered at cx, cy with radius r + pad).
        const region = targetRadius
        mask.setAttribute('x', String(radialCx - region))
        mask.setAttribute('y', String(radialCy - region))
        mask.setAttribute('width', String(region * 2))
        mask.setAttribute('height', String(region * 2))

        revealEl = BrowserAPIs.createElementNS(SVGNS, 'circle')
        revealEl.setAttribute('cx', String(radialCx))
        revealEl.setAttribute('cy', String(radialCy))
        revealEl.setAttribute('r', '0')
        revealEl.setAttribute('fill', '#fff')
      } else {
        mask.setAttribute('x', String(-pad))
        mask.setAttribute('y', String(-pad))
        mask.setAttribute('width', String(targetWidth))
        mask.setAttribute('height', String(w.layout.gridHeight + pad * 2))

        revealEl = BrowserAPIs.createElementNS(SVGNS, 'rect')
        revealEl.setAttribute('x', String(-pad))
        revealEl.setAttribute('y', String(-pad))
        revealEl.setAttribute('width', '0')
        revealEl.setAttribute('height', String(w.layout.gridHeight + pad * 2))
        revealEl.setAttribute('fill', '#fff')
      }
      mask.appendChild(revealEl)
      w.dom.elDefs.node.appendChild(mask)

      node.setAttribute('mask', `url(#${maskId})`)

      const startAt = performance.now() + (delay || 0)
      /** @param {number} now */
      const step = (now) => {
        if (w.globals.isDestroyed) return
        const t = Math.max(0, Math.min(1, (now - startAt) / speed))
        const eased = easeOutCubic(t)
        if (isRadial) {
          revealEl.setAttribute('r', String(eased * targetRadius))
        } else {
          revealEl.setAttribute('width', String(eased * targetWidth))
        }
        if (t < 1) {
          BrowserAPIs.requestAnimationFrame(step)
        } else {
          node.removeAttribute('mask')
          if (mask.parentNode) mask.parentNode.removeChild(mask)
          finalize()
        }
      }
      BrowserAPIs.requestAnimationFrame(step)
    }

    // Stroke draw via stroke-dashoffset (only safe when no custom dash pattern exists).
    /** @param {number} len */
    const runStrokeDraw = (len) => {
      node.setAttribute('stroke-dasharray', String(len))
      node.setAttribute('stroke-dashoffset', String(len))

      const startAt = performance.now() + (delay || 0)
      /** @param {number} now */
      const step = (now) => {
        if (w.globals.isDestroyed) return
        const t = Math.max(0, Math.min(1, (now - startAt) / speed))
        node.setAttribute('stroke-dashoffset', String(len * (1 - easeOutCubic(t))))
        if (t < 1) {
          BrowserAPIs.requestAnimationFrame(step)
        } else {
          node.removeAttribute('stroke-dasharray')
          node.removeAttribute('stroke-dashoffset')
          finalize()
        }
      }
      BrowserAPIs.requestAnimationFrame(step)
    }

    // Defer one frame: callers (e.g. Line.js for forecast paths) set stroke-dasharray
    // *after* renderPaths returns, so a sync check would miss it.
    BrowserAPIs.requestAnimationFrame(() => {
      // The chart may have been destroyed before this frame ran (e.g. React
      // StrictMode mounts, unmounts, then remounts). Its DOM is gone, so bail
      // before touching it. Fixes "Cannot read properties of null (reading
      // 'node')" in runMaskReveal. See react-apexcharts#602.
      if (w.globals.isDestroyed) return

      if (isFill) {
        runMaskReveal()
        return
      }

      const existingDash = node.getAttribute('stroke-dasharray')
      const hasCustomDash =
        !!existingDash && existingDash !== '0' && existingDash !== ''
      if (hasCustomDash) {
        // Preserve dash pattern; reveal via mask instead of fighting dashoffset.
        runMaskReveal()
        return
      }

      let len = 0
      try {
        if (typeof node.getTotalLength === 'function') {
          len = node.getTotalLength()
        }
      } catch (_) {
        len = 0
      }
      if (!len) {
        finalize()
        return
      }
      runStrokeDraw(len)
    })
  }

  // SVG.js animation for morphing one path to another
  /**
   * @param {any} el
   * @param {number} realIndex
   * @param {number} j
   * @param {string} fill
   * @param {string} pathFrom
   * @param {string} pathTo
   * @param {number} speed
   * @param {number} delay
   */
  morphSVG(el, realIndex, j, fill, pathFrom, pathTo, speed, delay) {
    const w = this.w

    if (!pathFrom) {
      pathFrom = el.attr('pathFrom')
    }

    if (!pathTo) {
      pathTo = el.attr('pathTo')
    }

    const disableAnimationForCorrupPath = () => {
      if (w.config.chart.type === 'radar') {
        // radar chart drops the path to bottom and hence a corrup path looks ugly
        // therefore, disable animation for such a case
        speed = 1
      }
      return `M 0 ${w.layout.gridHeight}`
    }

    if (
      !pathFrom ||
      pathFrom.indexOf('undefined') > -1 ||
      pathFrom.indexOf('NaN') > -1
    ) {
      pathFrom = disableAnimationForCorrupPath()
    }

    if (
      !pathTo.trim() ||
      pathTo.indexOf('undefined') > -1 ||
      pathTo.indexOf('NaN') > -1
    ) {
      pathTo = disableAnimationForCorrupPath()
    }
    if (!w.globals.shouldAnimate) {
      speed = 1
    }

    // Cross-type morph chains use the polygon-resample engine (smooth tween
    // between very different shapes via N-point perimeter sampling). Within-
    // type updates fall back to the default per-command lerp.
    const crossTypeMorph = this.ctx?.morphTypeChange?.isActive() === true
    const morphAlgo = crossTypeMorph ? 'polygons' : 'commands'

    el.plot(pathFrom)
      .animate(1, delay)
      .plot(pathFrom)
      .animate(speed, delay)
      .plot(pathTo, morphAlgo)
      .after(() => {
        // a flag to indicate that the original mount function can return true now as animation finished here
        if (Utils.isNumber(j)) {
          if (
            j ===
              w.seriesData.series[w.globals.maxValsInArrayIndex].length - 2 &&
            w.globals.shouldAnimate
          ) {
            this.animationCompleted(el)
          }
        } else if (fill !== 'none' && w.globals.shouldAnimate) {
          if (
            (!w.globals.comboCharts &&
              realIndex === w.seriesData.series.length - 1) ||
            w.globals.comboCharts
          ) {
            this.animationCompleted(el)
          }
        }

        this.showDelayedElements()
      })
  }
}
