/* FIRE Calculator — app logic + UI (bilingual 中文 / English).
   React 18 via Babel standalone (no build step), matching the sister sites. */

const { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } = React;

/* ---------- Icons (inline SVG, stroke style — matches the other calculators) */

const Icon = {
  Wallet: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M3 7a2 2 0 0 1 2-2h13v4" /><path d="M3 7v11a2 2 0 0 0 2 2h15V9H5a2 2 0 0 1-2-2Z" /><path d="M16 14.5h.01" />
    </svg>
  ),
  User: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" />
    </svg>
  ),
  Vault: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="12" cy="12" r="3.4" /><path d="M12 8.6v.01M12 15.4v.01M8.6 12h.01M15.4 12h.01" />
    </svg>
  ),
  Trend: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M3 17l6-6 4 4 8-8" /><path d="M15 7h6v6" />
    </svg>
  ),
  Flame: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M12 3c.6 3.2 4.8 5.4 4.8 10a4.8 4.8 0 0 1-9.6 0c0-2 .9-3.4 2-4.4.3 1.6 1.1 2.6 2.1 2.9C10.9 8.8 11.2 5.6 12 3Z" />
    </svg>
  ),
  Hourglass: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M6 3h12M6 21h12M7 3v3a5 5 0 0 0 10 0V3M7 21v-3a5 5 0 0 1 10 0v3" />
    </svg>
  ),
  Coffee: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M4 9h13v5a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V9Z" /><path d="M17 10h1.5a2.5 2.5 0 0 1 0 5H17" /><path d="M8 3v3M12 3v3" />
    </svg>
  ),
  Sliders: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0" /><circle cx="16" cy="6" r="2" /><circle cx="10" cy="12" r="2" /><circle cx="18" cy="18" r="2" />
    </svg>
  ),
  Chart: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M4 20V10M11 20V4M18 20v-7" />
    </svg>
  ),
  Bulb: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M9 18h6M10 21h4" /><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.45.9 1.15.9 1.9V17h5.2v-1.2c0-.75.3-1.45.9-1.9A6 6 0 0 0 12 3Z" />
    </svg>
  ),
  Share: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <circle cx="18" cy="5" r="2.5" /><circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="19" r="2.5" /><path d="M8.2 10.8l7.6-4.4M8.2 13.2l7.6 4.4" />
    </svg>
  ),
  Reset: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" />
    </svg>
  ),
  Expand: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
    </svg>
  ),
  Shrink: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
    </svg>
  ),
  Sun: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <circle cx="12" cy="12" r="4.2" /><path d="M12 2.5v2.2M12 19.3v2.2M4.5 12H2.3M21.7 12h-2.2M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6" />
    </svg>
  ),
  Moon: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5Z" />
    </svg>
  ),
  Alert: (p) => (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}>
      <path d="M12 3 2 20h20L12 3Z" /><path d="M12 10v4M12 17v.01" />
    </svg>
  ),
};

/* ---------- Copy (中文 / English) -------------------------------------------
   Every user-visible string lives here. `{name}` placeholders are filled by
   fmt(). Keep both languages in sync when adding a key. */

const COPY = {
  zh: {
    docTitle: "FIRE 财务自由计算器 · Zhijie Tan",
    brandTitle: "FIRE 财务自由计算器",
    brandSub: "Financial Independence, Retire Early · 从第一份薪水开始",
    present: "演示模式",
    exitPresent: "退出演示",
    eyebrow: "财务自由 · 从第一份薪水开始",
    heroA: "每一份薪水，",
    heroB: "都在决定你几岁自由。",
    heroSub: "拿到税后收入，先分成四个桶：固定支出与债务、储蓄、投资、弹性消费。拖动比例，看复利如何在一生中替你工作——以及每晚一年开始，要付出多大的代价。",

    step1: "第 1 步 · 你的起点",
    age: "现在年龄",
    ageUnit: "岁",
    income: "每月税后收入",
    incomeNote: "工资单上实际到手的钱",
    balance: "已有投资",
    balanceNote: "401(k)、IRA、券商账户等（不含储蓄）",

    step2: "第 2 步 · 分配你的薪水",
    step2Sub: "先付钱给未来的自己——弹性消费自动等于扣完其他三个桶后「剩下」的钱。",
    presetMoonlight: "月光族",
    presetStandard: "标准",
    presetAmbitious: "进取",
    presetFire: "FIRE",
    custom: "自定义",
    fixed: "固定支出 & 债务",
    fixedDesc: "房租、账单、保险、车贷、学贷、信用卡还款",
    savings: "储蓄",
    savingsDesc: "应急金、买车、首付、旅行等短期目标——会被花掉，所以不计入 FIRE",
    invest: "投资",
    investDesc: "长期投资（指数基金、401(k)、Roth IRA）——唯一替你复利的桶",
    flex: "弹性消费",
    flexDesc: "外卖、购物、娱乐——自动等于剩下的钱",
    perMonth: "/月",
    emergency: "按这个储蓄比例，存满 6 个月生活费的应急金大约需要 {n} 个月。",
    emergencyNone: "储蓄为 0：没有应急金，一次意外就可能被迫卖掉投资或刷信用卡。",
    flexZero: "弹性消费已经是 0——想再多投一点，得先从固定支出或储蓄里挪。",

    step3: "调整假设",
    ret: "投资年化回报（名义）",
    retNote: "标普 500 长期名义平均约 10%，每年都会波动",
    infl: "通货膨胀",
    inflNote: "美国长期平均约 3%",
    raise: "每年涨薪",
    raiseNote: "税后收入每年的增长（含通胀）",
    sweep: "涨薪部分直接投资",
    sweepNote: "只算超过通胀的那部分涨薪，其余按你的比例分配。对抗「生活方式通胀」",
    wr: "安全提现率",
    wrNote: "4% 规则：FIRE 数字 = 年支出 × 25",
    nominal: "未来的钱（名义）",
    real: "今天的钱（扣除通胀）",

    fireAt: "你将在",
    fireAtSuffix: "岁 实现财务自由",
    countdown: "还有 {y} 年 {m} 个月",
    countdownMonths: "还有 {m} 个月",
    already: "你已经达到 FIRE 数字了！",
    alreadySub: "你现有的投资已足够按 {wr}% 提现支撑目前的年支出。",
    never: "按这个比例，你在 100 岁前都无法实现财务自由",
    neverSub: "投资桶太小，复利没有足够的本金可以替你工作。试试「标准」或「FIRE」。",
    progress: "今天的进度",
    progressOf: "{a} / {b}",
    savingsNotCounted: "储蓄桶用于短期目标，不计入 FIRE 资产。",

    fireNumber: "你的 FIRE 数字",
    fireNumberFoot: "年支出 {spend} × {mult}（{when}）",
    whenFire: "在 FIRE 那年",
    whenNow: "按今天的支出",
    monthlyInvest: "每月投资",
    monthlyInvestFoot: "占税后收入 {p}%",
    at65: "65 岁时的投资资产",
    at65Foot: "其中 {p}% 来自复利",
    crossover: "钱替你打工的转折点",
    crossoverFoot: "此后每年复利赚的 > 你每年投入的",
    crossoverNone: "—",
    ageValue: "{n} 岁",

    chartTitle: "一生的复利曲线",
    chartSub: "你投入的本金 vs 复利帮你赚的钱",
    legendContrib: "你投入的本金",
    legendGrowth: "复利收益",
    legendTarget: "FIRE 目标",
    markerFire: "FIRE · {n} 岁",
    markerCross: "转折点 · {n} 岁",
    tipAge: "{n} 岁",
    tipTotal: "投资总额",

    waitTitle: "晚开始的代价",
    waitSub: "同样的比例，只是晚几年才开始投资（这几年的钱都花掉了）",
    waitNow: "现在开始",
    wait5: "晚 5 年",
    wait10: "晚 10 年",
    colAt65: "65 岁时",
    colFire: "FIRE 年龄",
    lost: "少 {x}",
    neverShort: "无法实现",
    catchup: "晚 10 年开始，想在 65 岁追上，每月得投资 {x}，而不是现在的 {y}——是 {n} 倍。",
    perYear: "每晚一年开始投资，就要多工作约 {n} 年才能自由。",
    waitNoInvest: "投资桶是 0，等不等都一样——都没有复利。",

    habitTitle: "每天少花一点",
    habitSub: "把这笔钱从弹性消费挪去投资（金额随通胀增长）",
    habitLabel: "每天少花",
    habitExamples: ["一瓶水", "一杯咖啡", "咖啡 + 零食", "一顿外卖", "外卖 + 奶茶", "一顿好饭"],
    habitMonthly: "≈ 每月 {x}",
    habitResult: "到 65 岁会变成",
    habitFire: "而且 FIRE 提前 {n} 年",
    habitFireNew: "而且让你在 {n} 岁实现 FIRE",

    lessonsTitle: "给年轻人的关键结论",
    l0: "投资桶是 0：工作一辈子，65 岁时的投资资产还是 $0。收入再高，不投资就没有复利。",
    l1: "到 65 岁，你一共投入 {p}，账户里却有 {b}——其中 {g}（{pct}%）是复利赚的，不是你存的。",
    l2: "从 {n} 岁起，你的钱每年赚的比你每年存的还多。越早开始，这一天来得越早。",
    l2None: "在 65 岁前，复利每年赚的都还追不上你每年存的——投资比例再高一点，转折点就会出现。",
    l3: "晚 5 年开始，65 岁时少 {x}。时间，是年轻人唯一比有钱人多的资产。",
    l4Low: "每次涨薪，把超过通胀的那部分拿一半去投资，FIRE 可以提前 {n} 年——而你的生活水平依然跟得上通胀。",
    l4High: "你把超过通胀的涨薪拿 {k}% 去投资，比「涨多少花多少」提前 {n} 年实现 FIRE。",
    l4None: "涨薪时先把一部分自动转去投资，是对抗「生活方式通胀」最简单的办法——前提是涨薪要跑赢通胀。",

    share: "分享我的计划",
    copied: "链接已复制",
    reset: "重置",
    footerDisclaimer: "仅供教育演示，不构成投资建议，也不保证任何投资回报。实际回报每年波动，可能出现亏损；4% 规则基于历史数据，不保证未来适用。模型不含税务、费用与社会安全金。投资前请咨询专业人士。",
  },

  en: {
    docTitle: "FIRE Calculator · Zhijie Tan",
    brandTitle: "FIRE Calculator",
    brandSub: "Financial Independence, Retire Early · starting with your first paycheck",
    present: "Present",
    exitPresent: "Exit",
    eyebrow: "Financial Independence · Starts With Your First Paycheck",
    heroA: "Every paycheck decides ",
    heroB: "when you get to be free.",
    heroSub: "When a paycheck lands, split it into four buckets — fixed costs & debt, savings, investing, and flexible spending. Move the percentages and watch compounding work for you over a lifetime — and see what every year of waiting really costs.",

    step1: "Step 1 · Your starting point",
    age: "Current age",
    ageUnit: "",
    income: "Monthly take-home pay",
    incomeNote: "What actually lands in your account",
    balance: "Already invested",
    balanceNote: "401(k), IRA, brokerage — not savings",

    step2: "Step 2 · Split your paycheck",
    step2Sub: "Pay your future self first — flexible spending is automatically whatever is left after the other three buckets.",
    presetMoonlight: "Paycheck-to-paycheck",
    presetStandard: "Standard",
    presetAmbitious: "Ambitious",
    presetFire: "FIRE",
    custom: "Custom",
    fixed: "Fixed costs & debt",
    fixedDesc: "Rent, bills, insurance, car & student loans, card payments",
    savings: "Savings",
    savingsDesc: "Emergency fund and short-term goals (car, down payment, travel) — it gets spent, so it doesn't count toward FIRE",
    invest: "Investing",
    investDesc: "Long-term investing (index funds, 401(k), Roth IRA) — the only bucket that compounds for you",
    flex: "Flexible spending",
    flexDesc: "Dining out, shopping, fun — automatically whatever is left",
    perMonth: "/mo",
    emergency: "At this savings rate, a 6-month emergency fund takes about {n} months to build.",
    emergencyNone: "Savings is 0: with no emergency fund, one surprise bill can force you to sell investments or run up a credit card.",
    flexZero: "Flexible spending is already 0 — to invest more, move money out of fixed costs or savings first.",

    step3: "Adjust the assumptions",
    ret: "Annual return (nominal)",
    retNote: "S&P 500 long-run nominal average ≈ 10%, with big swings year to year",
    infl: "Inflation",
    inflNote: "U.S. long-run average ≈ 3%",
    raise: "Annual raise",
    raiseNote: "Yearly growth in take-home pay, inflation included",
    sweep: "Invest this much of each raise",
    sweepNote: "Only the part of a raise above inflation; the rest follows your split. Your defense against lifestyle creep",
    wr: "Safe withdrawal rate",
    wrNote: "4% rule: FIRE number = annual spending × 25",
    nominal: "Future dollars",
    real: "Today's dollars",

    fireAt: "Financially independent at age",
    fireAtSuffix: "",
    countdown: "{y} years {m} months from now",
    countdownMonths: "{m} months from now",
    already: "You've already hit your FIRE number!",
    alreadySub: "What you have invested can already fund today's spending at a {wr}% withdrawal rate.",
    never: "At this split, you don't reach financial independence before 100",
    neverSub: "The investing bucket is too small for compounding to work with. Try Standard or FIRE.",
    progress: "Progress today",
    progressOf: "{a} of {b}",
    savingsNotCounted: "The savings bucket is for short-term goals and isn't counted toward FIRE.",

    fireNumber: "Your FIRE number",
    fireNumberFoot: "{spend}/yr spending × {mult} ({when})",
    whenFire: "in your FIRE year",
    whenNow: "at today's spending",
    monthlyInvest: "Invested per month",
    monthlyInvestFoot: "{p}% of take-home",
    at65: "Invested assets at 65",
    at65Foot: "{p}% of it is compound growth",
    crossover: "Money-works-for-you point",
    crossoverFoot: "After this, yearly growth beats yearly contributions",
    crossoverNone: "—",
    ageValue: "Age {n}",

    chartTitle: "Your lifetime compounding",
    chartSub: "What you put in vs. what compounding earned",
    legendContrib: "Your contributions",
    legendGrowth: "Compound growth",
    legendTarget: "FIRE target",
    markerFire: "FIRE · {n}",
    markerCross: "Crossover · {n}",
    tipAge: "Age {n}",
    tipTotal: "Total invested",

    waitTitle: "The cost of waiting",
    waitSub: "Same split — just starting to invest later (that money gets spent instead)",
    waitNow: "Start now",
    wait5: "Wait 5 years",
    wait10: "Wait 10 years",
    colAt65: "At 65",
    colFire: "FIRE age",
    lost: "−{x}",
    neverShort: "Never",
    catchup: "Wait 10 years and, to catch up by 65, you'd need to invest {x}/mo instead of {y} — {n}× as much.",
    perYear: "Every year you wait to start adds about {n} more {years} of work before you're free.",
    waitNoInvest: "With 0 in the investing bucket, waiting changes nothing — there's no compounding either way.",

    habitTitle: "Spend a little less each day",
    habitSub: "Move it from flexible spending into investing (grows with inflation)",
    habitLabel: "Cut per day",
    habitExamples: ["a bottle of water", "a coffee", "coffee + a snack", "a delivery order", "delivery + boba", "a nice dinner"],
    habitMonthly: "≈ {x} per month",
    habitResult: "Grows to this by 65",
    habitFire: "and moves FIRE {n} years earlier",
    habitFireNew: "and gets you to FIRE at {n}",

    lessonsTitle: "Key takeaways",
    l0: "With 0 in the investing bucket, a whole career of work leaves $0 invested at 65. No matter the income — no investing, no compounding.",
    l1: "By 65 you put in {p}, but the account holds {b} — {g} ({pct}%) was earned by compounding, not by you.",
    l2: "From age {n}, your money earns more each year than you put in. Start earlier and that day comes sooner.",
    l2None: "Before 65, yearly growth never overtakes what you put in each year — raise the investing share and a crossover appears.",
    l3: "Waiting 5 years costs {x} by 65. Time is the one asset young people have more of than anyone.",
    l4Low: "Invest half of the above-inflation part of every raise and FIRE arrives {n} years sooner — while your lifestyle still keeps pace with inflation.",
    l4High: "Investing {k}% of your above-inflation raises gets you to FIRE {n} years sooner than spending every raise.",
    l4None: "Automatically sending part of every raise to investing is the simplest defense against lifestyle creep — as long as your raises beat inflation.",

    share: "Share my plan",
    copied: "Link copied",
    reset: "Reset",
    footerDisclaimer: "Educational illustration only — not investment advice and not a guarantee of any return. Real returns swing year to year and include losses; the 4% rule is based on historical data and may not hold in the future. The model ignores taxes, fees, and Social Security. Consult a financial professional before investing.",
  },
};

function fmt(template, vars) {
  return template.replace(/\{(\w+)\}/g, (_, k) => (vars && vars[k] !== undefined ? vars[k] : ""));
}

/* ---------- Formatting helpers --------------------------------------------- */

function money(v) {
  if (!isFinite(v)) return "—";
  if (Math.abs(v) >= 1e6) return "$" + (v / 1e6).toFixed(2) + "M";
  return "$" + Math.round(v).toLocaleString("en-US");
}
function moneyAxis(v) {
  if (Math.abs(v) >= 1e6) return "$" + (v / 1e6).toFixed(1) + "M";
  if (Math.abs(v) >= 1e3) return "$" + Math.round(v / 1e3) + "k";
  return "$" + v;
}
function oneDecimal(n) {
  return (Math.round(n * 10) / 10).toFixed(1).replace(/\.0$/, "");
}

/* ---------- Presets ----------------------------------------------------------
   Four-bucket splits of take-home pay: fixed & debt / savings / investing /
   flexible. Flexible is always the remainder, so a preset only stores the
   first three. "Paycheck-to-paycheck" is the deliberate cautionary contrast. */

const PRESETS = [
  { id: "moonlight", key: "presetMoonlight", fixed: 60, savings: 5, invest: 0 },
  { id: "standard", key: "presetStandard", fixed: 50, savings: 10, invest: 20 },
  { id: "ambitious", key: "presetAmbitious", fixed: 50, savings: 10, invest: 25 },
  { id: "fire", key: "presetFire", fixed: 40, savings: 10, invest: 35 },
];

const DEFAULTS = {
  age: 22,
  income: 4000,
  balance: 0,
  fixed: 50,
  savings: 10,
  invest: 20,
  ret: 10,
  infl: 3,
  raise: 4,
  sweep: 0,
  wr: 4,
  real: false,
  daily: 10,
};

const RANGES = {
  age: [16, 50, 1],
  income: [1000, 15000, 100],
  balance: [0, 200000, 1000],
  fixed: [20, 80, 1],
  savings: [0, 30, 1],
  invest: [0, 60, 1],
  ret: [4, 12, 0.5],
  infl: [0, 6, 0.5],
  raise: [0, 8, 0.5],
  sweep: [0, 100, 10],
  wr: [3, 5, 0.25],
  daily: [1, 50, 1],
};

const HORIZON_AGE = 65;
const MAX_AGE = 100;

/* ---------- Core model -------------------------------------------------------
   Monthly simulation from today to age 100. Everything is computed in
   nominal (future) dollars; "today's dollars" mode divides by the inflation
   deflator at display time.

   Income: take-home × (1 + raise)^year, stepping up once a year.
   Raise sweep only touches the *real* raise — the part of income above
   what it takes to keep up with inflation:
     base_t = min(income_t, income_0 × (1 + infl)^year)   (lifestyle kept whole)
     Δ_t    = income_t − base_t                             (real raise, ≥ 0)
     invest_t = i·income_t + sweep·(1 − i)·Δ_t
     spend_t  = (fixed + flex)·(base_t + (1 − sweep)·Δ_t)
   (these two plus savings always add back up to income_t). Sweeping the
   nominal raise instead was tried first and rejected: with raise ≈ inflation
   it silently shrank real spending by ~25% over a career, so "invest half
   your raises" looked like 7+ years of free FIRE progress when it was really
   a lifestyle cut. If raise ≤ inflation, Δ = 0 and the lever does nothing —
   correct, since there's no real raise to invest.

   FIRE target at any month = 12 × spend_t ÷ withdrawal rate. The savings
   bucket is deliberately excluded from both sides: it's for short-term goals
   and gets spent, so it neither funds FIRE nor counts as FIRE spending.

   opts.delayYears — invest nothing for the first N years (cost-of-waiting).
   opts.dailyCut   — move $X/day (today's $, grows with inflation) from
                     flexible spending into investing (daily-habit card).
   opts.sweep      — override the raise sweep (for the lifestyle-creep lesson). */

function simulate(p, opts = {}) {
  const delayMonths = (opts.delayYears || 0) * 12;
  const dailyCut = opts.dailyCut || 0;
  const sweep = (opts.sweep !== undefined ? opts.sweep : p.sweep) / 100;

  const i = p.invest / 100;
  const spendShare = (100 - p.savings - p.invest) / 100; // fixed + flex
  const rm = Math.pow(1 + p.ret / 100, 1 / 12) - 1;
  const g = p.raise / 100;
  const pi = p.infl / 100;
  const wr = p.wr / 100;
  const months = (MAX_AGE - p.age) * 12;

  let balance = p.balance;
  let principal = p.balance;
  let principalReal = p.balance;
  let fireMonth = null;
  let yearContrib = 0;
  let yearStartBalance = balance;
  let crossoverAge = null;

  const firstSpend = spendShare * p.income;
  const yearly = [{
    age: p.age,
    balance, principal, principalReal,
    target: (12 * firstSpend) / wr,
    deflator: 1,
    monthlyInvest: delayMonths > 0 ? 0 : i * p.income + (dailyCut * 365) / 12,
  }];
  if (balance >= (12 * firstSpend) / wr) fireMonth = 0;

  for (let m = 0; m < months; m++) {
    const year = Math.floor(m / 12);
    const income = p.income * Math.pow(1 + g, year);
    const base = Math.min(income, p.income * Math.pow(1 + pi, year));
    const delta = income - base;
    let contrib = i * income + sweep * (1 - i) * delta;
    let spend = spendShare * (base + (1 - sweep) * delta);

    if (dailyCut > 0) {
      const habit = ((dailyCut * 365) / 12) * Math.pow(1 + pi, year);
      contrib += habit;
      spend = Math.max(0, spend - habit);
    }
    if (m < delayMonths) contrib = 0;

    balance = balance * (1 + rm) + contrib;
    principal += contrib;
    yearContrib += contrib;
    const deflator = Math.pow(1 + pi, (m + 1) / 12);
    principalReal += contrib / deflator;
    const target = (12 * spend) / wr;

    if (fireMonth === null && balance >= target) fireMonth = m + 1;

    if ((m + 1) % 12 === 0) {
      const growth = balance - yearStartBalance - yearContrib;
      const age = p.age + (m + 1) / 12;
      if (crossoverAge === null && yearContrib > 0 && growth > yearContrib) crossoverAge = age;
      const nextYear = year + 1;
      const nextIncome = p.income * Math.pow(1 + g, nextYear);
      const nextDelta = nextIncome - Math.min(nextIncome, p.income * Math.pow(1 + pi, nextYear));
      yearly.push({
        age, balance, principal, principalReal, target, deflator,
        monthlyInvest: i * nextIncome + sweep * (1 - i) * nextDelta,
      });
      yearContrib = 0;
      yearStartBalance = balance;
    }
  }

  const at65 = yearly.find((y) => y.age === HORIZON_AGE) || yearly[yearly.length - 1];
  return {
    yearly,
    fireMonth,
    fireAge: fireMonth === null ? null : p.age + fireMonth / 12,
    crossoverAge,
    at65,
  };
}

/* ---------- URL share state ------------------------------------------------- */

const PARAM_KEYS = {
  age: "a", income: "m", balance: "b", fixed: "fx", savings: "sv", invest: "iv",
  ret: "r", infl: "inf", raise: "g", sweep: "k", wr: "w", daily: "d",
};

function clampTo(key, v) {
  const [lo, hi, step] = RANGES[key];
  const snapped = Math.round(v / step) * step;
  return Math.min(hi, Math.max(lo, +snapped.toFixed(2)));
}

function readStateFromUrl() {
  const state = { ...DEFAULTS };
  let params;
  try { params = new URLSearchParams(window.location.search); } catch (e) { return { state, lang: null }; }
  Object.entries(PARAM_KEYS).forEach(([key, short]) => {
    const raw = params.get(short);
    if (raw === null || raw === "") return;
    const n = parseFloat(raw);
    if (isFinite(n)) state[key] = clampTo(key, n);
  });
  // Buckets must leave flexible ≥ 0; if a hand-edited link breaks that,
  // trim investing first, then savings.
  let over = state.fixed + state.savings + state.invest - 100;
  if (over > 0) { const cut = Math.min(over, state.invest); state.invest -= cut; over -= cut; }
  if (over > 0) state.savings = Math.max(0, state.savings - over);
  if (params.get("real") === "1") state.real = true;
  const lang = params.get("lang");
  return { state, lang: lang === "zh" || lang === "en" ? lang : null };
}

function buildShareUrl(state, lang) {
  const params = new URLSearchParams();
  Object.entries(PARAM_KEYS).forEach(([key, short]) => params.set(short, String(state[key])));
  if (state.real) params.set("real", "1");
  params.set("lang", lang);
  return window.location.origin + window.location.pathname + "?" + params.toString();
}

/* ---------- Theme + language ------------------------------------------------ */

function useTheme() {
  const [theme, setTheme] = useState(() => document.documentElement.getAttribute("data-theme") || "dark");
  // Layout effect, not a plain effect: the chart (a child) reads CSS
  // variables in its own effect, and child passive effects run before this
  // parent's — a plain useEffect would let the chart redraw with the
  // previous theme's colors.
  useLayoutEffect(() => { document.documentElement.setAttribute("data-theme", theme); }, [theme]);
  return [theme, () => setTheme((t) => (t === "dark" ? "light" : "dark"))];
}

function initialLang(fromUrl) {
  if (fromUrl) return fromUrl;
  try {
    const saved = localStorage.getItem("fire-lang");
    if (saved === "zh" || saved === "en") return saved;
  } catch (e) { /* storage blocked — fall through */ }
  const nav = (navigator.language || "").toLowerCase();
  return nav.startsWith("zh") ? "zh" : "en";
}

/* ---------- Header ----------------------------------------------------------- */

function SiteHeader({ t, lang, setLang, theme, onToggleTheme, presenting, onTogglePresent }) {
  return (
    <header className="site-header">
      <div className="brand">
        <div className="brand-mark">FI</div>
        <div className="brand-text">
          <div className="brand-title">
            {t.brandTitle}
            <span className="brand-pill">FIRE</span>
          </div>
          <div className="brand-sub">{t.brandSub}</div>
        </div>
      </div>
      <div className="header-actions">
        <div className="mode-toggle lang-toggle" role="tablist" aria-label="Language">
          <button type="button" className={lang === "zh" ? "is-active" : ""} onClick={() => setLang("zh")}>中文</button>
          <button type="button" className={lang === "en" ? "is-active" : ""} onClick={() => setLang("en")}>EN</button>
        </div>
        <button type="button" className="header-btn present-btn" onClick={onTogglePresent}>
          {presenting ? <Icon.Shrink /> : <Icon.Expand />}
          <span>{presenting ? t.exitPresent : t.present}</span>
        </button>
        <button type="button" className="header-btn icon-only" onClick={onToggleTheme} aria-label="Toggle theme">
          {theme === "dark" ? <Icon.Sun /> : <Icon.Moon />}
        </button>
      </div>
    </header>
  );
}

/* ---------- Generic slider field --------------------------------------------- */

function SliderField({ icon, label, value, display, note, min, max, step, onChange, className }) {
  const fill = ((value - min) / (max - min)) * 100;
  return (
    <div className={"control-field " + (className || "")}>
      <div className="control-label-row">
        <label className="control-label">{icon}{label}</label>
        <span className="control-value">{display}</span>
      </div>
      <input
        type="range" min={min} max={max} step={step} value={value}
        onChange={(e) => onChange(parseFloat(e.target.value))}
        style={{ "--fill": fill + "%" }}
        aria-label={label}
      />
      {note && <span className="control-note">{note}</span>}
    </div>
  );
}

/* ---------- Step 1: starting point ---------------------------------------------- */

function StartPanel({ t, lang, state, set }) {
  return (
    <section className="glass-panel controls-panel">
      <div className="panel-title"><Icon.User />{t.step1}</div>
      <div className="controls-grid start-grid">
        <SliderField
          icon={<Icon.User />} label={t.age}
          value={state.age} display={lang === "zh" ? `${state.age} ${t.ageUnit}` : state.age}
          min={RANGES.age[0]} max={RANGES.age[1]} step={RANGES.age[2]}
          onChange={(v) => set("age", v)}
        />
        <SliderField
          icon={<Icon.Wallet />} label={t.income}
          value={state.income} display={money(state.income) + t.perMonth}
          note={t.incomeNote}
          min={RANGES.income[0]} max={RANGES.income[1]} step={RANGES.income[2]}
          onChange={(v) => set("income", v)}
        />
        <SliderField
          icon={<Icon.Vault />} label={t.balance}
          value={state.balance} display={money(state.balance)}
          note={t.balanceNote}
          min={RANGES.balance[0]} max={RANGES.balance[1]} step={RANGES.balance[2]}
          onChange={(v) => set("balance", v)}
        />
      </div>
    </section>
  );
}

/* ---------- Step 2: paycheck split ------------------------------------------------ */

const BUCKETS = [
  { id: "fixed", color: "--b-fixed" },
  { id: "savings", color: "--b-save" },
  { id: "invest", color: "--b-invest" },
  { id: "flex", color: "--b-flex" },
];

function SplitPanel({ t, state, setBucket, applyPreset }) {
  const flex = 100 - state.fixed - state.savings - state.invest;
  const pct = { fixed: state.fixed, savings: state.savings, invest: state.invest, flex };
  const activePreset = PRESETS.find((p) => p.fixed === state.fixed && p.savings === state.savings && p.invest === state.invest);
  const monthlySpend = ((state.fixed + flex) / 100) * state.income;
  const monthlySave = (state.savings / 100) * state.income;
  const emergencyMonths = monthlySave > 0 ? Math.ceil((6 * monthlySpend) / monthlySave) : null;

  return (
    <section className="glass-panel controls-panel split-panel">
      <div className="split-head">
        <div>
          <div className="panel-title"><Icon.Wallet />{t.step2}</div>
          <p className="panel-sub">{t.step2Sub}</p>
        </div>
        <div className="mode-toggle preset-toggle" role="tablist" aria-label="Presets">
          {PRESETS.map((p) => (
            <button
              key={p.id} type="button"
              className={(activePreset && activePreset.id === p.id ? "is-active " : "") + (p.id === "moonlight" ? "is-warning" : "")}
              onClick={() => applyPreset(p)}
            >
              {t[p.key]}
            </button>
          ))}
          {!activePreset && <button type="button" className="is-active" disabled>{t.custom}</button>}
        </div>
      </div>

      {/* The paycheck bar — one row, four colored segments. */}
      <div className="paycheck-bar" role="img" aria-label="Paycheck split">
        {BUCKETS.map((b) => pct[b.id] > 0 && (
          <div key={b.id} className="paycheck-seg" style={{ width: pct[b.id] + "%", background: `var(${b.color})` }}>
            {pct[b.id] >= 9 && <span>{pct[b.id]}%</span>}
          </div>
        ))}
      </div>

      <div className="bucket-grid">
        {BUCKETS.map((b) => {
          const amount = (pct[b.id] / 100) * state.income;
          const isFlex = b.id === "flex";
          return (
            <div key={b.id} className={"bucket-card glass-card" + (b.id === "invest" ? " is-invest" : "")} style={{ "--bucket": `var(${b.color})` }}>
              <div className="bucket-top">
                <span className="bucket-name"><span className="bucket-dot" />{t[b.id]}</span>
                <span className="bucket-pct">{pct[b.id]}%</span>
              </div>
              <div className="bucket-amount">{money(amount)}<span>{t.perMonth}</span></div>
              {isFlex ? (
                <div className="bucket-remainder">
                  <div className="bucket-remainder-track"><div style={{ width: flex + "%" }} /></div>
                </div>
              ) : (
                <input
                  type="range" className="bucket-range"
                  min={RANGES[b.id][0]} max={RANGES[b.id][1]} step={1} value={pct[b.id]}
                  onChange={(e) => setBucket(b.id, parseInt(e.target.value, 10))}
                  style={{ "--fill": ((pct[b.id] - RANGES[b.id][0]) / (RANGES[b.id][1] - RANGES[b.id][0])) * 100 + "%" }}
                  aria-label={t[b.id]}
                />
              )}
              <p className="bucket-desc">{t[b.id + "Desc"]}</p>
            </div>
          );
        })}
      </div>

      <div className="split-notes">
        <p className={emergencyMonths === null ? "note-warn" : ""}>
          {emergencyMonths === null ? <Icon.Alert /> : null}
          {emergencyMonths === null ? t.emergencyNone : fmt(t.emergency, { n: emergencyMonths })}
        </p>
        {flex === 0 && <p className="note-warn"><Icon.Alert />{t.flexZero}</p>}
      </div>
    </section>
  );
}

/* ---------- Step 3: assumptions ----------------------------------------------------- */

function AssumptionsPanel({ t, state, set }) {
  return (
    <section className="glass-panel controls-panel">
      <div className="panel-title"><Icon.Sliders />{t.step3}</div>
      <div className="controls-grid assumptions-grid">
        <SliderField label={t.ret} value={state.ret} display={state.ret + "%"} note={t.retNote}
          min={RANGES.ret[0]} max={RANGES.ret[1]} step={RANGES.ret[2]} onChange={(v) => set("ret", v)} />
        <SliderField label={t.infl} value={state.infl} display={state.infl + "%"} note={t.inflNote}
          min={RANGES.infl[0]} max={RANGES.infl[1]} step={RANGES.infl[2]} onChange={(v) => set("infl", v)} />
        <SliderField label={t.raise} value={state.raise} display={state.raise + "%"} note={t.raiseNote}
          min={RANGES.raise[0]} max={RANGES.raise[1]} step={RANGES.raise[2]} onChange={(v) => set("raise", v)} />
        <SliderField label={t.sweep} value={state.sweep} display={state.sweep + "%"} note={t.sweepNote}
          min={RANGES.sweep[0]} max={RANGES.sweep[1]} step={RANGES.sweep[2]} onChange={(v) => set("sweep", v)} />
        <SliderField label={t.wr} value={state.wr} display={state.wr + "%"} note={t.wrNote}
          min={RANGES.wr[0]} max={RANGES.wr[1]} step={RANGES.wr[2]} onChange={(v) => set("wr", v)} />
      </div>
    </section>
  );
}

/* ---------- Dollar-mode toggle (nominal vs today's dollars) --------------------- */

function DollarToggle({ t, real, setReal }) {
  return (
    <div className="mode-toggle dollar-toggle" role="tablist" aria-label="Dollar mode">
      <button type="button" className={!real ? "is-active" : ""} onClick={() => setReal(false)}>{t.nominal}</button>
      <button type="button" className={real ? "is-active" : ""} onClick={() => setReal(true)}>{t.real}</button>
    </div>
  );
}

/* ---------- FIRE countdown + stat cards --------------------------------------------- */

function ResultsPanel({ t, lang, state, sim, dollars }) {
  const { fireAge, fireMonth, crossoverAge, at65, yearly } = sim;
  const monthlyInvest = yearly[0].monthlyInvest;
  const now = yearly[0];

  let fireYearEntry = null;
  if (fireMonth !== null) fireYearEntry = yearly[Math.min(yearly.length - 1, Math.ceil(fireMonth / 12))];
  const targetEntry = fireYearEntry && fireMonth > 0 ? fireYearEntry : now;
  const fireNumber = dollars(targetEntry.target, targetEntry.deflator);
  const annualSpend = fireNumber * (state.wr / 100);
  const mult = oneDecimal(100 / state.wr);

  const b65 = dollars(at65.balance, at65.deflator);
  const p65 = state.real ? at65.principalReal : at65.principal;
  const growthPct = b65 > 0 ? Math.max(0, Math.round(((b65 - p65) / b65) * 100)) : 0;

  const progress = Math.min(1, now.balance / now.target);
  const yearsLeft = fireMonth !== null ? Math.floor(fireMonth / 12) : 0;
  const monthsLeft = fireMonth !== null ? fireMonth % 12 : 0;

  let headline;
  if (fireMonth === 0) {
    headline = (
      <>
        <div className="fire-kicker"><Icon.Flame />{t.already}</div>
        <div className="fire-sub">{fmt(t.alreadySub, { wr: state.wr })}</div>
      </>
    );
  } else if (fireAge === null) {
    headline = (
      <>
        <div className="fire-kicker is-never"><Icon.Alert />{t.never}</div>
        <div className="fire-sub">{t.neverSub}</div>
      </>
    );
  } else {
    headline = (
      <>
        <div className="fire-kicker"><Icon.Flame />{t.fireAt}</div>
        <div className="fire-age">
          <span className="fire-age-num">{Math.floor(fireAge)}</span>
          {t.fireAtSuffix && <span className="fire-age-suffix">{t.fireAtSuffix}</span>}
        </div>
        <div className="fire-countdown">
          <Icon.Hourglass />
          {yearsLeft > 0 ? fmt(t.countdown, { y: yearsLeft, m: monthsLeft }) : fmt(t.countdownMonths, { m: monthsLeft })}
        </div>
      </>
    );
  }

  return (
    <section className={"glass-panel results-panel" + (fireAge === null ? " is-never" : "")}>
      <div className="fire-hero">
        {headline}
        <div className="fire-progress">
          <div className="fire-progress-label">
            <span>{t.progress}</span>
            <span>{fmt(t.progressOf, { a: money(now.balance), b: money(now.target) })}</span>
          </div>
          <div className="fire-progress-track"><div style={{ width: Math.max(progress * 100, progress > 0 ? 1.5 : 0) + "%" }} /></div>
          <div className="fire-progress-note">{t.savingsNotCounted}</div>
        </div>
      </div>

      <div className="stat-grid">
        <div className="glass-card stat-card is-highlight">
          <div className="stat-card-label"><Icon.Flame />{t.fireNumber}</div>
          <div className="stat-card-value">{money(fireNumber)}</div>
          <div className="stat-card-foot">{fmt(t.fireNumberFoot, { spend: money(annualSpend), mult, when: fireYearEntry && fireMonth > 0 ? t.whenFire : t.whenNow })}</div>
        </div>
        <div className="glass-card stat-card">
          <div className="stat-card-label"><Icon.Trend />{t.monthlyInvest}</div>
          <div className="stat-card-value">{money(monthlyInvest)}</div>
          <div className="stat-card-foot">{fmt(t.monthlyInvestFoot, { p: Math.round((monthlyInvest / state.income) * 100) })}</div>
        </div>
        <div className="glass-card stat-card">
          <div className="stat-card-label"><Icon.Vault />{t.at65}</div>
          <div className="stat-card-value">{money(b65)}</div>
          <div className="stat-card-foot">{fmt(t.at65Foot, { p: growthPct })}</div>
        </div>
        <div className="glass-card stat-card">
          <div className="stat-card-label"><Icon.Chart />{t.crossover}</div>
          <div className="stat-card-value">{crossoverAge === null ? t.crossoverNone : fmt(t.ageValue, { n: crossoverAge })}</div>
          <div className="stat-card-foot">{t.crossoverFoot}</div>
        </div>
      </div>
    </section>
  );
}

/* ---------- Lifetime compounding chart ------------------------------------------------ */

function cssVar(name, fallback) {
  const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  return v || fallback;
}

function withAlpha(hex, alpha) {
  const m = hex.replace("#", "");
  if (m.length !== 6) return hex;
  const n = parseInt(m, 16);
  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}

function LifetimeChart({ t, lang, state, sim, dollars, theme, presenting, setReal }) {
  const canvasRef = useRef(null);
  const chartRef = useRef(null);

  const endAge = Math.min(90, Math.max(HORIZON_AGE, sim.fireAge !== null ? Math.ceil(sim.fireAge) + 3 : HORIZON_AGE));
  const rows = useMemo(() => sim.yearly.filter((y) => y.age <= endAge), [sim, endAge]);

  useEffect(() => {
    const invest = cssVar("--b-invest", "#00A37E");
    const contrib = cssVar("--chart-contrib", "#11175E");
    const fireColor = cssVar("--fire", "#E0621B");
    const inkMuted = cssVar("--ink-muted", "#888");
    const inkSoft = cssVar("--ink-soft", "#555");
    const grid = cssVar("--divider", "rgba(128,128,128,0.15)");
    const surface = cssVar("--surface", "#fff");

    const labels = rows.map((r) => r.age);
    const contribData = rows.map((r) => (state.real ? r.principalReal : r.principal));
    const growthData = rows.map((r, idx) => Math.max(0, dollars(r.balance, r.deflator) - contribData[idx]));
    const targetData = rows.map((r) => dollars(r.target, r.deflator));

    // Vertical markers (FIRE age, crossover age) drawn by a tiny inline
    // plugin — keeps us off the chartjs-plugin-annotation dependency.
    const markers = [];
    if (sim.fireAge !== null && sim.fireMonth > 0 && sim.fireAge <= endAge) {
      markers.push({ age: sim.fireAge, color: fireColor, label: fmt(t.markerFire, { n: Math.floor(sim.fireAge) }), dash: [], row: 0 });
    }
    if (sim.crossoverAge !== null && sim.crossoverAge <= endAge) {
      markers.push({ age: sim.crossoverAge, color: invest, label: fmt(t.markerCross, { n: sim.crossoverAge }), dash: [4, 4], row: 1 });
    }
    const markerPlugin = {
      id: "ageMarkers",
      afterDatasetsDraw(chart) {
        const { ctx, chartArea, scales } = chart;
        markers.forEach((mk) => {
          const pos = mk.age - labels[0];
          const lo = Math.floor(pos), hi = Math.min(labels.length - 1, Math.ceil(pos));
          const xLo = scales.x.getPixelForValue(lo), xHi = scales.x.getPixelForValue(hi);
          const x = xLo + (xHi - xLo) * (pos - lo);
          ctx.save();
          ctx.strokeStyle = mk.color;
          ctx.lineWidth = 2;
          ctx.setLineDash(mk.dash);
          ctx.beginPath();
          ctx.moveTo(x, chartArea.top + 4);
          ctx.lineTo(x, chartArea.bottom);
          ctx.stroke();
          ctx.setLineDash([]);
          ctx.font = "600 11px Outfit, 'Microsoft YaHei', sans-serif";
          const w = ctx.measureText(mk.label).width + 12;
          const y = chartArea.top + 6 + mk.row * 24;
          const alignRight = x + w > chartArea.right;
          const bx = alignRight ? x - w - 4 : x + 4;
          ctx.fillStyle = surface;
          ctx.strokeStyle = mk.color;
          ctx.lineWidth = 1;
          ctx.fillRect(bx, y, w, 19);
          ctx.strokeRect(bx, y, w, 19);
          ctx.fillStyle = mk.color;
          ctx.textBaseline = "middle";
          ctx.fillText(mk.label, bx + 6, y + 10);
          ctx.restore();
        });
      },
    };

    if (chartRef.current) chartRef.current.destroy();
    chartRef.current = new Chart(canvasRef.current.getContext("2d"), {
      type: "line",
      data: {
        labels,
        datasets: [
          {
            label: t.legendContrib, data: contribData, stack: "money",
            borderColor: contrib, backgroundColor: withAlpha(contrib, 0.55),
            fill: "origin", tension: 0.3, borderWidth: 1.5, pointRadius: 0, pointHoverRadius: 3,
          },
          {
            label: t.legendGrowth, data: growthData, stack: "money",
            borderColor: invest, backgroundColor: withAlpha(invest, 0.45),
            fill: "-1", tension: 0.3, borderWidth: 2, pointRadius: 0, pointHoverRadius: 3,
          },
          {
            label: t.legendTarget, data: targetData, stack: "target",
            borderColor: fireColor, backgroundColor: "transparent",
            borderDash: [6, 5], borderWidth: 2, fill: false, tension: 0.3, pointRadius: 0, pointHoverRadius: 3,
          },
        ],
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        animation: { duration: 250 },
        interaction: { mode: "index", intersect: false },
        plugins: {
          legend: { display: false },
          tooltip: {
            backgroundColor: "#0B0D14",
            borderColor: "rgba(196, 160, 85, 0.35)",
            borderWidth: 1,
            titleColor: "#C4A055",
            bodyColor: "#DDD8CE",
            titleFont: { family: "Outfit", size: 12, weight: "600" },
            bodyFont: { family: "JetBrains Mono", size: 11 },
            padding: 10,
            callbacks: {
              title: (items) => fmt(t.tipAge, { n: items[0].label }),
              label: (c) => " " + c.dataset.label + ": " + money(c.raw),
              afterBody: (items) => {
                const idx = items[0].dataIndex;
                return [" " + t.tipTotal + ": " + money(contribData[idx] + growthData[idx])];
              },
            },
          },
        },
        scales: {
          x: {
            grid: { color: grid },
            ticks: { color: inkMuted, font: { family: "Outfit", size: presenting ? 13 : 11 }, maxTicksLimit: 12 },
          },
          y: {
            stacked: true,
            grid: { color: grid },
            ticks: { color: inkSoft, font: { family: "JetBrains Mono", size: presenting ? 13 : 11 }, callback: (v) => moneyAxis(v) },
          },
        },
      },
      plugins: [markerPlugin],
    });

    return () => { if (chartRef.current) chartRef.current.destroy(); };
  }, [rows, state.real, theme, lang, presenting, sim.fireAge, sim.crossoverAge]);

  return (
    <section className="glass-panel chart-panel lifetime-panel">
      <div className="chart-head">
        <div>
          <div className="chart-title"><Icon.Chart />{t.chartTitle}</div>
          <div className="chart-sub">{t.chartSub}</div>
        </div>
        <DollarToggle t={t} real={state.real} setReal={setReal} />
      </div>
      <div className="chart-legend lifetime-legend">
        <span><span className="legend-dot" style={{ background: "var(--chart-contrib)" }} />{t.legendContrib}</span>
        <span><span className="legend-dot" style={{ background: "var(--b-invest)" }} />{t.legendGrowth}</span>
        <span><span className="legend-line" />{t.legendTarget}</span>
      </div>
      <div className="chart-canvas-wrap lifetime-canvas"><canvas ref={canvasRef}></canvas></div>
    </section>
  );
}

/* ---------- Urgency: cost of waiting ---------------------------------------------------- */

function WaitPanel({ t, state, sims, dollars }) {
  const rows = [
    { key: "waitNow", sim: sims.now },
    { key: "wait5", sim: sims.wait5 },
    { key: "wait10", sim: sims.wait10 },
  ];
  const vals = rows.map((r) => dollars(r.sim.at65.balance, r.sim.at65.deflator));
  const max = Math.max(...vals, 1);
  const noInvest = state.invest === 0 && state.sweep === 0;

  // Catch-up: the wait-10 contribution stream scaled up until its 65 balance
  // matches start-now. Starting balance compounds the same in both, so only
  // the contribution-driven part scales.
  const years65 = HORIZON_AGE - state.age;
  const fvStart = state.balance * Math.pow(1 + state.ret / 100, years65);
  const fromContribNow = sims.now.at65.balance - fvStart;
  const fromContrib10 = sims.wait10.at65.balance - fvStart;
  const multiplier = fromContrib10 > 0 ? fromContribNow / fromContrib10 : null;
  const baseMonthly = sims.now.yearly[0].monthlyInvest;

  const fireNow = sims.now.fireAge, fire5 = sims.wait5.fireAge;
  const perYear = fireNow !== null && fire5 !== null && sims.now.fireMonth > 0 ? (fire5 - fireNow) / 5 : null;

  return (
    <section className="glass-panel urgency-panel">
      <div className="panel-title"><Icon.Hourglass />{t.waitTitle}</div>
      <p className="panel-sub">{t.waitSub}</p>

      {noInvest ? (
        <p className="note-warn"><Icon.Alert />{t.waitNoInvest}</p>
      ) : (
        <>
          <div className="wait-head">
            <span />
            <span>{t.colAt65}</span>
            <span>{t.colFire}</span>
          </div>
          <div className="wait-rows">
            {rows.map((r, idx) => (
              <div key={r.key} className={"wait-row" + (idx === 0 ? " is-now" : "")}>
                <span className="wait-label">{t[r.key]}</span>
                <div className="wait-bar-cell">
                  <div className="wait-bar"><div style={{ width: (vals[idx] / max) * 100 + "%" }} /></div>
                  <div className="wait-bar-val">
                    {money(vals[idx])}
                    {idx > 0 && <em>{fmt(t.lost, { x: money(vals[0] - vals[idx]) })}</em>}
                  </div>
                </div>
                <span className="wait-fire">{r.sim.fireAge === null ? t.neverShort : Math.floor(r.sim.fireAge)}</span>
              </div>
            ))}
          </div>
          <div className="wait-notes">
            {multiplier !== null && isFinite(multiplier) && baseMonthly > 0 && (
              <p>{fmt(t.catchup, { x: money(baseMonthly * multiplier), y: money(baseMonthly), n: oneDecimal(multiplier) })}</p>
            )}
            {perYear !== null && perYear > 0 && <p>{fmt(t.perYear, { n: oneDecimal(perYear), years: oneDecimal(perYear) === "1" ? "year" : "years" })}</p>}
          </div>
        </>
      )}
    </section>
  );
}

/* ---------- Urgency: small daily habit ------------------------------------------------------ */

function habitExample(t, daily) {
  const ex = t.habitExamples;
  if (daily < 4) return ex[0];
  if (daily < 8) return ex[1];
  if (daily < 13) return ex[2];
  if (daily < 20) return ex[3];
  if (daily < 35) return ex[4];
  return ex[5];
}

function HabitPanel({ t, state, set, baseSim, habitSim, dollars }) {
  const daily = state.daily;
  const monthly = (daily * 365) / 12;
  const extra = dollars(habitSim.at65.balance, habitSim.at65.deflator) - dollars(baseSim.at65.balance, baseSim.at65.deflator);

  let fireLine = null;
  if (habitSim.fireAge !== null && habitSim.fireMonth > 0) {
    if (baseSim.fireAge === null) {
      fireLine = fmt(t.habitFireNew, { n: Math.floor(habitSim.fireAge) });
    } else if (baseSim.fireAge - habitSim.fireAge >= 0.05) {
      fireLine = fmt(t.habitFire, { n: oneDecimal(baseSim.fireAge - habitSim.fireAge) });
    }
  }

  return (
    <section className="glass-panel urgency-panel habit-panel">
      <div className="panel-title"><Icon.Coffee />{t.habitTitle}</div>
      <p className="panel-sub">{t.habitSub}</p>
      <SliderField
        label={t.habitLabel}
        value={daily} display={money(daily)}
        note={"≈ " + habitExample(t, daily) + " · " + fmt(t.habitMonthly, { x: money(monthly) })}
        min={RANGES.daily[0]} max={RANGES.daily[1]} step={RANGES.daily[2]}
        onChange={(v) => set("daily", v)}
      />
      <div className="habit-result">
        <div className="habit-result-label">{t.habitResult}</div>
        <div className="habit-result-value">+{money(extra)}</div>
        {fireLine && <div className="habit-result-fire"><Icon.Flame />{fireLine}</div>}
      </div>
    </section>
  );
}

/* ---------- Lessons -------------------------------------------------------------------------- */

function LessonsPanel({ t, state, sims, sweepCompare, dollars }) {
  const at65 = sims.now.at65;
  const b = dollars(at65.balance, at65.deflator);
  const p = state.real ? at65.principalReal : at65.principal;
  const g = Math.max(0, b - p);
  const pct = b > 0 ? Math.round((g / b) * 100) : 0;
  const lost5 = b - dollars(sims.wait5.at65.balance, sims.wait5.at65.deflator);

  const items = [];
  items.push(b > 0 ? fmt(t.l1, { p: money(p), b: money(b), g: money(g), pct }) : t.l0);
  items.push(sims.now.crossoverAge !== null && sims.now.crossoverAge <= HORIZON_AGE ? fmt(t.l2, { n: sims.now.crossoverAge }) : t.l2None);
  if (lost5 > 0) items.push(fmt(t.l3, { x: money(lost5) }));

  const { base, alt } = sweepCompare;
  if (base !== null && alt !== null && Math.abs(base - alt) >= 0.1) {
    items.push(state.sweep < 50
      ? fmt(t.l4Low, { n: oneDecimal(base - alt) })
      : fmt(t.l4High, { k: state.sweep, n: oneDecimal(alt - base) }));
  } else {
    items.push(t.l4None);
  }

  return (
    <section className="glass-panel lessons-panel">
      <div className="panel-title"><Icon.Bulb />{t.lessonsTitle}</div>
      <ol className="lesson-list">
        {items.map((txt, idx) => <li key={idx}><span className="lesson-num">{idx + 1}</span><p>{txt}</p></li>)}
      </ol>
    </section>
  );
}

/* ---------- Share row ------------------------------------------------------------------------- */

function ShareRow({ t, state, lang, onReset }) {
  const [copied, setCopied] = useState(false);
  const share = async () => {
    const url = buildShareUrl(state, lang);
    try { window.history.replaceState(null, "", url); } catch (e) { /* ignore */ }
    try {
      if (navigator.share && /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent)) {
        await navigator.share({ title: t.docTitle, url });
        return;
      }
      await navigator.clipboard.writeText(url);
      setCopied(true);
      setTimeout(() => setCopied(false), 2200);
    } catch (e) {
      // Clipboard blocked (e.g. insecure context): the URL bar now holds
      // the plan, so the user can still copy it by hand.
      setCopied(true);
      setTimeout(() => setCopied(false), 2200);
    }
  };
  return (
    <div className="share-row">
      <button type="button" className="btn-primary" onClick={share}><Icon.Share />{copied ? t.copied : t.share}</button>
      <button type="button" className="btn-ghost" onClick={onReset}><Icon.Reset />{t.reset}</button>
    </div>
  );
}

/* ---------- Footer ----------------------------------------------------------------------------- */

function SiteFooter({ t }) {
  return (
    <footer className="site-footer">
      <div className="footer-inner">
        <div className="footer-brand">FIRE Calculator · Zhijie Tan</div>
        <p className="footer-disclaimer">{t.footerDisclaimer}</p>
      </div>
    </footer>
  );
}

/* ---------- App ----------------------------------------------------------------------------------- */

const URL_INIT = readStateFromUrl();

function App() {
  const [theme, toggleTheme] = useTheme();
  const [lang, setLangState] = useState(() => initialLang(URL_INIT.lang));
  const [state, setState] = useState(URL_INIT.state);
  const [presenting, setPresenting] = useState(false);
  const t = COPY[lang];

  const setLang = (l) => {
    setLangState(l);
    try { localStorage.setItem("fire-lang", l); } catch (e) { /* ignore */ }
  };

  useEffect(() => {
    document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
    document.title = t.docTitle;
  }, [lang]);

  // Presentation mode: fullscreen the whole page and bump type/chart sizes.
  // The class is kept even if the Fullscreen API is refused (embedded
  // previews reject it) so "big mode" still works for screen-sharing.
  useEffect(() => {
    const onChange = () => { if (!document.fullscreenElement) setPresenting(false); };
    document.addEventListener("fullscreenchange", onChange);
    return () => document.removeEventListener("fullscreenchange", onChange);
  }, []);
  useEffect(() => { document.body.classList.toggle("is-presenting", presenting); }, [presenting]);
  const togglePresent = () => {
    if (presenting) {
      setPresenting(false);
      if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
    } else {
      setPresenting(true);
      const req = document.documentElement.requestFullscreen;
      if (req) document.documentElement.requestFullscreen().catch(() => {});
    }
  };

  const set = useCallback((key, value) => setState((s) => ({ ...s, [key]: value })), []);

  // Flexible spending is the remainder, so the other three buckets are
  // capped at whatever keeps flexible ≥ 0.
  const setBucket = useCallback((key, value) => {
    setState((s) => {
      const others = ["fixed", "savings", "invest"].filter((k) => k !== key).reduce((sum, k) => sum + s[k], 0);
      return { ...s, [key]: Math.min(value, 100 - others) };
    });
  }, []);
  const applyPreset = useCallback((p) => setState((s) => ({ ...s, fixed: p.fixed, savings: p.savings, invest: p.invest })), []);
  const reset = () => {
    setState({ ...DEFAULTS });
    try { window.history.replaceState(null, "", window.location.pathname); } catch (e) { /* ignore */ }
  };

  const model = useMemo(() => ({
    age: state.age, income: state.income, balance: state.balance,
    savings: state.savings, invest: state.invest,
    ret: state.ret, infl: state.infl, raise: state.raise, sweep: state.sweep, wr: state.wr,
  }), [state.age, state.income, state.balance, state.savings, state.invest, state.ret, state.infl, state.raise, state.sweep, state.wr]);

  const sims = useMemo(() => ({
    now: simulate(model),
    wait5: simulate(model, { delayYears: 5 }),
    wait10: simulate(model, { delayYears: 10 }),
  }), [model]);
  const habitSim = useMemo(() => simulate(model, { dailyCut: state.daily }), [model, state.daily]);
  const sweepCompare = useMemo(() => {
    const alt = simulate(model, { sweep: state.sweep < 50 ? 50 : 0 });
    return { base: sims.now.fireAge, alt: alt.fireAge };
  }, [model, sims]);

  const dollars = useCallback((nominal, deflator) => (state.real ? nominal / deflator : nominal), [state.real]);

  return (
    <div className="app-shell">
      <div className="bg-halo" />
      <SiteHeader
        t={t} lang={lang} setLang={setLang}
        theme={theme} onToggleTheme={toggleTheme}
        presenting={presenting} onTogglePresent={togglePresent}
      />

      <main className="page">
        <div className="hero-block">
          <span className="eyebrow">{t.eyebrow}</span>
          <h1 className="hero-title">{t.heroA}<em>{t.heroB}</em></h1>
          <p className="hero-sub">{t.heroSub}</p>
        </div>

        <StartPanel t={t} lang={lang} state={state} set={set} />
        <SplitPanel t={t} state={state} setBucket={setBucket} applyPreset={applyPreset} />

        <ResultsPanel t={t} lang={lang} state={state} sim={sims.now} dollars={dollars} />
        <LifetimeChart
          t={t} lang={lang} state={state} sim={sims.now} dollars={dollars}
          theme={theme} presenting={presenting} setReal={(v) => set("real", v)}
        />

        <div className="urgency-grid">
          <WaitPanel t={t} state={state} sims={sims} dollars={dollars} />
          <HabitPanel t={t} state={state} set={set} baseSim={sims.now} habitSim={habitSim} dollars={dollars} />
        </div>

        <LessonsPanel t={t} state={state} sims={sims} sweepCompare={sweepCompare} dollars={dollars} />
        <AssumptionsPanel t={t} state={state} set={set} />
        <ShareRow t={t} state={state} lang={lang} onReset={reset} />
      </main>

      <SiteFooter t={t} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
