/* global React, ItemIcon, fmtNeso, fmtUsd, fmtTimeShort, fmtMD, POTENTIAL_TIERS, POTENTIAL_TIER_INDEX, JOBS, TIER1_OPTIONS, CATEGORIES, apiGet, apiSend, shortAddr */
const { useState, useEffect, useRef, useMemo, useCallback } = React;

// --- design tokens (墨綠黑 + 金黃) -----------------------------------------
const V1 = {
  bg: '#0a0f0c',
  bgSoft: '#0e1411',
  panel: '#121814',
  panelHi: '#161e19',
  border: '#1f2a23',
  borderHi: '#2a3830',
  text: '#e8efe6',
  text2: '#8a9890',
  text3: '#5a6862',
  gold: '#d4a64a',
  goldDim: '#a17e2f',
  goldGlow: 'rgba(212,166,74,0.12)',
  green: '#6ba65b',
  greenDim: '#3d6b34',
  red: '#d96a4a',
  blurple: '#5865f2',
  font: '"IBM Plex Sans", -apple-system, system-ui, sans-serif',
  mono: '"IBM Plex Mono", "SF Mono", monospace',
};
const POT_COLOR = { None: V1.text3, Rare: '#4d96ff', Epic: '#a855f7', Unique: V1.gold, Legendary: '#4ade80' };

// ===========================================================================
// Atoms
// ===========================================================================
function Panel({ title, sub, right, children, style, pad = 16, accent }) {
  return (
    <div style={{
      background: V1.panel, border: `1px solid ${V1.border}`, borderRadius: 8,
      display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative',
      minHeight: 0, ...style,
    }}>
      {accent && <div style={{ position:'absolute', top:0, left:0, right:0, height:1, background:`linear-gradient(90deg, transparent, ${V1.gold}, transparent)`, opacity:0.5 }}/>}
      {(title || right) && (
        <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:`12px ${pad}px 10px`, borderBottom:`1px solid ${V1.border}`, flexShrink:0 }}>
          <div style={{ display:'flex', alignItems:'center', gap:8 }}>
            <div style={{ width:3, height:11, background:V1.gold, borderRadius:1 }}/>
            <span style={{ fontFamily:V1.mono, fontSize:11, fontWeight:600, letterSpacing:'0.12em', color:V1.text, textTransform:'uppercase' }}>{title}</span>
            {sub && <span style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, letterSpacing:'0.05em', marginLeft:2 }}>· {sub}</span>}
          </div>
          {right}
        </div>
      )}
      <div style={{ flex:1, minHeight:0, padding:pad, paddingTop:title?pad:pad, overflow:'hidden', display:'flex', flexDirection:'column' }}>{children}</div>
    </div>
  );
}

function Pill({ children, color = V1.text2, dot, style, title }) {
  return (
    <span title={title} style={{
      display:'inline-flex', alignItems:'center', gap:6,
      fontFamily:V1.mono, fontSize:11, color,
      padding:'4px 8px', borderRadius:4,
      background:'rgba(255,255,255,0.02)',
      border:`1px solid ${V1.border}`,
      letterSpacing:'0.04em',
      ...style,
    }}>
      {dot && <span style={{ width:6, height:6, borderRadius:'50%', background:color, boxShadow:`0 0 6px ${color}` }}/>}
      {children}
    </span>
  );
}

function IconButton({ children, onClick, style, title, disabled }) {
  return (
    <button onClick={onClick} title={title} disabled={disabled} style={{
      background:'transparent', border:`1px solid ${V1.border}`,
      color:V1.text2, padding:'6px 10px', borderRadius:5,
      fontFamily:V1.mono, fontSize:11, cursor:disabled?'not-allowed':'pointer', opacity:disabled?0.4:1,
      display:'inline-flex', alignItems:'center', gap:6,
      letterSpacing:'0.04em', transition:'all 0.15s',
      ...style,
    }}>
      {children}
    </button>
  );
}

// ===========================================================================
// NXPC Ticker — derived from NESO/USD (fixed 1 NXPC = 100,000 NESO ratio)
// ===========================================================================
function NxpcTicker({ status }) {
  const price = status?.nesoPriceUsd != null ? status.nesoPriceUsd * 100_000 : null;
  const [history, setHistory] = useState([]);
  useEffect(() => {
    if (price != null) {
      setHistory(h => {
        const next = [...h, price];
        return next.length > 24 ? next.slice(-24) : next;
      });
    }
  }, [price, status?.nesoPriceUpdatedAt]);
  const first = history[0];
  const change = first ? ((price - first) / first) * 100 : 0;
  const min = history.length ? Math.min(...history) : 0;
  const max = history.length ? Math.max(...history) : 1;
  return (
    <div style={{ display:'inline-flex', alignItems:'center', gap:10, padding:'4px 10px', borderRadius:6, background:V1.bgSoft, border:`1px solid ${V1.borderHi}` }}>
      <div style={{ display:'flex', flexDirection:'column', lineHeight:1.1 }}>
        <span style={{ fontFamily:V1.mono, fontSize:9, color:V1.text3, letterSpacing:'0.12em' }}>NXPC/USD</span>
        <span style={{ fontFamily:V1.mono, fontSize:14, color:V1.gold, fontWeight:600 }}>
          {price != null ? `$${price.toFixed(5)}` : '—'}
        </span>
      </div>
      {history.length > 2 && (
        <svg width="60" height="22" viewBox="0 0 60 22" style={{ display:'block' }}>
          <polyline
            points={history.map((p,i)=>`${i*(60/(history.length-1))},${22 - ((p-min)/(max-min||1)*20) - 1}`).join(' ')}
            fill="none" stroke={V1.gold} strokeWidth="1.2"
          />
        </svg>
      )}
      {history.length > 1 && (
        <span style={{ fontFamily:V1.mono, fontSize:11, color: change>=0?V1.green:V1.red, fontWeight:500 }}>
          {change>=0?'+':''}{change.toFixed(2)}%
        </span>
      )}
    </div>
  );
}

// ===========================================================================
// Top Bar
// ===========================================================================
function TopBar({ status, onAddRule, sseState, me, onOpenAdmin }) {
  const pollAt = status?.lastPollAt ? new Date(status.lastPollAt) : null;
  return (
    <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 20px', borderBottom:`1px solid ${V1.border}`, background:V1.bgSoft, flexShrink:0 }}>
      <div style={{ display:'flex', alignItems:'center', gap:14 }}>
        <div style={{ width:32, height:32, borderRadius:6, background:`linear-gradient(135deg, ${V1.gold}, ${V1.goldDim})`, display:'flex', alignItems:'center', justifyContent:'center', color:'#0a0f0c', fontFamily:V1.mono, fontWeight:700, fontSize:14, letterSpacing:'-0.02em' }}>M</div>
        <div style={{ display:'flex', flexDirection:'column', gap:2 }}>
          <div style={{ display:'flex', alignItems:'baseline', gap:8 }}>
            <span style={{ fontFamily:V1.font, fontSize:16, fontWeight:600, color:V1.text, letterSpacing:'-0.01em' }}>MSU 市集追蹤器</span>
          </div>
          <div style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, letterSpacing:'0.06em' }}>
            套利偵測 · 規則追蹤 · DISCORD 推播
          </div>
        </div>
      </div>
      <div style={{ display:'flex', alignItems:'center', gap:8 }}>
        <NxpcTicker status={status}/>
        <Pill color={sseState==='connected'?V1.green:sseState==='reconnecting'?V1.red:V1.text3} dot title={sseState}>
          {sseState==='connected'?'即時連線':sseState==='reconnecting'?'離線':'連線中'}
        </Pill>
        <Pill title={pollAt ? `上次輪詢: ${pollAt.toISOString()}` : '尚未輪詢'}>輪詢 · {pollAt ? fmtTimeShort(pollAt) : '--:--'}</Pill>
        {me && (
          <Pill title={`已連接:@${me.username}`}>
            <img src={me.avatarUrl} alt="" width={14} height={14} style={{ borderRadius:'50%', verticalAlign:'middle', marginRight:5 }}/>
            {me.globalName || me.username}
          </Pill>
        )}
        {me?.isAdmin && (
          <IconButton onClick={onOpenAdmin} title="管理員儀表板" style={{ borderColor:V1.gold, color:V1.gold }}>
            ⚙ 管理
          </IconButton>
        )}
        <IconButton onClick={onAddRule} disabled={!me} title={me ? '新增追蹤規則' : '請先連接 Discord'} style={{ background: me ? V1.gold : V1.border, color:'#0a0f0c', borderColor: me ? V1.gold : V1.border, fontWeight:600, opacity: me ? 1 : 0.5, cursor: me ? 'pointer' : 'not-allowed' }}>
          ＋ 新增規則
        </IconButton>
      </div>
    </div>
  );
}

// ===========================================================================
// Stat tile
// ===========================================================================
function StatTile({ label, value, sub, trend, accent, spark }) {
  const trendColor = trend > 0 ? V1.green : trend < 0 ? V1.red : V1.text3;
  return (
    <div style={{
      background:V1.panel, border:`1px solid ${accent ? V1.goldDim+'66' : V1.border}`,
      borderRadius:8, padding:'12px 14px',
      position:'relative', overflow:'hidden',
      display:'flex', flexDirection:'column', gap:4, minHeight:80, height:'100%', boxSizing:'border-box',
    }}>
      <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
        <span style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, letterSpacing:'0.12em', textTransform:'uppercase' }}>{label}</span>
        {trend !== undefined && (
          <span style={{ fontFamily:V1.mono, fontSize:10, color:trendColor }}>
            {trend > 0 ? '▲' : trend < 0 ? '▼' : '·'} {Math.abs(trend).toFixed(1)}%
          </span>
        )}
      </div>
      <div style={{ display:'flex', alignItems:'baseline', gap:6 }}>
        <span style={{ fontFamily:V1.mono, fontSize:22, fontWeight:500, color: accent ? V1.gold : V1.text, letterSpacing:'-0.02em' }}>{value}</span>
        {sub && <span style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3 }}>{sub}</span>}
      </div>
      {spark && spark.length > 1 && (
        <svg viewBox="0 0 100 20" style={{ width:'100%', height:18, marginTop:'auto' }} preserveAspectRatio="none">
          <polyline points={spark.map((v,i)=>{
            const min = Math.min(...spark), max = Math.max(...spark);
            const norm = max === min ? 0.5 : (v - min) / (max - min);
            return `${i*(100/(spark.length-1))},${20 - norm*18 - 1}`;
          }).join(' ')} fill="none" stroke={accent?V1.gold:V1.greenDim} strokeWidth="1" opacity="0.9"/>
        </svg>
      )}
    </div>
  );
}

// ===========================================================================
// Arbitrage card + panel
// ===========================================================================
function ArbitrageCard({ op, selected, onSelect }) {
  return (
    <div onClick={() => onSelect?.(op)} style={{
      background: selected ? V1.panelHi : V1.bgSoft,
      border:`1px solid ${selected ? V1.gold : V1.border}`,
      borderRadius:8, padding:14,
      display:'flex', flexDirection:'column', gap:10, position:'relative', overflow:'hidden',
      transition:'all 0.15s', cursor:'pointer',
      boxShadow: selected ? `0 0 0 1px ${V1.gold}, 0 4px 16px rgba(212,166,74,0.15)` : 'none',
    }}
    onMouseEnter={e=>{ if (!selected) { e.currentTarget.style.borderColor=V1.gold; e.currentTarget.style.transform='translateY(-1px)'; } }}
    onMouseLeave={e=>{ if (!selected) { e.currentTarget.style.borderColor=V1.border; e.currentTarget.style.transform='translateY(0)'; } }}>
      <div style={{ position:'absolute', top:0, right:0, width:80, height:80, background:`radial-gradient(circle at top right, ${V1.goldGlow}, transparent 70%)`, pointerEvents:'none' }}/>

      <div style={{ display:'flex', alignItems:'center', gap:10 }}>
        <ItemIcon src={op.imageUrl} name={op.name} size={42}/>
        <div style={{ flex:1, minWidth:0 }}>
          <div style={{ fontFamily:V1.font, fontSize:13, fontWeight:600, color:V1.text, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{op.name}</div>
          <div style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, marginTop:2, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
            {(op.category?.full || '').replace(/^Item > /, '')}
          </div>
        </div>
        <div style={{ textAlign:'right' }}>
          <div style={{ fontFamily:V1.mono, fontSize:20, fontWeight:600, color:V1.gold, lineHeight:1 }}>−{op.discountPct?.toFixed(0)}%</div>
          <div style={{ fontFamily:V1.mono, fontSize:9, color:V1.text3, letterSpacing:'0.06em', marginTop:3 }}>低於參考價</div>
        </div>
      </div>

      {(() => {
        // The discount % was computed against `priceRefMedianNeso` (whichever
        // of local-sig vs maplen-grade had enough samples to win). If we
        // display `avg7dNeso` here instead, the user sees one number quoted
        // beside a discount calculated from a DIFFERENT number — exactly
        // the "29M median, 40M list, -26%" confusion that surfaced.
        const refMedian = op.priceRefMedianNeso ?? op.avg7dNeso;
        const isLocal = op.priceRefSource === 'local-sig' || op.priceRefSource === 'local-sig-loose';
        const isLoose = op.priceRefSource === 'local-sig-loose';
        const samples = isLocal ? op.localSigSamples : op.avgSampleCount;
        const sourceLabel = isLocal
          ? (isLoose ? '本地放寬' : '本地同特徵')
          : (op.priceRefSource === 'maplen-grade' ? 'maplen 同分桶' : '參考');
        const save = refMedian != null ? (refMedian - op.priceNeso) : null;
        return (
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, fontFamily:V1.mono }}>
            <div>
              <div style={{ fontSize:9, color:V1.text3, letterSpacing:'0.1em', marginBottom:2 }}>上架價</div>
              <div style={{ fontSize:14, color:V1.gold, fontWeight:600 }}>{fmtNeso(op.priceNeso)} <span style={{ fontSize:10, color:V1.text3 }}>NESO</span></div>
              <div style={{ fontSize:10, color:V1.text3, marginTop:1 }}>{fmtUsd(op.priceUsd)}</div>
            </div>
            <div>
              <div style={{ fontSize:9, color:V1.text3, letterSpacing:'0.1em', marginBottom:2 }}>
                {sourceLabel}中位數
                {isLoose && op.localSigRelaxLabel && (
                  <span title="樣本不足,已放寬比對範圍 — 此參考價可信度較低" style={{ color:V1.gold, marginLeft:4 }}>· {op.localSigRelaxLabel}</span>
                )}
              </div>
              <div style={{ fontSize:14, color:V1.text2, fontWeight:500, textDecoration:'line-through', textDecorationColor:V1.text3 }}>{fmtNeso(refMedian)}</div>
              <div style={{ fontSize:10, color: (save ?? 0) > 0 ? V1.green : V1.text3, marginTop:1 }}>
                {(save ?? 0) > 0 ? `省下 ≈ ${fmtNeso(save)}` : '—'} · 樣本 {samples ?? '?'}
              </div>
            </div>
          </div>
        );
      })()}

      <div style={{ display:'flex', flexWrap:'wrap', gap:6, fontFamily:V1.mono, fontSize:10 }}>
        {op.starforce > 0 && <span style={{ color:V1.gold, background:`${V1.gold}1a`, padding:'2px 6px', borderRadius:3 }}>{op.starforce}★</span>}
        <span style={{ color: POT_COLOR[op.potentialGradeLabel] ?? V1.text3, border:`1px solid ${V1.border}`, padding:'2px 6px', borderRadius:3 }}>{op.potentialGradeLabel}</span>
        {op.bonusPotentialGradeLabel && op.bonusPotentialGradeLabel !== 'None' && <span style={{ color: POT_COLOR[op.bonusPotentialGradeLabel] ?? V1.text3, border:`1px solid ${V1.border}`, padding:'2px 6px', borderRadius:3 }}>+{op.bonusPotentialGradeLabel}</span>}
        {op.job && <span style={{ color:V1.text3, border:`1px solid ${V1.border}`, padding:'2px 6px', borderRadius:3 }}>{op.job} · Lv{op.requiredLevel}</span>}
      </div>

      {/* Latest comparable trade — from maplen.gg same-bucket history.
          When `url` is present, the whole row links out to the source bucket query. */}
      {op.latestComparableTrade && op.latestComparableTrade.price_neso != null && (() => {
        const trade = op.latestComparableTrade;
        const hasUrl = !!trade.url;
        const rowStyle = {
          display:'flex', alignItems:'center', gap:6, fontFamily:V1.mono, fontSize:9.5,
          color:V1.text3, padding:'4px 6px',
          background:'rgba(255,255,255,0.02)',
          border:`1px dashed ${V1.border}`, borderRadius:4,
          textDecoration:'none', cursor: hasUrl ? 'pointer' : 'default',
          transition:'border-color 0.12s, background 0.12s',
        };
        const body = (
          <>
            <span style={{ color:V1.text3, letterSpacing:'0.06em' }}>近期成交</span>
            <span style={{ color:V1.gold, fontWeight:600 }}>{fmtNeso(trade.price_neso)}</span>
            {trade.starforce > 0 && <span style={{ color:V1.gold }}>★{trade.starforce}</span>}
            <span style={{ color: POT_COLOR[trade.potential] ?? V1.text3 }}>{trade.potential}</span>
            <span style={{ color:V1.text3 }}>/</span>
            <span style={{ color: POT_COLOR[trade.bonus_potential] ?? V1.text3 }}>{trade.bonus_potential}</span>
            <span style={{ flex:1 }}/>
            <span style={{ color:V1.text3 }}>{trade.traded_at ? fmtMD(trade.traded_at) : trade.timestamp_str}</span>
            {hasUrl && <span style={{ color:V1.gold, fontSize:10, marginLeft:2 }} title="在 maplen.gg 開啟同分桶歷史">↗</span>}
          </>
        );
        return hasUrl ? (
          <a
            href={trade.url}
            target="_blank"
            rel="noopener noreferrer"
            onClick={e => e.stopPropagation()}
            title="點擊在 maplen.gg 查看同分桶歷史成交"
            style={rowStyle}
            onMouseEnter={e => { e.currentTarget.style.borderColor = V1.goldDim; e.currentTarget.style.background = V1.goldGlow; }}
            onMouseLeave={e => { e.currentTarget.style.borderColor = V1.border; e.currentTarget.style.background = 'rgba(255,255,255,0.02)'; }}
          >
            {body}
          </a>
        ) : (
          <div style={rowStyle}>{body}</div>
        );
      })()}

      <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', borderTop:`1px solid ${V1.border}`, paddingTop:8, marginTop:'auto' }}>
        <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>
          {op.sellerNickname || shortAddr(op.sellerWalletAddr)} · {fmtTimeShort(op.createdAt)}
        </div>
        <span style={{
          color:V1.gold, border:`1px solid ${V1.goldDim}66`,
          padding:'4px 10px', borderRadius:4, fontFamily:V1.mono, fontSize:10, fontWeight:600,
          letterSpacing:'0.05em',
        }}>查看 →</span>
      </div>
    </div>
  );
}

// Renders in the span-8 slot where ArbitragePanel normally sits, but shows
// rule-hit cards. Used when the admin has set can_use_arbitrage=false on
// the current viewer.
function RuleHitsExpanded({ hits, selectedId, onSelect }) {
  return (
    <Panel
      title="追蹤規則命中"
      sub="符合你規則的新上架物品"
      right={<Pill color={V1.gold} dot>{hits.length} 筆</Pill>}
      accent
      style={{ gridColumn:'span 8', gridRow:'span 1' }}
      pad={14}
    >
      {hits.length === 0 ? (
        <div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center', color:V1.text3, fontFamily:V1.mono, fontSize:12, textAlign:'center', lineHeight:1.6 }}>
          <div>
            尚無命中 · 等待規則觸發…<br/>
            <span style={{ color:V1.text3, opacity:0.6, fontSize:11 }}>(套利通知已被管理員關閉)</span>
          </div>
        </div>
      ) : (
        <div style={{ flex:1, minHeight:0, overflowY:'auto', paddingRight:4 }}>
          {hits.map((h, i) => {
            const isSelected = selectedId && String(selectedId) === String(h.token_id);
            return (
              <div key={h.id} onClick={() => h.token_id && onSelect?.(String(h.token_id))} style={{
                padding:'12px 12px', borderBottom:`1px solid ${V1.border}`,
                display:'flex', alignItems:'center', gap:14, position:'relative',
                animation: i === 0 ? 'msuRowIn 0.4s ease-out' : 'none',
                cursor: h.token_id ? 'pointer' : 'default',
                background: isSelected ? V1.panelHi : 'transparent',
              }}
              onMouseEnter={h.token_id ? rowHoverIn : undefined}
              onMouseLeave={h.token_id ? (e) => rowHoverOut(e, isSelected) : undefined}>
                <div style={{ position:'absolute', left:0, top:0, bottom:0, width:2, background:V1.gold }}/>
                <div style={{ flex:1, minWidth:0 }}>
                  <div style={{ fontFamily:V1.font, fontSize:13, color:V1.text, fontWeight:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{h.name}</div>
                  <div style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, marginTop:3 }}>
                    {h.rule_name} · {fmtNeso(h.price_neso)} NESO · ★{h.starforce ?? 0} · {h.pot_label}/{h.bonus_label}
                  </div>
                </div>
                <div style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3 }}>{fmtTimeShort(h.hit_at)}</div>
                {h.pushed_discord ? <span title="已推送至 Discord" style={{ color:V1.green, fontSize:11 }}>✓</span> : null}
              </div>
            );
          })}
        </div>
      )}
    </Panel>
  );
}

function ArbitragePanel({ ops, minDiscount, setMinDiscount, selectedId, onSelect }) {
  return (
    <Panel
      title="套利機會"
      sub={`自動偵測 · 上架價 ≥ ${minDiscount}% 低於同分桶中位數 · 中位數 ≥ 1000 萬 NESO`}
      right={
        <div style={{ display:'flex', alignItems:'center', gap:8 }}>
          <span style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, letterSpacing:'0.08em' }}>門檻</span>
          {[20, 30, 40].map(v => (
            <button key={v} onClick={()=>setMinDiscount(v)} style={{
              background: minDiscount===v ? V1.gold : 'transparent',
              color: minDiscount===v ? V1.bg : V1.text2,
              border:`1px solid ${minDiscount===v ? V1.gold : V1.border}`,
              padding:'3px 8px', borderRadius:4, cursor:'pointer',
              fontFamily:V1.mono, fontSize:10, fontWeight:600, letterSpacing:'0.04em',
            }}>−{v}%</button>
          ))}
          <Pill color={V1.gold} dot>{ops.length} 筆</Pill>
        </div>
      }
      accent
      style={{ gridColumn:'span 8', gridRow:'span 1' }}
      pad={14}
    >
      {ops.length === 0 ? (
        <div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center', color:V1.text3, fontFamily:V1.mono, fontSize:12, textAlign:'center', lineHeight:1.6 }}>
          <div>
            目前沒有符合此門檻的物品<br/>
            <span style={{ color:V1.text3, opacity:0.6, fontSize:11 }}>放寬門檻或等待下一輪輪詢…</span>
          </div>
        </div>
      ) : (
        <div style={{ flex:1, minHeight:0, overflowY:'auto', display:'grid', gridTemplateColumns:'repeat(2, 1fr)', gap:10, paddingRight:4, alignContent:'start' }}>
          {ops.map(op => <ArbitrageCard key={op.tokenId} op={op} selected={selectedId === op.tokenId} onSelect={onSelect}/>)}
        </div>
      )}
    </Panel>
  );
}

// ===========================================================================
// Watchlist
// ===========================================================================
function WatchlistPanel({ rules, onToggle, onAdd, onDelete }) {
  return (
    <Panel
      title="追蹤規則"
      sub={`${rules.filter(r=>r.active).length}/${rules.length} 啟用中`}
      right={<IconButton onClick={onAdd}>＋ 新增</IconButton>}
      style={{ gridColumn:'span 4', gridRow:'span 1' }}
      pad={0}
    >
      <div style={{ flex:1, overflowY:'auto' }}>
        {rules.length === 0 && (
          <div style={{ padding:'24px 14px', textAlign:'center', color:V1.text3, fontFamily:V1.mono, fontSize:11 }}>
            尚無規則 · 點選 <span style={{ color:V1.gold }}>＋ 新增</span> 來建立
          </div>
        )}
        {rules.map(r => (
          <div key={r.id} style={{
            padding:'11px 14px', borderBottom:`1px solid ${V1.border}`,
            display:'flex', alignItems:'flex-start', gap:10,
            opacity: r.active ? 1 : 0.45,
          }}>
            <button onClick={()=>onToggle(r)} style={{
              width:28, height:16, borderRadius:8,
              background: r.active ? V1.gold : V1.border,
              border:'none', cursor:'pointer', padding:0,
              position:'relative', flexShrink:0, marginTop:2,
            }} title={r.active ? '停用此規則' : '啟用此規則'}>
              <div style={{ width:12, height:12, borderRadius:'50%', background:'#0a0f0c', position:'absolute', top:2, left: r.active ? 14 : 2, transition:'left 0.15s' }}/>
            </button>
            <div style={{ flex:1, minWidth:0 }}>
              <div style={{ display:'flex', alignItems:'center', gap:8 }}>
                <span style={{ fontFamily:V1.font, fontSize:12.5, fontWeight:500, color:V1.text, flex:1, minWidth:0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{r.name}</span>
                {r.matchesCount > 0 && <span style={{ fontFamily:V1.mono, fontSize:10, color:V1.gold, background:V1.goldGlow, padding:'1px 5px', borderRadius:3, flexShrink:0 }}>{r.matchesCount} 次命中</span>}
                <button onClick={()=>onDelete(r)} title="刪除規則" style={{ background:'transparent', border:'none', color:V1.text3, fontSize:14, cursor:'pointer', padding:'0 4px', lineHeight:1 }}>×</button>
              </div>
              <div style={{ display:'flex', gap:'2px 6px', marginTop:3, flexWrap:'wrap' }}>
                {r.itemName && <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text2 }}>「{r.itemName}」</span>}
                {r.tier1 && <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>· {r.tier1}{r.tier3?'/'+r.tier3:''}</span>}
                {r.job && r.job !== 'All' && <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>· {r.job}</span>}
                {(r.starMin != null || r.starMax != null) && <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.gold }}>· {r.starMin ?? 0}–{r.starMax ?? 25}★</span>}
                {r.priceMax != null && <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>· ≤ {fmtNeso(r.priceMax)}</span>}
              </div>
              {r.lastHitAt && <div style={{ fontFamily:V1.mono, fontSize:9, color:V1.text3, marginTop:3 }}>上次命中 · {fmtTimeShort(r.lastHitAt)}</div>}
            </div>
          </div>
        ))}
      </div>
    </Panel>
  );
}

// ===========================================================================
// Market Events Feed — tabbed: Rule hits | Arbitrage sold/removed
// ===========================================================================
function MarketEventsFeed({ hits, purchases, selectedId, onSelect }) {
  const [tab, setTab] = useState('rules');
  const rowHoverIn = (e) => { e.currentTarget.style.background = V1.panelHi; };
  const rowHoverOut = (e, isSelected) => { e.currentTarget.style.background = isSelected ? V1.panelHi : 'transparent'; };
  const tabBtn = (id, label, count, color) => (
    <button
      key={id}
      onClick={() => setTab(id)}
      style={{
        background: tab === id ? `${color}1a` : 'transparent',
        color: tab === id ? color : V1.text3,
        border: 'none',
        padding: '4px 10px', borderRadius: 4, cursor: 'pointer',
        fontFamily: V1.mono, fontSize: 10, fontWeight: 600,
        letterSpacing: '0.06em', textTransform: 'uppercase',
        borderBottom: tab === id ? `2px solid ${color}` : '2px solid transparent',
        transition: 'all 0.12s',
      }}>
      {label} <span style={{ opacity: 0.6, marginLeft: 4 }}>{count}</span>
    </button>
  );
  return (
    <Panel
      title="市場動態"
      sub={tab === 'rules' ? '規則命中' : '套利成交'}
      right={
        <div style={{ display:'flex', gap:2 }}>
          {tabBtn('rules', '規則', hits.length, V1.gold)}
          {tabBtn('sold',  '套利成交', purchases.length, V1.green)}
        </div>
      }
      style={{ gridColumn:'span 3', gridRow:'span 1' }}
      pad={0}
    >
      <div style={{ flex:1, overflowY:'auto' }}>
        {tab === 'rules' ? (
          hits.length === 0 ? (
            <div style={{ padding:'24px 14px', textAlign:'center', color:V1.text3, fontFamily:V1.mono, fontSize:11 }}>
              尚無命中 · 等待規則觸發…
            </div>
          ) : hits.map((h, i) => {
            const isSelected = selectedId && String(selectedId) === String(h.token_id);
            return (
            <div key={h.id} onClick={() => h.token_id && onSelect?.(String(h.token_id))} style={{
              padding:'10px 14px', borderBottom:`1px solid ${V1.border}`,
              display:'flex', alignItems:'center', gap:10, position:'relative',
              animation: i === 0 ? 'msuRowIn 0.4s ease-out' : 'none',
              cursor: h.token_id ? 'pointer' : 'default',
              background: isSelected ? V1.panelHi : 'transparent',
              transition: 'background 0.1s',
            }}
            onMouseEnter={h.token_id ? rowHoverIn : undefined}
            onMouseLeave={h.token_id ? (e) => rowHoverOut(e, isSelected) : undefined}>
              <div style={{ position:'absolute', left:0, top:0, bottom:0, width:2, background:V1.gold }}/>
              <ItemIcon name={h.name} size={30}/>
              <div style={{ flex:1, minWidth:0 }}>
                <div style={{ display:'flex', alignItems:'baseline', gap:6 }}>
                  <span style={{ fontFamily:V1.mono, fontSize:9, color:V1.gold, fontWeight:600, letterSpacing:'0.06em', background:V1.goldGlow, padding:'1px 5px', borderRadius:3 }}>規則</span>
                  <span style={{ fontFamily:V1.font, fontSize:12, color:V1.text, fontWeight:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', minWidth:0, flex:1 }}>{h.rule_name || '(未知規則)'}</span>
                  {h.pushed_discord ? <span title="已推播至 Discord" style={{ fontSize:10, color:V1.blurple }}>●</span> : null}
                </div>
                <div style={{ fontFamily:V1.mono, fontSize:10, color:V1.text2, marginTop:2, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
                  {h.name} · {h.starforce > 0 ? `${h.starforce}★ · ` : ''}{h.pot_label}/{h.bonus_label}
                </div>
              </div>
              <div style={{ textAlign:'right' }}>
                <div style={{ fontFamily:V1.mono, fontSize:12, color:V1.gold, fontWeight:600 }}>{fmtNeso(h.price_neso)}</div>
                <div style={{ fontFamily:V1.mono, fontSize:9, color:V1.text3 }}>{fmtTimeShort(h.hit_at)}</div>
              </div>
            </div>
            );
          })
        ) : (
          purchases.length === 0 ? (
            <div style={{ padding:'24px 14px', textAlign:'center', color:V1.text3, fontFamily:V1.mono, fontSize:11, lineHeight:1.6 }}>
              尚無套利成交紀錄<br/>
              <span style={{ opacity:0.7, fontSize:10 }}>追蹤的套利物品被買走 / 下架時會出現在這</span>
            </div>
          ) : purchases.map((s, i) => {
            const savedNeso = (s.reference_median_neso || 0) - (s.listed_price_neso || 0);
            const isSold = s.asset_status && s.asset_status !== 'NotFound';
            const isSelected = selectedId && String(selectedId) === String(s.token_id);
            return (
              <div key={s.id} onClick={() => s.token_id && onSelect?.(String(s.token_id))} style={{
                padding:'10px 14px', borderBottom:`1px solid ${V1.border}`,
                display:'flex', alignItems:'center', gap:10, position:'relative',
                animation: i === 0 ? 'msuRowIn 0.4s ease-out' : 'none',
                cursor: s.token_id ? 'pointer' : 'default',
                background: isSelected ? V1.panelHi : 'transparent',
                transition: 'background 0.1s',
              }}
              onMouseEnter={s.token_id ? rowHoverIn : undefined}
              onMouseLeave={s.token_id ? (e) => rowHoverOut(e, isSelected) : undefined}>
                <div style={{ position:'absolute', left:0, top:0, bottom:0, width:2, background: V1.green }}/>
                <ItemIcon src={s.image_url} name={s.name} size={30}/>
                <div style={{ flex:1, minWidth:0 }}>
                  <div style={{ display:'flex', alignItems:'baseline', gap:6 }}>
                    <span style={{ fontFamily:V1.mono, fontSize:9, color:V1.green, fontWeight:600, letterSpacing:'0.06em', background:'rgba(107,166,91,0.15)', padding:'1px 5px', borderRadius:3 }}>{isSold ? '已售' : '下架'}</span>
                    <span style={{ fontFamily:V1.font, fontSize:12, color:V1.text, fontWeight:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', minWidth:0, flex:1 }}>{s.name}</span>
                  </div>
                  <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text2, marginTop:2, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
                    {s.signature_label || ''}
                  </div>
                </div>
                <div style={{ textAlign:'right' }}>
                  <div style={{ fontFamily:V1.mono, fontSize:12, color:V1.gold, fontWeight:600 }}>{fmtNeso(s.listed_price_neso)}</div>
                  <div style={{ fontFamily:V1.mono, fontSize:9, color:V1.green }}>
                    {s.discount_pct != null ? `−${s.discount_pct.toFixed(0)}%` : ''}{savedNeso > 0 ? ` · 省${fmtNeso(savedNeso)}` : ''}
                  </div>
                  <div style={{ fontFamily:V1.mono, fontSize:9, color:V1.text3 }}>{fmtTimeShort(s.removed_at)}</div>
                </div>
              </div>
            );
          })
        )}
      </div>
    </Panel>
  );
}

// ===========================================================================
// Live Feed mini
// ===========================================================================
function LiveFeedMini({ listings, paused, setPaused, selectedId, onSelect }) {
  return (
    <Panel
      title="即時動態"
      sub="武器 / 防具"
      right={
        <button onClick={()=>setPaused(p=>!p)} style={{
          background:'transparent', color:paused?V1.red:V1.text3,
          border:`1px solid ${V1.border}`, padding:'3px 7px', borderRadius:4,
          fontFamily:V1.mono, fontSize:10, cursor:'pointer', letterSpacing:'0.04em',
        }}>{paused ? '▶ 繼續' : '⏸ 暫停'}</button>
      }
      style={{ gridColumn:'span 2', gridRow:'span 1' }}
      pad={0}
    >
      <div style={{ flex:1, overflowY:'auto', fontFamily:V1.mono, fontSize:10.5 }}>
        {listings.length === 0 && (
          <div style={{ padding:'24px 14px', textAlign:'center', color:V1.text3, fontSize:11 }}>
            等待新上架…<br/>(啟動約 15 秒)
          </div>
        )}
        {listings.slice(0, 30).map((it, idx) => {
          const isSelected = selectedId === it.tokenId;
          return (
            <div key={it.tokenId} onClick={()=>onSelect?.(it)} style={{
              padding:'7px 10px', borderBottom:`1px solid ${V1.border}`,
              display:'flex', flexDirection:'column', gap:1,
              cursor:'pointer',
              background: isSelected ? V1.panelHi : (idx === 0 ? V1.goldGlow : 'transparent'),
              borderLeft: isSelected ? `2px solid ${V1.gold}` : '2px solid transparent',
              animation: idx === 0 ? 'msuRowIn 0.4s ease-out' : 'none',
              color: V1.text3,
              transition:'background 0.1s',
            }}
            onMouseEnter={e=>{ if (!isSelected) e.currentTarget.style.background = V1.panelHi; }}
            onMouseLeave={e=>{ if (!isSelected) e.currentTarget.style.background = idx === 0 ? V1.goldGlow : 'transparent'; }}>
              <div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', gap:6, minWidth:0 }}>
                <span style={{ color:V1.text, fontSize:11, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', flex:1, minWidth:0 }}>
                  {it.starforce > 0 && <span style={{ color:V1.gold, marginRight:3 }}>{it.starforce}★</span>}
                  {it.isArbitrage && <span style={{ color:V1.gold, marginRight:3 }} title={`比均價低 ${it.discountPct?.toFixed(0)}%`}>⚡</span>}
                  {it.name}
                </span>
              </div>
              <div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', gap:6, fontSize:9.5 }}>
                <span style={{ color:V1.text3 }}>{fmtTimeShort(it.createdAt)}</span>
                <span style={{ color:V1.text2 }}>{fmtNeso(it.priceNeso)}</span>
              </div>
            </div>
          );
        })}
      </div>
    </Panel>
  );
}

// ===========================================================================
// Potential / Bonus Potential frame — colored box with a notched header label
// that "punches through" the border on the top edge, using the panel's bg as
// a mask. The label reads e.g. "潛在能力 · LEGENDARY" in the tier color.
// ===========================================================================
function PotentialFrame({ title, grade, lines, emptyText }) {
  const gradeColor = POT_COLOR[grade] || V1.text3;
  const hasGrade = grade && grade !== 'None';
  const ts = {
    bd: hasGrade ? gradeColor + '80' : V1.border,
    bg: hasGrade ? gradeColor + '14' : 'rgba(255,255,255,0.02)',
    c:  gradeColor,
  };
  return (
    <div style={{
      border: `1px solid ${ts.bd}`,
      borderRadius: 6,
      background: ts.bg,
      position: 'relative',
      padding: '12px 10px 9px',
    }}>
      {/* Notched label — protrudes above the border, panel-colored bg masks the border behind it */}
      <div style={{
        position: 'absolute', top: -7, left: 8,
        background: V1.panel, padding: '0 6px',
        display: 'flex', alignItems: 'center', gap: 6,
      }}>
        <span style={{ fontFamily:V1.mono, fontSize:8.5, color:V1.text3, letterSpacing:'0.14em' }}>{title}</span>
        <span style={{ fontFamily:V1.mono, fontSize:9.5, color: ts.c, fontWeight:700, letterSpacing:'0.08em', textTransform:'uppercase' }}>{grade || 'None'}</span>
      </div>
      <div style={{ display:'flex', flexDirection:'column', gap:3, marginTop:2 }}>
        {lines.length === 0 ? (
          <div style={{ fontFamily:V1.mono, fontSize:10.5, color:V1.text3, paddingLeft:10 }}>{emptyText}</div>
        ) : lines.map((line, i) => {
          const lineGrade = POTENTIAL_TIERS[line.grade ?? 0];
          const lineColor = POT_COLOR[lineGrade] || V1.text3;
          return (
            <div key={i} style={{
              fontFamily: V1.mono, fontSize: 10.5,
              color: hasGrade ? V1.text : V1.text3,
              paddingLeft: 10, position: 'relative',
              display: 'flex', alignItems: 'baseline', gap: 6,
            }}>
              <span style={{ position:'absolute', left:0, color: lineColor, fontWeight: 700 }}>›</span>
              <span style={{ flex: 1 }}>{line.label}</span>
              <span style={{ fontSize: 8.5, color: lineColor, letterSpacing: '0.04em', opacity: 0.8 }}>{lineGrade}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ===========================================================================
// Item Detail Panel — clicking a listing or arbitrage card populates this
// ===========================================================================
function ItemDetailPanel({ item, onClose }) {
  if (!item) {
    return (
      <Panel title="物品詳情" sub="點選任一物品" style={{ gridColumn:'span 4', gridRow:'span 1' }}>
        <div style={{ flex:1, display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column', gap:8, color:V1.text3, fontFamily:V1.mono, fontSize:11, textAlign:'center' }}>
          <svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" opacity="0.4">
            <rect x="3" y="3" width="18" height="18" rx="2"/>
            <path d="M9 9h6M9 13h6M9 17h4"/>
          </svg>
          <div style={{ lineHeight:1.7 }}>
            從 <span style={{ color:V1.gold }}>套利機會</span> 或 <span style={{ color:V1.text2 }}>即時動態</span><br/>
            點選任一物品查看詳細資訊。
          </div>
        </div>
      </Panel>
    );
  }

  const url = item.detailUrl || `https://msu.io/marketplace/nft/${item.tokenId}`;
  const expected = item.priceRefMedianNeso ?? item.avg7dNeso;
  const discount = item.discountPct;
  const refSource = item.priceRefSource;  // 'local-sig' | 'maplen-grade' | null
  const potLines = item.potentialLines || [];
  const bonusLines = item.bonusPotentialLines || [];
  const potColor = POT_COLOR[item.potentialGradeLabel] ?? V1.text3;
  const bonusColor = POT_COLOR[item.bonusPotentialGradeLabel] ?? V1.text3;

  return (
    <Panel
      title="物品詳情"
      sub={`#${String(item.tokenId).slice(0, 12)}…`}
      right={<button onClick={onClose} title="關閉" style={{ background:'transparent', border:`1px solid ${V1.border}`, color:V1.text3, width:24, height:24, borderRadius:4, cursor:'pointer', fontSize:12, lineHeight:1, padding:0 }}>×</button>}
      style={{ gridColumn:'span 4', gridRow:'span 1' }}
      pad={0}
    >
      <div style={{ flex:1, overflowY:'auto' }}>
        <div style={{ padding:'12px 14px', borderBottom:`1px solid ${V1.border}`, display:'flex', gap:10, alignItems:'flex-start' }}>
          <ItemIcon src={item.imageUrl} name={item.name} size={48}/>
          <div style={{ flex:1, minWidth:0 }}>
            <div style={{ fontFamily:V1.font, fontSize:13.5, fontWeight:600, color:V1.text, lineHeight:1.3 }}>{item.name}</div>
            <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3, marginTop:3, letterSpacing:'0.04em' }}>{(item.category?.full || '').replace(/^Item > /,'')}</div>
            <div style={{ display:'flex', gap:5, marginTop:6, flexWrap:'wrap' }}>
              <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.gold, background:`${V1.gold}1a`, padding:'1px 6px', borderRadius:3 }}>{item.starforce > 0 ? `${item.starforce}★` : '0★'}{item.maxStarforce ? `/${item.maxStarforce}` : ''}</span>
              {item.job && <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text2, border:`1px solid ${V1.border}`, padding:'1px 6px', borderRadius:3 }}>{item.job}</span>}
              {item.requiredLevel != null && <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text2, border:`1px solid ${V1.border}`, padding:'1px 6px', borderRadius:3 }}>Lv {item.requiredLevel}</span>}
              {item.isArbitrage && <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.gold, background:V1.goldGlow, padding:'1px 6px', borderRadius:3 }}>⚡ 套利</span>}
            </div>
          </div>
        </div>

        <div style={{ padding:'12px 14px', borderBottom:`1px solid ${V1.border}` }}>
          <div style={{ fontFamily:V1.mono, fontSize:9, color:V1.text3, letterSpacing:'0.12em', marginBottom:4 }}>價格</div>
          <div style={{ display:'flex', alignItems:'baseline', justifyContent:'space-between', gap:8 }}>
            <div>
              <div style={{ fontFamily:V1.mono, fontSize:20, color:V1.gold, fontWeight:600, lineHeight:1 }}>{fmtNeso(item.priceNeso)}<span style={{ fontSize:11, color:V1.text3, marginLeft:5 }}>NESO</span></div>
              <div style={{ fontFamily:V1.mono, fontSize:10.5, color:V1.text3, marginTop:3 }}>≈ {fmtUsd(item.priceUsd)} USD</div>
            </div>
            {expected != null && discount != null && (
              <div style={{ textAlign:'right' }}>
                <div style={{
                  fontFamily:V1.mono, fontSize:16, fontWeight:600, lineHeight:1,
                  color: discount > 0 ? (discount >= 20 ? V1.gold : V1.green) : V1.red,
                }}>
                  {discount > 0 ? '−' : '+'}{Math.abs(discount).toFixed(0)}%
                </div>
                <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3, marginTop:3 }}>vs {fmtNeso(expected)} 中位數</div>
                {refSource && (
                  <div style={{ fontFamily:V1.mono, fontSize:8.5, color: refSource === 'local-sig' ? V1.gold : V1.text3, marginTop:1, letterSpacing:'0.04em' }}>
                    {refSource === 'local-sig' ? '✓ 同訊號 (本地)' : 'maplen 同分桶'}
                  </div>
                )}
              </div>
            )}
          </div>
          {item.avgBucket && (
            <div style={{ display:'flex', gap:6, marginTop:8, flexWrap:'wrap', alignItems:'center' }}>
              <span style={{ fontFamily:V1.mono, fontSize:9, color:V1.text3, letterSpacing:'0.08em' }}>分桶:</span>
              <span style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.gold, background:`${V1.gold}1a`, padding:'1px 6px', borderRadius:3 }}>{item.avgBucket.star}★</span>
              <span style={{ fontFamily:V1.mono, fontSize:9.5, color: POT_COLOR[item.avgBucket.pot] ?? V1.text3, border:`1px solid ${V1.border}`, padding:'1px 6px', borderRadius:3 }}>{item.avgBucket.pot}</span>
              <span style={{ fontFamily:V1.mono, fontSize:9.5, color: POT_COLOR[item.avgBucket.bonus] ?? V1.text3, border:`1px solid ${V1.border}`, padding:'1px 6px', borderRadius:3 }}>+{item.avgBucket.bonus}</span>
              <span style={{ flex:1 }}/>
              {item.avgMeanNeso != null && <span style={{ fontFamily:V1.mono, fontSize:9, color:V1.text3 }}>均值 {fmtNeso(item.avgMeanNeso)}</span>}
            </div>
          )}
          {item.signatureLabel && (
            <div style={{ marginTop:4, fontFamily:V1.mono, fontSize:9, color:V1.gold, letterSpacing:'0.04em', lineHeight:1.4 }}>
              訊號: {item.signatureLabel}
            </div>
          )}
          {item.localSigSamples > 0 && (
            <div style={{ marginTop:2, fontFamily:V1.mono, fontSize:9, color: refSource === 'local-sig' ? V1.gold : V1.text3 }}>
              同訊號樣本 {item.localSigSamples}
              {item.localSigMedianNeso != null && ` · 中位數 ${fmtNeso(item.localSigMedianNeso)}`}
              {item.localSigMinNeso != null && item.localSigMaxNeso != null && ` · 範圍 ${fmtNeso(item.localSigMinNeso)}–${fmtNeso(item.localSigMaxNeso)}`}
              {item.localSigSpreadRatio != null && ` (${item.localSigSpreadRatio.toFixed(1)}×)`}
            </div>
          )}
          {item.avgSignatureLabel && item.avgSignatureLabel !== '(no signature)' && (
            <div style={{ marginTop:4, fontFamily:V1.mono, fontSize:9, color:V1.text3, letterSpacing:'0.04em' }}>
              maplen filter: {item.avgSignatureLabel}
            </div>
          )}
          {(item.avgSampleCount != null || item.avgOldestAt) && (
            <div style={{ display:'flex', gap:8, marginTop:4, fontFamily:V1.mono, fontSize:9, color:V1.text3, flexWrap:'wrap' }}>
              <span>樣本 {item.avgSampleCount ?? 0}{item.avgRawCount != null && item.avgRawCount !== item.avgSampleCount ? ` / ${item.avgRawCount} (已去除 ${item.avgDroppedOutliers || 0} 筆離群)` : ''}</span>
              {item.avgOldestAt && <span>· 時間窗 {fmtMD(item.avgOldestAt)} ~ {fmtMD(item.avgNewestAt)}</span>}
            </div>
          )}
          {item.avgMinKept != null && item.avgMaxKept != null && (
            <div style={{ display:'flex', gap:8, marginTop:4, fontFamily:V1.mono, fontSize:9, color:V1.text3, flexWrap:'wrap' }}>
              <span>價差 {fmtNeso(item.avgMinKept)} – {fmtNeso(item.avgMaxKept)}{item.avgSpreadRatio != null ? ` (${item.avgSpreadRatio.toFixed(1)}×)` : ''}</span>
            </div>
          )}
          {item.avgHeterogeneous && (
            <div style={{ marginTop:6, padding:'4px 8px', background:'rgba(217, 106, 74, 0.1)', border:`1px solid ${V1.red}55`, borderRadius:4, fontFamily:V1.mono, fontSize:9.5, color:V1.red, lineHeight:1.4 }}>
              ⚠ 桶內樣本價差過大 (&gt;5×) 或範圍 &gt;200M,中位數參考性低 · 不自動標記套利
            </div>
          )}
          {expected == null && item.eligibleForArbitrage === false && (
            <div style={{ marginTop:6, fontFamily:V1.mono, fontSize:9.5, color:V1.text3, fontStyle:'italic' }}>
              此物品不在套利追蹤範圍內 (低階裝備)
            </div>
          )}
          {expected == null && item.eligibleForArbitrage && item.avgSampleCount === 0 && (
            <div style={{ marginTop:6, fontFamily:V1.mono, fontSize:9.5, color:V1.text3, fontStyle:'italic' }}>
              maplen.gg 找不到同分桶歷史成交
            </div>
          )}
          {item.arbitrageReason && (
            <div style={{ marginTop:4, fontFamily:V1.mono, fontSize:9, color:V1.gold, letterSpacing:'0.04em' }}>
              eligible: {{
                'leg-leg':            '上下潛能皆 Legendary',
                'high-star':          '15★ 以上',
                'weapon-multi-pow':   '武器 ATT/Boss/IED 多詞條',
                'weapon-pow':         '武器 ATT/Boss/IED 詞條',
                'emblem-att-pot':     '徽章 ATT% 潛能',
                'emblem-ied-pot':     '徽章 IED% 潛能',
                'high-stat-line':     '主屬性 ≥27% 詞條',
                'bonus-12+9-att':     '附加潛能 12+9 ATT/MATT',
                'emblem-triple-att':  '徽章三條 ATT/MATT 附加',
                'triple-flat-att':    '附加潛能三條 flat ATT/MATT',
                'leg-half':           '單邊 Legendary',
                'uniq-uniq':          '上下潛能皆 Unique',
                'white-checkable':    '白板裝 (檢查市場價)',
              }[item.arbitrageReason] || item.arbitrageReason}
            </div>
          )}
        </div>

        <div style={{ padding:'18px 14px 14px', borderBottom:`1px solid ${V1.border}`, display:'flex', flexDirection:'column', gap:18 }}>
          <PotentialFrame
            title="潛在能力"
            grade={item.potentialGradeLabel}
            lines={potLines}
            emptyText="—  無潛在能力  —"
          />
          <PotentialFrame
            title="附加潛在能力"
            grade={item.bonusPotentialGradeLabel}
            lines={bonusLines}
            emptyText="—  無附加潛在能力  —"
          />
        </div>

        <div style={{ padding:'12px 14px', display:'flex', flexDirection:'column', gap:8 }}>
          <div style={{ display:'flex', justifyContent:'space-between', fontFamily:V1.mono, fontSize:10 }}>
            <span style={{ color:V1.text3, letterSpacing:'0.08em' }}>賣家</span>
            <span style={{ color:V1.text2 }}>{item.sellerNickname || shortAddr(item.sellerWalletAddr) || '—'}</span>
          </div>
          <div style={{ display:'flex', justifyContent:'space-between', fontFamily:V1.mono, fontSize:10 }}>
            <span style={{ color:V1.text3, letterSpacing:'0.08em' }}>上架時間</span>
            <span style={{ color:V1.text2 }} title={item.createdAt}>{fmtMD(item.createdAt)}</span>
          </div>
          {item.expiredAt && (
            <div style={{ display:'flex', justifyContent:'space-between', fontFamily:V1.mono, fontSize:10 }}>
              <span style={{ color:V1.text3, letterSpacing:'0.08em' }}>到期</span>
              <span style={{ color:V1.text2 }} title={item.expiredAt}>{fmtMD(item.expiredAt)}</span>
            </div>
          )}
          <div style={{ display:'flex', alignItems:'center', gap:6, padding:'8px 10px', background:V1.bgSoft, border:`1px solid ${V1.border}`, borderRadius:5, marginTop:4 }}>
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={V1.text3} strokeWidth="2" style={{ flexShrink:0 }}>
              <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>
              <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
            </svg>
            <span style={{ fontFamily:V1.mono, fontSize:10, color:V1.text2, flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{url}</span>
          </div>
          <div style={{ display:'flex', gap:6 }}>
            <a href={url} target="_blank" rel="noopener noreferrer" style={{
              flex:1, background:V1.gold, color:V1.bg, border:'none',
              padding:'8px', borderRadius:5, fontFamily:V1.mono, fontSize:10.5, fontWeight:700,
              cursor:'pointer', letterSpacing:'0.08em', textAlign:'center', textDecoration:'none',
              display:'flex', alignItems:'center', justifyContent:'center', gap:5,
            }}>前往 MSU 市集 →</a>
          </div>
        </div>
      </div>
    </Panel>
  );
}

// ===========================================================================
// Discord Panel
// ===========================================================================
// New compact panel: signed-in users see their avatar + opt-in toggle + test
// button. Anonymous visitors see a single "Connect Discord" button.
function DiscordPanel({ me, botStatus, onConnect, onLogout, onTest, onToggleArbitrage }) {
  const oauthReady = !!(botStatus?.oauthConfigured);
  const botReady = !!(botStatus?.botReady);

  if (!me) {
    return (
      <Panel title="Discord" sub="個人化通知" style={{ gridColumn:'span 3', gridRow:'span 1' }}>
        <div style={{ display:'flex', flexDirection:'column', gap:10, flex:1 }}>
          <div style={{ fontFamily:V1.font, fontSize:11.5, color:V1.text2, lineHeight:1.5 }}>
            連接 Discord 帳號後,可以建立個人追蹤規則,Bot 會直接 DM 你符合的物品。
          </div>
          {!oauthReady && (
            <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.red, lineHeight:1.5 }}>
              ⚠ 後端未設定 DISCORD_CLIENT_ID / SECRET,請見 README。
            </div>
          )}
          {!botReady && oauthReady && (
            <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3, lineHeight:1.5 }}>
              Bot Token 未設定,登入後仍無法收 DM。
            </div>
          )}
          <button
            onClick={onConnect}
            disabled={!oauthReady}
            style={{
              marginTop:'auto', background: oauthReady ? V1.blurple : V1.border, color:'#fff',
              border:'none', borderRadius:5, padding:'10px',
              fontFamily:V1.font, fontSize:13, fontWeight:600,
              cursor: oauthReady ? 'pointer' : 'not-allowed',
              display:'flex', alignItems:'center', justifyContent:'center', gap:8,
              opacity: oauthReady ? 1 : 0.5,
            }}
          >
            <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
            連接 Discord
          </button>
        </div>
      </Panel>
    );
  }

  // Signed-in view
  return (
    <Panel title="Discord" sub={`已連接 · @${me.username}`} style={{ gridColumn:'span 3', gridRow:'span 1' }}>
      <div style={{ display:'flex', flexDirection:'column', gap:8, flex:1 }}>
        <div style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 9px', background:V1.bgSoft, border:`1px solid ${V1.border}`, borderRadius:6 }}>
          <img src={me.avatarUrl} alt="" width={32} height={32} style={{ borderRadius:'50%', flexShrink:0, background:V1.blurple }}/>
          <div style={{ flex:1, minWidth:0 }}>
            <div style={{ fontFamily:V1.font, fontSize:12, color:V1.text, fontWeight:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
              {me.globalName || me.username}
            </div>
            <div style={{ fontFamily:V1.mono, fontSize:9.5, color:botReady ? V1.green : V1.text3 }}>
              ● {botReady ? 'Bot 就緒 · 將 DM 你' : 'Bot 未設定'}
            </div>
          </div>
        </div>
        <label
          title={me.canUseArbitrage === false ? '管理員已停權' : ''}
          style={{
            display:'flex', alignItems:'center', gap:8,
            fontFamily:V1.mono, fontSize:10.5,
            color: me.canUseArbitrage === false ? V1.text3 : V1.text2,
            cursor: me.canUseArbitrage === false ? 'not-allowed' : 'pointer',
            opacity: me.canUseArbitrage === false ? 0.5 : 1,
          }}>
          <input
            type="checkbox"
            disabled={me.canUseArbitrage === false}
            checked={!!me.optInArbitrage && me.canUseArbitrage !== false}
            onChange={e => onToggleArbitrage(e.target.checked)}
            style={{ accentColor: V1.gold }}
          />
          <span>套利 (≥30%) 也 DM 我{me.canUseArbitrage === false ? ' · 管理員已停權' : ''}</span>
        </label>
        <div style={{ display:'flex', gap:6, marginTop:'auto' }}>
          <button onClick={onTest} disabled={!botReady} style={{
            flex:1, background:V1.gold, color:'#0a0f0c', border:'none', padding:'7px',
            borderRadius:4, fontFamily:V1.mono, fontSize:10, fontWeight:600,
            cursor: botReady ? 'pointer' : 'not-allowed', letterSpacing:'0.05em',
            opacity: botReady ? 1 : 0.4,
          }}>傳送測試 DM</button>
          <button onClick={onLogout} title="登出" style={{
            background:'transparent', color:V1.text3, border:`1px solid ${V1.border}`,
            padding:'7px 10px', borderRadius:4, fontFamily:V1.mono, fontSize:10, cursor:'pointer'
          }}>登出</button>
        </div>
      </div>
    </Panel>
  );
}

// LEGACY: prior single-user panel. No longer rendered, kept in source for now.
function _LegacyDiscordPanel({ discord, onSave, onTest, toast }) {
  const [editing, setEditing] = useState(false);
  const savedMode = discord?.mode || 'webhook';
  const [draftMode, setDraftMode] = useState(savedMode);
  const [draftUrl, setDraftUrl] = useState('');
  const [draftBotToken, setDraftBotToken] = useState('');
  const [draftUserId, setDraftUserId] = useState('');
  const [draftLabel, setDraftLabel] = useState('');

  // "Connected" is mode-dependent. Bot mode needs token + user id; webhook
  // mode needs a webhook URL.
  const connected = !!discord?.enabled && (
    savedMode === 'bot'
      ? (!!discord?.hasBotToken && !!discord?.userId)
      : !!discord?.hasWebhook
  );

  useEffect(() => {
    // Don't pre-fill any secret inputs from the masked values — user must
    // re-paste the actual credential to rotate it. User ID is NOT a secret
    // (public snowflake) so we pre-fill it for convenience.
    setDraftMode(savedMode);
    setDraftUrl('');
    setDraftBotToken('');
    setDraftUserId(discord?.userId || '');
    setDraftLabel(discord?.channelLabel || '');
  }, [savedMode, discord?.hasWebhook, discord?.hasBotToken, discord?.userId, discord?.channelLabel, editing]);

  return (
    <Panel
      title="Discord"
      sub={draftMode === 'bot' ? 'Bot DM' : 'Webhook 整合'}
      style={{ gridColumn:'span 3', gridRow:'span 1' }}
    >
      {!connected || editing ? (
        <div style={{ display:'flex', flexDirection:'column', gap:8, flex:1, overflowY:'auto' }}>
          {/* Mode toggle */}
          <div style={{ display:'flex', gap:4, padding:2, background:V1.bgSoft, border:`1px solid ${V1.border}`, borderRadius:5 }}>
            {['webhook', 'bot'].map(m => (
              <button
                key={m}
                onClick={() => setDraftMode(m)}
                style={{
                  flex:1, background: draftMode === m ? V1.blurple : 'transparent',
                  color: draftMode === m ? '#fff' : V1.text2,
                  border:'none', borderRadius:3, padding:'5px 6px',
                  fontFamily:V1.font, fontSize:11, fontWeight:600, cursor:'pointer',
                }}>
                {m === 'webhook' ? 'Webhook (頻道)' : 'Bot DM (私訊)'}
              </button>
            ))}
          </div>

          {draftMode === 'webhook' ? (
            <>
              <div style={{ fontFamily:V1.font, fontSize:11.5, color:V1.text2, lineHeight:1.5 }}>
                貼上 Discord 頻道的 <a href="https://support.discord.com/hc/articles/228383668" target="_blank" rel="noopener" style={{ color:V1.gold }}>Webhook URL</a>。
              </div>
              {editing && discord?.hasWebhook && (
                <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3, lineHeight:1.5 }}>
                  目前 Webhook:<span style={{ color:V1.text2 }}>{discord.webhookUrlMasked || '••••'}</span>
                </div>
              )}
              <input
                value={draftUrl}
                onChange={e => setDraftUrl(e.target.value)}
                placeholder={discord?.hasWebhook ? '貼新網址才會取代' : 'https://discord.com/api/webhooks/…'}
                style={{ background:V1.bgSoft, color:V1.text, border:`1px solid ${V1.border}`, borderRadius:5, padding:'7px 9px', fontFamily:V1.mono, fontSize:11, outline:'none' }}
              />
            </>
          ) : (
            <>
              <div style={{ fontFamily:V1.font, fontSize:11.5, color:V1.text2, lineHeight:1.5 }}>
                Bot 必須跟你<b>共用至少一個伺服器</b>,且你的 DM 設定要允許接收。設定步驟見 README「Discord Bot DM 設定」。
              </div>
              {editing && discord?.hasBotToken && (
                <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3, lineHeight:1.5 }}>
                  目前 Bot Token:<span style={{ color:V1.text2 }}>{discord.botTokenMasked || '••••'}</span>
                </div>
              )}
              <input
                value={draftBotToken}
                onChange={e => setDraftBotToken(e.target.value)}
                placeholder={discord?.hasBotToken ? '貼新 token 才會取代' : 'Bot Token (從 Developer Portal 取得)'}
                style={{ background:V1.bgSoft, color:V1.text, border:`1px solid ${V1.border}`, borderRadius:5, padding:'7px 9px', fontFamily:V1.mono, fontSize:11, outline:'none' }}
              />
              <input
                value={draftUserId}
                onChange={e => setDraftUserId(e.target.value)}
                placeholder="你的 Discord User ID (17-20 位數字)"
                style={{ background:V1.bgSoft, color:V1.text, border:`1px solid ${V1.border}`, borderRadius:5, padding:'7px 9px', fontFamily:V1.mono, fontSize:11, outline:'none' }}
              />
            </>
          )}

          <input
            value={draftLabel}
            onChange={e => setDraftLabel(e.target.value)}
            placeholder="標籤 (選填,顯示用)"
            style={{ background:V1.bgSoft, color:V1.text, border:`1px solid ${V1.border}`, borderRadius:5, padding:'7px 9px', fontFamily:V1.mono, fontSize:11, outline:'none' }}
          />

          {(() => {
            // Build the save payload + check whether save makes sense.
            const trimmedUrl = draftUrl.trim();
            const trimmedToken = draftBotToken.trim();
            const trimmedUserId = draftUserId.trim();
            const label = draftLabel.trim() || null;

            let payload, canSave, hint;
            if (draftMode === 'webhook') {
              const haveWebhook = !!trimmedUrl || !!discord?.hasWebhook;
              canSave = haveWebhook;
              payload = {
                mode: 'webhook',
                webhookUrl: trimmedUrl || undefined, // omit = keep
                enabled: haveWebhook,
                channelLabel: label,
              };
              hint = trimmedUrl ? '會儲存新 Webhook URL' : (discord?.hasWebhook ? '保留現有 Webhook' : '尚未設定 Webhook');
            } else {
              const haveToken = !!trimmedToken || !!discord?.hasBotToken;
              const haveUserId = !!trimmedUserId || !!discord?.userId;
              canSave = haveToken && haveUserId;
              payload = {
                mode: 'bot',
                botToken: trimmedToken || undefined,
                userId: trimmedUserId || undefined,
                enabled: canSave,
                channelLabel: label,
              };
              hint = !haveToken ? '缺 Bot Token' : (!haveUserId ? '缺 User ID' : (trimmedToken ? '會儲存新 Token' : '保留現有 Token'));
            }
            return (
              <>
                <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>{hint}</div>
                <div style={{ display:'flex', gap:6, marginTop:'auto' }}>
                  <button onClick={()=>onSave(payload).then(()=>setEditing(false))} disabled={!canSave} style={{
                    flex:1, background:V1.blurple, color:'#fff', border:'none', borderRadius:5,
                    padding:'8px', fontFamily:V1.font, fontSize:12, fontWeight:600, cursor: canSave?'pointer':'not-allowed',
                    opacity: canSave?1:0.5,
                  }}>儲存</button>
                  {editing && <button onClick={()=>setEditing(false)} style={{ background:'transparent', color:V1.text3, border:`1px solid ${V1.border}`, padding:'8px 12px', borderRadius:5, fontFamily:V1.mono, fontSize:11, cursor:'pointer' }}>取消</button>}
                </div>
              </>
            );
          })()}
          <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3, letterSpacing:'0.04em', lineHeight:1.6 }}>
            儲存於後端 · 僅命中時送出
          </div>
        </div>
      ) : (
        <div style={{ display:'flex', flexDirection:'column', gap:10, flex:1 }}>
          <div style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 10px', background:V1.bgSoft, border:`1px solid ${V1.border}`, borderRadius:6 }}>
            <div style={{ width:30, height:30, borderRadius:'50%', background:V1.blurple, color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:V1.font, fontWeight:600, fontSize:13 }}>
              <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
            </div>
            <div style={{ flex:1, minWidth:0 }}>
              <div style={{ fontFamily:V1.font, fontSize:12, color:V1.text, fontWeight:500, overflow:'hidden', textOverflow:'ellipsis' }}>{discord.channelLabel || (savedMode === 'bot' ? `DM → ${discord.userId || ''}` : '已連線')}</div>
              <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.green }}>● {fmtTimeShort(discord.updatedAt)} · {savedMode === 'bot' ? `Bot ${discord.botTokenMasked || '已儲存'}` : (discord.webhookUrlMasked ? `Webhook ${discord.webhookUrlMasked.slice(-9)}` : 'Webhook 已儲存')}</div>
            </div>
          </div>
          <div style={{ display:'flex', gap:6, marginTop:'auto' }}>
            <button onClick={onTest} style={{ flex:1, background:V1.gold, color:'#0a0f0c', border:'none', padding:'7px', borderRadius:4, fontFamily:V1.mono, fontSize:10, fontWeight:600, cursor:'pointer', letterSpacing:'0.05em' }}>傳送測試</button>
            <button onClick={()=>setEditing(true)} style={{ background:'transparent', color:V1.text3, border:`1px solid ${V1.border}`, padding:'7px 9px', borderRadius:4, fontFamily:V1.mono, fontSize:10, cursor:'pointer' }}>編輯</button>
            <button onClick={()=>onSave({ mode: savedMode, webhookUrl: null, botToken: null, userId: null, enabled: false, channelLabel: null })} style={{ background:'transparent', color:V1.red, border:`1px solid ${V1.border}`, padding:'7px 9px', borderRadius:4, fontFamily:V1.mono, fontSize:10, cursor:'pointer' }}>×</button>
          </div>
        </div>
      )}
    </Panel>
  );
}

// ===========================================================================
// Rule Drawer
// ===========================================================================

// Module-scope: prevent React from re-creating this component on every parent
// render (which would unmount/remount inputs and lose focus on every keystroke).
function Field({ label, children, hint, optional }) {
  return (
    <div style={{ display:'flex', flexDirection:'column', gap:6 }}>
      <label style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, letterSpacing:'0.12em', textTransform:'uppercase' }}>
        {label} {optional && <span style={{ opacity:0.6 }}>· 選填</span>}
        {hint && <span style={{ marginLeft:6, opacity:0.6, textTransform:'none', letterSpacing:0 }}>{hint}</span>}
      </label>
      {children}
    </div>
  );
}

// ===========================================================================
// Admin drawer — only opens when the current user is in ADMIN_DISCORD_USER_IDS
// (the server enforces this on the API; the frontend just hides the entry).
// Lists every connected user and lets admin tweak max_rules + can_use_arbitrage.
// ===========================================================================
function AdminDrawer({ open, onClose, onToast }) {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [edits, setEdits] = useState({});  // discordUserId → { maxRules, canUseArbitrage }
  const [expandedId, setExpandedId] = useState(null);
  const [expandedRules, setExpandedRules] = useState({});  // discordUserId → rules[]

  const reload = useCallback(async () => {
    setLoading(true);
    try {
      const r = await apiGet('/api/admin/users');
      setUsers(r.users || []);
      setEdits({});
    } catch (e) {
      onToast?.({ kind:'error', text:`載入失敗:${e.message}` });
    } finally {
      setLoading(false);
    }
  }, [onToast]);

  useEffect(() => { if (open) reload(); }, [open, reload]);

  if (!open) return null;

  async function loadRulesFor(id) {
    if (expandedRules[id]) {
      setExpandedId(expandedId === id ? null : id);
      return;
    }
    try {
      const r = await apiGet(`/api/admin/users/${id}`);
      setExpandedRules(prev => ({ ...prev, [id]: r.rules || [] }));
      setExpandedId(id);
    } catch (e) {
      onToast?.({ kind:'error', text:`載入規則失敗:${e.message}` });
    }
  }

  async function saveUser(id) {
    const edit = edits[id];
    if (!edit) return;
    try {
      await apiSend('PUT', `/api/admin/users/${id}`, edit);
      onToast?.({ kind:'ok', text:`✓ 已更新 @${users.find(u=>u.discordUserId===id)?.username}` });
      await reload();
    } catch (e) {
      onToast?.({ kind:'error', text:`儲存失敗:${e.message}` });
    }
  }

  const editFor = (id) => edits[id] || {};
  const setEditFor = (id, patch) => setEdits(prev => ({ ...prev, [id]: { ...prev[id], ...patch } }));

  return (
    <div>
      <div onClick={onClose} style={{ position:'fixed', inset:0, background:'rgba(5,8,6,0.6)', backdropFilter:'blur(2px)', zIndex:50, animation:'msuFade 0.18s ease-out' }}/>
      <div style={{
        position:'fixed', top:0, right:0, bottom:0, width:560, maxWidth:'100vw',
        background:V1.bg, borderLeft:`1px solid ${V1.borderHi}`,
        zIndex:51, display:'flex', flexDirection:'column',
        animation:'msuSlide 0.24s cubic-bezier(.2,.7,.3,1)',
        boxShadow:'-20px 0 60px rgba(0,0,0,0.5)',
      }}>
        <div style={{ padding:'18px 20px', borderBottom:`1px solid ${V1.border}`, display:'flex', alignItems:'center', justifyContent:'space-between' }}>
          <div style={{ display:'flex', alignItems:'center', gap:12 }}>
            <div style={{ width:36, height:36, borderRadius:8, background:`linear-gradient(135deg, ${V1.gold}, ${V1.goldDim})`, display:'flex', alignItems:'center', justifyContent:'center', color:'#0a0f0c', fontSize:18, fontWeight:600 }}>⚙</div>
            <div>
              <div style={{ fontFamily:V1.font, fontSize:15, fontWeight:600, color:V1.text }}>管理員儀表板</div>
              <div style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, letterSpacing:'0.05em', marginTop:2 }}>連接的使用者 · 規則配額 · 套利權限</div>
            </div>
          </div>
          <button onClick={onClose} style={{ background:'transparent', border:`1px solid ${V1.border}`, color:V1.text2, width:32, height:32, borderRadius:6, cursor:'pointer', fontSize:16 }}>×</button>
        </div>

        <div style={{ flex:1, overflowY:'auto', padding:'14px 16px', display:'flex', flexDirection:'column', gap:10 }}>
          {loading && (
            <div style={{ color:V1.text3, fontFamily:V1.mono, fontSize:11, textAlign:'center', padding:'30px 0' }}>載入中…</div>
          )}
          {!loading && users.length === 0 && (
            <div style={{ color:V1.text3, fontFamily:V1.mono, fontSize:11, textAlign:'center', padding:'30px 0' }}>還沒有人連接 Discord</div>
          )}
          {!loading && users.map(u => {
            const edit = editFor(u.discordUserId);
            const liveMax = edit.maxRules ?? u.maxRules;
            const liveArb = edit.canUseArbitrage ?? u.canUseArbitrage;
            const dirty = (edit.maxRules != null && edit.maxRules !== u.maxRules)
              || (edit.canUseArbitrage != null && edit.canUseArbitrage !== u.canUseArbitrage);
            const overLimit = u.ruleCount > liveMax;
            return (
              <div key={u.discordUserId} style={{
                background:V1.bgSoft, border:`1px solid ${dirty ? V1.gold : V1.border}`,
                borderRadius:6, padding:'10px 12px', display:'flex', flexDirection:'column', gap:8,
              }}>
                <div style={{ display:'flex', alignItems:'center', gap:10 }}>
                  <img src={u.avatarUrl} alt="" width={32} height={32} style={{ borderRadius:'50%', background:V1.blurple }}/>
                  <div style={{ flex:1, minWidth:0 }}>
                    <div style={{ fontFamily:V1.font, fontSize:13, color:V1.text, fontWeight:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
                      {u.globalName || u.username}
                      {u.isAdmin && <span style={{ marginLeft:6, fontFamily:V1.mono, fontSize:9, color:V1.gold, background:V1.goldGlow, padding:'1px 5px', borderRadius:3 }}>ADMIN</span>}
                    </div>
                    <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>
                      @{u.username} · ID {u.discordUserId.slice(-6)} · 最後上線 {u.lastSeenAt ? fmtMD(u.lastSeenAt) : '—'}
                    </div>
                  </div>
                  <button
                    onClick={() => loadRulesFor(u.discordUserId)}
                    style={{ background:'transparent', color:V1.text3, border:`1px solid ${V1.border}`, padding:'4px 9px', borderRadius:4, fontFamily:V1.mono, fontSize:10, cursor:'pointer' }}
                  >規則 {u.ruleCount}{expandedId === u.discordUserId ? ' ▴' : ' ▾'}</button>
                </div>

                {/* edit row */}
                <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr auto', gap:8, alignItems:'center' }}>
                  <label style={{ display:'flex', flexDirection:'column', gap:2, fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>
                    規則上限
                    <input
                      className="msu-no-spinner"
                      type="number" min="0" max="1000"
                      value={liveMax}
                      onChange={e => setEditFor(u.discordUserId, { maxRules: Math.max(0, +e.target.value || 0) })}
                      style={{ background:V1.bg, color:V1.text, border:`1px solid ${overLimit ? V1.red : V1.border}`, borderRadius:4, padding:'5px 8px', fontFamily:V1.mono, fontSize:11, outline:'none' }}
                    />
                  </label>
                  <label style={{ display:'flex', alignItems:'center', gap:6, fontFamily:V1.mono, fontSize:10.5, color:V1.text2, cursor:'pointer' }}>
                    <input
                      type="checkbox"
                      checked={!!liveArb}
                      onChange={e => setEditFor(u.discordUserId, { canUseArbitrage: e.target.checked })}
                      style={{ accentColor: V1.gold }}
                    />
                    <span>允許接收套利通知</span>
                  </label>
                  <button
                    onClick={() => saveUser(u.discordUserId)}
                    disabled={!dirty}
                    style={{
                      background: dirty ? V1.gold : V1.border, color:'#0a0f0c',
                      border:'none', borderRadius:4, padding:'6px 12px',
                      fontFamily:V1.mono, fontSize:10, fontWeight:600,
                      cursor: dirty ? 'pointer' : 'not-allowed',
                      opacity: dirty ? 1 : 0.4,
                    }}>儲存</button>
                </div>
                {overLimit && (
                  <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.red }}>
                    ⚠ 目前規則數 ({u.ruleCount}) 已超過上限 ({liveMax}),既有規則不會被刪除但無法新增
                  </div>
                )}
                {u.optInArbitrage && !u.canUseArbitrage && (
                  <div style={{ fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>
                    使用者原本想收套利 DM,但你已停權
                  </div>
                )}

                {/* expanded rules list */}
                {expandedId === u.discordUserId && (
                  <div style={{ background:V1.bg, border:`1px solid ${V1.border}`, borderRadius:4, padding:'8px 10px', display:'flex', flexDirection:'column', gap:4, maxHeight:180, overflowY:'auto' }}>
                    {(expandedRules[u.discordUserId] || []).length === 0 ? (
                      <div style={{ color:V1.text3, fontFamily:V1.mono, fontSize:10, textAlign:'center', padding:'8px 0' }}>(無規則)</div>
                    ) : expandedRules[u.discordUserId].map(r => (
                      <div key={r.id} style={{ display:'flex', alignItems:'center', gap:8, fontFamily:V1.mono, fontSize:10, color:V1.text2 }}>
                        <span style={{ width:6, height:6, borderRadius:'50%', background: r.active ? V1.green : V1.text3, flexShrink:0 }}/>
                        <span style={{ flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{r.name}</span>
                        <span style={{ color:V1.text3 }}>命中 {r.matchesCount ?? 0}</span>
                      </div>
                    ))}
                  </div>
                )}
              </div>
            );
          })}
        </div>

        <div style={{ padding:'12px 16px', borderTop:`1px solid ${V1.border}`, display:'flex', justifyContent:'space-between', alignItems:'center', fontFamily:V1.mono, fontSize:9.5, color:V1.text3 }}>
          <span>{users.length} 位使用者已連接</span>
          <button onClick={reload} style={{ background:'transparent', color:V1.text2, border:`1px solid ${V1.border}`, padding:'5px 10px', borderRadius:4, fontFamily:V1.mono, fontSize:10, cursor:'pointer' }}>重新整理</button>
        </div>
      </div>
    </div>
  );
}

// === Range-clamp helpers (防呆) ===========================================
// Whenever the user changes one end of a range, snap the OTHER end so the
// invariant min ≤ max always holds. This keeps the form in a valid state
// without surfacing red-bordered error messages — the user just sees the
// other field tick up/down to keep up.

// Numeric ranges (star, level). `bounds` clamps to allowed [min, max].
function clampNumRange(form, key, raw, bounds = {}) {
  const { min = 0, max = Number.POSITIVE_INFINITY } = bounds;
  let v = Number(raw);
  if (!Number.isFinite(v)) v = min;
  v = Math.max(min, Math.min(max, v));
  const isMin = key.endsWith('Min');
  const otherKey = isMin ? key.replace(/Min$/, 'Max') : key.replace(/Max$/, 'Min');
  const other = Number(form[otherKey]);
  const next = { ...form, [key]: v };
  if (isMin && Number.isFinite(other) && other < v) next[otherKey] = v;
  if (!isMin && Number.isFinite(other) && other > v) next[otherKey] = v;
  return next;
}

// Potential tier ranges — compare by POTENTIAL_TIER_INDEX, not string order.
function clampTierRange(form, key, value) {
  const isMin = key.endsWith('Min');
  const otherKey = isMin ? key.replace(/Min$/, 'Max') : key.replace(/Max$/, 'Min');
  const valIdx = POTENTIAL_TIER_INDEX[value] ?? 0;
  const otherIdx = POTENTIAL_TIER_INDEX[form[otherKey]] ?? 0;
  const next = { ...form, [key]: value };
  if (isMin && otherIdx < valIdx) next[otherKey] = value;
  if (!isMin && otherIdx > valIdx) next[otherKey] = value;
  return next;
}

// Price range — both inputs are strings ('' = unbounded). Only clamp when
// both sides are numeric strings.
function clampPriceRange(form, key, raw) {
  const next = { ...form, [key]: raw };
  const otherKey = key === 'priceMin' ? 'priceMax' : 'priceMin';
  if (raw === '' || next[otherKey] === '') return next;
  const v = Number(raw);
  const o = Number(next[otherKey]);
  if (!Number.isFinite(v) || !Number.isFinite(o)) return next;
  if (key === 'priceMin' && o < v) next[otherKey] = String(v);
  if (key === 'priceMax' && o > v) next[otherKey] = String(v);
  return next;
}

function RuleDrawer({ open, onClose, onSave, initial }) {
  const [form, setForm] = useState({
    name: '', itemName: '', tier1: '', tier2: '', tier3: '', job: 'All',
    starMin: 0, starMax: 25, levelMin: 0, levelMax: 300,
    potMin: 'None', potMax: 'Legendary', bonusMin: 'None', bonusMax: 'Legendary',
    priceMin: '', priceMax: '',
    linePredicates: [],  // [{option, minValue}]
  });
  const [itemNames, setItemNames] = useState([]);
  const [lineCatalog, setLineCatalog] = useState([]);
  // Pending row for the line-predicate adder UI (one row at a time → list)
  const [draftPred, setDraftPred] = useState({ option: '', minValue: '' });

  // Lazy-fetch distinct item names + line catalog from server.
  useEffect(() => {
    if (!open) return;
    let cancelled = false;
    apiGet('/api/item-names').then(r => {
      if (!cancelled) setItemNames(Array.isArray(r?.names) ? r.names : []);
    }).catch(() => {});
    apiGet('/api/line-catalog').then(r => {
      if (!cancelled) setLineCatalog(Array.isArray(r?.catalog) ? r.catalog : []);
    }).catch(() => {});
    return () => { cancelled = true; };
  }, [open]);
  useEffect(() => {
    if (open) {
      const i = initial || {};
      setForm({
        name: i.name || '',
        itemName: i.itemName || '',
        tier1: i.tier1 || '',
        tier2: i.tier2 || '',
        tier3: i.tier3 || '',
        job: i.job || 'All',
        starMin: i.starMin ?? 0,
        starMax: i.starMax ?? 25,
        levelMin: i.levelMin ?? 0,
        levelMax: i.levelMax ?? 300,
        potMin: POTENTIAL_TIERS[i.potMin ?? 0],
        potMax: POTENTIAL_TIERS[i.potMax ?? 4],
        bonusMin: POTENTIAL_TIERS[i.bonusMin ?? 0],
        bonusMax: POTENTIAL_TIERS[i.bonusMax ?? 4],
        priceMin: i.priceMin ?? '',
        priceMax: i.priceMax ?? '',
        linePredicates: Array.isArray(i.linePredicates) ? i.linePredicates.map(p => ({ ...p })) : [],
      });
      setDraftPred({ option: '', minValue: '' });
    }
  }, [open, initial]);
  if (!open) return null;

  const tier2Options = form.tier1 ? Object.keys(CATEGORIES[form.tier1] || {}) : [];
  const tier3Options = (form.tier1 && form.tier2) ? (CATEGORIES[form.tier1]?.[form.tier2] || []) : [];

  const inputStyle = {
    background:V1.bgSoft, color:V1.text, border:`1px solid ${V1.border}`,
    borderRadius:6, padding:'9px 11px', fontFamily:V1.font, fontSize:13,
    outline:'none', width:'100%', boxSizing:'border-box',
  };

  const submit = () => {
    onSave({
      name: form.name,
      itemName: form.itemName || null,
      tier1: form.tier1 || null,
      tier2: form.tier2 || null,
      tier3: form.tier3 || null,
      job: form.job === 'All' ? null : form.job,
      levelMin: Number(form.levelMin) || null,
      levelMax: Number(form.levelMax) || null,
      starMin: Number(form.starMin),
      starMax: Number(form.starMax),
      potMin: POTENTIAL_TIER_INDEX[form.potMin] ?? 0,
      potMax: POTENTIAL_TIER_INDEX[form.potMax] ?? 4,
      bonusMin: POTENTIAL_TIER_INDEX[form.bonusMin] ?? 0,
      bonusMax: POTENTIAL_TIER_INDEX[form.bonusMax] ?? 4,
      priceMin: form.priceMin === '' ? null : Number(form.priceMin),
      priceMax: form.priceMax === '' ? null : Number(form.priceMax),
      linePredicates: (form.linePredicates || [])
        .filter(p => p.option && Number.isFinite(Number(p.minValue)))
        .map(p => ({ option: p.option, minValue: Number(p.minValue) })),
    });
  };

  const addPredicate = () => {
    if (!draftPred.option) return;
    const v = Number(draftPred.minValue);
    if (!Number.isFinite(v) || v < 0) return;
    // Replace if same option already present, else append.
    const existingIdx = form.linePredicates.findIndex(p => p.option === draftPred.option);
    const next = [...form.linePredicates];
    if (existingIdx >= 0) next[existingIdx] = { option: draftPred.option, minValue: v };
    else next.push({ option: draftPred.option, minValue: v });
    setForm({ ...form, linePredicates: next });
    setDraftPred({ option: '', minValue: '' });
  };
  const removePredicate = (idx) => {
    const next = form.linePredicates.filter((_, i) => i !== idx);
    setForm({ ...form, linePredicates: next });
  };
  const labelFor = (id) => lineCatalog.find(c => c.id === id)?.label || id;
  const unitFor = (id) => lineCatalog.find(c => c.id === id)?.unit || '%';

  // NOTE: Field is intentionally defined at module scope (below RuleDrawer).
  // Defining components inline inside a parent re-creates them on every render,
  // which unmounts/remounts children — that's how the focus-loss bug happened.

  return (
    <div>
      <div onClick={onClose} style={{ position:'fixed', inset:0, background:'rgba(5,8,6,0.6)', backdropFilter:'blur(2px)', zIndex:50, animation:'msuFade 0.18s ease-out' }}/>
      <div style={{
        position:'fixed', top:0, right:0, bottom:0, width:460, maxWidth:'100vw',
        background:V1.bg, borderLeft:`1px solid ${V1.borderHi}`,
        zIndex:51, display:'flex', flexDirection:'column',
        animation:'msuSlide 0.24s cubic-bezier(.2,.7,.3,1)',
        boxShadow:'-20px 0 60px rgba(0,0,0,0.5)',
      }}>
        <div style={{ padding:'18px 20px', borderBottom:`1px solid ${V1.border}`, display:'flex', alignItems:'center', justifyContent:'space-between' }}>
          <div style={{ display:'flex', alignItems:'center', gap:12 }}>
            <div style={{ width:36, height:36, borderRadius:8, background:`linear-gradient(135deg, ${V1.gold}, ${V1.goldDim})`, display:'flex', alignItems:'center', justifyContent:'center', color:'#0a0f0c', fontSize:18, fontWeight:600 }}>+</div>
            <div>
              <div style={{ fontFamily:V1.font, fontSize:15, fontWeight:600, color:V1.text }}>{initial ? '編輯追蹤規則' : '新增追蹤規則'}</div>
              <div style={{ fontFamily:V1.mono, fontSize:10, color:V1.text3, letterSpacing:'0.05em', marginTop:2 }}>命中時自動推播至 Discord</div>
            </div>
          </div>
          <button onClick={onClose} style={{ background:'transparent', border:`1px solid ${V1.border}`, color:V1.text2, width:32, height:32, borderRadius:6, cursor:'pointer', fontSize:16 }}>×</button>
        </div>

        <div style={{ flex:1, overflowY:'auto', padding:'20px', display:'flex', flexDirection:'column', gap:18 }}>
          <Field label="規則名稱" hint="(顯示於通知中)">
            <input style={inputStyle} placeholder="例:Arcane Umbra 套利" value={form.name} onChange={e=>setForm({...form, name:e.target.value})}/>
          </Field>
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:14 }}>
            <Field label="物品名稱" optional hint={itemNames.length ? `(${itemNames.length} 個已知物品,輸入時會自動建議)` : ''}>
              <input
                list="msu-item-names"
                style={inputStyle}
                placeholder="例:Fafnir Wind Chaser"
                value={form.itemName}
                onChange={e=>setForm({...form, itemName:e.target.value})}
                autoComplete="off"
              />
              <datalist id="msu-item-names">
                {itemNames.map(n => <option key={n} value={n}/>)}
              </datalist>
            </Field>
            <Field label="職業">
              <select style={inputStyle} value={form.job} onChange={e=>setForm({...form, job:e.target.value})}>
                {JOBS.map(j => <option key={j} value={j}>{j === 'All' ? '全部' : j}</option>)}
              </select>
            </Field>
          </div>
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:14 }}>
            <Field label="大分類">
              <select style={inputStyle} value={form.tier1} onChange={e=>setForm({...form, tier1:e.target.value, tier2:'', tier3:''})}>
                <option value="">全部</option>
                {['Weapon','Armor'].map(c => <option key={c} value={c}>{c}</option>)}
              </select>
            </Field>
            <Field label="次分類">
              <select style={inputStyle} value={form.tier2} disabled={!form.tier1} onChange={e=>setForm({...form, tier2:e.target.value, tier3:''})}>
                <option value="">全部</option>
                {tier2Options.map(c => <option key={c} value={c}>{c}</option>)}
              </select>
            </Field>
            <Field label="細分類">
              <select style={inputStyle} value={form.tier3} disabled={!form.tier2} onChange={e=>setForm({...form, tier3:e.target.value})}>
                <option value="">全部</option>
                {tier3Options.map(c => <option key={c} value={c}>{c}</option>)}
              </select>
            </Field>
          </div>
          <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:14 }}>
            <Field label="等級區間">
              <div style={{ display:'flex', gap:6, alignItems:'center' }}>
                <input className="msu-no-spinner" type="number" min="0" max="300" style={inputStyle} value={form.levelMin}
                  onChange={e=>setForm(clampNumRange(form, 'levelMin', e.target.value, { min: 0, max: 300 }))}/>
                <span style={{ color:V1.text3 }}>—</span>
                <input className="msu-no-spinner" type="number" min="0" max="300" style={inputStyle} value={form.levelMax}
                  onChange={e=>setForm(clampNumRange(form, 'levelMax', e.target.value, { min: 0, max: 300 }))}/>
              </div>
            </Field>
            <Field label="星力 ★">
              <div style={{ display:'flex', gap:6, alignItems:'center' }}>
                <input className="msu-no-spinner" type="number" min="0" max="25" style={inputStyle} value={form.starMin}
                  onChange={e=>setForm(clampNumRange(form, 'starMin', e.target.value, { min: 0, max: 25 }))}/>
                <span style={{ color:V1.text3 }}>—</span>
                <input className="msu-no-spinner" type="number" min="0" max="25" style={inputStyle} value={form.starMax}
                  onChange={e=>setForm(clampNumRange(form, 'starMax', e.target.value, { min: 0, max: 25 }))}/>
              </div>
            </Field>
          </div>
          <Field label="潛在能力 (等級區間)">
            <div style={{ display:'grid', gridTemplateColumns:'1fr 12px 1fr', gap:6, alignItems:'center' }}>
              <select className="msu-styled-select" style={inputStyle} value={form.potMin}
                onChange={e=>setForm(clampTierRange(form, 'potMin', e.target.value))}>
                {POTENTIAL_TIERS.map(p=><option key={p} value={p}>{POTENTIAL_TIER_LABEL_ZH[p]}</option>)}
              </select>
              <span style={{ color:V1.text3, textAlign:'center' }}>—</span>
              <select className="msu-styled-select" style={inputStyle} value={form.potMax}
                onChange={e=>setForm(clampTierRange(form, 'potMax', e.target.value))}>
                {POTENTIAL_TIERS.map(p=><option key={p} value={p}>{POTENTIAL_TIER_LABEL_ZH[p]}</option>)}
              </select>
            </div>
          </Field>
          <Field label="附加潛在能力 (等級區間)">
            <div style={{ display:'grid', gridTemplateColumns:'1fr 12px 1fr', gap:6, alignItems:'center' }}>
              <select className="msu-styled-select" style={inputStyle} value={form.bonusMin}
                onChange={e=>setForm(clampTierRange(form, 'bonusMin', e.target.value))}>
                {POTENTIAL_TIERS.map(p=><option key={p} value={p}>{POTENTIAL_TIER_LABEL_ZH[p]}</option>)}
              </select>
              <span style={{ color:V1.text3, textAlign:'center' }}>—</span>
              <select className="msu-styled-select" style={inputStyle} value={form.bonusMax}
                onChange={e=>setForm(clampTierRange(form, 'bonusMax', e.target.value))}>
                {POTENTIAL_TIERS.map(p=><option key={p} value={p}>{POTENTIAL_TIER_LABEL_ZH[p]}</option>)}
              </select>
            </div>
          </Field>
          <Field label="價格區間 (NESO)" hint="留白代表不限">
            <div style={{ display:'flex', gap:6, alignItems:'center' }}>
              <input style={inputStyle} placeholder="最小" value={form.priceMin}
                onChange={e=>setForm(clampPriceRange(form, 'priceMin', e.target.value))}/>
              <span style={{ color:V1.text3 }}>—</span>
              <input style={inputStyle} placeholder="最大" value={form.priceMax}
                onChange={e=>setForm(clampPriceRange(form, 'priceMax', e.target.value))}/>
            </div>
          </Field>

          <Field
            label="特定潛能行 (進階)"
            optional
            hint="值代表同類行的『加總』(例:Bonus Potential ATT +21 = 12+9 兩條 ATT flat 加起來)。多條限制以 AND 串接。"
          >
            {/* Current predicates list */}
            {form.linePredicates.length > 0 && (
              <div style={{ display:'flex', flexDirection:'column', gap:6, marginBottom:8 }}>
                {form.linePredicates.map((p, idx) => (
                  <div key={idx} style={{
                    display:'flex', alignItems:'center', gap:8,
                    background:V1.bgSoft, border:`1px solid ${V1.border}`,
                    borderRadius:5, padding:'7px 10px',
                  }}>
                    <span style={{ flex:1, fontFamily:V1.font, fontSize:12, color:V1.text }}>
                      {labelFor(p.option)}
                    </span>
                    <span style={{ fontFamily:V1.mono, fontSize:11, color:V1.gold, fontWeight:600 }}>
                      ≥ {p.minValue}{unitFor(p.option) === '%' ? '%' : ''}
                    </span>
                    <button
                      onClick={() => removePredicate(idx)}
                      title="刪除"
                      style={{ background:'transparent', border:'none', color:V1.text3, cursor:'pointer', fontSize:14, padding:'2px 6px', borderRadius:3 }}
                    >×</button>
                  </div>
                ))}
              </div>
            )}
            {/* Adder row */}
            <div style={{ display:'grid', gridTemplateColumns:'1fr 90px 40px', gap:6, alignItems:'center' }}>
              <select
                className="msu-styled-select"
                style={inputStyle}
                value={draftPred.option}
                onChange={e => setDraftPred({ ...draftPred, option: e.target.value })}
              >
                <option value="">選擇潛能/附加潛能屬性</option>
                <optgroup label="Potential (主潛能)">
                  {lineCatalog.filter(c => c.scope === 'potential').map(c => (
                    <option key={c.id} value={c.id}>{c.label}</option>
                  ))}
                </optgroup>
                <optgroup label="Bonus Potential (附加潛能)">
                  {lineCatalog.filter(c => c.scope === 'bonus').map(c => (
                    <option key={c.id} value={c.id}>{c.label}</option>
                  ))}
                </optgroup>
              </select>
              <input
                className="msu-no-spinner"
                style={inputStyle}
                type="number"
                min="0"
                placeholder={unitFor(draftPred.option) === '%' ? 'Value %' : 'Value'}
                value={draftPred.minValue}
                onChange={e => setDraftPred({ ...draftPred, minValue: e.target.value })}
                onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addPredicate(); } }}
              />
              <button
                onClick={addPredicate}
                disabled={!draftPred.option || draftPred.minValue === ''}
                title="新增"
                style={{
                  background: (!draftPred.option || draftPred.minValue === '') ? V1.border : V1.gold,
                  color: '#0a0f0c', border:'none', borderRadius:5,
                  fontFamily:V1.mono, fontSize:18, fontWeight:600,
                  cursor: (!draftPred.option || draftPred.minValue === '') ? 'not-allowed' : 'pointer',
                  opacity: (!draftPred.option || draftPred.minValue === '') ? 0.4 : 1,
                  height: 38,
                }}>+</button>
            </div>
          </Field>
        </div>
        <div style={{ padding:'14px 20px', borderTop:`1px solid ${V1.border}`, display:'flex', gap:10, justifyContent:'flex-end' }}>
          <button onClick={onClose} style={{ background:'transparent', color:V1.text2, border:`1px solid ${V1.border}`, padding:'10px 16px', borderRadius:6, fontFamily:V1.font, fontSize:13, cursor:'pointer' }}>取消</button>
          <button onClick={submit} style={{ background:V1.gold, color:'#0a0f0c', border:'none', padding:'10px 18px', borderRadius:6, fontFamily:V1.font, fontSize:13, fontWeight:600, cursor:'pointer' }}>儲存規則</button>
        </div>
      </div>
    </div>
  );
}

// ===========================================================================
// Toast
// ===========================================================================
function Toast({ msg, onClose }) {
  useEffect(() => {
    if (!msg) return;
    const t = setTimeout(onClose, 4500);
    return () => clearTimeout(t);
  }, [msg, onClose]);
  if (!msg) return null;
  const color = msg.kind === 'error' ? V1.red : V1.gold;
  return (
    <div style={{
      position:'fixed', bottom:20, left:'50%', transform:'translateX(-50%)',
      background:V1.bgSoft, border:`1px solid ${color}`,
      borderRadius:8, padding:'12px 16px',
      display:'flex', alignItems:'center', gap:10,
      boxShadow:'0 12px 40px rgba(0,0,0,0.4)',
      animation:'msuToast 0.3s cubic-bezier(.2,.7,.3,1)',
      zIndex:60, maxWidth:520,
    }}>
      <div style={{ width:6, height:6, borderRadius:'50%', background:color, boxShadow:`0 0 10px ${color}`, flexShrink:0 }}/>
      <span style={{ fontFamily:V1.font, fontSize:13, color:V1.text }}>{msg.text}</span>
      <button onClick={onClose} style={{ background:'transparent', border:'none', color:V1.text3, cursor:'pointer', fontSize:14, padding:4, marginLeft:4 }}>×</button>
    </div>
  );
}

// ===========================================================================
// Main
// ===========================================================================
function BentoDashboard() {
  const [listings, setListings] = useState([]);          // newest first
  const [paused, setPaused] = useState(false);
  const [rules, setRules] = useState([]);
  const [hits, setHits] = useState([]);
  const [purchases, setPurchases] = useState([]);
  // Bot/OAuth admin status (read-only from /api/discord).
  const [botStatus, setBotStatus] = useState({ botReady: false, oauthConfigured: false, clientId: null });
  // Currently-signed-in user (or null if anonymous).
  const [me, setMe] = useState(null);
  const [status, setStatus] = useState(null);
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [adminOpen, setAdminOpen] = useState(false);
  const [editingRule, setEditingRule] = useState(null);
  const [toast, setToast] = useState(null);
  const [minDiscount, setMinDiscount] = useState(20);
  const [sseState, setSseState] = useState('connecting');
  const [sparks, setSparks] = useState({ arb: [], arbSold: [], rules: [], listings: [] });
  const [selectedTokenId, setSelectedTokenId] = useState(null);
  // Cache of items hydrated from /api/listings/:tokenId — used when a click
  // target (a rule hit, an arb purchase) refers to an item that has already
  // scrolled out of the in-memory ring buffer.
  const [hydratedItems, setHydratedItems] = useState({});
  const pausedRef = useRef(false);
  pausedRef.current = paused;

  const refreshAll = useCallback(async () => {
    try {
      const [s, r, h, p, d, u] = await Promise.all([
        apiGet('/api/status'),
        apiGet('/api/rules'),
        apiGet('/api/rule-hits?limit=30'),
        apiGet('/api/arbitrage-purchases?limit=30'),
        apiGet('/api/discord'),
        apiGet('/api/auth/me'),
      ]);
      setStatus(s);
      setRules(r.rules || []);
      setHits(h.hits || []);
      setPurchases(p.purchases || []);
      setBotStatus(d || {});
      setMe(u?.user || null);
      if (s?.dashboard) {
        setSparks(prev => ({
          arb: trimSpark([...prev.arb, s.dashboard.arbitrage24h || 0]),
          arbSold: trimSpark([...prev.arbSold, s.dashboard.arbSold24h || 0]),
          rules: trimSpark([...prev.rules, s.dashboard.ruleHits24h || 0]),
          listings: trimSpark([...prev.listings, s.dashboard.listingsPerHour || 0]),
        }));
      }
    } catch (e) {
      console.error('refresh failed', e);
    }
  }, []);

  // SSE feed
  useEffect(() => {
    const es = new EventSource('/api/stream');
    es.addEventListener('hello', () => setSseState('connected'));
    es.addEventListener('snapshot', (e) => {
      try {
        const data = JSON.parse(e.data);
        setListings(Array.isArray(data.items) ? data.items : []);
        if (data.status) setStatus(s => ({ ...(s || {}), ...data.status }));
      } catch {}
    });
    es.addEventListener('listing', (e) => {
      if (pausedRef.current) return;
      try {
        const item = JSON.parse(e.data);
        setListings(prev => {
          const exists = prev.findIndex(x => x.tokenId === item.tokenId);
          if (exists >= 0) {
            const copy = prev.slice();
            copy.splice(exists, 1);
            return [item, ...copy].slice(0, 600);
          }
          return [item, ...prev].slice(0, 600);
        });
        if (item.isArbitrage) {
          setToast({ kind:'arb', text:`⚡ 套利機會 · ${item.name} 比同分桶中位數低 ${item.discountPct?.toFixed(0)}%` });
        }
      } catch {}
    });
    es.addEventListener('rule-hit', () => { refreshAll(); });
    es.addEventListener('rules-changed', () => { refreshAll(); });
    es.addEventListener('arbitrage-sold', (e) => {
      refreshAll();
      try {
        const p = JSON.parse(e.data);
        setToast({ kind:'ok', text:`💰 ${p.name} 已離場 (掛 ${fmtNeso(p.listedPrice)}, 中位數 ${fmtNeso(p.referenceMedian)})` });
      } catch {}
    });
    es.onerror = () => setSseState('reconnecting');
    return () => es.close();
  }, [refreshAll]);

  // Initial load + periodic refresh
  useEffect(() => {
    refreshAll();
    const id = setInterval(refreshAll, 20_000);
    return () => clearInterval(id);
  }, [refreshAll]);

  // Derived: arbitrage opportunities from current live listings
  const arbitrageOps = useMemo(() => {
    return listings
      .filter(l => l.isArbitrage
        && (l.discountPct ?? 0) >= minDiscount
        // The arbitrage min-avg gate must compare against the SAME median
        // that produced discountPct (priceRefMedianNeso). Using avg7dNeso
        // here was the cause of the "29M median / -26% / 40M listing"
        // mismatch — local-sig was the real reference at ~54M.
        && ((l.priceRefMedianNeso ?? l.avg7dNeso) ?? 0) >= 10_000_000)
      .sort((a, b) => (b.discountPct ?? 0) - (a.discountPct ?? 0))
      .slice(0, 8);
  }, [listings, minDiscount]);

  // Resolve the currently selected listing — first from the live ring buffer,
  // then from the on-demand DB-hydrated cache. This lets clicks on historical
  // events (rule hits, arb purchases) populate the detail panel even when the
  // item is long gone from in-memory state.
  const selectedListing = useMemo(() => {
    if (!selectedTokenId) return null;
    return listings.find(l => l.tokenId === selectedTokenId)
      || hydratedItems[selectedTokenId]
      || null;
  }, [listings, selectedTokenId, hydratedItems]);

  // When a non-live tokenId is selected, fetch its stored row once.
  useEffect(() => {
    if (!selectedTokenId) return;
    if (listings.some(l => l.tokenId === selectedTokenId)) return;
    if (hydratedItems[selectedTokenId]) return;
    let cancelled = false;
    apiGet(`/api/listings/${selectedTokenId}`)
      .then(r => {
        if (cancelled || !r?.item) return;
        setHydratedItems(prev => ({ ...prev, [selectedTokenId]: r.item }));
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [selectedTokenId, listings, hydratedItems]);

  // Auto-select top arbitrage on first appearance for a meaningful default state
  useEffect(() => {
    if (!selectedTokenId && arbitrageOps.length > 0) {
      setSelectedTokenId(arbitrageOps[0].tokenId);
    }
  }, [arbitrageOps, selectedTokenId]);

  const dashboard = status?.dashboard || { arbitrage24h: 0, ruleHits24h: 0, listings24h: 0, listingsPerHour: 0 };

  // Toggle / delete / save handlers
  async function toggleRule(rule) {
    try {
      const updated = await apiSend('PUT', `/api/rules/${rule.id}`, { active: !rule.active });
      setRules(rs => rs.map(r => r.id === rule.id ? updated.rule : r));
    } catch (e) { setToast({ kind:'error', text:`切換失敗:${e.message}` }); }
  }
  async function deleteRule(rule) {
    if (!confirm(`確定刪除規則「${rule.name}」?`)) return;
    try {
      await apiSend('DELETE', `/api/rules/${rule.id}`);
      setRules(rs => rs.filter(r => r.id !== rule.id));
      setToast({ kind:'ok', text:`✓ 已刪除規則「${rule.name}」` });
    } catch (e) { setToast({ kind:'error', text:e.message }); }
  }
  async function saveRule(payload) {
    try {
      const saved = await apiSend('POST', '/api/rules', payload);
      setRules(rs => {
        const idx = rs.findIndex(r => r.id === saved.rule.id);
        if (idx >= 0) { const copy = rs.slice(); copy[idx] = saved.rule; return copy; }
        return [saved.rule, ...rs];
      });
      setDrawerOpen(false);
      setEditingRule(null);
      setToast({ kind:'ok', text:`✓ 已儲存規則「${saved.rule.name}」` });
    } catch (e) { setToast({ kind:'error', text:e.message }); }
  }
  function connectDiscord() {
    window.location.href = '/api/auth/discord/start';
  }
  async function logoutDiscord() {
    try {
      await apiSend('POST', '/api/auth/logout', {});
      setMe(null);
      setRules([]);
      setHits([]);
      setToast({ kind:'ok', text:'✓ 已登出 Discord' });
    } catch (e) { setToast({ kind:'error', text:e.message }); }
  }
  async function testDiscord() {
    try {
      await apiSend('POST', '/api/discord/test', {});
      setToast({ kind:'ok', text:'✓ 測試 DM 已送出 (檢查你的 Discord)' });
    } catch (e) { setToast({ kind:'error', text:`測試失敗:${e.message}` }); }
  }
  async function toggleArbitrage(optIn) {
    try {
      await apiSend('PUT', '/api/auth/me/opt-in-arbitrage', { optIn });
      setMe(m => m ? { ...m, optInArbitrage: optIn } : m);
      setToast({ kind:'ok', text: optIn ? '✓ 已開啟套利 DM' : '✗ 已關閉套利 DM' });
    } catch (e) { setToast({ kind:'error', text:e.message }); }
  }

  return (
    <div style={{ width:'100vw', height:'100vh', background:V1.bg, color:V1.text, fontFamily:V1.font, display:'flex', flexDirection:'column', overflow:'hidden' }}>
      <div style={{ position:'absolute', inset:0, backgroundImage:`linear-gradient(${V1.border} 1px, transparent 1px), linear-gradient(90deg, ${V1.border} 1px, transparent 1px)`, backgroundSize:'40px 40px', opacity:0.18, pointerEvents:'none' }}/>
      <TopBar
        status={status}
        onAddRule={()=>{ setEditingRule(null); setDrawerOpen(true); }}
        sseState={sseState}
        me={me}
        onOpenAdmin={()=>setAdminOpen(true)}
      />
      <div style={{ flex:1, padding:14, display:'grid', gap:10, gridTemplateColumns:'repeat(12, 1fr)', gridTemplateRows:'88px 1fr 360px', position:'relative', overflow:'hidden', zIndex:1 }}>
        <div style={{ gridColumn:'span 3' }}>
          <StatTile label="套利機會 (24h)" value={dashboard.arbitrage24h} sub={`≥ ${minDiscount}% 折扣`} accent spark={sparks.arb}/>
        </div>
        <div style={{ gridColumn:'span 3' }}>
          <StatTile label="套利成交 (24h)" value={dashboard.arbSold24h ?? 0} sub="已離場 / 賣出" spark={sparks.arbSold}/>
        </div>
        <div style={{ gridColumn:'span 3' }}>
          <StatTile label="規則命中 (24h)" value={dashboard.ruleHits24h ?? 0} sub={`/ 啟用中 ${rules.filter(r=>r.active).length}`} spark={sparks.rules}/>
        </div>
        <div style={{ gridColumn:'span 3' }}>
          <StatTile label="每小時上架" value={dashboard.listingsPerHour ?? '—'} sub={status?.pollIntervalMs ? `輪詢 ${status.pollIntervalMs/1000} 秒` : ''} spark={sparks.listings}/>
        </div>

        {me && me.canUseArbitrage === false ? (
          <RuleHitsExpanded hits={hits} selectedId={selectedTokenId} onSelect={(tokenId)=>setSelectedTokenId(tokenId)}/>
        ) : (
          <ArbitragePanel ops={arbitrageOps} minDiscount={minDiscount} setMinDiscount={setMinDiscount} selectedId={selectedTokenId} onSelect={(op)=>setSelectedTokenId(op.tokenId)}/>
        )}
        <WatchlistPanel rules={rules} onToggle={toggleRule} onAdd={()=>{ setEditingRule(null); setDrawerOpen(true); }} onDelete={deleteRule}/>

        <ItemDetailPanel item={selectedListing} onClose={()=>setSelectedTokenId(null)}/>
        <MarketEventsFeed hits={hits} purchases={purchases} selectedId={selectedTokenId} onSelect={setSelectedTokenId}/>
        <LiveFeedMini listings={listings} paused={paused} setPaused={setPaused} selectedId={selectedTokenId} onSelect={(it)=>setSelectedTokenId(it.tokenId)}/>
        <DiscordPanel
          me={me}
          botStatus={botStatus}
          onConnect={connectDiscord}
          onLogout={logoutDiscord}
          onTest={testDiscord}
          onToggleArbitrage={toggleArbitrage}
        />
      </div>

      <RuleDrawer
        open={drawerOpen}
        initial={editingRule}
        onClose={()=>{ setDrawerOpen(false); setEditingRule(null); }}
        onSave={saveRule}
      />
      <AdminDrawer
        open={adminOpen && !!me?.isAdmin}
        onClose={()=>setAdminOpen(false)}
        onToast={setToast}
      />
      <Toast msg={toast} onClose={()=>setToast(null)}/>
    </div>
  );
}

function trimSpark(arr) { return arr.length > 16 ? arr.slice(arr.length - 16) : arr; }

window.BentoDashboard = BentoDashboard;
