/* Shared UI bits — sidebar, topbar, sub nav, chips, buttons */

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

/* ---------- Tiny sparkline (SVG) ---------- */
const Sparkline = ({ data, w = 120, h = 32, color = "var(--p-500)", fill = "rgba(59,85,240,.1)" }) => {
  if (!data || !data.length) return null;
  const max = Math.max(...data), min = Math.min(...data);
  const rng = max - min || 1;
  const step = w / (data.length - 1);
  const pts = data.map((v, i) => [i*step, h - ((v - min) / rng) * (h - 4) - 2]);
  const path = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(" ");
  const area = `${path} L${w},${h} L0,${h} Z`;
  return (
    <svg width={w} height={h} style={{display:"block"}}>
      <path d={area} fill={fill}/>
      <path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
    </svg>
  );
};

/* ---------- Bar column chart (for dashboard main chart) ---------- */
/* Multi-series bar chart with a hover tooltip that shows every series'
   value for the focused bucket (and a colored legend chip for each). */
const BarChart = ({ series, labels, height = 220, colors = ["var(--p-500)", "var(--g-400)"], seriesLabels, currency, allowNegative = false }) => {
  const all = series.flatMap(s => s);
  const maxPos = Math.max(0, ...all);
  const minNeg = allowNegative ? Math.min(0, ...all) : 0;
  const rangeRaw = maxPos - minNeg;
  const range = rangeRaw > 0 ? rangeRaw * 1.1 : 1;
  const zeroPct = -minNeg / range; // 0..1 — where the y=0 line sits
  const grid = [0, 0.25, 0.5, 0.75, 1];
  const [hoverIdx, setHoverIdx] = React.useState(null);
  const fmt = (v) => (v == null ? "—" : (Math.round(v) >= 1000 ? `${(v/1000).toFixed(1)}K` : `${Math.round(v)}`));
  const yLabel = (g) => {
    const v = maxPos - g * range;
    return `${Math.round(v/1000)}K`;
  };
  return (
    <div style={{position:"relative", height, paddingLeft:36}}>
      {/* Y grid */}
      {grid.map(g => (
        <div key={g} style={{position:"absolute", left:36, right:0, top:g*height, height:1, background:"var(--border-subtle)"}} />
      ))}
      {grid.map(g => (
        <div key={"l"+g} style={{position:"absolute", left:0, top:g*height - 7, fontSize:10.5, color:"var(--text-tertiary)", fontVariantNumeric:"tabular-nums"}}>
          {yLabel(g)}
        </div>
      ))}
      {/* Zero line for signed charts */}
      {allowNegative && minNeg < 0 && (
        <div style={{position:"absolute", left:36, right:0, top:(1 - zeroPct) * height, height:1, background:"var(--text-tertiary)", opacity:.6}}/>
      )}
      {/* Bars */}
      <div style={{position:"absolute", inset:0, left:36, display:"flex", alignItems:"stretch"}}>
        {labels.map((lab, i) => (
          <div key={i}
            onMouseEnter={() => setHoverIdx(i)}
            onMouseLeave={() => setHoverIdx(idx => idx === i ? null : idx)}
            style={{
              flex:1, position:"relative",
              display:"flex", justifyContent:"center", gap:3, alignItems:"stretch",
              padding:"0 2px",
              background: hoverIdx === i ? "color-mix(in oklab, var(--p-500) 5%, transparent)" : "transparent",
            }}>
            {series.map((s, si) => {
              const v = s[i] || 0;
              const baseTop = (1 - zeroPct) * height;
              const barH = (Math.abs(v) / range) * height;
              const top = v >= 0 ? baseTop - barH : baseTop;
              return (
                <div key={si} style={{
                  width:8, position:"absolute",
                  top, height: barH,
                  background:colors[si], borderRadius: v >= 0 ? "3px 3px 0 0" : "0 0 3px 3px",
                  minHeight:2,
                  left: `calc(50% + ${(si - (series.length - 1)/2) * 11}px - 4px)`,
                }}/>
              );
            })}
            {/* Hover tooltip — anchored above the column */}
            {hoverIdx === i && (
              <div style={{
                position:"absolute", bottom:"calc(100% + 4px)", left:"50%", transform:"translateX(-50%)",
                background:"#0f172a", color:"#f1f5f9",
                padding:"6px 10px", borderRadius:8,
                fontSize:11, fontWeight:600, whiteSpace:"nowrap",
                boxShadow:"0 8px 20px -6px rgba(15,20,32,.35)", zIndex:5,
                pointerEvents:"none",
              }}>
                <div style={{fontSize:10, color:"#94a3b8", fontWeight:700, textTransform:"uppercase", letterSpacing:".05em", marginBottom:3}}>{lab}</div>
                {series.map((s, si) => (
                  <div key={si} style={{display:"flex", alignItems:"center", gap:6, fontVariantNumeric:"tabular-nums"}}>
                    <span style={{width:8, height:8, borderRadius:2, background:colors[si]}}/>
                    <span style={{color:"#cbd5e1"}}>{seriesLabels?.[si] || `Series ${si+1}`}</span>
                    <span style={{marginLeft:"auto", paddingLeft:14, color:"#fff", fontWeight:700}}>{currency || ""} {fmt(s[i])}</span>
                  </div>
                ))}
              </div>
            )}
          </div>
        ))}
      </div>
    </div>
  );
};

/* ---------- Donut ----------
   centerValue / centerLabel default to the legacy placeholder so existing
   callers keep rendering unchanged; new callers should pass both. */
const Donut = ({ data, size = 140, thickness = 22, centerValue = "€412K", centerLabel = "TOTAL VOLUME" }) => {
  const total = data.reduce((a,b)=>a+b.value, 0);
  const r = (size - thickness) / 2;
  const c = 2 * Math.PI * r;
  let off = 0;
  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
      <circle cx={size/2} cy={size/2} r={r} stroke="var(--n-75)" strokeWidth={thickness} fill="none"/>
      {data.map((d, i) => {
        const frac = total === 0 ? 0 : d.value / total;
        const dash = frac * c;
        const el = (
          <circle key={i} cx={size/2} cy={size/2} r={r}
            stroke={d.color} strokeWidth={thickness} fill="none"
            strokeDasharray={`${dash} ${c - dash}`}
            strokeDashoffset={-off}
            transform={`rotate(-90 ${size/2} ${size/2})`}
            strokeLinecap="butt"
          />
        );
        off += dash;
        return el;
      })}
      {centerValue != null && (
        <text x={size/2} y={size/2 - 2} textAnchor="middle" fontSize="16" fontWeight="600" fill="var(--text-primary)" fontFamily="var(--font-sans)">
          {centerValue}
        </text>
      )}
      {centerLabel != null && (
        <text x={size/2} y={size/2 + 14} textAnchor="middle" fontSize="9" fill="var(--text-tertiary)" fontFamily="var(--font-sans)" letterSpacing="0.05em">
          {centerLabel}
        </text>
      )}
    </svg>
  );
};

/* ---------- Multi-line chart (SVG) ----------
   series: [[n,n,...], ...]  labels: [lab,...]  colors: [#hex,...]
   Optional: yFormat (fn), height, legend (array of {label, color}). */
const LineChart = ({ series, labels, colors = ["var(--p-500)", "var(--g-400)", "var(--warn-500)"], height = 200, yFormat = (v) => v, legend }) => {
  const all = series.flat();
  const max = Math.max(...all, 1) * 1.08;
  const min = Math.min(0, Math.min(...all));
  const rng = max - min || 1;
  const grid = [0, 0.25, 0.5, 0.75, 1];
  const W = 100; // percentage-based inner viewBox
  const step = labels.length > 1 ? W / (labels.length - 1) : 0;
  const toY = (v) => height - ((v - min) / rng) * (height - 8) - 4;
  return (
    <div style={{position:"relative", height, paddingLeft: 42}}>
      {grid.map(g => (
        <div key={g} style={{position:"absolute", left:42, right:8, top: g*height, height:1, background:"var(--border-subtle, #eef0f5)"}} />
      ))}
      {grid.map(g => (
        <div key={"l"+g} style={{position:"absolute", left:0, top: g*height - 7, width:38, textAlign:"right", fontSize:10.5, color:"var(--text-tertiary)", fontVariantNumeric:"tabular-nums"}}>
          {yFormat(Math.round(max * (1-g) + min * g))}
        </div>
      ))}
      <svg style={{position:"absolute", top:0, left:42, right:8, bottom:0, width:"calc(100% - 50px)", height:"100%"}} viewBox={`0 0 ${W} ${height}`} preserveAspectRatio="none">
        {series.map((s, si) => {
          const path = s.map((v, i) => `${i===0?"M":"L"} ${i*step} ${toY(v)}`).join(" ");
          return <path key={si} d={path} fill="none" stroke={colors[si % colors.length]} strokeWidth="1.5" vectorEffect="non-scaling-stroke" strokeLinecap="round" strokeLinejoin="round"/>;
        })}
      </svg>
      {legend && (
        <div style={{position:"absolute", top:-24, right:8, display:"flex", gap:12, fontSize:11}}>
          {legend.map((l, i) => (
            <span key={i} style={{display:"inline-flex", alignItems:"center", gap:5}}>
              <span style={{width:10, height:2, background: l.color, borderRadius:1}}/>
              {l.label}
            </span>
          ))}
        </div>
      )}
    </div>
  );
};

/* ---------- Stacked area chart ----------
   series stacked bottom-up; matches LineChart API. */
const StackedArea = ({ series, labels, colors, height = 200, yFormat = (v) => v, legend }) => {
  const totals = labels.map((_, i) => series.reduce((a, s) => a + (s[i] || 0), 0));
  const max = Math.max(...totals, 1) * 1.04;
  const grid = [0, 0.25, 0.5, 0.75, 1];
  const W = 100;
  const step = labels.length > 1 ? W / (labels.length - 1) : 0;
  const toY = (v) => height - (v / max) * (height - 8) - 4;
  // build cumulative stacks
  const stacks = series.map(() => new Array(labels.length).fill(0));
  for (let i = 0; i < labels.length; i++) {
    let acc = 0;
    for (let si = 0; si < series.length; si++) {
      acc += series[si][i] || 0;
      stacks[si][i] = acc;
    }
  }
  return (
    <div style={{position:"relative", height, paddingLeft: 42}}>
      {grid.map(g => (
        <div key={g} style={{position:"absolute", left:42, right:8, top: g*height, height:1, background:"var(--border-subtle, #eef0f5)"}} />
      ))}
      {grid.map(g => (
        <div key={"l"+g} style={{position:"absolute", left:0, top: g*height - 7, width:38, textAlign:"right", fontSize:10.5, color:"var(--text-tertiary)", fontVariantNumeric:"tabular-nums"}}>
          {yFormat(Math.round(max * (1-g)))}
        </div>
      ))}
      <svg style={{position:"absolute", top:0, left:42, right:8, bottom:0, width:"calc(100% - 50px)", height:"100%"}} viewBox={`0 0 ${W} ${height}`} preserveAspectRatio="none">
        {stacks.map((stack, si) => {
          const below = si === 0 ? new Array(labels.length).fill(0) : stacks[si - 1];
          const topPath = stack.map((v, i) => `${i===0?"M":"L"} ${i*step} ${toY(v)}`).join(" ");
          const bottomPath = below.map((v, i) => `L ${(labels.length - 1 - i)*step} ${toY(v)}`).reverse().join(" ");
          const d = `${topPath} ${bottomPath} Z`;
          return <path key={si} d={d} fill={colors[si % colors.length]} fillOpacity="0.82" stroke={colors[si % colors.length]} strokeWidth="1" vectorEffect="non-scaling-stroke"/>;
        })}
      </svg>
      {legend && (
        <div style={{position:"absolute", top:-24, right:8, display:"flex", gap:12, fontSize:11, flexWrap:"wrap"}}>
          {legend.map((l, i) => (
            <span key={i} style={{display:"inline-flex", alignItems:"center", gap:5}}>
              <span style={{width:10, height:10, background: l.color, borderRadius:2}}/>
              {l.label}
            </span>
          ))}
        </div>
      )}
    </div>
  );
};

/* ---------- Horizontal bar list ----------
   data: [{label, value, color, sub}]  Top-N style. */
const HorizontalBar = ({ data, format = (v) => v.toLocaleString(), barColor = "var(--p-500)", maxWidth = 160 }) => {
  const max = Math.max(...data.map(d => d.value), 1);
  return (
    <div style={{display:"flex", flexDirection:"column", gap:7}}>
      {data.map((d, i) => (
        <div key={i} style={{display:"grid", gridTemplateColumns:`minmax(120px, 1fr) ${maxWidth}px 72px`, alignItems:"center", gap:10, fontSize:12.5}}>
          <span style={{color:"var(--text-primary)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap"}}>
            {d.label}
            {d.sub && <span style={{color:"var(--text-tertiary)", fontSize:11, marginLeft:6}}>{d.sub}</span>}
          </span>
          <div style={{height:8, background:"var(--n-75)", borderRadius:3, overflow:"hidden"}}>
            <div style={{height:"100%", width: `${(d.value/max)*100}%`, background: d.color || barColor, borderRadius:3}}/>
          </div>
          <span className="tnum" style={{textAlign:"right", fontWeight:600}}>{format(d.value)}</span>
        </div>
      ))}
    </div>
  );
};

/* ---------- Heatmap grid ----------
   rows: [{label, values:[n,...]}]  cols: [lab,...]
   Darker cell = higher value. colorHue defaults to blue. */
const Heatmap = ({ rows, cols, format = (v) => v, colorHue = 223, cellSize = 30, rowLabelWidth = 80 }) => {
  const allVals = rows.flatMap(r => r.values);
  const max = Math.max(...allVals, 1);
  const min = Math.min(...allVals, 0);
  const rng = max - min || 1;
  const color = (v) => {
    if (v == null) return "transparent";
    const t = (v - min) / rng;
    // light → primary saturation ramp
    return `hsl(${colorHue}, 82%, ${Math.round(94 - t*52)}%)`;
  };
  return (
    <div style={{overflowX:"auto"}}>
      <table style={{borderCollapse:"separate", borderSpacing:2, fontSize:11, fontFamily:"var(--font-sans)"}}>
        <thead>
          <tr>
            <th style={{width:rowLabelWidth}}></th>
            {cols.map((c, i) => (
              <th key={i} style={{minWidth:cellSize, fontWeight:500, color:"var(--text-tertiary)", padding:"0 2px", textAlign:"center"}}>{c}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((r, ri) => (
            <tr key={ri}>
              <td style={{fontWeight:500, color:"var(--text-secondary)", padding:"0 8px 0 0", textAlign:"right", whiteSpace:"nowrap"}}>{r.label}</td>
              {r.values.map((v, ci) => (
                <td key={ci} title={`${r.label} · ${cols[ci]}: ${format(v)}`} style={{
                  width:cellSize, height:cellSize,
                  background: color(v),
                  borderRadius:4,
                  textAlign:"center",
                  color: v != null && (v - min) / rng > 0.55 ? "#fff" : "var(--text-primary)",
                  fontWeight:500,
                  fontVariantNumeric:"tabular-nums",
                }}>
                  {v == null ? "" : format(v)}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

/* ---------- Status chip ----------
   The label, chip class, dot colour and description for each payment status.
   This lived in `window.MOCK.STATUSES`, which put presentation inside the
   mock-data object and made every screen that renders a status chip count as
   a MOCK reader. It is not data: no row in any table decides that "declined"
   is red. It is styling, and styling is legitimately static.

   `data.jsx` loads first and still exports its own copy for the screens that
   have not been wired yet; when the last of them goes, so does that copy. The
   keys are the payment status vocabulary, and the legacy aliases stay so rows
   written before the rename still render. */
const PB_STATUS_CHIPS = {
  created:    { label: "Created",    chip: "chip--neutral", dot: "var(--n-400)",    desc: "Initiated on our side" },
  pending:    { label: "Pending",    chip: "chip--warn",    dot: "var(--warn-500)", desc: "Awaiting provider" },
  declined:   { label: "Declined",   chip: "chip--err",     dot: "var(--err-500)",  desc: "Declined on our side (limits)" },
  failed:     { label: "Failed",     chip: "chip--err",     dot: "var(--err-500)",  desc: "Rejected by provider" },
  rejected:   { label: "Rejected",   chip: "chip--err",     dot: "var(--err-500)",  desc: "Rejected by operator" },
  to_confirm: { label: "To confirm", chip: "chip--info",    dot: "var(--info-500)", desc: "Needs manual confirm" },
  approved:   { label: "Approved",   chip: "chip--ok",      dot: "var(--ok-500)",   desc: "Approved by operator" },
  balanced:   { label: "Balanced",   chip: "chip--ok",      dot: "var(--ok-600)",   desc: "Auto-completed within limits" },
  completed:  { label: "Balanced",   chip: "chip--ok",      dot: "var(--ok-500)",   desc: "Auto-completed within limits" },
  review:     { label: "To confirm", chip: "chip--info",    dot: "var(--info-500)", desc: "Needs manual confirm" },
  cancelled:  { label: "Declined",   chip: "chip--neutral", dot: "var(--n-400)",    desc: "Cancelled" },
};
window.PB_STATUS_CHIPS = PB_STATUS_CHIPS;

const StatusChip = ({ status }) => {
  const s = PB_STATUS_CHIPS[status];
  if (!s) return null;
  return (
    <span className={`chip ${s.chip}`}>
      <span className="dot" style={{background: s.dot}}/>
      {s.label}
    </span>
  );
};

/* ---------- Type chip (Deposit/Withdrawal) ---------- */
const TypeChip = ({ type }) => {
  const isDep = type === "Deposit";
  return (
    <span className={`chip ${isDep ? "chip--ok" : "chip--purple"}`} style={{fontWeight:500}}>
      <Icon name={isDep ? "arrow_down" : "arrow_up"} size={10} />
      {type}
    </span>
  );
};

/* ---------- Currency symbols + mock FX (demo-only static rates, EUR base) ----------
   A brand/skin can be configured in any of the CURRENCIES supported by
   Settings' brand form (src/pages/Settings.jsx). Reporting pages need to
   show that brand's own currency by default plus a quick EUR/USD
   conversion, so both the symbol lookup and the conversion table live
   here once, shared by Money and any page that renders money. */
const CURRENCY_SYMBOLS = { EUR: "€", USD: "$", GBP: "£", JPY: "¥", INR: "₹", CAD: "C$", AUD: "A$", BRL: "R$", MXN: "$", TRY: "₺", BOB: "Bs", PYG: "₲" };
const currencySymbol = (currency) => CURRENCY_SYMBOLS[currency] || (currency ? currency + " " : "");
window.currencySymbol = currencySymbol;

/* REAL RATES, OR NO CONVERSION. There is no static table here any more.
   ------------------------------------------------------------------------
   `FX_EUR_PER_UNIT` used to be sixteen hardcoded rates — EUR 1, USD 0.92, ARS
   0.00095 — and `fxConvert` was used by the navy strip on EVERY screen, by the
   Dashboard, by Methods and by six call sites in the Betting report. Real
   balances multiplied by invented rates, rendered with a currency symbol in
   front of them.
   
   `window.FX_RATES` is EUR-per-unit and is filled in by app.jsx from
   `currency_latest_rate`. Note the inversion: the DATABASE stores units PER
   EUR (007), so a rate of 1465 for USD means 1465 USD to the euro and the
   reciprocal is what belongs here. Getting that backwards is the defect found
   twice already in the report screens, and it survives review because the base
   currency stays exactly right while everything else is orders of magnitude
   out.

   fxConvert returns NULL when it cannot convert — no rate table loaded yet, or
   a currency missing from it. It does not fall back to 1:1. A converted figure
   nobody can compute must render as "—" and not as the unconverted number
   wearing the target currency's symbol, which is the same amount claiming to
   be a different amount of money. */
window.FX_RATES = window.FX_RATES || null;

const fxConvert = (amount, from, to) => {
  const a = Number(amount);
  if (!Number.isFinite(a)) return null;
  if (!from || !to || from === to) return a;
  const t = window.FX_RATES;
  if (!t) return null;
  const rf = Number(t[from]), rt = Number(t[to]);
  if (!rf || !rt) return null;
  return (a * rf) / rt;
};
window.fxConvert = fxConvert;

/* Curated list for currency pickers (e.g. the host navy strip's display-
   currency selector) — EUR/USD/GBP first since they're the most requested,
   the rest alphabetical. A symbol and a label are not money, so they stay
   static; the RATE is money and comes from the database. A code listed here
   whose rate has not loaded converts to nothing and renders "—". */
const CURRENCY_LABELS = {
  EUR: "Euro", USD: "US Dollar", GBP: "British Pound", CAD: "Canadian Dollar",
  AUD: "Australian Dollar", BRL: "Brazilian Real", MXN: "Mexican Peso",
  INR: "Indian Rupee", JPY: "Japanese Yen", ARS: "Argentine Peso",
  THB: "Thai Baht", NGN: "Nigerian Naira", LBP: "Lebanese Pound", TRY: "Turkish Lira",
  BOB: "Bolivian Boliviano", PYG: "Paraguayan Guaraní",
};
const CURRENCY_LIST = ["EUR", "USD", "GBP", ...Object.keys(CURRENCY_LABELS).filter(c => !["EUR","USD","GBP"].includes(c)).sort()]
  .map(code => ({ code, symbol: currencySymbol(code), label: CURRENCY_LABELS[code] || code }));
window.CURRENCY_LIST = CURRENCY_LIST;

/* ---------- Money ---------- */
const Money = ({ amount, currency = "EUR", sign = false, className = "" }) => {
  const sym = currencySymbol(currency);
  const v = Math.abs(amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  return <span className={`tnum ${className}`}>{sign && amount >= 0 ? "+" : amount < 0 ? "−" : ""}{sym}{v}</span>;
};

/* Small "≈ €X · $Y" note for a KPI/headline figure denominated in a
   brand's own currency — omits whichever of EUR/USD IS that currency so
   a EUR brand only sees a USD line and vice versa. Renders nothing for
   the aggregate "Multi" pseudo-currency (All brands view), where summed
   amounts aren't a single real currency to convert from. Pass
   nativeCurrency when `currency` is itself an override (e.g. the
   Payments "view in" picker) so the brand's own currency still shows up
   as a reference point alongside EUR/USD. */
const CurrencyConversion = ({ amount, currency, nativeCurrency, divisor = 1000, suffix = "K", decimals = 1 }) => {
  if (!currency || currency === "Multi") return null;
  const targets = [...new Set(["EUR", "USD", nativeCurrency].filter(Boolean))].filter(c => c !== currency);
  if (!targets.length) return null;
  const fmt = (v) => `${v < 0 ? "-" : ""}${Math.abs(v / divisor).toFixed(decimals)}${suffix}`;
  return (
    <div style={{ fontSize: 10.5, color: "var(--text-tertiary)", marginTop: 2 }}>
      ≈ {targets.map(t => `${currencySymbol(t)}${fmt(fxConvert(amount, currency, t))}`).join(" · ")}
    </div>
  );
};
window.CurrencyConversion = CurrencyConversion;

const formatTs = (ts) => {
  const d = new Date(ts);
  const opts = { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" };
  return d.toLocaleString("en-GB", opts);
};

/* ---------- Toggle (flip switch) ----------
   Drop-in replacement for binary Enabled/Disabled or Yes/No dropdowns.
   Uncontrolled if `value` prop is omitted (falls back to useState seeded
   from `defaultValue`); otherwise controlled via `value` + `onChange`. */
const Toggle = ({ value, defaultValue = false, onChange, label, onLabel = "Enabled", offLabel = "Disabled", disabled = false, size = "md" }) => {
  const controlled = value !== undefined;
  const [inner, setInner] = useState(defaultValue);
  const on = controlled ? value : inner;
  const flip = () => {
    if (disabled) return;
    const next = !on;
    if (!controlled) setInner(next);
    onChange && onChange(next);
  };
  const w = size === "sm" ? 28 : 34;
  const h = size === "sm" ? 16 : 20;
  const k = size === "sm" ? 12 : 16;
  return (
    <label style={{display:"inline-flex", alignItems:"center", gap:10, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.55 : 1}}>
      <span role="switch" aria-checked={on} onClick={flip}
        style={{
          width: w, height: h, borderRadius: 999, position:"relative",
          background: on ? "var(--ok-500, #16a34a)" : "var(--n-400, #9ca3af)",
          transition:"background .12s", flexShrink:0,
        }}>
        <span style={{
          position:"absolute", top: (h-k)/2, left: on ? w - k - 2 : 2,
          width: k, height: k, borderRadius: 999, background:"#fff",
          transition:"left .12s", boxShadow:"0 1px 2px rgba(15,20,32,.2)",
        }}/>
      </span>
      {(label || onLabel || offLabel) && (
        <span style={{fontSize:12.5, fontWeight:500, color: on ? "var(--text-primary)" : "var(--text-tertiary)"}}>
          {label || (on ? onLabel : offLabel)}
        </span>
      )}
    </label>
  );
};

/* ---------- V2Placeholder ----------
   Dashed-border banner for features parked for a future release.
   Consistent look for every "Coming in v2" notice across the app. */
const V2Placeholder = ({ title, children, compact = false }) => (
  <div style={{
    padding: compact ? "10px 14px" : "14px 18px",
    border:"1px dashed var(--border-strong, #cbd5e1)",
    borderRadius: 8,
    background:"var(--n-25, #f8fafc)",
    display:"flex", alignItems:"flex-start", gap:10,
  }}>
    <Icon name="calendar" size={14} style={{color:"var(--text-tertiary)", marginTop: 2}}/>
    <div style={{flex:1, minWidth:0}}>
      <div style={{display:"flex", alignItems:"center", gap:8, marginBottom: children ? 4 : 0}}>
        <span style={{fontSize:9, fontWeight:700, background:"#e2e8f0", color:"#475569", padding:"2px 6px", borderRadius:999, letterSpacing:"0.05em"}}>v2</span>
        <span style={{fontSize: compact ? 12.5 : 13, fontWeight:600, color:"var(--text-primary)"}}>{title}</span>
      </div>
      {children && <div style={{fontSize:12, color:"var(--text-secondary)"}}>{children}</div>}
    </div>
  </div>
);

/* ---------- CustomRangePopover ----------
   Shared "Custom" date / duration picker. Portals to <body> via
   ReactDOM.createPortal so no parent overflow can clip it. Positioned
   relative to its visual anchor (the previous-sibling DOM node — the
   "Custom…" trigger). Smart-positioned: prefers below-and-aligned-to-
   trigger-edge, flips above if the bottom would clip, slides left/right
   if it would overshoot the viewport. */
const CustomRangePopover = ({ initial, onApply, onCancel, anchorEl }) => {
  const init = initial || {};
  const [mode, setMode] = React.useState(init.mode || "last");
  const [n, setN] = React.useState(init.n ?? 30);
  const [unit, setUnit] = React.useState(init.unit || "min");
  const toIsoLocal = (ms) => {
    if (!ms) return "";
    const d = new Date(ms);
    const pad = (x) => String(x).padStart(2, "0");
    return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
  };
  const now = Date.now();
  const [fromStr, setFromStr] = React.useState(init.startMs ? toIsoLocal(init.startMs) : toIsoLocal(now - 86400_000));
  const [toStr, setToStr]     = React.useState(init.endMs   ? toIsoLocal(init.endMs)   : toIsoLocal(now));

  // Sentinel marker rendered in-place; its previous sibling is the anchor
  // (Custom trigger). We compute the portal position from that node.
  const sentinelRef = React.useRef(null);
  const cardRef = React.useRef(null);
  const [coords, setCoords] = React.useState(null);
  const [flipUp, setFlipUp] = React.useState(false);

  const W = 380; // popover width
  const reposition = React.useCallback(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;
    // Anchor: explicit prop > previous element sibling > parent.
    const anchor = anchorEl || sentinel.previousElementSibling || sentinel.parentElement;
    if (!anchor) return;
    const r = anchor.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    // Prefer to align right edges; clamp into viewport (8px margin).
    let left = Math.min(Math.max(8, r.right - W), vw - W - 8);
    // Estimate vertical space; flip above if there isn't room below.
    const cardH = cardRef.current?.offsetHeight || 420;
    const spaceBelow = vh - r.bottom;
    const shouldFlip = spaceBelow < cardH + 16 && r.top > cardH + 16;
    setFlipUp(shouldFlip);
    const top = shouldFlip ? r.top - cardH - 8 : r.bottom + 8;
    setCoords({ top, left });
  }, [anchorEl]);

  React.useLayoutEffect(() => {
    reposition();
  }, [reposition]);

  React.useEffect(() => {
    const handle = () => reposition();
    window.addEventListener("resize", handle);
    window.addEventListener("scroll", handle, true);
    return () => {
      window.removeEventListener("resize", handle);
      window.removeEventListener("scroll", handle, true);
    };
  }, [reposition]);

  // Reposition again after the card has actually rendered (height-aware).
  React.useEffect(() => {
    const t = setTimeout(reposition, 0);
    return () => clearTimeout(t);
  }, [mode, reposition]);

  // Click-outside / Esc to close.
  React.useEffect(() => {
    const onDoc = (e) => {
      if (cardRef.current && cardRef.current.contains(e.target)) return;
      const sentinel = sentinelRef.current;
      const anchor = anchorEl || (sentinel && sentinel.previousElementSibling);
      if (anchor && anchor.contains(e.target)) return;
      onCancel && onCancel();
    };
    const onEsc = (e) => { if (e.key === "Escape") onCancel && onCancel(); };
    const t = setTimeout(() => {
      document.addEventListener("mousedown", onDoc);
      document.addEventListener("keydown", onEsc);
    }, 0);
    return () => {
      clearTimeout(t);
      document.removeEventListener("mousedown", onDoc);
      document.removeEventListener("keydown", onEsc);
    };
  }, [onCancel, anchorEl]);

  const previewWindow = (() => {
    if (mode === "last") {
      const mult = unit === "min" ? 60_000 : unit === "hour" ? 3600_000 : 86400_000;
      const ms = Math.max(1, parseInt(n, 10) || 1) * mult;
      const end = Date.now();
      return { startMs: end - ms, endMs: end, valid: ms > 0 };
    }
    const a = new Date(fromStr).getTime();
    const b = new Date(toStr).getTime();
    return { startMs: a, endMs: b, valid: !isNaN(a) && !isNaN(b) && a < b };
  })();
  const previewLabel = previewWindow.valid
    ? (() => {
        const fmtD = (d) => d.toLocaleString("en-GB", { day:"2-digit", month:"short", hour:"2-digit", minute:"2-digit" });
        return `${fmtD(new Date(previewWindow.startMs))} → ${fmtD(new Date(previewWindow.endMs))}`;
      })()
    : "Pick a valid window";

  const apply = () => {
    if (!previewWindow.valid) return;
    if (mode === "last") {
      onApply && onApply({
        startMs: previewWindow.startMs, endMs: previewWindow.endMs,
        mode:"last", n: parseInt(n,10)||1, unit,
        label: `last ${parseInt(n,10)||1}${unit==="min"?"m":unit==="hour"?"h":"d"}`,
      });
    } else {
      onApply && onApply({
        startMs: previewWindow.startMs, endMs: previewWindow.endMs,
        mode:"range", fromStr, toStr, label: previewLabel,
      });
    }
  };

  const QUICK = [
    [5,"min","5m"], [15,"min","15m"], [30,"min","30m"],
    [1,"hour","1h"], [6,"hour","6h"], [24,"hour","24h"],
    [3,"day","3d"], [7,"day","7d"], [30,"day","30d"],
  ];

  // Render an invisible sentinel where the caller mounted us, so we keep a
  // reliable anchor reference, plus the portaled card itself.
  const card = (
    <div ref={cardRef} role="dialog" aria-label="Custom range"
      onMouseDown={e => e.stopPropagation()}
      style={{
        position:"fixed", zIndex:200,
        top: coords ? coords.top : -9999,
        left: coords ? coords.left : -9999,
        width: W,
        background:"#fff",
        border:"1px solid var(--border-default)",
        borderRadius:16,
        boxShadow:"0 32px 64px -16px rgba(15,20,32,.28), 0 0 0 1px rgba(15,20,32,.04)",
        overflow:"hidden",
        opacity: coords ? 1 : 0,
        transform: coords ? "translateY(0) scale(1)" : (flipUp ? "translateY(6px) scale(.98)" : "translateY(-6px) scale(.98)"),
        transformOrigin: flipUp ? "bottom right" : "top right",
        transition: "opacity .14s ease, transform .14s ease",
      }}>
      {/* Header — gradient strip, calendar icon, close. */}
      <div style={{
        padding:"14px 16px",
        background:"linear-gradient(135deg, color-mix(in oklab, var(--p-500) 12%, white), #fff 60%)",
        borderBottom:"1px solid var(--border-subtle)",
        display:"flex", alignItems:"center", gap:12,
      }}>
        <div style={{
          width:32, height:32, borderRadius:9,
          background:"linear-gradient(135deg, var(--p-500), var(--p-700))",
          color:"#fff", display:"grid", placeItems:"center",
          boxShadow:"0 2px 6px rgba(30,64,175,.25)",
        }}>
          <Icon name="calendar" size={14}/>
        </div>
        <div style={{flex:1, minWidth:0}}>
          <div style={{fontSize:13, fontWeight:700, color:"var(--text-primary)", lineHeight:1.2}}>Custom window</div>
          <div style={{fontSize:11, color:"var(--text-tertiary)", fontWeight:500, marginTop:2}}>Pick a duration or a date range</div>
        </div>
        <button onClick={onCancel} title="Close"
          style={{width:28, height:28, padding:0, border:"none", borderRadius:7, background:"rgba(255,255,255,.6)", color:"var(--text-tertiary)", cursor:"pointer", display:"grid", placeItems:"center", transition:"background .12s"}}
          onMouseEnter={e => e.currentTarget.style.background = "var(--n-50)"}
          onMouseLeave={e => e.currentTarget.style.background = "rgba(255,255,255,.6)"}>
          <Icon name="x" size={12}/>
        </button>
      </div>

      <div style={{padding:16}}>
        {/* Mode toggle */}
        <div style={{display:"flex", gap:4, padding:4, background:"var(--n-25)", border:"1px solid var(--border-subtle)", borderRadius:10, marginBottom:16}}>
          {[
            ["last","Last N",      "clock"],
            ["range","Date range", "calendar"],
          ].map(([k,l,icon]) => (
            <button key={k} onClick={()=>setMode(k)}
              style={{
                flex:1, padding:"7px 10px", border:"none", borderRadius:7,
                fontSize:12, fontWeight:700, cursor:"pointer",
                background: mode===k ? "#fff" : "transparent",
                color: mode===k ? "var(--p-700)" : "var(--text-secondary)",
                boxShadow: mode===k ? "0 1px 3px rgba(15,20,32,.08)" : "none",
                display:"inline-flex", alignItems:"center", justifyContent:"center", gap:6,
                transition:"all .12s",
              }}>
              <Icon name={icon} size={11}/> {l}
            </button>
          ))}
        </div>

        {mode === "last" ? (
          <div>
            <div style={{fontSize:10.5, fontWeight:700, color:"var(--text-tertiary)", textTransform:"uppercase", letterSpacing:".05em", marginBottom:6}}>Duration</div>
            <div style={{display:"flex", gap:8, marginBottom:14}}>
              <input type="number" min="1" max="9999" value={n} onChange={e=>setN(e.target.value)}
                style={{
                  flex:1, padding:"10px 14px",
                  border:"1px solid var(--border-default)", borderRadius:9,
                  fontSize:15, fontWeight:700, outline:"none", fontFamily:"inherit",
                  fontVariantNumeric:"tabular-nums",
                  background:"#fff",
                }}/>
              <select value={unit} onChange={e=>setUnit(e.target.value)}
                style={{
                  padding:"10px 12px", paddingRight:30,
                  border:"1px solid var(--border-default)", borderRadius:9,
                  fontSize:13, fontWeight:600, outline:"none", background:"#fff",
                  cursor:"pointer", minWidth:112,
                }}>
                <option value="min">minutes</option>
                <option value="hour">hours</option>
                <option value="day">days</option>
              </select>
            </div>
            <div style={{fontSize:10.5, fontWeight:700, color:"var(--text-tertiary)", textTransform:"uppercase", letterSpacing:".05em", marginBottom:6}}>Quick picks</div>
            <div style={{display:"flex", gap:6, flexWrap:"wrap"}}>
              {QUICK.map(([qn, qu, lab]) => {
                const active = parseInt(n,10) === qn && unit === qu;
                return (
                  <button key={lab} onClick={()=>{setN(qn); setUnit(qu);}}
                    style={{
                      padding:"5px 12px", borderRadius:999,
                      border:"1px solid " + (active ? "var(--p-500)" : "var(--border-default)"),
                      background: active ? "var(--p-50)" : "#fff",
                      color: active ? "var(--p-700)" : "var(--text-secondary)",
                      fontSize:11.5, fontWeight:700, cursor:"pointer",
                      transition:"all .12s",
                    }}>{lab}</button>
                );
              })}
            </div>
          </div>
        ) : (
          <div style={{display:"flex", flexDirection:"column", gap:10}}>
            <div>
              <div style={{fontSize:10.5, fontWeight:700, color:"var(--text-tertiary)", textTransform:"uppercase", letterSpacing:".05em", marginBottom:6}}>From</div>
              <div style={{position:"relative"}}>
                <input type="datetime-local" value={fromStr} onChange={e=>setFromStr(e.target.value)}
                  style={{
                    width:"100%", padding:"10px 12px",
                    border:"1px solid var(--border-default)", borderRadius:9,
                    fontSize:13, fontWeight:600, outline:"none", fontFamily:"inherit",
                    background:"#fff", boxSizing:"border-box",
                  }}/>
              </div>
            </div>
            <div>
              <div style={{fontSize:10.5, fontWeight:700, color:"var(--text-tertiary)", textTransform:"uppercase", letterSpacing:".05em", marginBottom:6}}>To</div>
              <div style={{position:"relative"}}>
                <input type="datetime-local" value={toStr} onChange={e=>setToStr(e.target.value)}
                  style={{
                    width:"100%", padding:"10px 12px",
                    border:"1px solid var(--border-default)", borderRadius:9,
                    fontSize:13, fontWeight:600, outline:"none", fontFamily:"inherit",
                    background:"#fff", boxSizing:"border-box",
                  }}/>
              </div>
            </div>
            {/* Quick relative shortcuts for the range mode too. */}
            <div style={{display:"flex", gap:6, flexWrap:"wrap"}}>
              {[
                ["Today",  0, 0],
                ["Yesterday", 1, 1],
                ["Last 7d", 7, 0],
                ["Last 30d", 30, 0],
                ["This month", "month", 0],
              ].map(([lab, from, to]) => (
                <button key={lab} onClick={() => {
                  let start, end;
                  end = new Date(); end.setHours(23, 59, 0, 0);
                  if (lab === "Today") {
                    start = new Date(); start.setHours(0, 0, 0, 0);
                  } else if (lab === "Yesterday") {
                    start = new Date(); start.setDate(start.getDate() - 1); start.setHours(0,0,0,0);
                    end   = new Date(); end.setDate(end.getDate() - 1);     end.setHours(23,59,0,0);
                  } else if (lab === "This month") {
                    start = new Date(); start.setDate(1); start.setHours(0,0,0,0);
                  } else {
                    start = new Date(Date.now() - from * 86400_000);
                  }
                  setFromStr(toIsoLocal(start.getTime()));
                  setToStr(toIsoLocal(end.getTime()));
                }}
                  style={{
                    padding:"5px 12px", borderRadius:999,
                    border:"1px solid var(--border-default)",
                    background:"#fff", color:"var(--text-secondary)",
                    fontSize:11.5, fontWeight:600, cursor:"pointer",
                  }}>{lab}</button>
              ))}
            </div>
          </div>
        )}

        {/* Resolved-window preview */}
        <div style={{
          marginTop:14, padding:"10px 12px",
          background: previewWindow.valid ? "var(--p-50)" : "var(--err-50, #fef2f2)",
          border: "1px solid " + (previewWindow.valid ? "color-mix(in oklab, var(--p-500) 22%, transparent)" : "var(--err-200, #fecaca)"),
          borderRadius:9,
          fontSize:12, fontWeight:600,
          color: previewWindow.valid ? "var(--p-700)" : "var(--err-700, #991b1b)",
          display:"flex", alignItems:"center", gap:8,
        }}>
          <Icon name={previewWindow.valid ? "check" : "alert"} size={12}/>
          <span style={{flex:1, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap"}}>{previewLabel}</span>
        </div>
      </div>

      {/* Footer */}
      <div style={{
        padding:"12px 16px",
        borderTop:"1px solid var(--border-subtle)",
        background:"var(--n-25)",
        display:"flex", gap:8, justifyContent:"flex-end",
      }}>
        <button onClick={onCancel}
          style={{
            padding:"8px 16px",
            border:"1px solid var(--border-default)", borderRadius:9,
            background:"#fff", fontSize:12.5, fontWeight:600,
            color:"var(--text-secondary)", cursor:"pointer",
          }}>Cancel</button>
        <button onClick={apply} disabled={!previewWindow.valid}
          style={{
            padding:"8px 18px",
            border:"none", borderRadius:9,
            background: previewWindow.valid ? "var(--p-500)" : "var(--n-200, #cbd5e1)",
            color:"#fff", fontSize:12.5, fontWeight:700,
            cursor: previewWindow.valid ? "pointer" : "not-allowed",
            display:"inline-flex", alignItems:"center", gap:6,
            boxShadow: previewWindow.valid ? "0 4px 10px -2px rgba(30,64,175,.3)" : "none",
          }}>
          <Icon name="check" size={11}/> Apply
        </button>
      </div>
    </div>
  );

  // Sentinel stays where the consumer mounted us (so we can find the
  // anchor). The card portals to <body>.
  return (
    <>
      <span ref={sentinelRef} style={{display:"none"}}/>
      {ReactDOM.createPortal(card, document.body)}
    </>
  );
};

/* ---------- CopyableId ----------
   Renders a monospaced ID with a small copy-to-clipboard button that
   appears on hover. Flashes a check icon for ~1.2s after a successful
   copy. Keeps text selection working since the button has its own click
   target. */
const CopyableId = ({ value, display, className, style, color }) => {
  const [copied, setCopied] = React.useState(false);
  if (value == null) return null;
  const copy = (e) => {
    e.stopPropagation();
    e.preventDefault();
    const text = String(value);
    const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1200); };
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(text).then(done).catch(() => {
        // Fallback for older / locked-down environments.
        const ta = document.createElement("textarea");
        ta.value = text; ta.setAttribute("readonly", "");
        ta.style.position = "fixed"; ta.style.opacity = "0";
        document.body.appendChild(ta); ta.select();
        try { document.execCommand("copy"); done(); } finally { document.body.removeChild(ta); }
      });
    } else {
      const ta = document.createElement("textarea");
      ta.value = text; document.body.appendChild(ta); ta.select();
      try { document.execCommand("copy"); done(); } finally { document.body.removeChild(ta); }
    }
  };
  return (
    <span className={"copy-id mono" + (className ? " " + className : "")} style={style}>
      <span style={{color: color || "var(--p-700)", fontWeight: 600}}>{display ?? String(value)}</span>
      <button type="button" className={"copy-id__btn" + (copied ? " copied" : "")}
        title={copied ? "Copied" : "Copy"} onClick={copy}>
        <Icon name={copied ? "check" : "copy"} size={10}/>
      </button>
    </span>
  );
};

/* ---------- Provider lookup ----------
   Resolves the PSP(s) handling a given brand × method. Tries the live
   ROUTES config first (Settings → Routes & cascading), falls back to the
   PSP_PROFILES directory (which PSPs declare support for the method).
   Returns { primary, chain: [{id, name}], source } or null. */
const PSP_DISPLAY_NAMES = {
  stripe:"Stripe", adyen:"Adyen", trustly:"Trustly", skrillpsp:"Skrill PSP",
  skrill:"Skrill", coinify:"Coinify", paysafe:"Paysafe", worldpay:"Worldpay",
  checkout:"Checkout", braintree:"Braintree",
};
const prettyPsp = (id) => PSP_DISPLAY_NAMES[id] || (id && id[0].toUpperCase() + id.slice(1));

window.getAllProviders = function () {
  const out = new Map();
  for (const p of (window.MOCK_PSP_PROFILES || [])) {
    out.set(p.id, { id: p.id, name: p.name || prettyPsp(p.id) });
  }
  for (const r of (window.MOCK_ROUTES || [])) {
    for (const id of (r.chain || [])) {
      if (!out.has(id)) out.set(id, { id, name: prettyPsp(id) });
    }
  }
  return Array.from(out.values()).sort((a, b) => a.name.localeCompare(b.name));
};

window.lookupMethodProvider = function ({ brand, methodId, methodName } = {}) {
  const ROUTES = window.MOCK_ROUTES || [];
  const PSPS = window.MOCK_PSP_PROFILES || [];
  const brandFields = brand ? [brand.name, brand.short, brand.id].filter(Boolean).map(s => String(s).toLowerCase()) : [];
  const matchesBrand = (rBrand) => {
    if (!rBrand || rBrand === "any") return true;
    const rl = String(rBrand).toLowerCase();
    return brandFields.some(b => b === rl || b.includes(rl) || rl.includes(b));
  };
  const matchesMethod = (rMethod) => {
    if (!rMethod) return false;
    const rl = String(rMethod).toLowerCase();
    const targets = [methodName, methodId].filter(Boolean).map(s => String(s).toLowerCase());
    return targets.some(t => t === rl || t.replace(/\s+/g, "") === rl.replace(/\s+/g, ""));
  };
  // Prefer the most specific route (non-"any" brand wins).
  const candidates = ROUTES.filter(r => matchesMethod(r.method) && matchesBrand(r.brand));
  candidates.sort((a, b) => (a.brand === "any" ? 1 : 0) - (b.brand === "any" ? 1 : 0));
  const route = candidates[0];
  if (route && route.chain?.length) {
    return {
      primary: route.chain[0],
      primaryName: prettyPsp(route.chain[0]),
      chain: route.chain.map(id => ({ id, name: prettyPsp(id) })),
      source: "route",
      routeId: route.id,
      status: route.status,
    };
  }
  const supporting = PSPS.filter(p => (p.methods || []).includes(methodId));
  if (supporting.length) {
    return {
      primary: supporting[0].id,
      primaryName: supporting[0].name || prettyPsp(supporting[0].id),
      chain: supporting.map(p => ({ id: p.id, name: p.name || prettyPsp(p.id) })),
      source: "psp",
    };
  }
  return null;
};

/* Tip — small inline "i" help badge. The popover is portaled to body and
   positioned in viewport coordinates so an ancestor's overflow or fixed
   modal stacking can never clip it. Flips below the badge automatically
   when there isn't enough room above, repositions on scroll / resize. */
const Tip = ({ children, size = 13 }) => {
  const [open, setOpen] = useState(false);
  const [coords, setCoords] = useState(null);
  const badgeRef = useRef(null);

  const reposition = useCallback(() => {
    const el = badgeRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const TIP_W = 300, TIP_H_GUESS = 110, M = 8;
    const vw = window.innerWidth, vh = window.innerHeight;
    const placement = rect.top < TIP_H_GUESS + M ? "bottom" : "top";
    const top = placement === "top" ? rect.top - M : rect.bottom + M;
    const centerX = rect.left + rect.width / 2;
    const halfW = TIP_W / 2;
    let left = centerX;
    if (left - halfW < M) left = halfW + M;
    if (left + halfW > vw - M) left = vw - M - halfW;
    setCoords({ left, top, placement, badgeCenterX: centerX });
  }, []);

  useEffect(() => {
    if (!open) return;
    reposition();
    const onScroll = () => reposition();
    const onResize = () => reposition();
    window.addEventListener("scroll", onScroll, true);
    window.addEventListener("resize", onResize);
    return () => {
      window.removeEventListener("scroll", onScroll, true);
      window.removeEventListener("resize", onResize);
    };
  }, [open, reposition]);

  return (
    <span ref={badgeRef}
      onMouseEnter={()=>setOpen(true)} onMouseLeave={()=>setOpen(false)}
      onFocus={()=>setOpen(true)} onBlur={()=>setOpen(false)}
      tabIndex={0}
      style={{position:"relative", marginLeft:6, display:"inline-flex", alignItems:"center", justifyContent:"center",
        width:size, height:size, borderRadius:999,
        background: open ? "var(--primary, #1e40af)" : "var(--n-75)",
        color: open ? "#fff" : "var(--text-tertiary)",
        fontSize: Math.max(8, size - 4), fontWeight:800, cursor:"help", userSelect:"none",
        verticalAlign:"middle", outline:"none", transition:"background .12s",
        fontFamily:"Georgia, 'Times New Roman', serif", fontStyle:"italic", lineHeight:1,
      }}>
      i
      {open && coords && ReactDOM.createPortal(
        <div style={{
          position:"fixed",
          left: coords.left, top: coords.top,
          transform: coords.placement === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
          width: 300, maxWidth:"min(300px, calc(100vw - 16px))",
          padding:"10px 12px", borderRadius:8,
          background:"#0f172a", color:"#f1f5f9",
          fontSize:12, fontWeight:500, lineHeight:1.55,
          letterSpacing:0, textTransform:"none",
          boxShadow:"0 18px 36px -10px rgba(15,20,32,.55), 0 0 0 1px rgba(255,255,255,.04)",
          zIndex:10000, pointerEvents:"none", textAlign:"left", whiteSpace:"normal",
        }}>
          {children}
          <span style={{
            position:"absolute",
            left: Math.max(8, Math.min(292, (coords.badgeCenterX || coords.left) - coords.left + 150)) - 5,
            ...(coords.placement === "top"
              ? { top:"100%", borderStyle:"solid", borderWidth:"6px 5px 0 5px", borderColor:"#0f172a transparent transparent transparent" }
              : { bottom:"100%", borderStyle:"solid", borderWidth:"0 5px 6px 5px", borderColor:"transparent transparent #0f172a transparent" }),
            width:0, height:0,
          }}/>
        </div>,
        document.body
      )}
    </span>
  );
};

/* Explainer — the "What this is, in plain English" panel used at the top
   of major pages and tabs. Soft primary tint, info icon on the left, free
   children on the right. Pass `bullets` for a short list under the lead. */
const Explainer = ({ title = "What this is, in plain English", children, bullets, compact = false }) => (
  <div style={{
    marginBottom: compact ? 12 : 18,
    padding: compact ? "10px 12px" : "14px 16px",
    borderRadius:10,
    border:"1px solid color-mix(in oklab, var(--primary, #1e40af) 18%, transparent)",
    background:"color-mix(in oklab, var(--primary, #1e40af) 4%, white)",
    display:"grid", gridTemplateColumns:"32px 1fr", gap:12, alignItems:"start",
  }}>
    <div style={{width:32, height:32, borderRadius:8, background:"var(--primary, #1e40af)", color:"#fff",
      display:"grid", placeItems:"center", flexShrink:0}}>
      <Icon name="info" size={14}/>
    </div>
    <div>
      <div style={{fontWeight:700, fontSize:13.5, marginBottom:4, color:"var(--text-primary)"}}>{title}</div>
      <div style={{fontSize:12.5, color:"var(--text-secondary)", lineHeight:1.55}}>{children}</div>
      {bullets && bullets.length > 0 && (
        <ul style={{fontSize:12, color:"var(--text-secondary)", lineHeight:1.7, marginTop:8, marginBottom:0, paddingLeft:18}}>
          {bullets.map((b, i) => <li key={i}>{b}</li>)}
        </ul>
      )}
    </div>
  </div>
);

/* goRoute — the canonical SPA cross-link.

   Pushes a registered route's path and lets app.jsx's popstate handler
   resolve it, the same mechanism browser Back/Forward uses. Returns false
   (navigating nowhere) when the route id has no prototype page, so callers
   can fall back honestly instead of pretending the jump happened.

   Several screens grew private copies of this (hdNavTo, hrnwNavTo, …) before
   it was shared; this is the one new code should use. */
const goRoute = (routeId) => {
  try {
    const path = window.pathForActive && window.pathForActive(routeId);
    if (!path) return false;
    if (window.location.pathname !== path) window.history.pushState({ active: routeId }, "", path);
    window.dispatchEvent(new PopStateEvent("popstate"));
    return true;
  } catch (_e) { return false; }
};

/* NoBackend — the canonical "this control needs a server" affordance.

   A prototype button that fires a toast saying the real thing "runs in the
   admin build" is worse than a dead button: it implies work happened. The
   honest form is a control that renders DISABLED and names the endpoint an
   engineer still has to wire. HostPlayers.jsx and HostUsers.jsx each grew a
   local copy of this during the first honesty pass; this is the shared one
   for every screen after it.

   The tooltip hangs off the wrapper span deliberately — browsers suppress
   `title` on a disabled control, so the hint needs an enabled ancestor. */
const NoBackend = ({ need, what, className = "", children, style, block, title }) => (
  <span
    className="nobackend"
    style={{ display: block ? "block" : "inline-flex", cursor: "not-allowed" }}
    title={title || `${what ? what + " \u2014 " : ""}not wired in this prototype \u00b7 requires backend: ${need}`}
  >
    <button type="button" className={className} disabled aria-disabled="true"
      style={{ opacity: .45, cursor: "not-allowed", pointerEvents: "none", ...(style || {}) }}>
      {children}
    </button>
  </span>
);

/* ====================================================================
   Wave 0 shared primitives — PayBO surface (design-alignment plan §4).
   CSS for all of these lives in styles/tokens.css ("Wave 0" section).
   ==================================================================== */

/* ChartTip — the shared dark chart tooltip (plan §4.6).
   Usage: <ChartTip title={label} rows={[{ swatch:"var(--chart-1)", label:"Deposits", value:"€1.2K" }]} />
   Render it inside a position:relative hover target; the class anchors it
   centered above (override via `style` for other placements). Replaces the
   hand-rolled #0f172a tooltips in Dashboard/ui charts. */
const ChartTip = ({ title, rows = [], style, className = "" }) => (
  <div className={`charttip ${className}`.trim()} style={style}>
    {title != null && <div className="charttip__title">{title}</div>}
    {rows.map((r, i) => (
      <div key={i} className="charttip__row">
        {r.swatch && <span className="charttip__swatch" style={{ background: r.swatch }} />}
        <span className="charttip__label">{r.label}</span>
        <span className="charttip__value">{r.value}</span>
      </div>
    ))}
  </div>
);

/* PillTabs — the canonical pill tab strip (plan §4.5; hsk-tab shape).
   Usage: <PillTabs tabs={[{ key:"all", label:"All", count:12 }]} active={tab} onChange={setTab} />
   tabs: [{ key, label, count?, icon?, dot?, pending?, href?, title? }].
   href mode renders a real <a> (full reload semantics — HostReportDaily's
   ?tab= pattern); otherwise buttons call onChange(key). Replaces hp-tabs,
   hccp-tabs, hrdy-tabs, bw-tab, hsl-tab, bare .tabs, Transactions' strip. */
const PillTabs = ({ tabs = [], active, onChange, className = "", ariaLabel }) => (
  <div className={`ptabs ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
    {tabs.map(t => {
      const cls = `ptab${t.key === active ? " is-on" : ""}${t.pending ? " is-pending" : ""}`;
      const inner = (
        <>
          {t.icon && <Icon name={t.icon} size={12} />}
          {t.label}
          {t.count != null && <span className="ptab__count">{t.count}</span>}
          {t.dot && <span className="ptab__dot" />}
        </>
      );
      return t.href ? (
        <a key={t.key} className={cls} href={t.href} title={t.title} role="tab" aria-selected={t.key === active}>{inner}</a>
      ) : (
        <button key={t.key} type="button" className={cls} title={t.title} role="tab" aria-selected={t.key === active}
          onClick={() => onChange && onChange(t.key)}>{inner}</button>
      );
    })}
  </div>
);

/* PbFilterPill — clearable active-filter pill (plan §4.4).
   Usage: <PbFilterPill label="Status: Pending" onClear={() => clear("status")} />
   The classed version of Transactions' inline FilterPill; PbFilterBar
   renders a row of these automatically. */
const PbFilterPill = ({ label, onClear }) => (
  <span className="pbf-pill">
    {label}
    <button type="button" className="pbf-pill__x" onClick={onClear} title="Remove">
      <Icon name="x" size={9} />
    </button>
  </span>
);

/* ---------- PbFilterBar field plumbing (mirrors report-shell's hrsField*
   helpers; duplicated under pbf* names because ui.jsx must not depend on
   report-shell.jsx, which loads after it) ---------- */
const pbfOpts = (options) => (options || []).map(o => (typeof o === "object" && o !== null) ? o : { value: o, label: String(o) });
const pbfFieldDefault = (f) => (f.defaultValue !== undefined ? f.defaultValue : "");
const pbfFieldActive = (f, v) => {
  const norm = (x) => (x == null ? "" : String(x));
  return norm(v) !== norm(pbfFieldDefault(f));
};
const pbfPillLabel = (f, v) => {
  if (f.type === "select") {
    const hit = pbfOpts(f.options).find(o => String(o.value) === String(v));
    return `${f.label}: ${hit ? hit.label : v}`;
  }
  return `${f.label}: ${v}`;
};

/* PbFilterBar — the PayBO hero filter strip, componentized (plan §4.4).
   The filter-hero__card shape Transactions hand-rolls, plus the active-
   filter pill row with "Clear all", with draft→applied semantics baked in.
   Adopters: Transactions, PlayersList, Config, Activity, Methods. (Host
   screens use HrsFilters instead.)

   Usage:
     <PbFilterBar fields={FIELDS} values={draft}
                  onChange={(k,v) => setDraft(d => ({ ...d, [k]:v }))}
                  onSearch={(v) => setApplied(v)}      // renders Search; draft→applied
                  onReset={() => { setDraft(DEFAULTS); setApplied(DEFAULTS); }}
                  resultLabel={rows.length} resultSub="transactions" />

   - fields: [{ key, label, type, options?, placeholder?, icon?, tip?,
       clearable?, width?, hidden?, defaultValue?, render? }]
     type ∈ "text" | "search" | "number" | "select" | "custom".
     "search" gets the highlighted --search card + a clear button.
     custom: render(value, setValue, field) → node.
   - values: { [key]: value } — fully controlled by the consumer (keep a
     separate draft/applied pair; commit the object onSearch passes).
   - onSearch(values): renders the Search button (apply-on-Search canon
     §2.3 — no live-apply, no per-keystroke fetches). Clearing a pill also
     calls onSearch with the patched values so removal applies at once.
     Omit for documented live-filter screens.
   - onReset(): renders Reset; also backs "Clear all".
   - resultLabel/resultSub: trailing Results card (filter-hero__value). */
const PbFilterBar = ({ fields = [], values = {}, onChange, onSearch, onReset, resultLabel, resultSub, children, className = "" }) => {
  const vis = fields.filter(f => !f.hidden);
  const activeFields = vis.filter(f => pbfFieldActive(f, values[f.key]));
  const set = (k, v) => onChange && onChange(k, v);
  const clearField = (f) => {
    const dv = pbfFieldDefault(f);
    set(f.key, dv);
    if (onSearch) onSearch({ ...values, [f.key]: dv });
  };
  const clearAll = () => {
    if (onReset) { onReset(); return; }
    vis.forEach(f => set(f.key, pbfFieldDefault(f)));
  };
  const control = (f) => {
    const v = values[f.key];
    if (f.type === "custom" && f.render) return f.render(v, (nv) => set(f.key, nv), f);
    if (f.type === "select") return (
      <select className="filter-hero__select" value={v == null ? "" : v} onChange={e => set(f.key, e.target.value)}>
        {f.placeholder != null && <option value="">{f.placeholder}</option>}
        {pbfOpts(f.options).map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
      </select>
    );
    return (
      <input className="filter-hero__input" inputMode={f.type === "number" ? "decimal" : undefined}
        placeholder={f.placeholder} value={v == null ? "" : v}
        onChange={e => set(f.key, e.target.value)}
        onKeyDown={onSearch ? (e) => { if (e.key === "Enter") onSearch(values); } : undefined} />
    );
  };
  return (
    <>
      <div className={`filter-hero pbf ${className}`.trim()}>
        {vis.map(f => (
          <div key={f.key}
            className={`filter-hero__card${f.type === "search" ? " filter-hero__card--search" : ""}`}
            style={{ position: "relative", ...(f.width ? { flexBasis: f.width, minWidth: f.width } : {}) }}>
            <div className="filter-hero__label">
              {f.icon && <Icon name={f.icon} size={11} />}{f.label}
              {f.tip && <Tip size={12}>{f.tip}</Tip>}
            </div>
            {control(f)}
            {(f.clearable || f.type === "search") && pbfFieldActive(f, values[f.key]) && (
              <button type="button" className="filter-hero__clear" title={`Clear ${f.label}`} onClick={() => clearField(f)}>
                <Icon name="x" size={9} />
              </button>
            )}
          </div>
        ))}
        {children}
        {resultLabel != null && (
          <div className="filter-hero__card filter-hero__card--result">
            <div className="filter-hero__label"><Icon name="chart" size={11} /> Results</div>
            <div className="filter-hero__value">
              {resultLabel}
              {resultSub != null && <span className="filter-hero__value-sub">{resultSub}</span>}
            </div>
          </div>
        )}
        {(onSearch || onReset) && (
          <div className="pbf-btns">
            {onSearch && <button type="button" className="btn btn--primary" onClick={() => onSearch(values)}><Icon name="search" size={13} /> Search</button>}
            {onReset && <button type="button" className="btn btn--secondary" onClick={() => onReset()}>Reset</button>}
          </div>
        )}
      </div>
      {activeFields.length > 0 && (
        <div className="pbf-pills">
          <span className="pbf-pills__lab">Active:</span>
          {activeFields.map(f => (
            <PbFilterPill key={f.key} label={pbfPillLabel(f, values[f.key])} onClear={() => clearField(f)} />
          ))}
          <button type="button" className="pbf-clearall" onClick={clearAll}>Clear all</button>
        </div>
      )}
    </>
  );
};

window.ChartTip = ChartTip;
window.PillTabs = PillTabs;
window.PbFilterPill = PbFilterPill;
window.PbFilterBar = PbFilterBar;

window.Tip = Tip;
window.Explainer = Explainer;

window.Toggle = Toggle;
window.CopyableId = CopyableId;
window.V2Placeholder = V2Placeholder;
window.Sparkline = Sparkline;
window.BarChart = BarChart;
window.Donut = Donut;
window.LineChart = LineChart;
window.StackedArea = StackedArea;
window.HorizontalBar = HorizontalBar;
window.Heatmap = Heatmap;
window.CustomRangePopover = CustomRangePopover;
window.StatusChip = StatusChip;
window.TypeChip = TypeChip;
window.Money = Money;
window.formatTs = formatTs;
window.NoBackend = NoBackend;
window.goRoute = goRoute;
