// @ts-check
import { Point, Matrix } from './math'

/*!
 * Path morphing for SVG path animations
 * Based on svg.pathmorphing.js by Ulrich-Matthias Schäfer (MIT License)
 * Refactored to be standalone (no SVG.js dependency)
 */

// Parse an SVG path 'd' string into an array of command arrays
// e.g. "M 0 0 L 10 20" → [['M', 0, 0], ['L', 10, 20]]
/**
 * @param {string} d
 */
function parsePath(d) {
  if (!d || typeof d !== 'string') return [['M', 0, 0]]

  const commands = []
  // Match command letter followed by numbers (including negative, decimal)
  const re = /([MmLlHhVvCcSsQqTtAaZz])\s*/g
  const numRe = /[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi
  let match
  const letters = []
  const positions = []

  // Find all command letters and their positions
  while ((match = re.exec(d)) !== null) {
    letters.push(match[1])
    positions.push(match.index)
  }

  for (let i = 0; i < letters.length; i++) {
    const start = positions[i] + letters[i].length
    const end = i + 1 < positions.length ? positions[i + 1] : d.length
    const paramStr = d.substring(start, end)
    const nums = []
    let numMatch
    numRe.lastIndex = 0
    while ((numMatch = numRe.exec(paramStr)) !== null) {
      nums.push(parseFloat(numMatch[0]))
    }

    const cmd = letters[i].toUpperCase()

    if (cmd === 'Z') {
      commands.push(['Z'])
    } else if (cmd === 'M' || cmd === 'L' || cmd === 'T') {
      // pairs of (x, y)
      for (let j = 0; j < nums.length; j += 2) {
        commands.push([cmd, nums[j], nums[j + 1]])
      }
    } else if (cmd === 'H') {
      for (let j = 0; j < nums.length; j++) {
        commands.push([cmd, nums[j]])
      }
    } else if (cmd === 'V') {
      for (let j = 0; j < nums.length; j++) {
        commands.push([cmd, nums[j]])
      }
    } else if (cmd === 'C') {
      for (let j = 0; j < nums.length; j += 6) {
        commands.push([
          cmd,
          nums[j],
          nums[j + 1],
          nums[j + 2],
          nums[j + 3],
          nums[j + 4],
          nums[j + 5],
        ])
      }
    } else if (cmd === 'S' || cmd === 'Q') {
      for (let j = 0; j < nums.length; j += 4) {
        commands.push([cmd, nums[j], nums[j + 1], nums[j + 2], nums[j + 3]])
      }
    } else if (cmd === 'A') {
      for (let j = 0; j < nums.length; j += 7) {
        commands.push([
          cmd,
          nums[j],
          nums[j + 1],
          nums[j + 2],
          nums[j + 3],
          nums[j + 4],
          nums[j + 5],
          nums[j + 6],
        ])
      }
    }
  }

  if (commands.length === 0) {
    commands.push(['M', 0, 0])
  }

  return commands
}

// Calculate bounding box of a parsed path array
/**
 * @param {any[]} arr
 */
function pathBbox(arr) {
  let minX = Infinity,
    minY = Infinity,
    maxX = -Infinity,
    maxY = -Infinity

  /**
   * @param {any[]} cmd
   */
  arr.forEach((cmd) => {
    for (let i = 1; i < cmd.length; i += 2) {
      if (i + 1 <= cmd.length) {
        const x = cmd[i]
        const y = cmd[i + 1]
        if (typeof x === 'number' && typeof y === 'number') {
          if (x < minX) minX = x
          if (x > maxX) maxX = x
          if (y < minY) minY = y
          if (y > maxY) maxY = y
        }
      }
    }
  })

  if (minX === Infinity) {
    return { x: 0, y: 0, width: 0, height: 0 }
  }
  return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}

// Serialize command array back to path string
/**
 * @param {any[]} arr
 */
function arrayToPath(arr) {
  /**
   * @param {any[]} cmd
   */
  return arr.map((cmd) => cmd.join(' ')).join(' ')
}

// Convert shorthand types to long form
/**
 * @this {any}
 * @param {any[]} val
 */
function simplify(val) {
  switch (val[0]) {
    case 'z':
    case 'Z':
      val[0] = 'L'
      val[1] = this.start[0]
      val[2] = this.start[1]
      break
    case 'H':
      val[0] = 'L'
      val[2] = this.pos[1]
      break
    case 'V':
      val[0] = 'L'
      val[2] = val[1]
      val[1] = this.pos[0]
      break
    case 'T':
      val[0] = 'Q'
      val[3] = val[1]
      val[4] = val[2]
      val[1] = this.reflection[1]
      val[2] = this.reflection[0]
      break
    case 'S':
      val[0] = 'C'
      val[6] = val[4]
      val[5] = val[3]
      val[4] = val[2]
      val[3] = val[1]
      val[2] = this.reflection[1]
      val[1] = this.reflection[0]
      break
  }
  return val
}

// Update reflection point and current position
/**
 * @this {any}
 * @param {any[]} val
 */
function setPosAndReflection(val) {
  var len = val.length
  this.pos = [val[len - 2], val[len - 1]]
  if ('SCQT'.indexOf(val[0]) != -1) {
    this.reflection = [
      2 * this.pos[0] - val[len - 4],
      2 * this.pos[1] - val[len - 3],
    ]
  }
  return val
}

// Convert all types to cubic bezier
/**
 * @this {any}
 * @param {any[]} val
 */
function toBezier(val) {
  var retVal = [val]

  switch (val[0]) {
    case 'M':
      this.pos = this.start = [val[1], val[2]]
      return retVal
    case 'L':
      val[5] = val[3] = val[1]
      val[6] = val[4] = val[2]
      // @ts-ignore — this.pos is always set before L/Q cases are reached
      val[1] = this.pos[0]
      // @ts-ignore
      val[2] = this.pos[1]
      break
    case 'Q':
      val[6] = val[4]
      val[5] = val[3]
      val[4] = (val[4] * 1) / 3 + (val[2] * 2) / 3
      val[3] = (val[3] * 1) / 3 + (val[1] * 2) / 3
      // @ts-ignore — this.pos is always set before L/Q cases are reached
      val[2] = (this.pos[1] * 1) / 3 + (val[2] * 2) / 3
      // @ts-ignore
      val[1] = (this.pos[0] * 1) / 3 + (val[1] * 2) / 3
      break
    case 'A':
      retVal = arcToBezier(this.pos ?? [], val)
      val = retVal[0]
      break
  }

  val[0] = 'C'
  this.pos = [val[5], val[6]]
  this.reflection = [2 * val[5] - val[3], 2 * val[6] - val[4]]

  return retVal
}

// Find next M command position
/** @param {any[]} arr @param {number | false} offset @returns {number | false} */
function findNextM(arr, offset) {
  if (offset === false) return false
  for (var i = offset, len = arr.length; i < len; ++i) {
    if (arr[i][0] == 'M') return i
  }
  return false
}

// Convert arc segment to cubic bezier curves
/**
 * @param {number[]} pos
 * @param {any[]} val
 */
function arcToBezier(pos, val) {
  var rx = Math.abs(val[1]),
    ry = Math.abs(val[2]),
    xAxisRotation = val[3] % 360,
    largeArcFlag = val[4],
    sweepFlag = val[5],
    x = val[6],
    y = val[7],
    A = new Point(pos[0], pos[1]),
    B = new Point(x, y),
    primedCoord,
    lambda,
    mat,
    k,
    c,
    cSquare,
    t,
    O,
    OA,
    OB,
    tetaStart,
    tetaEnd,
    deltaTeta,
    nbSectors,
    f,
    arcSegPoints,
    angle,
    sinAngle,
    cosAngle,
    pt,
    i,
    il,
    retVal = [],
    x1,
    y1,
    x2,
    y2

  if (rx === 0 || ry === 0 || (A.x === B.x && A.y === B.y)) {
    return [['C', A.x, A.y, B.x, B.y, B.x, B.y]]
  }

  primedCoord = new Point((A.x - B.x) / 2, (A.y - B.y) / 2).transform(
    /** @type {any} */ (new Matrix(0, 0, 0, 0, 0, 0)).rotate(xAxisRotation),
  )
  lambda =
    (primedCoord.x * primedCoord.x) / (rx * rx) +
    (primedCoord.y * primedCoord.y) / (ry * ry)
  if (lambda > 1) {
    lambda = Math.sqrt(lambda)
    rx = lambda * rx
    ry = lambda * ry
  }

  mat = /** @type {any} */ (new Matrix(0, 0, 0, 0, 0, 0))
    .rotate(xAxisRotation)
    .scale(1 / rx, 1 / ry)
    .rotate(-xAxisRotation)
  A = A.transform(mat)
  B = B.transform(mat)

  k = [B.x - A.x, B.y - A.y]
  cSquare = k[0] * k[0] + k[1] * k[1]
  c = Math.sqrt(cSquare)
  k[0] /= c
  k[1] /= c

  t = cSquare < 4 ? Math.sqrt(1 - cSquare / 4) : 0

  if (largeArcFlag === sweepFlag) {
    t *= -1
  }

  O = new Point((B.x + A.x) / 2 + t * -k[1], (B.y + A.y) / 2 + t * k[0])
  OA = new Point(A.x - O.x, A.y - O.y)
  OB = new Point(B.x - O.x, B.y - O.y)

  tetaStart = Math.acos(OA.x / Math.sqrt(OA.x * OA.x + OA.y * OA.y))
  if (OA.y < 0) tetaStart *= -1

  tetaEnd = Math.acos(OB.x / Math.sqrt(OB.x * OB.x + OB.y * OB.y))
  if (OB.y < 0) tetaEnd *= -1

  if (sweepFlag && tetaStart > tetaEnd) {
    tetaEnd += 2 * Math.PI
  }
  if (!sweepFlag && tetaStart < tetaEnd) {
    tetaEnd -= 2 * Math.PI
  }

  nbSectors = Math.ceil((Math.abs(tetaStart - tetaEnd) * 2) / Math.PI)
  arcSegPoints = []
  angle = tetaStart
  deltaTeta = (tetaEnd - tetaStart) / nbSectors
  f = (4 * Math.tan(deltaTeta / 4)) / 3

  for (i = 0; i <= nbSectors; i++) {
    cosAngle = Math.cos(angle)
    sinAngle = Math.sin(angle)
    pt = new Point(O.x + cosAngle, O.y + sinAngle)
    arcSegPoints[i] = [
      new Point(pt.x + f * sinAngle, pt.y - f * cosAngle),
      pt,
      new Point(pt.x - f * sinAngle, pt.y + f * cosAngle),
    ]
    angle += deltaTeta
  }

  arcSegPoints[0][0] = arcSegPoints[0][1].clone()
  arcSegPoints[arcSegPoints.length - 1][2] =
    arcSegPoints[arcSegPoints.length - 1][1].clone()

  mat = /** @type {any} */ (new Matrix(0, 0, 0, 0, 0, 0))
    .rotate(xAxisRotation)
    .scale(rx, ry)
    .rotate(-xAxisRotation)
  for (i = 0, il = arcSegPoints.length; i < il; i++) {
    arcSegPoints[i][0] = arcSegPoints[i][0].transform(mat)
    arcSegPoints[i][1] = arcSegPoints[i][1].transform(mat)
    arcSegPoints[i][2] = arcSegPoints[i][2].transform(mat)
  }

  for (i = 1, il = arcSegPoints.length; i < il; i++) {
    pt = arcSegPoints[i - 1][2]
    x1 = pt.x
    y1 = pt.y
    pt = arcSegPoints[i][0]
    x2 = pt.x
    y2 = pt.y
    pt = arcSegPoints[i][1]
    x = pt.x
    y = pt.y
    retVal.push(['C', x1, y1, x2, y2, x, y])
  }

  return retVal
}

// Synchronize one block (from M to next M) so types and lengths match
/**
 * @param {any[]} startArr
 * @param {number} startOffsetM
 * @param {any} startOffsetNextM
 * @param {any[]} destArr
 * @param {number} destOffsetM
 * @param {any} destOffsetNextM
 */
function handleBlock(
  startArr,
  startOffsetM,
  startOffsetNextM,
  destArr,
  destOffsetM,
  destOffsetNextM,
) {
  var startArrTemp = startArr.slice(startOffsetM, startOffsetNextM || undefined)
  var destArrTemp = destArr.slice(destOffsetM, destOffsetNextM || undefined)

  var i = 0,
    posStart = { pos: [0, 0], start: [0, 0] },
    posDest = { pos: [0, 0], start: [0, 0] }

  while (true) {
    startArrTemp[i] = simplify.call(posStart, startArrTemp[i])
    destArrTemp[i] = simplify.call(posDest, destArrTemp[i])

    if (
      startArrTemp[i][0] != destArrTemp[i][0] ||
      startArrTemp[i][0] == 'M' ||
      (startArrTemp[i][0] == 'A' &&
        (startArrTemp[i][4] != destArrTemp[i][4] ||
          startArrTemp[i][5] != destArrTemp[i][5]))
    ) {
      Array.prototype.splice.apply(
        startArrTemp,
        /** @type {[number, number, ...any[]]} */ (
          [i, 1].concat(
            /** @type {any} */ (toBezier).call(posStart, startArrTemp[i]),
          )
        ),
      )
      Array.prototype.splice.apply(
        destArrTemp,
        /** @type {[number, number, ...any[]]} */ (
          [i, 1].concat(
            /** @type {any} */ (toBezier).call(posDest, destArrTemp[i]),
          )
        ),
      )
    } else {
      startArrTemp[i] = /** @type {any} */ (setPosAndReflection).call(
        posStart,
        startArrTemp[i],
      )
      destArrTemp[i] = /** @type {any} */ (setPosAndReflection).call(
        posDest,
        destArrTemp[i],
      )
    }

    if (++i == startArrTemp.length && i == destArrTemp.length) break

    if (i == startArrTemp.length) {
      startArrTemp.push([
        'C',
        posStart.pos[0],
        posStart.pos[1],
        posStart.pos[0],
        posStart.pos[1],
        posStart.pos[0],
        posStart.pos[1],
      ])
    }

    if (i == destArrTemp.length) {
      destArrTemp.push([
        'C',
        posDest.pos[0],
        posDest.pos[1],
        posDest.pos[0],
        posDest.pos[1],
        posDest.pos[0],
        posDest.pos[1],
      ])
    }
  }

  return { start: startArrTemp, dest: destArrTemp }
}

// Synchronize two path arrays so they can be interpolated
/**
 * @param {string} fromD
 * @param {string} toD
 */
function synchronizePaths(fromD, toD) {
  var startArr = parsePath(fromD)
  var destArr = parsePath(toD)

  /** @type {number | false} */ var startOffsetM = 0
  /** @type {number | false} */ var destOffsetM = 0
  /** @type {number | false} */ var startOffsetNextM = false
  /** @type {number | false} */ var destOffsetNextM = false
  var result

  while (true) {
    if (startOffsetM === false && destOffsetM === false) break

    startOffsetNextM = findNextM(
      startArr,
      startOffsetM === false ? false : startOffsetM + 1,
    )
    destOffsetNextM = findNextM(
      destArr,
      destOffsetM === false ? false : destOffsetM + 1,
    )

    if (startOffsetM === false) {
      const bbox = pathBbox(/** @type {any} */ (result).start)
      if (bbox.height == 0 || bbox.width == 0) {
        startOffsetM = startArr.push(startArr[0]) - 1
      } else {
        startOffsetM =
          startArr.push([
            'M',
            bbox.x + bbox.width / 2,
            bbox.y + bbox.height / 2,
          ]) - 1
      }
    }

    if (destOffsetM === false) {
      const bbox = pathBbox(/** @type {any} */ (result).dest)
      if (bbox.height == 0 || bbox.width == 0) {
        destOffsetM = destArr.push(destArr[0]) - 1
      } else {
        destOffsetM =
          destArr.push([
            'M',
            bbox.x + bbox.width / 2,
            bbox.y + bbox.height / 2,
          ]) - 1
      }
    }

    result = handleBlock(
      startArr,
      startOffsetM,
      startOffsetNextM,
      destArr,
      destOffsetM,
      destOffsetNextM,
    )

    startArr = startArr
      .slice(0, startOffsetM)
      .concat(
        result.start,
        startOffsetNextM === false ? [] : startArr.slice(startOffsetNextM),
      )
    destArr = destArr
      .slice(0, destOffsetM)
      .concat(
        result.dest,
        destOffsetNextM === false ? [] : destArr.slice(destOffsetNextM),
      )

    startOffsetM =
      startOffsetNextM === false ? false : startOffsetM + result.start.length
    destOffsetM =
      destOffsetNextM === false ? false : destOffsetM + result.dest.length
  }

  return { start: startArr, dest: destArr }
}

/**
 * Create a path interpolation function.
 * @param {string} fromD - Source SVG path 'd' string
 * @param {string} toD - Target SVG path 'd' string
 * @returns {function} Interpolator: (pos: 0..1) => pathString
 */
function morphPaths(fromD, toD) {
  var synced = synchronizePaths(fromD, toD)
  var startArr = synced.start
  var destArr = synced.dest

  /**
   * @param {number} pos
   */
  return function (pos) {
    var result = startArr.map(function (from, idx) {
      return destArr[idx].map(function (to, toIdx) {
        if (toIdx === 0) return to // command letter
        // @ts-ignore — toIdx > 0 entries are always numbers; index 0 (command letter) is returned above
        return from[toIdx] + (destArr[idx][toIdx] - from[toIdx]) * pos
      })
    })
    return arrayToPath(result)
  }
}

export { parsePath, morphPaths, pathBbox, arrayToPath }
