/**
 * Donjon & Cie - Systeme FoundryVTT
 *
 * Donjon & Cie est un jeu de role edite par John Doe.
 * Ce systeme FoundryVTT est une implementation independante et n'est pas
 * affilie a John Doe.
 *
 * @author    LeRatierBretonnien
 * @copyright 2025–2026 LeRatierBretonnien
 * @license   CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
 */

import { DonjonEtCieUtility } from "./donjon-et-cie-utility.mjs";

export class DonjonEtCieMacros {
  static MISSION_PACK_MODE_SETTING = "missionPackMode";
  static MISSION_PACK_SOCKET_SCOPE = "missionPackCampaign";
  static MISSION_PACK_REQUEST_TIMEOUT = 300000;
  static MISSION_PACK_CAMPAIGN_DICE = [20, 12, 10, 8];
  static MISSION_PACK_STEWARD_MODES = {
    positive: "avantage",
    neutral: "normal",
    negative: "desavantage"
  };
  static #campaignRequests = new Map();
  static #socketRegistered = false;

  static MISSION_PACK_TABLES = [
    { key: "melee", name: "Armes de corps a corps", multiple: false },
    { key: "ranged", name: "Armes a distance", multiple: false },
    { key: "armor", name: "Armures", multiple: false },
    { key: "misc", name: "Encas et equipement divers", multiple: true }
  ];

  static MISSION_PACK_UNIQUE_ITEMS = [
    {
      name: "L epee de Monsieur Noir",
      type: "arme",
      system: {
        categorie: "melee",
        caracteristique: "force",
        degats: "1d6",
        portee: "",
        mains: 1,
        equipee: false,
        description: "<p>Elle n a rien de particulier mais confere un prestige important.</p><p>Avantage a tous les jets d interaction avec les employes de Donjon &amp; Cie.</p>",
        notes: ""
      }
    },
    {
      name: "Fil a plomb d Arnezon",
      type: "equipement",
      system: {
        quantite: 1,
        equipee: false,
        emplacement: "",
        description: "<p>Il oscille de maniere clairement etrange en presence d un passage secret.</p>",
        notes: ""
      }
    },
    {
      name: "Boussole de Drize Durban",
      type: "equipement",
      system: {
        quantite: 1,
        equipee: false,
        emplacement: "",
        description: "<p>Trois reglages : pointe vers le client, le resident ou l employe le plus proche.</p>",
        notes: ""
      }
    },
    {
      name: "Boule de cristal de la supervision",
      type: "equipement",
      system: {
        quantite: 1,
        equipee: false,
        emplacement: "",
        description: "<p>Permet de voir toute personne ou lieu qu on connait deja.</p><p>Jet de SAG pour la controler. La plupart des huiles sentent quand on les observe.</p>",
        notes: ""
      }
    },
    {
      name: "Dent du dragon Leogradonardicus III",
      type: "equipement",
      system: {
        quantite: 1,
        equipee: false,
        emplacement: "",
        description: "<p>Un jet de CHA reussi permet de controler les reptiles inintelligents et de charmer les humanoides reptiliens.</p>",
        notes: ""
      }
    },
    {
      name: "Doigt d Aarcarcerax",
      type: "consommable",
      system: {
        quantite: 1,
        delta: 4,
        effet: "Tue la creature vers qui on pointe le doigt.",
        description: "<p>Tue la creature vers qui on pointe le doigt.</p><p>La liche sait instantanement qu on a retrouve son doigt.</p>",
        notes: ""
      }
    },
    {
      name: "Cape de Vlad von Drakovitch",
      type: "consommable",
      system: {
        quantite: 1,
        delta: 6,
        effet: "Permet de se transformer en 1-3 rats, 4-5 chauve-souris, 6 forme gazeuse.",
        description: "<p>Delta 6 charges.</p><p>Permet de se transformer en 1-3 rats, 4-5 chauve-souris, 6 forme gazeuse.</p>",
        notes: ""
      }
    },
    {
      name: "Vieux carnet de notes d Affalella",
      type: "consommable",
      system: {
        quantite: 1,
        delta: 6,
        effet: "Fonctionne comme des faveurs de la Mercatique utilisables uniquement dans les aires client.",
        description: "<p>Vieux carnet de notes d Affalella, directrice de la Mercatique.</p><p>Fonctionne comme Delta 6 faveurs de la Mercatique utilisables uniquement dans les aires client.</p>",
        notes: ""
      }
    },
    {
      name: "Ancienne cle universelle de Paiji",
      type: "consommable",
      system: {
        quantite: 1,
        delta: 8,
        effet: "Ouvre tous les coffres, portes, armoires et autres serrures du Donjon.",
        description: "<p>Ancienne cle universelle de Paiji, directeur de la Maintenance.</p><p>Delta 8 usages. Ouvre tous les coffres, portes, armoires et autres serrures du Donjon.</p>",
        notes: ""
      }
    },
    {
      name: "Baguette d urgence",
      type: "equipement",
      system: {
        quantite: 1,
        equipee: false,
        emplacement: "",
        description: "<p>Teleporte l utilisateur au palier des huiles lorsque la baguette est brisee.</p>",
        notes: ""
      }
    }
  ];

  static registerSocketListeners() {
    if (this.#socketRegistered || !game.socket) return;

    game.socket.on(`system.${game.system.id}`, (payload) => {
      void this.#handleSocketMessage(payload);
    });

    this.#socketRegistered = true;
  }

  static #normalizeName(value) {
    return String(value ?? "")
      .normalize("NFD")
      .replace(/\p{Diacritic}/gu, "")
      .replace(/['’]/g, " ")
      .replace(/\s+/g, " ")
      .trim()
      .toLowerCase();
  }

  static #getMissionPackLabel(key) {
    return game.i18n.localize(`DNC.Macro.MissionPack.${key}`);
  }

  static #getModeLabel(mode) {
    if (mode === "avantage") return game.i18n.localize("DNC.UI.ModeAdvantage");
    if (mode === "desavantage") return game.i18n.localize("DNC.UI.ModeDisadvantage");
    return game.i18n.localize("DNC.UI.ModeNormal");
  }

  static #getMissionPackMode() {
    return String(game.settings.get("fvtt-donjon-et-cie", this.MISSION_PACK_MODE_SETTING) ?? "classic");
  }

  static #getMissionPackModeLabel(mode) {
    return game.i18n.localize(mode === "campaign"
      ? "DNC.Macro.MissionPack.ModeCampaign"
      : "DNC.Macro.MissionPack.ModeClassic");
  }

  static #getMissionPackModeDescription(mode) {
    return game.i18n.localize(mode === "campaign"
      ? "DNC.Macro.MissionPack.ModeCampaignHint"
      : "DNC.Macro.MissionPack.ModeClassicHint");
  }

  static #getStewardRelationLabel(relation) {
    return game.i18n.localize(`DNC.Macro.MissionPack.Relation.${relation ?? "neutral"}`);
  }

  static #getStewardRelationOptions() {
    return ["positive", "neutral", "negative"].map((relation) => ({
      value: relation,
      label: this.#getStewardRelationLabel(relation)
    }));
  }

  static #getCampaignDiceOptions() {
    return this.MISSION_PACK_CAMPAIGN_DICE.map((sides) => ({
      value: String(sides),
      label: `1d${sides}`
    }));
  }

  static #getDefaultCampaignAssignments() {
    return Object.fromEntries(this.MISSION_PACK_TABLES.map((spec, index) => [spec.key, String(this.MISSION_PACK_CAMPAIGN_DICE[index])]));
  }

  static #getDefaultMissionPackActorId(actors) {
    const controlledActor = canvas?.tokens?.controlled?.[0]?.actor ?? null;
    if (controlledActor?.type === "employe") return controlledActor.id;
    return actors[0]?.id ?? "";
  }

  static #getMissionPackActorOptions() {
    return game.actors
      .filter((actor) => actor.type === "employe")
      .sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }))
      .map((actor) => ({
        value: actor.id,
        label: actor.name
      }));
  }

  static #getMissionPackOwnerUsers(actor) {
    return game.users
      .filter((user) => !user.isGM && actor.testUserPermission(user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER))
      .sort((a, b) => Number(b.active) - Number(a.active) || a.name.localeCompare(b.name, "fr", { sensitivity: "base" }));
  }

  static #getCampaignTargetUser(actor) {
    return this.#getMissionPackOwnerUsers(actor).find((user) => user.active) ?? null;
  }

  static async #resolveMissionPackActor(target = null) {
    if (target?.documentName === "Actor") return target;
    if (target?.actor?.documentName === "Actor") return target.actor;
    if (typeof target === "string" && target) {
      const document = await fromUuid(target);
      if (document?.documentName === "Actor") return document;
      if (document?.actor?.documentName === "Actor") return document.actor;
    }

    const controlledTokens = canvas?.tokens?.controlled ?? [];
    if (controlledTokens.length === 1 && controlledTokens[0]?.actor) return controlledTokens[0].actor;
    if (controlledTokens.length > 1) {
      ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnMultipleTokens"));
      return null;
    }

    return game.user.character ?? null;
  }

  static async #getRandomTableDocuments() {
    const worldTables = game.tables?.contents ?? [];
    if (worldTables.length) return worldTables;

    const pack = game.packs.get("fvtt-donjon-et-cie.random-tables");
    return pack ? pack.getDocuments() : [];
  }

  static async #findRollTableByName(name) {
    const normalizedName = this.#normalizeName(name);
    const worldTable = game.tables.find((table) => this.#normalizeName(table.name) === normalizedName);
    if (worldTable) return worldTable;

    const packTables = await this.#getRandomTableDocuments();
    return packTables.find((table) => this.#normalizeName(table.name) === normalizedName) ?? null;
  }

  static async #getEquipmentDocuments() {
    const pack = game.packs.get("fvtt-donjon-et-cie.equipment");
    const packDocuments = pack ? await pack.getDocuments() : [];
    return [...(game.items?.contents ?? []), ...packDocuments];
  }

  static async #findItemByName(name) {
    const normalizedName = this.#normalizeName(name);
    const documents = await this.#getEquipmentDocuments();
    return documents.find((item) => this.#normalizeName(item.name) === normalizedName) ?? null;
  }

  static #extractUuidTargets(text) {
    const matches = [...String(text ?? "").matchAll(/@UUID\[([^\]]+)\](?:\{([^}]+)\})?/g)];
    return matches.map((match) => ({
      uuid: match[1],
      label: match[2] ?? ""
    }));
  }

  static #extractPlainTextEntries(text) {
    const rawText = String(text ?? "")
      .replace(/<[^>]+>/g, " ")
      .replace(/\s+/g, " ")
      .trim();

    if (!rawText) return [];

    return rawText
      .split(",")
      .map((entry) => entry.trim())
      .filter(Boolean)
      .filter((entry) => !/^dotation\s+\d+$/i.test(entry));
  }

  static async #evaluateRoll(formula) {
    const roll = await (new Roll(formula)).evaluate();
    const values = roll.dice
      .flatMap((die) => die.results.map((result) => Number(result.result ?? result.count ?? 0)))
      .filter((value) => Number.isFinite(value));

    return {
      roll,
      total: Number(roll.total ?? 0),
      values
    };
  }

  static #formatRollValues(values, kept) {
    if (!values.length) return String(kept ?? 0);
    if (values.length === 1) return String(values[0]);
    return `${values.join(" / ")} -> ${kept}`;
  }

  static async #rollPackDie(sides, { mode = "normal" } = {}) {
    const formula = mode === "avantage"
      ? `2d${sides}kh`
      : mode === "desavantage"
        ? `2d${sides}kl`
        : `1d${sides}`;
    const evaluated = await this.#evaluateRoll(formula);

    return {
      sides: Number(sides),
      dieLabel: `1d${sides}`,
      roll: evaluated.roll,
      values: evaluated.values,
      valuesLabel: this.#formatRollValues(evaluated.values, evaluated.total),
      kept: evaluated.total,
      mode,
      modeLabel: this.#getModeLabel(mode)
    };
  }

  static #createInlineItemData(spec) {
    return {
      name: spec.name,
      type: spec.type,
      img: DonjonEtCieUtility.getDefaultItemIcon(spec.type),
      system: foundry.utils.deepClone(spec.system)
    };
  }

  static #getTableResults(table) {
    return table?.results?.contents ?? [];
  }

  static #getTableRange(result) {
    const rawRange = Array.isArray(result?.range)
      ? result.range
      : result?._source?.range ?? [];
    return [Number(rawRange[0] ?? 0), Number(rawRange[1] ?? 0)];
  }

  static #findTableResultByTotal(table, total) {
    const entries = this.#getTableResults(table)
      .map((result) => ({
        result,
        range: this.#getTableRange(result)
      }))
      .filter(({ range }) => Number.isFinite(range[0]) && Number.isFinite(range[1]) && range[1] >= range[0])
      .sort((a, b) => a.range[0] - b.range[0]);

    if (!entries.length) {
      return {
        result: null,
        clampedTotal: total,
        range: null
      };
    }

    const minimum = entries[0].range[0];
    const maximum = entries.at(-1).range[1];
    const clampedTotal = Math.max(minimum, Math.min(maximum, total));
    const match = entries.find(({ range }) => clampedTotal >= range[0] && clampedTotal <= range[1]) ?? entries.at(-1);

    return {
      result: match.result,
      clampedTotal,
      range: match.range
    };
  }

  static async #resolveUniqueMissionPackEntry({ comparisonRoll = null } = {}) {
    const reference = await this.#rollPackDie(20);
    const actorEvaluation = comparisonRoll == null ? await this.#rollPackDie(20) : null;
    const actorRoll = Number(comparisonRoll ?? actorEvaluation?.kept ?? 0);

    if (reference.kept !== actorRoll) {
      return {
        matched: false,
        referenceRoll: reference.kept,
        actorRoll,
        uniqueRoll: null,
        itemName: "",
        itemData: null,
        rolls: [reference.roll, ...(actorEvaluation ? [actorEvaluation.roll] : [])]
      };
    }

    const uniqueRoll = await this.#rollPackDie(this.MISSION_PACK_UNIQUE_ITEMS.length);
    const spec = this.MISSION_PACK_UNIQUE_ITEMS[Math.max(0, uniqueRoll.kept - 1)] ?? this.MISSION_PACK_UNIQUE_ITEMS[0];
    const document = await this.#findItemByName(spec.name);
    const itemData = document ? this.#toEmbeddedItemData(document) : this.#createInlineItemData(spec);

    return {
      matched: true,
      referenceRoll: reference.kept,
      actorRoll,
      uniqueRoll: uniqueRoll.kept,
      itemName: document?.name ?? spec.name,
      itemData,
      rolls: [reference.roll, ...(actorEvaluation ? [actorEvaluation.roll] : []), uniqueRoll.roll]
    };
  }

  static async #resolveTableResultEntries(result, { multiple = false } = {}) {
    if (!result) {
      return {
        display: game.i18n.localize("DNC.Macro.MissionPack.NoResult"),
        entries: []
      };
    }

    const source = result._source ?? {};
    const resultName = String(result.name ?? source.name ?? "").trim();
    const resultDescription = String(result.description ?? source.description ?? "").trim();
    const resultText = String(source.text ?? "").trim();
    const resultLabel = resultName || resultDescription || resultText;

    const documentUuid = result.type === "document"
      ? result.documentUuid ?? source.documentUuid ?? null
      : null;

    if (documentUuid) {
      const document = await fromUuid(documentUuid);
      const label = document?.name ?? resultLabel;
      return {
        display: label,
        entries: label ? [{ name: label, document }] : []
      };
    }

    const sourceText = resultDescription || resultText || resultLabel;
    const uuidTargets = this.#extractUuidTargets(sourceText);
    if (uuidTargets.length) {
      const entries = [];
      for (const target of uuidTargets) {
        const document = await fromUuid(target.uuid);
        const name = document?.name ?? target.label;
        if (!name) continue;
        entries.push({ name, document });
      }

      return {
        display: entries.map((entry) => entry.name).join(", "),
        entries
      };
    }

    const names = multiple
      ? this.#extractPlainTextEntries(sourceText || resultLabel)
      : [resultLabel].filter(Boolean);

    return {
      display: names.join(", "),
      entries: names.map((name) => ({ name, document: null }))
    };
  }

  static #toEmbeddedItemData(item) {
    const data = foundry.utils.deepClone(item.toObject());
    delete data._id;
    delete data.folder;
    delete data.sort;
    return data;
  }

  static async #materializeMissionPackEntries(entries) {
    const embeddedItems = [];
    const addedNames = [];
    const missingNames = [];

    for (const entry of entries) {
      const item = entry.document ?? await this.#findItemByName(entry.name);
      if (!item) {
        missingNames.push(entry.name);
        continue;
      }

      embeddedItems.push(this.#toEmbeddedItemData(item));
      addedNames.push(item.name);
    }

    return {
      embeddedItems,
      addedNames,
      missingNames
    };
  }

  static async #openCampaignAllocationDialog(actor, { playerName = "", requesterName = "" } = {}) {
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-donjon-et-cie/templates/dialogs/mission-pack-campaign-dialog.hbs",
      {
        actorName: actor.name,
        playerName,
        requesterName,
        rank: Number(actor.system.anciennete?.rang ?? actor.system.sante?.dv ?? 0),
        assignments: this.MISSION_PACK_TABLES.map((spec) => ({
          key: spec.key,
          label: this.#getMissionPackLabel(spec.key),
          fieldName: `${spec.key}Die`,
          selectedDie: this.#getDefaultCampaignAssignments()[spec.key]
        })),
        diceOptions: this.#getCampaignDiceOptions(),
        relationOptions: this.#getStewardRelationOptions(),
        selectedRelation: "neutral"
      }
    );

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: game.i18n.localize("DNC.Macro.MissionPack.CampaignDialogTitle"),
        icon: "fa-solid fa-dice"
      },
      classes: ["dnc-roll-dialog", "dnc-mission-pack-dialog"],
      content,
      modal: true,
      buttons: [
        {
          action: "confirm",
          label: game.i18n.localize("DNC.Macro.MissionPack.CampaignDialogAction"),
          icon: "fa-solid fa-check",
          default: true,
          callback: async (event, button) => {
            const assignments = Object.fromEntries(this.MISSION_PACK_TABLES.map((spec) => [
              spec.key,
              Number(button.form.elements[`${spec.key}Die`]?.value ?? 0)
            ]));
            const selectedDice = Object.values(assignments).filter((value) => value > 0);

            if (selectedDice.length !== this.MISSION_PACK_TABLES.length) {
              ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnDiceRequired"));
              return null;
            }

            if (new Set(selectedDice).size !== selectedDice.length) {
              ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnDiceUnique"));
              return null;
            }

            return {
              assignments,
              stewardRelation: button.form.elements.stewardRelation?.value ?? "neutral"
            };
          }
        }
      ],
      rejectClose: false
    });
  }

  static async #handleSocketMessage(payload) {
    if (payload?.scope !== this.MISSION_PACK_SOCKET_SCOPE) return;

    if (payload.type === "campaign-response") {
      const pending = this.#campaignRequests.get(payload.requestId);
      if (!pending) return;
      clearTimeout(pending.timeoutId);
      this.#campaignRequests.delete(payload.requestId);
      pending.resolve(payload.result ?? null);
      return;
    }

    if (payload.type !== "campaign-request" || payload.targetUserId !== game.user.id) return;

    const actor = await fromUuid(payload.actorUuid);
    const allocation = actor
      ? await this.#openCampaignAllocationDialog(actor, {
        playerName: game.user.name,
        requesterName: payload.requesterName ?? ""
      })
      : null;

    game.socket.emit(`system.${game.system.id}`, {
      scope: this.MISSION_PACK_SOCKET_SCOPE,
      type: "campaign-response",
      requestId: payload.requestId,
      result: allocation
        ? {
          allocation,
          responderUserId: game.user.id,
          responderName: game.user.name
        }
        : {
          allocation: null,
          responderUserId: game.user.id,
          responderName: game.user.name
        }
    });
  }

  static async #requestCampaignAllocation(actor, targetUser) {
    if (!targetUser || targetUser.id === game.user.id) {
      const allocation = await this.#openCampaignAllocationDialog(actor, {
        playerName: game.user.name,
        requesterName: game.user.name
      });
      return allocation ? { allocation, responderName: game.user.name } : null;
    }

    const requestId = foundry.utils.randomID();
    const responsePromise = new Promise((resolve) => {
      const timeoutId = globalThis.setTimeout(() => {
        this.#campaignRequests.delete(requestId);
        resolve(null);
      }, this.MISSION_PACK_REQUEST_TIMEOUT);
      this.#campaignRequests.set(requestId, { resolve, timeoutId });
    });

    game.socket.emit(`system.${game.system.id}`, {
      scope: this.MISSION_PACK_SOCKET_SCOPE,
      type: "campaign-request",
      requestId,
      targetUserId: targetUser.id,
      actorUuid: actor.uuid,
      requesterName: game.user.name
    });

    const response = await responsePromise;
    if (!response?.allocation) {
      ui.notifications.warn(game.i18n.format("DNC.Macro.MissionPack.CampaignRequestCanceled", {
        actor: actor.name,
        player: targetUser.name
      }));
      return null;
    }

    return {
      allocation: response.allocation,
      responderName: response.responderName ?? targetUser.name
    };
  }

  static async #finalizeMissionPack(actor, drawPlans, {
    generationMode = "classic",
    controllerName = "",
    stewardRelation = "neutral",
    uniqueComparisonRoll = null
  } = {}) {
    const draws = [];
    const embeddedItems = [];
    const rolls = [];
    let missingCount = 0;

    for (const plan of drawPlans) {
      rolls.push(...(plan.rolls ?? []).filter((roll) => roll instanceof Roll));

      if (plan.failed || !plan.resolved) {
        draws.push({
          label: this.#getMissionPackLabel(plan.spec.key),
          display: game.i18n.format("DNC.Macro.MissionPack.TableMissing", { table: plan.spec.name }),
          addedNames: [],
          addedSummary: "",
          missingNames: [],
          missingSummary: "",
          failed: true
        });
        continue;
      }

      const materialized = await this.#materializeMissionPackEntries(plan.resolved.entries);
      embeddedItems.push(...materialized.embeddedItems);
      missingCount += materialized.missingNames.length;

      draws.push({
        label: this.#getMissionPackLabel(plan.spec.key),
        display: plan.resolved.display || game.i18n.localize("DNC.Macro.MissionPack.NoResult"),
        addedNames: materialized.addedNames,
        addedSummary: materialized.addedNames.join(", "),
        missingNames: materialized.missingNames,
        missingSummary: materialized.missingNames.join(", "),
        failed: false,
        ...plan.detail
      });
    }

    const uniqueEntry = await this.#resolveUniqueMissionPackEntry({ comparisonRoll: uniqueComparisonRoll });
    rolls.push(...(uniqueEntry.rolls ?? []).filter((roll) => roll instanceof Roll));
    if (uniqueEntry.itemData) {
      embeddedItems.push(uniqueEntry.itemData);
    }

    const createdItems = embeddedItems.length
      ? await actor.createEmbeddedDocuments("Item", embeddedItems, { renderSheet: false })
      : [];

    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-donjon-et-cie/templates/chat/mission-pack-card.hbs",
      {
        title: game.i18n.localize("DNC.Macro.MissionPack.Title"),
        actorName: actor.name,
        createdCount: createdItems.length,
        missingCount,
        draws,
        uniqueEntry,
        isCampaign: generationMode === "campaign",
        generationModeLabel: this.#getMissionPackModeLabel(generationMode),
        controllerName,
        stewardRelationLabel: generationMode === "campaign" ? this.#getStewardRelationLabel(stewardRelation) : ""
      }
    );

    await ChatMessage.create({
      speaker: ChatMessage.getSpeaker({ actor }),
      user: game.user.id,
      content,
      rolls
    });

    if (createdItems.length && !missingCount) {
      ui.notifications.info(game.i18n.format("DNC.Macro.MissionPack.Success", {
        actor: actor.name,
        count: createdItems.length
      }));
    } else if (createdItems.length) {
      ui.notifications.warn(game.i18n.format("DNC.Macro.MissionPack.Partial", {
        actor: actor.name,
        count: createdItems.length,
        missing: missingCount
      }));
    } else {
      ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnNothingAdded"));
    }

    return {
      actor,
      createdItems,
      missingCount,
      draws,
      uniqueEntry,
      generationMode,
      controllerName,
      stewardRelation
    };
  }

  static async #grantClassicMissionPack(actor) {
    const drawPlans = [];

    for (const spec of this.MISSION_PACK_TABLES) {
      const table = await this.#findRollTableByName(spec.name);
      if (!table) {
        drawPlans.push({ spec, failed: true, resolved: null, rolls: [] });
        continue;
      }

      const draw = await table.draw({ displayChat: false });
      const result = draw.results?.[0] ?? null;
      drawPlans.push({
        spec,
        failed: false,
        resolved: await this.#resolveTableResultEntries(result, { multiple: spec.multiple }),
        detail: null,
        rolls: draw.roll instanceof Roll ? [draw.roll] : []
      });
    }

    return this.#finalizeMissionPack(actor, drawPlans, {
      generationMode: "classic"
    });
  }

  static async #grantCampaignMissionPack(actor) {
    const targetUser = this.#getCampaignTargetUser(actor);
    const allocationResult = await this.#requestCampaignAllocation(actor, targetUser);
    if (!allocationResult?.allocation) return null;

    const rankBonus = Number(actor.system.anciennete?.rang ?? actor.system.sante?.dv ?? 0);
    const stewardRelation = allocationResult.allocation.stewardRelation ?? "neutral";
    const mode = this.MISSION_PACK_STEWARD_MODES[stewardRelation] ?? "normal";
    const drawPlans = [];
    let uniqueComparisonRoll = null;

    for (const spec of this.MISSION_PACK_TABLES) {
      const table = await this.#findRollTableByName(spec.name);
      if (!table) {
        drawPlans.push({ spec, failed: true, resolved: null, rolls: [] });
        continue;
      }

      const sides = Number(allocationResult.allocation.assignments?.[spec.key] ?? 0);
      const rollData = await this.#rollPackDie(sides, { mode });
      if (sides === 20) uniqueComparisonRoll = rollData.kept;

      const total = rollData.kept + rankBonus;
      const tableResult = this.#findTableResultByTotal(table, total);

      drawPlans.push({
        spec,
        failed: false,
        resolved: await this.#resolveTableResultEntries(tableResult.result, { multiple: spec.multiple }),
        detail: {
          dieLabel: rollData.dieLabel,
          modeLabel: rollData.modeLabel,
          rollValuesLabel: rollData.valuesLabel,
          kept: rollData.kept,
          rankBonus,
          total,
          resolvedTotal: tableResult.clampedTotal,
          clamped: tableResult.clampedTotal !== total
        },
        rolls: [rollData.roll]
      });
    }

    return this.#finalizeMissionPack(actor, drawPlans, {
      generationMode: "campaign",
      controllerName: allocationResult.responderName ?? targetUser?.name ?? game.user.name,
      stewardRelation,
      uniqueComparisonRoll
    });
  }

  /**
   * Open the GM-only mission pack dialog.
   * @returns {Promise<object|null>}
   */
  static async openMissionPackDialog() {
    if (!game.user.isGM) {
      ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnGMOnly"));
      return null;
    }

    const actorOptions = this.#getMissionPackActorOptions();
    if (!actorOptions.length) {
      ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnNoEmployees"));
      return null;
    }

    const mode = this.#getMissionPackMode();
    const selectedActorId = this.#getDefaultMissionPackActorId(actorOptions.map((option) => game.actors.get(option.value)).filter(Boolean));
    const content = await foundry.applications.handlebars.renderTemplate(
      "systems/fvtt-donjon-et-cie/templates/dialogs/mission-pack-dialog.hbs",
      {
        actorOptions,
        selectedActorId,
        modeLabel: this.#getMissionPackModeLabel(mode),
        modeDescription: this.#getMissionPackModeDescription(mode),
        isCampaign: mode === "campaign"
      }
    );

    return foundry.applications.api.DialogV2.wait({
      window: {
        title: game.i18n.localize("DNC.Macro.MissionPack.DialogTitle"),
        icon: "fa-solid fa-box-open"
      },
      classes: ["dnc-roll-dialog"],
      content,
      modal: false,
      buttons: [
        {
          action: "grant",
          label: game.i18n.localize("DNC.Macro.MissionPack.DialogAction"),
          icon: "fa-solid fa-box-open",
          default: true,
          callback: async (event, button) => {
            const actorId = button.form.elements.actorId?.value ?? "";
            const actor = actorId ? game.actors.get(actorId) : null;
            if (!actor) {
              ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnNoActor"));
              return null;
            }
            return this.grantMissionPack(actor);
          }
        }
      ],
      rejectClose: false
    });
  }

  /**
   * Draw the initial mission pack for the resolved actor and add the resulting items.
   * @param {Actor|string|null} target Resolved actor, token, or UUID. Defaults to selected token or user character.
   * @returns {Promise<object|null>}
   */
  static async grantMissionPack(target = null) {
    const actor = await this.#resolveMissionPackActor(target);
    if (!actor) {
      ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnNoActor"));
      return null;
    }

    if (actor.type !== "employe") {
      ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnInvalidActor"));
      return null;
    }

    return this.#getMissionPackMode() === "campaign"
      ? this.#grantCampaignMissionPack(actor)
      : this.#grantClassicMissionPack(actor);
  }
}
