/**
 * Anime.js - text - ESM
 * @version v4.5.0
 * @license MIT
 * @copyright 2026 - Julian Garnier
 */

import { createSeededRandom } from '../utils/random.js';
import { globals } from '../core/globals.js';
import { round } from '../core/helpers.js';
import { parseEase } from '../easings/eases/parser.js';
import { noop } from '../core/consts.js';

/**
 * @import {
 *   ScrambleTextParams,
 *   FunctionValue,
 * } from '../types/index.js'
*/

/**
 * @typedef {Object} ScrambleTextTween
 * @property {Number} from
 * @property {Number} to
 * @property {Number} duration
 * @property {Number} delay
 * @property {String} ease
 * @property {(v: Number) => String} modifier
 */

/**
 * '-' is the range operator; place it at the start or end of the string to use it as a literal (e.g. '-abc' or 'abc-')
 * @param {String} str
 * @return {String}
 */
const expandCharRanges = (str) => {
  let result = '';
  for (let i = 0, l = str.length; i < l; i++) {
    if (i + 2 < l && str[i + 1] === '-' && str.charCodeAt(i) < str.charCodeAt(i + 2)) {
      const start = str.charCodeAt(i);
      const end = str.charCodeAt(i + 2);
      for (let c = start; c <= end; c++) result += String.fromCharCode(c);
      i += 2;
    } else {
      result += str[i];
    }
  }
  return result;
};

const charSets = {
  lowercase: 'a-z',
  uppercase: 'A-Z',
  numbers: '0-9',
  symbols: '!%#_|*+=',
  braille: '⠀-⣿',
  blocks: '▀-▟',
  shades: '░-▓',
};

const originalTexts = new WeakMap();

/**
 * Returns a function-based tween value that scrambles the target's text content,
 * progressively revealing the original text.
 *
 * @param {ScrambleTextParams} [params]
 * @return {FunctionValue<ScrambleTextTween>}
 */
const scrambleText = (params = {}) => {
  if (!params) params = {};
  const charsParam = params.chars;
  const easeFn = parseEase(params.ease || 'linear');
  const text = params.text;
  const fromParam = params.from;
  const reversed = params.reversed || false;
  const perturbation = params.perturbation || 0;
  const cursorParam = params.cursor;
  const cursorChars = cursorParam === true ? '_'
                    : typeof cursorParam === 'number' ? String.fromCharCode(cursorParam)
                    : typeof cursorParam === 'string' ? cursorParam
                    : '';
  const cursorLen = cursorChars.length;
  const seed = params.seed || 0;
  const override = params.override !== undefined ? params.override : true;
  const revealRate = params.revealRate || 60;
  const interval = 1000 * globals.timeScale / revealRate;
  const settleDuration = params.settleDuration || 300 * globals.timeScale;
  const settleRate = params.settleRate || 30;
  const durationParam = params.duration;
  const revealDelayParam = params.revealDelay;
  const delayParam = params.delay;
  const onChange = params.onChange || noop;

  return (target, index, targets, prevTween) => {
    const rawChars = typeof charsParam === 'function' ? charsParam(target, index, targets) : (charsParam || 'a-zA-Z0-9!%#_');
    const characters = expandCharRanges(charSets[rawChars] || rawChars);
    const totalChars = characters.length - 1;
    const duration = typeof durationParam === 'function' ? durationParam(target, index, targets) : durationParam;
    const revealDelay = typeof revealDelayParam === 'function' ? revealDelayParam(target, index, targets) : (revealDelayParam || 0);
    const delay = typeof delayParam === 'function' ? delayParam(target, index, targets) : (delayParam || 0);
    const rng = seed ? createSeededRandom(seed) : createSeededRandom();
    if (!originalTexts.has(target)) originalTexts.set(target, target.textContent);
    const startingText = prevTween ? prevTween._value : target.textContent;
    const targetText = text !== undefined
                       ? (typeof text === 'function' ? text(target, index, targets) : text)
                       : prevTween ? prevTween._value
                       : originalTexts.get(target);
    const settledText = targetText === ' ' || targetText === '&nbsp;' ? ' ' : targetText;
    const startLength = startingText === ' ' ? 0 : startingText.length;
    const endLength = settledText.length;
    const overrideChars = override === true ? characters
                        : typeof override === 'string' && override.length > 0 ? expandCharRanges(charSets[/** @type {String} */(override)] || /** @type {String} */(override))
                        : null;
    const totalOverrideChars = overrideChars ? overrideChars.length - 1 : 0;
    // Space override uses &nbsp; so the browser doesn't collapse consecutive spaces in innerHTML
    const overrideChar = override === ' ' ? ' ' : null;
    // When starting from blank, only animate the target text length to avoid padding beyond it
    const animLength = override === '' ? endLength : Math.max(startLength, endLength);
    // Compute total duration from interval spacing and settle time, or use the explicit duration
    const animDuration = duration > 0 ? duration : (animLength - 1) * interval + settleDuration;
    const computedDuration = round((animDuration + revealDelay) / globals.timeScale, 0) * globals.timeScale;
    const revealDelayRatio = revealDelay > 0 ? round(revealDelay / computedDuration, 12) : 0;
    // Auto-resolve reveal direction: shrinking text reveals from right, growing from left
    const resolvedFrom = fromParam === undefined || fromParam === 'auto' ? (endLength < startLength ? 'right' : 'left') : fromParam;
    const charOrder = new Int32Array(animLength);
    if (resolvedFrom === 'random') {
      for (let i = 0; i < animLength; i++) charOrder[i] = i;
      for (let i = animLength - 1; i > 0; i--) {
        const j = rng(0, i);
        const t = charOrder[i]; charOrder[i] = charOrder[j]; charOrder[j] = t;
      }
    } else {
      const ref = resolvedFrom === 'right' ? (override === '' || !startLength ? animLength : startLength) - 1
                : resolvedFrom === 'center' ? ((override === '' || !startLength ? animLength : startLength) - 1) / 2
                : typeof resolvedFrom === 'number' ? resolvedFrom
                : 0;
      const abs = Math.abs;
      const indices = new Array(animLength);
      for (let i = 0; i < animLength; i++) indices[i] = i;
      indices.sort((a, b) => abs(a - ref) - abs(b - ref));
      for (let i = 0; i < animLength; i++) charOrder[indices[i]] = i;
    }
    if (reversed) {
      const last = animLength - 1;
      for (let i = 0; i < animLength; i++) charOrder[i] = last - charOrder[i];
    }
    // settleRatio is the fraction of the animation each character spends in the active scrambling zone
    const settleRatio = round(settleDuration / animDuration, 12);
    // settleSpacing is the time gap between consecutive characters entering the active zone
    const settleSpacing = round((1 - settleRatio) / animLength, 12);
    const cursorZone = cursorLen * settleSpacing;
    // stepRatio controls how often scramble characters refresh (based on settleRate)
    const stepRatio = round(1000 * globals.timeScale / (settleRate * computedDuration), 12);
    // Pre-compute per-character start and settle times
    const charStarts = new Float32Array(animLength);
    const charEnds = new Float32Array(animLength);
    const scale = perturbation > 0 ? perturbation * settleRatio : 0;
    for (let c = 0; c < animLength; c++) {
      const so = scale > 0 ? (rng(0, 2000) - 1000) / 1000 * scale : 0;
      const eo = scale > 0 ? (rng(0, 2000) - 1000) / 1000 * scale : 0;
      charStarts[c] = charOrder[c] * settleSpacing + so;
      charEnds[c] = Math.ceil((charStarts[c] + settleRatio + eo) / stepRatio) * stepRatio;
    }
    // When text shrinks with non-sequential from modes, delay target settle times past all extras
    if (endLength < animLength && resolvedFrom !== 'left' && resolvedFrom !== 'right' && resolvedFrom !== 'random') {
      let maxExtraEnd = 0;
      for (let c = endLength; c < animLength; c++) {
        if (charEnds[c] > maxExtraEnd) maxExtraEnd = charEnds[c];
      }
      const targets = new Array(endLength);
      for (let c = 0; c < endLength; c++) targets[c] = c;
      targets.sort((a, b) => charOrder[a] - charOrder[b]);
      const targetSpacing = (1 - maxExtraEnd) / endLength;
      for (let i = 0; i < endLength; i++) {
        const revealTime = maxExtraEnd + i * targetSpacing;
        if (revealTime > charEnds[targets[i]]) {
          charEnds[targets[i]] = revealTime;
        }
      }
    }
    // charCache holds the current scramble character for each position, refreshed at settleRate
    const charCache = new Array(animLength);
    for (let c = 0; c < animLength; c++) {
      charCache[c] = characters[rng(0, totalChars)];
    }
    // overrideCache holds scramble characters for the starting text (override: true or custom string)
    const overrideCache = overrideChars ? (overrideChars === characters ? charCache : new Array(animLength)) : null;
    if (overrideCache && overrideCache !== charCache) {
      for (let c = 0; c < animLength; c++) {
        overrideCache[c] = overrideChar || /** @type {String} */(overrideChars)[rng(0, overrideChars.length - 1)];
      }
    }
    // Build the initial display text based on override mode
    let fillStartText = startingText;
    if (!prevTween) {
      if (override === '') {
        fillStartText = '';
      } else if (overrideChars) {
        fillStartText = '';
        for (let c = 0; c < startLength; c++) {
          fillStartText += startingText[c] === ' ' ? ' ' : /** @type {Array<String>} */(overrideCache)[c];
        }
      }
    }

    let lastValue = -1;
    let lastStep = -1;
    let scrambled = '';
    const hasOverride = override !== '';
    const hasOverrideChars = !!overrideChars;
    const hasCursor = cursorLen > 0;

    return {
      from: 0,
      to: 1,
      duration: computedDuration,
      delay: delay,
      ease: 'linear',
      modifier: (v) => {
        if (v === lastValue) return scrambled;
        lastValue = v;
        if (delay > 0 && v <= 0) { scrambled = startingText; return startingText; }
        if (v <= 0) { scrambled = fillStartText; return fillStartText; }
        if (v >= 1) { scrambled = settledText; return settledText; }
        scrambled = '';
        // Only refresh scramble characters when we cross a settleRate step boundary
        const currentStep = (v / stepRatio) | 0;
        const refreshChars = currentStep !== lastStep;
        if (refreshChars) lastStep = currentStep;
        // Subtract delay ratio to get the effective animation progress
        const linear = revealDelayRatio > 0 ? (v - revealDelayRatio) / (1 - revealDelayRatio) : v;
        const t = linear > 0 ? easeFn(linear) : 0;
        for (let c = 0; c < animLength; c++) {
          // Each character has its own start/end window based on its reveal order
          const charStart = charStarts[c];
          const charEnd = charEnds[c];
          // Settled zone: character has finished its transition
          if (t >= charEnd) {
            if (c < endLength) scrambled += settledText[c];
            continue;
          }
          // Pre-transition zone: reveal wave hasn't reached this character yet
          if (t <= 0 || t < charStart) {
            if (hasOverride && c < startLength) {
              if (hasOverrideChars) {
                if (startingText[c] === ' ') {
                  scrambled += ' ';
                } else {
                  if (refreshChars) /** @type {Array<String>} */(overrideCache)[c] = overrideChar || /** @type {String} */(overrideChars)[rng(0, totalOverrideChars)];
                  scrambled += /** @type {Array<String>} */(overrideCache)[c];
                }
              } else {
                // Default (override: false): show the original starting text
                scrambled += startingText[c];
              }
            }
            continue;
          }
          // Active zone: character is between charStart and charEnd
          const isSpace = (c < endLength && settledText[c] === ' ') || (c < startLength && startingText[c] === ' ');
          if (isSpace) {
            scrambled += ' ';
          } else if (hasCursor && t - charStart < cursorZone) {
            // Cursor sub-zone: show cursor character based on position within cursor width
            scrambled += cursorChars[cursorLen - 1 - (((t - charStart) / settleSpacing) | 0)];
          } else {
            // Scramble zone: show cycling random characters
            if (refreshChars) charCache[c] = characters[rng(0, totalChars)];
            scrambled += charCache[c];
          }
        }
        if (refreshChars) onChange(scrambled, t);
        return scrambled;
      }
    }
  }
};

export { scrambleText };
