/**
 * Moteur de résolution des échanges de coups.
 *
 * Gère l'attaque (contact / distance), la défense (esquive / parade),
 * le calcul des dégâts, la déduction des protections et l'application
 * des blessures pour les personnages, PNJ et créatures.
 *
 * Le flux est assisté : les cartes de chat calculent et affichent tout,
 * un bouton "Appliquer" coche la blessure sur l'acteur cible.
 *
 * Liens entre messages (flags "world") :
 * - "vermine-exchange" sur le message d'attaque
 * - "vermine-defense" sur le message de défense
 */

const FLAG_EXCHANGE = 'vermine-exchange'
const FLAG_DEFENSE = 'vermine-defense'

export class VermineExchange {

  // ── Tables de difficulté ────────────────────────────────────────────

  /**
   * Capacité d'attaque selon le mode.
   * @param {string} mode 'contact' ou 'ranged'
   * @returns {string} clé de capacité (vigor / precision)
   */
  static attackAbility(mode) {
    return mode === 'ranged' ? 'precision' : 'vigor'
  }

  /**
   * Difficultés d'attaque au contact selon le niveau de compétence (0-5).
   * Incompétent/Débutant = 7, Confirmé = 5/7, Expert = 5/7/9,
   * Maître/Légende = 3/5/7/9.
   * @param {number} skillLevel valeur de compétence (0-5)
   * @returns {number[]}
   */
  static contactDifficultyOptions(skillLevel) {
    switch (parseInt(skillLevel, 10) || 0) {
      case 2: return [5, 7]
      case 3: return [5, 7, 9]
      case 4:
      case 5: return [3, 5, 7, 9]
      default: return [7]
    }
  }

  /**
   * Difficultés d'attaque à distance par portée.
   * @returns {{key: string, label: string, difficulty: number}[]}
   */
  static rangeDifficultyOptions() {
    return [
      { key: 'short', label: 'VERMINE.range_short', difficulty: 5 },
      { key: 'mid', label: 'VERMINE.range_mid', difficulty: 7 },
      { key: 'long', label: 'VERMINE.range_long', difficulty: 9 }
    ]
  }

  /**
   * Difficultés proposées au PNJ selon son niveau d'expérience
   * (champ "contact" : "7", "5 ou 7", "5,7 ou 9", "3,5,7 ou 9").
   * @param {Actor} actor
   * @returns {number[]}
   */
  static npcDifficultyOptions(actor) {
    const level = actor.system?.experience?.value || 1
    const cfg = CONFIG.VERMINE.npcExperienceLevels?.[level] || {}
    const raw = cfg.contact || '7'
    return raw.split(',').flatMap(part => part.trim().split(/\s+ou\s+/).map(Number))
  }

  /**
   * Mode d'attaque déduit de la compétence de l'arme.
   * @param {Item|null} weapon
   * @returns {string} 'contact' ou 'ranged'
   */
  static modeForWeapon(weapon) {
    const skill = weapon?.system?.skill || ''
    return ['firearms', 'archery', 'throwing'].includes(skill) ? 'ranged' : 'contact'
  }

  // ── Pool et dégâts ──────────────────────────────────────────────────

  /**
   * Pool d'attaque de base (sans les bonus optionnels du dialogue).
   * Personnage : caractéristique + pool de compétence.
   * PNJ : pool d'attaque du niveau de menace.
   * Créature : pool d'attaque calculé.
   * @param {Actor} actor L'attaquant
   * @param {Item|null} weapon
   * @param {string} mode
   * @returns {number}
   */
  static baseAttackPool(actor, weapon, mode) {
    const type = actor.type
    if (type === 'creature') {
      return actor.system?.computed?.attack || 0
    }
    if (type === 'npc') {
      const threat = actor.system?.threat?.value || 1
      return CONFIG.VERMINE.npcThreatLevels?.[threat]?.attack || 0
    }
    const abilityKey = this.attackAbility(mode)
    const ability = actor.system?.abilities?.[abilityKey]?.value || 0
    const skillKey = weapon?.system?.skill
    const skillValue = skillKey ? actor.system?.skills?.[skillKey]?.value || 0 : 0
    const skillPool = skillValue ? CONFIG.VERMINE.SkillLevels?.[skillValue]?.dicePool || 0 : 0
    return ability + skillPool
  }

  /**
   * Dégâts de base de l'arme (hors réussites de l'attaque).
   * Le trait "Vigueur+" ajoute la vigueur de l'attaquant.
   * PNJ sans arme et créatures utilisent leurs dégâts propres. Une créature
   * équipée d'une arme (morsure, griffes…) utilise les dégâts de celle-ci.
   * @param {Actor} actor L'attaquant
   * @param {Item|null} weapon
   * @returns {number}
   */
  static baseDamage(actor, weapon) {
    const type = actor.type
    if (type === 'creature') {
      if (weapon?.system?.damage) {
        let base = weapon.system.damage.value || 0
        if (weapon.system.damage.addVigor) base += actor.system?.computed?.vigor || 0
        return base
      }
      return actor.system?.computed?.damage || 0
    }
    if (weapon?.system?.damage) {
      let base = weapon.system.damage.value || 0
      if (weapon.system.damage.addVigor) base += this.#vigor(actor)
      return base
    }
    return this.#vigor(actor)
  }

  static #vigor(actor) {
    if (actor.type === 'npc') {
      const threat = actor.system?.threat?.value || 1
      return CONFIG.VERMINE.npcThreatLevels?.[threat]?.vigor || 0
    }
    if (actor.type === 'creature') return actor.system?.computed?.vigor || 0
    return actor.system?.abilities?.vigor?.value || 0
  }

  // ── Protection et blessures ─────────────────────────────────────────

  /**
   * Protection du défenseur.
   * Parade : seule la protection utilisée compte.
   * Sinon : somme des protections équipées (créature : protection de rôle).
   * @param {Actor} defender
   * @param {number|null} parryLevel Protection utilisée lors d'une parade
   * @returns {number}
   */
  static protectionTotal(defender, parryLevel = null) {
    if (parryLevel !== null) return Math.max(0, parryLevel)
    if (defender.type === 'creature') {
      return defender.system?.computed?.protection || 0
    }
    const defenses = defender.itemTypes?.defense?.filter(d => d.system.equipped) || []
    return defenses.reduce((sum, d) => sum + (d.system.level || 0), 0)
  }

  /**
   * Catégorie de blessure : le seuil le plus élevé inférieur ou égal
   * aux dégâts nets subis.
   * @param {number} netDamage
   * @param {Actor} defender
   * @returns {string|null} 'minorWound', 'majorWound', 'deadlyWound' ou null
   */
  static highestWound(netDamage, defender) {
    const sys = defender.system
    const deadly = sys?.deadlyWound?.threshold ?? Number.POSITIVE_INFINITY
    const major = sys?.majorWound?.threshold ?? Number.POSITIVE_INFINITY
    const minor = sys?.minorWound?.threshold ?? Number.POSITIVE_INFINITY
    if (netDamage >= deadly) return 'deadlyWound'
    if (netDamage >= major) return 'majorWound'
    if (netDamage >= minor) return 'minorWound'
    return null
  }

  /**
   * Clé i18n du libellé d'une catégorie de blessure.
   * @param {string|null} category
   * @returns {string}
   */
  static woundLabelKey(category) {
    if (category === 'deadlyWound') return 'VERMINE.wounds.deadly_wounds'
    if (category === 'majorWound') return 'VERMINE.wounds.heavy_wounds'
    if (category === 'minorWound') return 'VERMINE.wounds.light_wounds'
    return 'VERMINE.no_wound'
  }

  /**
   * Vérifie que l'utilisateur peut appliquer une blessure sur l'acteur.
   * @param {Actor} actor
   * @returns {boolean}
   */
  static canApplyWound(actor) {
    return Boolean(actor) && (game.user.isGM || actor.isOwner)
  }

  /** Ordre de gravité des blessures (croissant). */
  static WOUND_ORDER = ['minorWound', 'majorWound', 'deadlyWound']

  /** Malus de la pire blessure (règles p.42) : -1D Légère, -2D Grave, -3D Mortelle. */
  static WOUND_MALUS = { minorWound: 1, majorWound: 2, deadlyWound: 3 }

  /**
   * Malus de blessure applicable à toutes les actions : seul le Malus de la
   * Blessure la plus grave compte (non cumulatif).
   * @param {Actor} actor
   * @returns {number} 0, 1, 2 ou 3
   */
  static woundMalus(actor) {
    if (!actor?.system) return 0
    if (actor.system.deadlyWound?.value > 0) return 3
    if (actor.system.majorWound?.value > 0) return 2
    if (actor.system.minorWound?.value > 0) return 1
    return 0
  }

  /**
   * Applique +1 cercle de blessure, en montant en gravité si les cercles du
   * niveau correspondant sont déjà pleins (règles p.42 : si tous les cercles
   * d'une Blessure sont cochés, la suivante est de gravité supérieure).
   * @param {Actor} actor
   * @param {string} woundCategory 'minorWound' | 'majorWound' | 'deadlyWound'
   * @returns {Promise<Object|boolean|null>}
   *   - false si refusé (permissions) ou null si invalide
   *   - { category, value, escalated, death } si appliqué (death si tout est plein)
   */
  static async applyWound(actor, woundCategory) {
    if (!actor || !woundCategory) return null
    if (!this.canApplyWound(actor)) return false
    const startIdx = this.WOUND_ORDER.indexOf(woundCategory)
    if (startIdx === -1) return null
    for (let i = startIdx; i < this.WOUND_ORDER.length; i++) {
      const cat = this.WOUND_ORDER[i]
      const sys = actor.system?.[cat]
      const max = sys?.max ?? Number.POSITIVE_INFINITY
      const value = sys?.value || 0
      if (value < max) {
        await actor.update({ [`system.${cat}.value`]: value + 1 })
        return { category: cat, value: value + 1, escalated: cat !== woundCategory, death: false }
      }
    }
    return { category: woundCategory, value: null, escalated: false, death: true }
  }

  // ── Résolution ──────────────────────────────────────────────────────

  /**
   * Résout l'échange pour une cible donnée (défense la plus récente liée
   * à cette cible, ou abstention si aucune défense).
   * @param {ChatMessage} attackMessage
   * @param {Object|string|null} targetRef cible de l'attaque (ou son uuid),
   *   défaut : première cible — le matching se fait par uuid d'abord car des
   *   PNJ mineurs (tokens non liés) issus de la même fiche partagent le même id
   * @returns {Promise<Object|null>} résultat de la résolution
   */
  static async resolve(attackMessage, targetRef = null) {
    const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
    if (!exchange) return null

    const target = this.#matchTarget(exchange.targets || [], targetRef)
    if (!target) return null

    const defs = this.#defenseMessagesFor(exchange.id, target)
    const defenderMessage = defs[defs.length - 1]
    const defense = defenderMessage ? defenderMessage.getFlag('world', FLAG_DEFENSE) : null

    const attackerSuccesses = this.#messageTotal(attackMessage)
    const defenderSuccesses = defense ? this.#messageTotal(defenderMessage) : 0
    const hit = attackerSuccesses > defenderSuccesses

    // Le flag de défense porte actorId/actorUuid (clés différentes de celles
    // des cibles) : on normalise la référence avant résolution de l'acteur.
    const defenderRef = defense
      ? { id: defense.actorId ?? null, uuid: defense.actorUuid ?? null, name: defense.actorName ?? '' }
      : target
    const defender = await this.resolveDefender(defenderRef)

    const damage = (exchange.baseDamage || 0) + attackerSuccesses
    const parryLevel = defense?.type === 'parade' ? (defense.parryLevel ?? 0) : null
    const protection = hit && parryLevel !== null
      ? parryLevel
      : (defender ? this.protectionTotal(defender, hit ? null : parryLevel) : 0)
    const netDamage = hit ? Math.max(0, damage - protection) : 0
    const woundCategory = hit && defender ? this.highestWound(netDamage, defender) : null

    return {
      exchangeId: exchange.id,
      defenderId: defenderRef.id ?? null,
      defenderUuid: defenderRef.uuid ?? null,
      defenderName: defense?.actorName || target.name || '',
      defenseType: defense?.type || null,
      attackerSuccesses,
      defenderSuccesses,
      hit,
      damage,
      protection,
      netDamage,
      woundCategory,
      woundThreshold: woundCategory && defender ? defender.system[woundCategory].threshold : null,
      canApply: Boolean(defender) && this.canApplyWound(defender)
    }
  }

  /**
   * Rend le bloc de résolution (HTML) pour insertion dans le message.
   * @param {Object} r Résultat de VermineExchange.resolve
   * @returns {string}
   */
  static renderResolution(r) {
    const t = (key) => game.i18n.localize(key)
    const verdict = !r.hit
      ? (r.defenseType === 'parade'
        ? `<span class="res-parried">${t('VERMINE.missed_parade')}</span>`
        : `<span class="res-dodged">${t('VERMINE.missed_esquive')}</span>`)
      : (r.defenseType === 'parade'
        ? `<span class="res-hit">${t('VERMINE.hit_parade')}</span>`
        : `<span class="res-hit">${t('VERMINE.hit')}</span>`)

    const lines = [
      `<div class="resolution-line">${t('VERMINE.attacker_successes')}: ${r.attackerSuccesses} &middot; ${t('VERMINE.defender_successes')}: ${r.defenderSuccesses} &rarr; ${verdict}</div>`
    ]
    if (r.hit) {
      lines.push(`<div class="resolution-line">${t('VERMINE.raw_damage')}: <strong>${r.damage}</strong>${r.protection ? ` &minus; ${t('ADVERSITY.protection')}: ${r.protection}` : ''} = ${t('VERMINE.net_damage')}: <strong>${r.netDamage}</strong></div>`)
      if (r.woundCategory) {
        lines.push(`<div class="resolution-line res-wound">${t('VERMINE.wound')}: <strong>${t(this.woundLabelKey(r.woundCategory))}</strong> (${t('VERMINE.wounds.threshold')} ${r.woundThreshold}D)</div>`)
      } else {
        lines.push(`<div class="resolution-line res-noshock">${t('VERMINE.no_wound')}</div>`)
      }
    } else {
      lines.push(`<div class="resolution-line res-nodamage">${t('VERMINE.no_damage')}</div>`)
    }

    const apply = (r.hit && r.woundCategory && r.canApply)
      ? `<button type="button" class="exchange-apply" data-action="exchange-apply" data-defender-id="${r.defenderId}" data-defender-uuid="${r.defenderUuid}" data-wound="${r.woundCategory}"><i class="fas fa-briefcase-medical"></i> ${t('VERMINE.apply_wound')}</button>`
      : ''

    return `<div class="exchange-resolution">
      <div class="resolution-header"><i class="fas fa-exchange-alt"></i> ${t('VERMINE.resolve_result')}</div>
      ${lines.join('')}
      ${apply}
    </div>`
  }

  // ── Liaison chat ────────────────────────────────────────────────────

  /**
   * Lie les interactions d'échange de coups sur un message de chat.
   * @param {HTMLElement} html élément du message
   * @param {ChatMessage} message
   */
  static bindChat(html, message) {
    const exchange = message.getFlag('world', FLAG_EXCHANGE)
    if (exchange) {
      html.querySelectorAll('.exchange-defend').forEach(btn => {
        btn.addEventListener('click', ev => this.#onDefense(ev, message))
      })
      html.querySelectorAll('.exchange-resolve').forEach(btn => {
        btn.addEventListener('click', ev => this.#onResolve(ev, message))
      })
    }
    html.querySelectorAll('[data-action="exchange-apply"]').forEach(btn => {
      btn.addEventListener('click', ev => this.#onApply(ev, message))
    })
    html.querySelectorAll('.exchange-incapacitant').forEach(btn => {
      btn.addEventListener('click', ev => this.#onIncapacitant(ev, message))
    })
    html.querySelectorAll('.exchange-zone').forEach(btn => {
      btn.addEventListener('click', ev => this.#onApplyZone(ev, message))
    })
  }

  static async #onDefense(ev, attackMessage) {
    ev.preventDefault()
    ev.stopPropagation()
    const btn = ev.currentTarget
    const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
    if (!exchange) return
    const type = btn.dataset.type
    const actor = await this.resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
    if (!actor) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
      return
    }
    if (!game.user.isGM && !actor.isOwner) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
      return
    }
    if (actor.type === 'creature') {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_creature_no_defense'))
      return
    }
    const defense = {
      exchangeId: exchange.id,
      type,
      actorId: actor.id,
      actorUuid: actor.uuid,
      actorName: actor.name,
      difficulty: exchange.difficulty,
      parryLevel: type === 'parade' ? this.#parryLevel(actor) : null
    }
    // PNJ : pas de caractéristiques/compétences — la défense utilise la valeur
    // d'Action (Expérience), difficulté verrouillée.
    if (actor.type === 'npc') {
      const { default: NpcRollDialog } = await import('./dialogs/npcRollDialog.mjs')
      const dialog = await NpcRollDialog.create({
        actor,
        rolltype: 'action',
        locks: { difficulty: exchange.difficulty, handicap: exchange.rapideHandicap ?? null },
        defense
      })
      if (dialog) dialog.render(true)
      return
    }
    const { default: RollDialog } = await import('./dialogs/rollDialog.mjs')
    const locks = this.#defenseLocks(actor, exchange, type)
    const dialog = await RollDialog.create({
      actor,
      rolltype: 'skill',
      label: locks.skill,
      locks,
      defense
    })
    if (dialog) dialog.render(true)
  }

  static #defenseLocks(actor, exchange, type) {
    const locks = type === 'parade'
      ? { ability: 'vigor', skill: this.#parrySkill(actor), difficulty: exchange.difficulty }
      : {
        ability: 'reflexes',
        skill: exchange.mode === 'ranged' ? 'alertness' : 'close',
        difficulty: exchange.difficulty
      }
    // Trait Rapide (n) : le défenseur subit (n) Handicaps sur sa défense.
    if (exchange.rapideHandicap) locks.handicap = exchange.rapideHandicap
    return locks
  }

  static #parrySkill(actor) {
    const hasMelee = actor.itemTypes?.weapon?.some(w => w.system.equipped && w.system.skill === 'melee')
    return hasMelee ? 'melee' : 'close'
  }

  static #parryLevel(actor) {
    const defenses = actor.itemTypes?.defense?.filter(d => d.system.equipped) || []
    return defenses.reduce((best, d) => Math.max(best, d.system.level || 0), 0)
  }

  static async #onResolve(ev, attackMessage) {
    ev.preventDefault()
    const btn = ev.currentTarget
    if (!game.user.isGM && !attackMessage.isOwner) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
      return
    }
    const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
    if (!exchange) return
    if (!exchange.targets?.length) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_target'))
      return
    }
    const wrapper = btn.closest('.exchange-actions')
    const container = wrapper?.parentElement
    if (!container) return
    container.querySelectorAll('.exchange-resolution').forEach(el => el.remove())

    const targets = exchange.targets?.length ? exchange.targets : [null]
    for (const target of targets) {
      const result = await this.resolve(attackMessage, target)
      if (!result) continue
      const block = document.createElement('div')
      block.innerHTML = this.renderResolution(result)
      const node = block.firstElementChild
      container.appendChild(node)
      this.bindChat(node, attackMessage)
    }

    const msgEl = btn.closest('.vermine-roll-message')
    if (msgEl) await attackMessage.update({ content: msgEl.outerHTML })
  }

  static async #onIncapacitant(ev) {
    ev.preventDefault()
    ev.stopPropagation()
    const btn = ev.currentTarget
    const actor = await this.resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid })
    if (!actor) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected'))
      return
    }
    if (!game.user.isGM && !actor.isOwner) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
      return
    }
    const value = parseInt(btn.dataset.value, 10) || 7
    if (actor.type === 'character') {
      const { default: RollDialog } = await import('./dialogs/rollDialog.mjs')
      const dialog = await RollDialog.create({
        actor,
        rolltype: 'ability',
        label: 'health',
        locks: { ability: 'health', difficulty: value }
      })
      if (dialog) dialog.render(true)
      return
    }
    const { default: NpcRollDialog } = await import('./dialogs/npcRollDialog.mjs')
    const dialog = await NpcRollDialog.create({
      actor,
      rolltype: 'action',
      locks: { difficulty: value }
    })
    if (dialog) dialog.render(true)
  }

  /**
   * Trait Zone (n) : applique la blessure résolue à toutes les cibles de
   * l'attaque présentes dans la zone (confort pour le MJ).
   */
  static async #onApplyZone(ev, attackMessage) {
    ev.preventDefault()
    ev.stopPropagation()
    const btn = ev.currentTarget
    const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE)
    if (!exchange) return
    if (!game.user.isGM && !attackMessage.isOwner) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
      return
    }
    if (!exchange.targets?.length) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_target'))
      return
    }
    let applied = 0
    for (const target of exchange.targets) {
      const r = await this.resolve(attackMessage, target)
      if (!r?.hit || !r.woundCategory) continue
      const defender = await this.resolveDefender({ id: r.defenderId, uuid: r.defenderUuid })
      if (!defender || !this.canApplyWound(defender)) continue
      const result = await this.applyWound(defender, r.woundCategory)
      if (result && result !== false) applied += 1
    }
    btn.disabled = true
    btn.innerHTML = `<i class="fas fa-check"></i> ${game.i18n.format('VERMINE.zone_applied', { count: applied })}`
    if (game.user.isGM || attackMessage.isOwner) {
      const msgEl = btn.closest('.vermine-roll-message')
      if (msgEl) await attackMessage.update({ content: msgEl.outerHTML })
    }
  }

  static async #onApply(ev, attackMessage) {
    ev.preventDefault()
    const btn = ev.currentTarget
    const defender = await this.resolveDefender({ id: btn.dataset.defenderId, uuid: btn.dataset.defenderUuid })
    const wound = btn.dataset.wound
    if (!defender || !wound) return
    if (!this.canApplyWound(defender)) {
      ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission'))
      return
    }
    const result = await this.applyWound(defender, wound)
    if (result === false) return
    btn.disabled = true
    let label = game.i18n.localize('VERMINE.wound_applied')
    if (result?.death) {
      label = game.i18n.localize('VERMINE.wound_death')
    } else if (result?.escalated) {
      const from = game.i18n.localize(this.woundLabelKey(wound))
      const to = game.i18n.localize(this.woundLabelKey(result.category))
      label = game.i18n.format('VERMINE.wound_escalated', { from, to })
    }
    btn.innerHTML = `<i class="fas fa-check"></i> ${label}`
    // Persiste l'état du bouton seulement si l'utilisateur peut modifier le message
    if (game.user.isGM || attackMessage.isOwner) {
      const msgEl = btn.closest('.vermine-roll-message')
      if (msgEl) await attackMessage.update({ content: msgEl.outerHTML })
    }
  }

  // ── Helpers internes ────────────────────────────────────────────────

  /**
   * Résout l'acteur d'une cible depuis sa référence (id d'acteur monde ou
   * uuid). Le uuid est privilégié : pour un token non lié, `token.actor.id`
   * expose encore l'id de l'acteur monde dont le token est issu, et résoudre
   * par id blesserait alors tous les tokens liés à cette fiche (PNJ mineurs).
   * Le uuid d'un acteur synthétique ("Scene.x.Token.y.Actor.z") n'est pas
   * résolvable de façon fiable par fromUuid (document non indexé quand la
   * résolution est asynchrone) : on extrait donc directement le token.
   * @param {{id: string|null, uuid: string|null}} ref
   * @returns {Promise<Actor|null>}
   */
  static async resolveDefender({ id = null, uuid = null }) {
    if (uuid) {
      const parts = uuid.split('.')
      if (parts[0] === 'Scene' && parts[2] === 'Token') {
        const token = game.scenes.get(parts[1])?.tokens.get(parts[3])
        return token?.actor ?? null
      }
      if (parts[0] === 'Actor') {
        return game.actors.get(parts[1]) ?? null
      }
    }
    if (id) {
      const actor = game.actors.get(id)
      if (actor) return actor
    }
    return null
  }

  /**
   * Retrouve la cible d'un échange par uuid d'abord, puis par id. Le uuid est
   * fiable pour distinguer des PNJ mineurs (tokens non liés) issus de la même
   * fiche, qui partagent le même id d'acteur monde.
   * @param {Object[]} targets cibles du flag d'échange
   * @param {Object|string|null} ref référence de la cible (ou son uuid)
   * @returns {Object|null}
   */
  static #matchTarget(targets, ref) {
    if (!ref) return targets[0] ?? null
    const uuid = typeof ref === 'string' ? ref : (ref.uuid ?? null)
    const id = typeof ref === 'string' ? null : (ref.id ?? null)
    if (uuid) return targets.find(t => t.uuid === uuid) ?? null
    if (id) return targets.find(t => t.id === id) ?? null
    return targets[0] ?? null
  }

  static #defenseMessagesFor(exchangeId, target = null) {
    return game.messages.contents.filter(m => {
      const f = m.getFlag('world', FLAG_DEFENSE)
      if (!f || f.exchangeId !== exchangeId) return false
      if (!target) return true
      if (target.uuid && f.actorUuid === target.uuid) return true
      return f.actorId === target.id
    })
  }

  static #messageTotal(message) {
    const el = document.createElement('div')
    el.innerHTML = message.content || ''
    const totalEl = el.querySelector('#total')
    const val = parseInt(totalEl?.innerText, 10)
    return Number.isNaN(val) ? 0 : val
  }
}
