/* Regrade EV Calculator — expected cost to reach a target regrade grade.
 *
 * Supports:
 *   Crafted weapons  — 1H / 2H / Shield / Instrument at Illustrious–Ayanad
 *   Obsidian weapons — 1H / 2H / Shield at Tier 1–6 / Brimstone
 *   Crafted armor    — Chest / Legs / Head / Hands·Feet / Wrist·Waist at Illustrious–Ayanad
 *   Obsidian armor   — Chest / Head / Legs / Hands·Feet at Tier 1–6 / Brimstone
 *   Instruments share fees with shields (user-confirmed).
 *
 * Failure mechanics (user-verified):
 *   Basic → Unique   : stay on fail (no grade loss)
 *   Celestial        : 50% destroy item / 50% drop to Arcane
 *   Divine+          : always destroy on fail
 *
 * Success rates (user-verified, 4 difficulty tiers):
 *   Crafted gear uses Difficult rates.
 */

const { useState: useStateRg, useMemo: useMemoRg, useEffect: useEffectRg } = React;

// ─── Grade catalogue ─────────────────────────────────────────────────────
const RGRADE = [
  { name: 'Basic',     pByTier: { easy: 1.000, normal: 1.000, difficult: 1.000, ship: 1.000 }, failMode: 'stay',      charm: null },
  { name: 'Grand',     pByTier: { easy: 1.000, normal: 1.000, difficult: 1.000, ship: 0.600 }, failMode: 'stay',      charm: null },
  { name: 'Rare',      pByTier: { easy: 1.000, normal: 1.000, difficult: 1.000, ship: 0.600 }, failMode: 'stay',      charm: null },
  { name: 'Arcane',    pByTier: { easy: 0.675, normal: 0.500, difficult: 0.325, ship: 0.600 }, failMode: 'stay',      charm: { name: 'Dazzling Arcane Regrade Charm',  mult: 1.75, itemType: '34563' } },
  { name: 'Heroic',    pByTier: { easy: 0.675, normal: 0.500, difficult: 0.325, ship: 0.600 }, failMode: 'stay',      charm: { name: 'Dazzling Heroic Regrade Charm',  mult: 1.75, itemType: '34323' } },
  { name: 'Unique',    pByTier: { easy: 0.473, normal: 0.350, difficult: 0.228, ship: 0.500 }, failMode: 'stay',      charm: { name: 'Unique Regrade Charm',           mult: 1.50, itemType: '31974' } },
  { name: 'Celestial', pByTier: { easy: 0.405, normal: 0.300, difficult: 0.195, ship: 0.500 }, failMode: 'celestial', charm: { name: 'Celestial Regrade Charm',         mult: 1.50, itemType: '38755' } },
  { name: 'Divine',    pByTier: { easy: 0.135, normal: 0.100, difficult: 0.065, ship: 0.400 }, failMode: 'destroy',   charm: null },
  { name: 'Epic',      pByTier: { easy: 0.108, normal: 0.080, difficult: 0.052, ship: 0.350 }, failMode: 'destroy',   charm: null },
  { name: 'Legendary', pByTier: { easy: 0.041, normal: 0.030, difficult: 0.020, ship: 0.175 }, failMode: 'destroy',   charm: null },
  { name: 'Mythic',    pByTier: { easy: null,  normal: null,  difficult: null,  ship: null  }, failMode: 'destroy',   charm: null },
];

const DIFF_TIERS = [
  { id: 'normal',    label: 'Normal',     sub: 'Standard gear / drops' },
  { id: 'difficult', label: 'Difficult',  sub: 'Crafted gear' },
  { id: 'easy',      label: 'Easy',       sub: 'Event / boosted' },
  { id: 'ship',      label: 'Ship · Pet', sub: 'Ships, mounts, pets' },
];

// ─── Regrade fee data ─────────────────────────────────────────────────────
//
// Fees are gold per scroll attempt, indexed [0–9] by transition:
//   [0] Basic→Grand  [1] Grand→Rare  [2] Rare→Arcane  [3] Arcane→Heroic
//   [4] Heroic→Unique  [5] Unique→Celestial  [6] Celestial→Divine
//   [7] Divine→Epic  [8] Epic→Legendary  [9] Legendary→Mythic
//
// Source: linked spreadsheet (user confirmed "slightly wrong" placeholder).
// "--" entries in sheet (item doesn't start at Basic) stored as 0.
// Instrument fees identical to Shield fees (user-confirmed).

const CRAFTED_TIERS  = ['Illustrious', 'Magnificent', 'Epherium', 'Delphinad', 'Ayanad'];
const OBSIDIAN_TIERS = ['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4', 'Tier 5', 'Tier 6', 'Brimstone'];

const RFEES = {
  'crafted-weapon': {
    '1H': {
      Illustrious: [26.81, 30.06, 38.19, 48.62,  61.54,  85.98,  116.97, 154.98, 189.00,  244.00],
      Magnificent: [31.59, 36.29, 48.01, 63.06,  81.70,  116.97, 161.66, 216.51, 306.59,  383.00],
      Epherium:    [37.46, 43.93, 60.06, 80.77,  106.44, 154.98, 216.51, 292.01, 382.00,  518.00],
      Delphinad:   [44.49, 53.08, 74.49, 101.99, 136.07, 200.51, 282.18, 382.42, 547.03,  749.52],
      Ayanad:      [52.75, 63.82, 91.44, 126.91, 170.86, 253.97, 359.31, 488.59, 700.90,  962.06],
    },
    '2H': {
      Illustrious: [27.71, 31.24, 40.03, 51.33,  65.34,  91.82,  125.38, 166.57, 234.21,  283.00],
      Magnificent: [32.89, 37.98, 50.67, 66.98,  87.18,  125.38, 173.80, 233.22, 330.81,  450.84],
      Epherium:    [39.25, 46.26, 63.73, 86.17,  113.98, 166.57, 233.22, 315.01, 449.35,  585.00],
      Delphinad:   [46.87, 56.17, 79.36, 109.16, 146.07, 215.88, 304.37, 412.95, 591.28,  790.00],
      Ayanad:      [55.81, 67.81, 97.72, 136.15, 183.76, 273.80, 387.92, 527.97, 757.98, 1040.91],
    },
    'Shield': {
      Illustrious: [25.01, 27.72, 34.49, 43.18,  53.95,  74.32,  100.14, 131.82, 157.00, 201.00],
      Magnificent: [28.99, 32.91, 42.67, 55.21,  70.75,  100.14, 137.38, 183.09, 258.16, 316.00],
      Epherium:    [33.89, 39.28, 52.72, 69.98,  91.37,  131.82, 183.09, 246.01, 316.00, 424.00],
      Delphinad:   [39.74, 46.90, 64.74, 87.66,  116.06, 169.75, 237.82, 321.35, 458.53, 598.00],
      Ayanad:      [46.62, 55.85, 78.86, 108.42, 145.05, 214.31, 302.10, 409.83, 586.75, 797.00],
    },
    'Instrument': {
      // Instruments share fees with shields (user-confirmed).
      Illustrious: [25.01, 27.72, 34.49, 43.18,  53.95,  74.32,  100.14, 131.82, 157.00, 201.00],
      Magnificent: [28.99, 32.91, 42.67, 55.21,  70.75,  100.14, 137.38, 183.09, 258.16, 316.00],
      Epherium:    [33.89, 39.28, 52.72, 69.98,  91.37,  131.82, 183.09, 246.01, 316.00, 424.00],
      Delphinad:   [39.74, 46.90, 64.74, 87.66,  116.06, 169.75, 237.82, 321.35, 458.53, 598.00],
      Ayanad:      [46.62, 55.85, 78.86, 108.42, 145.05, 214.31, 302.10, 409.83, 586.75, 797.00],
    },
  },
  'obsidian-weapon': {
    '1H': {
      'Tier 1':   [0,     38.68, 51.77, 68.59,  89.43,  160.91, 240.08, 313.47, 431.36,  613.11],
      'Tier 2':   [0,     41.22, 55.78, 74.49,  97.67,  177.18, 265.23, 346.87, 477.99,  675.00],
      'Tier 3':   [0,     43.93, 60.06, 80.77,  106.44, 194.50, 292.01, 382.42, 527.63,  751.50],
      'Tier 4':   [0,     46.81, 64.60, 84.45,  115.76, 212.88, 320.45, 420.17, 580.34,  840.00],
      'Tier 5':   [0,     54.00, 76.00, 103.00, 130.78, 242.52, 366.28, 481.00, 665.29,  949.40],
      'Tier 6':   [0,     58.25, 82.64, 113.97, 152.80, 285.98, 433.48, 570.22, 789.86, 1128.47],
      'Brimstone':[0,     67.77, 97.67, 136.07, 183.65, 346.87, 527.63, 695.20, 964.38, 1379.36],
    },
    '2H': {
      'Tier 1':   [0,     40.56, 54.75, 72.97,  95.54,  172.99, 258.75, 338.26, 465.97,  662.87],
      'Tier 2':   [0,     43.32, 59.10, 79.36,  104.48, 190.61, 286.00, 374.44, 516.49,  751.00],
      'Tier 3':   [0,     46.26, 63.73, 86.17,  113.98, 209.37, 315.01, 412.95, 570.27,  856.00],
      'Tier 4':   [0,     49.38, 68.65, 93.40,  124.08, 229.29, 345.82, 453.85, 627.37,  976.00],
      'Tier 5':   [0,     54.40, 76.57, 105.05, 140.34, 261.40, 395.47, 519.76, 719.40, 1113.00],
      'Tier 6':   [0,     67.77, 88.19, 122.14, 164.20, 308.48, 468.27, 616.40, 854.34, 1221.18],
      'Brimstone':[0,     72.09, 104.48,146.07, 197.62, 374.44, 570.27, 751.81,1043.41, 1492.97],
    },
    'Shield': {
      'Tier 1':   [0,     34.90, 45.81, 59.82,  77.19,  136.76, 202.73, 263.89, 362.13,  513.59],
      'Tier 2':   [0,     37.02, 49.15, 64.74,  84.06,  150.32, 223.70, 291.72, 393.00,  582.00],
      'Tier 3':   [0,     39.28, 52.72, 69.98,  91.37,  164.75, 246.01, 321.35, 442.36,  655.00],
      'Tier 4':   [0,     41.67, 56.50, 75.54,  99.13,  180.07, 269.71, 352.80, 486.28,  727.00],
      'Tier 5':   [0,     45.54, 62.59, 84.50,  111.65, 204.77, 307.90, 403.50, 557.07,  844.00],
      'Tier 6':   [0,     51.21, 71.53, 97.64,  130.00, 240.98, 363.90, 477.85, 660.88,  943.06],
      'Brimstone':[0,     59.14, 85.06, 116.06, 155.71, 291.72, 442.36, 582.00, 806.31, 1152.13],
    },
  },
  'crafted-armor': {
    'Chest': {
      Illustrious: [23.20, 25.38, 30.79, 37.74, 46.36,  62.66,  83.31,  108.66, 170.00, 241.00],
      Magnificent: [26.39, 29.53, 37.34, 47.37, 59.80,  83.31,  113.11, 149.67, 220.00, 304.00],
      Epherium:    [30.31, 34.62, 45.37, 59.18, 76.30,  108.66, 149.67, 200.01, 282.68, 389.00],
      Delphinad:   [35.00, 40.72, 54.99, 73.33, 96.05,  139.00, 193.46, 260.28, 370.02, 472.00],
      Ayanad:      [40.50, 47.88, 66.29, 89.94, 119.24, 174.65, 244.88, 331.06, 472.60, 619.00],
    },
    'Legs': {
      Illustrious: [21.40, 23.03, 27.09, 32.31, 38.77, 50.99,  66.48,  85.49,  116.71, 128.00],
      Magnificent: [23.80, 26.15, 32.00, 39.53, 48.85, 66.48,  88.83,  116.26, 161.30, 185.00],
      Epherium:    [26.73, 29.97, 38.03, 48.39, 61.22, 85.49,  116.26, 154.01, 216.01, 258.00],
      Delphinad:   [30.25, 34.54, 45.24, 58.99, 76.03, 108.25, 149.09, 199.21, 281.52, 348.00],
      Ayanad:      [34.37, 39.91, 53.72, 71.45, 93.43, 134.98, 187.66, 252.30, 358.45, 489.03],
    },
    'Head': {
      Illustrious: [20.50, 21.86, 25.24, 29.59, 34.98, 45.16,  58.07,  73.91,  82.00,  98.00],
      Magnificent: [22.50, 24.45, 29.34, 35.61, 43.68, 58.07,  76.69,  99.55,  114.00, 142.00],
      Epherium:    [24.94, 27.64, 34.36, 42.99, 53.69, 73.91,  99.55,  131.01, 156.00, 199.00],
      Delphinad:   [27.87, 31.45, 40.37, 51.83, 66.03, 92.88,  126.91, 168.67, 207.00, 271.00],
      Ayanad:      [31.31, 35.93, 47.43, 62.21, 80.52, 115.15, 159.05, 212.91, 301.38, 410.19],
    },
    'Hands / Feet': {
      Illustrious: [19.60, 20.69, 23.40, 26.87, 31.18, 39.33, 49.66,  62.33,  76.00,  94.00],
      Magnificent: [21.20, 22.76, 26.67, 31.69, 37.90, 49.66, 64.55,  82.84,  105.00, 134.00],
      Epherium:    [23.15, 25.31, 30.69, 37.59, 46.15, 62.33, 82.84,  108.00, 149.34, 200.18],
      Delphinad:   [25.50, 28.36, 35.50, 44.66, 56.02, 77.50, 104.73, 138.14, 193.01, 254.00],
      Ayanad:      [28.25, 31.94, 41.15, 52.97, 67.62, 95.32, 130.44, 173.53, 244.30, 331.35],
    },
    'Wrist / Waist': {
      Illustrious: [18.70, 19.52, 21.55, 24.15, 27.36, 33.50, 41.24, 50.75,  72.00,  93.00],
      Magnificent: [19.90, 21.07, 24.00, 27.76, 32.43, 41.24, 52.42, 66.13,  92.00,  119.00],
      Epherium:    [21.37, 22.98, 27.01, 32.19, 38.61, 50.75, 66.13, 85.00,  116.00, 154.00],
      Delphinad:   [23.12, 25.27, 30.62, 37.50, 46.02, 62.13, 82.55, 107.60, 148.76, 191.00],
      Ayanad:      [25.19, 27.96, 34.86, 43.73, 54.71, 75.49, 101.83,134.15, 187.23, 252.52],
    },
  },
  'obsidian-armor': {
    'Chest': {
      'Tier 1': [0,     31.12, 39.85, 51.06,  64.95,  112.61, 165.39, 214.31, 292.91, 414.07],
      'Tier 2': [32.81, 42.52, 54.99, 70.45,  123.45, 182.16, 236.58, 318.00, 458.76, 629.00],
      'Tier 3': [34.62, 45.37, 59.18, 76.30,  135.00, 200.01, 260.28, 357.09, 518.00, 715.00],
      'Tier 4': [36.54, 48.40, 63.63, 82.51,  147.26, 218.97, 285.44, 392.23, 557.00, 758.50],
      'Tier 5': [39.63, 53.28, 70.80, 92.52,  167.01, 249.52, 326.00, 448.86, 662.00, 899.00],
      'Tier 6': [44.16, 60.43, 81.32, 107.20, 195.99, 294.32, 385.48, 531.90, 758.00,1052.00],
    },
    'Head': {
      'Tier 1': [0,     25.45, 30.90, 37.91, 46.59,  76.38,  109.37, 139.95, 189.07, 256.00],
      'Tier 2': [26.51, 32.58, 40.37, 50.03, 83.16,  119.85, 153.86, 206.00, 287.00, 401.00],
      'Tier 3': [27.64, 34.36, 42.99, 53.69, 90.37,  131.01, 168.67, 233.00, 328.00, 454.00],
      'Tier 4': [28.84, 36.25, 45.77, 57.57, 98.04,  142.85, 184.40, 251.14, 347.00, 480.00],
      'Tier 5':    [30.77, 39.30, 50.25, 63.82, 110.38, 161.95, 209.75, 286.54, 404.92, 564.00],
      'Tier 6':    [33.60, 43.77, 56.82, 73.00, 128.49, 189.95, 246.92, 338.44, 491.00, 661.00],
      'Brimstone': [36.74, 48.72, 64.10, 83.16, 148.54, 220.95, 293.54, 411.16, 584.07, 765.75],
    },
    'Legs': {
      'Tier 1':    [0,     27.3376, 33.8844, 42.293,  52.713,  88.455,   128.0393, 164.7357, 223.6802, 314.5551],
      'Tier 2':    [0,     28.6104, 35.8922, 45.2448, 56.8346, 96.5892,  140.6174, 181.4336, 246,      340],
      'Tier 3':    [0,     29.9653, 38.0295, 48.387,  61.2221, 105.2481, 154.0068, 199.2085, 271.8147, 379],
      'Tier 4':    [0,     31.404,  40.2989, 51.7235, 65.8809, 114.4424, 168.2243, 218.0827, 315,      433],
      'Tier 5':    [0,     33.7228, 43.9567, 57.101,  73.3895, 129.2611, 191.1388, 248.5024, 340.6439, 490],
      'Tier 6':    [0,     37.123,  49.3203, 64.9864, 84.3999, 150.9907, 224.74,   293.109,  402.9283, 591],
      'Brimstone': [0,     41.8866, 56.8346, 76.0338, 99.8254, 181.4336, 271.8148, 342.4916, 471.8814, 697.6789],
    },
    'Hands / Feet': {
      'Tier 1':    [0,     23.5584, 27.9229, 33.5287, 40.4754, 64.3033,  90.6928,  115.1572, 154.4535, 215.0367],
      'Tier 2':    [0,     24.4069, 29.2614, 35.4965, 43.2231, 69.7261,  99.0783,  126.2891, 168,      220],
      'Tier 3':    [0,     25.3102, 30.6863, 37.5913, 46.1481, 75.4987,  108.0046, 138.139,  190,      249],
      'Tier 4':    [0,     26.2693, 32.1993, 39.8157, 49.2539, 81.6283,  117.4829, 150.7218, 204.1125, 273],
      'Tier 5':    [0,     27.8152, 34.6378, 43.4007, 54.2597, 91.5074,  132.7592, 171.0016, 232.4293, 317],
      'Tier 6':    [0,     30.082,  38.2135, 48.6576, 61.5999, 105.9938, 155.16,   200.7393, 273.9522, 386.8247],
      'Brimstone': [0,     33.2578, 43.2231, 56.0225, 71.8836, 126.2891, 186.5432, 238.0313, 326.0231, 470.4526],
    },
  },
};

// ─── Gear category config ─────────────────────────────────────────────────
const GEAR_CATS = [
  { id: 'crafted-weapon',  label: 'Crafted Weapon',  subtypes: ['1H', '2H', 'Shield', 'Instrument'], tiers: CRAFTED_TIERS  },
  { id: 'obsidian-weapon', label: 'Obsidian Weapon', subtypes: ['1H', '2H', 'Shield'],               tiers: OBSIDIAN_TIERS },
  { id: 'crafted-armor',   label: 'Crafted Armor',   subtypes: ['Chest', 'Legs', 'Head', 'Hands / Feet', 'Wrist / Waist'], tiers: CRAFTED_TIERS  },
  { id: 'obsidian-armor',  label: 'Obsidian Armor',  subtypes: ['Chest', 'Head', 'Legs', 'Hands / Feet'], tiers: OBSIDIAN_TIERS },
];

// Obsidian weapons: match a specific recipe by name keyword and sum its materials.
const RG_RECIPE_HINT = {
  'obsidian-weapon': { '1H': 'obsidian sword', '2H': 'obsidian nodachi', 'Shield': 'obsidian shield' },
};

// Crafted weapons: cost = count × AH price of a named item (wisp component).
const RG_WISP_COST = {
  'crafted-weapon': {
    '1H':         { nameHint: 'one-hander metal mana wisp',  count: 6 },
    '2H':         { nameHint: 'two-hander metal mana wisp',  count: 6 },
    'Shield':     { nameHint: 'wooden mana wisp',            count: 6 },
    'Instrument': { nameHint: 'musical mana wisp',           count: 6 },
  },
};

// Crafted armor: cost = count × AH price of the material-specific wisp.
const RG_ARMOR_WISP_COUNT = {
  'Chest':         24,
  'Legs':          18,
  'Head':          15,
  'Hands / Feet':  15,
  'Wrist / Waist': 9,
};
const RG_OBS_ARMOR_WISP_COUNT = {
  'Chest':        4,
  'Legs':         3,
  'Head':         2,
  'Hands / Feet': 2,
};
const RG_ARMOR_WISP_NAME = {
  cloth:   'cloth mana wisp',
  leather: 'leather mana wisp',
  plate:   'plate mana wisp',
};
const OBS_ARMOR_TOPAZ_COUNT = 10;
const OBS_ARMOR_TOPAZ_HINT  = 'possessed topaz';

// ─── Regrade scroll item IDs ──────────────────────────────────────────────
// Each gear category uses a category-specific scroll, plus a Lucky Point
// that's crafted with the scroll to produce the Resplendent variant:
//   Weapon scroll + Lucky Sunpoint  → Resplendent Weapon Regrade Scroll
//   Armor scroll  + Lucky Moonpoint → Resplendent Armor Regrade Scroll
//   Accessory     + Lucky Starpoint → Resplendent Accessory Regrade Scroll
// We price the resplendent attempt as (regular scroll + Lucky Point), since
// that's the actual cost the player pays per attempt when self-crafting.
// Prices are looked up against ctx.prices at run-time.
const SCROLL_IDS = {
  'crafted-weapon':  { regular: '28298', luckyPoint: '28300', luckyName: 'Lucky Sunpoint'  },
  'obsidian-weapon': { regular: '28298', luckyPoint: '28300', luckyName: 'Lucky Sunpoint'  },
  'crafted-armor':   { regular: '28299', luckyPoint: '28308', luckyName: 'Lucky Moonpoint' },
  'obsidian-armor':  { regular: '28299', luckyPoint: '28308', luckyName: 'Lucky Moonpoint' },
  // Accessories (Lucky Starpoint 31930) would slot in here once the gear category is added.
};

function feesFor(catId, subtype, tier) {
  const cat = RFEES[catId];
  if (!cat) return Array(10).fill(0);
  const sub = cat[subtype];
  if (!sub) return Array(10).fill(0);
  return sub[tier] || Array(10).fill(0);
}

// ─── localStorage keys ────────────────────────────────────────────────────
const RG_CAT_KEY    = 'craft.regrade.cat.v1';
const RG_SUB_KEY    = 'craft.regrade.sub.v1';
const RG_TIER_KEY   = 'craft.regrade.tier.v1';
const RG_FROM_KEY   = 'craft.regrade.from.v1';
const RG_TO_KEY     = 'craft.regrade.to.v1';
const RG_DIFF_KEY   = 'craft.regrade.diff.v1';
const RG_CHARM_KEY  = 'craft.regrade.charm.v1';
const RG_RESPLD_KEY = 'craft.regrade.resplendent.v1';
const RG_RESPMAP_KEY = 'craft.regrade.respMap.v1';
const RG_BASECOST_KEY = 'craft.regrade.baseCost.v1';
const RG_AUTOCOST_KEY  = 'craft.regrade.autoCost.v1';
const RG_MATTYPE_KEY   = 'craft.regrade.matType.v1';

// ─── EV computation ───────────────────────────────────────────────────────
//
// Celestial math (fail = 50% destroy, 50% → Arcane):
//   E[Celestial attempts] = 2/(1+p)
//   E[reclimbs]           = (1−p)/(1+p)
//   P(survive → Divine)   = 2p/(1+p)
//   E[stage cost]         = E[attempts]×fee + E[reclimbs]×(Arcane→Celestial scroll cost)
//
// Resplendent: skip reduction applied at every grade after the first in path.
//   P(previous grade skipped here) = 0.2 → reduces expected scrolls by 20%.
//
function computeRegradeEV(fromIdx, toIdx, tier, charmEnabled, respEnabled, fees, baseItemCost = 0, scrollPrices = {}) {
  const regularPrice = scrollPrices.regular    ?? 0;
  const luckyPrice   = scrollPrices.luckyPoint ?? 0;

  function effFeeAt(i) {
    return (fees[i] || 0) + regularPrice + (i >= 6 && respEnabled[i] ? luckyPrice : 0);
  }

  function pEff(i) {
    const g = RGRADE[i];
    const base = g.pByTier[tier] ?? 0;
    const boost = charmEnabled[i] && g.charm != null;
    return Math.min(1, base * (boost ? g.charm.mult : 1));
  }

  // Reclimb cost from a Celestial drop (back to Arcane) → reclimb to Cel.
  // Grades 3-5 (Arcane/Heroic/Unique) don't use resplendent, so no skip factors here.
  function reclimbToCelGold() {
    let c = 0;
    if (pEff(3) > 0) c += effFeeAt(3) / pEff(3);
    if (pEff(4) > 0) c += effFeeAt(4) / pEff(4);
    if (pEff(5) > 0) c += effFeeAt(5) / pEff(5);
    return c;
  }

  const reclimbCel = reclimbToCelGold();
  const stages    = [];
  // Cumulative cost from fromGrade up to (but not including) the current stage,
  // with all destructions + replacements + reclimbs along the way already baked in.
  // Used as the "climb-back cost" when an item destroys at a higher grade — you
  // have to re-acquire the item AND pay to re-climb to where you were.
  let T = 0;

  for (let i = fromIdx; i < toIdx; i++) {
    const g           = RGRADE[i];
    const p           = pEff(i);
    const fee         = fees[i] || 0;
    const effFee      = effFeeAt(i);
    const useCharm    = !!(charmEnabled[i] && g.charm);
    const useResp     = i >= 6 && !!respEnabled[i];
    // Skip factor: resp at the previous grade gives 20% chance of landing here already upgraded.
    // Only applies at Divine (7) and above — resp is unavailable below Celestial (6).
    const useRespPrev = i >= 7 && !!respEnabled[i - 1];
    const skipPct     = useRespPrev ? 0.20 : 0;
    const skipFactor  = 1 - skipPct;

    if (g.failMode === 'stay') {
      const scrolls = (1 / p) * skipFactor;
      const sc      = scrolls * effFee;
      stages.push({ gradeIdx: i, gradeName: g.name, p, useCharm, useResp, charmName: g.charm?.name ?? null, fee, scrollsExpected: scrolls, reclimbs: null, reclimbGold: null, scrollCost: sc, itemLossCost: 0, totalCost: sc, survivalProb: 1, kind: 'safe' });
      T += sc;

    } else if (g.failMode === 'celestial') {
      // With replacement: keep climbing back after destruction until success.
      //   E[scrolls at Cel per success] = 1/p
      //   E[Arcane→Cel reclimbs]        = (1−p)/(2p)
      //   E[item destructions]          = (1−p)/(2p)
      // Each destruction = baseItemCost + climb-back from Grand to here (T).
      const scrolls          = (1 / p) * skipFactor;
      const expectedReclimbs = ((1 - p) / (2 * p)) * skipFactor;
      const expectedDestroys = ((1 - p) / (2 * p)) * skipFactor;
      const sc               = scrolls * effFee + expectedReclimbs * reclimbCel;
      const itemLossCost     = expectedDestroys * (baseItemCost + T);
      const survivalProb     = 2 * p / (1 + p);
      stages.push({ gradeIdx: i, gradeName: g.name, p, useCharm, useResp, charmName: g.charm?.name ?? null, fee, scrollsExpected: scrolls, reclimbs: expectedReclimbs, reclimbGold: reclimbCel, scrollCost: sc, itemLossCost, totalCost: sc + itemLossCost, survivalProb, kind: 'celestial' });
      T += sc + itemLossCost;

    } else {
      // Destroy on fail. With replacement:
      //   E[scrolls per success] = 1/p
      //   E[destructions]        = (1−p)/p
      // Each destruction = baseItemCost + climb-back from Grand to here (T).
      const scrolls          = (1 / p) * skipFactor;
      const sc               = scrolls * effFee;
      const expectedDestroys = ((1 - p) / p) * skipFactor;
      const itemLossCost     = expectedDestroys * (baseItemCost + T);
      stages.push({ gradeIdx: i, gradeName: g.name, p, useCharm, useResp, charmName: g.charm?.name ?? null, fee, scrollsExpected: scrolls, reclimbs: null, reclimbGold: null, scrollCost: sc, itemLossCost, totalCost: sc + itemLossCost, survivalProb: p, kind: 'destroy' });
      T += sc + itemLossCost;
    }
  }
  return stages;
}

// ─── Optimal plan optimizer ──────────────────────────────────────────────
//
// Enumerates 2^N (charms) × 2^M (per-grade resplendent) combinations and picks
// the minimum-cost combination. Each charm slot and each resplendent slot is
// an independent binary choice. Resp at a grade only matters if the next grade
// is in the chain (skip lands at i+1), so the optimizer naturally picks "off"
// for the final transition.
function computeOptimalCharmPlan(fromIdx, toIdx, tier, fees, prices, baseItemCost = 0, scrollPrices = {}) {
  const charmSlots = [];
  for (let i = fromIdx; i < toIdx; i++) {
    if (RGRADE[i].charm?.itemType) charmSlots.push(i);
  }
  // Resplendent slots: Celestial (6) and above only. Only enumerated when we
  // have a Lucky Point price — resp is unavailable below Celestial.
  const respKnown  = scrollPrices?.luckyPoint != null;
  const respSlots  = respKnown
    ? Array.from({ length: toIdx - fromIdx }, (_, k) => fromIdx + k).filter(gi => gi >= 6)
    : [];
  const N = charmSlots.length;
  const M = respSlots.length;

  if (N === 0 && M === 0) return null;
  // If we have no charm prices AND no resp prices, nothing to optimize.
  if (N > 0 && !charmSlots.some(gi => prices?.[RGRADE[gi].charm.itemType] != null) && M === 0) return null;

  const combos = [];
  for (let cmask = 0; cmask < (1 << N); cmask++) {
    for (let rmask = 0; rmask < (1 << M); rmask++) {
      const ce = {}, re = {};
      for (let j = 0; j < N; j++) ce[charmSlots[j]] = !!(cmask & (1 << j));
      for (let k = 0; k < M; k++) re[respSlots[k]]  = !!(rmask & (1 << k));

      const stages    = computeRegradeEV(fromIdx, toIdx, tier, ce, re, fees, baseItemCost, scrollPrices);
      const scrollFee = stages.reduce((s, st) => s + st.totalCost, 0);
      let   charmCost = 0;
      let   noPrice   = false;

      // Main-path charm purchases
      for (let j = 0; j < N; j++) {
        if (!(cmask & (1 << j))) continue;
        const price = prices?.[RGRADE[charmSlots[j]].charm.itemType];
        if (price == null) { noPrice = true; continue; }
        const st = stages.find(s => s.gradeIdx === charmSlots[j]);
        if (st) charmCost += st.scrollsExpected * price;
      }
      // Reclimb charm purchases for Arcane(3)/Heroic(4)/Unique(5)
      const cel = stages.find(s => s.kind === 'celestial');
      if (cel?.reclimbs > 0) {
        for (const gi of [3, 4, 5]) {
          const j = charmSlots.indexOf(gi);
          if (j === -1 || !(cmask & (1 << j))) continue;
          const price = prices?.[RGRADE[gi].charm.itemType];
          if (price == null) { noPrice = true; continue; }
          const pB = Math.min(1, (RGRADE[gi].pByTier[tier] ?? 0) * RGRADE[gi].charm.mult);
          if (pB > 0) charmCost += cel.reclimbs * (1 / pB) * price;
        }
      }

      combos.push({ ce, re, cmask, rmask, scrollFee, charmCost, total: scrollFee + charmCost, noPrice });
    }
  }

  // Pick best valid combo, falling back to all-off if everything is missing.
  let bestI = -1, bestCost = Infinity;
  for (let i = 0; i < combos.length; i++) {
    if (!combos[i].noPrice && combos[i].total < bestCost) {
      bestCost = combos[i].total;
      bestI = i;
    }
  }
  if (bestI === -1) bestI = combos.findIndex(c => c.cmask === 0 && c.rmask === 0);
  const best     = combos[bestI];
  const baseline = combos.find(c => c.cmask === 0 && c.rmask === 0) || best;

  // Marginal savings: flip just one charm bit at the best mask/resp.
  const charmDecisions = charmSlots.map((gi, j) => {
    const flipMask = best.cmask ^ (1 << j);
    const flipped  = combos.find(c => c.cmask === flipMask && c.rmask === best.rmask);
    const inOptimal = !!(best.cmask & (1 << j));
    const g = RGRADE[gi];
    const price = prices?.[g.charm.itemType] ?? null;
    const saving = (flipped && !flipped.noPrice && !best.noPrice)
      ? (inOptimal ? flipped.total - best.total : best.total - flipped.total)
      : null;
    return { gradeIdx: gi, gradeName: g.name, charmName: g.charm.name, mult: g.charm.mult, price, inOptimal, saving };
  });

  // Marginal savings: flip just one resp bit at the best mask/resp.
  const respDecisions = respSlots.map((gi, k) => {
    const flipMask = best.rmask ^ (1 << k);
    const flipped  = combos.find(c => c.cmask === best.cmask && c.rmask === flipMask);
    const inOptimal = !!(best.rmask & (1 << k));
    const saving = (flipped && !flipped.noPrice && !best.noPrice)
      ? (inOptimal ? flipped.total - best.total : best.total - flipped.total)
      : null;
    return { gradeIdx: gi, gradeName: RGRADE[gi].name, inOptimal, saving };
  });

  return {
    bestCE:          best.ce,
    bestRE:          best.re,
    bestScrollFees:  best.scrollFee,
    bestCharmCost:   best.charmCost,
    bestTotal:       best.total,
    baselineTotal:   baseline.total,
    totalSaving:     (!baseline.noPrice && !best.noPrice) ? baseline.total - best.total : null,
    decisions:       charmDecisions, // kept for backwards-compat with existing UI naming
    charmDecisions,
    respDecisions,
    respAvailable:   respKnown,
    allPricesKnown:  charmSlots.every(gi => prices?.[RGRADE[gi].charm.itemType] != null),
  };
}

// ─── Monte Carlo helpers ──────────────────────────────────────────────────
function mcMean(arr)   { return arr.reduce((s, v) => s + v, 0) / arr.length; }
function mcMedian(arr) {
  const s = [...arr].sort((a, b) => a - b);
  const m = Math.floor(s.length / 2);
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function mcStdev(arr) {
  const mu = mcMean(arr);
  return Math.sqrt(arr.reduce((s, v) => s + (v - mu) ** 2, 0) / arr.length);
}
function mcPercentile(sorted, p) {
  const idx = Math.min(Math.floor(p / 100 * sorted.length), sorted.length - 1);
  return sorted[idx];
}

function runMonteCarlo(nRuns, fromIdx, toIdx, tier, charmEnabled, respEnabled, fees, baseItemCost, scrollPrices = {}, charmPrices = {}) {
  const regularPrice = scrollPrices.regular    ?? 0;
  const luckyPrice   = scrollPrices.luckyPoint ?? 0;

  const golds = [], dests = [];
  const atts  = {}, destsByGrade = {};
  for (let gi = fromIdx; gi < toIdx; gi++) { atts[gi] = []; destsByGrade[gi] = []; }

  for (let run = 0; run < nRuns; run++) {
    let gold = 0, grade = fromIdx, nDest = 0;
    const a = {}, d = {};

    while (grade < toIdx) {
      const g        = RGRADE[grade];
      const base     = g.pByTier[tier] ?? 0;
      const useCharm = !!(charmEnabled[grade] && g.charm);
      const resp     = grade >= 6 && !!respEnabled[grade];
      const p        = Math.min(1, base * (useCharm ? g.charm.mult : 1));

      gold += (fees[grade] || 0) + regularPrice + (resp ? luckyPrice : 0) + (useCharm ? (charmPrices[grade] || 0) : 0);
      a[grade] = (a[grade] || 0) + 1;

      if (Math.random() < p) {
        let advance = 1;
        if (resp && Math.random() < 0.20) advance = 2;
        grade += advance;
      } else {
        const fm = g.failMode;
        if (fm === 'celestial') {
          if (Math.random() < 0.5) { nDest++; d[grade] = (d[grade] || 0) + 1; gold += baseItemCost; grade = fromIdx; }
          else grade = 3;
        } else if (fm === 'destroy') {
          nDest++; d[grade] = (d[grade] || 0) + 1; gold += baseItemCost; grade = fromIdx;
        }
        // 'stay' — grade unchanged
      }
    }

    golds.push(gold);
    dests.push(nDest);
    for (let gi = fromIdx; gi < toIdx; gi++) {
      atts[gi].push(a[gi] || 0);
      destsByGrade[gi].push(d[gi] || 0);
    }
  }

  return { golds, dests, atts, destsByGrade };
}

// ─── Panel ────────────────────────────────────────────────────────────────
// Starting grade is always Basic. User picks gear category, subtype, tier, and
// target grade. Difficulty is auto-set: crafted → Difficult, obsidian → Normal.
function RegradePanel({ ctx, laborValue, currency }) {
  const ls = (k, fb) => { try { const v = localStorage.getItem(k); return v != null ? v : fb; } catch { return fb; } };

  const [catId,    setCatId]    = useStateRg(() => ls(RG_CAT_KEY,  'crafted-weapon'));
  const [subtype,  setSubtype]  = useStateRg(() => ls(RG_SUB_KEY,  '1H'));
  const [fromGrade,      setFromGrade]      = useStateRg(() => parseInt(ls(RG_FROM_KEY, '1')));
  const [toGrade,        setToGrade]        = useStateRg(() => parseInt(ls(RG_TO_KEY, '6')));
  const [charmEnabled,   setCharmEnabled]   = useStateRg(() => { try { return JSON.parse(localStorage.getItem(RG_CHARM_KEY) || '{}'); } catch { return {}; } });
  // Per-grade resplendent choice. Migrates from the old all-or-nothing
  // boolean key by enabling every grade if it was set to '1'.
  const [respEnabled,    setRespEnabled]    = useStateRg(() => {
    try {
      const raw = localStorage.getItem(RG_RESPMAP_KEY);
      if (raw) return JSON.parse(raw);
      if (localStorage.getItem(RG_RESPLD_KEY) === '1') {
        const all = {};
        for (let i = 0; i < RGRADE.length; i++) all[i] = true;
        return all;
      }
      return {};
    } catch { return {}; }
  });
  const [baseItemCost,   setBaseItemCost]   = useStateRg(() => { const v = parseFloat(ls(RG_BASECOST_KEY, '0')); return Number.isFinite(v) && v >= 0 ? v : 0; });
  const [autoItemCost,   setAutoItemCost]   = useStateRg(() => ls(RG_AUTOCOST_KEY, '0') === '1');
  const [materialType,   setMaterialType]   = useStateRg(() => ls(RG_MATTYPE_KEY, 'cloth'));
  const [simMode,   setSimMode]   = useStateRg('mc'); // 'ev' | 'mc'
  const [mcN,       setMcN]       = useStateRg(1000);
  const [mcResults, setMcResults] = useStateRg(null);
  const [mcOptResult,  setMcOptResult]  = useStateRg(null);
  const [mcOptRunning, setMcOptRunning] = useStateRg(false);

  useEffectRg(() => { try { localStorage.setItem(RG_CAT_KEY,    catId);    } catch {} }, [catId]);
  useEffectRg(() => { try { localStorage.setItem(RG_SUB_KEY,    subtype);  } catch {} }, [subtype]);
  useEffectRg(() => { try { localStorage.setItem(RG_FROM_KEY,   String(fromGrade)); } catch {} }, [fromGrade]);
  useEffectRg(() => { try { localStorage.setItem(RG_TO_KEY,     String(toGrade)); } catch {} }, [toGrade]);
  useEffectRg(() => { try { localStorage.setItem(RG_CHARM_KEY,  JSON.stringify(charmEnabled)); } catch {} }, [charmEnabled]);
  useEffectRg(() => { try { localStorage.setItem(RG_RESPMAP_KEY, JSON.stringify(respEnabled)); } catch {} }, [respEnabled]);
  useEffectRg(() => { try { localStorage.setItem(RG_BASECOST_KEY, String(baseItemCost)); } catch {} }, [baseItemCost]);
  useEffectRg(() => { try { localStorage.setItem(RG_AUTOCOST_KEY, autoItemCost ? '1' : '0'); } catch {} }, [autoItemCost]);
  useEffectRg(() => { try { localStorage.setItem(RG_MATTYPE_KEY,  materialType); } catch {} }, [materialType]);

  const catInfo  = GEAR_CATS.find(c => c.id === catId) || GEAR_CATS[0];
  // Crafted gear uses Magnificent (index 1) — Illustrious is not used in practice.
  const gearTier = catId.startsWith('crafted') ? (catInfo.tiers[1] ?? catInfo.tiers[0]) : catInfo.tiers[0];
  // Difficulty: crafted + obsidian gear both use the Difficult rate table.
  const tier     = 'difficult';
  const tierInfo = DIFF_TIERS.find(t => t.id === tier);
  const validTo  = Math.max(fromGrade + 1, toGrade);
  const fees     = feesFor(catId, subtype, gearTier);

  // Codex recipes matching the current gear tier — used to populate the
  // "set base item cost from codex" picker.
  const codexMatches = useMemoRg(() => {
    if (!ctx?.recipes) return [];
    const needle = gearTier.toLowerCase();
    return ctx.recipes
      .filter(r => r.productName && r.productName.toLowerCase().includes(needle))
      .sort((a, b) => a.productName.localeCompare(b.productName));
  }, [ctx, gearTier]);

  // Auto item cost: cheapest craft price from codex for the current slot/tier.
  const autoItemCostDebug = useMemoRg(() => {
    if (!ctx?.prices) return { value: null, filterDesc: '—', totalCodexMatches: 0, poolUsed: 0, rows: [] };
    const prices  = ctx.prices;

    // ── Crafted weapon: count × wisp AH price ──────────────────────────────
    const wispSpec = RG_WISP_COST[catId]?.[subtype];
    if (wispSpec) {
      const needle = wispSpec.nameHint.toLowerCase();
      let wispId = null, wispName = null;
      for (const [id, name] of (ctx.itemNames || new Map())) {
        if (name.toLowerCase().includes(needle)) { wispId = id; wispName = name; break; }
      }
      const unitPrice = wispId != null ? (prices[wispId] ?? null) : null;
      const value     = unitPrice != null ? unitPrice * wispSpec.count : null;
      return {
        value,
        filterDesc: `${wispSpec.count}× "${wispSpec.nameHint}"`,
        totalCodexMatches: codexMatches.length,
        poolUsed: wispId != null ? 1 : 0,
        rows: [{
          name: wispName ?? wispSpec.nameHint,
          cost: value,
          missing: wispId == null ? ['item not found in codex'] : unitPrice == null ? [`${wispName} — no AH price`] : [],
        }],
      };
    }

    // ── Obsidian weapon: match recipe by name, sum materials ───────────────
    const hint = RG_RECIPE_HINT[catId]?.[subtype];
    if (hint) {
      const pool = (ctx.recipes || []).filter(r => r.productName?.toLowerCase().includes(hint));
      const rows = [];
      let cheapest = null;
      for (const recipe of pool) {
        let cost = 0, complete = true;
        const missing = [];
        for (const mat of recipe.materials) {
          const p = prices[mat.itemType];
          if (p == null) { complete = false; missing.push(`${mat.name} (${mat.itemType})`); }
          else cost += mat.amount * p;
        }
        rows.push({ name: recipe.productName, cost: complete ? cost : null, missing });
        if (complete && (cheapest == null || cost < cheapest)) cheapest = cost;
      }
      return { value: cheapest, filterDesc: `recipe keyword "${hint}"`, totalCodexMatches: codexMatches.length, poolUsed: pool.length, rows };
    }

    // ── Crafted armor: count × material wisp AH price ─────────────────────
    if (catId === 'crafted-armor') {
      const count      = RG_ARMOR_WISP_COUNT[subtype] ?? null;
      const wispName   = RG_ARMOR_WISP_NAME[materialType] ?? null;
      const needle     = wispName?.toLowerCase();
      let wispId = null, wispFoundName = null;
      if (needle) {
        for (const [id, name] of (ctx.itemNames || new Map())) {
          if (name.toLowerCase().includes(needle)) { wispId = id; wispFoundName = name; break; }
        }
      }
      const unitPrice = wispId != null ? (prices[wispId] ?? null) : null;
      const value     = unitPrice != null && count != null ? unitPrice * count : null;
      return {
        value,
        filterDesc: `${count ?? '?'}× "${wispName ?? '?'}"`,
        totalCodexMatches: codexMatches.length,
        poolUsed: wispId != null ? 1 : 0,
        rows: [{
          name: wispFoundName ?? wispName ?? '?',
          cost: value,
          missing: !wispId ? ['item not found in codex'] : unitPrice == null ? [`${wispFoundName} — no AH price`] : [],
        }],
      };
    }

    // ── Obsidian armor: count × material wisp + 10 × Possessed Topaz ─────
    if (catId === 'obsidian-armor') {
      const wispCount = RG_OBS_ARMOR_WISP_COUNT[subtype] ?? null;
      const wispName  = RG_ARMOR_WISP_NAME[materialType] ?? null;
      const needle    = wispName?.toLowerCase();
      let wispId = null, wispFoundName = null;
      if (needle) {
        for (const [id, name] of (ctx.itemNames || new Map())) {
          if (name.toLowerCase().includes(needle)) { wispId = id; wispFoundName = name; break; }
        }
      }
      let topazId = null, topazFoundName = null;
      for (const [id, name] of (ctx.itemNames || new Map())) {
        if (name.toLowerCase().includes(OBS_ARMOR_TOPAZ_HINT)) { topazId = id; topazFoundName = name; break; }
      }
      const wispPrice  = wispId  != null ? (prices[wispId]  ?? null) : null;
      const topazPrice = topazId != null ? (prices[topazId] ?? null) : null;
      const wispCost   = wispPrice  != null && wispCount != null ? wispPrice  * wispCount          : null;
      const topazCost  = topazPrice != null                      ? topazPrice * OBS_ARMOR_TOPAZ_COUNT : null;
      const value      = wispCost != null && topazCost != null   ? wispCost + topazCost            : null;
      const rows = [
        {
          name: wispFoundName ?? wispName ?? '?',
          cost: wispCost,
          missing: !wispId ? ['item not found in codex'] : wispPrice == null ? [`${wispFoundName} — no AH price`] : [],
        },
        {
          name: topazFoundName ?? 'Possessed Topaz',
          cost: topazCost,
          missing: !topazId ? ['Possessed Topaz not found in codex'] : topazPrice == null ? [`${topazFoundName} — no AH price`] : [],
        },
      ];
      return {
        value,
        filterDesc: `${wispCount ?? '?'}× "${wispName ?? '?'}" + ${OBS_ARMOR_TOPAZ_COUNT}× "possessed topaz"`,
        totalCodexMatches: codexMatches.length,
        poolUsed: (wispId != null ? 1 : 0) + (topazId != null ? 1 : 0),
        rows,
      };
    }

    // ── Fallback (should not reach) ────────────────────────────────────────
    return { value: null, filterDesc: 'unrecognised category', totalCodexMatches: 0, poolUsed: 0, rows: [] };
  }, [codexMatches, ctx?.prices, ctx?.recipes, ctx?.itemNames, catId, subtype, gearTier, materialType]);

  const autoItemCostValue = autoItemCostDebug.value;

  const effectiveItemCost = autoItemCost && autoItemCostValue != null ? autoItemCostValue : baseItemCost;

  // Scroll prices for the current gear category, used in EV math + optimizer.
  const scrollPrices = useMemoRg(() => {
    const ids = SCROLL_IDS[catId] || { regular: null, luckyPoint: null };
    const p   = ctx?.prices || {};
    return {
      regular:      ids.regular    != null ? p[ids.regular]    ?? null : null,
      luckyPoint:   ids.luckyPoint != null ? p[ids.luckyPoint] ?? null : null,
      regularId:    ids.regular,
      luckyPointId: ids.luckyPoint,
      luckyName:    ids.luckyName  || 'Lucky Point',
    };
  }, [catId, ctx?.prices]);

  // Reset subtype when category changes if current value isn't valid.
  useEffectRg(() => {
    if (!catInfo.subtypes.includes(subtype)) setSubtype(catInfo.subtypes[0]);
  }, [catId]);

  const stages = useMemoRg(
    () => computeRegradeEV(fromGrade, validTo, tier, charmEnabled, respEnabled, fees, effectiveItemCost, scrollPrices),
    [fromGrade, validTo, tier, charmEnabled, respEnabled, fees, effectiveItemCost, scrollPrices]
  );

  const totals = useMemoRg(() => {
    const gold      = stages.reduce((s, st) => s + st.totalCost, 0);
    const scrolls   = stages.reduce((s, st) => s + st.scrollsExpected, 0);
    const survival  = stages.reduce((s, st) => s * st.survivalProb, 1);
    const hasDanger = stages.some(st => st.kind !== 'safe');
    // 90th percentile scroll count: sum of geometric RVs approximated as normal.
    // Each grade contributes variance (1-p)/p². z=1.282 → 90th pct of N(0,1).
    const variance  = stages.reduce((s, st) => s + (st.p > 0 ? (1 - st.p) / (st.p * st.p) : 0), 0);
    const scrolls90 = Math.ceil(scrolls + 1.282 * Math.sqrt(variance));
    return { gold, scrolls, scrolls90, survival, hasDanger };
  }, [stages]);

  const charmsInPath = useMemoRg(
    () => RGRADE.slice(fromGrade, validTo).filter(g => g.charm != null),
    [fromGrade, validTo]
  );

  const optimalPlan = useMemoRg(
    () => computeOptimalCharmPlan(fromGrade, validTo, tier, fees, ctx?.prices, effectiveItemCost, scrollPrices),
    [fromGrade, validTo, tier, fees, ctx?.prices, effectiveItemCost, scrollPrices]
  );

  // AH price per grade index for charms, built directly from ctx.prices.
  const charmPrices = useMemoRg(() => {
    const p = ctx?.prices || {};
    const m = {};
    for (let i = 0; i < RGRADE.length; i++) {
      const charm = RGRADE[i].charm;
      if (charm?.itemType != null && p[charm.itemType] != null) m[i] = p[charm.itemType];
    }
    return m;
  }, [ctx?.prices]);

  // Lookup maps so per-row toggles can find their optimizer decision in O(1).
  const charmDecMap = useMemoRg(() => {
    const m = {};
    if (optimalPlan) for (const d of optimalPlan.charmDecisions) m[d.gradeIdx] = d;
    return m;
  }, [optimalPlan]);
  const respDecMap = useMemoRg(() => {
    const m = {};
    if (optimalPlan) for (const d of optimalPlan.respDecisions) m[d.gradeIdx] = d;
    return m;
  }, [optimalPlan]);

  // Clear stale MC results whenever any setting that affects the simulation changes.
  useEffectRg(() => { setMcResults(null); }, [catId, subtype, validTo, charmEnabled, respEnabled, baseItemCost]);

  function runSim() {
    setMcResults(runMonteCarlo(mcN, fromGrade, validTo, tier, charmEnabled, respEnabled, fees, effectiveItemCost, scrollPrices, charmPrices));
  }

  function runMcOptimize() {
    setMcOptRunning(true);
    setMcOptResult(null);
    // Defer to next tick so state update renders before the blocking loop.
    setTimeout(() => {
      const charmSlots = [], respSlots = [];
      for (let gi = fromGrade; gi < validTo; gi++) {
        if (RGRADE[gi].charm != null) charmSlots.push(gi);
        if (gi >= 6) respSlots.push(gi);
      }
      const nC = charmSlots.length, nR = respSlots.length;
      let bestMean = Infinity, bestCE = {}, bestRE = {}, bestPerms = 0;
      for (let cm = 0; cm < (1 << nC); cm++) {
        const ce = {};
        for (let j = 0; j < nC; j++) ce[charmSlots[j]] = !!(cm & (1 << j));
        for (let rm = 0; rm < (1 << nR); rm++) {
          const re = {};
          for (let j = 0; j < nR; j++) re[respSlots[j]] = !!(rm & (1 << j));
          const res  = runMonteCarlo(mcN, fromGrade, validTo, tier, ce, re, fees, effectiveItemCost, scrollPrices, charmPrices);
          const mean = mcMean(res.golds);
          bestPerms++;
          if (mean < bestMean) { bestMean = mean; bestCE = { ...ce }; bestRE = { ...re }; }
        }
      }
      setMcOptResult({ bestMean, bestCE, bestRE, totalPerms: bestPerms });
      setMcOptRunning(false);
    }, 0);
  }

  function toggleCharm(i) { setCharmEnabled(p => ({ ...p, [i]: !p[i] })); }
  function toggleResp(i)  { setRespEnabled(p =>  ({ ...p, [i]: !p[i] })); }

  const hasCelestial = stages.some(st => st.kind === 'celestial');
  const hasDestroy   = stages.some(st => st.kind === 'destroy');
  const missingFees  = fees.some((f, i) => i >= fromGrade && i < validTo && f === 0);

  return (
    <main className="detail wisp-view seeds-view regrade-view v2" data-view-accent="regrade">
      <div className="fade-in">

        {/* ── Page hero ─────────────────────────────────────────────── */}
        <div className="page-hero">
          <div className="page-hero-crest" aria-hidden>⚙</div>
          <div className="page-hero-meta">
            <div className="page-hero-eyebrow">Regrade Lottery</div>
            <h1 className="page-hero-title">
              <em>{catInfo.label}</em> · {subtype} · {gearTier}
            </h1>
            <div className="page-hero-tags">
              <span><span className={`gp ${RGRADE[fromGrade].name.toLowerCase()}`}>{RGRADE[fromGrade].name}</span> → <span className={`gp ${RGRADE[validTo].name.toLowerCase()}`}>{RGRADE[validTo].name}</span></span>
              <span className="dot" />
              <span>{tierInfo?.label} rates</span>
              <span className="dot" />
              <span style={{color: 'var(--labor)'}}>{stages.length} transitions</span>
              {totals.hasDanger && (
                <>
                  <span className="dot" />
                  <span className="pill" style={{color: 'var(--garnet)', borderColor: 'rgba(213, 106, 106, 0.4)'}}>
                    {hasCelestial && hasDestroy ? 'celestial + destroy' : hasCelestial ? 'celestial risk' : 'destroy risk'}
                  </span>
                </>
              )}
              {missingFees && (
                <>
                  <span className="dot" />
                  <span style={{color: '#fb923c'}}>⚠ Some fee data missing</span>
                </>
              )}
            </div>
          </div>
          {simMode === 'ev' && (
            <div className="page-hero-aside">
              <div className="hero-aside-card">
                <div className="hero-aside-label">Expected Cost (EV)</div>
                <div className="hero-aside-value">
                  <span>{window.fmtG(totals.gold)}</span>
                  <span className="unit">{currency}</span>
                </div>
                <div className={`hero-aside-foot ${totals.survival >= 0.5 ? 'pos' : 'neg'}`}>
                  {totals.hasDanger
                    ? <>single-pass survival {(totals.survival * 100).toFixed(1)}%</>
                    : <>safe path — no item destruction</>}
                </div>
              </div>
            </div>
          )}
        </div>

        {/* ── Hero metric strip ────────────────────────────────────── */}
        <div className="metric-strip">
          <div className="metric-cell hero accent">
            <div className="ms-label">Avg scrolls needed</div>
            <div className="ms-value">
              <span>{totals.scrolls.toFixed(1)}</span>
              <span className="unit">scrolls</span>
            </div>
            <div className="ms-sub">90% confidence: {totals.scrolls90.toLocaleString()}</div>
          </div>
          <div className={`metric-cell ${totals.survival >= 0.5 ? 'pos' : 'neg'}`}>
            <div className="ms-label">Survival probability</div>
            <div className="ms-value">
              <span>{(totals.survival * 100).toFixed(1)}%</span>
              <span className="unit">single pass</span>
            </div>
            <div className="ms-sub">{totals.hasDanger ? 'item can destroy en route' : 'no destruction path'}</div>
          </div>
          <div className="metric-cell">
            <div className="ms-label">Item replacement cost</div>
            <div className="ms-value">
              {effectiveItemCost > 0
                ? <><span>{window.fmtG(effectiveItemCost)}</span><span className="unit">{currency}</span></>
                : <span style={{color:'var(--muted)', fontSize:16, fontStyle:'italic'}}>not priced</span>}
            </div>
            <div className="ms-sub">{autoItemCost ? 'auto · codex' : 'manual entry'}</div>
          </div>
          <div className="metric-cell">
            <div className="ms-label">Charms in path</div>
            <div className="ms-value">
              <span>{charmsInPath.length}</span>
              <span className="unit">avail.</span>
            </div>
            <div className="ms-sub">{optimalPlan ? `optimal saves ${optimalPlan.totalSaving != null && optimalPlan.totalSaving > 0 ? window.fmtG(optimalPlan.totalSaving) + currency : '—'}` : 'optimizing…'}</div>
          </div>
        </div>

        {/* ── Compact mode toggle ─────────────────────────────────── */}
        <div className="modebar-v2">
          <div className="toggle-stack">
            <div className="toggle-stack-label">Math mode</div>
            <div className="toggle-group">
              <button className={simMode === 'ev' ? 'on' : ''} onClick={() => setSimMode('ev')}>
                <span className="gl">∑</span> EV Formula
              </button>
              <button className={simMode === 'mc' ? 'on' : ''} onClick={() => setSimMode('mc')}>
                <span className="gl">⚄</span> Monte Carlo
              </button>
            </div>
          </div>
        </div>

        {/* ── Compact controls ────────────────────────────────────── */}
        <div className="framed-section" style={{marginBottom: 22}}>
          <div className="framed-section-head">
            <span className="fs-eyebrow">Setup</span>
            <span className="fs-title">Gear &amp; target</span>
            <span className="fs-rule" />
            <span className="fs-aside">change category, slot, start / target grade and item cost</span>
          </div>
          <div className="rg-controls" style={{padding: 18, gap: 14}}>
          <RgSelect label="Gear"   value={catId}    onChange={setCatId}
            options={GEAR_CATS.map(c => ({value: c.id, label: c.label}))} />
          <RgSelect label={catId.includes('weapon') ? 'Type' : 'Slot'} value={subtype} onChange={setSubtype}
            options={catInfo.subtypes.map(s => ({value: s, label: s}))} />
          {catId.includes('armor') && (
            <RgSelect label="Material" value={materialType} onChange={setMaterialType}
              options={[{value:'cloth',label:'Cloth'},{value:'leather',label:'Leather'},{value:'plate',label:'Plate'}]} />
          )}
          <RgSelect label="Start grade" value={String(fromGrade)} onChange={v => {
              const g = Number(v);
              setFromGrade(g);
              if (toGrade <= g) setToGrade(g + 1);
            }}
            options={RGRADE.slice(1, 10).map((g, i) => ({value: String(i + 1), label: g.name}))} />
          <RgSelect label="Target" value={String(validTo)} onChange={v => setToGrade(Number(v))}
            options={RGRADE.slice(fromGrade + 1).map((g, i) => ({value: String(fromGrade + 1 + i), label: g.name}))}
            accent="gold" />
          <label className="rg-ctrl">
            <span className="rg-ctrl-lbl" style={{display:'flex', alignItems:'center', gap:6}}>
              Item cost
              <button
                type="button"
                onClick={() => setAutoItemCost(v => !v)}
                title={autoItemCost ? 'Using codex craft cost — click to enter manually' : 'Click to auto-calculate from codex recipe prices'}
                style={{
                  fontSize: 10, padding: '1px 6px', borderRadius: 3,
                  border: '1px solid ' + (autoItemCost ? 'var(--sapphire)' : 'var(--line)'),
                  background: autoItemCost ? 'rgba(99,153,255,0.15)' : 'transparent',
                  color: autoItemCost ? 'var(--sapphire)' : 'var(--muted)',
                  cursor: 'pointer', letterSpacing: '0.06em', lineHeight: 1.4,
                }}
              >
                {autoItemCost ? '⚙ auto' : '⚙ auto'}
              </button>
            </span>
            <span className="rg-cost-field">
              <input
                type="number" min="0" step="0.5"
                className="rg-cost-input"
                value={autoItemCost ? (autoItemCostValue != null ? autoItemCostValue.toFixed(2) : '') : baseItemCost}
                placeholder={autoItemCost && autoItemCostValue == null ? 'no price' : undefined}
                disabled={autoItemCost}
                onChange={e => setBaseItemCost(Math.max(0, parseFloat(e.target.value) || 0))}
                style={autoItemCost ? {opacity: 0.6} : undefined}
              />
              <span className="rg-cost-cur">{currency}</span>
            </span>
          </label>
          <div style={{flex: 1}} />
          {missingFees && <span style={{color: '#fb923c', fontSize: 11, alignSelf: 'center'}}>⚠ Some fee data missing</span>}
          </div>
        </div>


        {simMode === 'ev' ? (
          <>
            {/* ── WIP disclaimer ──────────────────────────────────────── */}
            <div style={{
              margin: '0 0 18px',
              padding: '14px 18px',
              background: 'rgba(213,106,106,0.10)',
              border: '2px solid var(--garnet)',
              borderRadius: 6,
              display: 'flex',
              alignItems: 'flex-start',
              gap: 12,
            }}>
              <span style={{fontSize: 22, lineHeight: 1}}>⚠</span>
              <div>
                <div style={{fontWeight: 700, color: 'var(--garnet)', fontSize: 14, letterSpacing: '0.05em', marginBottom: 4}}>
                  WORK IN PROGRESS — EV Formula is unreliable
                </div>
                <div style={{fontSize: 12, color: 'var(--parch-3)', lineHeight: 1.6}}>
                  The expected value calculations below are not fully verified and may produce incorrect results, particularly for grades involving destruction or resplendent scrolls.
                  Use <strong style={{color: 'var(--parch)'}}>Monte Carlo</strong> mode for accurate cost estimates.
                </div>
              </div>
            </div>

            {/* ── Hero: optimal cost + charm chips ────────────────────── */}
            {optimalPlan && (
              <div className="rg-hero">
                <div className="rg-hero-totals">
                  <div className="rg-hero-main">
                    <div className="rg-hero-lbl">Optimal expected cost</div>
                    <div className="rg-hero-val">
                      {window.fmtG(optimalPlan.bestTotal)}<span className="rg-hero-cur">{currency}</span>
                    </div>
                  </div>
                  <div className="rg-hero-side">
                    <span><span className="rg-side-lbl">current</span> <span className="mono" style={{color: 'var(--parch)'}}>{window.fmtG(totals.gold)}{currency}</span></span>
                    <span><span className="rg-side-lbl">scroll fees</span> <span className="mono">{window.fmtG(optimalPlan.bestScrollFees)}{currency}</span></span>
                    <span><span className="rg-side-lbl">charm cost</span> <span className="mono" style={{color: 'var(--amethyst)'}}>{window.fmtG(optimalPlan.bestCharmCost)}{currency}</span></span>
                    {optimalPlan.totalSaving != null && optimalPlan.totalSaving > 0 && (
                      <span style={{color: 'var(--emerald)'}}>↓ saves {window.fmtG(optimalPlan.totalSaving)}{currency} vs no charms</span>
                    )}
                    {optimalPlan.totalSaving != null && optimalPlan.totalSaving <= 0 && (
                      <span style={{color: 'var(--muted)'}}>charms not worth it at current prices</span>
                    )}
                    {!optimalPlan.allPricesKnown && (
                      <span style={{color: '#fb923c'}}>⚠ some charm prices missing</span>
                    )}
                  </div>
                  <button className="rg-apply-btn" onClick={() => {
                    setCharmEnabled(optimalPlan.bestCE);
                    if (optimalPlan.respAvailable) setRespEnabled(optimalPlan.bestRE);
                  }}>
                    Apply optimal
                  </button>
                </div>
              </div>
            )}

            {/* ── Breakdown table ─────────────────────────────────────── */}
            <section className="seeds-section seeds-tone-gold">
              <div className="seeds-section-head">
                <div className="seeds-section-title">
                  <span className="seeds-section-rule" style={{background: 'var(--gold)'}} />
                  <span className="seeds-section-name">Per-grade breakdown</span>
                  <span className="seeds-section-sub">{RGRADE[fromGrade].name} → {RGRADE[validTo].name}</span>
                </div>
                <div className="rg-totals-inline">
                  <span className="rg-side-lbl">avg scrolls</span>
                  <span className="mono" style={{color: 'var(--parch)'}}>{totals.scrolls.toFixed(1)}</span>
                  <span className="rg-side-lbl" style={{marginLeft: 14}}>90% confidence</span>
                  <span className="mono" style={{color: 'var(--amethyst)', fontWeight: 600}}
                    title="Buy this many scrolls to have a 90% chance of finishing the chain (normal approximation of sum of geometric distributions)">
                    {totals.scrolls90.toLocaleString()}
                  </span>
                  {totals.hasDanger && (
                    <>
                      <span className="rg-side-lbl" style={{marginLeft: 14}}>single-pass survival</span>
                      <span className={`mono ${totals.survival >= 0.5 ? 'pos' : 'neg'}`} style={{fontWeight: 600}}>
                        {(totals.survival * 100).toFixed(1)}%
                      </span>
                    </>
                  )}
                </div>
              </div>

              <div className="wisp-table seeds-table rg-table">
                <div className="regrade-row rg-head">
                  <div>#</div>
                  <div>Transition</div>
                  <div className="num">Success</div>
                  <div>Charm</div>
                  <div>Resplendent</div>
                  <div className="num">Avg scrolls</div>
                  <div className="num">Stage cost</div>
                </div>
                {stages.map((st, i) => (
                  <RegradeStageRow
                    key={st.gradeName}
                    stage={st}
                    rank={i + 1}
                    currency={currency}
                    charmEnabled={charmEnabled}
                    respEnabled={respEnabled}
                    charmDecision={charmDecMap[st.gradeIdx]}
                    respDecision={respDecMap[st.gradeIdx]}
                    scrollPrices={scrollPrices}
                    onToggleCharm={toggleCharm}
                    onToggleResp={toggleResp}
                    isLastTransition={i === stages.length - 1}
                  />
                ))}
              </div>
            </section>

            <MarkovChainDiagram stages={stages} fromIdx={fromGrade} toIdx={validTo} />
          </>
        ) : (
          <>
            {/* ── Charm / Resp configuration ───────────────────────── */}
            <div className="framed-section" style={{marginBottom: 18}}>
              <div className="framed-section-head">
                <span className="fs-eyebrow">Simulation Setup</span>
                <span className="fs-title">Charms &amp; Resplendent per grade</span>
                <span className="fs-rule" />
                <span className="fs-aside">{stages.length} stage{stages.length === 1 ? '' : 's'} · click cards to toggle</span>
              </div>
              <div className="framed-section-body padded">
                <div className="rg-config-grid">
                  {stages.map(st => {
                    const cd = charmDecMap[st.gradeIdx];
                    const g  = RGRADE[st.gradeIdx];
                    const charmOn = !!charmEnabled[st.gradeIdx];
                    const respOn  = !!respEnabled[st.gradeIdx];
                    const hasCharm = !!g.charm;
                    const hasResp  = st.gradeIdx >= 6;
                    return (
                      <div key={st.gradeIdx} className="rg-config-card">
                        <div className="rg-config-card-head">
                          <span className={`gp ${g.name.toLowerCase()}`}>{g.name}</span>
                          <span className="rg-config-card-pct" title="Base success rate (before charm)">
                            {(g.pByTier[tier] != null ? (g.pByTier[tier] * 100).toFixed(0) + '%' : '—')}
                          </span>
                        </div>

                        {hasCharm ? (
                          <button
                            className={`rg-opt-row ${charmOn ? 'on' : ''}`}
                            onClick={() => toggleCharm(st.gradeIdx)}
                            title={`${g.charm.name} ×${g.charm.mult}`}
                          >
                            <span className="rg-opt-check" aria-hidden>{charmOn ? '✓' : ''}</span>
                            <span className="rg-opt-body">
                              <span className="rg-opt-name">
                                Charm
                                <span className="rg-opt-mult">×{g.charm.mult}</span>
                              </span>
                              <span className="rg-opt-meta">
                                {cd?.price != null ? `${window.fmtG(cd.price)}${currency}` : 'no AH price'}
                              </span>
                            </span>
                          </button>
                        ) : (
                          <div className="rg-opt-row disabled">
                            <span className="rg-opt-check" aria-hidden>—</span>
                            <span className="rg-opt-body">
                              <span className="rg-opt-name">No charm</span>
                              <span className="rg-opt-meta">guaranteed grade</span>
                            </span>
                          </div>
                        )}

                        {hasResp ? (
                          <button
                            className={`rg-opt-row resp ${respOn ? 'on' : ''}`}
                            onClick={() => toggleResp(st.gradeIdx)}
                            title="Resplendent scroll — 20% chance to skip past the next grade on success"
                          >
                            <span className="rg-opt-check" aria-hidden>{respOn ? '✓' : ''}</span>
                            <span className="rg-opt-body">
                              <span className="rg-opt-name">
                                Resplendent
                                <span className="rg-opt-mult">+20%</span>
                              </span>
                              <span className="rg-opt-meta">
                                {scrollPrices?.luckyPoint != null
                                  ? `+${window.fmtG(scrollPrices.luckyPoint)}${currency} ${scrollPrices?.luckyName || 'Lucky'}`
                                  : 'no AH price'}
                              </span>
                            </span>
                          </button>
                        ) : (
                          <div className="rg-opt-row disabled">
                            <span className="rg-opt-check" aria-hidden>—</span>
                            <span className="rg-opt-body">
                              <span className="rg-opt-name">No Resp</span>
                              <span className="rg-opt-meta">unlocks at Celestial</span>
                            </span>
                          </div>
                        )}
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>

            {/* ── Monte Carlo controls ─────────────────────────────── */}
            <div className="rg-mc-controls">
              <label className="stepper" style={{flexDirection: 'column'}}>
                <span className="stepper-label">Simulations</span>
                <select
                  className="rg-mc-select"
                  value={mcN}
                  onChange={e => setMcN(Number(e.target.value))}
                >
                  <option value={500}>500</option>
                  <option value={1000}>1,000</option>
                  <option value={5000}>5,000</option>
                  <option value={10000}>10,000</option>
                </select>
              </label>
              <button className="rg-mc-btn run" onClick={runSim}>
                <span className="rg-mc-btn-glyph">▶</span> Run
              </button>
              <button
                className="rg-mc-btn opt"
                onClick={runMcOptimize}
                disabled={mcOptRunning}
                title="Test every charm + resplendent permutation (1,000 runs each) and apply the cheapest combination"
              >
                <span className="rg-mc-btn-glyph">{mcOptRunning ? '⟳' : '★'}</span>
                {mcOptRunning ? 'Optimizing…' : 'Optimize'}
              </button>
              {mcResults && !mcOptRunning && (
                <span className="rg-mc-status">
                  <span className="rg-mc-status-dot" />
                  {mcN.toLocaleString()} runs complete
                </span>
              )}
            </div>

            {/* ── Optimizer result ─────────────────────────────────── */}
            {mcOptResult && (
              <div style={{
                marginBottom: 14, padding: '12px 16px',
                background: 'rgba(99,153,255,0.07)', border: '1px solid rgba(99,153,255,0.3)',
                borderRadius: 6, display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
              }}>
                <div>
                  <div style={{fontSize:11, color:'var(--muted)', marginBottom:3, letterSpacing:'0.08em', textTransform:'uppercase'}}>
                    Optimal setup · {mcOptResult.totalPerms} permutations tested
                  </div>
                  <div style={{fontSize:14, color:'var(--sapphire)', fontWeight:600}}>
                    {window.fmtG(mcOptResult.bestMean)}{currency} <span style={{fontSize:11, color:'var(--muted)', fontWeight:400}}>mean total cost</span>
                  </div>
                  <div style={{marginTop:6, display:'flex', gap:6, flexWrap:'wrap'}}>
                    {stages.map(st => {
                      const g = RGRADE[st.gradeIdx];
                      const chOn = !!mcOptResult.bestCE[st.gradeIdx];
                      const reOn = !!mcOptResult.bestRE[st.gradeIdx];
                      if (!g.charm && st.gradeIdx < 6) return null;
                      return (
                        <span key={st.gradeIdx} style={{fontSize:11, padding:'2px 7px', borderRadius:3,
                          background:'var(--bg-card)', border:'1px solid var(--line)', color:'var(--parch-3)'}}>
                          <span style={{color:'var(--parch)', fontWeight:600}}>{g.name}</span>
                          {g.charm && <span style={{color: chOn ? 'var(--amethyst)' : 'var(--muted)', marginLeft:4}}>{chOn ? '✓charm' : '✗charm'}</span>}
                          {st.gradeIdx >= 6 && <span style={{color: reOn ? 'var(--jade)' : 'var(--muted)', marginLeft:4}}>{reOn ? '✓resp' : '✗resp'}</span>}
                        </span>
                      );
                    })}
                  </div>
                </div>
                <button className="btn" style={{marginLeft:'auto', padding:'6px 14px', flexShrink:0}} onClick={() => {
                  setCharmEnabled(mcOptResult.bestCE);
                  setRespEnabled(mcOptResult.bestRE);
                }}>Apply</button>
              </div>
            )}

            {/* ── Monte Carlo results ───────────────────────────────── */}
            {mcResults && (() => {
              const { golds, dests, atts, destsByGrade } = mcResults;
              const sorted = [...golds].sort((a, b) => a - b);
              const mu   = mcMean(golds);
              const med  = mcMedian(golds);
              const sd   = mcStdev(golds);
              const dmu  = mcMean(dests);
              const dmax = Math.max(...dests);
              const gmin = sorted[0], gmax = sorted[sorted.length - 1];

              // Histogram buckets
              const BUCKETS = 24;
              const range = gmax - gmin || 1;
              const bw = range / BUCKETS;
              const counts = new Array(BUCKETS).fill(0);
              for (const v of golds) counts[Math.min(Math.floor((v - gmin) / bw), BUCKETS - 1)]++;
              const maxCount = Math.max(...counts);
              const HW = 480, HH = 80;
              const medX = Math.round((med - gmin) / range * HW);
              const muX  = Math.round((mu  - gmin) / range * HW);
              const barW = Math.floor(HW / BUCKETS) - 1;

              const pcts = [10, 25, 50, 75, 90, 95, 99].map(p => ({ p, v: mcPercentile(sorted, p) }));
              const MC_COLS = '36px minmax(120px,1fr) 80px 100px 110px 90px';
              return (
                <>
                  {/* Hero stats */}
                  <div className="rg-hero">
                    <div className="rg-hero-totals">
                      <div className="rg-hero-main">
                        <div className="rg-hero-lbl">Mean total cost (fees + scrolls)</div>
                        <div className="rg-hero-val">
                          {window.fmtG(mu)}<span className="rg-hero-cur">{currency}</span>
                        </div>
                      </div>
                      <div className="rg-hero-side">
                        <span><span className="rg-side-lbl">median</span>    <span className="mono" style={{color:'var(--parch)'}}>{window.fmtG(med)}{currency}</span></span>
                        <span><span className="rg-side-lbl">std dev</span>   <span className="mono">{window.fmtG(sd)}{currency}</span></span>
                        <span><span className="rg-side-lbl">min</span>       <span className="mono" style={{color:'var(--jade)'}}>{window.fmtG(gmin)}{currency}</span></span>
                        <span><span className="rg-side-lbl">max</span>       <span className="mono" style={{color:'var(--garnet)'}}>{window.fmtG(gmax)}{currency}</span></span>
                        <span><span className="rg-side-lbl">avg destroys</span> <span className="mono" style={{color:'#fb923c'}}>{dmu.toFixed(1)} <span style={{color:'var(--muted)'}}>/ max {dmax}</span></span></span>
                      </div>
                    </div>
                  </div>

                  {/* Distribution histogram */}
                  <section className="seeds-section seeds-tone-gold" style={{marginTop:14}}>
                    <div className="seeds-section-head">
                      <div className="seeds-section-title">
                        <span className="seeds-section-rule" style={{background:'var(--gold)'}} />
                        <span className="seeds-section-name">Cost distribution</span>
                        <span className="seeds-section-sub">
                          {mcN.toLocaleString()} runs ·{' '}
                          <span style={{color:'var(--gold)'}}>— median</span> ·{' '}
                          <span style={{color:'var(--sapphire)'}}>— mean</span>
                        </span>
                      </div>
                    </div>
                    <svg width="100%" viewBox={`0 0 ${HW} ${HH + 20}`} style={{display:'block', padding:'8px 4px', overflow:'visible'}}>
                      {counts.map((c, bi) => {
                        const bh = maxCount > 0 ? Math.round(c / maxCount * HH) : 0;
                        const bx = bi * (barW + 1);
                        const isMed = bi === Math.min(Math.floor((med - gmin) / bw), BUCKETS - 1);
                        return (
                          <rect key={bi} x={bx} y={HH - bh} width={barW} height={bh}
                            fill={isMed ? 'var(--gold)' : 'rgba(180,140,80,0.35)'} rx={1} />
                        );
                      })}
                      <line x1={medX} y1={0} x2={medX} y2={HH} stroke="var(--gold)" strokeWidth="1.5" strokeDasharray="3,2" opacity="0.9" />
                      <line x1={muX}  y1={0} x2={muX}  y2={HH} stroke="var(--sapphire)" strokeWidth="1.5" strokeDasharray="3,2" opacity="0.9" />
                      <text x={0}    y={HH + 14} fill="var(--muted)" fontSize={9} textAnchor="start">{window.fmtG(gmin)}</text>
                      <text x={HW/2} y={HH + 14} fill="var(--muted)" fontSize={9} textAnchor="middle">{window.fmtG((gmin + gmax) / 2)}</text>
                      <text x={HW}   y={HH + 14} fill="var(--muted)" fontSize={9} textAnchor="end">{window.fmtG(gmax)}</text>
                    </svg>
                  </section>

                  {/* Percentile table */}
                  <section className="seeds-section seeds-tone-gold" style={{marginTop:14}}>
                    <div className="seeds-section-head">
                      <div className="seeds-section-title">
                        <span className="seeds-section-rule" style={{background:'var(--gold)'}} />
                        <span className="seeds-section-name">Percentile breakdown</span>
                      </div>
                    </div>
                    <div style={{display:'flex', flexWrap:'wrap', borderTop:'1px solid var(--line)'}}>
                      {pcts.map(({p, v}) => (
                        <div key={p} style={{flex:1, minWidth:70, padding:'10px 12px', borderRight:'1px solid var(--line)', textAlign:'center'}}>
                          <div style={{fontSize:10, color:'var(--muted)', marginBottom:3, letterSpacing:'0.08em'}}>p{p}</div>
                          <div className="mono" style={{fontSize:13, color: p <= 25 ? 'var(--jade)' : p >= 90 ? 'var(--garnet)' : 'var(--parch)', fontWeight:600}}>
                            {window.fmtG(v)}<span style={{fontSize:10, color:'var(--muted)', marginLeft:2}}>{currency}</span>
                          </div>
                        </div>
                      ))}
                    </div>
                  </section>

                  {/* Per-grade breakdown */}
                  <section className="seeds-section seeds-tone-gold" style={{marginTop:14}}>
                    <div className="seeds-section-head">
                      <div className="seeds-section-title">
                        <span className="seeds-section-rule" style={{background:'var(--gold)'}} />
                        <span className="seeds-section-name">Per-grade breakdown</span>
                        <span className="seeds-section-sub">{mcN.toLocaleString()} runs</span>
                      </div>
                    </div>
                    <div className="wisp-table seeds-table">
                      <div className="regrade-row rg-head" style={{gridTemplateColumns:MC_COLS}}>
                        <div>#</div>
                        <div>Transition</div>
                        <div className="num">Success %</div>
                        <div className="num">Avg attempts</div>
                        <div className="num">Avg scroll cost</div>
                        <div className="num">Avg destroys</div>
                      </div>
                      {stages.map((st, i) => {
                        const gradeAtts  = atts[st.gradeIdx] || [];
                        const gradeDests = destsByGrade ? (destsByGrade[st.gradeIdx] || []) : [];
                        const avgAtts    = gradeAtts.length  ? mcMean(gradeAtts)  : 0;
                        const avgDests   = gradeDests.length ? mcMean(gradeDests) : 0;
                        const accent     = st.kind === 'destroy' ? 'var(--garnet)' : st.kind === 'celestial' ? '#fb923c' : null;
                        return (
                          <div key={st.gradeName} className="regrade-row"
                            style={{gridTemplateColumns:MC_COLS, ...(accent ? {borderLeft:`2px solid ${accent}`} : {})}}>
                            <div className="num mono">{i + 1}</div>
                            <div style={{color: accent || 'var(--parch)', fontWeight:600}}>
                              {RGRADE[st.gradeIdx].name} <span style={{color:'var(--muted)', fontWeight:400}}>→</span> {RGRADE[st.gradeIdx + 1]?.name}
                            </div>
                            <div className="num mono" style={{color: st.p >= 0.5 ? 'var(--jade)' : st.p >= 0.25 ? 'var(--gold)' : 'var(--garnet)'}}>
                              {(st.p * 100).toFixed(1)}%
                            </div>
                            <div className="num mono" style={{color:'var(--parch)'}}>{avgAtts.toFixed(1)}</div>
                            <div className="num mono" style={{color:'var(--gold)'}}>
                              {st.fee > 0 ? window.fmtG(avgAtts * st.fee) + currency : '—'}
                            </div>
                            <div className="num mono" style={{color: avgDests > 0.05 ? '#fb923c' : 'var(--muted)'}}>
                              {avgDests > 0 ? avgDests.toFixed(2) : '—'}
                            </div>
                          </div>
                        );
                      })}
                    </div>
                  </section>
                </>
              );
            })()}
          </>
        )}
      </div>
    </main>
  );
}

// ─── Compact select control ───────────────────────────────────────────────
function RgSelect({ label, value, onChange, options, accent }) {
  return (
    <label className={`rg-ctrl ${accent === 'gold' ? 'rg-ctrl-accent' : ''}`}>
      <span className="rg-ctrl-lbl">{label}</span>
      <select className="rg-ctrl-sel" value={value} onChange={e => onChange(e.target.value)}>
        {options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
      </select>
    </label>
  );
}

// ─── Stage row ────────────────────────────────────────────────────────────
function RgToggle({ isOn, isOptimal, label, sublabel, priceLine, title, variant, onClick, disabled }) {
  const cls = `rg-tog ${variant} ${isOn ? 'on' : 'off'} ${isOptimal ? 'opt' : ''} ${disabled ? 'disabled' : ''}`;
  return (
    <button className={cls} onClick={onClick} title={title} disabled={disabled}>
      <span className="ck">{isOn ? '✓' : '○'}</span>
      <span className="lbl">{label}</span>
      {sublabel && <span className="sub">{sublabel}</span>}
      {priceLine && <span className="sub" style={{opacity: 0.7}}>{priceLine}</span>}
      {isOptimal && <span className="opt-tag">opt</span>}
    </button>
  );
}

function RegradeStageRow({ stage, rank, currency, charmEnabled, respEnabled, charmDecision, respDecision, scrollPrices, onToggleCharm, onToggleResp, isLastTransition }) {
  const isCelestial = stage.kind === 'celestial';
  const isDestroy   = stage.kind === 'destroy';
  const accent      = isDestroy ? 'var(--garnet)' : isCelestial ? '#fb923c' : null;
  const hasItemLoss = stage.itemLossCost > 0;
  const sourceGrade = RGRADE[stage.gradeIdx];
  const nextGrade   = RGRADE[stage.gradeIdx + 1];
  const charm       = sourceGrade.charm;
  const isCharmOn   = !!charmEnabled[stage.gradeIdx];
  const isRespOn    = !!respEnabled[stage.gradeIdx];
  const charmOpt    = !!charmDecision?.inOptimal;
  const respOpt     = !!respDecision?.inOptimal;
  const luckyPrice  = scrollPrices?.luckyPoint;
  const luckyName   = scrollPrices?.luckyName || 'Lucky Point';

  return (
    <div className="regrade-row" style={accent ? {borderLeft: `2px solid ${accent}`} : {}}>
      <div className="num mono">{rank}</div>
      <div>
        <div style={{color: accent || 'var(--parch)', fontWeight: 600}}>
          {sourceGrade.name} <span style={{color: 'var(--muted)', fontWeight: 400}}>→</span> {nextGrade ? nextGrade.name : '—'}
        </div>
        {isCelestial && (
          <div style={{fontSize: 11, color: '#fb923c', marginTop: 2}}>
            50% destroy · 50% drop to Arcane{stage.reclimbs != null ? ` · ~${stage.reclimbs.toFixed(1)} reclimbs` : ''}
          </div>
        )}
        {isDestroy && (
          <div style={{fontSize: 11, color: 'var(--garnet)', marginTop: 2}}>Destroys on fail</div>
        )}
        {stage.fee === 0 && (
          <div style={{fontSize: 11, color: '#fb923c', marginTop: 2}}>⚠ fee data missing</div>
        )}
      </div>
      <div className="num mono" style={{color: stage.p >= 0.5 ? 'var(--jade)' : stage.p >= 0.25 ? 'var(--gold)' : 'var(--garnet)'}}>
        {(stage.p * 100).toFixed(1)}%
        {stage.useCharm && <span style={{fontSize: 10, color: 'var(--amethyst)', display: 'block', lineHeight: 1.2}}>+charm</span>}
      </div>
      <div>
        {charm ? (
          <RgToggle
            isOn={isCharmOn}
            isOptimal={charmOpt}
            label={charm.name.replace(' Regrade Charm', '').replace('Dazzling ', '')}
            sublabel={`×${charm.mult}`}
            priceLine={charmDecision?.price != null ? window.fmtG(charmDecision.price) + currency : 'no AH price'}
            variant="charm"
            title={`${charm.name} ×${charm.mult} · ${charmDecision?.price != null ? window.fmtG(charmDecision.price) + currency + ' / charm' : 'no AH price'}${charmOpt ? '\nOptimizer says: USE this charm.' : '\nOptimizer says: skip.'}`}
            onClick={() => onToggleCharm(stage.gradeIdx)}
          />
        ) : <span style={{color: 'var(--muted)', fontFamily: 'var(--mono)'}}>—</span>}
      </div>
      <div>
        {stage.gradeIdx >= 6 ? (
          <RgToggle
            isOn={isRespOn}
            isOptimal={respOpt}
            label="Resp"
            sublabel={luckyPrice != null ? `+${window.fmtG(luckyPrice)}${currency}` : 'no price'}
            variant="resp"
            disabled={isLastTransition}
            title={isLastTransition
              ? 'Resp at the final transition would only skip past your target — wasted gold.'
              : `Use ${luckyName}${luckyPrice != null ? ' (' + window.fmtG(luckyPrice) + currency + ')' : ''} at ${sourceGrade.name}. 20% chance on success to skip ${nextGrade?.name || 'next grade'}.${respOpt ? '\nOptimizer says: USE.' : '\nOptimizer says: skip.'}`}
            onClick={() => !isLastTransition && onToggleResp(stage.gradeIdx)}
          />
        ) : <span style={{color: 'var(--muted)', fontFamily: 'var(--mono)'}}>—</span>}
      </div>
      <div className="num mono" style={{color: 'var(--parch)'}}>{stage.scrollsExpected.toFixed(2)}</div>
      <div className="num mono" style={{color: 'var(--gold)', fontWeight: 600}}>
        {stage.fee > 0 || hasItemLoss ? window.fmtG(stage.totalCost) + currency : '—'}
        {isCelestial && stage.reclimbGold > 0 && stage.fee > 0 && (
          <span style={{fontSize: 10, color: 'var(--muted)', display: 'block', fontWeight: 400}}>
            incl. {window.fmtG(stage.reclimbs * stage.reclimbGold)}{currency} reclimb
          </span>
        )}
        {hasItemLoss && (
          <span style={{fontSize: 10, color: 'var(--garnet)', display: 'block', fontWeight: 400}}>
            + {window.fmtG(stage.itemLossCost)}{currency} item loss
          </span>
        )}
      </div>
    </div>
  );
}

// ─── Markov Chain Diagram ─────────────────────────────────────────────────
//
// Visualises the per-attempt state transitions as a directed graph.
// States = grade nodes; edges carry the probability of each outcome per scroll.
// The "Restart" node represents item destruction / replacement: the player
// re-acquires the item and climbs back from the starting grade.
//
// Edge types:
//   fwd     — success, advance 1 grade          (green)
//   skip    — great success via resp, +2 grades  (emerald, dashed)
//   self    — fail and stay (safe grades only)   (grey)
//   drop    — Celestial fail → Arcane            (orange)
//   destroy — Celestial/Divine+ fail → Restart   (red)
//   restart — Restart → first grade              (grey, dashed)
const MC_SPACING = 100;
const MC_PAD     = 55;
const MC_R       = 20;
const MC_TOP_Y   = 85;
const MC_BOT_Y   = 195;

function mcFmtPct(p) {
  if (p >= 0.9995) return '100%';
  if (p < 0.0005)  return '~0%';
  return `${(p * 100).toFixed(1)}%`;
}

function MarkovChainDiagram({ stages, fromIdx, toIdx }) {
  if (!stages || stages.length === 0) return null;

  const W         = (toIdx - fromIdx) * MC_SPACING + MC_PAD * 2;
  const hasDanger = stages.some(s => s.kind === 'celestial' || s.kind === 'destroy');
  const H         = hasDanger ? MC_BOT_Y + 45 : MC_TOP_Y + 45;

  const nodePos = {};
  for (let gi = fromIdx; gi <= toIdx; gi++) {
    nodePos[gi] = { x: MC_PAD + (gi - fromIdx) * MC_SPACING, y: MC_TOP_Y };
  }

  const restartX = MC_PAD + Math.round((toIdx - fromIdx) / 2) * MC_SPACING;
  const restartY = MC_BOT_Y;

  const edges = [];
  for (const st of stages) {
    const fPos  = nodePos[st.gradeIdx];
    const tPos  = nodePos[st.gradeIdx + 1];
    const t2Pos = nodePos[st.gradeIdx + 2];
    if (!fPos) continue;

    const p    = st.p;
    const resp = st.useResp;

    if (st.kind === 'safe') {
      if (tPos) edges.push({ type: 'fwd',     fPos, tPos,  p: resp ? p * 0.8 : p });
      if (resp && t2Pos) edges.push({ type: 'skip', fPos, tPos: t2Pos, p: p * 0.2 });
      if (p < 1) edges.push({ type: 'self',   fPos, tPos: fPos, p: 1 - p });

    } else if (st.kind === 'celestial') {
      const pFwd  = resp ? p * 0.8 : p;
      const pFail = 1 - p;
      if (tPos) edges.push({ type: 'fwd',  fPos, tPos, p: pFwd });
      if (resp && t2Pos) edges.push({ type: 'skip', fPos, tPos: t2Pos, p: p * 0.2 });
      const arcPos = nodePos[3];
      if (arcPos && arcPos !== fPos) edges.push({ type: 'drop',    fPos, tPos: arcPos, p: pFail * 0.5 });
      if (hasDanger) edges.push({ type: 'destroy', fPos, toXY: { x: restartX, y: restartY }, p: pFail * 0.5 });

    } else if (st.kind === 'destroy') {
      if (tPos) edges.push({ type: 'fwd',  fPos, tPos, p: resp ? p * 0.8 : p });
      if (resp && t2Pos) edges.push({ type: 'skip', fPos, tPos: t2Pos, p: p * 0.2 });
      if (hasDanger) edges.push({ type: 'destroy', fPos, toXY: { x: restartX, y: restartY }, p: 1 - p });
    }
  }

  if (hasDanger) {
    edges.push({ type: 'restart', fromXY: { x: restartX, y: restartY }, tPos: nodePos[fromIdx], p: 1 });
  }

  const COLORS = { fwd: '#4ade80', skip: '#34d399', self: '#6b7280', drop: '#fb923c', destroy: '#f87171', restart: '#9ca3af' };

  function edgeGeom(e) {
    const { type, fPos, tPos, toXY, fromXY } = e;
    if (type === 'fwd') {
      const x1 = fPos.x + MC_R, y1 = fPos.y, x2 = tPos.x - MC_R - 6, y2 = tPos.y;
      return { d: `M ${x1} ${y1} L ${x2} ${y2}`, lx: (x1+x2)/2, ly: y1 - 10 };
    }
    if (type === 'skip') {
      const x1 = fPos.x + MC_R, y1 = fPos.y, x2 = tPos.x - MC_R - 6, y2 = tPos.y;
      const mx = (x1+x2)/2, cy = y1 - 50;
      return { d: `M ${x1} ${y1} Q ${mx} ${cy} ${x2} ${y2}`, lx: mx, ly: cy - 7, dash: true };
    }
    if (type === 'self') {
      const cx = fPos.x, cy = fPos.y - MC_R;
      return { d: `M ${cx-7} ${cy+3} C ${cx-28} ${cy-32} ${cx+28} ${cy-32} ${cx+7} ${cy+3}`, lx: cx, ly: cy - 35, noArrow: true };
    }
    if (type === 'drop') {
      const x1 = fPos.x, y1 = fPos.y + MC_R, x2 = tPos.x + MC_R + 6, y2 = tPos.y;
      const mx = (x1+x2)/2, my = Math.max(y1, y2) + 48;
      return { d: `M ${x1} ${y1} Q ${mx} ${my} ${x2} ${y2}`, lx: mx, ly: my + 11 };
    }
    if (type === 'destroy') {
      const x1 = fPos.x, y1 = fPos.y + MC_R, x2 = toXY.x, y2 = toXY.y - 13;
      const cpx = (x1+x2)/2 + (fPos.x - restartX)*0.25, cpy = (y1+y2)/2;
      return { d: `M ${x1} ${y1} Q ${cpx} ${cpy} ${x2} ${y2}`, lx: (x1+x2)/2 + (fPos.x > restartX ? 13 : -13), ly: cpy };
    }
    if (type === 'restart') {
      const x1 = fromXY.x - 28, y1 = fromXY.y, x2 = tPos.x, y2 = tPos.y + MC_R + 6;
      return { d: `M ${x1} ${y1} Q ${(x1+x2)/2 - 15} ${(y1+y2)/2} ${x2} ${y2}`, lx: null, ly: null, dash: true };
    }
    return null;
  }

  return (
    <section className="seeds-section" style={{ marginTop: 20 }}>
      <div className="seeds-section-head">
        <div className="seeds-section-title">
          <span className="seeds-section-rule" style={{ background: 'var(--gold)' }} />
          <span className="seeds-section-name">Markov Chain</span>
          <span className="seeds-section-sub">per-attempt transition probabilities</span>
        </div>
      </div>

      <div style={{ overflowX: 'auto', padding: '12px 0' }}>
        <svg viewBox={`0 0 ${W} ${H}`} style={{ display: 'block', minWidth: 280, maxWidth: W, width: '100%' }}>
          <defs>
            {Object.entries(COLORS).map(([id, fill]) => (
              <marker key={id} id={`mc-${id}`} markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
                <path d="M0,0 L0,7 L7,3.5 z" fill={fill} />
              </marker>
            ))}
          </defs>

          {edges.map((e, i) => {
            const g = edgeGeom(e);
            if (!g) return null;
            const color = COLORS[e.type];
            return (
              <g key={i}>
                <path d={g.d} fill="none" stroke={color} strokeWidth="1.5"
                  strokeDasharray={g.dash ? '4 3' : undefined}
                  markerEnd={g.noArrow ? undefined : `url(#mc-${e.type})`} />
                {g.lx != null && (
                  <text x={g.lx} y={g.ly} textAnchor="middle" fill={color} fontSize="9" fontFamily="var(--mono)">
                    {mcFmtPct(e.p)}
                  </text>
                )}
              </g>
            );
          })}

          {Object.entries(nodePos).map(([giStr, pos]) => {
            const gi = parseInt(giStr);
            const st = stages.find(s => s.gradeIdx === gi);
            const isTarget = gi === toIdx;
            const ring = isTarget ? '#4ade80' : st?.kind === 'destroy' ? '#f87171' : st?.kind === 'celestial' ? '#fb923c' : '#6b7280';
            return (
              <g key={gi}>
                <circle cx={pos.x} cy={pos.y} r={MC_R}
                  fill={isTarget ? 'rgba(74,222,128,0.12)' : 'rgba(255,255,255,0.04)'}
                  stroke={ring} strokeWidth={isTarget ? 2 : 1.5} />
                <text x={pos.x} y={pos.y - 4} textAnchor="middle" fontSize="7.5"
                  fill={isTarget ? '#4ade80' : 'var(--parch)'} fontFamily="var(--mono)" fontWeight="700">
                  {RGRADE[gi]?.name?.slice(0, 6) ?? '?'}
                </text>
                {st && (
                  <text x={pos.x} y={pos.y + 10} textAnchor="middle" fontSize="7.5"
                    fill={ring} fontFamily="var(--mono)">
                    {mcFmtPct(st.p)}
                  </text>
                )}
              </g>
            );
          })}

          {hasDanger && (
            <g>
              <rect x={restartX - 28} y={restartY - 13} width={56} height={26}
                rx={5} fill="rgba(0,0,0,0.2)" stroke="#6b7280" strokeWidth="1.5" strokeDasharray="4 3" />
              <text x={restartX} y={restartY + 5} textAnchor="middle"
                fill="#9ca3af" fontSize="9" fontFamily="var(--mono)">Restart</text>
            </g>
          )}
        </svg>
      </div>

      <div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', padding: '4px 2px', fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--mono)' }}>
        {[
          { c: '#4ade80', l: 'Success' },
          { c: '#34d399', l: 'Great success (resp)', d: true },
          { c: '#6b7280', l: 'Stay' },
          { c: '#fb923c', l: 'Celestial drop → Arcane' },
          { c: '#f87171', l: 'Destroyed → Restart' },
        ].map(({ c, l, d }) => (
          <span key={l} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
            <svg width="22" height="8">
              <line x1="0" y1="4" x2="22" y2="4" stroke={c} strokeWidth="1.5" strokeDasharray={d ? '4 3' : undefined} />
            </svg>
            {l}
          </span>
        ))}
      </div>
    </section>
  );
}

window.RegradePanel = RegradePanel;
