/**
 * Chroniques de l'Étrange — Système FoundryVTT
 *
 * Chroniques de l'Étrange est un jeu de rôle édité par Antre-Monde Éditions.
 * Ce système FoundryVTT est une implémentation indépendante et n'est pas
 * affilié à Antre-Monde Éditions,
 * mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
 *
 * @author    LeRatierBretonnien
 * @copyright 2024–2026 LeRatierBretonnien
 * @license   CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
 */

/**
 * Wu Xing rolling system for Chroniques de l'Étrange.
 *
 * The Wu Xing cycle maps each aspect (by index 0-4) to die face groups:
 *  - metal=0 : faces 3,8
 *  - water=1  : faces 1,6
 *  - earth=2  : faces 0/10,5
 *  - fire=3   : faces 2,7
 *  - wood=4   : faces 4,9
 *
 * For a given active aspect the five result categories are:
 *  successes / auspicious / noxious / loksyu (yin face, yang face) / tinji
 * Each category is associated with one of the five aspects in Wu Xing cycle order.
 */

import { MAGICS, ASPECT_LABELS, ASPECT_ICONS, ASPECT_FACES, ASPECT_NAMES, WU_XING_CYCLE } from "../config/constants.js"
import { updateLoksyuFromRoll, updateTinjiFromRoll } from "./apps/singletons.js"

const RESULT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-dice-result.html"
const SKILL_PROMPT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-skill-dice-prompt.html"
const SKILL_SPECIAL_PROMPT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-skill-special-dice-prompt.html"
const MAGIC_PROMPT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-magic-dice-prompt.html"
const WEAPON_PROMPT_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/form/cde-weapon-dice-prompt.html"

/** Maps i18n element label → aspect name (for speciality default aspect lookup) */
const LABELELEMENT_TO_ASPECT = {
  "CDE.Metal": "metal",
  "CDE.Water": "water",
  "CDE.Earth": "earth",
  "CDE.Fire":  "fire",
  "CDE.Wood":  "wood",
}

/** Range order from closest to furthest. Weapons can be used at any
 *  range ≤ the weapon's listed range, with no dice malus. */
const RANGE_ORDER = ["contact", "courte", "mediane", "longue", "extreme"]

const RANGE_LABELS = {
  contact: "CDE.RangeContact",
  courte:  "CDE.RangeCourte",
  mediane: "CDE.RangeMediane",
  longue:  "CDE.RangeLongue",
  extreme: "CDE.RangeExtreme",
}

/** Maps weapon type string → default skill key */
const WEAPON_TYPE_SKILL = {
  melee:   "kungfu",
  thrown:  "rangedcombat",
  ranged:  "rangedcombat",
  firearm: "rangedcombat",
}

/** Maps weapon damageAspect name → ASPECT_NAMES index */
const WEAPON_ASPECT_INDEX = { metal: 0, eau: 1, water: 1, terre: 2, earth: 2, feu: 3, fire: 3, bois: 4, wood: 4 }

/** Count how many times each die face appeared in the roll results */
function countFaces(rollResults) {
  const counts = { 1:0, 2:0, 3:0, 4:0, 5:0, 6:0, 7:0, 8:0, 9:0, 0:0 }
  for (const die of rollResults) {
    const face = die.result === 10 ? 0 : die.result
    counts[face]++
  }
  return counts
}

/**
 * Compute Wu Xing result categories from face counts and active aspect.
 * Returns { successesdice, auspiciousdice, noxiousdice, loksyudice, tinjidice, loksyurepartition }
 */
function computeWuXingResults(faces, aspectName, bonusAuspicious = 0) {
  const cycle = WU_XING_CYCLE[aspectName]
  if (!cycle) return null

  const [succAspect, ausAspect, noxAspect, lokAspect, tinAspect] = cycle
  const [succYin, succYang] = ASPECT_FACES[succAspect]
  const [ausYin, ausYang]   = ASPECT_FACES[ausAspect]
  const [noxYin, noxYang]   = ASPECT_FACES[noxAspect]
  const [lokYin, lokYang]   = ASPECT_FACES[lokAspect]
  const [tinYin, tinYang]   = ASPECT_FACES[tinAspect]

  const yin = game.i18n.localize("CDE.Yin")
  const yang = game.i18n.localize("CDE.Yang")

  return {
    successesdice:  faces[succYin] + faces[succYang],
    auspiciousdice: faces[ausYin]  + faces[ausYang] + bonusAuspicious,
    noxiousdice:    faces[noxYin]  + faces[noxYang],
    loksyudice:     faces[lokYin]  + faces[lokYang],
    loksyurepartition: `[${yin}(${faces[lokYin]}) ${yang}(${faces[lokYang]})]`,
    tinjidice:      faces[tinYin]  + faces[tinYang],
  }
}

/** Read a named field from a dialog DOM element */
function readField(dlg, name) {
  const el = dlg.querySelector(`[name="${name}"]`)
  if (!el) return null
  return el.type === "checkbox" ? el.checked : el.value
}

/**
 * Open a DialogV2.prompt with the given template + data and return the resolved form values.
 * The callback receives the DialogV2 application instance; fields are read from its .element.
 * @returns {Promise<Record<string,string>|null>}
 */
async function showRollPrompt({ title, template, data, fields }) {
  const content = await foundry.applications.handlebars.renderTemplate(template, data)
  return foundry.applications.api.DialogV2.prompt({
    window: { title },
    content,
    rejectClose: false,
    ok: {
      label: game.i18n.localize("CDE.Validate"),
      callback: (event, button, dialog) => {
        // In AppV2, dialog is the application instance; .element is the root HTMLElement
        const root = dialog.element ?? dialog
        const result = {}
        for (const field of fields) {
          result[field] = readField(root, field)
        }
        return result
      },
    },
  })
}

/**
 * Open the skill roll prompt and return the user-confirmed parameters.
 * @param {object} params - Initial values
 * @returns {Promise<object|null>}
 */
async function showSkillPrompt(params) {
  return showRollPrompt({
    title: params.title,
    template: params.isSpecial ? SKILL_SPECIAL_PROMPT_TEMPLATE : SKILL_PROMPT_TEMPLATE,
    data: {
      numberofdice: params.numberofdice,
      aspect:              Number(params.aspect ?? 0),
      bonusmalus:          params.bonusmalus ?? 0,
      woundmalus:          params.woundmalus ?? 0,
      bonusauspiciousdice: params.bonusauspiciousdice ?? 0,
      typeofthrow:         Number(params.typeofthrow ?? 0),
    },
    fields: ["aspect", "bonusmalus", "woundmalus", "bonusauspiciousdice", "typeofthrow"],
  })
}

/**
 * Open the magic roll prompt and return the user-confirmed parameters.
 */
async function showMagicPrompt(params) {
  return showRollPrompt({
    title: params.title,
    template: MAGIC_PROMPT_TEMPLATE,
    data: {
      numberofdice:        params.numberofdice ?? 0,
      aspectskill:         Number(params.aspectskill ?? 0),
      bonusmalusskill:     params.bonusmalusskill ?? 0,
      bonusauspiciousdice: params.bonusauspiciousdice ?? 0,
      rolldifficulty:      params.rolldifficulty ?? 1,
      freepowerlevels:     params.freepowerlevels ?? 0,
      typeofthrow:         Number(params.typeofthrow ?? 0),
    },
    fields: ["aspectskill", "bonusmalusskill", "bonusauspiciousdice",
             "rolldifficulty", "freepowerlevels", "typeofthrow"],
  })
}

/**
 * Open the weapon attack roll prompt and return user-confirmed parameters.
 */
async function showWeaponPrompt(params) {
  const maxRange = params.effectiverange ?? "contact"
  const maxIdx   = RANGE_ORDER.indexOf(maxRange)
  const validRanges = RANGE_ORDER.slice(0, maxIdx + 1).map(r => ({
    value:    r,
    label:    RANGE_LABELS[r],
    selected: r === maxRange,
  }))

  return showRollPrompt({
    title: params.title,
    template: WEAPON_PROMPT_TEMPLATE,
    data: {
      numberofdice:        params.numberofdice ?? 0,
      weaponName:          params.weaponName ?? "",
      weaponTypeLabel:     params.weaponTypeLabel ?? "CDE.Weapon",
      weaponAspectIcon:    params.weaponAspectIcon ?? "",
      weaponAspectLabel:   params.weaponAspectLabel ?? "",
      damageBase:          params.damageBase ?? 0,
      weaponskill:         params.weaponskill ?? "kungfu",
      aspect:              Number(params.aspect ?? 0),
      effectiverange:      params.effectiverange ?? "contact",
      validRanges,
      bonusmalus:          params.bonusmalus ?? 0,
      woundmalus:          params.woundmalus ?? 0,
      bonusauspiciousdice: params.bonusauspiciousdice ?? 0,
      typeofthrow:         Number(params.typeofthrow ?? 0),
    },
    fields: ["weaponskill", "aspect", "effectiverange", "bonusmalus", "woundmalus",
             "bonusauspiciousdice", "typeofthrow"],
  })
}

/**
 * Build and send a single enriched ChatMessage containing both the roll
 * (for Dice So Nice) and the Wu Xing result card.
 */
async function sendResultMessage(actor, resultData, roll, rollMode) {
  const html = await foundry.applications.handlebars.renderTemplate(RESULT_TEMPLATE, resultData)
  return ChatMessage.create({
    user:    game.user.id,
    speaker: ChatMessage.getSpeaker({ actor }),
    content: html,
    rolls:   [roll],
    rollMode,
    flags: {
      "fvtt-chroniques-de-l-etrange": { rollResult: { ...resultData } },
    },
  })
}

const ROLL_MODES = ["roll", "gmroll", "blindroll", "selfroll"]

/**
 * Main entry point: roll dice for a given actor.
 *
 * @param {Actor} actor
 * @param {string} rollKey  - e.g. "prowess-skill", "fire-aspect", "alchemy-magic-elixirs"
 */
export async function rollForActor(actor, rollKey) {
  const parts = rollKey.split("-")
  const skillLibel = parts[0]
  const typeLibel  = parts[1]
  const specialLibel = parts[2] ?? null

  const sys = actor.system
  const typeOfThrow = Number(sys.prefs?.typeofthrow?.choice ?? 0)

  let numberofdice = 0
  let title = ""
  let isSpecial = false
  let isMagic = false
  let isMagicSpecial = false
  let kfDefaultAspect = -1  // set in "itemkungfu" case, used when computing defaultAspect

  // ---- Determine dice count + title ----
  const MAGIC_I18N_KEYS = {
    internalcinnabar: "CDE.InternalCinnabar",
    alchemy:          "CDE.Alchemy",
    masteryoftheway:  "CDE.MasteryOfTheWay",
    exorcism:         "CDE.Exorcism",
    geomancy:         "CDE.Geomancy",
  }

  switch (typeLibel) {
    case "aspect":
      numberofdice = sys.aspect[skillLibel]?.value ?? 0
      title = game.i18n.localize(sys.aspect[skillLibel]?.label ?? "CDE.Roll")
      break
    case "aptitude":
      // NPC aptitude roll — flat pool with WuXing prompt
      numberofdice = sys.aptitudes?.[skillLibel]?.value ?? 0
      title = game.i18n.localize(`CDE.${skillLibel.charAt(0).toUpperCase() + skillLibel.slice(1)}`)
      break
    case "skill":
      numberofdice = sys.skills?.[skillLibel]?.value ?? 0
      title = game.i18n.localize(sys.skills?.[skillLibel]?.label ?? "CDE.Roll")
      break
    case "special":
      numberofdice = sys.skills?.[skillLibel]?.value ?? 0
      title = game.i18n.localize(sys.skills?.[skillLibel]?.label ?? "CDE.Roll")
      title += ` [${game.i18n.localize("CDE.Speciality")}]`
      isSpecial = true
      if (!sys.skills?.[skillLibel]?.specialities) {
        ui.notifications.warn(game.i18n.localize("CDE.Error2"))
        return
      }
      break
    case "resource":
      numberofdice = sys.resources?.[skillLibel]?.value ?? 0
      title = game.i18n.localize(sys.resources?.[skillLibel]?.label ?? "CDE.Roll")
      break
    case "field":
      numberofdice = sys.resources?.[skillLibel]?.value ?? 0
      title = game.i18n.localize(sys.resources?.[skillLibel]?.label ?? "CDE.Roll")
      title += ` [${game.i18n.localize("CDE.Field")}]`
      isSpecial = true
      if (!sys.resources?.[skillLibel]?.specialities) {
        ui.notifications.warn(game.i18n.localize("CDE.Error4"))
        return
      }
      break
    case "magic":
      numberofdice = sys.magics?.[skillLibel]?.value ?? 0
      isMagic = true
      title = game.i18n.localize(MAGIC_I18N_KEYS[skillLibel] ?? "CDE.Magics")
      break
    case "magicspecial":
      numberofdice = sys.magics?.[skillLibel]?.value ?? 0
      isMagicSpecial = true
      isMagic = true
      if (!sys.magics?.[skillLibel]?.speciality?.[specialLibel]?.check) {
        ui.notifications.warn(game.i18n.localize("CDE.Error6"))
        return
      }
      title = `${game.i18n.localize(MAGIC_I18N_KEYS[skillLibel] ?? "CDE.Magics")} [${game.i18n.localize(MAGICS?.[skillLibel]?.speciality?.[specialLibel]?.label ?? "")}]`
      break
    case "itemkungfu": {
      // skillLibel = item._id — look up the kungfu item to find which skill + aspect to use
      const kfItem = actor.items.get(skillLibel)
      if (!kfItem) { ui.notifications.warn(game.i18n.localize("CDE.Error0")); return }
      const kfSkill = kfItem.system.skill ?? "kungfu"
      numberofdice = sys.skills?.[kfSkill]?.value ?? 0
      title = `${kfItem.name} [${game.i18n.localize(sys.skills?.[kfSkill]?.label ?? "CDE.KungFu")}]`
      const kfAspect = kfItem.system.aspect?.toLowerCase() ?? "metal"
      const ASPECT_NORMALIZE = { eau: "water", terre: "earth", feu: "fire", bois: "wood" }
      kfDefaultAspect = ASPECT_NAMES.indexOf(ASPECT_NORMALIZE[kfAspect] ?? kfAspect)
      if (kfDefaultAspect < 0) kfDefaultAspect = 0
      break
    }
    case "itemweapon": {
      // skillLibel = item._id — look up the weapon item to find type + aspect + damage
      const wpItem = actor.items.get(skillLibel)
      if (!wpItem) { ui.notifications.warn(game.i18n.localize("CDE.Error0")); return }

      const wpType  = wpItem.system.weaponType ?? "melee"
      const wpSkill = WEAPON_TYPE_SKILL[wpType] ?? "kungfu"
      numberofdice  = sys.skills?.[wpSkill]?.value ?? 0

      const wpAspectRaw = wpItem.system.damageAspect ?? "metal"
      const wpAspectIdx = WEAPON_ASPECT_INDEX[wpAspectRaw] ?? 0
      const wpRange     = wpItem.system.range ?? "contact"

      const WEAPON_TYPE_LABELS = {
        melee:   "CDE.WeaponMelee",
        thrown:  "CDE.WeaponThrown",
        ranged:  "CDE.WeaponRanged",
        firearm: "CDE.WeaponFirearm",
      }

      // Show weapon-specific prompt
      const wParams = await showWeaponPrompt({
        title: `${wpItem.name} [${game.i18n.localize(sys.skills?.[wpSkill]?.label ?? "CDE.WeaponRoll")}]`,
        numberofdice,
        weaponName:      wpItem.name,
        weaponTypeLabel: WEAPON_TYPE_LABELS[wpType] ?? "CDE.Weapon",
        weaponAspectIcon:  ASPECT_ICONS[ASPECT_NAMES[wpAspectIdx]] ?? "",
        weaponAspectLabel: game.i18n.localize(ASPECT_LABELS[ASPECT_NAMES[wpAspectIdx]] ?? ""),
        damageBase:   wpItem.system.damageBase ?? 0,
        weaponskill:  wpSkill,
        aspect:       wpAspectIdx,
        effectiverange: wpRange,
        bonusmalus:   0,
        woundmalus:   0,
        bonusauspiciousdice: 0,
        typeofthrow:  typeOfThrow,
      })

      if (!wParams) return // cancelled

      // Resolve final pool from weapon prompt values
      const wpChosenSkill = wParams.weaponskill ?? wpSkill
      const wpSkillDice   = sys.skills?.[wpChosenSkill]?.value ?? 0
      const wpAspFinal    = Number(wParams.aspect ?? wpAspectIdx)
      const wpAspectDice  = sys.aspect[ASPECT_NAMES[wpAspFinal]]?.value ?? 0
      const wpBonusMalus  = Number(wParams.bonusmalus ?? 0)
      const wpWoundMalus  = Number(wParams.woundmalus ?? 0)
      const wpBonusAusp   = Number(wParams.bonusauspiciousdice ?? 0)
      const wpThrowMode   = Number(wParams.typeofthrow ?? 0)
      const wpDamageBase  = wpItem.system.damageBase ?? 0

      const wpSpecialtyBonus = wpItem.system.hasSpeciality ? 1 : 0

      const wpTotalDice = wpSkillDice + wpAspectDice + wpBonusMalus - wpWoundMalus + wpSpecialtyBonus
      if (wpTotalDice <= 0) {
        ui.notifications.warn(game.i18n.localize("CDE.Error0"))
        return
      }

      const wpRoll = new Roll(`${wpTotalDice}d10`)
      await wpRoll.evaluate()

      const wpAspectName = ASPECT_NAMES[wpAspFinal] ?? "metal"
      const wpFaces   = countFaces(wpRoll.dice[0]?.results ?? [])
      const wpResults = computeWuXingResults(wpFaces, wpAspectName, wpBonusAusp)
      if (!wpResults) return

      const wpModParts = []
      if (wpBonusMalus !== 0) wpModParts.push(`${wpBonusMalus > 0 ? "+" : ""}${wpBonusMalus} ${game.i18n.localize("CDE.BonusMalus")}`)
      if (wpWoundMalus !== 0) wpModParts.push(`-${wpWoundMalus} ${game.i18n.localize("CDE.WoundMalus")}`)
      if (wpBonusAusp  !== 0) wpModParts.push(`+${wpBonusAusp} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`)

      // Damage = character's aspect value (from weapon's damageAspect) + weapon base damage
      const wpDamageAspectRaw = wpItem.system.damageAspect ?? "metal"
      const wpDamageAspectIdx = WEAPON_ASPECT_INDEX[wpDamageAspectRaw] ?? 0
      const wpDamageAspectName = ASPECT_NAMES[wpDamageAspectIdx]
      const wpDamageAspectValue = sys.aspect?.[wpDamageAspectName]?.value ?? 0
      const wpDamageAspectLabel = game.i18n.localize(ASPECT_LABELS[wpDamageAspectName] ?? "")

      const wpMsg = await sendResultMessage(actor, {
        rollLabel:   `${wpItem.name}`,
        aspectName:  wpAspectName,
        aspectLabel: game.i18n.localize(ASPECT_LABELS[wpAspectName] ?? ""),
        aspectIcon:  ASPECT_ICONS[wpAspectName] ?? "",
        totalDice:   wpTotalDice,
        modifiersText: wpModParts.length ? wpModParts.join(" · ") : "",
        spellPower: null,
        rollDifficulty: null,
        actorName: actor.name ?? "",
        actorImg:  actor.img ?? "",
        // weapon-specific
        weaponName:      wpItem.name,
        damageBase:      wpDamageBase,
        damageAspectValue: wpDamageAspectValue,
        damageAspectLabel: wpDamageAspectLabel,
        totalDamage:     wpDamageBase + wpDamageAspectValue,
        ...wpResults,
        aspect: wpAspectName,
        d1: wpFaces[1], d2: wpFaces[2], d3: wpFaces[3], d4: wpFaces[4], d5: wpFaces[5],
        d6: wpFaces[6], d7: wpFaces[7], d8: wpFaces[8], d9: wpFaces[9], d0: wpFaces[0],
      }, wpRoll, ROLL_MODES[wpThrowMode] ?? "roll")

      if (game.modules.get("dice-so-nice")?.active && wpMsg?.id) {
        try { await game.dice3d.waitFor3DAnimationByMessageID(wpMsg.id) } catch (_e) { /* DSN not available */ }
      }
      // Auto-update Loksyu/TinJi singletons from weapon roll faces
      if ((wpResults.loksyudice ?? 0) > 0) await updateLoksyuFromRoll(wpAspectName, wpFaces)
      if ((wpResults.tinjidice  ?? 0) > 0) await updateTinjiFromRoll(wpResults.tinjidice)
      return
    }
    default:
      ui.notifications.warn(`Unknown roll type: ${typeLibel}`)
      return
  }

  // For magic rolls / itemkungfu, allow 0 base dice (user can add bonus dice in the prompt).
  if (numberofdice <= 0 && typeLibel !== "aspect" && typeLibel !== "itemkungfu" && !isMagic) {
    ui.notifications.warn(game.i18n.localize("CDE.Error0"))
    return
  }

  // ---- Pre-compute default aspect for magic based on skill ----
  const MAGIC_ASPECTS = {
    internalcinnabar: 0, // metal
    alchemy:          1, // water
    masteryoftheway:  2, // earth
    exorcism:         3, // fire
    geomancy:         4, // wood
  }

  let defaultAspect = typeLibel === "aspect"
    ? ["metal","water","earth","fire","wood"].indexOf(skillLibel)
    : 0
  if (isMagic && MAGIC_ASPECTS[skillLibel] !== undefined) {
    defaultAspect = MAGIC_ASPECTS[skillLibel]
  }
  if (kfDefaultAspect >= 0) {
    defaultAspect = kfDefaultAspect
  }

  // ---- Show roll prompt ----
  let params

  if (isMagic) {
    params = await showMagicPrompt({
      title,
      numberofdice,
      aspectskill:      defaultAspect,
      bonusmalusskill:  0,
      bonusauspiciousdice: 0,
      rolldifficulty:   1,
      freepowerlevels:  0,
      typeofthrow:      typeOfThrow,
    })
  } else {
    params = await showSkillPrompt({
      title,
      numberofdice,
      aspect:              defaultAspect,
      bonusmalus:          0,
      woundmalus:          0,
      bonusauspiciousdice: 0,
      typeofthrow:         typeOfThrow,
      isSpecial,
    })
  }

  if (!params) return // cancelled

  // ---- Compute total dice and roll ----
  let aspectIndex, bonusMalus, bonusAuspicious, throwMode
  let rollDifficulty = 1  // magic only: multiplier applied to successes

  if (isMagic) {
    const skillAspectIndex = Number(params.aspectskill ?? 0)
    aspectIndex     = skillAspectIndex // used for both dice pool and Wu Xing cycle
    bonusMalus      = Number(params.bonusmalusskill ?? 0)
    bonusAuspicious = Number(params.bonusauspiciousdice ?? 0)
    rollDifficulty  = Math.max(1, Number(params.rolldifficulty ?? 1))
    throwMode       = Number(params.typeofthrow ?? 0)
    // magic: magic skill + aspect + bonuses + 1 (speciality base) + HEI spent
    const aspectDice = sys.aspect?.[ASPECT_NAMES[aspectIndex]]?.value ?? 0
    numberofdice = numberofdice + aspectDice + bonusMalus + 1
  } else {
    aspectIndex     = Number(params.aspect ?? 0)
    bonusMalus      = Number(params.bonusmalus ?? 0)
    const woundMalus = Number(params.woundmalus ?? 0)
    bonusAuspicious = Number(params.bonusauspiciousdice ?? 0)
    throwMode       = Number(params.typeofthrow ?? 0)

    const aspectDice = (typeLibel !== "aspect")
      ? (sys.aspect?.[ASPECT_NAMES[aspectIndex]]?.value ?? 0)
      : 0

    numberofdice = numberofdice + aspectDice + bonusMalus - woundMalus
    if (isSpecial) numberofdice += 1 // +1d for speciality
  }

  if (numberofdice <= 0) {
    ui.notifications.warn(game.i18n.localize("CDE.Error0"))
    return
  }

  // ---- Roll ----
  const roll = new Roll(`${numberofdice}d10`)
  await roll.evaluate()

  const rollModeKey = ROLL_MODES[throwMode] ?? "roll"

  // ---- Compute spell power (magic only) ----
  // Power = rollDifficulty × character aspect value for the speciality's
  // associated element (or the school's aspect for base magic rolls).
  let spellPower = null
  let spellPowerAspectName = null
  let spellPowerAspectValue = null
  if (isMagic) {
    if (isMagicSpecial && specialLibel) {
      const specialCfg = MAGICS?.[skillLibel]?.speciality?.[specialLibel]
      const elemName = LABELELEMENT_TO_ASPECT[specialCfg?.labelelementkey]
      if (elemName) spellPowerAspectName = elemName
    }
    if (!spellPowerAspectName) spellPowerAspectName = ASPECT_NAMES[aspectIndex]
    spellPowerAspectValue = sys.aspect?.[spellPowerAspectName]?.value ?? 0
    const freePowerLevels = Number(params.freepowerlevels ?? 0)
    spellPower = rollDifficulty * (spellPowerAspectValue + freePowerLevels)
  }

  // ---- Compute Wu Xing results ----
  // The Wu Xing cycle always uses the roll's aspect (skill aspect for magic,
  // skill/resource aspect otherwise) to determine which faces count as
  // successes/auspicious/etc.
  const wuXingAspectName = ASPECT_NAMES[aspectIndex]
  const allResults = roll.dice[0]?.results ?? []
  const faces = countFaces(allResults)
  const results = computeWuXingResults(faces, wuXingAspectName, bonusAuspicious)
  if (!results) return

  // ---- Build modifier summary text ----
  const modParts = []
  if (isMagic) {
    const bm = Number(params.bonusmalusskill ?? 0)
    const ba = Number(params.bonusauspiciousdice ?? 0)
    const fp = Number(params.freepowerlevels ?? 0)
    if (bm !== 0) modParts.push(`${bm > 0 ? "+" : ""}${bm} ${game.i18n.localize("CDE.BonusMalus")}`)
    if (ba !== 0) modParts.push(`+${ba} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`)
    if (fp !== 0) modParts.push(`+${fp} ${game.i18n.localize("CDE.FreePowerLevels")}`)
    if (rollDifficulty !== 1) modParts.push(`×${rollDifficulty} ${game.i18n.localize("CDE.RollDifficulty")}`)
  } else {
    const bm = Number(params.bonusmalus ?? 0)
    const wm = Number(params.woundmalus ?? 0)
    const ba = Number(params.bonusauspiciousdice ?? 0)
    if (bm !== 0) modParts.push(`${bm > 0 ? "+" : ""}${bm} ${game.i18n.localize("CDE.BonusMalus")}`)
    if (wm !== 0) modParts.push(`-${wm} ${game.i18n.localize("CDE.WoundMalus")}`)
    if (ba !== 0) modParts.push(`+${ba} ${game.i18n.localize("CDE.BonusAuspiciousDice")}`)
  }

  // ---- Send single enriched ChatMessage (roll + result card) ----
  const msg = await sendResultMessage(actor, {
    // Roll identity
    rollLabel:    title,
    aspectName:   wuXingAspectName,
    aspectLabel:  game.i18n.localize(ASPECT_LABELS[wuXingAspectName] ?? ""),
    aspectIcon:   ASPECT_ICONS[wuXingAspectName] ?? "",
    totalDice:    numberofdice,
    modifiersText: modParts.length ? modParts.join(" · ") : "",
    // Spell power (magic only)
    spellPower,
    spellPowerAspectLabel: spellPowerAspectName ? game.i18n.localize(ASPECT_LABELS[spellPowerAspectName] ?? "") : "",
    spellPowerAspectValue,
    spellPowerFreeLevels: isMagic ? Number(params.freepowerlevels ?? 0) : 0,
    rollDifficulty: isMagic ? rollDifficulty : null,
    // Actor info
    actorName:  actor.name ?? "",
    actorImg:   actor.img ?? "",
    // Wu Xing results
    aspect: wuXingAspectName,
    ...results,
    // Die face counts
    d1: faces[1], d2: faces[2], d3: faces[3], d4: faces[4], d5: faces[5],
    d6: faces[6], d7: faces[7], d8: faces[8], d9: faces[9], d0: faces[0],
  }, roll, rollModeKey)

  // ---- Wait for Dice So Nice animation ----
  if (game.modules.get("dice-so-nice")?.active && msg?.id) {
    try { await game.dice3d.waitFor3DAnimationByMessageID(msg.id) } catch (_e) { /* DSN not available */ }
  }

  // ---- Auto-update Loksyu / TinJi singletons ----
  if ((results.loksyudice ?? 0) > 0) await updateLoksyuFromRoll(wuXingAspectName, faces)
  if ((results.tinjidice  ?? 0) > 0) await updateTinjiFromRoll(results.tinjidice)
}
