/* ============================================================================
 * DrNick widget — React (Babel, no build). Renders into #dn-root.
 * 3 states: FAB → welcome → chat. REAL streaming + tool trace + confirm/2FA flow.
 *
 * Ported from the design prototype (widget/widget.jsx) but the mock step-engine is
 * replaced by window.DrNickAPI (real SSE + confirm). The confirm card gains a TOTP
 * input + per-reason error handling (the mock had none).
 * ==========================================================================*/
const { useState, useRef, useEffect, useCallback } = React;

/* ── icons (inline stroke SVG, per ONFA iconography) ───────────────────────*/
const I = {
  send:  () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>,
  close: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>,
  clip:  () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.44 11.05 12.25 20.24a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>,
  check: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>,
  shield:() => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>,
  chev:  () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6"/></svg>,
  x:     () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>,
  edit:  () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>,
  doc:   () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h6"/></svg>,
  sun:   () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>,
  moon:  () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>,
  plus:  () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14"/></svg>,
  delegate: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v12M8 11l4 4 4-4"/><path d="M5 21h14"/></svg>,
  yield: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 17 9 11l4 4 8-8"/><path d="M17 7h4v4"/></svg>,
  wallet: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7h15a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h11"/><circle cx="16" cy="13" r="1.4" fill="currentColor" stroke="none"/></svg>,
  history: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 8v4l3 2"/></svg>,
};
const QICON = { delegate: I.delegate, yield: I.yield, wallet: I.wallet, history: I.history };

function fmtSize(b) {
  if (b < 1024) return b + ' B';
  if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB';
  return (b / 1024 / 1024).toFixed(1) + ' MB';
}
const isImageMime = (m) => typeof m === 'string' && m.indexOf('image/') === 0;

// Theme adapter source for EMBEDDED mode: the ONFA host marks dark with `body.dark-mode`
// (verified: header.php sets <body class="dark-mode"> on wallet/airdrop pages).
// ⚠ INVERSE of the bundle's dark default — ONFA with NO 'dark-mode' class means LIGHT.
function readHostTheme() {
  try { return document.body.classList.contains('dark-mode') ? 'dark' : 'light'; } catch (_e) { return 'light'; }
}

// EMBED mode (iframe) params — the loader stamps the host origin + initial theme onto THIS frame's
// URL (?host=<origin>&theme=dark|light). `host` is the ONLY origin we accept postMessage from and
// the ONLY target we post up to (origin-pinned, both directions). These are presentation signals —
// an origin string and a theme enum, never a credential. Absent in standalone/follow-host (the
// frame is not loaded with a query there) → HOST_ORIGIN '' and embed effects stay inert.
const EMBED_PARAMS = (() => { try { return new URLSearchParams(window.location.search); } catch (_e) { return new URLSearchParams(); } })();
const HOST_ORIGIN = EMBED_PARAMS.get('host') || '';
const INIT_THEME = EMBED_PARAMS.get('theme') === 'light' ? 'light' : 'dark';

// Character-presence avatar (avatar.css). state ∈ '' | is-present | is-thinking | is-streaming
// | is-serious | is-offline. All motion is CSS + gated on prefers-reduced-motion.
function Ava({ size, state, dot, img }) {
  const st = state || '';
  return (
    <span className={'dn-ava ' + (size || 'sm') + (st ? ' ' + st : '')}>
      {st === 'is-present' && <span className="dn-ava-halo" />}
      {st === 'is-thinking' && <span className="dn-ava-orbit" />}
      <img className="dn-ava-img" src={img || AVT} alt="" />
      {dot !== false && <span className="dn-ava-dot" />}
    </span>
  );
}

const AVT = 'assets/avt-bot.png';
const BOT = 'assets/bot.png';

/* ── UI strings — ALL user-facing copy lives here (centralised so i18n is cheap
 *    later). NOT an i18n system: no locale detection, no switcher — just one VI
 *    table. Add hardcoded copy to this object, never inline in JSX. ──────────*/
// ── UI strings, bilingual. STATIC UI ONLY — the LLM/persona language is a backend
// concern (unchanged). `STRINGS` is a mutable binding the widget re-points per `lang`
// at render time, so every existing `STRINGS.x` reference stays one-liner. ──────────
const S_VI = {
  fabTip: 'Hỏi Dr. Nick', fabOpen: 'Mở Dr. Nick', botAlt: 'Dr. Nick',
  panelLabel: 'Trợ lý Dr. Nick', headName: 'Dr. Nick', headStatus: 'Cố vấn · Trực tuyến',
  newChatAria: 'Cuộc trò chuyện mới', themeAria: 'Chuyển sáng/tối', langAria: 'Ngôn ngữ',
  quickAria: 'Hành động nhanh', closeAria: 'Đóng', sendAria: 'Gửi',
  inputPlaceholder: 'Hỏi về số dư, ủy thác, lãi suất…',
  footnote: 'Server xác thực · giao dịch luôn xem trước & do bạn xác nhận',
  attachAria: 'Đính kèm tài liệu (PDF, Word, Excel, ảnh)', removeFileAria: 'Gỡ tệp',
  fileDefaultPrompt: 'Thầy đọc giúp con tài liệu này nhé.',
  fileErrTooBig: 'Tệp quá lớn (tối đa 10 MB).',
  fileErrMime: 'Định dạng không hỗ trợ (chỉ PDF, Word, Excel, ảnh).',
  fileStatus: { uploading: 'Đang tải…', reading: 'Đang đọc…', ready: 'Sẵn sàng', parsed: 'Đã đọc', failed: 'Không đọc được', rejected: 'Bị từ chối' },
  welcomeEyebrow: 'ONFA · Dr.Nick', toolLabel: 'đang tra cứu',
  errGeneric: 'Đã xảy ra lỗi.', errConnect: 'Lỗi kết nối: ',
  confirmTitle: 'Xác nhận giao dịch', deadTitle: 'Giao dịch không hoàn tất',
  agree: 'Đồng ý', cancel: 'Huỷ', submit: 'Xác nhận', closeBtn: 'Đóng',
  otpPlaceholder: 'Mã 2FA (6 số)', otpNote: 'Nhập mã từ Google Authenticator',
  otpFormatErr: 'Mã 2FA phải gồm đúng 6 chữ số.',
  confirmed: 'Đã xác nhận — đang thực hiện.', cancelled: 'Đã huỷ. Không có gì được thực hiện.',
  closedNote: 'Đã đóng phiếu xác nhận.',
  sheetTitle: 'Bắt đầu cuộc trò chuyện mới?',
  sheetBody: 'Giao dịch đang chờ xác nhận sẽ bị huỷ. Không có khoản tiền nào bị trừ.',
  sheetStay: 'Ở lại', sheetContinue: 'Tiếp tục',
  offlineTitle: 'Mình tạm mất kết nối',
  offlineBody: 'Đừng lo — không có giao dịch nào đang chạy. Mình sẽ kết nối lại ngay khi mạng ổn định.',
  offlineStatus: 'Đang thử kết nối lại', offlineRetry: 'Thử lại ngay',
  dead: {
    not_enrolled: 'Con cần bật 2FA (Google Authenticator) trong ONFA trước, rồi yêu cầu lại giao dịch nhé.',
    totp_invalid: 'Mã không đúng — giao dịch này đã huỷ. Con yêu cầu lại giao dịch mới nhé.',
    email_otp_invalid: 'Cần mã email (tính năng đang hoàn thiện). Con yêu cầu lại giao dịch nhé.',
    email_otp_expired: 'Mã email đã hết hạn. Con yêu cầu lại giao dịch nhé.',
    ACTION_EXPIRED: 'Phiếu xác nhận đã hết hạn. Con yêu cầu lại giao dịch nhé.',
    ACTION_NOT_FOUND: 'Phiếu xác nhận không còn hiệu lực. Con yêu cầu lại giao dịch nhé.',
    _fallback: (reason) => 'Không hoàn tất được giao dịch (' + reason + '). Con yêu cầu lại nhé.',
  },
};
const S_EN = {
  fabTip: 'Ask Dr. Nick', fabOpen: 'Open Dr. Nick', botAlt: 'Dr. Nick',
  panelLabel: 'Dr. Nick assistant', headName: 'Dr. Nick', headStatus: 'Advisor · Online',
  newChatAria: 'New conversation', themeAria: 'Toggle theme', langAria: 'Language',
  quickAria: 'Quick actions', closeAria: 'Close', sendAria: 'Send',
  inputPlaceholder: 'Ask about balance, delegation, yield…',
  footnote: 'Server-verified · actions always preview before they run',
  attachAria: 'Attach a document (PDF, Word, Excel, image)', removeFileAria: 'Remove file',
  fileDefaultPrompt: 'Please read this document for me.',
  fileErrTooBig: 'File too large (max 10 MB).',
  fileErrMime: 'Unsupported format (PDF, Word, Excel, image only).',
  fileStatus: { uploading: 'Uploading…', reading: 'Reading…', ready: 'Ready', parsed: 'Read', failed: 'Unreadable', rejected: 'Rejected' },
  welcomeEyebrow: 'ONFA · Dr.Nick', toolLabel: 'looking up',
  errGeneric: 'Something went wrong.', errConnect: 'Connection error: ',
  confirmTitle: 'Confirm transaction', deadTitle: 'Transaction not completed',
  agree: 'Agree', cancel: 'Cancel', submit: 'Confirm', closeBtn: 'Close',
  otpPlaceholder: '2FA code (6 digits)', otpNote: 'Enter the code from Google Authenticator',
  otpFormatErr: 'The 2FA code must be exactly 6 digits.',
  confirmed: 'Confirmed — executing.', cancelled: 'Cancelled. Nothing was executed.',
  closedNote: 'Confirmation closed.',
  sheetTitle: 'Start a new conversation?',
  sheetBody: 'The pending transaction will be cancelled. No funds are moved.',
  sheetStay: 'Stay', sheetContinue: 'Continue',
  offlineTitle: 'I’m offline for a moment',
  offlineBody: 'Don’t worry — no transaction is running. I’ll reconnect as soon as the network is back.',
  offlineStatus: 'Reconnecting', offlineRetry: 'Try again',
  dead: {
    not_enrolled: 'Please enable 2FA (Google Authenticator) in ONFA first, then request the transaction again.',
    totp_invalid: 'Wrong code — this transaction was cancelled. Please request a new one.',
    email_otp_invalid: 'Email code needed (feature in progress). Please request the transaction again.',
    email_otp_expired: 'The email code expired. Please request the transaction again.',
    ACTION_EXPIRED: 'The confirmation expired. Please request the transaction again.',
    ACTION_NOT_FOUND: 'The confirmation is no longer valid. Please request the transaction again.',
    _fallback: (reason) => 'Could not complete the transaction (' + reason + '). Please try again.',
  },
};
let STRINGS = S_VI; // re-pointed per `lang` in DrNickWidget render

// Quick-action bar: both the visible LABEL and the spoken MESSAGE localise by `lang` (EN-default).
// Sending the message in the active language keeps the bot's reply in that language (user-typed wins).
const QUICK_ACTIONS = [
  { key: 'delegate', icon: 'delegate', vi: 'Ủy thác', en: 'Delegate',
    msg: 'Con muốn ủy thác cho thầy đầu tư hộ.', msg_en: 'I want to delegate funds for you to invest on my behalf (ZenInvest).' },
  { key: 'yield', icon: 'yield', vi: 'Nhận lãi', en: 'Claim yield',
    msg: 'Con muốn nhận lãi các khoản đã đáo hạn.', msg_en: 'I want to claim the yield on my matured positions.' },
  { key: 'balance', icon: 'wallet', vi: 'Số dư', en: 'Balance',
    msg: 'Số dư của con bao nhiêu?', msg_en: "What's my balance?" },
  { key: 'history', icon: 'history', vi: 'Lịch sử', en: 'History',
    msg: 'Xem lịch sử giao dịch của con.', msg_en: 'Show my transaction history.' },
];

/* ── dead-state message per backend reason (never lump email vs totp vs enroll) ──
 * A card is DEAD when the one-time token was burned/closed (totp_invalid), the user isn't enrolled,
 * the token expired, or any other non-retryable failure. The transaction is over — the user must ask
 * DrNick again. `totp_format` is NOT dead: the token is still alive, retry in place. */
function deadMessage(reason) {
  return STRINGS.dead[reason] || STRINGS.dead._fallback(reason);
}

/* ── markdown bubble ───────────────────────────────────────────────────────*/
function Markdown({ md, streaming }) {
  const html = window.renderMarkdown(md || '');
  return (
    <div className="dn-bubble">
      <span dangerouslySetInnerHTML={{ __html: html }} />
      {streaming && <span className="dn-caret" />}
    </div>
  );
}

/* ── tool trace row ────────────────────────────────────────────────────────*/
function ToolRow({ item }) {
  const done = item.status === 'done';
  return (
    <div className={'dn-tool' + (done ? ' done' : '')}>
      <span className="spin">{done && <I.check />}</span>
      <span className="lbl">{STRINGS.toolLabel}{done ? '' : '…'}</span>
      <span className="nm">{item.name}</span>
    </div>
  );
}

/* ── card body: structured rows (design's .dn-confirm-rows) + a warning line, else the summary
 *    string fallback. The detailed deal is here; the bubble only says a short spoken line. ──────*/
function CardBody({ c }) {
  if (c.summary_rows && c.summary_rows.length) {
    return (
      <>
        <div className="dn-confirm-rows">
          {c.summary_rows.map((r, i) => (
            <div className="dn-confirm-row" key={i}>
              <span className="k">{r.k}</span>
              <span className={'v' + (r.gold ? ' gold' : '')}>{r.v}</span>
            </div>
          ))}
        </div>
        {c.warning && <div className="dn-confirm-warn">{c.warning}</div>}
      </>
    );
  }
  return <div className="dn-confirm-ttl-line">{c.summary}</div>;
}

/* ── confirm card — TWO STEPS: preview → 2FA. Terminal: confirmed/cancelled/dead/closed ──
 * Step 1 (preview): summary + [Đồng ý][Huỷ], NO TOTP — the user reads the deal un-rushed.
 * Step 2 (twofa): "Đồng ý" reveals the TOTP input (FE-only, same confirm_token, NO API call here).
 * A DEAD card always keeps a working [Đóng] — dead = transaction over, never a stuck UI. */
function ConfirmCard({ item, onAgree, onCancel, onSubmit2fa, onClose }) {
  const c = item.card; // { confirm_token, summary, requires_2fa, action_type }
  const [totp, setTotp] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [formatErr, setFormatErr] = useState(false);

  // ── terminal renders ──
  if (item.status === 'confirmed') {
    return (
      <div className="dn-confirm resolved">
        <div className="dn-confirm-resolved-note ok"><I.check /> {STRINGS.confirmed}</div>
      </div>
    );
  }
  if (item.status === 'cancelled') {
    return (
      <div className="dn-confirm resolved">
        <div className="dn-confirm-resolved-note cancel"><I.x /> {STRINGS.cancelled}</div>
      </div>
    );
  }
  if (item.status === 'closed') {
    return (
      <div className="dn-confirm resolved">
        <div className="dn-confirm-resolved-note cancel"><I.x /> {STRINGS.closedNote}</div>
      </div>
    );
  }

  const head = (
    <div className="dn-confirm-head">
      <span className="ttl"><I.shield /> {item.status === 'dead' ? STRINGS.deadTitle : STRINGS.confirmTitle}</span>
      <span className={'onfa-badge ' + (item.status === 'dead' ? 'failed' : 'pending')}>{c.action_type || 'Action'}</span>
    </div>
  );

  // ── DEAD: token burned / not_enrolled / expired / other. No TOTP, no retry — only [Đóng]. ──
  if (item.status === 'dead') {
    return (
      <div className="dn-confirm dead">
        {head}
        <div className="dn-confirm-ttl-line">{c.summary}</div>
        <div className="dn-confirm-err">{deadMessage(item.reason)}</div>
        <div className="dn-confirm-actions">
          <button className="onfa-btn ghost" onClick={() => onClose(item)}>{STRINGS.closeBtn}</button>
        </div>
      </div>
    );
  }

  // ── STEP 1 — preview (summary only) ──
  if (item.status === 'preview') {
    return (
      <div className="dn-confirm">
        {head}
        <CardBody c={c} />
        <div className="dn-confirm-actions">
          <button className="onfa-btn ghost" onClick={() => onCancel(item)}>{STRINGS.cancel}</button>
          <button className="onfa-btn primary" onClick={() => onAgree(item)}>{STRINGS.agree}</button>
        </div>
      </div>
    );
  }

  // ── STEP 2 — 2FA (TOTP; same confirm_token). Client-side 6-digit gate blocks totp_format. ──
  const valid6 = /^\d{6}$/.test(totp);
  async function submit() {
    if (!valid6) { setFormatErr(true); return; } // never sends a malformed code
    setFormatErr(false);
    setSubmitting(true);
    let res;
    try {
      res = await onSubmit2fa(item, totp);
    } catch (e) {
      setSubmitting(false); // network throw → token almost certainly not consumed → retry in place
      return;
    }
    if (res && res.handled) return; // parent → confirmed/dead, re-renders
    setSubmitting(false); // defensive totp_format slipped through → re-enable
  }

  return (
    <div className="dn-confirm">
      {head}
      <CardBody c={c} />
      <div className="dn-otp-row">
        <input
          className="dn-otp"
          inputMode="numeric"
          maxLength={6}
          placeholder={STRINGS.otpPlaceholder}
          value={totp}
          disabled={submitting}
          onChange={(e) => { setTotp(e.target.value.replace(/\D/g, '').slice(0, 6)); setFormatErr(false); }}
        />
        <span className="dn-otp-note">{STRINGS.otpNote}</span>
      </div>
      {formatErr && <div className="dn-confirm-err">{STRINGS.otpFormatErr}</div>}
      <div className="dn-confirm-actions">
        <button className="onfa-btn ghost" disabled={submitting} onClick={() => onCancel(item)}>{STRINGS.cancel}</button>
        <button className="onfa-btn primary" disabled={submitting || !valid6} onClick={submit}>{STRINGS.submit}</button>
      </div>
    </div>
  );
}

/* ── user message ──────────────────────────────────────────────────────────*/
function UserMsg({ item }) {
  return (
    <div className="dn-msg user">
      <div className="dn-bubble">
        {item.files && item.files.map((f, i) => (
          <div className="dn-msg-file" key={i}>
            <span className="fic"><I.doc /></span>
            <span className="fmeta">
              <span className="fname">{f.name}</span>
              <span className="fsize">{fmtSize(f.size)}</span>
            </span>
          </div>
        ))}
        {item.text && <span>{item.text}</span>}
      </div>
    </div>
  );
}

/* ── welcome state ─────────────────────────────────────────────────────────*/
function Welcome({ onChip }) {
  const greet = window.DrNickAPI.greeting;
  const g = DrNickLang.greeting(greet, STRINGS === S_EN ? 'EN' : 'VI'); // language-matched welcome (EN-default)
  return (
    <div className="dn-welcome dn-welcome-hero">
      <Ava size="xl" state="is-present" img={BOT} />
      <span className="dn-eyebrow"><span className="bar" />{STRINGS.welcomeEyebrow}</span>
      <h2 className="dn-welcome-h">{g.headline}</h2>
      <p className="dn-welcome-sub">{g.sub}</p>
      <div className="dn-chips">
        {g.chips.map((c, i) => (
          <button className="dn-chip" key={i} onClick={() => onChip(c)}>
            <span>{c}</span><I.chev />
          </button>
        ))}
      </div>
    </div>
  );
}

/* ── main widget ───────────────────────────────────────────────────────────*/
function DrNickWidget({ mode }) {
  // 'self' (standalone): internal toggle, default dark. Two host-driven modes hide the own toggle:
  //   'follow-host' — same-page embed: read body.dark-mode + observe it (direct DOM).
  //   'embed'       — iframe embed: cross-origin, theme arrives via postMessage from the loader.
  const followHost = mode === 'follow-host';
  const embedFrame = mode === 'embed';
  const embedded = followHost || embedFrame; // host owns the theme; own sun/moon toggle is hidden
  const [open, setOpen] = useState(false);
  const [messages, setMessages] = useState([]);
  const [busy, setBusy] = useState(false);
  const [streaming, setStreaming] = useState(false); // a bot answer is streaming live (presence)
  const [netError, setNetError] = useState(false);   // SSE/connection lost (presence is-offline)
  const [hasNew, setHasNew] = useState(false);        // unseen bot reply while the panel is closed
  const [input, setInput] = useState('');
  const [attachments, setAttachments] = useState([]); // {lid,name,size,mime,kind,status,file_id,error}
  // theme + lang are UI PREFERENCES (identifiers, not credentials) → localStorage OK. lang switches
  // STATIC UI strings only — never the LLM/persona language (backend concern, no i18n decision yet).
  const [selfTheme, setSelfTheme] = useState(() => { try { return localStorage.getItem('drnick.theme') || 'dark'; } catch (_e) { return 'dark'; } });
  const [hostTheme, setHostTheme] = useState(() => {
    if (mode === 'follow-host') return readHostTheme(); // same-page: read host body class now
    if (mode === 'embed') return INIT_THEME;            // iframe: seed from URL, then postMessage live
    return 'dark';
  });
  const theme = embedded ? hostTheme : selfTheme; // resolved data-theme of .dn-root
  // EN-default UI language; an explicit stored choice wins, else a Vietnamese host locale (window.DN.lang)
  // → VI, everything else → EN. (Bot response language is the backend's concern — country/JWT directive.)
  const [lang, setLang] = useState(() => {
    try {
      const stored = localStorage.getItem('drnick.lang');
      const hint = window.DN && window.DN.lang ? String(window.DN.lang) : '';
      return DrNickLang.defaultLang(stored, hint); // pure: stored wins, else vi-locale → VI, else EN
    } catch (_e) { return 'EN'; }
  });
  const [showNew, setShowNew] = useState(false); // new-conversation confirm sheet (when a tx is pending)
  STRINGS = lang === 'EN' ? S_EN : S_VI; // re-point the bilingual table for this render (sync, top-down)
  const idRef = useRef(0);
  const busyRef = useRef(false);
  const bodyRef = useRef(null);
  const taRef = useRef(null);
  const fileRef = useRef(null);

  useEffect(() => { if (!embedded) { try { localStorage.setItem('drnick.theme', selfTheme); } catch (_e) { /* ignore */ } } }, [selfTheme, embedded]);
  useEffect(() => { try { localStorage.setItem('drnick.lang', lang); } catch (_e) { /* ignore */ } }, [lang]);

  // follow-host (same-page): follow the host theme live (body class), disconnect on unmount.
  // In an iframe this body is the FRAME's empty body, never the host's — so it is gated OUT of
  // 'embed' (which gets its theme via the postMessage bridge below instead).
  useEffect(() => {
    if (!followHost) return undefined;
    setHostTheme(readHostTheme());
    const obs = new MutationObserver(() => setHostTheme(readHostTheme()));
    obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
    return () => obs.disconnect();
  }, [followHost]);

  // ── EMBED (iframe) postMessage bridge — STRICT, origin-pinned, allowlist BY CONSTRUCTION ──
  // The host page and this widget are cross-origin. Exactly these messages cross; nothing else:
  //   IN  : { type:'drnick:theme', theme:'dark'|'light' }  host loader → re-skin
  //   IN  : { type:'drnick:auth',  jwt:'<RS256>' }         host loader → set the Bearer credential (M5-P2b)
  //   OUT : { type:'drnick:resize', state:'fab'|'panel' }  → loader resizes the iframe (click-through)
  // The auth channel is INBOUND ONLY (host→frame): the widget NEVER posts a token back up, never
  // persists it (apiEngine.setAuth holds it in memory), never logs/URLs it. Origin-pinned to
  // HOST_ORIGIN; any other origin or any other type is dropped.
  useEffect(() => {
    if (!embedFrame) return undefined;
    const onMsg = (e) => {
      if (e.origin !== HOST_ORIGIN) return;        // origin-pinned: reject any foreign origin
      const d = e.data;
      if (!d || typeof d !== 'object') return;
      if (d.type === 'drnick:theme') {
        setHostTheme(d.theme === 'light' ? 'light' : 'dark');
      } else if (d.type === 'drnick:auth' && typeof d.jwt === 'string') {
        window.DrNickAPI.setAuth(d.jwt);           // Bearer credential, in-memory only, never echoed up
      }
      // any other type is intentionally ignored
    };
    window.addEventListener('message', onMsg);
    return () => window.removeEventListener('message', onMsg);
  }, [embedFrame]);

  // Tell the loader to grow/shrink the iframe as the panel opens/closes — a collapsed iframe is a
  // tiny corner box so host clicks pass through. The payload is the state enum and NOTHING else.
  // Posted only to the pinned HOST_ORIGIN (never '*'); if the host origin is unknown we stay silent.
  useEffect(() => {
    if (!embedFrame || !HOST_ORIGIN) return;
    try { window.parent.postMessage({ type: 'drnick:resize', state: open ? 'panel' : 'fab' }, HOST_ORIGIN); } catch (_e) { /* ignore */ }
  }, [embedFrame, open]);

  // opening the panel clears the unseen-reply badge
  useEffect(() => { if (open) setHasNew(false); }, [open]);

  const setAttach = (lid, fields) =>
    setAttachments((cur) => cur.map((a) => (a.lid === lid ? { ...a, ...fields } : a)));
  const removeAttach = (lid) => setAttachments((cur) => cur.filter((a) => a.lid !== lid));

  const nid = () => 'm' + (++idRef.current);
  const addMsg = (m) => setMessages((cur) => [...cur, m]);
  const patch = (id, fields) =>
    setMessages((cur) => cur.map((m) => (m.id === id ? { ...m, ...fields } : m)));

  // mirror messages into a ref so async handlers (new-conversation) see the latest without a stale closure
  const messagesRef = useRef([]);
  useEffect(() => { messagesRef.current = messages; }, [messages]);

  // On mount: redraw the persisted conversation so the screen matches what the backend remembers
  // (P4.6 history). Tool/confirm rows are ephemeral and not persisted — only user/assistant text.
  const loadedRef = useRef(false);
  useEffect(() => {
    if (loadedRef.current) return;
    loadedRef.current = true;
    let alive = true;
    // Fail-fast: if THIS origin doesn't serve /v1 (frame served from a static host/CDN instead of the
    // DrNick origin), surface the offline state loudly instead of letting every call silently 404/501.
    window.DrNickAPI.checkApi().then((ok) => { if (alive && !ok) setNetError(true); });
    window.DrNickAPI.loadHistory().then((res) => {
      const hist = (res && res.messages) || [];
      if (!alive || hist.length === 0) return;
      setMessages(hist.map((m) => (m.role === 'assistant'
        ? { id: 'h' + m.id, role: 'bot', md: m.content, streaming: false }
        : { id: 'h' + m.id, role: 'user', text: m.content })));
    }).catch(() => { /* history redraw is best-effort — a fresh panel still works */ });
    return () => { alive = false; };
  }, []);

  // a confirm card awaiting the user (preview/twofa) = a pending transaction
  const pendingTx = messages.some((m) => m.role === 'confirm' && (m.status === 'preview' || m.status === 'twofa'));

  // "Cuộc trò chuyện mới": cancel any pending confirm FIRST (don't leave a pending dangling once the
  // user walks away), then forget the cid + create a fresh conversation. Cancel = safe, no money.
  const handleNewConversation = useCallback(async () => {
    setShowNew(false);
    for (const m of messagesRef.current) {
      if (m.role === 'confirm' && (m.status === 'preview' || m.status === 'twofa')) {
        try { await window.DrNickAPI.confirm(m.card.confirm_token, 'cancel'); } catch (_e) { /* best-effort */ }
      }
    }
    try { await window.DrNickAPI.resetConversation(); } catch (_e) { /* fresh panel still usable */ }
    setMessages([]);
  }, []);

  // New-conv button → if a tx is pending, confirm via the sheet first (design); else reset directly.
  const requestNew = () => { if (pendingTx) setShowNew(true); else handleNewConversation(); };

  // Quick-action → send the message in the ACTIVE language (EN-default) through the REAL apiEngine.
  const onQuick = (key) => {
    if (busyRef.current || pendingTx) return;
    const msg = DrNickLang.quickActionMsg(QUICK_ACTIONS, key, lang);
    if (msg) send(msg);
  };

  // let the landing-page CTAs open the widget
  useEffect(() => {
    const openIt = () => setOpen(true);
    window.addEventListener('drnick:open', openIt);
    return () => window.removeEventListener('drnick:open', openIt);
  }, []);

  // autoscroll
  useEffect(() => {
    const el = bodyRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [messages, open]);

  const setBusyBoth = (v) => { busyRef.current = v; setBusy(v); };

  const runTurn = useCallback((text, fileIds) => {
    setBusyBoth(true);
    setNetError(false); // a new attempt clears the offline presence
    let botId = null;       // created lazily on first token (no empty bubble on a confirm-only turn)
    let toolId = null;
    const ensureBot = () => {
      if (botId == null) { botId = nid(); addMsg({ id: botId, role: 'bot', md: '', streaming: true }); }
      return botId;
    };
    window.DrNickAPI.streamChat(text, {
      onTool: (p) => {
        toolId = nid();
        addMsg({ id: toolId, role: 'tool', name: p.name || '', status: p.status || 'done' });
      },
      onToken: (delta) => {
        setStreaming(true); // presence: is-streaming
        const id = ensureBot();
        setMessages((cur) => cur.map((m) => (m.id === id ? { ...m, md: (m.md || '') + delta } : m)));
      },
      onConfirm: (p) => {
        // confirm_token lives only inside this message object in memory — never logged/persisted.
        // Two-step card: it opens in `preview` (summary only) — TOTP appears after "Đồng ý".
        addMsg({ id: nid(), role: 'confirm', card: p, status: 'preview' });
      },
      onDone: () => {
        if (botId != null) patch(botId, { streaming: false });
        setStreaming(false);
        if (!open) setHasNew(true); // unseen reply while the panel is closed → FAB badge
        setBusyBoth(false);
      },
      onError: (p) => {
        const id = ensureBot();
        patch(id, { streaming: false, error: (p && p.message) || STRINGS.errGeneric });
        setStreaming(false);
        setNetError(true); // a stream-level error → offline presence
        setBusyBoth(false);
      },
    }, fileIds && fileIds.length ? fileIds : undefined).catch((e) => {
      const id = ensureBot();
      patch(id, { streaming: false, error: STRINGS.errConnect + e.message });
      setStreaming(false);
      setNetError(true); // connection threw → offline presence + empty-state when nothing else to show
      setBusyBoth(false);
    });
  }, [open]);

  const send = useCallback((text) => {
    if (busyRef.current) return;
    // attach files that finished uploading/parsing OK (ready images + parsed docs); skip failed/in-flight.
    const ready = attachments.filter((a) => a.status === 'ready' || a.status === 'parsed');
    const t = (text || '').trim();
    if (!t && ready.length === 0) return;
    const message = t || STRINGS.fileDefaultPrompt; // backend requires a non-empty message
    addMsg({
      id: nid(),
      role: 'user',
      text: t,
      files: ready.map((a) => ({ name: a.name, size: a.size })),
    });
    setInput('');
    setAttachments([]);
    if (taRef.current) taRef.current.style.height = 'auto';
    runTurn(message, ready.map((a) => a.file_id));
  }, [runTurn, attachments]);

  // 📎 → validate (MIME/size from the backend contract) → POST /v1/files → (docs) POST /parse.
  // Each file gets a chip whose status tracks uploading → ready|parsed|failed|rejected.
  const onPickFiles = useCallback((e) => {
    const files = Array.from((e.target && e.target.files) || []);
    if (e.target) e.target.value = ''; // allow re-picking the same file
    const api = window.DrNickAPI;
    for (const file of files) {
      const lid = 'f' + (++idRef.current);
      const kind = isImageMime(file.type) ? 'image' : 'doc';
      const base = { lid, name: file.name, size: file.size, mime: file.type, kind };
      // client-side guard (server still enforces) — reject without an upload round-trip
      if (api.ALLOWED_MIME.indexOf(file.type) === -1) {
        setAttachments((cur) => [...cur, { ...base, status: 'rejected', error: STRINGS.fileErrMime }]);
        continue;
      }
      if (file.size > api.MAX_UPLOAD_BYTES) {
        setAttachments((cur) => [...cur, { ...base, status: 'rejected', error: STRINGS.fileErrTooBig }]);
        continue;
      }
      setAttachments((cur) => [...cur, { ...base, status: 'uploading' }]);
      api.uploadFile(file).then((res) => {
        if (kind === 'image') {
          setAttach(lid, { status: 'ready', file_id: res.file_id }); // images go to vision at chat-time
          return;
        }
        setAttach(lid, { status: 'reading', file_id: res.file_id });
        api.parseFile(res.file_id)
          .then(() => setAttach(lid, { status: 'parsed' }))
          .catch((err) => setAttach(lid, { status: 'failed', error: err.code || 'parse_failed' }));
      }).catch((err) => setAttach(lid, { status: 'failed', error: err.code || 'upload_failed' }));
    }
  }, []);

  // STEP 1 "Đồng ý" → reveal the TOTP step. FE-only: NO API call, same confirm_token carries over.
  const handleAgree = (item) => patch(item.id, { status: 'twofa' });

  // Cancel from EITHER step → tell the backend (best-effort) and close the card.
  const handleCancel = async (item) => {
    try { await window.DrNickAPI.confirm(item.card.confirm_token, 'cancel'); } catch (_) { /* best-effort */ }
    patch(item.id, { status: 'cancelled' });
  };

  // STEP 2 submit. { handled:true } if the card reached confirmed/dead; only a (defensive)
  // totp_format returns un-handled so the 2FA step re-enables for another try.
  const handleSubmit2fa = async (item, totp) => {
    const res = await window.DrNickAPI.confirm(item.card.confirm_token, 'confirm', totp);
    if (res && res.success) { patch(item.id, { status: 'confirmed' }); return { handled: true }; }
    const reason = (res && (res.reason || res.error)) || 'error';
    if (reason === 'totp_format') return { reason }; // token still alive → retry in place
    // burned / not_enrolled / expired / other → DEAD (closeable). No re-enable.
    patch(item.id, { status: 'dead', reason });
    return { handled: true };
  };

  // Dismiss a dead card so the user is never stuck.
  const handleClose = (item) => patch(item.id, { status: 'closed' });

  const onSubmit = () => { if (!busyRef.current) send(input); };
  const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSubmit(); } };
  const grow = (e) => {
    setInput(e.target.value);
    const el = e.target;
    el.style.height = 'auto';
    el.style.height = Math.min(el.scrollHeight, 120) + 'px';
  };
  const uploading = attachments.some((a) => a.status === 'uploading' || a.status === 'reading');
  const readyCount = attachments.filter((a) => a.status === 'ready' || a.status === 'parsed').length;
  const canSend = !busy && !uploading && (input.trim().length > 0 || readyCount > 0);
  const quickDisabled = busy || pendingTx;
  const inChat = messages.length > 0;
  // header avatar presence (single hub): offline > serious(tx pending) > streaming > thinking(tool) > idle
  const headPresence = netError ? 'is-offline' : pendingTx ? 'is-serious' : streaming ? 'is-streaming' : busy ? 'is-thinking' : '';
  const showOffline = netError && messages.length === 0; // first connection failed → full offline state

  return (
    <div className="dn-root" data-theme={theme}>
      {!open && (
        <div className="dn-fab-wrap">
          <span className="dn-fab-tip">{STRINGS.fabTip}</span>
          <button className={'dn-fab' + (hasNew ? ' has-new' : '')} aria-label={STRINGS.fabOpen} onClick={() => setOpen(true)}>
            <img src={AVT} alt={STRINGS.botAlt} />
            {hasNew ? <span className="dn-fab-badge">1</span> : <span className="dn-fab-ping" />}
          </button>
        </div>
      )}

      {open && (
        <div className="dn-panel" role="dialog" aria-label={STRINGS.panelLabel}>
          <header className="dn-head">
            <Ava size="sm" state={headPresence} />
            <div className="dn-head-meta">
              <div className="dn-head-name">{STRINGS.headName}</div>
              <div className="dn-head-status"><span className="dot" />{netError ? STRINGS.offlineStatus : STRINGS.headStatus}</div>
            </div>
            <div className="dn-head-actions">
              <div className="dn-lang" role="group" aria-label={STRINGS.langAria}>
                <button className={lang === 'VI' ? 'on' : ''} onClick={() => setLang('VI')}>VI</button>
                <button className={lang === 'EN' ? 'on' : ''} onClick={() => setLang('EN')}>EN</button>
              </div>
              <span className="dn-head-divider" />
              {inChat && (
                <button className="dn-iconbtn sm" aria-label={STRINGS.newChatAria} title={STRINGS.newChatAria} onClick={requestNew}><I.plus /></button>
              )}
              {!embedded && (
                <button className="dn-iconbtn sm" aria-label={STRINGS.themeAria} title={STRINGS.themeAria} onClick={() => setSelfTheme((t) => (t === 'dark' ? 'light' : 'dark'))}>{theme === 'dark' ? <I.sun /> : <I.moon />}</button>
              )}
              <button className="dn-iconbtn sm" aria-label={STRINGS.closeAria} onClick={() => setOpen(false)}><I.close /></button>
            </div>
          </header>

          <div className="dn-body" ref={bodyRef}>
            {showOffline ? (
              <div className="dn-state">
                <Ava size="lg" state="is-offline" />
                <h3 className="dn-state-h">{STRINGS.offlineTitle}</h3>
                <p className="dn-state-p">{STRINGS.offlineBody}</p>
                <div className="dn-state-actions">
                  <button className="onfa-btn primary" onClick={() => setNetError(false)}>{STRINGS.offlineRetry}</button>
                </div>
                <span className="dn-state-status"><span className="dot" />{STRINGS.offlineStatus}</span>
              </div>
            ) : messages.length === 0 ? (
              <Welcome onChip={(c) => send(c)} />
            ) : (
              <div className="dn-thread">
                {messages.map((m) => {
                  if (m.role === 'user') return <UserMsg item={m} key={m.id} />;
                  if (m.role === 'tool') return <ToolRow item={m} key={m.id} />;
                  if (m.role === 'confirm')
                    return (
                      <ConfirmCard
                        item={m}
                        key={m.id}
                        onAgree={handleAgree}
                        onCancel={handleCancel}
                        onSubmit2fa={handleSubmit2fa}
                        onClose={handleClose}
                      />
                    );
                  return (
                    <div className="dn-msg bot" key={m.id}>
                      <Ava size="sm" state={m.streaming ? 'is-streaming' : ''} dot={false} />
                      <div>
                        <Markdown md={m.md} streaming={m.streaming} />
                        {m.error && <div className="dn-confirm-err">{m.error}</div>}
                      </div>
                    </div>
                  );
                })}
              </div>
            )}
          </div>

          <div className="dn-composer">
            <div className={'dn-quick' + (quickDisabled ? ' disabled' : '')} role="group" aria-label={STRINGS.quickAria}>
              {QUICK_ACTIONS.map((a) => {
                const Ic = QICON[a.icon];
                return (
                  <button className="dn-qbtn" key={a.key} disabled={quickDisabled} onClick={() => onQuick(a.key)}>
                    <Ic /><span>{lang === 'EN' ? a.en : a.vi}</span>
                  </button>
                );
              })}
            </div>
            {attachments.length > 0 && (
              <div className="dn-attach-row">
                {attachments.map((a) => (
                  <div className={'dn-attach-chip' + (a.status === 'failed' || a.status === 'rejected' ? ' failed' : '')} key={a.lid} title={a.error || a.name}>
                    <span className="fic"><I.doc /></span>
                    <span className="nm">{a.name}</span>
                    <span className="st">{STRINGS.fileStatus[a.status] || ''}</span>
                    <button className="x" aria-label={STRINGS.removeFileAria} onClick={() => removeAttach(a.lid)}><I.x /></button>
                  </div>
                ))}
              </div>
            )}
            <div className="dn-input-row">
              <input
                ref={fileRef}
                type="file"
                multiple
                accept={window.DrNickAPI.FILE_ACCEPT}
                style={{ display: 'none' }}
                onChange={onPickFiles}
              />
              <button className="dn-attach-btn" aria-label={STRINGS.attachAria} title={STRINGS.attachAria} onClick={() => fileRef.current && fileRef.current.click()}><I.clip /></button>
              <textarea
                ref={taRef}
                className="dn-textarea"
                rows="1"
                placeholder={STRINGS.inputPlaceholder}
                value={input}
                onChange={grow}
                onKeyDown={onKey}
              />
              <button className="dn-send" aria-label={STRINGS.sendAria} disabled={!canSend} onClick={onSubmit}><I.send /></button>
            </div>
            <div className="dn-footnote">{STRINGS.footnote}</div>
          </div>

          {showNew && (
            <div className="dn-sheet-scrim" onClick={(e) => { if (e.target === e.currentTarget) setShowNew(false); }}>
              <div className="dn-sheet" role="alertdialog" aria-label={STRINGS.newChatAria}>
                <span className="sic"><I.plus /></span>
                <h4>{STRINGS.sheetTitle}</h4>
                <p>{STRINGS.sheetBody}</p>
                <div className="dn-sheet-actions">
                  <button className="onfa-btn ghost" onClick={() => setShowNew(false)}>{STRINGS.sheetStay}</button>
                  <button className="onfa-btn primary" onClick={handleNewConversation}>{STRINGS.sheetContinue}</button>
                </div>
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

/* ── mount ─────────────────────────────────────────────────────────────────
 * Host selects the mode via `window.DRNICK_WIDGET_MODE` set BEFORE this script:
 *   (unset)        → standalone, auto-mount 'self' (internal sun/moon toggle)
 *   'follow-host'  → same-page embed in ONFA, follows body.dark-mode (own toggle hidden)
 *   'embed'        → iframe embed (loader.js), theme via postMessage (own toggle hidden)
 *   'manual'       → no auto-mount; host calls DrNick.mount({mode}) itself
 * NOTE: the global is `window.DrNick` (NOT `DrNickWidget`) — at browser top-level a
 * `function DrNickWidget(){}` declaration IS `window.DrNickWidget`, so naming the
 * namespace the same would overwrite the component with {mount} and render nothing. */
function normalizeMode(m) {
  if (m === 'follow-host' || m === 'embed') return m;
  return 'self';
}
function mount(opts) {
  opts = opts || {};
  const node = document.getElementById('dn-root');
  if (!node) return;
  // accept opts.mode (new) or legacy opts.theme==='follow-host' (back-compat with embed-example.html)
  const m = normalizeMode(opts.mode || opts.theme);
  ReactDOM.createRoot(node).render(<DrNickWidget mode={m} />);
}
window.DrNick = { mount };
if (window.DRNICK_WIDGET_MODE !== 'manual') {
  mount({ mode: normalizeMode(window.DRNICK_WIDGET_MODE) });
}
