/* Wisp Crafting Calculator — cost-per-wisp ranking.
 *
 * Lists every way to produce a wisp of the selected armor material or weapon
 * wisp type, ranked by cost-per-wisp ascending. Three source kinds:
 *
 *   • Buy wisp on AH       — direct purchase at the live AH wisp price.
 *   • Craft & salvage      — single-grade: craft any (grade × slot) piece,
 *                            salvage it for wisps. (Mostly wisp-negative —
 *                            shown for reference but rarely optimal.)
 *   • Upgrade chain        — wisp-craft at a low grade, transmute up through
 *                            upgrade recipes (with mana-seal rerolls at each
 *                            Sealed intermediate), salvage at the top.
 *
 * AH prices of completed sealed pieces are intentionally not used as a wisp
 * source (the listings are noisy and skew the calc). */

const WISP_DATA = {
  armor: {
    wisp: { Cloth: '38843', Leather: '38844', Plate: '38845' },
    types: ['Cloth', 'Leather', 'Plate'],
    slots: ['Head', 'Arms', 'Hands', 'Chest', 'Waist', 'Legs', 'Feet'],
    slotKeywords: {
      Cloth:   { Hood: 'Head', Sleeves: 'Arms', Gloves: 'Hands', Shirt: 'Chest', Sash: 'Waist', Pants: 'Legs', Shoes: 'Feet' },
      Leather: { Cap: 'Head', Guards: 'Arms', Fists: 'Hands', Jerkin: 'Chest', Belt: 'Waist', Breeches: 'Legs', Boots: 'Feet' },
      Plate:   { Helm: 'Head', Vambraces: 'Arms', Gauntlets: 'Hands', Cuirass: 'Chest', Tassets: 'Waist', Greaves: 'Legs', Sabatons: 'Feet' },
    },
    // Wisps required to craft sealed piece [grade][slot]
    craft: {
      Illustrious: { Head: 7,  Arms: 4,   Hands: 7,  Chest: 12,   Waist: 4,    Legs: 9,    Feet: 7 },
      Magnificent: { Head: 15, Arms: 9,   Hands: 15, Chest: 24,   Waist: 9,    Legs: 18,   Feet: 15 },
      Epherium:    { Head: 69, Arms: 42,  Hands: 69, Chest: 111,  Waist: 42,   Legs: 83,   Feet: 69 },
      Delphinad:   { Head: 420,Arms: 252, Hands: 420,Chest: 672,  Waist: 252,  Legs: 504,  Feet: 420 },
      Ayanad:      { Head: 750,Arms: 1050,Hands: 750,Chest: 2800, Waist: 1050, Legs: 2100, Feet: 1750 },
    },
    // Wisps returned when salvaging that piece [grade][slot]
    salvage: {
      Illustrious: { Head: 3, Arms: 2, Hands: 3, Chest: 4, Waist: 2, Legs: 3, Feet: 3 },
      Magnificent: { Head: 5, Arms: 3, Hands: 5, Chest: 8, Waist: 3, Legs: 6, Feet: 5 },
      Epherium:    { Head: 15,Arms: 9, Hands: 15,Chest: 24,Waist: 9, Legs: 18,Feet: 15 },
      Delphinad:   { Head: 75,Arms: 45,Hands: 75,Chest: 120,Waist: 45,Legs: 90,Feet: 75 },
      Ayanad:      { Head: 438,Arms: 263,Hands: 438,Chest: 700,Waist: 263,Legs: 525,Feet: 438 },
    },
  },
  weapons: {
    wisp: { 'Wooden': '38837', '2H Metal': '38836', '1H Metal': '38835' },
    types: ['Wooden', '2H Metal', '1H Metal'],
    craft:   { Illustrious: 3,  Magnificent: 6,  Epherium: 28, Delphinad: 168, Ayanad: 700 },
    salvage: { Illustrious: 1,  Magnificent: 2,  Epherium: 6,  Delphinad: 30,  Ayanad: 175 },
  },
  grades: ['Illustrious', 'Magnificent', 'Epherium', 'Delphinad', 'Ayanad'],
  // Reroll success rate per grade — used for chain mana-seal cost estimation.
  baseSuccessRate: {
    Illustrious: 0.25, Magnificent: 0.25,
    Epherium: 1/7, Delphinad: 1/7, Ayanad: 1/7,
  },
  // Glowing chance per grade — chance a sealed craft is "glowing" and grants
  // a guaranteed-success reroll. Hardcoded per ArcheAge Classic data.
  // Ayanad is rerolled only when chains start there (rare); value is unused
  // in practice since Ayanad is typically the chain END.
  glowingRate: {
    Illustrious: 0.10, Magnificent: 0.10,
    Epherium:    0.05, Delphinad:   0.05,
    Ayanad:      0.05,
  },
  // Slot-class for armor mana seals. Coarser than the 7-slot Table A;
  // multiple slots share a mana seal.
  slotClass: { Head: 'Medium', Hands: 'Medium', Feet: 'Medium',
               Arms: 'Small', Waist: 'Small',
               Chest: 'Chest', Legs: 'Pants' },
  // Mana seal acquisition cost — expressed as wisps required per seal.
  // Used to price seals off the live wisp AH price rather than the seal AH
  // listing (which is noisy). Weapon seals are uniform across wisp type
  // (Wooden / 2H Metal / 1H Metal all share the same seal cost in wisps).
  manaSealWispCost: {
    Chest:  { Epherium: 15, Delphinad: 51, Ayanad: 77 },
    Pants:  { Epherium: 11, Delphinad: 38, Ayanad: 58 },
    Medium: { Epherium: 9,  Delphinad: 32, Ayanad: 48 },
    Small:  { Epherium: 6,  Delphinad: 19, Ayanad: 29 },
    Weapon: { Epherium: 4,  Delphinad: 12, Ayanad: 29 },
  },
};

const GRADE_KEYWORDS = WISP_DATA.grades;

// Find every sealing recipe and bucket by (material, slot, grade) for armor
// or (type, grade) for weapons. Multiple recipes per cell are kept so the
// calculator can pick the cheapest.
function buildSealingIndex(recipes) {
  const out = {
    armor: { Cloth: {}, Leather: {}, Plate: {} },
    weapons: { 'Wooden': {}, '2H Metal': {}, '1H Metal': {} },
  };
  for (const mat of WISP_DATA.armor.types) {
    for (const slot of WISP_DATA.armor.slots) {
      out.armor[mat][slot] = {};
      for (const g of WISP_DATA.grades) out.armor[mat][slot][g] = [];
    }
  }
  for (const t of WISP_DATA.weapons.types) {
    out.weapons[t] = {};
    for (const g of WISP_DATA.grades) out.weapons[t][g] = [];
  }
  const armorWispToMat = {};
  for (const [m, id] of Object.entries(WISP_DATA.armor.wisp)) armorWispToMat[id] = m;
  const weaponWispToType = {};
  for (const [t, id] of Object.entries(WISP_DATA.weapons.wisp)) weaponWispToType[id] = t;

  for (const r of recipes) {
    for (const m of r.materials) {
      const wid = String(m.itemType);
      const mat = armorWispToMat[wid];
      const wType = weaponWispToType[wid];
      if (!mat && !wType) continue;
      let grade = GRADE_KEYWORDS.find(g => r.productName.includes(g)) || null;

      if (mat) {
        const kw = WISP_DATA.armor.slotKeywords[mat];
        const slot = Object.keys(kw).find(k => r.productName.includes(k));
        if (!slot) break;
        const mappedSlot = kw[slot];
        if (!grade) {
          for (const g of WISP_DATA.grades) {
            if (WISP_DATA.armor.craft[g][mappedSlot] === m.amount) { grade = g; break; }
          }
        }
        if (!grade) break;
        if (WISP_DATA.armor.craft[grade][mappedSlot] !== m.amount) break;
        out.armor[mat][mappedSlot][grade].push({
          recipe: r, grade, slot: mappedSlot, material: mat,
          wispNeeded: m.amount, productId: r.productItemType, productName: r.productName,
        });
      } else if (wType) {
        if (!grade) {
          for (const g of WISP_DATA.grades) {
            if (WISP_DATA.weapons.craft[g] === m.amount) { grade = g; break; }
          }
        }
        if (!grade) break;
        if (WISP_DATA.weapons.craft[grade] !== m.amount) break;
        out.weapons[wType][grade].push({
          recipe: r, grade, slot: null, type: wType,
          wispNeeded: m.amount, productId: r.productItemType, productName: r.productName,
        });
      }
      break;
    }
  }
  return out;
}

// Sum priced materials for a recipe → { cost, missing, breakdown }.
// breakdown is the per-material detail used by the expand panel.
function craftMatCost(recipe, prices) {
  let cost = 0;
  const missing = [];
  const breakdown = [];
  for (const m of recipe.materials) {
    const p = prices[m.itemType];
    if (p == null) {
      missing.push(m.name);
      breakdown.push({ ...m, unit: null, sub: null });
    } else {
      cost += p * m.amount;
      breakdown.push({ ...m, unit: p, sub: p * m.amount });
    }
  }
  return { cost, missing, breakdown };
}

// Slot-keyword → (mode, key, slot) reverse lookup, built once.
// Used to parse gear names like "Delphinad Ocean Shirt" or "Sealed Ayanad
// Greatsword" into a structured (material/weapon-type, slot, grade) shape
// without needing to find the item in any pre-built index. This is what lets
// upgrade-recipe detection work even for items that have no craft recipe of
// their own (e.g. Delphinad Ocean Shirt — only ever produced by rerolling).
const __weaponSlotKeywords = {
  'Wooden':   ['Staff', 'Bow', 'Shield', 'Scepter'],
  '2H Metal': ['Greatsword', 'Nodachi', 'Greataxe', 'Greatclub', 'Longspear'],
  '1H Metal': ['Dagger', 'Sword', 'Katana', 'Axe', 'Club', 'Shortspear'],
};
const __slotToGear = new Map();
for (const mat of WISP_DATA.armor.types) {
  for (const [kw, slot] of Object.entries(WISP_DATA.armor.slotKeywords[mat])) {
    __slotToGear.set(kw, { mode: 'armor', key: mat, slot });
  }
}
for (const [t, kws] of Object.entries(__weaponSlotKeywords)) {
  for (const kw of kws) __slotToGear.set(kw, { mode: 'weapons', key: t, slot: null });
}
// Sort keywords longest first so multi-word matches like "Greatsword" win
// over their shorter contained words.
const __slotKeywordsSorted = [...__slotToGear.keys()].sort((a, b) => b.length - a.length);

function parseGearName(name) {
  if (!name) return null;
  // Grade — token-bounded so e.g. "Magnificent" doesn't match in some longer name.
  const grade = WISP_DATA.grades.find(g => new RegExp('\\b' + g + '\\b').test(name));
  if (!grade) return null;
  const tokens = name.split(/\s+/);
  for (const kw of __slotKeywordsSorted) {
    if (tokens.includes(kw)) {
      return { ...__slotToGear.get(kw), grade };
    }
  }
  return null;
}

// Upgrade recipe discovery — rewritten to parse by name pattern. A recipe is
// an upgrade if (a) it has no wisp material (those are wisp-crafts), (b) its
// product name parses to a (mode, key, slot, grade) tuple and (c) one of its
// 1× materials parses to the SAME (mode, key, slot) at exactly one grade
// lower. This catches both pre-reroll inputs (e.g. "Magnificent Ocean Shirt")
// and post-reroll inputs (e.g. "Delphinad Ocean Shirt") that aren't produced
// by any craft recipe.
function findUpgradeRecipes(recipes /* , sealingIndex — unused */) {
  const armorWispIds = new Set(Object.values(WISP_DATA.armor.wisp));
  const weaponWispIds = new Set(Object.values(WISP_DATA.weapons.wisp));

  const out = {
    armor: { Cloth: {}, Leather: {}, Plate: {} },
    weapons: { 'Wooden': {}, '2H Metal': {}, '1H Metal': {} },
  };
  for (const mat of WISP_DATA.armor.types) {
    for (const slot of WISP_DATA.armor.slots) {
      out.armor[mat][slot] = {};
      for (const g of WISP_DATA.grades) out.armor[mat][slot][g] = [];
    }
  }
  for (const t of WISP_DATA.weapons.types) {
    out.weapons[t] = {};
    for (const g of WISP_DATA.grades) out.weapons[t][g] = [];
  }
  const gradeIdx = (g) => WISP_DATA.grades.indexOf(g);

  for (const r of recipes) {
    if (r.materials.some(m => armorWispIds.has(String(m.itemType)) || weaponWispIds.has(String(m.itemType)))) continue;
    const productMeta = parseGearName(r.productName);
    if (!productMeta) continue;
    const productIdx = gradeIdx(productMeta.grade);
    if (productIdx <= 0) continue;
    // Find a 1× material with matching (mode, key, slot) at grade = productIdx - 1.
    for (const m of r.materials) {
      if (m.amount !== 1) continue;
      const inputMeta = parseGearName(m.name);
      if (!inputMeta) continue;
      if (inputMeta.mode !== productMeta.mode) continue;
      if (inputMeta.key !== productMeta.key) continue;
      if (inputMeta.slot !== productMeta.slot) continue;
      if (gradeIdx(inputMeta.grade) !== productIdx - 1) continue;
      const entry = {
        recipe: r,
        fromGrade: inputMeta.grade,
        toGrade: productMeta.grade,
        inputProductId: m.itemType,
        productId: r.productItemType,
        productName: r.productName,
      };
      if (productMeta.mode === 'armor') {
        out.armor[productMeta.key][productMeta.slot][productMeta.grade].push(entry);
      } else {
        out.weapons[productMeta.key][productMeta.grade].push(entry);
      }
      break;
    }
  }
  return out;
}

window.parseGearName = parseGearName;

// Find mana seal item IDs by canonical name pattern. Returns:
//   { armor: { Cloth: { Chest|Legs|Head|Hands|Feet|Arms|Waist: { grade: id } } }, weapons: {...} }
// Only Epherium / Delphinad / Ayanad have mana seals in the data.
function findManaSeals(recipes) {
  // Build name -> {id, labor} map from recipes that produce mana seals.
  // Labor is captured per-recipe so the chain calculator can add the cost of
  // crafting the seal yourself (user always crafts, never buys from AH).
  const byName = new Map();
  for (const r of recipes) {
    if (/mana seal$/i.test(r.productName)) {
      // Keep first occurrence; recipes are scanned in order, the canonical
      // wisp-only recipe appears before any alt variants.
      if (!byName.has(r.productName)) {
        byName.set(r.productName, {
          id: String(r.productItemType),
          labor: r.laborCost || 0,
        });
      }
    }
  }
  const out = {
    armor: { Cloth: {}, Leather: {}, Plate: {} },
    weapons: { 'Wooden': {}, '2H Metal': {}, '1H Metal': {} },
  };
  const sealGrades = ['Epherium', 'Delphinad', 'Ayanad'];
  for (const mat of WISP_DATA.armor.types) {
    for (const slot of WISP_DATA.armor.slots) {
      out.armor[mat][slot] = {};
      const slotClass = WISP_DATA.slotClass[slot];
      for (const g of sealGrades) {
        const name = `${g} ${mat} ${slotClass} Mana Seal`;
        const entry = byName.get(name);
        if (entry) out.armor[mat][slot][g] = entry;
      }
    }
  }
  for (const g of sealGrades) {
    // Weapon seal names use the long form ("Two-Hander Metal" / "One-Hander
    // Metal") in the recipe data, not the short type IDs ("2H Metal" / "1H
    // Metal") we use elsewhere. Map across when looking up by name.
    const weaponSealName = { 'Wooden': 'Wooden', '2H Metal': 'Two-Hander Metal', '1H Metal': 'One-Hander Metal' };
    for (const t of WISP_DATA.weapons.types) {
      const name = `${g} ${weaponSealName[t]} Mana Seal`;
      const entry = byName.get(name);
      if (entry) out.weapons[t][g] = entry;
    }
  }
  return out;
}

// Gilda Dust id — Gilda variants are the "ranking" recipe forms with extra
// materials; profit-focused crafting always uses the no-Gilda alternate when
// it exists. Stripped from candidate lists in both single-grade craft picks
// and chain upgrade picks.
const GILDA_DUST_ID = '8000026';
function preferNoGildaList(list) {
  if (!list || list.length <= 1) return list;
  const noGilda = list.filter(cr => !cr.recipe.materials.some(m => String(m.itemType) === GILDA_DUST_ID));
  return noGilda.length > 0 ? noGilda : list;
}

// Pick the cheapest recipe variant from a list (fully priced if possible).
// Returns { entry, matCost, missing, laborCost, breakdown } or null.
function pickCheapestRecipe(list, prices, laborGold, laborMult, opts = {}) {
  if (!list || list.length === 0) return null;
  const candidates = preferNoGildaList(list);
  const excludeId = opts.excludeId;
  let best = null;
  let firstPartial = null;
  for (const cr of candidates) {
    let cost = 0;
    const missing = [];
    const breakdown = [];
    for (const m of cr.recipe.materials) {
      if (excludeId != null && String(m.itemType) === String(excludeId)) {
        breakdown.push({ ...m, unit: null, sub: null, excluded: true });
        continue;
      }
      const p = prices[m.itemType];
      if (p == null) { missing.push(m.name); breakdown.push({ ...m, unit: null, sub: null }); }
      else { cost += p * m.amount; breakdown.push({ ...m, unit: p, sub: p * m.amount }); }
    }
    const effLp = (cr.recipe.laborCost || 0) * laborMult;
    const labor = effLp * laborGold;
    // totalCost intentionally excludes labor gold — the wisp page reports
    // material-only cost. Labor LP is tracked separately for EV g/lp.
    const row = { entry: cr, matCost: cost, missing, laborCost: labor, effLp, totalCost: cost, breakdown };
    if (missing.length === 0) {
      if (best == null || row.totalCost < best.totalCost) best = row;
    } else if (firstPartial == null) {
      firstPartial = row;
    }
  }
  return best || firstPartial;
}

// Enumerate viable upgrade-chain wisp sources for the selected (mode, key)
// across every slot (armor) or single dimension (weapons). Each chain:
//   start grade  → end grade   (start, end ∈ {Illus, Mag, Eph, Delph, Ayanad})
//   end > start  AND  start ≥ Magnificent (Illus intermediates need a Mag
//   reroll which no mana seal supports — those chains are dominated by
//   starting at Mag anyway).
//
// Returns array of chain rows in the same shape as listWispSources rows so
// the UI can show them inline.
function enumerateChains({ mode, key, sealingIndex, upgradeRecipes, manaSeals, prices, laborGold, laborMult, wispPrice }) {
  const out = [];
  const wispId = mode === 'armor' ? WISP_DATA.armor.wisp[key] : WISP_DATA.weapons.wisp[key];
  const slots = mode === 'armor' ? WISP_DATA.armor.slots : [null];
  const grades = WISP_DATA.grades;
  const gradeIdx = (g) => grades.indexOf(g);
  // Effective reroll success = glow + (1−glow) * base, both per-grade hardcoded.
  const successRate = (g) => {
    const glow = WISP_DATA.glowingRate[g] ?? 0;
    return glow + (1 - glow) * WISP_DATA.baseSuccessRate[g];
  };

  for (const slot of slots) {
    for (let s = 1; s < grades.length; s++) { // start ≥ Magnificent (idx 1)
      for (let e = s + 1; e < grades.length; e++) {
        const startGrade = grades[s];
        const endGrade = grades[e];
        const startList = mode === 'armor'
          ? sealingIndex.armor[key][slot][startGrade]
          : sealingIndex.weapons[key][startGrade];
        const startPick = pickCheapestRecipe(startList, prices, laborGold, laborMult);
        if (!startPick) continue;
        // Detect whether the start wisp-craft already gives upgradeable form.
        // It's "Sealed" form if the product name contains "Sealed " — those are
        // the Delph/Ay wisp-crafts.
        const startProducesSealed = /^Sealed /.test(startPick.entry.productName);

        const steps = [];
        let missing = [];
        let total = 0;
        let labor = 0;
        // Expected wisps consumed by seal crafting across all rerolls in the
        // chain. Each reroll = sealWispCost wisps × expected_attempts.
        let sealWispsBurned = 0;
        let okChain = true;

        // Step 1: initial wisp-craft.
        steps.push({
          kind: 'wisp-craft', grade: startGrade,
          recipe: startPick.entry.recipe, productName: startPick.entry.productName,
          matCost: startPick.matCost, laborCost: startPick.laborCost,
          effLp: startPick.effLp,
          totalCost: startPick.totalCost,
          breakdown: startPick.breakdown,
          missing: startPick.missing,
        });
        total += startPick.totalCost;
        labor += startPick.effLp;
        missing.push(...startPick.missing);

        // Loop: for each transition G → G+1 between start and end
        let needsRerollBeforeNextUpgrade = startProducesSealed;
        for (let k = s; k < e; k++) {
          const fromG = grades[k];
          const toG = grades[k + 1];
          // If we currently have Sealed at fromG and need to upgrade, reroll first.
          if (needsRerollBeforeNextUpgrade) {
            // User always crafts their own seals — cost = wisp materials +
            // recipe labor. No fallback to AH listings.
            let sealWispCost = null;
            let sealLaborLp = 0;
            let sealId = null;
            if (mode === 'armor') {
              const slotClass = WISP_DATA.slotClass[slot];
              sealWispCost = (WISP_DATA.manaSealWispCost[slotClass] || {})[fromG];
              const sealEntry = (manaSeals.armor[key][slot] || {})[fromG];
              sealId = sealEntry?.id;
              sealLaborLp = (sealEntry?.labor || 0) * laborMult;
            } else {
              sealWispCost = (WISP_DATA.manaSealWispCost.Weapon || {})[fromG];
              const sealEntry = (manaSeals.weapons[key] || {})[fromG];
              sealId = sealEntry?.id;
              sealLaborLp = (sealEntry?.labor || 0) * laborMult;
            }
            const sealLaborGold = sealLaborLp * laborGold;
            const sealWispGold = (sealWispCost != null && wispPrice != null)
              ? sealWispCost * wispPrice : null;
            // Seal cost = wisp materials only — labor LP is tracked separately
            // for the chain's total labor (and the EV g/lp denominator).
            const sealPrice = sealWispGold;
            const p = successRate(fromG);
            const expected = 1 / p;
            const cost = sealPrice != null ? sealPrice * expected : null;
            steps.push({
              kind: 'reroll', grade: fromG, sealId,
              sealWispCost, sealWispGold, sealLaborLp, sealLaborGold,
              sealPrice, pSuccess: p, expectedAttempts: expected,
              totalCost: cost,
            });
            if (cost == null) {
              missing.push(`${fromG} mana seal cost`);
              okChain = false;
            } else {
              total += cost;
              labor += sealLaborLp * expected;
              if (sealWispCost != null) sealWispsBurned += sealWispCost * expected;
            }
          }
          // Apply upgrade recipe (fromG Typhoon → Sealed toG). Exclude the typhoon
          // material from cost (we're producing it ourselves).
          const upList = mode === 'armor'
            ? upgradeRecipes.armor[key][slot][toG]
            : upgradeRecipes.weapons[key][toG];
          if (!upList || upList.length === 0) {
            missing.push(`no ${fromG}→${toG} upgrade recipe`);
            okChain = false;
            break;
          }
          // Find one whose input is the Typhoon piece we just produced/rerolled.
          // Use the cheapest fully priced upgrade.
          const upPick = pickCheapestRecipe(upList, prices, laborGold, laborMult, {
            excludeId: upList[0].inputProductId,
          });
          if (!upPick) {
            missing.push(`${fromG}→${toG} no priced upgrade`);
            okChain = false;
            break;
          }
          steps.push({
            kind: 'upgrade', fromGrade: fromG, toGrade: toG,
            recipe: upPick.entry.recipe, productName: upPick.entry.productName,
            matCost: upPick.matCost, laborCost: upPick.laborCost,
            effLp: upPick.effLp,
            totalCost: upPick.totalCost,
            breakdown: upPick.breakdown,
            missing: upPick.missing,
          });
          total += upPick.totalCost;
          labor += upPick.effLp;
          missing.push(...upPick.missing);
          // After an upgrade we have Sealed of toG. If there's another upgrade
          // coming, we'll need to reroll.
          needsRerollBeforeNextUpgrade = true;
        }
        if (!okChain) {
          out.push(buildChainRow({ mode, key, slot, startGrade, endGrade, steps, totalCost: null, salvageYield: null, wispId, prices, missing, wispPrice, laborGold }));
          continue;
        }
        const salvageYield = mode === 'armor'
          ? WISP_DATA.armor.salvage[endGrade][slot]
          : WISP_DATA.weapons.salvage[endGrade];
        const wispsConsumed = startPick.entry.recipe.materials.find(m => String(m.itemType) === wispId)?.amount || 0;
        out.push(buildChainRow({ mode, key, slot, startGrade, endGrade, steps, totalCost: total, salvageYield, wispsConsumed, sealWispsBurned, wispId, prices, missing, wispPrice, totalLabor: labor, laborGold }));
      }
    }
  }
  return out;
}

function buildChainRow({ mode, key, slot, startGrade, endGrade, steps, totalCost, salvageYield, wispsConsumed, sealWispsBurned, wispId, prices, missing, wispPrice, totalLabor, laborGold }) {
  const netWisps = (salvageYield != null && wispsConsumed != null) ? salvageYield - wispsConsumed : null;
  // Expected wisp return accounts for ALL wisp consumption: the initial
  // wisp-craft input plus the expected wisps burned crafting mana seals
  // across every reroll step in the chain.
  const expectedWispReturn = (netWisps != null && sealWispsBurned != null)
    ? netWisps - sealWispsBurned : null;
  // Cost per gross wisp produced (for comparison with AH wisp price).
  // total includes the wisps × AH_price already, so this is a clean cost-per-wisp.
  const costPerWisp = (totalCost != null && salvageYield != null && salvageYield > 0) ? totalCost / salvageYield : null;
  // EV g/labor — with labor excluded from totalCost, this reduces to
  // (gross wisp value at AH − totalCost) / total effective labor.
  let evGPerLp = null;
  if (totalCost != null && wispPrice != null && totalLabor != null && totalLabor > 0) {
    const grossValue = salvageYield * wispPrice;
    evGPerLp = (grossValue - totalCost) / totalLabor;
  }
  return {
    kind: 'chain', method: `Chain: ${startGrade} → ${endGrade}`,
    grade: `${startGrade}→${endGrade}`,
    slot,
    startGrade, endGrade,
    wispsPerCraft: salvageYield,
    netWisps,
    wispsConsumed,
    sealWispsBurned,
    expectedWispReturn,
    laborPerCraft: totalLabor || null,
    matCost: null,
    laborCost: null,
    totalCost,
    costPerWisp,
    evGPerLp,
    hasPrice: totalCost != null && missing.length === 0,
    recipe: null,
    productName: null,
    breakdown: null,
    steps,
    missing,
  };
}
// direct AH purchase + one row per (grade, slot) cell using the cheapest
// crafted-and-salvaged option. Returns rows sorted by costPerWisp asc with
// missing-price rows last.
function listWispSources({ mode, key, sealingIndex, upgradeRecipes, manaSeals, prices, laborGold, laborMult }) {
  const wispId = mode === 'armor' ? WISP_DATA.armor.wisp[key] : WISP_DATA.weapons.wisp[key];
  const wispPrice = prices[wispId];
  const out = [];
  const _laborMult = laborMult ?? 1;

  out.push({
    kind: 'ah-wisp',
    method: 'Buy wisp (AH)',
    grade: null,
    slot: null,
    wispsPerCraft: 1,
    wispsConsumed: 0,
    netWisps: 1,
    laborPerCraft: 0,
    matCost: null,
    laborCost: 0,
    totalCost: wispPrice,
    costPerWisp: wispPrice,
    hasPrice: wispPrice != null,
    recipe: null,
    productName: null,
    breakdown: null,
    missing: wispPrice == null ? ['AH wisp price not loaded'] : [],
  });

  const iter = mode === 'armor'
    ? WISP_DATA.armor.slots.flatMap(s => WISP_DATA.grades.map(g => ({ grade: g, slot: s })))
    : WISP_DATA.grades.map(g => ({ grade: g, slot: null }));

  for (const { grade, slot } of iter) {
    const cellRecipes = mode === 'armor'
      ? (sealingIndex.armor[key][slot] && sealingIndex.armor[key][slot][grade]) || []
      : (sealingIndex.weapons[key][grade]) || [];
    if (cellRecipes.length === 0) continue;
    const salvageYield = mode === 'armor'
      ? WISP_DATA.armor.salvage[grade][slot]
      : WISP_DATA.weapons.salvage[grade];

    // Pick the recipe with the lowest fully-priced craft cost. If none are
    // fully priced, surface the cheapest partial so the row still appears
    // marked as missing.
    let best = null;
    let firstPartial = null;
    const cellCandidates = preferNoGildaList(cellRecipes);
    for (const cr of cellCandidates) {
      const { cost, missing, breakdown } = craftMatCost(cr.recipe, prices);
      const effLp = (cr.recipe.laborCost || 0) * _laborMult;
      const laborCost = effLp * laborGold;
      // totalCost = materials only (labor excluded from wisp-page cost math).
      const total = cost;
      const row = {
        entry: cr, matCost: cost, laborCost, effLp, totalCost: total, missing, breakdown,
      };
      if (missing.length === 0) {
        if (best == null || total < best.totalCost) best = row;
      } else if (firstPartial == null) {
        firstPartial = row;
      }
    }
    const pick = best || firstPartial;
    if (!pick) continue;

    // Wisps consumed by this wisp-craft (the wisp material it requires).
    const craftWispsConsumed = pick.entry.recipe.materials.find(m => String(m.itemType) === wispId)?.amount || 0;

    // EV g/lp for craft row: (gross wisp value at AH − totalCost) / effective LP.
    // totalCost excludes labor gold (wisp page convention), so no extra adjustment.
    let evGPerLp = null;
    if (wispPrice != null && pick.matCost != null && pick.effLp > 0) {
      evGPerLp = (salvageYield * wispPrice - pick.matCost) / pick.effLp;
    }

    out.push({
      kind: 'craft',
      method: 'Craft → salvage',
      grade, slot,
      wispsPerCraft: salvageYield,
      wispsConsumed: craftWispsConsumed,
      netWisps: salvageYield - craftWispsConsumed,
      laborPerCraft: pick.effLp || 0,
      matCost: pick.matCost,
      laborCost: pick.laborCost,
      totalCost: pick.missing.length ? null : pick.totalCost,
      costPerWisp: pick.missing.length ? null : (pick.totalCost / salvageYield),
      evGPerLp,
      hasPrice: pick.missing.length === 0,
      recipe: pick.entry.recipe,
      productName: pick.entry.productName,
      breakdown: pick.breakdown,
      missing: pick.missing,
    });
  }

  out.sort((a, b) => {
    if (a.hasPrice !== b.hasPrice) return a.hasPrice ? -1 : 1;
    return (a.costPerWisp ?? Infinity) - (b.costPerWisp ?? Infinity);
  });

  // Append multi-grade chain sources (computed when upgrade recipe index +
  // mana seal map are passed in). Inserted after sort so the existing
  // single-grade rows keep their relative order; chains will be re-sorted
  // along with everything else below.
  if (upgradeRecipes && manaSeals) {
    const chains = enumerateChains({
      mode, key, sealingIndex, upgradeRecipes, manaSeals,
      prices, laborGold, laborMult: _laborMult, wispPrice,
    });
    for (const c of chains) out.push(c);
    out.sort((a, b) => {
      if (a.hasPrice !== b.hasPrice) return a.hasPrice ? -1 : 1;
      return (a.costPerWisp ?? Infinity) - (b.costPerWisp ?? Infinity);
    });
  }

  return { rows: out, wispPrice };
}

window.WISP_DATA = WISP_DATA;
window.buildSealingIndex = buildSealingIndex;
window.findUpgradeRecipes = findUpgradeRecipes;
window.findManaSeals = findManaSeals;
window.listWispSources = listWispSources;
