import { LesOubliesUtility } from "./les-oublies-utility.js"

const PRIME_DEFINITIONS = [
  {
    id: "none",
    label: "Aucune prime",
    type: "prime",
    actionTypes: ["all"],
    effects: {},
  },
  {
    id: "efficacite",
    label: "Efficacité",
    type: "prime",
    actionTypes: ["all"],
    effects: {
      finalModifier: 3,
    },
  },
  {
    id: "debordement",
    label: "Débordement",
    type: "prime",
    actionTypes: ["all"],
    effects: {
      opponentFinalModifier: -3,
    },
  },
  {
    id: "acceleration",
    label: "Accélération",
    type: "prime",
    actionTypes: ["all"],
    effects: {
      initiativeDelta: 4,
    },
  },
  {
    id: "blessure-grave",
    label: "Blessure grave",
    type: "prime",
    actionTypes: ["meleeAttack", "rangedAttack"],
    effects: {
      damageMultiplier: 1.5,
    },
  },
  {
    id: "blessure-precise",
    label: "Blessure précise",
    type: "prime",
    actionTypes: ["meleeAttack", "rangedAttack"],
    effects: {
      armorDivisor: 2,
    },
  },
  {
    id: "blessure-non-letale",
    label: "Blessure non létale",
    type: "prime",
    actionTypes: ["meleeAttack", "rangedAttack"],
    effects: {
      nonLethal: true,
    },
  },
  {
    id: "attaques-multiples",
    label: "Attaques multiples",
    type: "prime",
    actionTypes: ["meleeAttack"],
    effects: {
      damageMultiplier: 0.5,
      targetsMultiple: true,
    },
  },
]

const PENALTY_DEFINITIONS = [
  {
    id: "none",
    label: "Aucune pénalité",
    type: "penalty",
    actionTypes: ["all"],
    effects: {},
  },
  {
    id: "difficulte",
    label: "Difficulté",
    type: "penalty",
    actionTypes: ["all"],
    effects: {
      finalModifier: -3,
    },
  },
  {
    id: "facilite",
    label: "Facilité",
    type: "penalty",
    actionTypes: ["all"],
    effects: {
      opponentFinalModifier: 3,
    },
  },
  {
    id: "ralentissement",
    label: "Ralentissement",
    type: "penalty",
    actionTypes: ["all"],
    effects: {
      initiativeDelta: -4,
    },
  },
  {
    id: "danger",
    label: "Danger",
    type: "penalty",
    actionTypes: ["all"],
    effects: {
      nextReactionModifier: -3,
    },
  },
  {
    id: "abandon-position",
    label: "Abandon de position avantageuse",
    type: "penalty",
    actionTypes: ["all"],
    effects: {
      note: "Le personnage perd sa position avantageuse.",
    },
  },
  {
    id: "risque",
    label: "Risque",
    type: "penalty",
    actionTypes: ["all"],
    effects: {
      riskIncident: true,
    },
  },
  {
    id: "blessure-legere",
    label: "Blessure légère",
    type: "penalty",
    actionTypes: ["meleeAttack", "rangedAttack"],
    effects: {
      damageMultiplier: 0.5,
    },
  },
]

const ATTACK_DIFFICULTIES = {
  melee: [
    { value: 3, label: "Cible inerte (+3)" },
    { value: 3, label: "Attaquant en hauteur (+3)" },
    { value: 3, label: "Cible d'une taille deux fois supérieure ou plus (+3)" },
    { value: -3, label: "Cible en hauteur (-3)" },
    { value: -3, label: "Obscurité (-3)" },
    { value: -3, label: "Cible d'une taille deux fois inférieure ou moins (-3)" },
  ],
  ranged: [
    { value: 3, label: "Cible à moins de la portée (+3)" },
    { value: 3, label: "Cible très grande (+3)" },
    { value: 3, label: "Cible immobile (+3)" },
    { value: 0, label: "Cible à découvert (0)" },
    { value: 0, label: "Entre la portée et son double (0)" },
    { value: -1, label: "À genoux derrière un couvert (-1)" },
    { value: -2, label: "À plat ventre derrière un couvert (-2)" },
    { value: -3, label: "Cible très petite (-3)" },
    { value: -3, label: "Cible en mouvement rapide (-3)" },
    { value: -3, label: "Faible lumière (-3)" },
    { value: -3, label: "Couvert léger (-3)" },
    { value: -4, label: "Bouclier humain (-4)" },
    { value: -5, label: "Seule la tête est visible (-5)" },
    { value: -6, label: "Entre le double et le triple de la portée (-6)" },
  ],
}

const MOVEMENT_DIFFICULTIES = [
  { value: 3, label: "Descendre (+3)" },
  { value: -3, label: "Monter (-3)" },
  { value: -3, label: "Franchir un obstacle (-3)" },
  { value: -3, label: "Ouvrir une porte non verrouillée (-3)" },
  { value: -3, label: "Terrain difficile (-3)" },
  { value: 3, label: "Se jeter à terre (+3)" },
  { value: -3, label: "Se relever (-3)" },
  { value: -6, label: "Ramper (-6)" },
  { value: 3, label: "Courte distance (jusqu'à 10 cm) (+3)" },
  { value: 3, label: "Chemin direct (+3)" },
  { value: -3, label: "Longue distance (20 cm et plus) (-3)" },
  { value: -3, label: "Faire un détour (-3)" },
]

const TEST_DIFFICULTIES = [
  { value: 12, label: "Exceptionnellement facile (+12)" },
  { value: 9, label: "Très facile (+9)" },
  { value: 6, label: "Facile (+6)" },
  { value: 3, label: "Avantageuse (+3)" },
  { value: 0, label: "Normale (+0)" },
  { value: -3, label: "Difficile (-3)" },
  { value: -6, label: "Très difficile (-6)" },
  { value: -9, label: "Extrêmement difficile (-9)" },
  { value: -12, label: "Presque impossible (-12)" },
]

const HARVEST_SIDE_EFFECTS = {
  1: "La main du personnage tremble plus ou moins violemment.",
  2: "Le personnage n'arrive à trouver ni repos ni sommeil.",
  3: "Le personnage se replie sur lui-même et parle très peu.",
  4: "Le personnage ne trouve plus de sens à la vie et est moralement brisé.",
  5: "Le personnage subit des troubles de la perception.",
  6: "Le personnage continue de ressentir une peur irraisonnée.",
  7: "Le personnage est tourmenté et se sent en danger.",
  8: "Le personnage se ferme aux autres et n'éprouve plus d'empathie.",
  9: "Le personnage a des brusques accélérations du rythme cardiaque.",
  10: "Le personnage souffre d'un tic.",
  11: "Le personnage n'arrive pas à se concentrer.",
  12: "Le personnage est frappé de brèves amnésies.",
}

const PRESET_ACTIONS = {
  encourager: {
    key: "encourager",
    title: "Encourager un allié",
    mode: "test",
    skillKey: "commandement",
    difficulty: 0,
    hint: "Sur succès, choisissez l'avantage accordé : initiative, prochaine action ou prochaine réaction.",
  },
  intimider: {
    key: "intimider",
    title: "Intimider un adversaire",
    mode: "confrontation",
    attackerSkillKey: "commandement",
    defenderSkillKey: "volonte",
    difficulty: 0,
    hint: "Sur succès, choisissez le désavantage imposé à l'adversaire.",
  },
  evaluer: {
    key: "evaluer",
    title: "Évaluer un adversaire",
    mode: "confrontation",
    attackerSkillKey: "strategie",
    attackerAlternativeKeys: ["tactique"],
    defenderSkillKey: "subterfuge",
    difficulty: 0,
    hint: "Le paramètre observé sera rappelé dans la carte de chat si l'évaluation aboutit.",
  },
  maitriser: {
    key: "maitriser",
    title: "Maîtriser un adversaire",
    mode: "confrontation",
    attackerSkillKey: "corpsacorps",
    defenderSkillKey: "corpsacorps",
    difficulty: 0,
    hint: "Sur succès, la carte rappelle les suites possibles : réduire au silence, prendre en otage, conforter sa prise, étouffer, attacher.",
  },
  seDeplacer: {
    key: "seDeplacer",
    title: "Se déplacer",
    mode: "test",
    skillKey: "athletisme",
    difficulty: 0,
    hint: "Le premier déplacement court sans opposition peut être automatique ; lancez ce jet quand un vrai test est requis.",
  },
}

export class LesOubliesRolls {
  static #actorLocks = new Map()

  static async openTestDialog(actor, preset = {}) {
    const data = await this.#promptTestOptions(actor, preset)
    if (!data || typeof data !== "object") return null

    const result = await this.resolveTest(actor, data)
    return this.#createChatMessage(actor, result)
  }

  static async openInitiativeDialog(actor) {
    const rapidite = actor.getSkillScoreByKey?.("rapidite") ?? 0
    const data = await this.#promptTestOptions(actor, {
      label: game.i18n.localize("LESOUBLIES.rolls.initiative"),
      score: rapidite,
      difficulty: 0,
      rollMode: this.getDefaultRollMode(actor),
      extraDie: "",
      mode: "initiative",
      lockLabel: true,
    })
    if (!data || typeof data !== "object") return null

    const result = await this.resolveTest(actor, data)
    if (!result) return null
    const initiativeScore = Math.min(Math.max(Math.ceil(result.final / 2), 0), 12)
    await this.#syncInitiativeToCombat(actor, initiativeScore)

    return this.#createChatMessage(actor, {
      ...result,
      mode: "initiative",
      initiativeScore,
      successLabel: null,
    })
  }

  static async openConfrontationDialog(actor, preset = {}) {
    const data = await this.#promptConfrontationOptions(actor, preset)
    if (!data || typeof data !== "object") return null
    const defenderActor = this.#resolveDialogTargetActor(data.defenderActorId, preset.targetActor ?? this.#getTargetActor())
    return this.#createConfrontationMessage(actor, {
      ...data,
      defenderLabel: defenderActor?.name ?? data.defenderLabel,
      defenderScore: defenderActor
        ? this.#getSkillScoreWithAlternatives(defenderActor, data.defenderSkill)
        : Number(data.defenderScore ?? 0),
      defenderSongesValue: defenderActor ? Number(defenderActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0),
      defenderSongesPoints: defenderActor ? Number(defenderActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0),
      defenderCauchemarValue: defenderActor ? Number(defenderActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0),
      defenderCauchemarPoints: defenderActor ? Number(defenderActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0),
    }, preset.actionData ?? null)
  }

  static async openAttackDialog(actor, { itemId = null, mode = null } = {}) {
    const weapon = itemId ? actor.items.get(itemId) ?? null : null
    const attackMode = mode ?? this.#getWeaponAttackMode(weapon)
    const targetActor = this.#getTargetActor()
    const data = await this.#promptAttackOptions(actor, {
      weapon,
      attackMode,
      targetActor,
    })
    if (!data) return null
    const defenderActor = this.#resolveDialogTargetActor(data.defenderActorId, targetActor)
    if (!defenderActor) {
      ui.notifications.info("Aucune cible sélectionnée : choisissez un adversaire avant de lancer une attaque.")
      return null
    }

    const modifiers = this.#resolveModifierSelection(data.primeId, data.penaltyId, attackMode === "ranged" ? "rangedAttack" : "meleeAttack")
    const reactionOptions = this.#getAttackReactionOptions(data.attackerSkill)
    const actionData = {
      actionType: attackMode === "ranged" ? "rangedAttack" : "meleeAttack",
      title: attackMode === "ranged" ? "Tirer" : "Frapper",
      subtitle: weapon ? `${weapon.name} · ${this.#getSkillLabel(data.attackerSkill)}` : this.#getSkillLabel(data.attackerSkill),
      hint: attackMode === "ranged"
        ? "Réaction possible : Esquive ou Mêlée avec bouclier."
        : "Réaction possible : Corps à corps, Esquive ou Mêlée selon l'attaque.",
      weaponName: weapon?.name ?? (attackMode === "ranged" ? "Arme à distance" : "Arme"),
      modifiers,
      targetLabel: data.defenderLabel,
      notes: data.notes?.trim() || "",
      targetActor: defenderActor,
      applyToTarget: Boolean(data.applyToTarget && defenderActor),
      damageRequest: {
        actor,
        weapon,
        baseDamage: Number(data.baseDamage ?? 0),
        baseLabel: String(data.baseDamageLabel || weapon?.system?.damage || data.baseDamage || "0"),
        targetProtection: Number(data.targetProtection ?? 0),
        targetLabel: String(data.defenderLabel || defenderActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")),
        targetActor: defenderActor,
        applyToTarget: Boolean(data.applyToTarget && defenderActor),
        modifiers,
      },
      extraContext: {
        difficultyLabel: data.difficultyLabel,
        reactionLabel: reactionOptions.find((option) => option.value === data.defenderSkill)?.label ?? this.#getSkillLabel(data.defenderSkill),
      },
    }

    return this.#createConfrontationMessage(actor, {
      confrontationType: "directe",
      attackerLabel: actor.name,
      attackerScore: this.#getSkillScoreWithAlternatives(actor, data.attackerSkill),
      attackerDifficulty: Number(data.difficulty ?? 0),
      attackerRollMode: data.attackerRollMode,
      attackerExtraDie: data.attackerExtraDie,
      attackerFinalModifier: modifiers.summary.finalModifier,
      defenderLabel: data.defenderLabel,
      defenderScore: defenderActor
        ? this.#getSkillScoreWithAlternatives(defenderActor, data.defenderSkill)
        : Number(data.defenderScore ?? 0),
      defenderDifficulty: Number(data.defenderDifficulty ?? 0),
      defenderRollMode: data.defenderRollMode,
      defenderExtraDie: data.defenderExtraDie,
      defenderFinalModifier: modifiers.summary.opponentFinalModifier,
      defenderSongesValue: defenderActor ? Number(defenderActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0),
      defenderSongesPoints: defenderActor ? Number(defenderActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0),
      defenderCauchemarValue: defenderActor ? Number(defenderActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0),
      defenderCauchemarPoints: defenderActor ? Number(defenderActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0),
    }, actionData)
  }

  static async openDamageDialog(actor, { itemId = null } = {}) {
    const weapon = itemId ? actor.items.get(itemId) ?? null : null
    const targetActor = this.#getTargetActor()
    const data = await this.#promptDamageOptions(actor, {
      weapon,
      targetActor,
    })
    if (!data) return null

    const modifiers = this.#resolveModifierSelection(data.primeId, data.penaltyId, this.#getWeaponAttackMode(weapon) === "ranged" ? "rangedAttack" : "meleeAttack")
    const damage = this.#computeDamageResolution({
      actor,
      weapon,
      baseDamage: Number(data.baseDamage ?? 0),
      baseLabel: String(data.baseDamageLabel || weapon?.system?.damage || data.baseDamage || "0"),
      targetProtection: Number(data.targetProtection ?? 0),
      targetLabel: String(data.targetLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")),
      targetActor,
      applyToTarget: Boolean(data.applyToTarget && targetActor),
      modifiers,
    })

    if (damage.applyResult) {
      await this.#applyDamageToActor(damage.applyResult.actor, damage.applyResult.damage)
    }

    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/chat-action-roll.hbs",
      {
        actor,
        result: null,
        action: {
          title: "Résolution de dégâts",
          subtitle: weapon ? weapon.name : "Dégâts",
          hint: "Les dégâts des attaques standards sont fixes puis modifiés par primes, pénalités et protection.",
          modifiers,
          notes: data.notes?.trim() || "",
          damage,
        },
      },
    )

    return ChatMessage.create({
      speaker: ChatMessage.getSpeaker({ actor }),
      content,
    })
  }

  static async openSpellDialog(actor, itemId) {
    const spell = actor.items.get(itemId)
    if (!spell) return null

    const data = await this.#promptSpellOptions(actor, spell)
    if (!data) return null
    const paymentMode = String(data.paymentMode || "points")
    const paymentSource = this.#normalizeThreadReserveSource(data.paymentSource)
    const paymentOwner = paymentMode === "fils"
      ? this.#getThreadReserveOwner(actor, paymentSource)
      : actor
    const activation = await this.#withActorLock(`spell:${paymentOwner?.id ?? actor.id}:${paymentMode}`, async () => {
      const skill = actor.getCompetenceByKey?.(spell.system.skillKey) ?? null
      const skillBase = Number(skill?.system?.base ?? 0)
      if (skillBase < 1) {
        ui.notifications.warn(`Il faut au moins une base de 1 en ${this.#getSkillLabel(spell.system.skillKey)} pour activer ce sortilège.`)
        return null
      }

      const métierMatch = this.#actorMatchesSpellGrant(actor, spell)
      const surcharge = !métierMatch
      const effectiveCost = Number(data.actualCost ?? 0) * (surcharge ? 2 : 1)
      if (paymentMode === "points") {
        const resource = spell.system.polarity || "songes"
        const available = Number(actor.system?.[resource]?.points ?? 0)
        if (available < effectiveCost) {
          ui.notifications.warn(game.i18n.format("LESOUBLIES.rolls.notEnoughResourceDetailed", {
            resource: resource === "songes" ? game.i18n.localize("LESOUBLIES.ui.songes") : game.i18n.localize("LESOUBLIES.ui.cauchemar"),
            actor: actor.name,
            required: effectiveCost,
            available,
          }))
          return null
        }
        if (effectiveCost > 0) {
          await actor.update({
            [`system.${resource}.points`]: Math.max(available - effectiveCost, 0),
          })
        }
      } else {
        const reserve = this.#getThreadReserveState(actor, paymentSource)
        if (!reserve.owner) {
          ui.notifications.warn("Aucune réserve de compagnie n'est liée à ce personnage.")
          return null
        }

        const resourceKey = this.#getThreadResourceKey(spell.system.polarity)
        const available = Number(reserve[resourceKey] ?? 0)
        if (available < effectiveCost) {
          ui.notifications.warn(game.i18n.format("LESOUBLIES.rolls.notEnoughResourceDetailed", {
            resource: `${effectiveCost > 1 ? "fils" : "fil"} de ${spell.system.polarity === "cauchemar" ? "Cauchemar" : "Songes"}`,
            actor: reserve.label,
            required: effectiveCost,
            available,
          }))
          return null
        }

        if (effectiveCost > 0) {
          await reserve.owner.update({
            [`system.reserves.${resourceKey}`]: Math.max(available - effectiveCost, 0),
            "system.reserves.emptyGlobes": Number(reserve.emptyGlobes ?? 0) + effectiveCost,
          })
        }
      }

      return {
        métierMatch,
        surcharge,
        effectiveCost,
        paymentMode,
        paymentSource,
        paymentSourceLabel: paymentMode === "fils"
          ? this.#getThreadReserveLabel(actor, paymentSource)
          : actor.name,
      }
    })
    if (!activation) return null

    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/chat-spell-activation.hbs",
      {
        actor,
        spell,
        activation: {
          targetLabel: data.targetLabel?.trim() || "Sans cible précisée",
          paymentMode: activation.paymentMode,
          actualCost: Number(data.actualCost ?? 0),
          effectiveCost: activation.effectiveCost,
          costLabel: activation.paymentMode === "points"
            ? `${activation.effectiveCost} point${activation.effectiveCost > 1 ? "s" : ""} de ${spell.system.polarity === "cauchemar" ? "Cauchemar" : "Songes"}`
            : `${activation.effectiveCost} fil${activation.effectiveCost > 1 ? "s" : ""} de ${spell.system.polarity === "cauchemar" ? "Cauchemar" : "Songes"}`,
          paymentSourceLabel: activation.paymentSourceLabel,
          métierMatch: activation.métierMatch,
          surcharge: activation.surcharge,
          notes: data.notes?.trim() || "",
        },
      },
    )

    return ChatMessage.create({
      speaker: ChatMessage.getSpeaker({ actor }),
      content,
    })
  }

  static async openCombatPresetDialog(actor, actionKey) {
    const preset = PRESET_ACTIONS[actionKey]
    if (!preset) return null

    if (preset.mode === "test") {
      const data = await this.#promptPresetTestOptions(actor, preset)
      if (!data) return null

      const modifiers = this.#resolveModifierSelection(data.primeId, data.penaltyId, data.actionType)
      const result = await this.resolveTest(actor, {
        label: preset.title,
        score: this.#getSkillScoreWithAlternatives(actor, preset.skillKey, preset.alternativeKeys),
        difficulty: Number(data.difficulty ?? preset.difficulty ?? 0),
        rollMode: data.rollMode,
        extraDie: data.extraDie,
        mode: "action",
        finalModifier: modifiers.summary.finalModifier,
        metadata: {
          action: {
            title: preset.title,
            subtitle: this.#getSkillLabel(preset.skillKey),
            hint: preset.hint,
            modifiers,
            notes: data.notes?.trim() || "",
            outcome: this.#buildPresetOutcome(actionKey, data),
          },
        },
      })
      return this.#createChatMessage(actor, result)
    }

    const targetActor = this.#getTargetActor()
    const data = await this.#promptPresetConfrontationOptions(actor, preset, targetActor)
    if (!data) return null
    const defenderActor = this.#resolveDialogTargetActor(data.defenderActorId, targetActor)

    const modifiers = this.#resolveModifierSelection(data.primeId, data.penaltyId, actionKey)
    const actionData = {
      actionType: actionKey,
      title: preset.title,
      subtitle: `${this.#getSkillLabel(preset.attackerSkillKey)} contre ${this.#getSkillLabel(preset.defenderSkillKey)}`,
      hint: preset.hint,
      modifiers,
      notes: data.notes?.trim() || "",
      targetLabel: defenderActor?.name ?? data.defenderLabel,
      targetActor: defenderActor,
      applyToTarget: false,
      outcome: this.#buildPresetOutcome(actionKey, data),
    }

    return this.#createConfrontationMessage(actor, {
      confrontationType: "directe",
      attackerLabel: actor.name,
      attackerScore: this.#getSkillScoreWithAlternatives(actor, preset.attackerSkillKey, preset.attackerAlternativeKeys),
      attackerDifficulty: Number(data.attackerDifficulty ?? preset.difficulty ?? 0),
      attackerRollMode: data.attackerRollMode,
      attackerExtraDie: data.attackerExtraDie,
      attackerFinalModifier: modifiers.summary.finalModifier,
      defenderLabel: defenderActor?.name ?? data.defenderLabel,
      defenderScore: defenderActor
        ? this.#getSkillScoreWithAlternatives(defenderActor, preset.defenderSkillKey)
        : Number(data.defenderScore ?? 0),
      defenderDifficulty: Number(data.defenderDifficulty ?? 0),
      defenderRollMode: data.defenderRollMode,
      defenderExtraDie: data.defenderExtraDie,
      defenderFinalModifier: modifiers.summary.opponentFinalModifier,
      defenderSongesValue: defenderActor ? Number(defenderActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0),
      defenderSongesPoints: defenderActor ? Number(defenderActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0),
      defenderCauchemarValue: defenderActor ? Number(defenderActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0),
      defenderCauchemarPoints: defenderActor ? Number(defenderActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0),
    }, actionData)
  }

  static async openThreadHarvestDialog(actor) {
    const data = await this.#promptThreadHarvestOptions(actor)
    if (!data) return null

    const threadCount = Math.max(Number(data.threadCount ?? 1), 1)
    const destinationSource = this.#normalizeThreadReserveSource(data.destinationSource)
    const destinationReserve = this.#getThreadReserveState(actor, destinationSource)
    if (!destinationReserve.owner) {
      ui.notifications.warn("Aucune réserve de compagnie n'est liée à ce personnage.")
      return null
    }
    if (Number(destinationReserve.emptyGlobes ?? 0) < threadCount) {
      ui.notifications.warn(`${destinationReserve.label} ne dispose pas de suffisamment de globes vides pour stocker cette récolte.`)
      return null
    }

    const damageTaken = threadCount
    const difficulty = -3 * (threadCount - 1)
    const result = await this.resolveTest(actor, {
      label: `Récolte de fils de ${data.threadType === "cauchemar" ? "Cauchemar" : "Songes"}`,
      score: this.#getSkillScoreWithAlternatives(actor, "onirologie"),
      difficulty,
      rollMode: data.rollMode,
      extraDie: data.extraDie,
      mode: "action",
      metadata: {
        action: {
          title: "Récolte de fils",
          subtitle: `${threadCount} fil${threadCount > 1 ? "s" : ""} de ${data.threadType === "cauchemar" ? "Cauchemar" : "Songes"}`,
          hint: "Le test subit -3 par fil supplémentaire souhaité et inflige 1 dégât par fil souhaité.",
          notes: data.notes?.trim() || "",
        },
      },
    })
    if (!result) return null

    await this.#applyDamageToActor(actor, damageTaken)
    if (result.success) {
      await this.#storeHarvestedThreads(actor, destinationSource, data.threadType, threadCount)
    }
    const durationRoll = await this.#evaluateDisplayedRoll("1d12")
    const effectRoll = await this.#evaluateDisplayedRoll("1d12")
    const effectIndex = Number(effectRoll.total ?? 1)
    result.metadata.action.harvest = {
      threadType: data.threadType,
      threadCount,
      damageTaken,
      lockoutOnFailure: !result.success,
      durationHours: Number(durationRoll.total ?? 0),
      sideEffectRoll: effectIndex,
      sideEffectText: HARVEST_SIDE_EFFECTS[effectIndex],
      sleeperLabel: data.sleeperLabel?.trim() || "Dormeur non précisé",
      destinationLabel: destinationReserve.label,
      stored: result.success,
    }

    return this.#createChatMessage(actor, result)
  }

  static getDefaultRollMode(actor) {
    return actor?.type === "creature" ? "single" : "dual"
  }

  static getRollModes() {
    return [
      { value: "dual", label: game.i18n.localize("LESOUBLIES.rolls.rollModes.dual") },
      { value: "songes", label: game.i18n.localize("LESOUBLIES.rolls.rollModes.songes") },
      { value: "cauchemar", label: game.i18n.localize("LESOUBLIES.rolls.rollModes.cauchemar") },
      { value: "single", label: game.i18n.localize("LESOUBLIES.rolls.rollModes.single") },
    ]
  }

  static getExtraDieModes() {
    return [
      { value: "", label: game.i18n.localize("LESOUBLIES.rolls.extraDice.none") },
      { value: "songes", label: game.i18n.localize("LESOUBLIES.rolls.extraDice.songes") },
      { value: "cauchemar", label: game.i18n.localize("LESOUBLIES.rolls.extraDice.cauchemar") },
    ]
  }

  static getWeaponActionLabel(weapon) {
    return this.#getWeaponAttackMode(weapon) === "ranged" ? "Tirer" : "Frapper"
  }

  static isRangedWeapon(weapon) {
    return this.#getWeaponAttackMode(weapon) === "ranged"
  }

  static resolveModifierSelection(primeId = "none", penaltyId = "none", actionType = "all") {
    return this.#resolveModifierSelection(primeId, penaltyId, actionType)
  }

  static async resolveTest(actor, options) {
    const rollContext = this.#getRollContext(actor)
    const spentResource = this.#createSpentResource(options.extraDie)
    if (spentResource && !this.#canSpendResource(rollContext, spentResource.type)) {
      ui.notifications.warn(game.i18n.format("LESOUBLIES.rolls.notEnoughResource", {
        resource: spentResource.label,
        actor: rollContext.label,
      }))
      return null
    }

    const pool = this.#buildPool(options.rollMode, options.extraDie)
    const dice = await this.#rollExplodingPool(pool)

    const selectedIndex = this.#needsSelection(dice)
      ? await this.#promptDieSelection(actor, options.label, dice)
      : this.#selectBestDie(dice)

    if (!Number.isInteger(selectedIndex)) return null

    const selectedDie = dice[selectedIndex]
    const debt = this.#computeDebt(rollContext, dice, selectedDie)
    if (spentResource) await this.#spendResource(rollContext, spentResource)

    const natural = Number(selectedDie.total ?? 0)
    const score = Number(options.score ?? 0)
    const difficulty = Number(options.difficulty ?? 0)
    const threshold = 12
    const finalModifier = Number(options.finalModifier ?? 0)
    const baseFinal = natural + score + difficulty
    const final = baseFinal + finalModifier
    const automaticFailure = selectedDie.firstFace === 1
    const success = !automaticFailure && final >= threshold

    return {
      mode: options.mode ?? "test",
      label: options.label,
      score,
      difficulty,
      threshold,
      margin: final - threshold,
      rollMode: options.rollMode,
      rollModeLabel: game.i18n.localize(`LESOUBLIES.rolls.rollModes.${options.rollMode}`),
      dice,
      selectedDie,
      selectedSummary: `${selectedDie.typeLabel} ${selectedDie.total}`,
      natural,
      baseFinal,
      finalModifier,
      final,
      success,
      automaticFailure,
      successLabel: game.i18n.localize(success ? "LESOUBLIES.rolls.success" : "LESOUBLIES.rolls.failure"),
      choiceLabel: game.i18n.localize(debt.amount ? "LESOUBLIES.rolls.riskyChoice" : "LESOUBLIES.rolls.safeChoice"),
      debt,
      spentResource,
      metadata: foundry.utils.deepClone(options.metadata ?? {}),
    }
  }

  static async #createChatMessage(actor, result) {
    if (!result) return null

    const template = result.mode === "initiative"
      ? "systems/fvtt-les-oublies/templates/chat-initiative-roll.hbs"
      : result.mode === "action"
        ? "systems/fvtt-les-oublies/templates/chat-action-roll.hbs"
        : "systems/fvtt-les-oublies/templates/chat-test-roll.hbs"

    const content = await foundry.applications.handlebars.renderTemplate(template, {
      actor,
      result,
      action: result.metadata?.action ?? null,
    })

    return ChatMessage.create({
      speaker: ChatMessage.getSpeaker({ actor }),
      content,
    })
  }

  static async #syncInitiativeToCombat(actor, initiativeScore) {
    if (!game.combat || !actor?.id) return false

    const combatants = game.combat.combatants.filter((combatant) => combatant.actorId === actor.id)
    if (!combatants.length) {
      ui.notifications.warn(`${actor.name} n'est pas présent dans le combat actif.`)
      return false
    }

    await game.combat.updateEmbeddedDocuments("Combatant", combatants.map((combatant) => ({
      _id: combatant.id,
      initiative: initiativeScore,
    })))

    return true
  }

  static async #createConfrontationMessage(actor, data, actionData = null) {
    const attacker = await this.resolveTest(actor, {
      label: data.attackerLabel,
      score: data.attackerScore,
      difficulty: data.attackerDifficulty,
      rollMode: data.attackerRollMode,
      extraDie: data.attackerExtraDie,
      mode: "confrontation",
      finalModifier: data.attackerFinalModifier,
    })
    const defenderContext = data.targetActor ?? this.#createVirtualRollContext({
      label: data.defenderLabel,
      songesValue: data.defenderSongesValue,
      songesPoints: data.defenderSongesPoints,
      cauchemarValue: data.defenderCauchemarValue,
      cauchemarPoints: data.defenderCauchemarPoints,
    })
    const defender = await this.resolveTest(defenderContext, {
      label: data.defenderLabel,
      score: data.defenderScore,
      difficulty: data.defenderDifficulty,
      rollMode: data.defenderRollMode,
      extraDie: data.defenderExtraDie,
      mode: "confrontation",
      finalModifier: data.defenderFinalModifier,
    })
    if (!attacker || !defender) return null

    const outcomeKey = this.#getConfrontationOutcome(attacker, defender)
    const confrontationAction = foundry.utils.deepClone(actionData ?? {})
    if (confrontationAction.damageRequest) {
      const hit = ["attacker-success", "attacker-advantage"].includes(outcomeKey)
      const damage = hit
        ? this.#computeDamageResolution(confrontationAction.damageRequest)
        : null
      confrontationAction.damage = damage
      if (damage?.applyResult) {
        await this.#applyDamageToActor(damage.applyResult.actor, damage.applyResult.damage)
      }
    }
    if (confrontationAction.outcome) {
      confrontationAction.outcome.success = ["attacker-success", "attacker-advantage"].includes(outcomeKey)
    }

    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/chat-confrontation-roll.hbs",
      {
        actor,
        confrontationType: game.i18n.localize(`LESOUBLIES.rolls.confrontationTypes.${data.confrontationType}`),
        attacker,
        defender,
        outcomeKey,
        outcomeLabel: game.i18n.localize(`LESOUBLIES.rolls.outcomes.${outcomeKey}`),
        action: confrontationAction,
      },
    )

    return ChatMessage.create({
      speaker: ChatMessage.getSpeaker({ actor }),
      content,
    })
  }

  static async #promptTestOptions(actor, preset = {}) {
    const difficulty = Number(preset.difficulty ?? 0)
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-roll-test.hbs",
      {
        actor,
        rollModes: this.getRollModes(),
        extraDieModes: this.getExtraDieModes(),
        difficultyOptions: this.#getDifficultyOptions(TEST_DIFFICULTIES, difficulty),
        resources: this.#getDialogResources(actor),
        values: {
          label: preset.label ?? "",
          score: Number(preset.score ?? 0),
          difficulty,
          rollMode: preset.rollMode ?? this.getDefaultRollMode(actor),
          extraDie: preset.extraDie ?? "",
        },
        isInitiative: preset.mode === "initiative",
        lockLabel: Boolean(preset.lockLabel),
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: game.i18n.localize(
          preset.mode === "initiative" ? "LESOUBLIES.rolls.dialogs.initiativeTitle" : "LESOUBLIES.rolls.dialogs.testTitle",
        ),
      },
      content,
      buttons: [
        {
          action: "roll",
          label: game.i18n.localize("LESOUBLIES.rolls.roll"),
          default: true,
          callback: async (_event, _button, dialog) => {
            const form = this.#getDialogElement(dialog)?.querySelector("form")
            if (!form) return null
            const data = this.#formToObject(form)
            return {
              label: String(data.label || "").trim() || preset.label || game.i18n.localize("LESOUBLIES.ui.roll"),
              score: Number(data.score ?? 0),
              difficulty: Number(data.difficulty ?? 0),
              rollMode: String(data.rollMode || this.getDefaultRollMode(actor)),
              extraDie: String(data.extraDie || ""),
              mode: preset.mode ?? "test",
            }
          },
        },
        {
          action: "cancel",
          label: game.i18n.localize("Cancel"),
        },
      ],
    })
  }

  static async #promptConfrontationOptions(actor, preset = {}) {
    const targetActor = preset.targetActor ?? this.#getTargetActor()
    const defenderSkill = preset.defenderSkill ?? "esquive"
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-roll-confrontation.hbs",
      {
        actor,
        targetActor,
        targetStatus: this.#getConfrontationTargetStatus(targetActor),
        targetOptions: this.#getConfrontationTargetOptions(actor, targetActor),
        defenderSkillOptions: this.#getConfrontationSkillOptions(),
        rollModes: this.getRollModes(),
        extraDieModes: this.getExtraDieModes(),
        defaultRollMode: this.getDefaultRollMode(actor),
        attackerResources: this.#getDialogResources(actor),
        defenderResources: targetActor ? this.#getDialogResources(targetActor) : {
          songesValue: 0,
          songesPoints: 0,
          cauchemarValue: 0,
          cauchemarPoints: 0,
        },
        values: {
          attackerLabel: preset.attackerLabel ?? actor.name,
          attackerScore: Number(preset.attackerScore ?? 0),
          attackerDifficulty: Number(preset.attackerDifficulty ?? 0),
          attackerRollMode: preset.attackerRollMode ?? this.getDefaultRollMode(actor),
          attackerExtraDie: preset.attackerExtraDie ?? "",
          defenderLabel: targetActor?.name ?? preset.defenderLabel ?? game.i18n.localize("LESOUBLIES.rolls.defender"),
          defenderActorId: targetActor?.id ?? "",
          defenderSkill,
          defenderScore: targetActor
            ? this.#getSkillScoreWithAlternatives(targetActor, defenderSkill)
            : Number(preset.defenderScore ?? 0),
          defenderDifficulty: Number(preset.defenderDifficulty ?? 0),
          defenderRollMode: preset.defenderRollMode ?? this.getDefaultRollMode(targetActor ?? actor),
          defenderExtraDie: preset.defenderExtraDie ?? "",
          confrontationType: preset.confrontationType ?? "directe",
        },
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: game.i18n.localize("LESOUBLIES.rolls.dialogs.confrontationTitle"),
      },
      content,
      render: (_event, dialog) => {
        this.#bindConfrontationTargetSelection(dialog, {
          actor,
          fallbackTargetActor: targetActor,
          skillFieldName: "defenderSkill",
        })
      },
      buttons: [
        {
          action: "roll",
          label: game.i18n.localize("LESOUBLIES.rolls.roll"),
          default: true,
          callback: async (_event, _button, dialog) => {
            const form = this.#getDialogElement(dialog)?.querySelector("form")
            if (!form) return null
            const data = this.#formToObject(form)
            return {
              confrontationType: data.confrontationType || "directe",
              attackerLabel: String(data.attackerLabel || actor.name || game.i18n.localize("LESOUBLIES.rolls.attacker")).trim(),
              attackerScore: Number(data.attackerScore ?? 0),
              attackerDifficulty: Number(data.attackerDifficulty ?? 0),
              attackerRollMode: String(data.attackerRollMode || this.getDefaultRollMode(actor)),
              attackerExtraDie: String(data.attackerExtraDie || ""),
              defenderActorId: String(data.defenderActorId || ""),
              defenderSkill: String(data.defenderSkill || defenderSkill),
              defenderLabel: String(data.defenderLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")).trim(),
              defenderScore: Number(data.defenderScore ?? 0),
              defenderDifficulty: Number(data.defenderDifficulty ?? 0),
              defenderRollMode: String(data.defenderRollMode || this.getDefaultRollMode(targetActor ?? actor)),
              defenderExtraDie: String(data.defenderExtraDie || ""),
              defenderSongesValue: targetActor ? Number(targetActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0),
              defenderSongesPoints: targetActor ? Number(targetActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0),
              defenderCauchemarValue: targetActor ? Number(targetActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0),
              defenderCauchemarPoints: targetActor ? Number(targetActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0),
              targetActor,
            }
          },
        },
        {
          action: "cancel",
          label: game.i18n.localize("Cancel"),
        },
      ],
    })
  }

  static async #promptAttackOptions(actor, { weapon = null, attackMode = "melee", targetActor = null } = {}) {
    const baseDamage = this.#getWeaponBaseDamage(actor, weapon)
    const baseDamageLabel = weapon?.system?.damage || String(baseDamage)
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-roll-attack-v2.hbs",
      {
        actor,
        weapon,
        attackTitle: attackMode === "ranged" ? "Tirer" : "Frapper",
        attackerResources: this.#getDialogResources(actor),
        defenderResources: targetActor ? this.#getDialogResources(targetActor) : {
          songesValue: 0,
          songesPoints: 0,
          cauchemarValue: 0,
          cauchemarPoints: 0,
        },
        targetActor,
        targetStatus: this.#getConfrontationTargetStatus(targetActor, { requireTarget: true }),
        targetOptions: this.#getConfrontationTargetOptions(actor, targetActor).map((entry, index) => (
          index === 0 ? { ...entry, label: "— Sélectionner un adversaire —" } : entry
        )),
        rollModes: this.getRollModes(),
        extraDieModes: this.getExtraDieModes(),
        attackSkills: this.#getAttackSkillOptions(attackMode),
        reactionSkills: this.#getAttackReactionOptions(attackMode === "ranged" ? "tir" : "melee"),
        difficultyOptions: ATTACK_DIFFICULTIES[attackMode] ?? [],
        primeOptions: this.#getModifierOptions("prime", attackMode === "ranged" ? "rangedAttack" : "meleeAttack"),
        penaltyOptions: this.#getModifierOptions("penalty", attackMode === "ranged" ? "rangedAttack" : "meleeAttack"),
        values: {
          attackerSkill: attackMode === "ranged" ? "tir" : "melee",
          attackerRollMode: this.getDefaultRollMode(actor),
          attackerExtraDie: "",
          defenderLabel: targetActor?.name ?? game.i18n.localize("LESOUBLIES.rolls.defender"),
          defenderActorId: targetActor?.id ?? "",
          defenderSkill: attackMode === "ranged" ? "esquive" : "esquive",
          defenderScore: 0,
          defenderDifficulty: 0,
          defenderRollMode: this.getDefaultRollMode(targetActor ?? actor),
          defenderExtraDie: "",
          targetProtection: this.#getActorProtection(targetActor),
          difficultyPreset: 0,
          customDifficulty: 0,
          primeId: "none",
          penaltyId: "none",
          baseDamage,
          baseDamageLabel,
          applyToTarget: Boolean(targetActor),
          notes: "",
        },
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: attackMode === "ranged" ? "Attaque à distance" : "Attaque de mêlée",
      },
      content,
      render: (_event, dialog) => {
        this.#bindConfrontationTargetSelection(dialog, {
          actor,
          fallbackTargetActor: targetActor,
          skillFieldName: "defenderSkill",
          requireTarget: true,
        })
      },
      buttons: [
        {
          action: "roll",
          label: game.i18n.localize("LESOUBLIES.rolls.roll"),
          default: true,
          callback: async (_event, _button, dialog) => {
            const form = this.#getDialogElement(dialog)?.querySelector("form")
            if (!form) return null
            const data = this.#formToObject(form)
            const defenderActor = this.#resolveDialogTargetActor(data.defenderActorId, targetActor)
            if (!defenderActor) {
              ui.notifications.info("Aucune cible sélectionnée : choisissez un adversaire avant de lancer une attaque.")
              dialog.close()
              return null
            }
            const difficultyPreset = Number(data.difficultyPreset ?? 0)
            const customDifficulty = Number(data.customDifficulty ?? 0)
            return {
              attackerSkill: String(data.attackerSkill || (attackMode === "ranged" ? "tir" : "melee")),
              attackerRollMode: String(data.attackerRollMode || this.getDefaultRollMode(actor)),
              attackerExtraDie: String(data.attackerExtraDie || ""),
              defenderActorId: String(data.defenderActorId || ""),
              defenderLabel: String(data.defenderLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")).trim(),
              defenderSkill: String(data.defenderSkill || "esquive"),
              defenderScore: Number(data.defenderScore ?? 0),
              defenderDifficulty: Number(data.defenderDifficulty ?? 0),
              defenderRollMode: String(data.defenderRollMode || this.getDefaultRollMode(targetActor ?? actor)),
              defenderExtraDie: String(data.defenderExtraDie || ""),
              defenderSongesValue: targetActor ? Number(targetActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0),
              defenderSongesPoints: targetActor ? Number(targetActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0),
              defenderCauchemarValue: targetActor ? Number(targetActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0),
              defenderCauchemarPoints: targetActor ? Number(targetActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0),
              difficulty: difficultyPreset + customDifficulty,
              difficultyLabel: this.#formatDifficultyBreakdown(difficultyPreset, customDifficulty),
              targetProtection: Number(data.targetProtection ?? 0),
              primeId: String(data.primeId || "none"),
              penaltyId: String(data.penaltyId || "none"),
              baseDamage: Number(data.baseDamage ?? baseDamage),
              baseDamageLabel: String(data.baseDamageLabel || baseDamageLabel),
              applyToTarget: data.applyToTarget === "on",
              notes: String(data.notes || ""),
            }
          },
        },
        {
          action: "cancel",
          label: game.i18n.localize("Cancel"),
        },
      ],
    })
  }

  static async #promptDamageOptions(actor, { weapon = null, targetActor = null } = {}) {
    const baseDamage = this.#getWeaponBaseDamage(actor, weapon)
    const baseDamageLabel = weapon?.system?.damage || String(baseDamage)
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-damage-resolution.hbs",
      {
        actor,
        weapon,
        targetActor,
        primeOptions: this.#getModifierOptions("prime", this.#getWeaponAttackMode(weapon) === "ranged" ? "rangedAttack" : "meleeAttack"),
        penaltyOptions: this.#getModifierOptions("penalty", this.#getWeaponAttackMode(weapon) === "ranged" ? "rangedAttack" : "meleeAttack"),
        values: {
          targetLabel: targetActor?.name ?? game.i18n.localize("LESOUBLIES.rolls.defender"),
          targetProtection: this.#getActorProtection(targetActor),
          baseDamage,
          baseDamageLabel,
          primeId: "none",
          penaltyId: "none",
          applyToTarget: Boolean(targetActor),
          notes: "",
        },
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: "Résolution de dégâts",
      },
      content,
      buttons: [
        {
          action: "resolve",
          label: "Résoudre",
          default: true,
          callback: async (_event, _button, dialog) => {
            const form = this.#getDialogElement(dialog)?.querySelector("form")
            if (!form) return null
            const data = this.#formToObject(form)
            return {
              targetLabel: String(data.targetLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")).trim(),
              targetProtection: Number(data.targetProtection ?? 0),
              baseDamage: Number(data.baseDamage ?? baseDamage),
              baseDamageLabel: String(data.baseDamageLabel || baseDamageLabel),
              primeId: String(data.primeId || "none"),
              penaltyId: String(data.penaltyId || "none"),
              applyToTarget: data.applyToTarget === "on",
              notes: String(data.notes || ""),
            }
          },
        },
        {
          action: "cancel",
          label: game.i18n.localize("Cancel"),
        },
      ],
    })
  }

  static async #promptSpellOptions(actor, spell) {
    const isMetierMatch = this.#actorMatchesSpellGrant(actor, spell)
    const effectiveCost = Number(spell.system.cost ?? 0) * (isMetierMatch ? 1 : 2)
    const polarityLabel = spell.system.polarity === "cauchemar"
      ? game.i18n.localize("LESOUBLIES.ui.cauchemar")
      : game.i18n.localize("LESOUBLIES.ui.songes")
    const threadReserves = this.#getThreadDialogState(actor)
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-spell-activation-v2.hbs",
      {
        actor,
        spell,
        resources: this.#getDialogResources(actor),
        threadReserves,
        isMetierMatch,
        effectiveCostLabel: `${effectiveCost} point${effectiveCost > 1 ? "s" : ""} de ${polarityLabel}`,
        values: {
          actualCost: Number(spell.system.cost ?? 0),
          paymentMode: "points",
          paymentSource: "actor",
          targetLabel: "",
          notes: "",
        },
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: `Activer ${spell.name}`,
      },
      content,
      render: (_event, dialog) => {
        this.#bindSpellPaymentSelection(dialog, { actor, spell, effectiveCost })
      },
      buttons: [
        {
          action: "activate",
          label: "Activer",
          default: true,
          callback: async (_event, _button, dialog) => {
            const form = this.#getDialogElement(dialog)?.querySelector("form")
            if (!form) return null
            const data = this.#formToObject(form)
            return {
              actualCost: Math.max(Number(data.actualCost ?? spell.system.cost ?? 0), 0),
              paymentMode: String(data.paymentMode || "points"),
              paymentSource: String(data.paymentSource || "actor"),
              targetLabel: String(data.targetLabel || ""),
              notes: String(data.notes || ""),
            }
          },
        },
        {
          action: "cancel",
          label: game.i18n.localize("Cancel"),
        },
      ],
    })
  }

  static async #promptPresetTestOptions(actor, preset) {
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-roll-action.hbs",
      {
        actor,
        title: preset.title,
        hint: preset.hint,
        rollModes: this.getRollModes(),
        extraDieModes: this.getExtraDieModes(),
        resources: this.#getDialogResources(actor),
        difficultyOptions: preset.key === "seDeplacer" ? MOVEMENT_DIFFICULTIES : [],
        primeOptions: this.#getModifierOptions("prime", preset.key),
        penaltyOptions: this.#getModifierOptions("penalty", preset.key),
        values: {
          rollMode: this.getDefaultRollMode(actor),
          extraDie: "",
          difficultyPreset: 0,
          customDifficulty: Number(preset.difficulty ?? 0),
          primeId: "none",
          penaltyId: "none",
          targetLabel: "",
          outcomeChoice: preset.key === "encourager" ? "initiative" : "",
          notes: "",
        },
        showMovementOptions: preset.key === "seDeplacer",
        showEncourageOptions: preset.key === "encourager",
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: preset.title,
      },
      content,
      buttons: [
        {
          action: "roll",
          label: game.i18n.localize("LESOUBLIES.rolls.roll"),
          default: true,
          callback: async (_event, _button, dialog) => {
            const form = this.#getDialogElement(dialog)?.querySelector("form")
            if (!form) return null
            const data = this.#formToObject(form)
            return {
              actionType: preset.key,
              rollMode: String(data.rollMode || this.getDefaultRollMode(actor)),
              extraDie: String(data.extraDie || ""),
              difficulty: Number(data.difficultyPreset ?? 0) + Number(data.customDifficulty ?? 0),
              primeId: String(data.primeId || "none"),
              penaltyId: String(data.penaltyId || "none"),
              notes: String(data.notes || ""),
              targetLabel: String(data.targetLabel || ""),
              outcomeChoice: String(data.outcomeChoice || ""),
            }
          },
        },
        {
          action: "cancel",
          label: game.i18n.localize("Cancel"),
        },
      ],
    })
  }

  static async #promptPresetConfrontationOptions(actor, preset, targetActor = null) {
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-roll-preset-confrontation.hbs",
      {
        actor,
        title: preset.title,
        hint: preset.hint,
        targetActor,
        targetStatus: this.#getConfrontationTargetStatus(targetActor),
        defenderSkillLabel: this.#getSkillLabel(preset.defenderSkillKey),
        targetOptions: this.#getConfrontationTargetOptions(actor, targetActor),
        rollModes: this.getRollModes(),
        extraDieModes: this.getExtraDieModes(),
        attackerResources: this.#getDialogResources(actor),
        defenderResources: targetActor ? this.#getDialogResources(targetActor) : {
          songesValue: 0,
          songesPoints: 0,
          cauchemarValue: 0,
          cauchemarPoints: 0,
        },
        primeOptions: this.#getModifierOptions("prime", preset.key),
        penaltyOptions: this.#getModifierOptions("penalty", preset.key),
        values: {
          attackerDifficulty: Number(preset.difficulty ?? 0),
          defenderLabel: targetActor?.name ?? game.i18n.localize("LESOUBLIES.rolls.defender"),
          defenderActorId: targetActor?.id ?? "",
          defenderDifficulty: 0,
          attackerRollMode: this.getDefaultRollMode(actor),
          attackerExtraDie: "",
          defenderRollMode: this.getDefaultRollMode(targetActor ?? actor),
          defenderExtraDie: "",
          defenderScore: targetActor
            ? this.#getSkillScoreWithAlternatives(targetActor, preset.defenderSkillKey)
            : 0,
          primeId: "none",
          penaltyId: "none",
          outcomeChoice: "",
          targetLabel: "",
          notes: "",
        },
        showIntimidateOptions: preset.key === "intimider",
        showEvaluateOptions: preset.key === "evaluer",
        showGrappleOptions: preset.key === "maitriser",
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: preset.title,
      },
      content,
      render: (_event, dialog) => {
        this.#bindConfrontationTargetSelection(dialog, {
          actor,
          fallbackTargetActor: targetActor,
          skillKey: preset.defenderSkillKey,
        })
      },
      buttons: [
        {
          action: "roll",
          label: game.i18n.localize("LESOUBLIES.rolls.roll"),
          default: true,
          callback: async (_event, _button, dialog) => {
            const form = this.#getDialogElement(dialog)?.querySelector("form")
            if (!form) return null
            const data = this.#formToObject(form)
            return {
              attackerDifficulty: Number(data.attackerDifficulty ?? preset.difficulty ?? 0),
              defenderActorId: String(data.defenderActorId || ""),
              defenderLabel: String(data.defenderLabel || targetActor?.name || game.i18n.localize("LESOUBLIES.rolls.defender")).trim(),
              defenderDifficulty: Number(data.defenderDifficulty ?? 0),
              attackerRollMode: String(data.attackerRollMode || this.getDefaultRollMode(actor)),
              attackerExtraDie: String(data.attackerExtraDie || ""),
              defenderRollMode: String(data.defenderRollMode || this.getDefaultRollMode(targetActor ?? actor)),
              defenderExtraDie: String(data.defenderExtraDie || ""),
              defenderScore: Number(data.defenderScore ?? 0),
              defenderSongesValue: targetActor ? Number(targetActor.system.songes?.value ?? 0) : Number(data.defenderSongesValue ?? 0),
              defenderSongesPoints: targetActor ? Number(targetActor.system.songes?.points ?? 0) : Number(data.defenderSongesPoints ?? 0),
              defenderCauchemarValue: targetActor ? Number(targetActor.system.cauchemar?.value ?? 0) : Number(data.defenderCauchemarValue ?? 0),
              defenderCauchemarPoints: targetActor ? Number(targetActor.system.cauchemar?.points ?? 0) : Number(data.defenderCauchemarPoints ?? 0),
              primeId: String(data.primeId || "none"),
              penaltyId: String(data.penaltyId || "none"),
              outcomeChoice: String(data.outcomeChoice || ""),
              targetLabel: String(data.targetLabel || ""),
              notes: String(data.notes || ""),
            }
          },
        },
        {
          action: "cancel",
          label: game.i18n.localize("Cancel"),
        },
      ],
    })
  }

  static async #promptThreadHarvestOptions(actor) {
    const threadReserves = this.#getThreadDialogState(actor)
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-thread-harvest-v2.hbs",
      {
        actor,
        rollModes: this.getRollModes(),
        extraDieModes: this.getExtraDieModes(),
        resources: this.#getDialogResources(actor),
        threadReserves,
        values: {
          threadType: "songes",
          threadCount: 1,
          destinationSource: "actor",
          rollMode: this.getDefaultRollMode(actor),
          extraDie: "",
          sleeperLabel: "",
          notes: "",
        },
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: "Récolte de fils",
      },
      content,
      buttons: [
        {
          action: "roll",
          label: game.i18n.localize("LESOUBLIES.rolls.roll"),
          default: true,
          callback: async (_event, _button, dialog) => {
            const form = this.#getDialogElement(dialog)?.querySelector("form")
            if (!form) return null
            const data = this.#formToObject(form)
            return {
              threadType: String(data.threadType || "songes"),
              threadCount: Number(data.threadCount ?? 1),
              destinationSource: String(data.destinationSource || "actor"),
              rollMode: String(data.rollMode || this.getDefaultRollMode(actor)),
              extraDie: String(data.extraDie || ""),
              sleeperLabel: String(data.sleeperLabel || ""),
              notes: String(data.notes || ""),
            }
          },
        },
        {
          action: "cancel",
          label: game.i18n.localize("Cancel"),
        },
      ],
    })
  }

  static async #promptDieSelection(actor, label, dice) {
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-les-oublies/templates/dialog-roll-choice.hbs",
      {
        actor: this.#getRollContext(actor),
        label,
        dice: dice.map((die) => ({
          ...die,
          debtLabel: this.#computeDebt(this.#getRollContext(actor), dice, die).label,
        })),
      },
    )

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: game.i18n.localize("LESOUBLIES.rolls.dialogs.choiceTitle"),
      },
      content,
      buttons: dice.map((die) => ({
        action: `select-${die.index}`,
        label: `${die.typeLabel} ${die.total}`,
        callback: async () => die.index,
      })).concat({
        action: "cancel",
        label: game.i18n.localize("Cancel"),
      }),
    })
  }

  static #buildPool(rollMode, extraDie) {
    const dice = []
    switch (rollMode) {
      case "songes":
        dice.push({ type: "songes" }, { type: "songes" })
        break
      case "cauchemar":
        dice.push({ type: "cauchemar" }, { type: "cauchemar" })
        break
      case "single":
        dice.push({ type: "neutral" })
        break
      case "dual":
      default:
        dice.push({ type: "songes" }, { type: "cauchemar" })
        break
    }

    if (extraDie && rollMode !== "single") {
      dice.push({ type: extraDie, source: "extra" })
    }
    return dice
  }

  static async #rollExplodingPool(pool) {
    const dice = pool.map(({ type, source = "base" }, index) => ({
      index,
      type,
      typeLabel: game.i18n.localize(`LESOUBLIES.rolls.dice.${type}`),
      source,
      sourceLabel: source === "extra" ? game.i18n.localize("LESOUBLIES.rolls.extraDie") : null,
      faces: [],
      rolls: [],
      total: 0,
    }))

    let pendingDice = [...dice]
    while (pendingDice.length) {
      const roll = await this.#evaluateDisplayedRoll(
        Array.from({ length: pendingDice.length }, () => "1d12").join(" + "),
        (pendingRoll) => this.#applyDieAppearances(pendingRoll, pendingDice),
      )

      const dieTerms = roll.terms.filter((term) => Number(term.faces ?? 0) === 12)
      const nextPendingDice = []

      pendingDice.forEach((die, index) => {
        const face = Number(dieTerms[index]?.results?.[0]?.result ?? 0)
        die.rolls.push(roll)
        die.faces.push(face)
        die.total += face
        if (face === 12) nextPendingDice.push(die)
      })

      pendingDice = nextPendingDice
    }

    return dice.map((die) => ({
      ...die,
      firstFace: die.faces[0] ?? 0,
      exploded: die.faces.length > 1,
      breakdown: die.faces.join(" + "),
    }))
  }

  static #applyDieAppearances(roll, dice) {
    const dieTerms = roll.terms.filter((term) => Number(term.faces ?? 0) === 12)
    dieTerms.forEach((term, index) => {
      term.options ??= {}
      term.options.appearance = this.#getDieAppearance(dice[index]?.type)
    })
  }

  static #getDieAppearance(type) {
    switch (type) {
      case "songes":
        return {
          foreground: "#111111",
          background: "#f3efe4",
          outline: "#b8aa87",
        }
      case "cauchemar":
        return {
          foreground: "#f4f0e8",
          background: "#111111",
          outline: "#5d5d5d",
        }
      default:
        return {
          foreground: "#201813",
          background: "#ddd0b0",
          outline: "#8d5c3b",
        }
    }
  }

  static async #evaluateDisplayedRoll(formula, configureRoll = null) {
    const roll = typeof formula === "string" ? new Roll(formula) : formula
    if (configureRoll) configureRoll(roll)
    await roll.evaluate()
    await this.#showDiceSoNice(roll)
    return roll
  }

  static async #showDiceSoNice(roll) {
    if (!game.modules.get("dice-so-nice")?.active) return
    if (!game.dice3d?.showForRoll) return

    const coreRollMode = game.settings.get("core", "rollMode")
    let whisper = null
    let blind = false

    switch (coreRollMode) {
      case "blindroll":
        blind = true
      case "gmroll":
        whisper = ChatMessage.getWhisperRecipients("GM").map((user) => user.id)
        break
      case "selfroll":
        whisper = [game.user.id]
        break
      case "publicroll":
      case "roll":
      default:
        whisper = null
        break
    }

    await game.dice3d.showForRoll(roll, game.user, true, whisper, blind)
  }

  static #needsSelection(dice) {
    return new Set(dice.map((die) => die.type)).size > 1
  }

  static #selectBestDie(dice) {
    return dice.reduce((bestIndex, die, index, collection) => (
      die.total > collection[bestIndex].total ? index : bestIndex
    ), 0)
  }

  static #computeDebt(actor, dice, selectedDie) {
    if (!actor?.system) {
      return {
        type: null,
        amount: 0,
        label: game.i18n.localize("LESOUBLIES.rolls.noDebt"),
      }
    }

    const rolledPolarities = new Set(
      dice
        .map((die) => die.type)
        .filter((type) => ["songes", "cauchemar"].includes(type)),
    )

    if (rolledPolarities.size < 2 || !["songes", "cauchemar"].includes(selectedDie.type)) {
      return {
        type: null,
        amount: 0,
        label: game.i18n.localize("LESOUBLIES.rolls.noDebt"),
      }
    }

    const songesValue = Number(actor.system.songes?.value ?? 0)
    const cauchemarValue = Number(actor.system.cauchemar?.value ?? 0)
    if (songesValue === cauchemarValue) {
      return {
        type: null,
        amount: 0,
        label: game.i18n.localize("LESOUBLIES.rolls.noDebt"),
      }
    }

    const dominant = songesValue > cauchemarValue ? "songes" : "cauchemar"
    if (selectedDie.type === dominant) {
      return {
        type: null,
        amount: 0,
        label: game.i18n.localize("LESOUBLIES.rolls.noDebt"),
      }
    }

    return {
      type: selectedDie.type,
      amount: 1,
      label: game.i18n.format("LESOUBLIES.rolls.debtGain", {
        type: game.i18n.localize(`LESOUBLIES.rolls.dice.${selectedDie.type}`),
      }),
    }
  }

  static #getDialogElement(dialog) {
    return dialog.element instanceof HTMLElement ? dialog.element : dialog.element?.[0] ?? null
  }

  static #formToObject(form) {
    return Object.fromEntries(new FormData(form).entries())
  }

  static #getRollContext(actor) {
    if (actor?.system) return actor
    return actor ?? this.#createVirtualRollContext()
  }

  static #createVirtualRollContext({
    label = game.i18n.localize("LESOUBLIES.rolls.defender"),
    songesValue = 0,
    songesPoints = 0,
    cauchemarValue = 0,
    cauchemarPoints = 0,
  } = {}) {
    return {
      name: label,
      system: {
        songes: {
          value: Number(songesValue ?? 0),
          points: Number(songesPoints ?? 0),
        },
        cauchemar: {
          value: Number(cauchemarValue ?? 0),
          points: Number(cauchemarPoints ?? 0),
        },
      },
    }
  }

  static #getDialogResources(actor) {
    const context = this.#getRollContext(actor)
    return {
      songesValue: Number(context.system.songes?.value ?? 0),
      songesPoints: Number(context.system.songes?.points ?? 0),
      cauchemarValue: Number(context.system.cauchemar?.value ?? 0),
      cauchemarPoints: Number(context.system.cauchemar?.points ?? 0),
      songesThreads: Number(context.system.reserves?.songesThreads ?? 0),
      cauchemarThreads: Number(context.system.reserves?.cauchemarThreads ?? 0),
      emptyGlobes: Number(context.system.reserves?.emptyGlobes ?? 0),
    }
  }

  static #normalizeThreadReserveSource(source) {
    return ["company", "compagnie"].includes(String(source || "").toLowerCase()) ? "company" : "actor"
  }

  static #getThreadReserveOwner(actor, source = "actor") {
    return this.#normalizeThreadReserveSource(source) === "company"
      ? actor?.getCompagnie?.() ?? null
      : actor
  }

  static #getThreadReserveLabel(actor, source = "actor") {
    const normalized = this.#normalizeThreadReserveSource(source)
    if (normalized === "actor") return "Réserve personnelle"
    const company = this.#getThreadReserveOwner(actor, normalized)
    return company ? `Réserve de compagnie — ${company.name}` : "Réserve de compagnie"
  }

  static #getThreadResourceKey(polarity) {
    return polarity === "cauchemar" ? "cauchemarThreads" : "songesThreads"
  }

  static #getThreadReserveState(actor, source = "actor") {
    const owner = this.#getThreadReserveOwner(actor, source)
    return {
      owner,
      source: this.#normalizeThreadReserveSource(source),
      label: this.#getThreadReserveLabel(actor, source),
      songesThreads: Math.max(Number(owner?.system?.reserves?.songesThreads ?? 0), 0),
      cauchemarThreads: Math.max(Number(owner?.system?.reserves?.cauchemarThreads ?? 0), 0),
      emptyGlobes: Math.max(Number(owner?.system?.reserves?.emptyGlobes ?? 0), 0),
    }
  }

  static #getThreadDialogState(actor) {
    const actorReserve = this.#getThreadReserveState(actor, "actor")
    const companyReserve = this.#getThreadReserveState(actor, "company")
    const options = [
      { value: "actor", label: actorReserve.label },
    ]
    if (companyReserve.owner) options.push({ value: "company", label: companyReserve.label })
    return {
      actor: actorReserve,
      company: companyReserve,
      options,
      hasCompany: Boolean(companyReserve.owner),
    }
  }

  static async #storeHarvestedThreads(actor, destinationSource, threadType, threadCount) {
    const reserve = this.#getThreadReserveState(actor, destinationSource)
    if (!reserve.owner || threadCount < 1) return false
    const resourceKey = this.#getThreadResourceKey(threadType)
    await reserve.owner.update({
      [`system.reserves.${resourceKey}`]: Number(reserve[resourceKey] ?? 0) + threadCount,
      "system.reserves.emptyGlobes": Math.max(Number(reserve.emptyGlobes ?? 0) - threadCount, 0),
    })
    return true
  }

  static #bindSpellPaymentSelection(dialog, { actor, spell, effectiveCost }) {
    const root = this.#getDialogElement(dialog)
    const form = root?.querySelector("form")
    if (!form) return

    const modeField = form.elements.namedItem("paymentMode")
    const sourceField = form.elements.namedItem("paymentSource")
    const effectiveCostField = root.querySelector("[data-effective-cost]")
    const sourceWrapper = root.querySelector("[data-payment-source]")
    const sourceHint = root.querySelector("[data-payment-source-hint]")

    const update = () => {
      const paymentMode = modeField instanceof HTMLSelectElement ? String(modeField.value || "points") : "points"
      const paymentSource = sourceField instanceof HTMLSelectElement ? String(sourceField.value || "actor") : "actor"
      const polarityLabel = spell.system.polarity === "cauchemar" ? "Cauchemar" : "Songes"
      if (effectiveCostField instanceof HTMLInputElement) {
        effectiveCostField.value = paymentMode === "points"
          ? `${effectiveCost} point${effectiveCost > 1 ? "s" : ""} de ${polarityLabel}`
          : `${effectiveCost} fil${effectiveCost > 1 ? "s" : ""} de ${polarityLabel}`
      }
      if (sourceWrapper instanceof HTMLElement) {
        sourceWrapper.hidden = paymentMode !== "fils"
      }
      if (sourceHint instanceof HTMLElement) {
        sourceHint.textContent = paymentMode === "fils"
          ? `${this.#getThreadReserveLabel(actor, paymentSource)} utilisée. Les globes vidés y retournent automatiquement.`
          : "La dépense se fait dans les points de Songes ou de Cauchemar du personnage."
      }
    }

    if (modeField instanceof HTMLSelectElement) modeField.addEventListener("change", update)
    if (sourceField instanceof HTMLSelectElement) sourceField.addEventListener("change", update)
    update()
  }

  static #createSpentResource(extraDie) {
    if (!extraDie) return null
    return {
      type: extraDie,
      amount: 1,
      label: game.i18n.localize(`LESOUBLIES.rolls.extraDice.${extraDie}`),
    }
  }

  static #canSpendResource(actor, resourceType) {
    return Number(actor?.system?.[resourceType]?.points ?? 0) >= 1
  }

  static async #spendResource(actor, spentResource) {
    if (!actor?.update || !spentResource) return
    const path = `system.${spentResource.type}.points`
    const current = Number(actor.system?.[spentResource.type]?.points ?? 0)
    await actor.update({ [path]: Math.max(current - spentResource.amount, 0) })
  }

  static #getWeaponAttackMode(weapon) {
    const category = String(weapon?.system?.category || "").toLowerCase()
    if (["distance", "ranged", "tir", "projectile", "jet"].some((keyword) => category.includes(keyword))) return "ranged"
    return "melee"
  }

  static #getAttackSkillOptions(mode) {
    if (mode === "ranged") {
      return [{ value: "tir", label: this.#getSkillLabel("tir") }]
    }

    return [
      { value: "melee", label: this.#getSkillLabel("melee") },
      { value: "corpsacorps", label: this.#getSkillLabel("corpsacorps") },
    ]
  }

  static #getAttackReactionOptions(attackerSkill) {
    if (attackerSkill === "tir") {
      return [
        { value: "esquive", label: "Esquive" },
        { value: "melee", label: "Mêlée (avec bouclier)" },
      ]
    }

    return [
      { value: "corpsacorps", label: "Corps à corps" },
      { value: "melee", label: "Mêlée" },
      { value: "esquive", label: "Esquive" },
    ]
  }

  static #getConfrontationTargetOptions(actor, selectedActor = null) {
    const choices = LesOubliesUtility.sortByName(
      game.actors.filter((candidate) => (
        ["creature", "personnage"].includes(candidate.type)
        && candidate.id !== actor?.id
      )),
    ).map((candidate) => ({
      value: candidate.id,
      label: `${candidate.name} — ${game.i18n.localize(`TYPES.Actor.${candidate.type}`)}`,
    }))

    return [
      { value: "", label: "Saisie manuelle" },
      ...LesOubliesUtility.ensureChoice(
        choices,
        selectedActor?.id,
        selectedActor ? `${selectedActor.name} — ${game.i18n.localize(`TYPES.Actor.${selectedActor.type}`)}` : null,
      ),
    ]
  }

  static #getConfrontationSkillOptions() {
    const skills = CONFIG.LESOUBLIES?.config?.skills ?? CONFIG.LESOUBLIES?.skills ?? {}
    return Object.entries(skills)
      .map(([value, data]) => ({
        value,
        label: data.label ?? value,
      }))
      .sort((left, right) => left.label.localeCompare(right.label, "fr"))
  }

  static #resolveDialogTargetActor(actorId, fallbackTargetActor = null) {
    if (actorId !== undefined && actorId !== null && actorId !== "") {
      return game.actors.get(String(actorId)) ?? null
    }
    if (actorId === "") return null
    return fallbackTargetActor ?? null
  }

  static #getConfrontationTargetStatus(targetActor = null, { requireTarget = false } = {}) {
    if (!targetActor) {
      return {
        message: requireTarget
          ? "Aucune cible n'est actuellement sélectionnée. Sélectionnez un adversaire dans la liste ci-dessous pour lancer l'attaque."
          : "Aucune cible n'est actuellement sélectionnée. Choisissez un adversaire dans la liste ci-dessous ou conservez la saisie manuelle.",
        state: "empty",
      }
    }

    return {
      message: `Adversaire sélectionné : ${targetActor.name}. Ses valeurs de confrontation sont utilisées automatiquement.`,
      state: "selected",
    }
  }

  static #bindConfrontationTargetSelection(dialog, {
    actor,
    fallbackTargetActor = null,
    skillFieldName = null,
    skillKey = null,
    requireTarget = false,
  } = {}) {
    const root = this.#getDialogElement(dialog)
    const form = root?.querySelector("form")
    if (!form) return

    const actorField = form.elements.namedItem("defenderActorId")
    if (!(actorField instanceof HTMLSelectElement)) return

    const labelField = form.elements.namedItem("defenderLabel")
    const scoreField = form.elements.namedItem("defenderScore")
    const rollModeField = form.elements.namedItem("defenderRollMode")
    const songesValueField = form.elements.namedItem("defenderSongesValue")
    const songesPointsField = form.elements.namedItem("defenderSongesPoints")
    const cauchemarValueField = form.elements.namedItem("defenderCauchemarValue")
    const cauchemarPointsField = form.elements.namedItem("defenderCauchemarPoints")
    const skillField = skillFieldName ? form.elements.namedItem(skillFieldName) : null
    const targetStatusField = root.querySelector("[data-target-status]")

    const defaultLabel = game.i18n.localize("LESOUBLIES.rolls.defender")
    const getSelectedSkill = () => {
      if (skillKey) return skillKey
      if (skillField instanceof HTMLSelectElement) return String(skillField.value || "melee")
      return "melee"
    }

    const updateTargetFields = ({ preserveRollMode = false } = {}) => {
      const targetActor = this.#resolveDialogTargetActor(actorField.value, fallbackTargetActor)
      const hasActor = Boolean(targetActor)
      const currentSkill = getSelectedSkill()

      if (targetStatusField instanceof HTMLElement) {
        const targetStatus = this.#getConfrontationTargetStatus(targetActor, { requireTarget })
        targetStatusField.textContent = targetStatus.message
        targetStatusField.dataset.state = targetStatus.state
      }

      if (labelField instanceof HTMLInputElement) {
        labelField.value = hasActor ? targetActor.name : (labelField.value || defaultLabel)
        labelField.readOnly = hasActor
      }

      if (scoreField instanceof HTMLInputElement) {
        if (hasActor) {
          scoreField.value = String(this.#getSkillScoreWithAlternatives(targetActor, currentSkill))
        }
        scoreField.readOnly = hasActor
      }

      if (rollModeField instanceof HTMLSelectElement && hasActor && !preserveRollMode) {
        rollModeField.value = this.getDefaultRollMode(targetActor)
      }

      const resourceValues = hasActor
        ? {
            songesValue: Number(targetActor.system.songes?.value ?? 0),
            songesPoints: Number(targetActor.system.songes?.points ?? 0),
            cauchemarValue: Number(targetActor.system.cauchemar?.value ?? 0),
            cauchemarPoints: Number(targetActor.system.cauchemar?.points ?? 0),
          }
        : null

      const bindNumericField = (field, value) => {
        if (!(field instanceof HTMLInputElement)) return
        if (resourceValues) field.value = String(value)
        field.readOnly = hasActor
      }

      bindNumericField(songesValueField, resourceValues?.songesValue ?? 0)
      bindNumericField(songesPointsField, resourceValues?.songesPoints ?? 0)
      bindNumericField(cauchemarValueField, resourceValues?.cauchemarValue ?? 0)
      bindNumericField(cauchemarPointsField, resourceValues?.cauchemarPoints ?? 0)
    }

    actorField.addEventListener("change", () => updateTargetFields())
    if (skillField instanceof HTMLSelectElement) {
      skillField.addEventListener("change", () => updateTargetFields({ preserveRollMode: true }))
    }
    updateTargetFields()
  }

  static async #withActorLock(lockKey, callback) {
    const previous = this.#actorLocks.get(lockKey) ?? Promise.resolve()
    let release
    const current = new Promise((resolve) => {
      release = resolve
    })
    const queued = previous.finally(() => current)
    this.#actorLocks.set(lockKey, queued)
    await previous
    try {
      return await callback()
    } finally {
      release()
      if (this.#actorLocks.get(lockKey) === queued) {
        this.#actorLocks.delete(lockKey)
      }
    }
  }

  static #getModifierOptions(type, actionType) {
    const source = type === "prime" ? PRIME_DEFINITIONS : PENALTY_DEFINITIONS
    return source
      .filter((modifier) => modifier.actionTypes.includes("all") || modifier.actionTypes.includes(actionType))
      .map((modifier) => ({
        value: modifier.id,
        label: modifier.label,
      }))
  }

  static #resolveModifierSelection(primeId = "none", penaltyId = "none", actionType = "all") {
    const selectedPrime = PRIME_DEFINITIONS.find((modifier) => modifier.id === primeId && (modifier.actionTypes.includes("all") || modifier.actionTypes.includes(actionType)))
      ?? PRIME_DEFINITIONS[0]
    const selectedPenalty = PENALTY_DEFINITIONS.find((modifier) => modifier.id === penaltyId && (modifier.actionTypes.includes("all") || modifier.actionTypes.includes(actionType)))
      ?? PENALTY_DEFINITIONS[0]

    const modifiers = [selectedPrime, selectedPenalty]
    const summary = modifiers.reduce((accumulator, modifier) => {
      const effects = modifier.effects ?? {}
      accumulator.finalModifier += Number(effects.finalModifier ?? 0)
      accumulator.opponentFinalModifier += Number(effects.opponentFinalModifier ?? 0)
      accumulator.initiativeDelta += Number(effects.initiativeDelta ?? 0)
      accumulator.damageMultiplier *= Number(effects.damageMultiplier ?? 1)
      if (effects.armorDivisor) {
        accumulator.armorDivisor = accumulator.armorDivisor
          ? Math.max(accumulator.armorDivisor, effects.armorDivisor)
          : effects.armorDivisor
      }
      accumulator.nonLethal = accumulator.nonLethal || Boolean(effects.nonLethal)
      accumulator.targetsMultiple = accumulator.targetsMultiple || Boolean(effects.targetsMultiple)
      accumulator.riskIncident = accumulator.riskIncident || Boolean(effects.riskIncident)
      if (effects.note) accumulator.notes.push(effects.note)
      if (effects.nextReactionModifier) accumulator.nextReactionModifier += Number(effects.nextReactionModifier)
      return accumulator
    }, {
      finalModifier: 0,
      opponentFinalModifier: 0,
      initiativeDelta: 0,
      damageMultiplier: 1,
      armorDivisor: null,
      nonLethal: false,
      targetsMultiple: false,
      riskIncident: false,
      nextReactionModifier: 0,
      notes: [],
    })

    return {
      prime: selectedPrime.id === "none" ? null : selectedPrime,
      penalty: selectedPenalty.id === "none" ? null : selectedPenalty,
      summary,
      labels: [selectedPrime, selectedPenalty]
        .filter((modifier) => modifier.id !== "none")
        .map((modifier) => modifier.label),
    }
  }

  static #getSkillLabel(skillKey) {
    return CONFIG.LESOUBLIES?.config?.skills?.[skillKey]?.label
      ?? CONFIG.LESOUBLIES?.skills?.[skillKey]?.label
      ?? skillKey
  }

  static #getSkillScoreWithAlternatives(actor, primaryKey, alternativeKeys = []) {
    if (!actor?.getSkillScoreByKey) return 0
    const keys = [primaryKey, ...(alternativeKeys ?? [])].filter(Boolean)
    for (const key of keys) {
      const value = Number(actor.getSkillScoreByKey(key) ?? 0)
      if (value > 0) return value
    }
    return Number(actor.getSkillScoreByKey(primaryKey) ?? 0)
  }

  static #getWeaponBaseDamage(actor, weapon) {
    return LesOubliesUtility.getWeaponBaseDamage(actor, weapon)
  }

  static #extractFirstInteger(text) {
    const match = String(text || "").match(/-?\d+/)
    return match ? Number(match[0]) : null
  }

  static #getActorProtection(actor) {
    if (!actor) return 0
    const armors = actor.itemTypes?.armure ?? actor.items?.filter?.((item) => item.type === "armure") ?? []
    const equippedArmorProtection = armors
      .filter((item) => item.system?.equipped)
      .reduce((total, item) => total + Number(item.system?.protection ?? 0), 0)
    return Math.max(equippedArmorProtection, Number(actor.system?.protection ?? 0), 0)
  }

  static #computeDamageResolution({
    actor,
    weapon = null,
    baseDamage = 0,
    baseLabel = "",
    targetProtection = 0,
    targetLabel = "",
    targetActor = null,
    applyToTarget = false,
    modifiers,
  }) {
    const summary = modifiers?.summary ?? {}
    const adjustedBase = Math.max(Math.ceil(Number(baseDamage ?? 0) * Number(summary.damageMultiplier ?? 1)), 0)
    const effectiveProtection = summary.armorDivisor
      ? Math.ceil(Number(targetProtection ?? 0) / Number(summary.armorDivisor))
      : Number(targetProtection ?? 0)
    const finalDamage = Math.max(adjustedBase - effectiveProtection, 0)
    const applyResult = applyToTarget && targetActor ? { actor: targetActor, damage: finalDamage } : null

    return {
      weaponName: weapon?.name ?? "Attaque",
      baseDamage: Number(baseDamage ?? 0),
      baseLabel,
      adjustedBase,
      targetProtection: Number(targetProtection ?? 0),
      effectiveProtection,
      finalDamage,
      nonLethal: Boolean(summary.nonLethal),
      targetsMultiple: Boolean(summary.targetsMultiple),
      applyResult,
      targetLabel,
    }
  }

  static async #applyDamageToActor(actor, damage) {
    if (!actor?.update || !Number.isFinite(damage) || damage <= 0) return
    const currentHp = Number(actor.system?.hp?.value ?? 0)
    await actor.update({
      "system.hp.value": Math.max(currentHp - damage, 0),
    })
  }

  static #formatDifficultyBreakdown(presetValue, customValue) {
    const parts = []
    if (presetValue) parts.push(`${presetValue > 0 ? "+" : ""}${presetValue}`)
    if (customValue) parts.push(`${customValue > 0 ? "+" : ""}${customValue}`)
    return parts.length ? parts.join(" ") : "0"
  }

  static #getDifficultyOptions(options, selectedValue = 0) {
    const normalizedValue = Number(selectedValue ?? 0)
    const entries = options.map((entry) => ({
      value: Number(entry.value ?? 0),
      label: entry.label,
    }))
    if (!entries.some((entry) => entry.value === normalizedValue)) {
      entries.push({
        value: normalizedValue,
        label: `Personnalisée (${normalizedValue > 0 ? "+" : ""}${normalizedValue})`,
      })
    }
    return entries
  }

  static #getConfrontationOutcome(attacker, defender) {
    const attackerSuccess = attacker.success
    const defenderSuccess = defender.success

    if (attackerSuccess && !defenderSuccess) return "attacker-success"
    if (!attackerSuccess && defenderSuccess) return "defender-success"
    if (attacker.final > defender.final) return "attacker-advantage"
    if (defender.final > attacker.final) return "defender-advantage"
    return "tie"
  }

  static #buildPresetOutcome(actionKey, data) {
    switch (actionKey) {
      case "encourager":
        return {
          label: "Effet choisi",
          description: {
            initiative: "+4 à l'initiative à la fin du tour de l'allié.",
            action: "+3 au résultat final de sa prochaine action.",
            reaction: "+3 au résultat final de sa prochaine réaction.",
          }[data.outcomeChoice] ?? "Effet à préciser en jeu.",
        }
      case "intimider":
        return {
          label: "Effet choisi",
          description: {
            initiative: "-4 à l'initiative à la fin du tour de l'adversaire.",
            action: "-3 au résultat final de sa prochaine action.",
            reaction: "-3 au résultat final de sa prochaine réaction.",
          }[data.outcomeChoice] ?? "Effet à préciser en jeu.",
        }
      case "evaluer":
        return {
          label: "Paramètre observé",
          description: data.targetLabel?.trim() || "Paramètre non précisé.",
        }
      case "maitriser":
        return {
          label: "Suite envisagée",
          description: {
            silence: "Réduire au silence (gratuite, automatique).",
            otage: "Prendre en otage (gratuite, automatique ; couverture 4).",
            conforter: "Conforter sa prise (+3 au résultat initial, une fois).",
            etouffer: "Étouffer (libre, automatique ; 2 PV, peut être non létal).",
            attacher: "Attacher l'otage (libre, automatique).",
          }[data.outcomeChoice] ?? "Aucune suite immédiate déclarée.",
        }
      case "seDeplacer":
        return {
          label: "Destination",
          description: data.targetLabel?.trim() || "Destination non précisée.",
        }
      default:
        return null
    }
  }

  static #getTargetActor() {
    const target = Array.from(game.user?.targets ?? []).find((token) => token?.actor)
    return target?.actor ?? null
  }

  static #actorMatchesSpellGrant(actor, spell) {
    const métier = actor?.getCreationItem?.("metier") ?? null
    const grants = métier?.system?.spellGrants ?? []
    return grants.some((grant) => (
      String(grant.tradition || "").trim().toLowerCase() === String(spell.system.tradition || "").trim().toLowerCase()
      && String(grant.polarity || "").trim().toLowerCase() === String(spell.system.polarity || "").trim().toLowerCase()
      && String(grant.skillKey || "").trim().toLowerCase() === String(spell.system.skillKey || "").trim().toLowerCase()
    ))
  }
}
