// qid-connect-widget.js
//
// The "Sign in with qID" button and dialog. Vanilla JS, self-contained, no
// external requests. Drop it on any page:
//
//   import { mountQidConnect } from "@qid/connect-widget";
//
//   mountQidConnect(document.querySelector("#signin"), {
//     api: "/qid",                      // where the server SDK is mounted
//     appName: "Your app",              // shown in the dialog
//     buttonText: "Sign in with qID",   // relabel the button (localization, etc.)
//     onSignedIn(session) { ... },      // { address, account }
//     transports: ["qr", "paste", "deeplink"], // tab order; first one opens active (this is the default)
//     wallets: [                        // shown in the dialog, WalletConnect style
//       { name: "PQ Wallet for BTX", status: "available", url: "https://pq-wallet.com" },
//       { name: "bonuz Wallet", status: "available", url: "https://bonuz.xyz/btx-wallet" },
//     ],                                // url (optional) makes the row a link to
//                                       // the wallet's page (new tab); pass [] to hide the list
//     signingFx: true,                  // brief signing animation before the check
//     trigger: true,                    // false = render no button; call handle.open() yourself
//     pollIntervalMs: 1500,             // QR/deeplink poll cadence
//     onClose(reason) { ... },          // 'backdrop' | 'escape' | 'x' | 'signedin' | 'programmatic'
//     onError(err) { ... },             // Error with { stage, code?, httpStatus?, cause? }
//   });
//   // returns { open, close, unmount }
//
// Three ways in, WalletConnect style:
//   deeplink Open in the installed BTX PQ desktop wallet. The button launches
//          btxqid://sign?req=<base64url(request)>; the wallet signs and POSTs the
//          proof to proof_url, and this dialog picks the session up with the same
//          poll the QR flow uses. No copy-paste.
//   paste  Desktop wallet. Copy the request, sign in the PQ Wallet for BTX
//          (Settings, qID Sign-In), paste the proof back.
//   qr     Scan with a phone wallet. The QR carries the same request plus a
//          proof URL; the phone signs and posts the proof straight to your
//          server, and this dialog picks the session up by polling with a
//          secret only this browser holds. No relay server anywhere.
//
// The signed challenge inside the request is frozen qID Sign-In v1 and never
// changes; transports are wrapping, not protocol.
//
// SPDX-License-Identifier: MIT

import qrcodegen from "./vendor/qrcode.mjs";

const REASON_TEXT = {
  nonce_unknown: "This sign-in request expired or was already used. Start again.",
  nonce_used: "This proof was already used. Start again to get a fresh request.",
  origin_mismatch: "This proof was made for a different site. Sign the request this site gave you.",
  expired: "The request expired. Start again and sign the new one.",
  not_yet_valid: "Your wallet clock looks wrong. Check the system time and try again.",
  bad_signature: "The proof did not verify. Copy the whole proof from the wallet and try again.",
  bad_proof_shape: "That does not look like a qID proof. Copy the whole JSON the wallet gave you.",
  bad_request_body: "That does not look like a qID proof. Copy the whole JSON the wallet gave you.",
  bad_origin: "This site's sign-in is misconfigured (origin mismatch). If you are the developer, set the server's origin to the exact host users land on.",
  server_error: "The server had a problem. Try again.",
};

const CSS = `
.qidc-btn{display:inline-flex;align-items:center;gap:.55em;padding:.68em 1.15em;border:1px solid var(--qidc-accent,#34d399);border-radius:12px;background:var(--qidc-btn-bg,var(--qidc-accent,#34d399));color:var(--qidc-btn-fg,#04120c);font:700 15px/1 system-ui,sans-serif;cursor:pointer;transition:transform .12s,box-shadow .12s;box-shadow:0 1px 8px rgba(52,211,153,.25)}
.qidc-btn:hover{transform:translateY(-1px);box-shadow:0 3px 14px rgba(52,211,153,.4)}
.qidc-btn:active{transform:translateY(0)}
.qidc-btn svg{flex:none}
.qidc-overlay{position:fixed;inset:0;background:rgba(2,8,5,.68);display:flex;align-items:center;justify-content:center;z-index:99999;padding:16px;animation:qidc-fade var(--qidc-anim-duration,.18s) ease}
.qidc-modal{position:relative;background:var(--qidc-bg,#101613);color:var(--qidc-fg,#e9f3ed);border:1px solid var(--qidc-border,#25352c);border-radius:20px;max-width:400px;width:100%;max-height:92vh;overflow:auto;font:14px/1.5 system-ui,sans-serif;padding:20px 22px 18px;box-shadow:0 24px 64px rgba(0,0,0,.45);animation:qidc-pop var(--qidc-anim-duration,.18s) ease}
.qidc-modal:focus{outline:none}
@keyframes qidc-fade{from{opacity:0}to{opacity:1}}
@keyframes qidc-pop{from{opacity:0;transform:scale(.96) translateY(6px)}to{opacity:1;transform:none}}
.qidc-head{display:flex;align-items:center;gap:.55em;margin:0 0 2px}
.qidc-head svg{color:var(--qidc-accent,#34d399)}
.qidc-head h2{margin:0;font-size:16.5px;font-weight:700;letter-spacing:.2px}
.qidc-x{margin-left:auto;border:none;background:transparent;color:var(--qidc-muted,#8fae9d);font-size:20px;line-height:1;cursor:pointer;padding:4px 6px;border-radius:8px}
.qidc-x:hover{background:var(--qidc-code-bg,#0a0f0c);color:var(--qidc-fg,#e9f3ed)}
.qidc-sub{margin:2px 0 14px;color:var(--qidc-muted,#8fae9d);font-size:12.5px}
.qidc-tabs{display:flex;gap:4px;background:var(--qidc-code-bg,#0a0f0c);border:1px solid var(--qidc-border,#25352c);border-radius:12px;padding:4px;margin-bottom:16px}
.qidc-tab{flex:1;border:none;border-radius:9px;padding:8px 6px;background:transparent;color:var(--qidc-muted,#8fae9d);font:600 12.5px/1 system-ui,sans-serif;cursor:pointer;transition:background .12s,color .12s}
.qidc-tab[aria-selected="true"]{background:var(--qidc-bg,#101613);color:var(--qidc-fg,#e9f3ed);box-shadow:0 1px 4px rgba(0,0,0,.35)}
.qidc-step{margin:12px 0 6px;font-weight:700;font-size:12px;letter-spacing:.4px;text-transform:uppercase;color:var(--qidc-accent,#34d399)}
.qidc-code{position:relative;background:var(--qidc-code-bg,#0a0f0c);border:1px solid var(--qidc-border,#25352c);border-radius:10px;padding:10px;font:11.5px/1.45 ui-monospace,monospace;word-break:break-all;white-space:pre-wrap;max-height:110px;overflow:auto}
.qidc-copy{position:absolute;top:6px;right:6px;padding:5px 11px;border-radius:7px;border:1px solid var(--qidc-accent,#34d399);background:var(--qidc-bg,#101613);color:var(--qidc-accent,#34d399);font:700 11.5px/1 system-ui,sans-serif;cursor:pointer;animation:qidc-copy-pulse 2.2s ease infinite}
.qidc-copy:hover{background:var(--qidc-accent,#34d399);color:#04120c;animation:none}
@keyframes qidc-copy-pulse{0%,100%{box-shadow:0 0 0 0 rgba(52,211,153,0)}50%{box-shadow:0 0 0 4px rgba(52,211,153,.3)}}
.qidc-info{display:inline-flex;flex:none;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border-radius:50%;border:1px solid #6ee7b7;background:transparent;color:#6ee7b7;font:700 11px/1 ui-monospace,monospace;text-decoration:none;cursor:pointer;margin-left:8px;animation:qidc-info-pulse 2.6s ease infinite}
.qidc-info:hover{background:var(--qidc-code-bg,#0a0f0c);animation:none}
@keyframes qidc-info-pulse{0%,100%{box-shadow:0 0 0 0 rgba(110,231,183,0)}50%{box-shadow:0 0 0 3px rgba(110,231,183,.3)}}
.qidc-help{margin:6px 0 0;color:var(--qidc-muted,#8fae9d);font-size:12px}
.qidc-help b{color:var(--qidc-fg,#e9f3ed);font-weight:600}
.qidc-ta{width:100%;box-sizing:border-box;min-height:88px;margin-top:6px;background:var(--qidc-code-bg,#0a0f0c);color:var(--qidc-fg,#e9f3ed);border:1px solid var(--qidc-border,#25352c);border-radius:10px;padding:10px;font:11.5px/1.45 ui-monospace,monospace;resize:vertical}
.qidc-ta:focus{outline:none;border-color:var(--qidc-accent,#34d399)}
.qidc-actions{display:flex;gap:10px;margin-top:14px;align-items:center}
.qidc-primary{padding:.65em 1.25em;border-radius:10px;border:none;background:var(--qidc-accent,#34d399);color:#04120c;font:700 13.5px/1 system-ui,sans-serif;cursor:pointer;transition:filter .12s}
.qidc-primary:hover{filter:brightness(1.1)}
.qidc-primary:disabled{opacity:.4;cursor:default;filter:none}
.qidc-cancel{padding:.65em 1em;border-radius:10px;border:1px solid var(--qidc-border,#25352c);background:transparent;color:var(--qidc-muted,#8fae9d);font:600 13.5px/1 system-ui,sans-serif;cursor:pointer}
.qidc-err{margin-top:10px;color:#f87171;font-size:12.5px;min-height:1em}
.qidc-note{margin-top:16px;padding-top:12px;border-top:1px solid var(--qidc-border,#25352c);color:var(--qidc-muted,#8fae9d);font-size:11.5px;display:flex;gap:.5em;align-items:flex-start}
.qidc-note svg{flex:none;margin-top:1px;color:var(--qidc-accent,#34d399)}
.qidc-qrwrap{display:flex;flex-direction:column;align-items:center;gap:12px;padding:4px 0 2px}
.qidc-qrcard{position:relative;background:#fff;border-radius:16px;padding:14px;box-shadow:0 2px 16px rgba(0,0,0,.25)}
.qidc-qrcard svg{display:block;width:212px;height:212px}
.qidc-qrmark{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);min-width:46px;height:30px;padding:0 8px;border-radius:9px;background:#0b1310;color:#34d399;font:800 13.5px/1 ui-monospace,monospace;letter-spacing:.5px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 4px #fff}
.qidc-wait{display:flex;align-items:center;gap:.5em;color:var(--qidc-muted,#8fae9d);font-size:12.5px}
.qidc-dot{width:8px;height:8px;border-radius:50%;background:var(--qidc-accent,#34d399);animation:qidc-pulse 1.2s ease infinite}
@keyframes qidc-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.75)}}
.qidc-link{border:none;background:transparent;color:var(--qidc-accent,#34d399);font:600 12.5px/1 system-ui,sans-serif;cursor:pointer;padding:6px 10px;border-radius:8px}
.qidc-link:hover{background:var(--qidc-code-bg,#0a0f0c)}
.qidc-countdown{margin-top:10px;text-align:center;color:var(--qidc-muted,#8fae9d);font-size:11.5px;min-height:1.3em;display:flex;align-items:center;justify-content:center;gap:.4em}
.qidc-wallets{display:flex;flex-direction:column;gap:6px;margin:0 0 14px;padding:10px 12px;background:var(--qidc-code-bg,#0a0f0c);border:1px solid var(--qidc-border,#25352c);border-radius:12px}
.qidc-wallets-title{font:700 10.5px/1 system-ui,sans-serif;letter-spacing:.5px;text-transform:uppercase;color:var(--qidc-muted,#8fae9d);margin-bottom:2px}
.qidc-wallet{display:flex;align-items:center;gap:.6em;font:600 12.5px/1.3 system-ui,sans-serif;color:var(--qidc-fg,#e9f3ed)}
a.qidc-wallet-link{text-decoration:none;cursor:pointer;border-radius:8px;margin:0 -6px;padding:3px 6px;transition:background .12s}
a.qidc-wallet-link:hover{background:var(--qidc-bg,#101613)}
.qidc-wallet-go{font:700 11px/1 ui-monospace,monospace;color:var(--qidc-muted,#8fae9d);margin-left:2px}
a.qidc-wallet-link:hover .qidc-wallet-go{color:var(--qidc-accent,#34d399)}
.qidc-chip{margin-left:auto;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;text-transform:uppercase;padding:4px 8px;border-radius:999px;border:1px solid var(--qidc-border,#25352c)}
.qidc-chip.avail{color:var(--qidc-accent,#34d399);border-color:var(--qidc-accent,#34d399)}
.qidc-chip.soon{color:var(--qidc-muted,#8fae9d)}
.qidc-signing{display:flex;flex-direction:column;align-items:center;gap:12px;padding:22px 0 16px}
.qidc-matrix{margin:0;background:var(--qidc-code-bg,#0a0f0c);border:1px solid var(--qidc-border,#25352c);border-radius:12px;padding:12px 14px;font:11px/1.35 ui-monospace,monospace;color:var(--qidc-accent,#34d399);letter-spacing:1px;overflow:hidden;user-select:none;text-shadow:0 0 6px rgba(52,211,153,.35)}
.qidc-signing-label{color:var(--qidc-muted,#8fae9d);font-size:12.5px}
.qidc-done{display:flex;flex-direction:column;align-items:center;gap:10px;padding:26px 0 18px;text-align:center}
.qidc-check{width:56px;height:56px;border-radius:50%;background:var(--qidc-accent,#34d399);display:flex;align-items:center;justify-content:center;animation:qidc-pop .25s ease}
.qidc-check svg{color:#04120c}
.qidc-done b{font-size:15px}
.qidc-addr{font:11.5px/1.4 ui-monospace,monospace;color:var(--qidc-muted,#8fae9d);word-break:break-all;max-width:300px}
.qidc-acct{position:relative;display:inline-block}
.qidc-acct-chip{display:inline-flex;align-items:center;gap:.5em;padding:.5em .85em;border:1px solid var(--qidc-border,#25352c);border-radius:999px;background:var(--qidc-bg,#101613);color:var(--qidc-fg,#e9f3ed);font:600 13px/1 ui-monospace,monospace;cursor:pointer;transition:border-color .12s,background .12s}
.qidc-acct-chip:hover{border-color:var(--qidc-accent,#34d399)}
.qidc-acct-chip .qidc-dot{background:var(--qidc-accent,#34d399);flex:none;animation:none}
.qidc-acct-chip .qidc-caret{margin-left:.15em;color:var(--qidc-muted,#8fae9d);transition:transform .15s}
.qidc-acct[data-open="true"] .qidc-caret{transform:rotate(180deg)}
.qidc-acct-menu{position:absolute;top:calc(100% + 8px);right:0;min-width:230px;background:var(--qidc-bg,#101613);border:1px solid var(--qidc-border,#25352c);border-radius:14px;box-shadow:0 16px 44px rgba(0,0,0,.5);padding:12px;z-index:99998;animation:qidc-pop var(--qidc-anim-duration,.14s) ease;font:14px/1.4 system-ui,sans-serif}
.qidc-acct-label{font:700 10px/1 system-ui,sans-serif;letter-spacing:.5px;text-transform:uppercase;color:var(--qidc-muted,#8fae9d);margin:2px 2px 6px}
.qidc-acct-addr{display:flex;align-items:center;gap:.5em;background:var(--qidc-code-bg,#0a0f0c);border:1px solid var(--qidc-border,#25352c);border-radius:9px;padding:8px 10px;font:11.5px/1.35 ui-monospace,monospace;color:var(--qidc-fg,#e9f3ed);word-break:break-all;cursor:pointer}
.qidc-acct-addr:hover{border-color:var(--qidc-accent,#34d399)}
.qidc-acct-addr .qidc-copyhint{margin-left:auto;flex:none;color:var(--qidc-muted,#8fae9d);font:600 10.5px/1 system-ui,sans-serif}
.qidc-acct-sep{height:1px;background:var(--qidc-border,#25352c);margin:10px -12px}
.qidc-acct-item{display:flex;align-items:center;gap:.6em;width:100%;box-sizing:border-box;text-align:left;border:none;background:transparent;color:var(--qidc-fg,#e9f3ed);font:600 13px/1 system-ui,sans-serif;padding:9px 8px;border-radius:8px;cursor:pointer;text-decoration:none}
.qidc-acct-item:hover{background:var(--qidc-code-bg,#0a0f0c)}
.qidc-acct-item.danger{color:#f87171}
.qidc-acct-item.danger:hover{background:rgba(248,113,113,.1)}
.qidc-acct-item svg{flex:none;opacity:.85}
.qidc-acct-logout-err{margin:8px 2px 2px;color:#f87171;font:600 11.5px/1.35 system-ui,sans-serif}
@media (prefers-reduced-motion: reduce){
.qidc-overlay,.qidc-modal,.qidc-copy,.qidc-info,.qidc-dot,.qidc-check,.qidc-acct-menu,.qidc-caret{animation:none;transition:none}
}
@media (prefers-color-scheme: light){
.qidc-modal{--qidc-bg:#ffffff;--qidc-fg:#0c1410;--qidc-border:#dbe7e0;--qidc-muted:#5c7268;--qidc-code-bg:#f3f7f5}
}
`;

// The qID mark: a geometric q with an electron dot.
const MARK = `<svg width="19" height="19" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="6.4" stroke="currentColor" stroke-width="2.2"/><path d="M15.6 15.6 L20 20" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/><circle cx="20" cy="5" r="1.7" fill="currentColor"/></svg>`;
const SHIELD = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2l8 3v6c0 5-3.4 9.4-8 11-4.6-1.6-8-6-8-11V5l8-3z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M8.5 12l2.5 2.5 4.5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const CHECK = `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4.5 12.5l5 5 10-11" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>`;

const TAB_LABEL = { deeplink: "Open in wallet", paste: "Desktop wallet", qr: "Scan with phone" };

// Dynamic connector (v2 Job A). The sign-in request rotates on the client so a
// screenshotted or stale request cannot be reused: it has a short DISPLAY
// lifetime, a visible countdown, and it proactively refreshes a little before it
// expires. On refresh the widget asks the server to RETIRE (burn) the superseded
// nonce (see core.js challenge({ retire })), so the real acceptance window tracks
// this display lifetime, not the server's longer TTL ceiling. Pure UX plus one
// safe server tightening; no protocol change, no new signing surface.
const DISPLAY_TTL_MS = 120_000; // how long a connect request is shown before it rotates
const REFRESH_AT_MS = 15_000; // rotate this long before the display lifetime ends

function injectStyles() {
  if (document.getElementById("qidc-styles")) return;
  const style = document.createElement("style");
  style.id = "qidc-styles";
  style.textContent = CSS;
  document.head.appendChild(style);
}

function el(tag, className, html) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (html != null) node.innerHTML = html;
  return node;
}

async function copyToClipboard(text, button) {
  try {
    await navigator.clipboard.writeText(text);
    const old = button.textContent;
    button.textContent = "Copied";
    setTimeout(() => (button.textContent = old), 1200);
  } catch {
    /* the user can still select the text manually */
  }
}

function shortAddress(addr) {
  return addr.length > 24 ? `${addr.slice(0, 14)}…${addr.slice(-8)}` : addr;
}

// Normalize the api base so a trailing slash (a natural typo: api: "/qid/")
// doesn't produce "/qid//challenge", which a WHATWG URL parse on the server
// mis-reads as protocol-relative and 404s every endpoint. Keep a bare "/".
function normalizeApi(api) {
  const s = String(api == null ? "" : api);
  return s.length > 1 ? s.replace(/\/+$/, "") : s;
}

const HOW_URL = "https://qid.dev/connect/how";
// The small "i" affordance. Was a bare <a target="_blank"> that navigated away
// from the sign-in flow in a browser and did NOTHING in embedded webviews (the
// bonuz app, in-app browsers) where target=_blank is a no-op — so the help was
// unreachable exactly where it is most needed. Now it toggles a one-line inline
// hint (a guaranteed, in-flow reaction in every context) that still links out
// to the full guide.
function infoLink() {
  const btn = document.createElement("button");
  btn.type = "button";
  btn.className = "qidc-info";
  btn.textContent = "i";
  btn.title = "How to connect";
  btn.setAttribute("aria-label", "How to connect");
  btn.setAttribute("aria-expanded", "false");
  let panel = null;
  btn.onclick = (e) => {
    e.preventDefault();
    if (panel) { panel.remove(); panel = null; btn.setAttribute("aria-expanded", "false"); return; }
    panel = el("p", "qidc-help");
    panel.style.cssText = "margin-top:8px;text-align:center";
    const a = document.createElement("a");
    a.className = "qidc-link";
    a.href = HOW_URL;
    a.target = "_blank";
    a.rel = "noopener";
    a.textContent = "Full guide ↗";
    panel.append(
      document.createTextNode(
        "Scan the QR with your phone wallet, or open/paste in your desktop wallet. A sign-in only proves ownership — it can never move funds. "
      ),
      a
    );
    // Drop the hint right below the status/help line that hosts this button.
    const host = btn.parentElement;
    (host.parentElement || host).insertBefore(panel, host.nextSibling);
    btn.setAttribute("aria-expanded", "true");
  };
  return btn;
}

function qrSvg(text) {
  const qr = qrcodegen(0, "Q"); // auto version, high correction: survives the center mark
  qr.addData(text);
  qr.make();
  return qr.createSvgTag(4, 0).replace('fill="white"', 'fill="#ffffff"');
}

export function mountQidConnect(target, options = {}) {
  const {
    api = "/qid",
    appName = document.title || "this app",
    transports = ["qr", "paste", "deeplink"],
    buttonText = "Sign in with qID",
    onSignedIn = () => {},
    onError = () => {},
    onClose = () => {},
    trigger = true,
    pollIntervalMs = 1500,
    wallets = [
      { name: "PQ Wallet for BTX", status: "available", url: "https://pq-wallet.com" },
      { name: "bonuz Wallet", status: "available", url: "https://bonuz.xyz/btx-wallet" },
    ],
    signingFx = true,
  } = options;

  if (!target) throw new Error("mountQidConnect: target element is required");
  injectStyles();
  const apiBase = normalizeApi(api);

  // Structured errors for onError: { stage: 'challenge'|'verify'|'poll', code?, httpStatus?, cause? }.
  function structuredError(stage, cause, extra = {}) {
    const err = new Error(
      extra.message || (cause && cause.message) || `qid-connect: ${stage} failed`
    );
    err.stage = stage;
    if (extra.code) err.code = extra.code;
    if (extra.httpStatus != null) err.httpStatus = extra.httpStatus;
    else if (cause && cause.httpStatus != null) err.httpStatus = cause.httpStatus;
    if (cause) err.cause = cause;
    return err;
  }

  let button = null;
  if (trigger) {
    button = el("button", "qidc-btn", `${MARK}<span>${escapeHtml(buttonText)}</span>`);
    button.type = "button";
    target.appendChild(button);
  }

  let overlay = null;
  let pollTimer = null;
  let escHandler = null;
  let signingTimer = null; // the signing-animation interval
  let successTimer = null;  // the post-check delay before onSignedIn
  let tickTimer = null;     // the 1s countdown + proactive-rotation tick (v2 Job A)
  let amnesiaRenews = 0;    // one free auto-recovery when the server forgets a fresh nonce
  let deeplinkFallbackTimer = null; // the deep-link "not installed" reveal timer (hoisted so close() clears it)
  let displayDeadline = 0;  // client-side display expiry of the current challenge (v2 Job A)
  let countdownEl = null;   // the shared countdown / expiry line the tick updates (v2 Job A)
  let closed = false;       // true after the dialog is torn down; cancel means cancel
  let settled = false;      // a proof was accepted; rotation must stop (terminal state)
  let retryTimer = null;    // pending auto-retry after a failed rotation
  let openSeq = 0;          // open() generation token; a stale open's continuations bail
  let lastFocus = null;     // element to restore focus to when the modal closes
  let destroyed = false;    // unmount() is final: open() no-ops afterward

  // teardown clears all live state WITHOUT firing onClose; close() layers the
  // user-facing semantics (focus restore + onClose) on top. Re-open uses
  // teardown so it doesn't emit a spurious onClose for a dialog that stays open.
  function teardown() {
    closed = true;
    if (pollTimer) clearInterval(pollTimer), (pollTimer = null);
    if (signingTimer) clearInterval(signingTimer), (signingTimer = null);
    if (successTimer) clearTimeout(successTimer), (successTimer = null);
    if (tickTimer) clearInterval(tickTimer), (tickTimer = null);
    if (retryTimer) clearTimeout(retryTimer), (retryTimer = null);
    if (deeplinkFallbackTimer) clearTimeout(deeplinkFallbackTimer), (deeplinkFallbackTimer = null);
    countdownEl = null;
    if (escHandler) document.removeEventListener("keydown", escHandler), (escHandler = null);
    if (overlay) overlay.remove(), (overlay = null);
  }
  function close(reason = "programmatic") {
    const wasOpen = !!overlay;
    teardown();
    if (wasOpen) {
      if (lastFocus && typeof lastFocus.focus === "function") lastFocus.focus();
      lastFocus = null;
      onClose(reason);
    }
  }

  // Fetch a fresh sign-in request. On a rotation, pass the superseded nonce as
  // `retire` PLUS its poll secret so the server burns it (core.js
  // challenge({ retire, retireSecret })); the secret proves we were the browser
  // issued that nonce, so a bystander who only saw the QR can't grief it. This
  // keeps the real acceptance window tied to the visible display lifetime.
  async function fetchChallenge(retire, retireSecret) {
    // Bounded lifetime: a hung request (black-holed TCP, captive portal) must
    // REJECT, not hang forever — otherwise the renewing re-entrancy guard would
    // stay latched and block all further renews for this dialog.
    const ctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
    const timer = ctrl ? setTimeout(() => ctrl.abort(), 20000) : null;
    try {
      const res = await fetch(`${apiBase}/challenge`, {
        method: "POST",
        ...(ctrl ? { signal: ctrl.signal } : {}),
        ...(typeof retire === "string" && retire
          ? {
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify(
                typeof retireSecret === "string" && retireSecret
                  ? { retire, retireSecret }
                  : { retire }
              ),
            }
          : {}),
      });
      if (!res.ok) {
        const err = new Error("challenge_failed");
        err.httpStatus = res.status;
        throw err;
      }
      return res.json(); // { challenge, request, pollSecret }
    } finally {
      if (timer) clearTimeout(timer);
    }
  }

  async function verifyWithServer(proof) {
    // Same bounded lifetime as fetchChallenge: a black-holed connection must
    // reject (re-enabling the button) instead of leaving "Checking proof" stuck
    // for the minutes the OS takes to kill the socket.
    const ctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
    const timer = ctrl ? setTimeout(() => ctrl.abort(), 20000) : null;
    try {
      const res = await fetch(`${apiBase}/verify`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ proof }),
        credentials: "same-origin",
        ...(ctrl ? { signal: ctrl.signal } : {}),
      });
      const verdict = await res.json().catch(() => ({}));
      if (res.ok && verdict.ok) {
        return { ok: true, address: verdict.address, account: verdict.account };
      }
      return { ok: false, reason: verdict.reason || "server_error" };
    } catch {
      return { ok: false, reason: "server_error" };
    } finally {
      if (timer) clearTimeout(timer);
    }
  }

  function showSuccess(modalBody, { address, account }) {
    settled = true;
    if (pollTimer) clearInterval(pollTimer), (pollTimer = null);
    if (tickTimer) clearInterval(tickTimer), (tickTimer = null);
    modalBody.innerHTML = "";
    const done = el("div", "qidc-done");
    done.append(
      el("div", "qidc-check", CHECK),
      el("b", null, "Signed in"),
      (() => { const a = el("div", "qidc-addr"); a.textContent = shortAddress(address); return a; })()
    );
    modalBody.appendChild(done);
    successTimer = setTimeout(() => {
      successTimer = null;
      // If the user tore the dialog down before this fired, cancel means cancel:
      // do not fire the success side effect (in an integrator that is the login /
      // the relic claim). close() below is idempotent.
      if (closed) return;
      close("signedin");
      onSignedIn({ address, account });
    }, 1100);
  }

  // A brief cypherpunk beat between "proof accepted" and the check mark: a
  // tiny character rain in the brand accent, ~0.8s, all local, a few hundred
  // bytes. Skipped when the user asks for reduced motion or signingFx=false.
  function showSigning(modalBody, done) {
    settled = true;
    if (tickTimer) clearInterval(tickTimer), (tickTimer = null);
    const reduced =
      typeof matchMedia === "function" &&
      matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!signingFx || reduced) return done();
    if (pollTimer) clearInterval(pollTimer), (pollTimer = null);
    modalBody.innerHTML = "";
    const wrap = el("div", "qidc-signing");
    const pre = el("pre", "qidc-matrix");
    pre.setAttribute("aria-hidden", "true");
    const label = el("div", "qidc-signing-label", "Verifying the post-quantum signature");
    wrap.append(pre, label);
    modalBody.appendChild(wrap);

    const GLYPHS = "01<>[]{}#$%&*+=~^?!/\\|;:.abcdef";
    const COLS = 30;
    const ROWS = 5;
    const FRAME_MS = 55;
    const TOTAL_MS = 820;
    const start = Date.now();
    signingTimer = setInterval(() => {
      // Stop mutating detached nodes and never advance to success if the user
      // closed the dialog mid-animation.
      if (closed) {
        clearInterval(signingTimer), (signingTimer = null);
        return;
      }
      const t = (Date.now() - start) / TOTAL_MS;
      if (t >= 1) {
        clearInterval(signingTimer), (signingTimer = null);
        done();
        return;
      }
      let grid = "";
      for (let r = 0; r < ROWS; r++) {
        for (let c = 0; c < COLS; c++) {
          // A settle wave sweeps left to right: behind it the noise freezes
          // into dots, ahead of it the rain keeps flickering.
          const settledCell = c / COLS < t * 1.25 - (r / ROWS) * 0.15;
          grid += settledCell
            ? (r === Math.floor(ROWS / 2) && Math.random() < 0.12 ? "q" : "·")
            : GLYPHS[(Math.random() * GLYPHS.length) | 0];
        }
        if (r < ROWS - 1) grid += "\n";
      }
      pre.textContent = grid;
    }, FRAME_MS);
  }

  // A verified proof only signs the user in if the browser actually STORED the
  // session cookie. If cookies are blocked (strict settings, some embedded
  // webviews), /verify and /poll return 200 but Set-Cookie is dropped, and a
  // "Signed in" screen would be a lie the next page load exposes. Confirm the
  // session once, then either animate success or say cookies are blocked. gen is
  // the open() generation captured by the caller, so a superseded dialog bails.
  async function finishSignIn(mount, address, account, gen) {
    if (closed || settled || gen !== openSeq) return;
    settled = true; // terminal either way: stop rotation/poll from re-entering
    if (pollTimer) clearInterval(pollTimer), (pollTimer = null);
    if (tickTimer) clearInterval(tickTimer), (tickTimer = null);
    // Bound the probe like every other fetch: this runs AFTER a proof was
    // accepted and both timers are cleared, so a black-holed same-origin GET
    // with no timeout would wedge the completion path with nothing rendered.
    // On abort the catch below falls through to success (the session cookie was
    // already set by the verify/poll that got us here).
    const ctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
    const timer = ctrl ? setTimeout(() => ctrl.abort(), 20000) : null;
    try {
      const res = await fetch(`${apiBase}/session`, {
        credentials: "same-origin",
        ...(ctrl ? { signal: ctrl.signal } : {}),
      });
      if (closed || gen !== openSeq) return;
      if (!res.ok) {
        showCookiesBlocked(mount);
        onError(structuredError("verify", null, {
          code: "cookies_blocked",
          message:
            "qid-connect: the proof verified but no session cookie was stored — the browser is blocking cookies for this site.",
        }));
        return;
      }
    } catch {
      // The confirm probe itself failed (transport error or the 20s abort):
      // don't block a real sign-in on a flaky extra GET; proceed to success.
      if (closed || gen !== openSeq) return;
    } finally {
      if (timer) clearTimeout(timer);
    }
    showSigning(mount, () => showSuccess(mount, { address, account }));
  }

  function showCookiesBlocked(mount) {
    settled = true;
    if (pollTimer) clearInterval(pollTimer), (pollTimer = null);
    if (tickTimer) clearInterval(tickTimer), (tickTimer = null);
    mount.innerHTML = "";
    const wrap = el("div", "qidc-qrwrap");
    const msg = el(
      "p",
      "qidc-help",
      "Signed in, but your browser is blocking cookies for this site, so the session could not be saved. Allow cookies for this site and try again."
    );
    msg.style.textAlign = "center";
    const retry = el("button", "qidc-primary", "Try again");
    retry.type = "button";
    retry.onclick = open;
    wrap.append(msg, retry);
    mount.appendChild(wrap);
  }

  // ---- transport views --------------------------------------------------

  function pasteView(mount, session, modalBody) {
    const requestJson = JSON.stringify(session.request);

    const step1 = el("div", "qidc-step", "1. Copy the sign-in request");
    const code = el("div", "qidc-code");
    code.textContent = requestJson;
    const copyBtn = el("button", "qidc-copy", "Copy");
    copyBtn.type = "button";
    copyBtn.onclick = () => copyToClipboard(requestJson, copyBtn);
    code.appendChild(copyBtn);
    const help = el(
      "p",
      "qidc-help",
      "In <b>PQ Wallet for BTX</b>: <b>Settings → qID Sign-In</b>. Paste the request, sign, paste the proof back. "
    );
    help.appendChild(infoLink());

    const step2 = el("div", "qidc-step", "2. Paste the proof from the wallet");
    const ta = el("textarea", "qidc-ta");
    ta.placeholder = '{"v":1,"alg":"ML-DSA-44","address":"btx1..."}';
    ta.setAttribute("aria-label", "Proof from the wallet");

    const actions = el("div", "qidc-actions");
    const submit = el("button", "qidc-primary", "Verify and sign in");
    submit.type = "button";
    submit.disabled = true;
    actions.append(submit);
    const err = el("div", "qidc-err", "");

    ta.addEventListener("input", () => {
      submit.disabled = ta.value.trim().length === 0;
      err.textContent = "";
    });
    submit.onclick = async () => {
      let proof;
      try {
        proof = JSON.parse(ta.value.trim());
      } catch {
        err.textContent = REASON_TEXT.bad_proof_shape;
        onError(structuredError("verify", null, { code: "bad_proof_shape" }));
        return;
      }
      // Capture the open() generation: if the user closes (and maybe reopens)
      // while /verify is in flight, this stale continuation must not drive the
      // signing animation into a fresh dialog or fire success for a dead one.
      const gen = openSeq;
      submit.disabled = true;
      submit.textContent = "Checking proof";
      const verdict = await verifyWithServer(proof);
      if (closed || gen !== openSeq) return; // superseded / torn down mid-verify
      submit.disabled = false;
      submit.textContent = "Verify and sign in";
      if (verdict.ok) finishSignIn(modalBody, verdict.address, verdict.account, gen);
      else {
        err.textContent = REASON_TEXT[verdict.reason] || REASON_TEXT.server_error;
        onError(structuredError("verify", null, { code: verdict.reason }));
      }
    };

    mount.append(step1, code, help, step2, ta, actions, err);
  }

  function qrView(mount, session, modalBody, renewSession) {
    const wrap = el("div", "qidc-qrwrap");
    const card = el("div", "qidc-qrcard");
    card.innerHTML = qrSvg(JSON.stringify(session.request));
    card.appendChild(el("div", "qidc-qrmark", "qID"));

    const wait = el("div", "qidc-wait");
    const waitText = el("span", null, "Waiting for your wallet");
    wait.append(el("span", "qidc-dot"), waitText, infoLink());

    const caption = el(
      "p",
      "qidc-help",
      "Scan with your phone wallet. Approve, and you are in."
    );
    caption.style.textAlign = "center";

    const copyLink = el("button", "qidc-link", "Copy request instead");
    copyLink.type = "button";
    copyLink.onclick = () => copyToClipboard(JSON.stringify(session.request), copyLink);

    card.style.cursor = "pointer";
    card.title = "Click to copy the sign-in request";
    card.onclick = () => copyToClipboard(JSON.stringify(session.request), copyLink);
    wrap.append(card, wait, caption, copyLink);
    mount.appendChild(wrap);

    return {
      expire() {
        wait.innerHTML = "";
        const renew = el("button", "qidc-primary", "Code expired, get a new one");
        renew.type = "button";
        renew.onclick = renewSession;
        wait.appendChild(renew);
      },
      setTrouble(bad) {
        waitText.textContent = bad
          ? "Trouble reaching the server — still trying"
          : "Waiting for your wallet";
      },
    };
  }

  // Desktop direct-connect: launch the installed PQ Wallet for BTX with the same request the QR carries,
  // via btxqid://sign?req=<base64url(request)>. The wallet POSTs its proof to proof_url; the already
  // running poll loop (startPolling) picks the session up — identical completion path to the QR flow.
  // Returns { expire() } so the poll loop's expiry→renew branch keeps working, exactly like qrView.
  function deeplinkView(mount, session, modalBody, renewSession) {
    const wrap = el("div", "qidc-qrwrap");
    const caption = el(
      "p",
      "qidc-help",
      "Opens your installed <b>PQ Wallet for BTX</b>. Approve, and you are in."
    );
    caption.style.textAlign = "center";

    const openBtn = el("button", "qidc-primary");
    openBtn.type = "button";
    openBtn.textContent = "Open in PQ Wallet for BTX";

    const status = el("div", "qidc-wait");
    const statusText = el("span", null, "Waiting for your wallet");
    status.append(el("span", "qidc-dot"), statusText, infoLink());

    // Fallback affordances, revealed only if nothing happens: download link + copy the request so the
    // user can still complete via paste on another machine. All static text / textContent, no dynamic HTML.
    const fallback = el("div", null);
    fallback.style.cssText = "text-align:center;margin-top:6px";
    fallback.style.display = "none";
    const dl = el("a", "qidc-link");
    dl.textContent = "Don't have the wallet? Download it";
    dl.href = "https://pq-wallet.com";
    dl.target = "_blank";
    dl.rel = "noopener";
    const copyLink = el("button", "qidc-link", "Copy request instead");
    copyLink.type = "button";
    copyLink.onclick = () => copyToClipboard(JSON.stringify(session.request), copyLink);
    fallback.append(dl, el("br"), copyLink);


    openBtn.onclick = () => {
      // A hidden anchor click is the most reliable custom-scheme trigger; location.href also works.
      const a = document.createElement("a");
      a.href = buildBtxqidSignUrl(session.request);
      a.style.display = "none";
      document.body.appendChild(a);
      a.click();
      a.remove();
      // Immediate feedback: acknowledge the click right away so the button never
      // feels dead. Without this, nothing visibly changes for seconds — and when
      // the OS has no btxqid:// handler (the common "nothing happened" report)
      // nothing ever would. Re-enable shortly with an "again" label so a retry
      // reads as a retry.
      openBtn.disabled = true;
      openBtn.textContent = "Opening PQ Wallet for BTX…";
      setTimeout(() => {
        if (openBtn.isConnected) { openBtn.disabled = false; openBtn.textContent = "Open in PQ Wallet for BTX again"; }
      }, 1200);
      // Handoff grace: the user now leaves for the wallet's approval screen,
      // which realistically takes longer than the time left before the next
      // rotation. Push the display deadline out so rotation doesn't retire the
      // nonce mid-approval (which would 401 the wallet's proof). The server TTL
      // still bounds real acceptance. Same rationale as the paste exemption.
      displayDeadline = Math.max(displayDeadline, Date.now() + 60000);
      // Reveal the fallback affordances quickly REGARDLESS of blur: a failed
      // scheme launch (stale handler, app missing) also blurs the page via the
      // OS error dialog, so blur is not proof the wallet opened. If the wallet
      // did open, the extra affordances below the status line are harmless.
      // The timer is hoisted to mount scope so close() clears it if the dialog
      // is torn down first.
      if (deeplinkFallbackTimer) clearTimeout(deeplinkFallbackTimer);
      deeplinkFallbackTimer = setTimeout(() => {
        deeplinkFallbackTimer = null;
        fallback.style.display = "";
      }, 2500);
    };

    wrap.append(openBtn, status, caption, fallback);
    mount.appendChild(wrap);

    return {
      expire() {
        if (deeplinkFallbackTimer) clearTimeout(deeplinkFallbackTimer), (deeplinkFallbackTimer = null);
        status.innerHTML = ""; // static replacement, no user data (matches qrView)
        const renew = el("button", "qidc-primary", "Code expired, get a new one");
        renew.type = "button";
        renew.onclick = renewSession;
        status.appendChild(renew);
      },
      setTrouble(bad) {
        statusText.textContent = bad
          ? "Trouble reaching the server — still trying"
          : "Waiting for your wallet";
      },
    };
  }

  // ---- the dialog --------------------------------------------------------

  async function open() {
    if (destroyed) return; // unmount() is final
    // Capture the focus-restore target BEFORE teardown removes the overlay. On a
    // re-open-while-open (the "Try again" path) keep the original trigger rather
    // than letting activeElement fall to <body>.
    const priorFocus = overlay ? lastFocus : document.activeElement;
    teardown(); // re-open: tear the old dialog down WITHOUT firing onClose
    const gen = ++openSeq; // a second open() invalidates this one's continuations
    closed = false; // fresh dialog: re-arm the success side effect (close() set it true)
    settled = false;
    lastFocus = priorFocus;
    overlay = el("div", "qidc-overlay");
    // Theming: the overlay is portaled to <body>, so --qidc-* custom properties
    // set on the mount element would never reach it. Copy them across so the
    // documented per-mount theming works for the dialog too.
    try {
      const cs = getComputedStyle(target);
      for (const v of ["--qidc-accent", "--qidc-bg", "--qidc-fg", "--qidc-muted", "--qidc-border",
                       "--qidc-code-bg", "--qidc-btn-bg", "--qidc-btn-fg", "--qidc-anim-duration"]) {
        const val = cs.getPropertyValue(v).trim();
        if (val) overlay.style.setProperty(v, val);
      }
    } catch { /* non-browser env */ }
    const modal = el("div", "qidc-modal");
    modal.setAttribute("role", "dialog");
    modal.setAttribute("aria-modal", "true");
    modal.setAttribute("aria-label", "Sign in with qID");

    const head = el("div", "qidc-head", `${MARK}<h2>Sign in with qID</h2>`);
    const x = el("button", "qidc-x", "×");
    x.type = "button";
    x.setAttribute("aria-label", "Close");
    x.onclick = () => close("x");
    head.appendChild(x);

    const sub = el(
      "p",
      "qidc-sub",
      `Sign in to ${escapeHtml(appName)} with your BTX address. No email, no password.`
    );

    const tabBar = el("div", "qidc-tabs");
    tabBar.setAttribute("role", "tablist");
    const body = el("div");
    body.setAttribute("role", "tabpanel");
    const note = el("div", "qidc-note");
    note.innerHTML = `${SHIELD}<span>A sign-in can never move funds.</span>`;

    // The supported-wallet list, WalletConnect style: which wallets speak qID
    // today and which are on the way. Names go in as textContent, never HTML.
    let walletList = null;
    if (Array.isArray(wallets) && wallets.length > 0) {
      walletList = el("div", "qidc-wallets");
      walletList.appendChild(el("div", "qidc-wallets-title", "Works with"));
      for (const w of wallets) {
        if (!w || typeof w.name !== "string") continue;
        // A wallet with a `url` renders as a link: clicking the name OR the
        // status badge opens the wallet's page (download / learn more) in a new
        // tab — the intuitive move for someone who doesn't have it yet.
        const hasUrl = typeof w.url === "string" && /^https?:\/\//.test(w.url);
        const row = el(hasUrl ? "a" : "div", "qidc-wallet" + (hasUrl ? " qidc-wallet-link" : ""));
        if (hasUrl) {
          row.href = w.url;
          row.target = "_blank";
          row.rel = "noopener";
          row.title = "Open " + w.name;
        }
        const name = el("span");
        name.textContent = w.name;
        const chip = el(
          "span",
          w.status === "available" ? "qidc-chip avail" : "qidc-chip soon",
          w.status === "available" ? "Available now" : "Coming soon"
        );
        row.append(name, chip);
        if (hasUrl) row.append(el("span", "qidc-wallet-go", "↗"));
        walletList.appendChild(row);
      }
    }

    modal.append(head, sub, ...(walletList ? [walletList] : []), tabBar, body, note);
    overlay.appendChild(modal);
    document.body.appendChild(overlay);

    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) close("backdrop");
    });
    // Escape closes; Tab is trapped inside the modal (aria-modal made honest).
    escHandler = (e) => {
      if (e.key === "Escape") { close("escape"); return; }
      if (e.key === "Tab" && overlay) {
        const focusables = [...overlay.querySelectorAll(
          'button, a[href], textarea, input, select, [tabindex]:not([tabindex="-1"])'
        )].filter((n) => !n.disabled && n.offsetParent !== null); // drop disabled + display:none
        if (!focusables.length) return;
        const first = focusables[0];
        const last = focusables[focusables.length - 1];
        if (!overlay.contains(document.activeElement)) { e.preventDefault(); first.focus(); }
        else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
        else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
      }
    };
    document.addEventListener("keydown", escHandler);
    modal.tabIndex = -1;
    modal.focus();

    let session;
    try {
      session = await fetchChallenge();
      if (gen !== openSeq || closed) return; // superseded by a newer open() or closed while loading
    } catch (e) {
      if (gen !== openSeq || closed) return;
      onError(structuredError("challenge", e));
      // Show the failure IN the dialog instead of flash-closing it: a dialog
      // that appears and vanishes reads as "the button is broken", when it
      // really means /challenge is unreachable.
      const down = el("div", "qidc-qrwrap");
      const msg = el("p", "qidc-help", "Sign-in is temporarily unavailable. Check your connection and try again.");
      msg.style.textAlign = "center";
      const retryBtn = el("button", "qidc-primary", "Try again");
      retryBtn.type = "button";
      retryBtn.onclick = open;
      down.append(msg, retryBtn);
      body.appendChild(down);
      return;
    }
    displayDeadline = Date.now() + DISPLAY_TTL_MS;
    amnesiaRenews = 0;

    let qrHandle = null;
    let active = transports[0];
    // Poll backoff jitter and a hard cap so a dialog left open does not poll
    // forever. The cap is anchored to when the dialog OPENED, not reset by each
    // startPolling(): otherwise every ~105s rotation would push it out and an
    // abandoned tab would poll (minting+retiring a nonce) indefinitely.
    const POLL_MAX_MS = 15 * 60 * 1000;
    const openedAt = Date.now();

    // One place to show "expired, tap to refresh", used by BOTH the server-side
    // expiry (poll returns 'expired') and any transport. This closes the old gap
    // where the paste transport had no expiry affordance at all.
    function showExpired() {
      if (pollTimer) clearInterval(pollTimer), (pollTimer = null);
      if (tickTimer) clearInterval(tickTimer), (tickTimer = null);
      if (qrHandle && typeof qrHandle.expire === "function") qrHandle.expire();
      if (countdownEl) {
        countdownEl.textContent = "";
        const renew = el("button", "qidc-link", "Request expired, get a new one");
        renew.type = "button";
        renew.onclick = renewSession;
        countdownEl.appendChild(renew);
      }
    }

    let pollFailStreak = 0;
    let unknownStatusReported = false;
    let polling = false; // in-flight guard: don't pile up on a slow link
    function startPolling() {
      if (pollTimer) clearInterval(pollTimer);
      // NOTE: no local gen shadow here. The tick closes over open()'s `gen`, so a
      // stale renewSession that calls startPolling cannot capture the current
      // openSeq and masquerade as the live dialog.
      const tick = async () => {
        if (polling) return;
        polling = true;
        // Bound the poll fetch too, so a black-holed connection can't wedge the
        // in-flight guard and stall every subsequent tick.
        const ctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
        const to = ctrl ? setTimeout(() => ctrl.abort(), 20000) : null;
        try {
          const res = await fetch(
            `${apiBase}/poll?nonce=${encodeURIComponent(session.challenge.nonce)}&secret=${encodeURIComponent(session.pollSecret)}`,
            { credentials: "same-origin", ...(ctrl ? { signal: ctrl.signal } : {}) }
          );
          const out = await res.json().catch(() => ({}));
          if (gen !== openSeq || closed || settled) return; // abandoned generation
          if (pollFailStreak >= 4 && qrHandle && typeof qrHandle.setTrouble === "function") {
            qrHandle.setTrouble(false); // recovered: back to the normal wait line
          }
          pollFailStreak = 0;
          if (out.status === "done") {
            finishSignIn(body, out.address, out.account, gen);
          } else if (out.status === "expired") {
            // An "expired" verdict seconds after issue is server amnesia (a
            // restart, or multiple instances without a shared store), not a
            // real expiry. Recover once with a fresh challenge and tell the
            // integrator via onError; a repeat falls through to the normal
            // expired affordance.
            const sinceIssue = Date.now() - (displayDeadline - DISPLAY_TTL_MS);
            if (sinceIssue < 30000 && amnesiaRenews < 1 && !closed && active !== "paste") {
              amnesiaRenews++;
              onError(structuredError("poll", null, {
                code: "server_amnesia",
                message:
                  "qid-connect: the server reported this sign-in request expired seconds after issuing it. " +
                  "If you run multiple server instances, they must share a durable store (see the production checklist in INTEGRATION.md).",
              }));
              renewSession();
            } else {
              showExpired();
            }
          } else if (out.status && out.status !== "pending") {
            // Any other status (e.g. a proxy tripping the /poll origin guard ->
            // 403 { status:"bad_origin" }) parses fine, so the wait spinner would
            // otherwise sit silently until POLL_MAX. Surface it once instead.
            if (!unknownStatusReported) {
              unknownStatusReported = true;
              if (qrHandle && typeof qrHandle.setTrouble === "function") qrHandle.setTrouble(true);
              onError(structuredError("poll", null, {
                code: out.status || "unexpected_status",
                httpStatus: res.status,
              }));
            }
          }
        } catch (e) {
          if (gen !== openSeq || closed || settled) return;
          // Transient errors keep polling, but a real outage should not look
          // identical to "waiting for wallet": after a few consecutive
          // failures, say so (and tell the integrator once), recover on success.
          pollFailStreak++;
          if (pollFailStreak === 4) {
            if (qrHandle && typeof qrHandle.setTrouble === "function") qrHandle.setTrouble(true);
            onError(structuredError("poll", e, { code: "unreachable" }));
          }
        } finally {
          if (to) clearTimeout(to);
          polling = false;
        }
      };
      // Base interval plus small jitter so many open dialogs do not hit the server in lockstep.
      const jitter = () => pollIntervalMs + Math.floor(Math.random() * 400);
      pollTimer = setInterval(() => {
        if (closed) return;
        if (Date.now() - openedAt > POLL_MAX_MS) {
          showExpired();
          return;
        }
        tick();
      }, jitter());
    }

    // The 1s countdown that also rotates the request proactively, a little before
    // its display lifetime ends, so a screenshotted or stale request cannot be
    // reused. renewSession burns the superseded nonce server-side.
    function startTick() {
      if (tickTimer) clearInterval(tickTimer);
      tickTimer = setInterval(() => {
        if (closed || settled) {
          clearInterval(tickTimer), (tickTimer = null);
          return;
        }
        // Stop rotating an abandoned dialog once it passes the poll cap, so it
        // doesn't mint+retire a fresh nonce every ~105s all night.
        if (Date.now() - openedAt > POLL_MAX_MS) {
          clearInterval(tickTimer), (tickTimer = null);
          showExpired();
          return;
        }
        // The paste flow is exempt from proactive rotation: copy → wallet →
        // sign → paste realistically takes longer than the display lifetime,
        // and rotating would wipe the textarea and retire a request the user
        // already signed. Screenshot replay is a QR/deeplink concern; the
        // server TTL still bounds a paste request's real lifetime.
        if (active === "paste") {
          if (countdownEl) countdownEl.textContent = "";
          return;
        }
        const remainMs = displayDeadline - Date.now();
        if (remainMs <= REFRESH_AT_MS) {
          clearInterval(tickTimer), (tickTimer = null);
          renewSession(); // proactive rotation before the request goes stale
          return;
        }
        if (countdownEl) countdownEl.textContent = `Refreshes in ${Math.ceil(remainMs / 1000)}s`;
      }, 1000);
    }

    let renewing = false;
    async function renewSession() {
      if (gen !== openSeq || closed || settled || renewing) return; // terminal / already renewing
      // Cancel any pending soft-retry: a fresh renew (from the manual button, a
      // tab switch, or a later tick) supersedes it. Without this the orphaned
      // timer fires ~15s on and retires the CURRENT live nonce mid sign-in.
      if (retryTimer) clearTimeout(retryTimer), (retryTimer = null);
      renewing = true;
      const prev = session; // the request we are rotating away from
      const retire = prev && prev.challenge && prev.challenge.nonce;
      const retireSecret = prev && prev.pollSecret;
      try {
        // Last-chance claim BEFORE we discard the current nonce. A QR scan /
        // deeplink handoff can be accepted server-side in the gap between our
        // last poll and this rotation; that proof is parked under the OLD nonce,
        // and once we rotate, startPolling only ever polls the new one — the
        // wallet says "signed in" while the browser shows a fresh QR forever.
        // One poll of the old nonce closes that window. (Paste never rotates.)
        if (active !== "paste" && retire && retireSecret) {
          // Bounded like every other fetch: a hung rescue poll would otherwise
          // never settle, so the outer finally never runs and renewing latches
          // true — killing the manual "get a new one" affordance. On abort we
          // fall through to a normal rotation.
          const rctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
          const rtimer = rctrl ? setTimeout(() => rctrl.abort(), 20000) : null;
          try {
            const res = await fetch(
              `${apiBase}/poll?nonce=${encodeURIComponent(retire)}&secret=${encodeURIComponent(retireSecret)}`,
              { credentials: "same-origin", ...(rctrl ? { signal: rctrl.signal } : {}) }
            );
            const out = await res.json().catch(() => ({}));
            if (gen !== openSeq || closed || settled) return;
            if (out && out.status === "done") {
              renewing = false;
              finishSignIn(body, out.address, out.account, gen);
              return;
            }
          } catch {
            /* ignore (transport error or the 20s abort): fall through to rotate */
          } finally {
            if (rtimer) clearTimeout(rtimer);
          }
          if (gen !== openSeq || closed || settled) return;
        }
        session = await fetchChallenge(retire, retireSecret);
        if (gen !== openSeq || closed || settled) return;
      } catch (e) {
        if (gen !== openSeq || closed || settled) return;
        onError(structuredError("challenge", e));
        // Keep the dialog alive: the current request stays on screen (it was
        // not retired — the fetch failed), soft-retry shortly, and offer a
        // manual retry as well.
        if (pollTimer) clearInterval(pollTimer), (pollTimer = null);
        if (tickTimer) clearInterval(tickTimer), (tickTimer = null);
        if (retryTimer) clearTimeout(retryTimer);
        retryTimer = setTimeout(() => {
          retryTimer = null;
          if (gen === openSeq && !closed && !settled) renewSession();
        }, 15000);
        if (countdownEl) {
          countdownEl.textContent = "";
          const retry = el("button", "qidc-link", "Can't reach the site — tap to retry");
          retry.type = "button";
          retry.onclick = renewSession;
          countdownEl.appendChild(retry);
        }
        return;
      } finally {
        renewing = false;
      }
      displayDeadline = Date.now() + DISPLAY_TTL_MS;
      render(active);
      startPolling();
      startTick();
    }

    function render(name) {
      active = name;
      if (deeplinkFallbackTimer) clearTimeout(deeplinkFallbackTimer), (deeplinkFallbackTimer = null);
      body.innerHTML = "";
      qrHandle = null;
      countdownEl = null;
      for (const btn of tabBar.children) {
        btn.setAttribute("aria-selected", String(btn.dataset.t === name));
      }
      if (name === "deeplink") qrHandle = deeplinkView(body, session, body, renewSession);
      else if (name === "qr") qrHandle = qrView(body, session, body, renewSession);
      else pasteView(body, session, body);
      // Shared countdown / expiry line under every transport (including paste).
      countdownEl = el("div", "qidc-countdown", "");
      body.appendChild(countdownEl);
    }

    if (transports.length > 1) {
      for (const t of transports) {
        const tab = el("button", "qidc-tab");
        tab.textContent = TAB_LABEL[t] || t; // textContent: an unknown transport name is never HTML
        tab.type = "button";
        tab.dataset.t = t;
        tab.setAttribute("role", "tab");
        tab.onclick = () => {
          // Sitting on the rotation-exempt paste tab can outlive the display
          // lifetime; entering a rotating transport with a stale request would
          // show a QR/deeplink that the server may already refuse. Renew first.
          if (t !== "paste" && Date.now() >= displayDeadline) {
            active = t;
            renewSession();
          } else {
            render(t);
            startTick();
          }
        };
        tabBar.appendChild(tab);
      }
      // ARIA tabs: arrow keys move and activate.
      tabBar.addEventListener("keydown", (e) => {
        if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
        const tabs = Array.from(tabBar.children);
        const i = tabs.findIndex((b) => b.dataset.t === active);
        const next = tabs[(i + (e.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length];
        next.focus();
        next.click();
      });
    } else {
      tabBar.style.display = "none";
    }

    render(transports[0]);
    startPolling();
    startTick();
  }

  if (button) button.onclick = open;

  return {
    open,
    close: () => close("programmatic"),
    unmount() {
      destroyed = true;
      close("programmatic");
      if (button) button.remove();
    },
  };
}

// mountQidAccount(target, options) — the signed-in counterpart to the "Sign in"
// button. Renders a compact account chip (green dot + short address) that opens
// a dropdown: the full address (click to copy), any integrator menu items, and
// Log out. Keeps the signed-in experience identical across every BTX app, the
// same way the sign-in dialog is. Reuses the same --qidc-* theming tokens.
//
//   mountQidAccount(document.querySelector("#account"), {
//     api: "/qid",                       // where the server SDK is mounted
//     onSignedOut() { location.reload() },
//     explorer: (addr) => `https://btxscan.io/address/${addr}`, // optional link
//     menuItems: [                       // optional, future-proof (e.g. Bonuz ID socials)
//       { label: "My profile", onClick(session) { ... } },
//     ],
//   });
//
// It renders nothing when there is no session (GET /qid/session -> 401), so it
// is safe to mount unconditionally next to the sign-in button. Call the
// returned refresh() after a sign-in completes to show the chip without a
// reload. Log out is a deliberate two-step (open the menu, then Log out), never
// a single accidental click.
export function mountQidAccount(target, options = {}) {
  const {
    api = "/qid",
    onSignedOut = () => {},
    // Fires when a session is first observed on this mount — which includes a
    // page-load RESTORE, not only a fresh sign-in. Don't use it for one-time
    // "welcome" side effects; use mountQidConnect's onSignedIn for that.
    onSignedIn = () => {},
    menuItems = [],
    explorer = null,
    logoutLabel = "Log out",
  } = options;
  if (!target) throw new Error("mountQidAccount: target element is required");
  injectStyles();
  const apiBase = normalizeApi(api);

  let session = null;   // { address, account, ... } or null
  let root = null;      // the .qidc-acct element
  let menu = null;      // the open dropdown, or null
  let docHandler = null;
  let destroyed = false;
  let reqGen = 0;       // request generation: a stale /session response is discarded
  let loggingOut = false;

  const LOGOUT_ICON = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 17l5-5-5-5M15 12H3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;

  function closeMenu() {
    const menuHadFocus = menu && menu.contains(document.activeElement);
    if (menu) menu.remove(), (menu = null);
    if (root) {
      root.setAttribute("data-open", "false");
      const c = root.querySelector(".qidc-acct-chip");
      if (c) {
        c.setAttribute("aria-expanded", "false");
        // Return focus to the chip if it was inside the menu, so keyboard/AT
        // users are not dropped to <body> on Escape / outside-close.
        if (menuHadFocus) c.focus();
      }
    }
    if (docHandler) {
      document.removeEventListener("click", docHandler, true);
      document.removeEventListener("keydown", docHandler, true);
      docHandler = null;
    }
  }

  function openMenu() {
    if (!root || !session || menu) return;
    menu = el("div", "qidc-acct-menu");
    menu.setAttribute("role", "menu");

    menu.appendChild(el("div", "qidc-acct-label", "Signed in"));
    const addrRow = el("div", "qidc-acct-addr");
    const addrText = el("span");
    addrText.textContent = session.address; // textContent: never HTML
    const hint = el("span", "qidc-copyhint", "Copy");
    addrRow.append(addrText, hint);
    addrRow.title = "Click to copy your address";
    addrRow.setAttribute("role", "button");
    addrRow.setAttribute("tabindex", "0");
    addrRow.onclick = () => copyToClipboard(session.address, hint);
    addrRow.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); copyToClipboard(session.address, hint); } };
    menu.appendChild(addrRow);

    if (typeof explorer === "function") {
      let href = null;
      try { href = explorer(session.address); } catch { href = null; }
      if (typeof href === "string" && /^https?:\/\//.test(href)) {
        const a = el("a", "qidc-acct-item");
        a.href = href; a.target = "_blank"; a.rel = "noopener";
        a.setAttribute("role", "menuitem");
        a.textContent = "View on explorer ↗";
        a.onclick = () => closeMenu();
        menu.appendChild(a);
      }
    }

    for (const item of Array.isArray(menuItems) ? menuItems : []) {
      if (!item || typeof item.label !== "string") continue;
      const b = el("button", "qidc-acct-item" + (item.danger ? " danger" : ""));
      b.type = "button";
      b.setAttribute("role", "menuitem");
      b.textContent = item.label; // textContent: integrator strings never HTML
      b.onclick = () => {
        closeMenu();
        try { if (typeof item.onClick === "function") item.onClick(session); } catch { /* item's problem */ }
      };
      menu.appendChild(b);
    }

    menu.appendChild(el("div", "qidc-acct-sep"));
    const out = el("button", "qidc-acct-item danger qidc-acct-logout", `${LOGOUT_ICON}<span>${escapeHtml(logoutLabel)}</span>`);
    out.type = "button";
    out.setAttribute("role", "menuitem");
    out.onclick = doLogout;
    menu.appendChild(out);

    root.appendChild(menu);
    root.setAttribute("data-open", "true");
    // Move focus into the menu so keyboard/AT users land in it (and Escape can
    // then restore focus to the chip).
    const firstItem = menu.querySelector("button, a[href], [tabindex]");
    if (firstItem) firstItem.focus();

    // Close on outside click or Escape. Capture phase so it runs before other
    // handlers; ignore clicks inside our own root.
    docHandler = (e) => {
      if (e.type === "keydown") { if (e.key === "Escape") { e.stopPropagation(); closeMenu(); } return; }
      if (root && !root.contains(e.target)) closeMenu();
    };
    document.addEventListener("click", docHandler, true);
    document.addEventListener("keydown", docHandler, true);
  }

  async function doLogout() {
    if (loggingOut) return; // ignore a double-click
    loggingOut = true;
    reqGen++; // discard any /session response that resolves after this logout
    const btn = menu && menu.querySelector(".qidc-acct-logout");
    const labelSpan = btn && btn.querySelector("span");
    const restoreLabel = labelSpan ? labelSpan.textContent : logoutLabel;
    if (labelSpan) labelSpan.textContent = "Logging out…";
    // Bound the request: a hung POST (captive portal, black-holed TCP) must not
    // latch loggingOut forever, which would freeze every future refresh().
    const ctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
    const timer = ctrl ? setTimeout(() => ctrl.abort(), 15000) : null;
    let ok = false;
    try {
      const res = await fetch(`${apiBase}/logout`, {
        method: "POST",
        credentials: "same-origin",
        ...(ctrl ? { signal: ctrl.signal } : {}),
      });
      ok = !!res.ok;
    } catch {
      ok = false;
    } finally {
      if (timer) clearTimeout(timer);
    }
    loggingOut = false;
    if (destroyed) return;
    if (!ok) {
      // The server session was NOT cleared. Do not tell the user (or the host
      // app) they are logged out when they are not — on a shared machine the
      // next person would be signed in as them. Keep the chip, restore the
      // button, and show an inline retry instead of firing onSignedOut.
      if (labelSpan) labelSpan.textContent = restoreLabel;
      if (menu && !menu.querySelector(".qidc-acct-logout-err")) {
        const e = el("div", "qidc-acct-logout-err");
        e.textContent = "Couldn't log out. Check your connection and try again.";
        menu.appendChild(e);
      }
      return;
    }
    // Invalidate any refresh() that STARTED during this logout: such a call
    // captured a gen higher than the one our leading reqGen++ set, so it would
    // survive the existing guard and a late stale /session (cookie not yet
    // cleared when that request was processed) could resurrect the chip. A
    // second bump here makes every in-flight refresh's gen stale, so none can
    // re-render the chip after we clear it.
    reqGen++;
    closeMenu();
    session = null;
    renderChip();
    onSignedOut();
  }

  function renderChip() {
    if (destroyed) return;
    // No-op skip: if the chip already shows this exact session, don't tear it
    // down. A blind repaint (e.g. a background refresh that found no change)
    // would destroy an open menu mid-read and drop keyboard focus to <body>
    // (closeMenu refocuses the chip that the next line then removes).
    if (session && root && root.dataset.addr === session.address) return;
    const focusWasInside = !!(root && root.contains(document.activeElement));
    closeMenu();
    target.innerHTML = "";
    root = null;
    if (!session) return;
    root = el("div", "qidc-acct");
    root.setAttribute("data-open", "false");
    root.dataset.addr = session.address;
    const chip = el("button", "qidc-acct-chip",
      `<span class="qidc-dot" style="width:8px;height:8px;border-radius:50%;display:inline-block"></span>` +
      `<span>${escapeHtml(shortAddress(session.address))}</span>` +
      `<span class="qidc-caret" aria-hidden="true">▾</span>`);
    chip.type = "button";
    chip.setAttribute("aria-haspopup", "menu");
    chip.setAttribute("aria-expanded", "false");
    chip.setAttribute("aria-label", "Account menu");
    chip.onclick = (e) => {
      e.stopPropagation();
      if (menu) closeMenu(); else openMenu();
      chip.setAttribute("aria-expanded", String(!!menu));
    };
    root.appendChild(chip);
    target.appendChild(root);
    // Keep keyboard/AT users on the chip if focus was inside the chip/menu we
    // just replaced (an address-changing repaint while the menu was open).
    if (focusWasInside) chip.focus();
  }

  async function refresh() {
    // Don't repaint the chip while a logout is in flight — even the transient
    // pre-clear flicker shouldn't happen. (doLogout also bumps reqGen at the
    // end, which is the load-bearing guard; this is belt-and-suspenders.)
    if (loggingOut) return session;
    const gen = ++reqGen;
    if (destroyed) return session;
    try {
      const res = await fetch(`${apiBase}/session`, { credentials: "same-origin" });
      if (destroyed || gen !== reqGen) return session; // superseded (logout / newer refresh)
      if (res.ok) {
        const next = await res.json().catch(() => null);
        if (destroyed || gen !== reqGen) return session; // re-check after the second await
        const had = !!session;
        session = next && next.address ? next : null;
        if (session && !had) onSignedIn(session);
      } else {
        session = null;
      }
    } catch {
      if (destroyed || gen !== reqGen) return session;
      // Only a transport-level failure reaches here (offline, DNS, TLS, abort):
      // keep whatever we last knew rather than flapping the chip. An HTTP error
      // status is NOT a throw — it lands on the res.ok===false branch above,
      // which clears the chip (so a 401 correctly signs the chip out). Nothing
      // to repaint on a transient transport blip, so return early.
      return session;
    }
    if (destroyed || gen !== reqGen) return session;
    renderChip();
    return session;
  }

  refresh();

  return {
    refresh,
    unmount() {
      destroyed = true;
      closeMenu();
      target.innerHTML = "";
      root = null;
    },
  };
}

// Build the desktop deep-link the PQ Wallet for BTX handles: btxqid://sign?req=<base64url(request JSON)>.
// The request is small (~300 B), well under any URL length limit; the ~7.5 KiB PROOF returns via the
// wallet's POST to proof_url, never through a URL. Exported for unit testing.
export function buildBtxqidSignUrl(request) {
  const json = JSON.stringify(request);
  const bytes = new TextEncoder().encode(json);
  let bin = "";
  for (const b of bytes) bin += String.fromCharCode(b);
  const b64url = btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `btxqid://sign?req=${b64url}`;
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (c) => ({
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#39;",
  })[c]);
}
