/* global React, ReactDOM, POOLS, formatContractError, I, Mono,
   Landing, PoolList, PoolDetail, CreatePool, AllowlistManager, Emergency,
   useTweaks, TweaksPanel, TweakSection, TweakRadio, TweakButton,
   useApiHealth */

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

// -------------------------------------------------------------------------
// Network context + provider
// -------------------------------------------------------------------------
//
// Holds the active network id, exposes the resolved cfg, and runs the switch
// state machine (idle -> blocked / switching -> idle) per design section 3.
// Pending-toast count is read via a registered getter so the provider does
// not own the toasts state -- App keeps owning it and registers the counter
// on each render so switchTo() can gate on the latest count.

const NetworkContext = createContext(null);

function loadStoredId() {
  const NC = window.NetworkConfig;
  if (!NC) return "testnet";
  try {
    const raw = localStorage.getItem(NC.STORAGE_KEY);
    if (raw === "testnet" || raw === "mainnet") return raw;
    if (raw == null) return NC.DEFAULT;
    // Garbage value -- self-heal by removing the bad key.
    try { localStorage.removeItem(NC.STORAGE_KEY); } catch (_) {}
    return NC.DEFAULT;
  } catch (_) {
    return NC.DEFAULT;
  }
}

function NetworkProvider({ children }) {
  const NC = window.NetworkConfig;
  const [id, setId] = useState(loadStoredId);
  const [switchState, setSwitchState] = useState("idle");
  const pendingCounterRef = useRef(() => 0);

  const cfg = useMemo(() => NC.get(id), [id]);

  // Mirror cfg into window.* whenever id changes so legacy callers see the
  // active network. The initial mirror happens in data.jsx at module load;
  // this effect handles all subsequent switches.
  useEffect(() => {
    if (typeof window.setNetworkGlobals === "function") {
      window.setNetworkGlobals(cfg);
    }
  }, [cfg.id]);

  const registerPendingCounter = useCallback((fn) => {
    pendingCounterRef.current = typeof fn === "function" ? fn : () => 0;
  }, []);

  const switchTo = (targetId) => {
    if (targetId === id) {
      if (switchState !== "idle") setSwitchState("idle");
      return;
    }
    const pending = pendingCounterRef.current();
    if (pending > 0) {
      setSwitchState("blocked");
      return;
    }
    setSwitchState("switching");
    // Mirror globals BEFORE setId so any synchronous reader (api.jsx helpers,
    // etc.) sees the new cfg atomically with the React re-render.
    if (typeof window.setNetworkGlobals === "function") {
      window.setNetworkGlobals(NC.get(targetId));
    }
    try { localStorage.setItem(NC.STORAGE_KEY, targetId); } catch (_) {}
    setId(targetId);
    setSwitchState("idle");
  };

  const clearBlocked = () => {
    setSwitchState((s) => s === "blocked" ? "idle" : s);
  };

  const value = {
    ...cfg,
    switchTo,
    switchState,
    registerPendingCounter,
    clearBlocked,
  };

  return <NetworkContext.Provider value={value}>{children}</NetworkContext.Provider>;
}

function useNetwork() {
  const v = useContext(NetworkContext);
  if (!v) throw new Error("useNetwork() must be used inside <NetworkProvider>");
  return v;
}

// Small inline warning glyph reused by the banner and the modal. Stroke-
// only triangle + exclamation + dot. No icon dependency.
function WarnIcon({ color = "var(--orange)", size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
         stroke={color} strokeWidth="2.2"
         strokeLinecap="round" strokeLinejoin="round"
         aria-hidden="true">
      <path d="M12 2 L22 20 L2 20 Z"/>
      <line x1="12" y1="9" x2="12" y2="14"/>
      <circle cx="12" cy="17.5" r="0.6" fill={color}/>
    </svg>
  );
}

// -------------------------------------------------------------------------
// Tx-success pub-sub bus
// -------------------------------------------------------------------------
//
// Brute-force refresh trigger: TxToast emits { kind: "tx-success", ... }
// whenever a tracked tx flips to success. PoolList subscribes so the live
// pool list refreshes after ANY confirmed tx (create-pool, swap, burn,
// etc). No filtering by tx kind -- the refetch cost is one events fetch
// plus N get-pool reads, acceptable for the demo cadence.

const TxBusContext = createContext(null);

function TxBusProvider({ children }) {
  const subsRef = useRef(new Set());
  const value = useMemo(() => ({
    subscribe: (fn) => {
      subsRef.current.add(fn);
      return () => { subsRef.current.delete(fn); };
    },
    emit: (event) => {
      // Snapshot to avoid mutation-during-iteration if a handler
      // unsubscribes itself.
      const snapshot = Array.from(subsRef.current);
      for (const fn of snapshot) {
        try { fn(event); } catch (e) {
          console.warn("TxBus handler threw:", e);
        }
      }
    },
  }), []);
  return <TxBusContext.Provider value={value}>{children}</TxBusContext.Provider>;
}

function useTxBus() {
  const v = useContext(TxBusContext);
  if (!v) throw new Error("useTxBus() must be used inside <TxBusProvider>");
  return v;
}

// Convenience hook: subscribe to tx-success events. Handler is stored in
// a ref so the subscription does not churn when the handler reference
// changes between renders. Returns nothing; cleanup happens on unmount.
function useOnTxSuccess(handler) {
  const bus = useTxBus();
  const ref = useRef(handler);
  useEffect(() => { ref.current = handler; }, [handler]);
  useEffect(() => {
    return bus.subscribe((ev) => {
      if (ev && ev.kind === "tx-success") {
        ref.current(ev);
      }
    });
  }, [bus]);
}

window.useOnTxSuccess = useOnTxSuccess;

function netDotColor(color) {
  return color === "green" ? "var(--green)" : "var(--orange)";
}

// -------------------------------------------------------------------------
// NetworkSwitcher dropdown (replaces the static net-pill in Topbar).
// -------------------------------------------------------------------------
function NetworkSwitcher({ pendingCount, wallet }) {
  const net = useNetwork();
  const [open, setOpen] = useState(false);

  const toggle = () => {
    // Closing the dropdown also clears any "blocked" hint so the next open
    // starts from a clean state.
    if (open && net.switchState === "blocked") net.clearBlocked();
    setOpen(!open);
  };

  const onPick = async (targetId, disabled) => {
    if (disabled) return;
    if (targetId !== net.id) {
      // Detect against the TARGET cfg, not the current one -- the question
      // is whether the wallet would land on the wrong network AFTER the
      // switch goes through.
      const targetCfg = window.NetworkConfig.get(targetId);
      const m = window.detectWalletMismatch(targetCfg, wallet);
      if (m) {
        try {
          await window.showWalletMismatchModal({
            kind:          "pre-switch",
            walletNetwork: m.walletNetwork,
            appNetwork:    m.appNetwork,
          });
        } catch (_) {
          // User cancelled the switch.
          setOpen(false);
          return;
        }
      }
    }
    net.switchTo(targetId);
    if (targetId !== net.id) {
      // switchTo may have entered "blocked"; keep the dropdown open so the
      // user can read the hint. Otherwise close it on a successful switch.
      setTimeout(() => setOpen(false), 0);
    } else {
      setOpen(false);
    }
  };

  return (
    <div style={{ position: "relative" }}>
      <span
        className="net-pill"
        role="button"
        tabIndex={0}
        onClick={toggle}
        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }}
        style={{ cursor: "pointer", userSelect: "none" }}
        title="Switch active network"
      >
        <span className="dot" style={{ background: netDotColor(net.color) }}/>
        {net.label}
        <span className="dim" style={{ marginLeft: 6, fontSize: 10 }}>{open ? "^" : "v"}</span>
      </span>
      {open && (
        <div
          className="card"
          style={{ position: "absolute", right: 0, top: "calc(100% + 8px)", padding: 8, width: 260, zIndex: 50 }}
        >
          {window.NetworkConfig.list.map((nid) => {
            const ncfg = window.NetworkConfig.get(nid);
            const selected = nid === net.id;
            const disabled = !selected && net.switchState === "blocked";
            return (
              <div
                key={nid}
                onClick={() => onPick(nid, disabled)}
                style={{
                  display: "flex", alignItems: "center", gap: 8,
                  padding: "6px 6px",
                  borderRadius: 4,
                  cursor: disabled ? "not-allowed" : "pointer",
                  opacity: disabled ? 0.55 : 1,
                  textDecoration: disabled ? "line-through" : "none",
                  background: selected ? "rgba(255,255,255,0.04)" : "transparent",
                }}
              >
                <span style={{
                  display: "inline-block", width: 12, height: 12, borderRadius: "50%",
                  border: "1.4px solid var(--ink-3)",
                  background: selected ? "var(--ink-1)" : "transparent",
                  flexShrink: 0,
                }}/>
                <span className="dot" style={{ background: netDotColor(ncfg.color) }}/>
                <span style={{ flex: 1 }}>{ncfg.label}</span>
                {disabled && (
                  <span className="dim" style={{ fontSize: 10 }}>(cannot, pending)</span>
                )}
              </div>
            );
          })}
          <div className="hr" style={{ margin: "6px 0" }}/>
          <div className="dim" style={{ fontSize: 11, padding: "2px 4px", lineHeight: 1.4 }}>
            {net.switchState === "blocked"
              ? `${pendingCount} pending tx. Wait or dismiss before switching network.`
              : "Switches the entire app."}
          </div>
        </div>
      )}
    </div>
  );
}

// -------------------------------------------------------------------------
// Wallet / network mismatch banner.
// -------------------------------------------------------------------------
//
// Visible iff wallet is connected AND the wallet's STX address prefix
// disagrees with cfg.addressPrefix (e.g. an "ST..." address while the app
// is set to mainnet). Banner offers a Re-connect CTA that disconnects then
// re-connects so the wallet pops its native UI for the user to choose the
// right network there.
function NetworkMismatchBanner({ wallet }) {
  const net = useNetwork();
  if (!wallet || !wallet.connected) return null;
  const addr = wallet.fullAddress || wallet.address || "";
  if (!addr) return null;
  const prefix = addr.slice(0, 2);
  // Only act on real Stacks principals; skip mock/synthetic addresses.
  if (prefix !== "ST" && prefix !== "SP") return null;
  if (prefix === net.addressPrefix) return null;
  const walletLabel = prefix === "SP" ? "Mainnet" : "Testnet";

  const reconnect = async () => {
    try { if (wallet.disconnect) wallet.disconnect(); } catch (_) {}
    try { if (wallet.connect) await wallet.connect(); } catch (_) {}
  };

  return (
    <div
      role="alert"
      style={{
        borderLeft: "3px solid var(--red)",
        background: "rgba(255, 180, 0, 0.10)",
        padding: "10px 16px",
        display: "flex", alignItems: "flex-start", gap: 14,
        fontSize: 13, lineHeight: 1.4,
      }}
    >
      <span style={{ flexShrink: 0, marginTop: 2 }}>
        <WarnIcon color="var(--red)" size={18}/>
      </span>
      <div style={{ flex: 1 }}>
        <div style={{ fontWeight: 600 }}>
          Wallet on {walletLabel} but app is set to {net.label}.
        </div>
        <ol style={{
          margin: "4px 0 0 18px", padding: 0,
          fontSize: 12, lineHeight: 1.5, color: "var(--ink-3)",
        }}>
          <li>Open your wallet extension</li>
          <li>Switch its network to {net.label}</li>
          <li>Click Re-connect below</li>
        </ol>
      </div>
      <button className="btn" onClick={reconnect} style={{ flexShrink: 0, alignSelf: "center" }}>
        Re-connect wallet
      </button>
    </div>
  );
}

// -------------------------------------------------------------------------
// Active wallet-mismatch guardrails (pre-switch + pre-sign).
// -------------------------------------------------------------------------
//
// NetworkMismatchBanner above is the passive signal. These helpers add two
// active layers: (A) before NetworkSwitcher commits a switch and the wallet
// would end up on the wrong network, and (C) before any sign-triggering
// submit() in views-2/views-3. Pattern mirrors MainnetDeployModal in
// views-3.jsx -- a module-scoped pub/sub bridges async submit() handlers
// to a React-mounted modal.

let _wmmSetter  = null;
let _wmmResolve = null;
let _wmmReject  = null;

// kind: "pre-switch" | "pre-sign"
// walletNetwork / appNetwork: "Testnet" | "Mainnet"
// reconnect: async () => void  (only used by the pre-sign variant)
function showWalletMismatchModal(opts) {
  return new Promise((resolve, reject) => {
    if (!_wmmSetter) {
      reject(new Error("WalletMismatchModal not mounted"));
      return;
    }
    _wmmResolve = resolve;
    _wmmReject  = reject;
    _wmmSetter({ open: true, opts });
  });
}

function WalletMismatchModal() {
  const [state, setState] = useState({ open: false, opts: null });

  useEffect(() => {
    _wmmSetter = setState;
    return () => {
      _wmmSetter = null;
      if (_wmmReject) {
        const r = _wmmReject;
        _wmmResolve = _wmmReject = null;
        r(new Error("WalletMismatchModal unmounted"));
      }
    };
  }, []);

  if (!state.open || !state.opts) return null;
  const o = state.opts;

  const close = () => setState({ open: false, opts: null });
  const onCancel = () => {
    close();
    const r = _wmmReject;
    _wmmResolve = _wmmReject = null;
    if (r) r(new Error("User cancelled wallet mismatch"));
  };
  const onPrimary = async () => {
    // For pre-sign: trigger reconnect, then reject so submit() aborts and
    // the user can retry after re-connecting. For pre-switch: resolve so
    // the switch proceeds.
    if (o.kind === "pre-sign" && typeof o.reconnect === "function") {
      try { await o.reconnect(); } catch (_) {}
    }
    close();
    if (o.kind === "pre-switch") {
      const r = _wmmResolve;
      _wmmResolve = _wmmReject = null;
      if (r) r();
    } else {
      const r = _wmmReject;
      _wmmResolve = _wmmReject = null;
      if (r) r(new Error("User chose to reconnect; please retry tx"));
    }
  };

  const title = o.kind === "pre-switch"
    ? "Wallet network mismatch"
    : "Cannot sign - wallet on wrong network";
  const primaryLabel = o.kind === "pre-switch" ? "Switch anyway" : "Re-connect wallet";

  return (
    <div
      role="dialog"
      aria-modal="true"
      style={{
        position: "fixed", inset: 0,
        background: "rgba(0,0,0,0.65)",
        display: "flex", alignItems: "center", justifyContent: "center",
        zIndex: 200, padding: 20,
      }}
      onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
    >
      <div className="card" style={{ maxWidth: 520, width: "100%", padding: 22 }}>
        <h2 style={{ marginTop: 0, marginBottom: 4, fontSize: 18, fontWeight: 600 }}>
          {title}
        </h2>
        <div className="hr" style={{ margin: "8px 0 14px" }}/>
        <p style={{ fontSize: 13, lineHeight: 1.55, margin: 0 }}>
          {o.kind === "pre-switch"
            ? `Your wallet is on ${o.walletNetwork} but you're switching the app to ${o.appNetwork}. To sign transactions on ${o.appNetwork}, you'll need to:`
            : `Your wallet is on ${o.walletNetwork} but the app is set to ${o.appNetwork}. The transaction will be rejected if signed now. To fix:`
          }
        </p>
        <ol style={{
          margin: "8px 0 4px 18px", padding: 0,
          fontSize: 13, lineHeight: 1.6, color: "var(--ink-2)",
        }}>
          <li>Open your wallet extension</li>
          <li>Switch its network to {o.appNetwork}</li>
          <li>{o.kind === "pre-switch"
            ? "Reconnect after the switch goes through"
            : "Click Re-connect wallet below"}</li>
        </ol>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 16 }}>
          <button className="btn" onClick={onCancel}>Cancel</button>
          <button className="btn btn-primary" onClick={onPrimary}>{primaryLabel}</button>
        </div>
      </div>
    </div>
  );
}

// Pure detection: returns null on no mismatch / no wallet / mock wallet,
// else { walletNetwork, appNetwork }. Used by both pre-switch (against the
// TARGET cfg, not the current one) and pre-sign (against the current cfg).
function detectWalletMismatch(net, wallet) {
  if (!wallet || !wallet.connected) return null;
  const addr = wallet.fullAddress || wallet.address || "";
  const prefix = addr.slice(0, 2);
  if (prefix !== "ST" && prefix !== "SP") return null;
  if (prefix === net.addressPrefix) return null;
  return {
    walletNetwork: prefix === "SP" ? "Mainnet" : "Testnet",
    appNetwork:    net.label,
  };
}

// Probe the wallet for its CURRENT active-network address via SIP-030
// stx_getAddresses. Different from `wallet.fullAddress`, which is the
// address captured at connect() time and stays frozen even if the user
// later switches the wallet's active network in the extension. Both
// Leather and Xverse return addresses for the wallet's currently selected
// network, so the prefix of the returned principal tells us where the
// wallet would actually sign right now.
//
// NOTE: deliberately call getAddresses WITHOUT `network` here. The whole
// point of this probe is "what network is the wallet ACTUALLY on right
// now?" -- passing { network } would scope Xverse's response to the
// requested chain and silently mask any user-side mismatch.
//
// Wrapped in a Promise.race timeout so a stuck wallet popup or a
// hanging extension cannot block submit() indefinitely. Returns null on
// timeout / failure so callers fall back to the cached session check.
async function probeLiveWalletAddress() {
  const C8 = window.StacksConnect;
  if (!C8 || typeof C8.request !== "function") return null;
  try {
    const TIMEOUT_MS = 8000;
    const probe = C8.request("stx_getAddresses");
    const timeout = new Promise((_, rej) =>
      setTimeout(() => rej(new Error("probe timeout")), TIMEOUT_MS));
    const resp = await Promise.race([probe, timeout]);
    const addrs = resp?.addresses || resp?.result?.addresses || [];
    const stx =
      addrs.find(a => a?.symbol === "STX" || a?.symbol === "stx" || a?.symbol === "stacks") ||
      addrs.find(a => /^(ST|SP)[A-Z0-9]+$/.test(a?.address || ""));
    return stx?.address || null;
  } catch (_) {
    return null;
  }
}

// Sign-time assertion. Throws on cancel OR on the reconnect path (the
// connect dance is async; the wallet may not actually be matching yet by
// the time the user clicks the original submit button, so the caller
// should always abort the in-flight tx and let the user re-click submit).
//
// Two-stage check:
//   1. Cheap prefix compare against the cached session (`wallet.fullAddress`).
//   2. Live `stx_getAddresses` probe -- catches the case where the user
//      switched the wallet's active network in the extension AFTER
//      connect(), which leaves the cached session pointing at the old
//      network. Without the live probe the dapp passes the tx to the
//      wallet and Leather/Xverse rejects with their native
//      "Transaction Failed - mismatch" modal.
async function assertWalletReady(net, wallet) {
  const m = detectWalletMismatch(net, wallet);
  if (m) {
    await showWalletMismatchModal({
      kind:          "pre-sign",
      walletNetwork: m.walletNetwork,
      appNetwork:    m.appNetwork,
      reconnect: async () => {
        try { if (wallet.disconnect) wallet.disconnect(); } catch (_) {}
        try { if (wallet.connect)    await wallet.connect(); } catch (_) {}
      },
    });
    return;
  }

  // Live probe. Mock wallets (non-real) have no `fullAddress` -- skip.
  if (!wallet || !wallet.connected || !wallet.fullAddress) return;
  const live = await probeLiveWalletAddress();
  if (!live) return;
  const livePrefix = live.slice(0, 2);
  if (livePrefix !== "ST" && livePrefix !== "SP") return;
  if (livePrefix === net.addressPrefix) return;
  const walletNetwork = livePrefix === "SP" ? "Mainnet" : "Testnet";
  await showWalletMismatchModal({
    kind:          "pre-sign",
    walletNetwork,
    appNetwork:    net.label,
    reconnect: async () => {
      try { if (wallet.disconnect) wallet.disconnect(); } catch (_) {}
      try { if (wallet.connect)    await wallet.connect(); } catch (_) {}
    },
  });
}

window.assertWalletReady       = assertWalletReady;
window.showWalletMismatchModal = showWalletMismatchModal;
window.detectWalletMismatch    = detectWalletMismatch;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "poolMode": "graduated",
  "wallet": "real",
  "showError": false
}/*EDITMODE-END*/;

// Real Stacks wallet via @stacks/connect 8.x.
// 8.x uses the wallet provider API directly (window.LeatherProvider /
// window.XverseProviders) — no Stencil web component, no UserSession, no
// AppConfig. Caveat: this version of Connect via esm.sh does NOT persist
// the addresses itself (isConnected()/getLocalStorage() return false/null
// even after a successful connect). So we do persistence manually under
// our own key, transparent and minimal.
function truncateAddr(a) {
  if (!a) return null;
  return a.length > 13 ? a.slice(0, 6) + "…" + a.slice(-6) : a;
}

// Pick the STX address from a Connect 8.x addresses array. Shape per
// docs: [{ address: "STxxx", publicKey: "0x...", symbol: "STX" }, ...].
// Xverse returns both BTC + STX; the BTC one starts with "bc1" (mainnet)
// or "tb1" (testnet) — never with "ST"/"SP". So the STX address is the
// only one matching Stacks principal format.
function pickStxAddress(addresses) {
  if (!Array.isArray(addresses)) return null;
  // Try by symbol (Leather convention).
  let stx = addresses.find(a => a?.symbol === "STX" || a?.symbol === "stx" || a?.symbol === "stacks");
  // Fallback: match by address prefix (Stacks addresses always start with ST or SP).
  if (!stx) stx = addresses.find(a => /^(ST|SP)[A-Z0-9]+$/.test(a?.address || ""));
  return stx?.address || null;
}

const WALLET_STORAGE_KEY = "stacks-strategy:wallet";

// ROOT-CAUSE FIX for Xverse "Transaction Failed - mismatch between your
// active network and the network you're logged in with on the app".
//
// Xverse's wallet_connect RPC schema (sats-connect-core) requires:
//   network: 'Mainnet' | 'Testnet' | 'Signet'   <-- CAPITALIZED
//
// @stacks/connect 8.2.6 sends:
//   network: 'mainnet' | 'testnet' | 'regtest' | 'devnet'   <-- lowercase
//
// When the dapp calls StacksConnect.connect({network: 'mainnet'}), Connect 8
// internally maps it to provider.request('wallet_connect', {network:'mainnet'})
// for Xverse-like wallets. Xverse's valibot picklist validation rejects the
// lowercase string and either hangs or returns empty addresses (we observed
// both in the wild). With no network arg, Xverse binds the dapp's per-origin
// session to whatever its default is (testnet on fresh installs), and the
// per-origin binding persists even across "Revoke from Connected Apps".
//
// Result downstream: stx_signTransaction checks (stored session network ==
// tx's chainId) and rejects mainnet txs with the red modal because the dapp
// is "logged in" as testnet from Xverse's POV.
//
// Fix: call Xverse's wallet_connect DIRECTLY (bypassing Connect 8's mapping)
// with the correctly capitalized network. Normalize the JSON-RPC response
// back into Connect 8's shape so downstream code (pickStxAddress, api.jsx)
// keeps working unchanged.
function networkIdToXverse(networkId) {
  if (networkId === "mainnet") return "Mainnet";
  if (networkId === "testnet") return "Testnet";
  return null;
}

// Find Xverse's real provider. Multi-wallet collision: Leather can hijack
// `window.XverseProviders.StacksProvider` with `defineProperty({configurable:
// false})` before Xverse's inpage script runs, leaving a Leather stub that
// throws "request function is not implemented". Connect 8's DEFAULT_PROVIDERS
// lists Xverse as `XverseProviders.BitcoinProvider` -- this is the single
// real entry point on Xverse and it handles ALL methods (stx_, btc_,
// wallet_*) via internal routing. Prefer it; fall back to StacksProvider
// only if BitcoinProvider is somehow missing (older Xverse builds).
function getXverseProvider() {
  const xv = window.XverseProviders;
  if (!xv) return null;
  // BitcoinProvider is the canonical entry per Connect 8's DEFAULT_PROVIDERS.
  if (xv.BitcoinProvider && typeof xv.BitcoinProvider.request === "function") {
    return xv.BitcoinProvider;
  }
  // Fallback for hypothetical older Xverse build (and as a last resort,
  // even if StacksProvider is a Leather hijack, the StxConnect path catches
  // the "not implemented" throw and falls back to the SDK).
  if (xv.StacksProvider && typeof xv.StacksProvider.request === "function") {
    return xv.StacksProvider;
  }
  return null;
}

async function connectXverseDirect(networkId) {
  const provider = getXverseProvider();
  console.log("[WALLET] connectXverseDirect start", {
    networkId,
    hasProvider: !!provider,
    providerKind: provider === window.XverseProviders?.BitcoinProvider ? "BitcoinProvider"
                 : provider === window.XverseProviders?.StacksProvider ? "StacksProvider"
                 : "none",
  });
  if (!provider || typeof provider.request !== "function") return null;
  const network = networkIdToXverse(networkId);
  console.log("[WALLET] capitalized network:", network);
  if (!network) return null;

  // CRITICAL ORDER: wallet_changeNetwork FIRST, wallet_connect SECOND.
  // The `network` param in wallet_connect's params schema is documented but
  // IGNORED by Xverse -- the wallet returns the address of whatever network
  // is GLOBALLY active at the moment of the call, not the one we requested.
  // Confirmed by logs: with Xverse global = Testnet, calling
  // wallet_connect({network: "Mainnet"}) returns ST2ABWV7 (testnet address)
  // and network: {stacks: "testnet"}. We then change network correctly via
  // wallet_changeNetwork, but the cache already holds the wrong address +
  // pubkey-derived hash and the next stx_signTransaction fails with the
  // generic "Network mismatch".
  //
  // wallet_changeNetwork returns success even when the change is a no-op
  // (already on target). On a real change it also triggers an internal
  // Xverse signer reset which appears to fix the per-origin state.
  try {
    console.log("[WALLET] wallet_changeNetwork BEFORE connect:", { name: network });
    const chgResp = await provider.request("wallet_changeNetwork", { name: network });
    console.log("[WALLET] wallet_changeNetwork BEFORE connect RESPONSE:", JSON.stringify(chgResp, null, 2));
  } catch (e) {
    // "Network already active" comes back as -32602; not fatal, just means
    // no internal state reset happened. Sign may still fail downstream.
    console.warn("[WALLET] wallet_changeNetwork BEFORE connect failed (non-fatal):",
      e?.message || e?.error?.message || JSON.stringify(e));
  }

  // Now wallet_connect. With Xverse's global network freshly set to the
  // requested network, the returned address+pubkey will match the chain
  // the user is targeting.
  let resp;
  try {
    const params = { network, addresses: ["stacks"] };
    console.log("[WALLET] wallet_connect REQUEST:", params);
    resp = await provider.request("wallet_connect", params);
    console.log("[WALLET] wallet_connect RESPONSE (full):", JSON.stringify(resp, null, 2));
  } catch (e) {
    console.error("[WALLET] wallet_connect THREW:", e, "code:", e?.code, "error:", e?.error);
    if (e?.error?.code === 4001 || e?.code === 4001) {
      throw new Error("User rejected");
    }
    throw e;
  }

  // Verify final network state for our records.
  try {
    const netResp = await provider.request("wallet_getNetwork");
    console.log("[WALLET] wallet_getNetwork after connect (full):", JSON.stringify(netResp, null, 2));
  } catch (e) {
    console.warn("[WALLET] wallet_getNetwork failed (non-fatal):", e?.message || e);
  }
  // Raw provider returns JSON-RPC envelope: { jsonrpc, id, result: {...} }
  // OR (depending on Xverse build) the unwrapped result directly. Handle both.
  if (resp && typeof resp === "object" && "error" in resp && resp.error) {
    throw new Error(`Xverse wallet_connect: ${resp.error?.message || JSON.stringify(resp.error)}`);
  }
  const result = resp?.result || resp;
  const rawAddrs = Array.isArray(result?.addresses) ? result.addresses : [];
  if (!rawAddrs.length) return null;

  // Normalize Xverse's address shape:
  //   { address, publicKey, purpose: 'stacks'|'ordinals'|'payment'|..., addressType, walletType }
  // to Connect 8's shape:
  //   { address, publicKey, symbol: 'STX' | <purpose-uppercase> }
  // pickStxAddress() looks for symbol === 'STX'/'stx'/'stacks' first, then
  // falls back to address-prefix matching. Both paths will succeed.
  return rawAddrs
    .filter(a => a && typeof a.address === "string" && typeof a.publicKey === "string")
    .map(a => ({
      address:   a.address,
      publicKey: a.publicKey,
      symbol:    a.purpose === "stacks" ? "STX" : String(a.purpose || "").toUpperCase(),
    }));
}

function loadStoredAddresses() {
  try {
    const raw = localStorage.getItem(WALLET_STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed?.addresses) ? parsed.addresses : null;
  } catch (_) { return null; }
}

function useRealWallet(networkId) {
  const [ready, setReady] = useState(() => !!window.StacksConnect);
  // Initialize from localStorage synchronously so the first paint shows the
  // pill if a session exists (avoids a flicker through the disconnected state).
  const [fullAddress, setFullAddress] = useState(() => pickStxAddress(loadStoredAddresses()));

  // Wait for the esm.sh module bridge to finish.
  useEffect(() => {
    if (ready) return;
    const onReady = () => setReady(true);
    window.addEventListener("stacks:ready", onReady);
    if (window.StacksConnect) setReady(true);
    return () => window.removeEventListener("stacks:ready", onReady);
  }, [ready]);

  // Auto-disconnect when the app's active network changes. Xverse persists
  // a per-origin session keyed by the network the dapp first interacted on;
  // re-using a testnet-bound session for mainnet signing triggers a "logged
  // in with on the app" mismatch modal that fires BEFORE any of our code
  // sees a request go out. Forcing a fresh reconnect on network change
  // primes the wallet-side session against the new chain via
  // stx_getAddresses({network}) on the next user click.
  //
  // Network-change-only dep list. fullAddress is intentionally OMITTED so
  // the effect does not re-trigger right after a successful connect(),
  // which would race the setter and wipe the just-set address.
  const lastNetIdRef = useRef(networkId);
  useEffect(() => {
    if (lastNetIdRef.current === networkId) return;
    lastNetIdRef.current = networkId;
    try {
      if (window.StacksConnect && typeof window.StacksConnect.disconnect === "function") {
        window.StacksConnect.disconnect();
      }
    } catch (_) {}
    try { localStorage.removeItem(WALLET_STORAGE_KEY); } catch (_) {}
    setFullAddress(null);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [networkId]);

  const connect = async () => {
    if (!ready) return;

    const hasXverse  = !!window.XverseProviders?.StacksProvider;
    const hasLeather = !!window.LeatherProvider;
    console.log("[WALLET] connect() called", {
      networkId,
      hasXverse,
      hasLeather,
      capitalizedNet: networkIdToXverse(networkId),
      willUseXverseDirect: hasXverse && networkIdToXverse(networkId),
    });

    // Xverse-direct path. See connectXverseDirect() docstring for the why:
    // Connect 8 sends lowercase network to Xverse's wallet_connect, which
    // requires capitalized network per its valibot schema. Wrong case = the
    // mismatch modal at sign time. We bypass Connect 8 and call the provider
    // directly to bind the per-origin session against the right network on
    // Xverse side. Leather + other wallets keep using the SDK modal.
    // CHANGED: drop the !hasLeather guard. If Xverse is present we prefer
    // the direct path even when Leather coexists, otherwise the dual-wallet
    // user never gets the network binding fix.
    if (hasXverse && networkIdToXverse(networkId)) {
      try {
        const addrs = await connectXverseDirect(networkId);
        if (Array.isArray(addrs) && addrs.length) {
          try { localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify({ addresses: addrs })); } catch (_) {}
          const addr = pickStxAddress(addrs);
          if (addr) setFullAddress(addr);
          // Register the selection with Connect 8 so subsequent
          // StacksConnect.request() calls (stx_signTransaction, etc.) route
          // back to Xverse without re-prompting. Connect 8's DEFAULT_PROVIDERS
          // lists Xverse as "XverseProviders.BitcoinProvider" (single entry
          // for all chains); setting it as StacksProvider was a wrong guess
          // -- Connect 8 would .reduce() through window.XverseProviders.
          // StacksProvider and hit the Leather-hijacked stub.
          try {
            const setId = window.StacksConnect?.setSelectedProviderId;
            if (typeof setId === "function") {
              setId("XverseProviders.BitcoinProvider");
            } else {
              localStorage.setItem("STX_PROVIDER", "XverseProviders.BitcoinProvider");
            }
          } catch (_) {}
          console.log("[WALLET] post-xverse-direct selectedProviderId:",
            typeof window.StacksConnect?.getSelectedProviderId === "function"
              ? window.StacksConnect.getSelectedProviderId()
              : localStorage.getItem("STX_PROVIDER"));
          return;
        }
        console.warn("Xverse direct connect returned no addresses; falling back to SDK");
      } catch (e) {
        if (e?.message === "User rejected") return;
        console.warn("Xverse direct connect failed; falling back to SDK:", e?.message || e);
      }
    }

    // SDK fallback: Leather, multi-wallet picker, future wallets.
    if (!window.StacksConnect || typeof window.StacksConnect.connect !== "function") return;
    try {
      const response = await window.StacksConnect.connect();
      let addrs = response?.addresses;
      if (!Array.isArray(addrs) || !addrs.length) addrs = response?.result?.addresses;
      if (Array.isArray(addrs) && addrs.length) {
        try { localStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify({ addresses: addrs })); } catch (_) {}
        const addr = pickStxAddress(addrs);
        if (addr) setFullAddress(addr);
      } else {
        console.warn("Connect returned no addresses:", response);
      }
    } catch (e) {
      console.warn("Connect cancelled/failed:", e?.message || e);
    }
  };

  const disconnect = () => {
    console.log("[WALLET] disconnect() called");
    try {
      if (window.StacksConnect && typeof window.StacksConnect.disconnect === "function") {
        window.StacksConnect.disconnect();
      }
    } catch (_) {}
    // clearLocalStorage wipes the Connect SDK's own cached addresses;
    // without it the SDK's getLocalStorage() keeps returning the prior
    // session and the wallet treats the next connect() as a resume.
    try {
      if (window.StacksConnect && typeof window.StacksConnect.clearLocalStorage === "function") {
        window.StacksConnect.clearLocalStorage();
      }
    } catch (_) {}
    // Also clear the selected provider id so the next connect doesn't route
    // through a stale provider entry (we observed "XverseProviders.BitcoinProvider"
    // persisting across disconnect, routing stx_signTransaction through the BTC
    // provider instead of the StacksProvider where the session was rebound).
    try {
      if (typeof window.StacksConnect?.clearSelectedProviderId === "function") {
        window.StacksConnect.clearSelectedProviderId();
      } else {
        // Fallback: connect-ui stores the id under this key.
        localStorage.removeItem("STX_PROVIDER");
      }
    } catch (_) {}
    try { localStorage.removeItem(WALLET_STORAGE_KEY); } catch (_) {}
    setFullAddress(null);
    console.log("[WALLET] disconnect() done; selectedProviderId now:",
      typeof window.StacksConnect?.getSelectedProviderId === "function"
        ? window.StacksConnect.getSelectedProviderId()
        : localStorage.getItem("STX_PROVIDER"));
  };

  return {
    connected: !!fullAddress,
    kind: fullAddress ? "eoa" : null,
    address: fullAddress ? truncateAddr(fullAddress) : null,
    fullAddress,
    label: fullAddress ? "Stacks wallet" : null,
    ready,
    connect,
    disconnect,
  };
}

function parseRouteFromHash() {
  const h = (window.location.hash || "").replace(/^#\/?/, "");
  if (!h) return { name: "landing" };
  const parts = h.split("/");
  const name = parts[0];
  if (name === "pool" && parts[1]) return { name: "pool", poolId: parts[1] };
  const allowed = ["landing", "pools", "pool", "create", "allowlist", "emergency", "docs"];
  if (allowed.includes(name)) return { name };
  return { name: "landing" };
}

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const net = useNetwork();

  // Routing (in-app, hash-persisted so reloads + back/forward keep the user
  // on the current route instead of bouncing back to landing).
  const [route, setRoute] = useState(parseRouteFromHash); // landing | pools | pool | create | allowlist | emergency | docs
  const go = (name, arg) => {
    const next =
      name === "pool" ? { name: "pool", poolId: arg } :
      name === "docs" ? { name: "landing" } : // placeholder
                        { name };
    setRoute(next);
    const hash =
      next.name === "landing" ? "" :
      next.name === "pool" && next.poolId ? `#/pool/${next.poolId}` :
                                             `#/${next.name}`;
    if (window.location.hash !== hash) {
      window.history.pushState(null, "", hash || window.location.pathname);
    }
    window.scrollTo({ top: 0 });
  };
  useEffect(() => {
    const onHashChange = () => setRoute(parseRouteFromHash());
    window.addEventListener("hashchange", onHashChange);
    window.addEventListener("popstate", onHashChange);
    return () => {
      window.removeEventListener("hashchange", onHashChange);
      window.removeEventListener("popstate", onHashChange);
    };
  }, []);

  // Real wallet hook always runs (hooks rule); we just choose which value to
  // expose based on the tweak. Mock variants get connect/disconnect stubs that
  // flip the tweak so the topbar buttons work the same in both worlds.
  // Pass the active network id so connect() can bind the wallet-side session
  // to the right chain (fixes the Xverse "logged in with on the app"
  // mismatch modal that fires even when wallet + dapp visually agree).
  const realWallet = useRealWallet(net.id);
  const wallet = useMemo(() => {
    if (t.wallet === "real") return realWallet;
    if (t.wallet === "disconnected") return {
      connected: false, kind: null, address: null,
      connect: () => setTweak("wallet", "connected"),
      disconnect: () => {},
    };
    if (t.wallet === "multisig") return {
      connected: true, kind: "multisig", address: "ST7MULTI…ADMIN", label: "Asigna · 2/3",
      connect: () => {},
      disconnect: () => setTweak("wallet", "disconnected"),
    };
    return {
      connected: true, kind: "eoa", address: "ST3RJ9…NQGV1A", label: "Leather",
      connect: () => {},
      disconnect: () => setTweak("wallet", "disconnected"),
    };
  }, [t.wallet, realWallet]);

  // Demo pool gets remixed by tweak — we shallow-rewrite POOLS[0].mode
  useEffect(() => {
    POOLS[0].mode = t.poolMode || "graduated";
  }, [t.poolMode]);

  // Toasts
  const [toasts, setToasts] = useState([]);
  const showError = (msg) => addToast({ kind: "error", title: "Error", body: msg });
  const addToast = (toast) => {
    const id = Math.random().toString(36).slice(2);
    setToasts(prev => [...prev, { id, ...toast }]);
    if (toast.kind !== "pending") setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 6000);
    return id;
  };
  const updateToast = (id, patch) => setToasts(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t));

  // Live pending-toast count and provider registration. The provider reads
  // the count via the registered getter at switch-time (see NetworkProvider).
  const pendingCount = useMemo(
    () => toasts.filter(x => x.kind === "pending" && x.txid).length,
    [toasts]
  );
  useEffect(() => {
    net.registerPendingCounter(() => pendingCount);
  }, [pendingCount, net.registerPendingCounter]);

  // Trigger an error toast on demand via tweak
  const errFiredRef = React.useRef(false);
  useEffect(() => {
    if (t.showError && !errFiredRef.current) {
      errFiredRef.current = true;
      addToast({ kind:"error", title:"Slippage exceeded", body: formatContractError("u213") });
      setTimeout(() => { errFiredRef.current = false; setTweak("showError", false); }, 100);
    }
  }, [t.showError]);

  // Surface a tx the caller has already broadcast. `info.txid` triggers
  // real polling via TxToast/useTxTracker. Without txid this is just a
  // fire-and-forget toast (used by mock paths that haven't been wired yet).
  const onTx = (info) => {
    if (!wallet.connected) return showError("Connect a wallet first.");
    const id = addToast({
      kind: "pending",
      title: info.summary,
      body:  info.detail,
      txid:  info.txid || null,
      // Snapshot the explorer chain at creation time so a later network
      // switch does not rewrite this toast's explorer link.
      explorerChain: net.explorerChain,
    });
    if (!info.txid) {
      // Legacy fake path — kept so still-mocked panels (TradePanel, etc.)
      // don't break. Will be replaced as each panel is wired to a real tx.
      setTimeout(() => {
        updateToast(id, { kind: "success", title: info.summary, body: info.detail + " · confirmed" });
        setTimeout(() => removeToast(id), 5500);
      }, 2400);
    }
  };
  const removeToast = (id) => setToasts(prev => prev.filter(t => t.id !== id));

  return (
    <div className="shell">
      {/* Topbar hidden on landing -- that route renders its own LAUNKR nav. */}
      {route.name !== "landing" && (
        <Topbar route={route.name} go={go} wallet={wallet} t={t} setTweak={setTweak} pendingCount={pendingCount}/>
      )}
      {route.name !== "landing" && <NetworkMismatchBanner wallet={wallet}/>}

      {route.name === "landing"   && <Landing go={go}/>}
      {route.name === "pools"     && <PoolList go={go}/>}
      {route.name === "pool"      && <PoolDetail poolId={route.poolId} go={go} wallet={wallet} showError={showError} onTx={onTx}/>}
      {route.name === "create"    && <CreatePool go={go} wallet={wallet} showError={showError} onTx={onTx}/>}
      {route.name === "allowlist" && <AllowlistManager go={go} wallet={wallet} showError={showError} onTx={onTx}/>}
      {route.name === "emergency" && <Emergency go={go} wallet={wallet} showError={showError} onTx={onTx}/>}

      <TxTracker toasts={toasts} updateToast={updateToast} removeToast={removeToast}/>
      <WalletMismatchModal/>

      <TweaksPanel>
        <TweakSection label="Demo pool mode"/>
        <TweakRadio label="STGY mode" value={t.poolMode} options={["direct","bonding","graduated"]} onChange={v=>setTweak("poolMode", v)}/>

        <TweakSection label="Wallet"/>
        <TweakRadio label="State" value={t.wallet} options={["real","disconnected","connected","multisig"]} onChange={v=>setTweak("wallet", v)}/>

        <TweakSection label="Errors"/>
        <TweakButton label="Trigger u213 slippage error" onClick={()=>setTweak("showError", true)}/>

        <TweakSection label="Jump to"/>
        <TweakButton label="Landing" onClick={()=>go("landing")}/>
        <TweakButton label="Pool list" onClick={()=>go("pools")}/>
        <TweakButton label={"STGY · " + (t.poolMode || "graduated")} onClick={()=>go("pool","stgy")}/>
        <TweakButton label="Create pool" onClick={()=>go("create")}/>
        <TweakButton label="Allowlist" onClick={()=>go("allowlist")}/>
        <TweakButton label="Emergency" onClick={()=>go("emergency")}/>
      </TweaksPanel>
    </div>
  );
}

function Topbar({ route, go, wallet, t, setTweak, pendingCount }) {
  const [walletOpen, setWalletOpen] = useState(false);
  const net = useNetwork();
  const apiHealth = useApiHealth();
  return (
    <header className="topbar">
      <div className="brand" onClick={()=>go("landing")}>
        <span className="brand-mark"/>
        <span>LAUNKR<span style={{ color: "var(--orange)" }}>.</span></span>
        <small>protocol</small>
      </div>
      <nav className="nav">
        <a className={"nav-link " + (route==="pools"?"active":"")}     onClick={()=>go("pools")}>Pools</a>
        <a className={"nav-link " + (route==="create"?"active":"")}    onClick={()=>go("create")}>Create</a>
        <a className={"nav-link " + (route==="allowlist"?"active":"")} onClick={()=>go("allowlist")}>Allowlist</a>
        <a className={"nav-link " + (route==="emergency"?"active":"")} onClick={()=>go("emergency")}>Admin</a>
      </nav>
      <div className="spacer"/>

      <ApiStatusPill health={apiHealth}/>

      <NetworkSwitcher pendingCount={pendingCount} wallet={wallet}/>

      {wallet.connected ? (
        <div style={{ position:"relative", flexShrink: 0 }}>
          <button className="btn" onClick={()=>setWalletOpen(!walletOpen)} style={{ whiteSpace:"nowrap" }}>
            {wallet.kind === "multisig" ? <I.multisig/> : <I.user/>}
            <Mono style={{ fontSize: 12 }}>{wallet.address}</Mono>
            <span className="dim" style={{ fontSize: 11 }}>- {wallet.label}</span>
          </button>
          {walletOpen && (
            <div className="card" style={{ position:"absolute", right: 0, top: "calc(100% + 8px)", padding: 10, width: 240, zIndex: 50 }}>
              <div className="kv" style={{ padding: "4px 0" }}>
                <span className="k">Network</span>
                <span className="v" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                  <span className="dot" style={{ background: netDotColor(net.color) }}/>
                  {net.label}
                </span>
              </div>
              <div className="kv" style={{ padding: "4px 0" }}><span className="k">Adapter</span><span className="v">{wallet.label}</span></div>
              <div className="hr"/>
              <button className="btn btn-ghost" style={{ width:"100%", justifyContent:"flex-start" }}
                onClick={(e)=>{ e.stopPropagation(); setWalletOpen(false); wallet.disconnect && wallet.disconnect(); }}>
                Disconnect
              </button>
            </div>
          )}
        </div>
      ) : (
        <button className="btn btn-primary" onClick={()=> wallet.connect && wallet.connect()}>
          Connect wallet
        </button>
      )}
    </header>
  );
}

// Tiny pill driven by useApiHealth. Same visual language as the network pill
// (we reuse `.net-pill` + a coloured `.dot`) so it slots in cleanly.
function ApiStatusPill({ health }) {
  const net = useNetwork();
  const status = health?.status || "idle";
  const lname = net.label.toLowerCase();
  const map = {
    idle:  { color: "var(--ink-3)",  label: "API ...",   title: `Probing Hiro ${lname}...` },
    ok:    { color: "var(--green)",  label: "API ok",    title: `Hiro ${lname} - ${health.latencyMs}ms` },
    lag:   { color: "var(--yellow)", label: "API lag",   title: `Hiro ${lname} slow - ${health.latencyMs}ms` },
    error: { color: "var(--red)",    label: "API error", title: `Hiro ${lname} unreachable${health?.error ? " - " + health.error : ""}` },
  };
  const v = map[status] || map.idle;
  return (
    <span className="net-pill" title={v.title} style={{ marginRight: 6 }}>
      <span className="dot" style={{ background: v.color }}/> {v.label}
      {status === "ok" && health?.height != null && (
        <span className="dim" style={{ fontSize: 11, marginLeft: 4 }}>- #{health.height}</span>
      )}
    </span>
  );
}

function TxTracker({ toasts, updateToast, removeToast }) {
  if (!toasts.length) return null;
  return (
    <div className="tx-tracker">
      {toasts.map(t =>
        <TxToast key={t.id} toast={t} updateToast={updateToast} removeToast={removeToast}/>
      )}
    </div>
  );
}

// One-toast view + lifecycle. When the toast carries a real `txid`, mounts
// useTxTracker; promotes the toast to success/error/timeout and arranges
// auto-dismiss for terminal states.
function TxToast({ toast, updateToast, removeToast }) {
  const tracking = window.useTxTracker(toast.kind === "pending" ? toast.txid || null : null);
  const bus = useTxBus();

  // Build the explorer URL once we have a txid. explorerChain is snapshot
  // onto the toast at creation time so a later network switch does not
  // rewrite this toast's link. Legacy toasts (no explorerChain) fall back
  // to "testnet" so the link never breaks.
  const explorerUrl = toast.txid
    ? `https://explorer.hiro.so/txid/${toast.txid}?chain=${toast.explorerChain || "testnet"}`
    : null;

  // React to tx_status transitions.
  React.useEffect(() => {
    if (!toast.txid) return;
    if (tracking.status === "success") {
      updateToast(toast.id, {
        kind: "success",
        title: toast.title.replace(/\bpending\b/i, "confirmed") + " · confirmed",
        body: toast.body,
      });
      bus.emit({
        kind: "tx-success",
        txid: toast.txid,
        summary: toast.title,
      });
      setTimeout(() => removeToast(toast.id), 6000);
    } else if (tracking.status === "error") {
      const code = tracking.code;
      const reason = code
        ? `${code} · ${formatContractError(code)}`
        : (tracking.error || "tx aborted");
      updateToast(toast.id, {
        kind: "error",
        title: `${toast.title} · failed`,
        body: reason,
      });
      setTimeout(() => removeToast(toast.id), 8000);
    } else if (tracking.status === "timeout") {
      updateToast(toast.id, {
        kind: "error",
        title: `${toast.title} · timeout`,
        body: tracking.error || "Tx not confirmed in 90s.",
      });
      setTimeout(() => removeToast(toast.id), 8000);
    }
    // status === "pending" / "idle" : no-op
  }, [tracking.status, tracking.code, tracking.error, toast.txid, toast.id]);

  return (
    <div className={"tx-card " + (toast.kind === "success" ? "success" : toast.kind === "error" ? "error" : "")}>
      <div className="tx-card-h">
        {toast.kind === "pending" && <span className="tx-spinner"/>}
        {toast.kind === "success" && <span className="tx-success-dot"/>}
        {toast.kind === "error"   && <span className="tx-error-dot"/>}
        <span style={{ flex: 1 }}>{toast.title}</span>
        {explorerUrl ? (
          <a className="dim" href={explorerUrl} target="_blank" rel="noreferrer"
             style={{ fontSize: 11.5, display:"flex", alignItems:"center", gap: 4 }}>
            explorer <I.ext/>
          </a>
        ) : (
          <a className="dim" style={{ fontSize: 11.5, display:"flex", alignItems:"center", gap: 4, opacity: 0.5 }}>
            explorer <I.ext/>
          </a>
        )}
      </div>
      <div className="muted mono" style={{ fontSize: 11.5 }}>{toast.body}</div>
      {toast.txid && (
        <div className="muted mono" style={{ fontSize: 11, marginTop: 4, opacity: 0.7 }}>
          {toast.txid.slice(0, 10)}…{toast.txid.slice(-6)}
        </div>
      )}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("app")).render(
  <NetworkProvider>
    <TxBusProvider>
      <App/>
    </TxBusProvider>
  </NetworkProvider>
);
