// AI Chat — assistant médical intelligent avec widgets riches.
// Style ChatGPT adapté au design Vasco.

const CH_VT = window.VT || {};
const CH_INK = CH_VT.ink || '#0a0a0a';
const CH_SUB = CH_VT.sub || '#6b7280';
const CH_BG = CH_VT.paper || '#f6f5f1';
const CH_SURF = CH_VT.surface || '#ffffff';
const CH_HAIR = CH_VT.hair || 'rgba(10,10,10,0.06)';
const CH_HAIR2 = CH_VT.hair2 || 'rgba(10,10,10,0.10)';
const CH_OK = CH_VT.success || '#059669';
const CH_MED = CH_VT.medBlue || '#3d6b8f';
const CH_MED_TINT = CH_VT.medBlueTint || 'rgba(61,107,143,0.10)';
const CH_OK_TINT = CH_VT.successTint || 'rgba(5,150,105,0.10)';
const CH_OK_TINT_SOFT = CH_VT.successTintSoft || 'rgba(5,150,105,0.06)';

// Le serveur n'accepte que quatre types et vérifie en plus le contenu réel du
// fichier : Word, Excel et les autres formats bureautiques sont refusés, y
// compris renommés, un `.docx` étant une archive ZIP détectée comme telle.
// La règle vit dans `api-client.js` ; on ne la duplique pas ici.
const CH_ACCEPTED_LABEL = 'PDF, Word (.docx), JPEG, PNG ou TXT, 10 Mo maximum';

const CH_WAVE_BARS = 20;
// Un RMS de parole ordinaire tourne autour de 0,02–0,15 : sans gain, les
// barres resteraient collées au plancher.
const CH_WAVE_GAIN = 12;
// En dessous, on considère qu'aucune voix n'est captée.
const CH_SILENCE_RMS = 0.004;

// Libellés repris de `mobile/src/features/assistant/assistant-widgets.tsx` :
// deux formulations pour un même refus serveur finiraient par diverger.
const CH_WIDGET_REASONS = {
  role_not_allowed: 'Votre rôle ne permet pas cette action.',
  not_owner: 'Seul le propriétaire de la conversation peut agir.',
  patient_access_revoked: 'L’accès au patient a été révoqué.',
  resource_stale: 'Cette proposition doit être rechargée.',
  generation_in_progress: 'La réponse est encore en cours.',
  provider_unavailable: 'Le fournisseur requis est indisponible.',
  scanner_unavailable: 'Le service d’inspection est indisponible.',
  extractor_unavailable: 'L’extracteur de contenu n’est pas configuré.',
  mail_channel_not_configured: 'Aucun canal sécurisé (MSSanté ou Apicrypt) n’est configuré.',
  print_provider_unavailable: 'La préparation du document imprimable est indisponible.',
  ccam_source_unavailable: 'Le référentiel CCAM autorisé est indisponible.',
  source_unavailable: 'Une source nécessaire n’est plus accessible.',
  conversation_deleted: 'La conversation a été supprimée.',
  recipient_missing: 'Ajoutez un destinataire avant l’envoi.',
  already_confirmed: 'Ce rendez-vous est déjà confirmé.',
};

const CH_ACTION_META = {
  open: { label: 'Ouvrir', icon: <><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></> },
  copy: { label: 'Copier', icon: <><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></> },
  edit: { label: 'Modifier', icon: <><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="M15 5l4 4"/></> },
  send: { label: 'Envoyer', icon: <><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></> },
  print: { label: 'Imprimer', icon: <><path d="M6 9V3h12v6"/><rect x="6" y="13" width="12" height="8" rx="1"/><path d="M6 17H3v-5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5h-3"/></> },
  confirm: { label: 'Confirmer', icon: <><path d="M20 6L9 17l-5-5"/></> },
  discard: { label: 'Rejeter', icon: <><path d="M18 6L6 18"/><path d="M6 6l12 12"/></> },
};

function chWidgetReason(capability) {
  if (!capability || capability.enabled) return null;
  return CH_WIDGET_REASONS[capability.reason_code] || 'Action indisponible.';
}

// ── Widget action buttons ──
// Un bouton refusé par le serveur affiche sa raison au survol et reste inerte :
// inventer un succès sur une action clinique serait un mensonge d'interface.
// `done` remplace l'icône par une coche, le temps de faire savoir que l'action a
// abouti. C'est le bouton qui porte sa confirmation, jamais une notification.
const CH_DONE_ICON = <path d="M20 6L9 17l-5-5" />;

function WAction({ icon, onClick, label, reason = null, busy = false, done = false, doneLabel = null }) {
  const blocked = Boolean(reason) || busy;
  const shown = done ? (doneLabel || label) : label;
  return (
    <button
      onClick={blocked ? undefined : onClick}
      className={blocked ? undefined : 'press w-action'}
      title={reason ? `${label} — ${reason}` : shown}
      aria-label={reason ? `${label} — ${reason}` : shown}
      aria-disabled={blocked ? 'true' : undefined}
      disabled={blocked}
      style={{
        width: 32, height: 32, borderRadius: 12,
        background: done ? CH_OK_TINT : 'rgba(10,10,10,0.04)', border: 'none',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        cursor: blocked ? 'default' : 'pointer', flexShrink: 0,
        opacity: blocked ? 0.4 : 1,
        transition: 'background .16s ease',
      }}>
      {busy ? <VascoSpinner size={13} color={CH_SUB} thickness={2} /> : (
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
          stroke={done ? CH_OK : CH_SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
          {done ? CH_DONE_ICON : icon}
        </svg>
      )}
    </button>
  );
}

// Même bouton, écrit en toutes lettres. Les commandes d'un codage se lisent au pied
// de la carte, où une icône seule demanderait de deviner ce qu'on engage.
function WTextAction({
  label, onClick, reason = null, busy = false, done = false, doneLabel = null, style = null,
}) {
  const blocked = Boolean(reason) || busy;
  const shown = done ? (doneLabel || label) : label;
  return (
    <button
      onClick={blocked ? undefined : onClick}
      className={blocked || done ? undefined : 'press w-text-action'}
      title={reason ? `${label} — ${reason}` : shown}
      aria-label={reason ? `${label} — ${reason}` : shown}
      aria-disabled={blocked ? 'true' : undefined}
      disabled={blocked}
      style={{
        display: 'flex', alignItems: 'center', gap: 6,
        background: 'none', border: 'none', padding: '2px 0',
        fontFamily: 'inherit', fontSize: 12, fontWeight: 600,
        color: done ? CH_OK : CH_SUB,
        cursor: blocked ? 'default' : 'pointer',
        opacity: blocked ? 0.45 : 1,
        transition: 'color .16s ease',
        ...(style || {}),
      }}>
      {busy && <VascoSpinner size={11} color={CH_SUB} thickness={2} />}
      {done && (
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={CH_OK}
          strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">{CH_DONE_ICON}</svg>
      )}
      {shown}
    </button>
  );
}

// Barre d'actions d'un widget, entièrement dérivée de `capabilities` renvoyé
// par le serveur : l'ordre, la présence et l'état de chaque bouton viennent de
// l'allowlist canonique, jamais d'une supposition du client.
// `variant="text"` rend la même barre en libellés discrets, pour un pied de carte.
function WidgetActions({
  msg, runtime, onWidgetUpdated, onEdit, onNeedPatient, onOpenPatient, variant = 'icons',
}) {
  const [busyAction, setBusyAction] = React.useState(null);
  const [doneAction, markDone] = useActionDone();
  const capabilities = msg.widgetCapabilities || [];
  if (!runtime || !msg.widgetId || capabilities.length === 0) return null;

  const widgetRef = { id: msg.widgetId, version: msg.widgetVersion, type: msg.widget };

  const run = async (action, options) => {
    if (busyAction) return;
    if (action === 'edit') { onEdit?.(); return; }
    let settings = options;
    // Un compte rendu ne s'écrit que dans le dossier d'un patient. Le fil décide : le
    // résolveur n'est fourni que s'il n'en désigne aucun. On demande alors avant
    // d'appeler le serveur — partir pour se faire refuser, puis rouvrir une feuille,
    // ferait deux allers pour un seul geste.
    if (action === 'save' && onNeedPatient) {
      const patientId = await onNeedPatient(widgetRef);
      if (!patientId) return;
      settings = { ...(options || {}), patientId };
    }
    setBusyAction(action);
    try {
      const result = await runtime.widgetAction(widgetRef, action, settings);
      onWidgetUpdated?.(result.widget);
      // `copy` est la seule action dont l'effet utile est local : le reçu
      // serveur prouve la traçabilité, le presse-papiers rend le service.
      if (action === 'copy' && navigator.clipboard?.writeText) {
        try {
          await navigator.clipboard.writeText(chWidgetPlainText(msg));
        } catch { /* Le refus du presse-papiers n'annule pas le reçu serveur. */ }
      }
      // `print` est de la même famille : le serveur enregistre la passe-main, et
      // c'est l'appareil qui imprime. Un refus du navigateur remonte en revanche
      // jusqu'au toast et laisse le bouton muet — le reçu resterait acquis, mais
      // rien n'aurait été transmis, et la coche le dirait à tort.
      if (action === 'print') await chPrintOrdonnance(msg);
      // « Ouvrir » d'une fiche patient ne se termine pas au reçu : ce que le geste
      // promet, c'est le dossier à l'écran. Le serveur a vérifié que ce patient
      // appartient bien à l'organisation et aux sources de la fiche — il renvoie son
      // id dans le reçu, et c'est celui-là qu'on ouvre, jamais un id relu de la carte.
      // Un refus (fiche sans patient, dossier d'une autre organisation, dossier archivé)
      // sort en 409 ou 404 avant ce point : il tombe dans le `catch`, dit sa raison au
      // toast et laisse la conversation à l'écran — la carte reste sous les yeux, alors
      // qu'un écran « Dossier introuvable » aurait fait quitter le fil pour l'apprendre.
      if (action === 'open' && onOpenPatient) {
        const patientId = msg.widget === 'patient' ? result.receipt?.resource_id : null;
        if (!patientId) {
          throw new Error('La commande d’ouverture ne désigne aucun patient autorisé');
        }
        // Le dossier est chargé avant la navigation : si ce chargement échoue,
        // l'ouverture a dit pourquoi et le bouton ne se coche pas — le reçu, lui,
        // reste acquis au serveur, mais aucun écran ne s'est ouvert.
        if (await onOpenPatient(patientId) === false) return;
      }
      // Aucune notification de réussite : le bouton porte lui-même le résultat,
      // et les actions qui changent l'état du widget — confirmer, écarter — le
      // montrent déjà dans la carte.
      markDone(action);
    } catch (error) {
      showToast(error?.message || 'Action impossible', 'error');
    } finally {
      setBusyAction(null);
    }
  };

  if (variant === 'text') {
    const data = msg.data || {};
    const capability = (action) => capabilities.find((entry) => entry.action === action) || null;
    const discarded = Boolean(data.discarded_at);
    const confirmed = Boolean(data.confirmed_at);
    // Le codage s'édite en cochant les actes dans la carte elle-même : un bouton
    // « modifier » ouvrirait un éditeur de texte sur une liste de codes.
    const command = (action, label, options) => {
      const entry = capability(action);
      if (!entry) return null;
      return (
        <WTextAction
          label={label}
          reason={chWidgetReason(entry)}
          busy={busyAction === action}
          done={doneAction === action}
          doneLabel={chActionResult(msg.widget, action, options)}
          onClick={() => void run(action, options)}
        />
      );
    };
    // Le compte rendu n'a qu'une commande au pied : enregistrer. Une fois signé, le
    // bouton cède la place à la signature elle-même ; corriger le texte l'efface côté
    // serveur et le bouton revient, pour réécrire une version de plus au dossier.
    // Un relevé de constantes entre au dossier de la même façon, et se lit de la même
    // façon : même commande, même signature, même place.
    if (msg.widget === 'cr' || msg.widget === 'constantes') {
      // Tant que le serveur n'autorise pas « enregistrer » et que rien n'est signé, la
      // carte se termine où elle se terminait : un pied vide y ajouterait un blanc.
      if (!data.saved_at && !capability('save')) return null;
      return (
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
          gap: 16, marginTop: 14,
        }}>
          {data.saved_at ? (
            <span style={{ fontSize: 12, fontWeight: 600, color: CH_OK }}>
              Enregistré {chStamp(data.saved_at)}
            </span>
          ) : command('save', 'Enregistrer')}
        </div>
      );
    }
    return (
      <div style={{
        display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
        gap: 16, marginTop: 12,
      }}>
        {discarded ? (
          <>
            <span style={{ fontSize: 12, fontWeight: 600, color: CH_SUB }}>Codage écarté</span>
            {command('discard', 'Rétablir', { discarded: false })}
          </>
        ) : (
          <>
            {/* Une signature déjà posée ne se repose pas : elle s'affiche. Toute
                modification l'efface côté serveur, et le bouton revient. */}
            {confirmed ? (
              <span style={{ fontSize: 12, fontWeight: 600, color: CH_OK }}>
                Confirmé {chStamp(data.confirmed_at)}
              </span>
            ) : command('confirm', 'Confirmer')}
            {command('discard', 'Rejeter', { discarded: true })}
          </>
        )}
      </div>
    );
  }

  return (
    <div style={{ display: 'flex', gap: 4 }}>
      {capabilities.map((capability) => {
        const meta = CH_ACTION_META[capability.action];
        if (!meta) return null;
        // Le codage s'édite en cochant les actes dans la carte elle-même, et se
        // confirme ou s'écarte au pied de celle-ci : l'en-tête ne garde que
        // « copier », dont l'effet ne dépend pas de ce qu'on vient de décider.
        if (msg.widget === 'ccam' && capability.action !== 'copy') return null;
        // Enregistrer se lit au pied de la carte, sous le compte rendu — ou sous le
        // relevé — qu'il fait entrer au dossier : une icône de plus dans l'en-tête ne
        // dirait pas ce qu'elle engage.
        if ((msg.widget === 'cr' || msg.widget === 'constantes') && capability.action === 'save') return null;
        return (
          <WAction
            key={capability.action}
            label={meta.label}
            icon={meta.icon}
            reason={chWidgetReason(capability)}
            busy={busyAction === capability.action}
            done={doneAction === capability.action}
            doneLabel={chActionResult(msg.widget, capability.action)}
            onClick={() => void run(capability.action)}
          />
        );
      })}
    </div>
  );
}

// Ce que le bouton peut affirmer s'arrête à ce que l'application a constaté. La
// feuille est remise au dialogue de l'appareil, qui ne dit jamais si elle est
// sortie : « transmis à l'impression » est tout ce qui est vrai — le serveur, lui,
// n'enregistre que la passe-main (`widget_print_authorized`).
// Aucun libellé pour `send` : tant qu'aucun canal sécurisé n'est configuré, le serveur
// refuse l'envoi, et un mot d'aboutissement gardé au chaud ici finirait par s'afficher.
// Il reviendra avec l'adaptateur MSSanté ou Apicrypt, et dira ce que le reçu prouve.
const CH_ACTION_RESULTS = {
  open: 'Ouverture enregistrée',
  copy: 'Copié',
  print: 'Transmis à l’impression',
  confirm: 'Rendez-vous confirmé',
};

// Confirmer un codage n'est pas confirmer un rendez-vous, et écarter n'est pas
// rétablir : un message unique par action mentirait une fois sur deux.
function chActionResult(widget, action, options) {
  if (widget === 'ccam' && action === 'confirm') return 'Codage confirmé';
  if (action === 'save') return 'Enregistré au dossier';
  if (action === 'discard') {
    return options?.discarded === false ? 'Codage rétabli' : 'Codage écarté';
  }
  return CH_ACTION_RESULTS[action] || 'Action enregistrée';
}

// Horodatage d'une signature : jour et heure, sans les secondes que personne ne lit.
function chStamp(value) {
  const at = new Date(value);
  if (Number.isNaN(at.getTime())) return '';
  return `le ${at.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} à ${
    at.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
}

// Le presse-papiers doit recevoir le contenu réellement affiché, pas un JSON.
function chWidgetPlainText(msg) {
  const data = msg.data || {};
  if (msg.widget === 'email') return `${data.subject || ''}\n\n${data.body || ''}`.trim();
  if (msg.widget === 'courrier') return `${data.to || ''}\n\n${data.body || ''}`.trim();
  if (msg.widget === 'cr') {
    return [data.title, ...(data.sections || []).map((s) => `${s.heading}\n${s.content}`)]
      .filter(Boolean).join('\n\n');
  }
  if (msg.widget === 'constantes') {
    return (data.vitals || []).map((v) => `${v.label} ${v.value} ${v.unit || ''}`.trim()).join('\n');
  }
  if (msg.widget === 'ccam') {
    const principal = data.principal ? `${data.principal.code} ${data.principal.label}` : '';
    const extras = (data.complementaires || []).map((c) => `${c.code} ${c.label}`).join('\n');
    return [principal, extras].filter(Boolean).join('\n');
  }
  return msg.text || '';
}

// ═══════════════════════════════════════════════════════════
// Impression de la fiche ordonnance
// ═══════════════════════════════════════════════════════════
// L'application vit dans un cadre de téléphone : la feuille est composée dans un
// document à part et remise au dialogue d'impression de l'appareil, par la même
// mécanique que le dossier patient (`vascoPrintFrame`).
//
// Le titre et la mention sont ceux de
// `mobile/src/features/assistant/assistant-conversation-screen.tsx`, qui imprime
// déjà cette fiche : deux formulations pour un même papier finiraient par diverger,
// et c'est sur du papier que la divergence se lirait.
//
// Ce qui part sur la feuille est exactement ce que la carte affiche : le patient
// nommé par la fiche et ses lignes de traitement, rien d'autre. Aucun prescripteur
// n'y est imprimé — la fiche n'en porte aucun, et un nom sous des médicaments se
// lit comme une signature. Le document dit donc ce qu'il est : un brouillon non
// signé. `verification_required` est vrai pour toute ordonnance servie par l'API,
// et le papier, lui, circule sans écran autour pour le rappeler.
const CH_ORDONNANCE_PRINT_STYLES = `
  @page { size: A4; margin: 18mm 16mm; }
  body {
    margin: 0;
    font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
    font-size: 10.5pt; line-height: 1.45; color: #111;
  }
  .head { border-bottom: 1.5pt solid #111; padding-bottom: 8pt; margin-bottom: 16pt; }
  .kicker { font-size: 8pt; letter-spacing: 1.2pt; text-transform: uppercase; color: #555; }
  .name { font-size: 20pt; font-weight: 700; margin-top: 4pt; }
  .head-meta {
    display: flex; justify-content: space-between; gap: 12pt;
    font-size: 9pt; color: #555; margin-top: 6pt;
  }
  .med { padding: 8pt 0; border-bottom: 0.5pt solid #bbb; page-break-inside: avoid; }
  .med-name { font-size: 12pt; font-weight: 700; }
  .med-posology { margin-top: 2pt; }
  .med-duration { margin-top: 2pt; color: #555; }
  .draft {
    margin-top: 20pt; border: 1pt solid #111; padding: 10pt 12pt;
    font-size: 10pt; font-weight: 700;
  }
`;

function chOrdonnancePrintHtml(data, printedAt) {
  const esc = window.vascoPrintEscape || ((value) => String(value));
  // Un champ vide ne s'imprime pas : sur du papier qui suivra le patient, une
  // ligne remplie pour faire joli se lit comme une posologie.
  const value = (raw) => {
    const text = typeof raw === 'string' ? raw.trim() : raw;
    return text && text !== '—' ? String(text) : null;
  };
  const meds = (Array.isArray(data.meds) ? data.meds : []).map((med) => {
    const name = value(med.name);
    if (!name) return '';
    const posology = value(med.posology);
    const duration = value(med.duration);
    return `<div class="med"><div class="med-name">${esc(name)}</div>${
      posology ? `<div class="med-posology">${esc(posology)}</div>` : ''}${
      duration ? `<div class="med-duration">${esc(duration)}</div>` : ''}</div>`;
  }).join('');
  const patient = value(data.patient) || '';

  return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>Ordonnance — brouillon — ${esc(patient)}</title>
<style>${CH_ORDONNANCE_PRINT_STYLES}</style>
</head>
<body>
<div class="head">
  <div class="kicker">Ordonnance — brouillon</div>
  <div class="name">${esc(patient)}</div>
  <div class="head-meta"><span>Imprimé le ${esc(printedAt)}</span></div>
</div>
${meds}
<div class="draft">Document à vérifier. Non validé et non signé.</div>
</body>
</html>`;
}

// Remettre la feuille au dialogue d'impression de l'appareil. Ce que l'appareil en
// fait ensuite — une feuille, un PDF, une annulation — ne remonte pas, ni ici ni au
// serveur : personne dans l'application ne peut donc dire qu'une ordonnance est
// imprimée, seulement qu'elle a été transmise.
function chPrintOrdonnance(msg) {
  // Seule l'ordonnance s'imprime. Si le serveur venait à offrir « imprimer »
  // ailleurs, mieux vaut un refus visible qu'une coche sur une feuille que
  // personne n'a su composer.
  if (msg.widget !== 'ordonnance') {
    return Promise.reject(new Error('Cette fiche ne peut pas être imprimée'));
  }
  const frame = window.vascoPrintFrame;
  if (!frame) {
    return Promise.reject(
      new Error('Le dialogue d’impression est indisponible. Le brouillon est conservé.'),
    );
  }
  const printedAt = new Intl.DateTimeFormat('fr-FR', {
    dateStyle: 'long', timeStyle: 'short'
  }).format(new Date());
  return frame(chOrdonnancePrintHtml(msg.data || {}, printedAt));
}

// ═══════════════════════════════════════════════════════════
// Rendu Markdown minimal des réponses
// ═══════════════════════════════════════════════════════════
// Le modèle répond en Markdown : sans interprétation, l'utilisateur lit des
// astérisques au milieu d'un texte clinique. Les éléments sont construits en
// React et jamais injectés en HTML : la réponse reste une donnée non fiable,
// et `dangerouslySetInnerHTML` ouvrirait une injection depuis une pièce jointe
// ou un message d'un tiers.
function chInline(text, keyPrefix) {
  const nodes = [];
  // Gras, italique et code littéral, dans cet ordre : `**` doit être reconnu
  // avant `*`, sinon un gras se lit comme deux italiques.
  const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*\n]+\*|_[^_\n]+_)/g;
  let last = 0;
  let match = pattern.exec(text);
  let index = 0;
  while (match) {
    if (match.index > last) nodes.push(text.slice(last, match.index));
    const token = match[0];
    const key = `${keyPrefix}-i${index}`;
    index += 1;
    if (token.startsWith('**') || token.startsWith('__')) {
      nodes.push(<strong key={key} style={{ fontWeight: 750 }}>{token.slice(2, -2)}</strong>);
    } else if (token.startsWith('`')) {
      nodes.push(
        <code key={key} className="mono" style={{
          fontSize: '0.92em', background: 'rgba(10,10,10,0.06)',
          padding: '1px 5px', borderRadius: 5,
        }}>{token.slice(1, -1)}</code>,
      );
    } else {
      nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
    }
    last = match.index + token.length;
    match = pattern.exec(text);
  }
  if (last < text.length) nodes.push(text.slice(last));
  return nodes.length ? nodes : [text];
}

function ChatMarkdown({ text }) {
  if (!text) return null;
  const lines = String(text).replace(/\r/g, '').split('\n');
  const blocks = [];
  let list = null;

  const flushList = () => {
    if (!list) return;
    const Tag = list.ordered ? 'ol' : 'ul';
    blocks.push(
      <Tag key={`l${blocks.length}`} style={{
        margin: '4px 0', paddingLeft: 20, display: 'flex',
        flexDirection: 'column', gap: 2,
      }}>
        {list.items.map((item, index) => (
          <li key={index} style={{ lineHeight: 1.5 }}>{chInline(item, `l${blocks.length}-${index}`)}</li>
        ))}
      </Tag>,
    );
    list = null;
  };

  lines.forEach((raw, lineIndex) => {
    const line = raw.trimEnd();
    if (!line.trim()) { flushList(); return; }

    const heading = /^(#{1,4})\s+(.*)$/.exec(line);
    if (heading) {
      flushList();
      blocks.push(
        <div key={`h${lineIndex}`} style={{
          fontWeight: 750, fontSize: heading[1].length <= 2 ? '1.05em' : '1em',
          margin: '6px 0 2px',
        }}>{chInline(heading[2], `h${lineIndex}`)}</div>,
      );
      return;
    }

    const bullet = /^\s*[-*•]\s+(.*)$/.exec(line);
    const ordered = /^\s*(\d+)[.)]\s+(.*)$/.exec(line);
    if (bullet || ordered) {
      const isOrdered = Boolean(ordered);
      if (!list || list.ordered !== isOrdered) { flushList(); list = { ordered: isOrdered, items: [] }; }
      list.items.push(isOrdered ? ordered[2] : bullet[1]);
      return;
    }

    flushList();
    blocks.push(
      <div key={`p${lineIndex}`} style={{ lineHeight: 1.5 }}>{chInline(line, `p${lineIndex}`)}</div>,
    );
  });
  flushList();

  return <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>{blocks}</div>;
}

// ═══════════════════════════════════════════════════════════
// Pièces jointes d'un message — vignette, ouverture, téléchargement
// ═══════════════════════════════════════════════════════════
// Comme dans une messagerie ordinaire : l'image se voit, le PDF s'ouvre dans le
// lecteur du navigateur, et le Word se télécharge — aucun navigateur ne sait
// l'afficher. Une pièce jointe qu'on ne peut ni voir ni ouvrir n'est qu'un nom
// de fichier, et l'expéditeur ne peut pas vérifier ce qu'il a envoyé.
const CH_DOCX_MEDIA_TYPE =
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document';

const CH_KIND_LABELS = {
  'application/pdf': 'PDF',
  [CH_DOCX_MEDIA_TYPE]: 'Document Word',
  'text/plain': 'Texte',
};

function chAttachmentLabel(attachment) {
  if (attachment.mediaType?.startsWith('image/')) return 'Image';
  return CH_KIND_LABELS[attachment.mediaType] || 'Document';
}

function ChatAttachment({ attachment, runtime, isUser, onPreview }) {
  const isImage = Boolean(attachment.mediaType?.startsWith('image/'));
  const [url, setUrl] = React.useState(attachment.previewUrl || null);
  const [failed, setFailed] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const ownedRef = React.useRef(null);

  // Une URL d'objet non libérée retient le fichier entier en mémoire tant que
  // l'onglet vit.
  React.useEffect(() => () => {
    if (ownedRef.current) URL.revokeObjectURL(ownedRef.current);
  }, []);

  const ensureUrl = React.useCallback(async () => {
    if (url) return url;
    if (!runtime?.attachmentObjectUrl || !attachment.id) return null;
    const next = await runtime.attachmentObjectUrl(attachment);
    ownedRef.current = next;
    setUrl(next);
    return next;
  }, [url, runtime, attachment]);

  // La vignette se charge d'elle-même ; le reste attend un geste explicite.
  React.useEffect(() => {
    if (!isImage || url || failed || !runtime?.attachmentObjectUrl) return undefined;
    let cancelled = false;
    (async () => {
      try {
        const next = await ensureUrl();
        if (cancelled && next) URL.revokeObjectURL(next);
      } catch {
        if (!cancelled) setFailed(true);
      }
    })();
    return () => { cancelled = true; };
  }, [isImage, url, failed, runtime, ensureUrl]);

  const open = async () => {
    if (busy) return;
    setBusy(true);
    try {
      const target = await ensureUrl();
      if (!target) return;
      if (isImage) { onPreview?.({ url: target, filename: attachment.filename }); return; }
      if (attachment.mediaType === 'application/pdf') {
        // Le navigateur sait afficher un PDF ; il ne sait pas afficher un Word.
        window.open(target, '_blank', 'noopener');
        return;
      }
      const link = document.createElement('a');
      link.href = target;
      link.download = attachment.filename || 'document';
      document.body.appendChild(link);
      link.click();
      link.remove();
    } catch (error) {
      showToast(error?.message || 'Pièce jointe indisponible', 'error');
    } finally {
      setBusy(false);
    }
  };

  const meta = [
    chAttachmentLabel(attachment),
    attachment.sizeBytes ? readableFileSize(attachment.sizeBytes) : null,
  ].filter(Boolean).join(' · ');

  if (isImage && url) {
    return (
      <button onClick={open} className="press" aria-label={`Agrandir ${attachment.filename}`}
        style={{
          padding: 0, border: `1px solid ${CH_HAIR2}`, borderRadius: 14,
          overflow: 'hidden', background: CH_SURF, cursor: 'pointer', maxWidth: 210,
          display: 'block',
        }}>
        <img src={url} alt={attachment.filename} style={{
          display: 'block', maxWidth: 208, maxHeight: 208, objectFit: 'cover',
        }} />
        <span style={{
          display: 'block', padding: '5px 8px', fontSize: 10, fontWeight: 500,
          color: CH_SUB, textAlign: 'left',
        }}>
          {attachment.filename}
          {attachment.scanMode === 'simulated' && ' · inspection simulée locale'}
        </span>
      </button>
    );
  }

  return (
    <button onClick={open} disabled={busy} className={busy ? undefined : 'press'}
      aria-label={`${isImage ? 'Ouvrir' : attachment.mediaType === 'application/pdf' ? 'Ouvrir' : 'Télécharger'} ${attachment.filename}`}
      style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '8px 11px', borderRadius: 12,
        background: isUser ? 'rgba(10,10,10,0.08)' : CH_SURF,
        border: `1px solid ${CH_HAIR2}`, color: CH_INK,
        fontFamily: 'inherit', textAlign: 'left',
        cursor: busy ? 'default' : 'pointer', maxWidth: 240,
      }}>
      <span style={{
        width: 30, height: 30, borderRadius: 9, flexShrink: 0,
        background: 'rgba(10,10,10,0.05)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>
        {busy ? <VascoSpinner size={13} color={CH_SUB} thickness={2} /> : (
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={CH_MED}
            strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
            {isImage
              ? <><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M21 15l-5-5L5 21"/></>
              : <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>}
          </svg>
        )}
      </span>
      <span style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
        <span style={{
          fontSize: 11.5, fontWeight: 650,
          overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 170,
        }}>{attachment.filename}</span>
        <span style={{ fontSize: 10, fontWeight: 500, color: CH_SUB, marginTop: 1 }}>
          {failed ? 'Contenu indisponible' : meta}
          {attachment.scanMode === 'simulated' && ' · inspection simulée locale'}
        </span>
      </span>
    </button>
  );
}

// Aperçu plein cadre d'une image, refermable au clic ou à l'Échap.
function ChatImagePreview({ preview, onClose }) {
  React.useEffect(() => {
    const onKey = (event) => { if (event.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);
  return (
    <div role="dialog" aria-modal="true" aria-label={preview.filename}
      onClick={onClose}
      style={{
        position: 'absolute', inset: 0, zIndex: 220, background: 'rgba(0,0,0,0.82)',
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
        gap: 12, padding: 20, cursor: 'zoom-out',
      }}>
      <img src={preview.url} alt={preview.filename} style={{
        maxWidth: '100%', maxHeight: '80%', objectFit: 'contain', borderRadius: 10,
      }} />
      <div style={{ fontSize: 12, fontWeight: 600, color: '#fff', textAlign: 'center' }}>
        {preview.filename}
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// Feuille générique — saisie, choix ou confirmation
// ═══════════════════════════════════════════════════════════
// Les boîtes natives `prompt`/`confirm` bloquent le fil du navigateur, sortent
// du cadre iOS de l'application et ne suivent aucun de ses styles. Tout passe
// donc par cette feuille, comme partout ailleurs dans Vasco.
function ChPromptSheet({ title, message, mode = 'text', value = '', options = [], confirmLabel = 'Valider', danger = false, busy = false, onCancel, onSubmit }) {
  const [draft, setDraft] = React.useState(value);
  const inputRef = React.useRef(null);

  React.useEffect(() => {
    if (mode === 'text') setTimeout(() => inputRef.current?.focus(), 60);
  }, [mode]);

  const canSubmit = mode !== 'text' || draft.trim().length > 0;

  return (
    <>
      <div onClick={onCancel} style={{
        position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)', zIndex: 210,
      }} />
      <div role="dialog" aria-modal="true" aria-label={title} style={{
        position: 'absolute', left: 16, right: 16, top: '50%', transform: 'translateY(-50%)',
        zIndex: 211, background: CH_SURF, borderRadius: VT.rXl2 || 22,
        padding: '18px 18px 16px', boxShadow: VT.shadowLg,
        maxHeight: '80%', display: 'flex', flexDirection: 'column',
      }}>
        <div style={{ fontSize: 15.5, fontWeight: 700, color: CH_INK, letterSpacing: '-0.2px' }}>{title}</div>
        {message && (
          <div style={{ fontSize: 12.5, fontWeight: 500, color: CH_SUB, marginTop: 5, lineHeight: 1.45 }}>
            {message}
          </div>
        )}

        {mode === 'text' && (
          <input
            ref={inputRef}
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => { if (event.key === 'Enter' && canSubmit) onSubmit(draft.trim()); }}
            maxLength={120}
            style={{
              marginTop: 14, width: '100%', height: 44, padding: '0 12px',
              borderRadius: VT.rMd || 12, border: `1px solid ${CH_HAIR2}`,
              background: CH_SURF, outline: 'none', fontFamily: 'inherit',
              fontSize: 14, fontWeight: 500, color: CH_INK,
            }} />
        )}

        {mode === 'choice' && (
          <div className="vasco-scroll" style={{ marginTop: 12, flex: 1, overflowY: 'auto', maxHeight: 280 }}>
            {options.map((option) => (
              <button key={String(option.value)} onClick={() => onSubmit(option.value)} className="press" style={{
                width: '100%', textAlign: 'left', padding: '11px 12px', marginBottom: 6,
                borderRadius: 14, background: option.active ? CH_MED_TINT : 'rgba(10,10,10,0.03)',
                border: 'none', cursor: 'pointer', fontFamily: 'inherit',
                fontSize: 13.5, fontWeight: 600, color: option.active ? CH_MED : CH_INK,
              }}>{option.label}</button>
            ))}
          </div>
        )}

        <div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
          <button onClick={onCancel} className="press" style={{
            flex: 1, height: VT.hLg || 46, borderRadius: VT.rLg || 16, background: 'none',
            border: `1px solid ${CH_HAIR2}`, fontFamily: 'inherit',
            fontSize: 13.5, fontWeight: 700, color: CH_INK, cursor: 'pointer',
          }}>Annuler</button>
          {mode !== 'choice' && (
            <button onClick={() => onSubmit(mode === 'text' ? draft.trim() : true)}
              disabled={!canSubmit || busy} className="press" style={{
                flex: 1, height: VT.hLg || 46, borderRadius: VT.rLg || 16,
                background: danger ? (VT.danger || '#b91c1c') : CH_INK, border: 'none',
                fontFamily: 'inherit', fontSize: 13.5, fontWeight: 700, color: '#fff',
                cursor: !canSubmit || busy ? 'default' : 'pointer', opacity: canSubmit ? 1 : 0.5,
                display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
              }}>
              {busy && <VascoSpinner size={14} color="#fff" thickness={2}
                style={{ borderColor: 'rgba(255,255,255,0.3)', borderTopColor: '#fff' }} />}
              {busy ? 'En cours…' : confirmLabel}
            </button>
          )}
        </div>
      </div>
    </>
  );
}

// ═══════════════════════════════════════════════════════════
// Édition d'un widget — POST /assistant/widgets/{id}/actions (action `edit`)
// ═══════════════════════════════════════════════════════════
// Le serveur exige un remplacement complet et typé, pas un patch : chaque
// champ ci-dessous correspond exactement au schéma `WidgetEditReplacement`.
const CH_EDIT_TITLES = {
  email: 'Modifier l’e-mail',
  courrier: 'Modifier le courrier',
  cr: 'Modifier le compte rendu',
  ordonnance: 'Modifier l’ordonnance',
};

const chEditInput = {
  width: '100%', padding: '10px 12px', borderRadius: VT.rMd || 12,
  border: `1px solid ${CH_HAIR2}`, background: CH_SURF, outline: 'none',
  fontFamily: 'inherit', fontSize: 13.5, fontWeight: 500, color: CH_INK,
};

function ChEditField({ label, children }) {
  return (
    <label style={{ display: 'block', marginBottom: 12 }}>
      <div style={{ fontSize: 11.5, fontWeight: 650, color: CH_INK, marginBottom: 5 }}>{label}</div>
      {children}
    </label>
  );
}

function WidgetEditSheet({ msg, busy, onClose, onSave }) {
  const data = msg.data || {};
  const [to, setTo] = React.useState(data.to || '');
  const [subject, setSubject] = React.useState(data.subject || '');
  const [body, setBody] = React.useState(data.body || '');
  const [type, setType] = React.useState(data.type || '');
  const [documentType, setDocumentType] = React.useState(data.document_type || 'CRO');
  const [title, setTitle] = React.useState(data.title || '');
  const [sections, setSections] = React.useState(() => (data.sections || []).map((s) => ({ ...s })));
  const [meds, setMeds] = React.useState(() => (data.meds || []).map((m) => ({ ...m })));

  const kind = msg.widget;
  const valid = (() => {
    if (kind === 'email') return subject.trim() && body.trim();
    if (kind === 'courrier') return to.trim() && body.trim();
    if (kind === 'cr') {
      return type.trim() && title.trim() && sections.length > 0
        && sections.every((s) => s.heading.trim() && s.content.trim());
    }
    if (kind === 'ordonnance') {
      return meds.length > 0
        && meds.every((m) => m.name.trim() && m.posology.trim() && m.duration.trim());
    }
    return false;
  })();

  const submit = () => {
    if (!valid || busy) return;
    if (kind === 'email') {
      onSave({ to: to.trim() || null, subject: subject.trim(), body });
      return;
    }
    if (kind === 'courrier') { onSave({ to: to.trim(), body }); return; }
    if (kind === 'cr') {
      onSave({
        type: type.trim(), document_type: documentType, title: title.trim(), sections,
      });
      return;
    }
    onSave({ meds });
  };

  return (
    <>
      <div onClick={onClose} style={{
        position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)', zIndex: 200,
      }} />
      <div role="dialog" aria-modal="true" aria-label={CH_EDIT_TITLES[kind] || 'Modifier'} style={{
        position: 'absolute', left: 14, right: 14, top: '50%', transform: 'translateY(-50%)',
        zIndex: 201, background: CH_SURF, borderRadius: VT.rXl2 || 22,
        padding: '18px 18px 16px', boxShadow: VT.shadowLg,
        maxHeight: '86%', display: 'flex', flexDirection: 'column',
      }}>
        <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
          <div>
            <div style={{ fontSize: 15.5, fontWeight: 700, color: CH_INK, letterSpacing: '-0.2px' }}>
              {CH_EDIT_TITLES[kind] || 'Modifier'}
            </div>
            <div style={{ fontSize: 12, fontWeight: 500, color: CH_SUB, marginTop: 3, lineHeight: 1.4 }}>
              Le brouillon est enregistré sur le serveur. Il reste à vérifier avant tout usage.
            </div>
          </div>
          <button onClick={onClose} className="press" aria-label="Fermer" style={{
            background: 'none', border: 'none', cursor: 'pointer', padding: 0, flexShrink: 0,
          }}>
            <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke={CH_SUB}
              strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
              <path d="M18 6L6 18M6 6l12 12" />
            </svg>
          </button>
        </div>

        <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto', marginTop: 14 }}>
          {kind === 'email' && <>
            <ChEditField label="Destinataire">
              <input value={to} onChange={(e) => setTo(e.target.value)} maxLength={254} style={chEditInput} />
            </ChEditField>
            <ChEditField label="Objet">
              <input value={subject} onChange={(e) => setSubject(e.target.value)} maxLength={200} style={chEditInput} />
            </ChEditField>
            <ChEditField label="Corps">
              <textarea value={body} onChange={(e) => setBody(e.target.value)} rows={9}
                maxLength={12000} style={{ ...chEditInput, resize: 'vertical', lineHeight: 1.5 }} />
            </ChEditField>
          </>}

          {kind === 'courrier' && <>
            <ChEditField label="Destinataire">
              <input value={to} onChange={(e) => setTo(e.target.value)} maxLength={254} style={chEditInput} />
            </ChEditField>
            <ChEditField label="Corps">
              <textarea value={body} onChange={(e) => setBody(e.target.value)} rows={11}
                maxLength={20000} style={{ ...chEditInput, resize: 'vertical', lineHeight: 1.5 }} />
            </ChEditField>
          </>}

          {kind === 'cr' && <>
            <ChEditField label="Type">
              <input value={type} onChange={(e) => setType(e.target.value)} maxLength={80} style={chEditInput} />
            </ChEditField>
            {/* Le rangement au dossier, qui décide de l'onglet où le compte rendu se
                retrouvera. Proposé par le modèle, corrigé ici. */}
            <ChEditField label="Rangement au dossier">
              <div style={{ display: 'flex', gap: 6 }}>
                {CH_CR_CATEGORIES.map((category) => {
                  const active = documentType === category.value;
                  return (
                    <button key={category.value} type="button" className="press"
                      onClick={() => setDocumentType(category.value)}
                      style={{
                        flex: 1, padding: '9px 6px', borderRadius: VT.rMd || 12,
                        border: `1px solid ${active ? CH_INK : CH_HAIR2}`,
                        background: active ? CH_INK : CH_SURF,
                        color: active ? '#fff' : CH_INK,
                        fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
                      }}>{category.label}</button>
                  );
                })}
              </div>
            </ChEditField>
            <ChEditField label="Titre">
              <input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={200} style={chEditInput} />
            </ChEditField>
            {sections.map((section, index) => (
              <div key={index} style={{
                border: `1px solid ${CH_HAIR}`, borderRadius: 14, padding: 12, marginBottom: 10,
              }}>
                <ChEditField label={`Section ${index + 1} — intitulé`}>
                  <input value={section.heading} maxLength={120} style={chEditInput}
                    onChange={(e) => setSections((cur) => cur.map((s, i) => (
                      i === index ? { ...s, heading: e.target.value } : s
                    )))} />
                </ChEditField>
                <ChEditField label="Contenu">
                  <textarea value={section.content} rows={4} maxLength={10000}
                    style={{ ...chEditInput, resize: 'vertical', lineHeight: 1.5 }}
                    onChange={(e) => setSections((cur) => cur.map((s, i) => (
                      i === index ? { ...s, content: e.target.value } : s
                    )))} />
                </ChEditField>
                {sections.length > 1 && (
                  <button onClick={() => setSections((cur) => cur.filter((_, i) => i !== index))}
                    className="press" style={{
                      background: 'none', border: 'none', padding: 0, cursor: 'pointer',
                      fontFamily: 'inherit', fontSize: 12, fontWeight: 650, color: VT.danger || '#b91c1c',
                    }}>Retirer cette section</button>
                )}
              </div>
            ))}
            {sections.length < 30 && (
              <button onClick={() => setSections((cur) => [...cur, { heading: '', content: '' }])}
                className="press" style={{
                  width: '100%', height: 38, borderRadius: 12, background: 'none',
                  border: `1px dashed ${CH_HAIR2}`, cursor: 'pointer',
                  fontFamily: 'inherit', fontSize: 12.5, fontWeight: 650, color: CH_SUB,
                }}>Ajouter une section</button>
            )}
          </>}

          {kind === 'ordonnance' && <>
            {meds.map((med, index) => (
              <div key={index} style={{
                border: `1px solid ${CH_HAIR}`, borderRadius: 14, padding: 12, marginBottom: 10,
              }}>
                <ChEditField label={`Médicament ${index + 1}`}>
                  <input value={med.name} maxLength={200} style={chEditInput}
                    onChange={(e) => setMeds((cur) => cur.map((m, i) => (
                      i === index ? { ...m, name: e.target.value } : m
                    )))} />
                </ChEditField>
                <ChEditField label="Posologie">
                  <input value={med.posology} maxLength={500} style={chEditInput}
                    onChange={(e) => setMeds((cur) => cur.map((m, i) => (
                      i === index ? { ...m, posology: e.target.value } : m
                    )))} />
                </ChEditField>
                <ChEditField label="Durée">
                  <input value={med.duration} maxLength={120} style={chEditInput}
                    onChange={(e) => setMeds((cur) => cur.map((m, i) => (
                      i === index ? { ...m, duration: e.target.value } : m
                    )))} />
                </ChEditField>
                {meds.length > 1 && (
                  <button onClick={() => setMeds((cur) => cur.filter((_, i) => i !== index))}
                    className="press" style={{
                      background: 'none', border: 'none', padding: 0, cursor: 'pointer',
                      fontFamily: 'inherit', fontSize: 12, fontWeight: 650, color: VT.danger || '#b91c1c',
                    }}>Retirer ce médicament</button>
                )}
              </div>
            ))}
            {meds.length < 50 && (
              <button onClick={() => setMeds((cur) => [...cur, { name: '', posology: '', duration: '' }])}
                className="press" style={{
                  width: '100%', height: 38, borderRadius: 12, background: 'none',
                  border: `1px dashed ${CH_HAIR2}`, cursor: 'pointer',
                  fontFamily: 'inherit', fontSize: 12.5, fontWeight: 650, color: CH_SUB,
                }}>Ajouter un médicament</button>
            )}
          </>}
        </div>

        <div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
          <button onClick={onClose} className="press" style={{
            flex: 1, height: VT.hLg || 46, borderRadius: VT.rLg || 16, background: 'none',
            border: `1px solid ${CH_HAIR2}`, fontFamily: 'inherit',
            fontSize: 13.5, fontWeight: 700, color: CH_INK, cursor: 'pointer',
          }}>Annuler</button>
          <button onClick={submit} disabled={!valid || busy} className="press" style={{
            flex: 1, height: VT.hLg || 46, borderRadius: VT.rLg || 16,
            background: CH_INK, border: 'none', fontFamily: 'inherit',
            fontSize: 13.5, fontWeight: 700, color: '#fff',
            cursor: !valid || busy ? 'default' : 'pointer', opacity: valid ? 1 : 0.5,
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
          }}>
            {busy && <VascoSpinner size={14} color="#fff" thickness={2}
              style={{ borderColor: 'rgba(255,255,255,0.3)', borderTopColor: '#fff' }} />}
            {busy ? 'Enregistrement…' : 'Enregistrer'}
          </button>
        </div>
      </div>
    </>
  );
}

// ═══════════════════════════════════════════════════════════
// WIDGET: Fiche Patient
// ═══════════════════════════════════════════════════════════
function WidgetPatient({ data, actions }) {
  // Les cinq statuts que le serveur peut servir. Les deux derniers manquaient : un
  // patient en soins palliatifs, ou décédé, tombait sur le repli et s'affichait
  // « Ambulatoire ». Libellés et gris repris de la liste des patients
  // (`patients-data.jsx`, `patients-list.jsx`), pour qu'un statut se lise pareil
  // partout.
  const statusColors = {
    hosp: { bg: 'rgba(59,130,246,0.08)', color: '#1d4ed8', label: 'Hospitalisé' },
    ambu: { bg: CH_OK_TINT_SOFT, color: CH_OK, label: 'Ambulatoire' },
    rea:  { bg: 'rgba(249,115,22,0.08)', color: '#c2410c', label: 'Réa' },
    palliatif: { bg: '#f3f4f6', color: CH_SUB, label: 'Soins palliat.' },
    decede: { bg: '#f3f4f6', color: CH_SUB, label: 'Décédé' },
  };
  const st = statusColors[data.status] || statusColors.ambu;
  return (
    <div style={{
      background: CH_SURF, borderRadius: 22, padding: '16px 18px',
      border: `1px solid ${CH_HAIR}`, boxShadow: VT.shadowCard,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
        <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.5px', textTransform: 'uppercase' }}>Fiche patient</span>
        {actions}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
        <div style={{
          width: 44, height: 44, borderRadius: 16, background: 'rgba(10,10,10,0.05)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontSize: 15, fontWeight: 700, color: CH_INK,
        }}>{data.initials}</div>
        <div>
          <div style={{ fontSize: 17, fontWeight: 700, color: CH_INK, letterSpacing: '-0.3px' }}>{data.name}</div>
          <div className="mono" style={{ fontSize: 11, fontWeight: 500, color: CH_SUB, marginTop: 2 }}>
            {data.age} ans · {data.sex} · IPP {data.ipp}
          </div>
        </div>
      </div>
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
        <span style={{ fontSize: 10, fontWeight: 600, padding: '3px 10px', borderRadius: 999, background: st.bg, color: st.color, fontFamily: "'JetBrains Mono', monospace", textTransform: 'uppercase', letterSpacing: '0.3px' }}>{st.label}</span>
        {data.bed && <span className="mono" style={{ fontSize: 10, fontWeight: 600, padding: '3px 10px', borderRadius: 999, background: 'rgba(10,10,10,0.04)', color: CH_INK, letterSpacing: '0.3px' }}>LIT {data.bed}</span>}
      </div>
      {[
        ['Allergies', data.allergies],
        ['Traitements', data.treatments],
        ['Médecin tt.', data.mt],
      ].map(([k, v]) => (
        <div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderTop: `1px solid ${CH_HAIR}` }}>
          <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.4px', textTransform: 'uppercase' }}>{k}</span>
          <span style={{ fontSize: 12.5, fontWeight: 500, color: CH_INK, textAlign: 'right', maxWidth: '60%' }}>{v}</span>
        </div>
      ))}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// WIDGET: Constantes
// ═══════════════════════════════════════════════════════════
function WidgetConstantes({ data, actions, footer = null }) {
  const statusColor = (s) => s === 'normal' ? CH_OK : s === 'warning' ? '#d97706' : '#b91c1c';
  const statusBg = (s) => s === 'normal' ? CH_OK_TINT_SOFT : s === 'warning' ? 'rgba(249,115,22,0.08)' : 'rgba(185,28,28,0.08)';
  return (
    <div style={{
      background: CH_SURF, borderRadius: 22, padding: '16px 18px',
      border: `1px solid ${CH_HAIR}`, boxShadow: VT.shadowCard,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
        <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.5px', textTransform: 'uppercase' }}>Constantes — {data.patient}</span>
        {actions}
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
        {data.vitals.map((v) => (
          <div key={v.label} style={{
            padding: '10px 12px', borderRadius: 16,
            background: statusBg(v.status),
            border: `1px solid ${CH_HAIR}`,
          }}>
            <div className="mono" style={{ fontSize: 9, fontWeight: 600, color: CH_SUB, letterSpacing: '0.4px', textTransform: 'uppercase' }}>{v.label}</div>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 4 }}>
              <span style={{ fontSize: 22, fontWeight: 800, color: statusColor(v.status), letterSpacing: '-0.5px' }}>{v.value}</span>
              <span className="mono" style={{ fontSize: 10, fontWeight: 500, color: CH_SUB }}>{v.unit}</span>
            </div>
          </div>
        ))}
      </div>
      <div className="mono" style={{ fontSize: 10, fontWeight: 500, color: CH_SUB, marginTop: 10, letterSpacing: '0.2px' }}>
        Relevé le {data.date} à {data.time}
      </div>
      {footer}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// WIDGET: Email
// ═══════════════════════════════════════════════════════════
function WidgetEmail({ data, actions }) {
  return (
    <div style={{
      background: CH_SURF, borderRadius: 22, padding: '16px 18px',
      border: `1px solid ${CH_HAIR}`, boxShadow: VT.shadowCard,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
        <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.5px', textTransform: 'uppercase' }}>Adresse e-mail</span>
        {actions}
      </div>
      <div style={{ borderTop: `1px solid ${CH_HAIR}`, padding: '10px 0 6px' }}>
        <div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
          <span style={{ fontSize: 13, fontWeight: 700, color: CH_INK }}>Objet</span>
          <span style={{ fontSize: 13, fontWeight: 500, color: CH_INK }}>{data.subject}</span>
        </div>
      </div>
      <div style={{ borderTop: `1px solid ${CH_HAIR}`, padding: '10px 0 0', marginTop: 4 }}>
        <div className="serif" style={{ fontSize: 14.5, lineHeight: 1.6, color: CH_INK, whiteSpace: 'pre-line' }}>
          {data.body}
        </div>
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// WIDGET: Ordonnance
// ═══════════════════════════════════════════════════════════
function WidgetOrdonnance({ data, actions }) {
  return (
    <div style={{
      background: CH_SURF, borderRadius: 22, padding: '16px 18px',
      border: `1px solid ${CH_HAIR}`, boxShadow: VT.shadowCard,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
        <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.5px', textTransform: 'uppercase' }}>Ordonnance — {data.patient}</span>
        {actions}
      </div>
      {data.meds.map((m, i) => (
        <div key={i} style={{
          padding: '10px 0', borderTop: i === 0 ? `1px solid ${CH_HAIR}` : `1px solid ${CH_HAIR}`,
          display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
        }}>
          <div>
            <div style={{ fontSize: 14, fontWeight: 600, color: CH_INK }}>{m.name}</div>
            <div style={{ fontSize: 12, fontWeight: 500, color: CH_SUB, marginTop: 2 }}>{m.posology}</div>
          </div>
          <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, flexShrink: 0, marginTop: 2 }}>{m.duration}</span>
        </div>
      ))}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// WIDGET: Courrier
// ═══════════════════════════════════════════════════════════
function WidgetCourrier({ data, actions }) {
  return (
    <div style={{
      background: CH_SURF, borderRadius: 22, padding: '16px 18px',
      border: `1px solid ${CH_HAIR}`, boxShadow: VT.shadowCard,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
        <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.5px', textTransform: 'uppercase' }}>Courrier confrère</span>
        {actions}
      </div>
      <div style={{ borderTop: `1px solid ${CH_HAIR}`, paddingTop: 12 }}>
        <div className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.3px', marginBottom: 4 }}>
          À : {data.to}
        </div>
        <div className="serif" style={{ fontSize: 14.5, lineHeight: 1.6, color: CH_INK, whiteSpace: 'pre-line' }}>
          {data.body}
        </div>
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// WIDGET: Compte Rendu
// ═══════════════════════════════════════════════════════════
// Valeur réservée de la feuille de choix : elle ne peut pas se confondre avec un
// identifiant de patient, qui est un UUID.
const CH_NEW_PATIENT = 'nouveau-patient';

const CH_CR_CATEGORIES = [
  { value: 'CRO', label: 'Opératoire' },
  { value: 'CRC', label: 'Consultation' },
  { value: 'CRH', label: 'Hospitalisation' },
];

function chCrCategory(value) {
  const found = CH_CR_CATEGORIES.find((entry) => entry.value === value);
  return found ? found.label : '';
}

function WidgetCR({ data, actions, footer }) {
  return (
    <div style={{
      background: CH_SURF, borderRadius: 22, padding: '16px 18px',
      border: `1px solid ${CH_HAIR}`, boxShadow: VT.shadowCard,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
        <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.5px', textTransform: 'uppercase' }}>{data.type}</span>
        {actions}
      </div>
      <div style={{ fontSize: 16, fontWeight: 700, color: CH_INK, marginBottom: 12, letterSpacing: '-0.2px' }}>{data.title}</div>
      {data.sections.map((s, i) => (
        <div key={i} style={{ marginBottom: 10 }}>
          <div className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.4px', textTransform: 'uppercase', marginBottom: 4 }}>{s.heading}</div>
          <div className="serif" style={{ fontSize: 14, lineHeight: 1.55, color: CH_INK }}>{s.content}</div>
        </div>
      ))}
      {footer}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// WIDGET: RDV
// ═══════════════════════════════════════════════════════════
function WidgetRDV({ data, accent, actions, onConfirm, confirmed, busy, reason }) {
  return (
    <div style={{
      background: CH_SURF, borderRadius: 22, padding: '16px 18px',
      border: `1px solid ${CH_HAIR}`, boxShadow: VT.shadowCard,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
        <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.5px', textTransform: 'uppercase' }}>Rendez-vous planifié</span>
      </div>
      <div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 14 }}>
        <div style={{
          width: 50, height: 50, borderRadius: 16,
          background: 'rgba(10,10,10,0.04)',
          display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
        }}>
          <span className="mono" style={{ fontSize: 9, fontWeight: 600, color: CH_SUB, letterSpacing: '0.4px', textTransform: 'uppercase' }}>{data.month}</span>
          <span style={{ fontSize: 20, fontWeight: 800, color: CH_INK, lineHeight: 1, letterSpacing: '-0.5px' }}>{data.day}</span>
        </div>
        <div>
          <div style={{ fontSize: 15, fontWeight: 600, color: CH_INK, letterSpacing: '-0.1px' }}>{data.patient}</div>
          <div className="mono" style={{ fontSize: 12, fontWeight: 500, color: CH_SUB, marginTop: 2 }}>{data.time} · {data.lieu}</div>
          <div style={{ fontSize: 12, fontWeight: 500, color: CH_SUB, marginTop: 2 }}>{data.motif}</div>
        </div>
      </div>
      {/* Confirmer crée un vrai rendez-vous côté serveur. Tant que l'appel n'a
          pas abouti, rien n'est annoncé : un « Confirmé » affiché sans reçu
          ferait croire à un rendez-vous inexistant. */}
      <button
        onClick={() => { if (!confirmed && !busy && !reason) onConfirm?.(); }}
        disabled={confirmed || busy || Boolean(reason)}
        className={confirmed || busy || reason ? undefined : 'press'}
        title={reason || undefined}
        style={{
          width: '100%', height: VT.hMd, borderRadius: VT.rMd,
          background: confirmed ? CH_OK : CH_INK, color: '#fff', border: 'none',
          fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
          cursor: confirmed || busy || reason ? 'default' : 'pointer',
          opacity: reason ? 0.45 : 1,
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
          transition: 'background .25s ease',
        }}>
        {busy ? <VascoSpinner size={14} color="#fff" thickness={2}
          style={{ borderColor: 'rgba(255,255,255,0.3)', borderTopColor: '#fff' }} /> : (
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"
            style={confirmed ? { animation: 'rdvCheck .35s cubic-bezier(.16,1,.3,1)' } : undefined}>
            <path d="M20 6L9 17l-5-5"/>
          </svg>
        )}
        {busy ? 'Confirmation…' : confirmed ? 'Confirmé' : 'Confirmer'}
      </button>
      {reason && (
        <div style={{ fontSize: 11.5, fontWeight: 500, color: CH_SUB, marginTop: 8, lineHeight: 1.4 }}>
          {reason}
        </div>
      )}
      <style>{`
        @keyframes rdvCheck {
          from { transform: scale(.4); opacity: .4; }
          to   { transform: scale(1); opacity: 1; }
        }
      `}</style>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// Chat messages — user + assistant with widgets
// ═══════════════════════════════════════════════════════════
const DEMO_MESSAGES = [
  // 1. Fiche patient
  { role: 'user', text: 'Donne-moi la fiche de M. Martin Jean' },
  { role: 'assistant', text: 'Voici la fiche du patient :', widget: 'patient', data: {
    name: 'Jean Martin', initials: 'JM', age: 72, sex: 'M', ipp: '2210·44871',
    status: 'hosp', bed: 'CV2 · 412',
    allergies: 'Pénicilline, Iode (modéré)',
    treatments: 'Warfarine 5mg, Atorvastatine 40mg, Aspirine 75mg',
    mt: 'Dr. Vidal, Hervé',
  }},

  // 2. Constantes
  { role: 'user', text: 'Quelles sont ses constantes du jour ?' },
  { role: 'assistant', text: 'Constantes relevées ce matin :', widget: 'constantes', data: {
    patient: 'M. Martin',
    date: '30/04/26', time: '07:30',
    vitals: [
      { label: 'TA Syst.', value: '14.8', unit: 'cmHg', status: 'warning' },
      { label: 'TA Diast.', value: '8.2', unit: 'cmHg', status: 'normal' },
      { label: 'FC', value: '78', unit: 'bpm', status: 'normal' },
      { label: 'SpO2', value: '96', unit: '%', status: 'normal' },
      { label: 'Temp.', value: '37.2', unit: '°C', status: 'normal' },
      { label: 'INR', value: '3.8', unit: '', status: 'critical' },
    ],
  }},

  // 3. Email
  { role: 'user', text: 'Rédige un mail au Dr. Legrand pour lui demander un avis cardio pour ce patient' },
  { role: 'assistant', text: '', widget: 'email', data: {
    subject: 'Demande d\'avis cardiologique — M. Martin Jean',
    body: `Cher Confrère,

Je me permets de vous adresser M. Martin Jean, 72 ans, actuellement hospitalisé dans notre service pour un pontage fémoro-poplité sus-géniculé gauche (J0).

Le patient présente un antécédent de pontage coronarien (2015) et un INR supra-thérapeutique à 3.8 ce matin sous Warfarine 5mg.

Pourriez-vous nous donner votre avis sur la prise en charge cardiologique périopératoire, et notamment sur l'ajustement de l'anticoagulation ?

Je vous remercie par avance et reste à votre disposition.

Confraternellement,
Dr. Farmehdi`,
  }},

  // 4. Ordonnance
  { role: 'user', text: 'Fais-moi l\'ordonnance de sortie pour Mme Durand' },
  { role: 'assistant', text: 'Ordonnance générée :', widget: 'ordonnance', data: {
    patient: 'Mme Durand Hélène',
    meds: [
      { name: 'Clopidogrel 75mg', posology: '1 comprimé par jour au petit-déjeuner', duration: '6 mois' },
      { name: 'Atorvastatine 20mg', posology: '1 comprimé le soir', duration: '12 mois' },
      { name: 'Metformine 1000mg', posology: '1 comprimé matin et soir', duration: '12 mois' },
      { name: 'Paracétamol 1g', posology: '1 comprimé si douleur, max 4/jour', duration: '10 jours' },
      { name: 'Enoxaparine 4000 UI', posology: '1 injection sous-cutanée par jour', duration: '21 jours' },
    ],
  }},

  // 5. Courrier
  { role: 'user', text: 'Rédige un courrier pour le médecin traitant de M. Bernard Paul' },
  { role: 'assistant', text: '', widget: 'courrier', data: {
    to: 'Dr. Ferrand Luc — Médecin traitant',
    body: `Mon cher Confrère,

Je vous adresse ce courrier concernant votre patient M. Bernard Paul, 79 ans, que nous suivons pour un anévrysme de l'aorte abdominale infra-rénal de 54mm.

Après discussion en staff de chirurgie vasculaire, l'indication opératoire est retenue. Nous proposons un traitement endovasculaire (EVAR) compte tenu du terrain (BPCO stade III, AOD en cours).

L'intervention est programmée pour le 15 mai 2026. Un bilan pré-opératoire complet est nécessaire comprenant : ECG, échocardiographie, EFR, bilan biologique standard et consultation d'anesthésie.

Merci de bien vouloir organiser ce bilan et de nous recontacter pour la suite.

Confraternellement,
Dr. Farmehdi`,
  }},

  // 6. Compte rendu
  { role: 'user', text: 'Génère le CR opératoire du pontage de M. Martin' },
  { role: 'assistant', text: '', widget: 'cr', data: {
    type: 'CR Opératoire',
    title: 'Pontage fémoro-poplité sus-géniculé gauche',
    sections: [
      { heading: 'Indication', content: 'AOMI stade IIb gauche. Claudication invalidante à 60 mètres. Lésions TASC C de l\'artère fémorale superficielle gauche.' },
      { heading: 'Anesthésie', content: 'Anesthésie générale. Installation en décubitus dorsal.' },
      { heading: 'Intervention', content: 'Abord de l\'artère fémorale commune gauche au scarpa. Abord de l\'artère poplitée sus-articulaire par voie médiale. Tunnellisation sous-sartoriale. Pontage par prothèse PTFE 6mm armé. Anastomose proximale termino-latérale sur l\'AFC. Anastomose distale termino-latérale sur l\'AP1.' },
      { heading: 'Suites', content: 'Bon flux dans le pontage en fin d\'intervention. Pouls pédieux et tibial postérieur retrouvés. Hémostase soigneuse. Fermeture plan par plan. Pansement compressif.' },
    ],
  }},

  // 7. Codage CCAM — placé juste après le CR : c'est l'enchaînement réel
  //    (on code l'intervention qu'on vient de décrire) et ça rend la feature
  //    visible dans la conversation d'ouverture, pas seulement dans l'historique.
  { role: 'user', text: 'Code-moi cette intervention en CCAM' },
  { role: 'assistant', text: 'Voici les codes CCAM correspondants :', widget: 'ccam', data: {
    intervention: 'Pontage fémoro-poplité G',
    principal: {
      code: 'EDCA003',
      label: 'Pontage fémoro-poplité ou fémoro-jambier par abord direct, avec veine ou prothèse',
      tarif: '522,41 €',
      activite: '1',
    },
    complementaires: [
      { code: 'ZZQP004', label: 'Écho-doppler artériel peropératoire', tarif: '75,60 €', modificateur: '50%' },
      { code: 'YYYY030', label: 'Supplément pour anesthésie > 2h', tarif: '63,00 €', modificateur: '100%' },
    ],
    total: '660,71 €',
  }},

  // 8. RDV
  { role: 'user', text: 'Programme un RDV de contrôle pour Mme Petit dans 1 mois' },
  { role: 'assistant', text: 'RDV programmé :', widget: 'rdv', data: {
    patient: 'Mme Petit Aline',
    day: '30', month: 'Mai',
    time: '10:30', lieu: 'Salle 2',
    motif: 'Contrôle FAV radio-céphalique à 2 mois',
  }},
];

// ═══════════════════════════════════════════════════════════
// Message bubble
// ═══════════════════════════════════════════════════════════
function ChatBubble({ msg, accent, runtime = null, onWidgetUpdated, onEditWidget, onRetry, retrying = false, onPreviewImage, onNeedPatient = null, onOpenPatient = null }) {
  const isUser = msg.role === 'user';
  const [rdvBusy, setRdvBusy] = React.useState(false);
  const [ccamBusy, setCcamBusy] = React.useState(null);
  const [conflict, setConflict] = React.useState(null);
  const confirmCapability = (msg.widgetCapabilities || [])
    .find((capability) => capability.action === 'confirm');

  // Un rendez-vous qui chevauche un créneau existant est refusé en 409 : le
  // serveur renvoie alors un jeton et la liste des chevauchements. On les
  // montre puis on rejoue l'action avec ce jeton, jamais sans.
  const confirmRdv = async () => {
    if (!runtime || !msg.widgetId || rdvBusy) return;
    const widgetRef = { id: msg.widgetId, version: msg.widgetVersion, type: 'rdv' };
    setRdvBusy(true);
    try {
      const result = await runtime.widgetAction(widgetRef, 'confirm');
      // Le widget passe en « confirmé » et porte le rendez-vous créé : rien à
      // annoncer en bas de l'écran.
      onWidgetUpdated?.(result.widget);
    } catch (error) {
      const detail = error?.detail;
      if (error?.status === 409 && detail?.confirmation_token) {
        // On ne force rien tant que l'utilisateur n'a pas vu les créneaux
        // qui se chevauchent : le jeton atteste précisément de cette vue.
        setConflict(detail);
      } else {
        showToast(error?.message || 'Confirmation impossible', 'error');
      }
    } finally {
      setRdvBusy(false);
    }
  };

  const confirmRdvDespiteConflict = async () => {
    if (!conflict) return;
    const widgetRef = { id: msg.widgetId, version: msg.widgetVersion, type: 'rdv' };
    setRdvBusy(true);
    try {
      const forced = await runtime.widgetAction(widgetRef, 'confirm', {
        confirmation: 'appointment_conflict',
        conflictToken: conflict.confirmation_token,
      });
      onWidgetUpdated?.(forced.widget);
      setConflict(null);
    } catch (error) {
      showToast(error?.message || 'Confirmation impossible', 'error');
    } finally {
      setRdvBusy(false);
    }
  };

  // Cocher un acte, c'est éditer le brouillon : le serveur revalide la liste retenue,
  // recalcule le total et rend une nouvelle version. Rien n'est décidé côté écran.
  const editCcam = async (code, changes) => {
    if (!runtime || !msg.widgetId || ccamBusy) return;
    const data = msg.data || {};
    const all = [data.principal, ...(data.complementaires || [])].filter(Boolean);
    const retained = all.filter((act) => act.selected !== false).map((act) => act.code);
    setCcamBusy(code);
    try {
      const result = await runtime.widgetAction(
        { id: msg.widgetId, version: msg.widgetVersion, type: 'ccam' },
        'edit',
        { replacement: { selected_codes: retained, ...changes } },
      );
      onWidgetUpdated?.(result.widget);
    } catch (error) {
      showToast(error?.message || 'Modification impossible', 'error');
    } finally {
      setCcamBusy(null);
    }
  };

  // Cocher : la liste retenue bascule pour ce seul code. Le serveur revalide, applique la
  // règle d'association et recalcule le total. Rien n'est décidé côté écran.
  const toggleCcamCode = (code) => {
    const data = msg.data || {};
    const all = [data.principal, ...(data.complementaires || [])].filter(Boolean);
    const retained = all
      .filter((act) => (act.code === code ? act.selected === false : act.selected !== false))
      .map((act) => act.code);
    return editCcam(code, { selected_codes: retained });
  };

  const addCcamCode = (code) => editCcam(code, { added_codes: [code] });

  // Remonter d'un rang, pas sauter en tete : l'acte echange sa place avec le precedent
  // qui compte pour le rang, c'est-a-dire coche et tarife. Depuis le troisieme rang il
  // faut donc deux gestes : 0 %, puis 50 %, puis principal. On envoie l'ordre voulu en
  // entier ; le serveur recalcule les taux et efface la confirmation, la liste signee
  // ayant change.
  const raiseCcamCode = (code) => {
    const data = msg.data || {};
    const all = [data.principal, ...(data.complementaires || [])].filter(Boolean);
    const index = all.findIndex((act) => act.code === code);
    if (index <= 0) return undefined;
    const counts = (act) => act.selected !== false
      && act.amount_minor !== null && act.amount_minor !== undefined;
    let target = -1;
    for (let position = index - 1; position >= 0; position -= 1) {
      if (counts(all[position])) { target = position; break; }
    }
    if (target < 0) return undefined;
    const order = all.map((act) => act.code);
    order.splice(index, 1);
    order.splice(target, 0, code);
    return editCcam(code, { order_codes: order });
  };

  // Confirmer et rejeter ne passent pas par l'édition : ce sont des actions propres,
  // tracées séparément au journal d'audit. Elles sont rendues par `WidgetActions`,
  // qui tient déjà l'allowlist du serveur, la version attendue et l'état occupé.

  const widgetActions = (
    <WidgetActions msg={msg} runtime={runtime}
      onWidgetUpdated={onWidgetUpdated} onEdit={() => onEditWidget?.(msg)}
      onOpenPatient={onOpenPatient} />
  );

  if (msg.status === 'failed') {
    return (
      <div role="alert" style={{
        margin: '0 16px 16px', padding: '13px 14px', borderRadius: 16,
        background: 'rgba(166,60,47,0.07)', border: '1px solid rgba(166,60,47,0.18)',
        color: CH_INK,
      }}>
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
          <div aria-hidden="true" style={{
            width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            background: 'rgba(166,60,47,0.12)', color: '#8f3328',
            fontSize: 14, fontWeight: 800,
          }}>!</div>
          <div style={{ minWidth: 0 }}>
            <div style={{ fontSize: 13.5, fontWeight: 750, color: '#7f2f27' }}>
              Réponse non générée
            </div>
            <div style={{ marginTop: 3, fontSize: 12.5, fontWeight: 500, lineHeight: 1.4, color: CH_SUB }}>
              Votre message a bien été envoyé, mais le traitement a échoué.
            </div>
            {msg.errorMessage && (
              <div style={{ marginTop: 5, fontSize: 11.5, fontWeight: 500, lineHeight: 1.35, color: '#8f3328' }}>
                {msg.errorMessage}
              </div>
            )}
            {/* Le serveur indique lui-même si une reprise est possible : le
                bouton n'apparaît jamais sur un échec définitif. */}
            {runtime && msg.retryable && (
              <button onClick={() => onRetry?.(msg)} disabled={retrying} className={retrying ? undefined : 'press'}
                style={{
                  marginTop: 10, height: 34, padding: '0 14px', borderRadius: 12,
                  background: '#7f2f27', border: 'none', fontFamily: 'inherit',
                  fontSize: 12.5, fontWeight: 700, color: '#fff',
                  cursor: retrying ? 'default' : 'pointer',
                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
                }}>
                {retrying && <VascoSpinner size={12} color="#fff" thickness={2}
                  style={{ borderColor: 'rgba(255,255,255,0.3)', borderTopColor: '#fff' }} />}
                {retrying ? 'Nouvelle tentative…' : 'Réessayer'}
              </button>
            )}
          </div>
        </div>
      </div>
    );
  }

  return (
    <div data-widget-id={msg.widgetId || undefined} style={{
      display: 'flex', flexDirection: 'column',
      alignItems: isUser ? 'flex-end' : 'flex-start',
      marginBottom: 16,
      padding: '0 16px',
    }}>
      {/* Text bubble */}
      {msg.text && (
        <div style={{
          maxWidth: '85%',
          padding: '10px 16px',
          borderRadius: isUser ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
          background: isUser ? CH_INK : 'rgba(10,10,10,0.04)',
          color: isUser ? '#fff' : CH_INK,
          fontSize: 14, fontWeight: 500, lineHeight: 1.5,
        }}>
          {/* Le message de l'utilisateur est rendu littéralement : ce qu'il a
              tapé ne doit pas être réinterprété. */}
          {isUser ? msg.text : <ChatMarkdown text={msg.text} />}
        </div>
      )}

      {Array.isArray(msg.attachments) && msg.attachments.length > 0 && (
        <div style={{
          maxWidth: '85%', display: 'flex', flexWrap: 'wrap', gap: 6,
          marginTop: msg.text ? 7 : 0, justifyContent: isUser ? 'flex-end' : 'flex-start',
        }}>
          {msg.attachments.map((attachment) => (
            <ChatAttachment key={attachment.id} attachment={attachment}
              runtime={runtime} isUser={isUser} onPreview={onPreviewImage} />
          ))}
        </div>
      )}

      {/* Widget */}
      {msg.widget && (
        <div style={{ width: '100%', marginTop: msg.text ? 8 : 0 }}>
          {msg.widget === 'patient' && <WidgetPatient data={msg.data} actions={widgetActions} />}
          {msg.widget === 'constantes' && (
            <WidgetConstantes data={msg.data} actions={widgetActions}
              footer={
                // Pas de résolveur de patient ici : un relevé désigne déjà le dossier
                // d'où il vient. Demander « au dossier de qui ? » proposerait de le
                // déplacer, ce qui n'est pas ce que le bouton engage.
                <WidgetActions msg={msg} runtime={runtime}
                  onWidgetUpdated={onWidgetUpdated} variant="text" />
              } />
          )}
          {msg.widget === 'email' && <WidgetEmail data={msg.data} actions={widgetActions} />}
          {msg.widget === 'ordonnance' && <WidgetOrdonnance data={msg.data} actions={widgetActions} />}
          {msg.widget === 'courrier' && <WidgetCourrier data={msg.data} actions={widgetActions} />}
          {msg.widget === 'cr' && (
            <WidgetCR data={msg.data} actions={widgetActions}
              footer={
                <WidgetActions msg={msg} runtime={runtime}
                  onWidgetUpdated={onWidgetUpdated} onNeedPatient={onNeedPatient}
                  variant="text" />
              } />
          )}
          {msg.widget === 'rdv' && (
            <WidgetRDV data={msg.data} accent={accent} actions={widgetActions}
              onConfirm={confirmRdv} busy={rdvBusy}
              confirmed={msg.widgetStatus === 'confirmed' || confirmCapability?.reason_code === 'already_confirmed'}
              reason={runtime ? chWidgetReason(confirmCapability) : null} />
          )}
          {msg.widget === 'ccam' && (
            <WidgetCCAM data={msg.data} actions={widgetActions} busyCode={ccamBusy}
              footer={
                <WidgetActions msg={msg} runtime={runtime}
                  onWidgetUpdated={onWidgetUpdated} variant="text" />
              }
              onToggle={runtime && msg.widgetId ? toggleCcamCode : null}
              onAdd={runtime && msg.widgetId ? addCcamCode : null}
              onRaise={runtime && msg.widgetId ? raiseCcamCode : null} />
          )}
        </div>
      )}

      {conflict && (
        <ChPromptSheet
          title="Créneau déjà occupé"
          danger
          busy={rdvBusy}
          confirmLabel="Confirmer quand même"
          message={`Ce rendez-vous chevauche ${
            (conflict.conflicts || []).length > 1
              ? `${conflict.conflicts.length} rendez-vous existants`
              : 'un rendez-vous existant'
          }. Confirmer créera malgré tout le rendez-vous.`}
          mode="confirm"
          onCancel={() => setConflict(null)}
          onSubmit={() => void confirmRdvDespiteConflict()}
        />
      )}
    </div>
  );
}

function encodePcmWav(chunks, sampleRate) {
  const sampleCount = chunks.reduce((total, chunk) => total + chunk.length, 0);
  const buffer = new ArrayBuffer(44 + sampleCount * 2);
  const view = new DataView(buffer);
  const writeText = (offset, value) => {
    for (let index = 0; index < value.length; index += 1) {
      view.setUint8(offset + index, value.charCodeAt(index));
    }
  };
  writeText(0, 'RIFF');
  view.setUint32(4, 36 + sampleCount * 2, true);
  writeText(8, 'WAVE');
  writeText(12, 'fmt ');
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 1, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * 2, true);
  view.setUint16(32, 2, true);
  view.setUint16(34, 16, true);
  writeText(36, 'data');
  view.setUint32(40, sampleCount * 2, true);
  let offset = 44;
  chunks.forEach((chunk) => {
    for (let index = 0; index < chunk.length; index += 1) {
      const sample = Math.max(-1, Math.min(1, chunk[index]));
      view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
      offset += 2;
    }
  });
  return new Blob([buffer], { type: 'audio/wav' });
}

function readableFileSize(bytes) {
  if (bytes < 1024) return `${bytes} o`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}

// ═══════════════════════════════════════════════════════════
// WIDGET: Codes CCAM
// ═══════════════════════════════════════════════════════════
// L'API compte en centimes entiers. Diviser au moment de l'affichage, jamais avant :
// un tarif transporté en euros flottants perd des centimes sur des lignes qu'on additionne.
function ccamAmount(minor) {
  if (minor === null || minor === undefined) return 'tarif non publié';
  return `${(minor / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
}

// Le taux d'association, en points de base : 10000 = 100 %, 5000 = 50 %, 0 = non
// facturable. `null` veut dire « la règle ne s'applique pas » — acte décoché, ou acte
// sans tarif propre comme les gestes complémentaires.
function ccamRate(act) {
  const rate = act.billing_rate_basis_points;
  if (rate === null || rate === undefined) return '';
  return rate === 0 ? 'non facturable' : `${rate / 100} %`;
}

// Ce qui sera facturé pour cet acte, taux appliqué — pas son tarif de catalogue. Les
// deux diffèrent dès le second acte, et c'est le premier qui compte.
function ccamBilled(act) {
  const rate = act.billing_rate_basis_points;
  if (act.amount_minor === null || act.amount_minor === undefined) return 'tarif non publié';
  if (rate === null || rate === undefined) return ccamAmount(act.amount_minor);
  return ccamAmount(Math.floor((act.amount_minor * rate + 5000) / 10000));
}

// Recherche dans le référentiel, à l'intérieur du widget. La même route que la feuille
// du tableau de bord : un acte ajouté à la main est relu dans la table exactement comme
// un acte proposé par le modèle.
function CCAMAddAct({ onAdd, existing }) {
  const [open, setOpen] = React.useState(false);
  const [term, setTerm] = React.useState('');
  const [hits, setHits] = React.useState([]);
  const [state, setState] = React.useState('idle');

  React.useEffect(() => {
    const query = term.trim();
    if (!open || query.length < 3) { setHits([]); setState('idle'); return undefined; }
    let cancelled = false;
    setState('loading');
    const timer = setTimeout(() => {
      window.VascoApi?.ccam?.search(query).then((payload) => {
        if (cancelled) return;
        setHits(payload.items.filter((item) => !existing.includes(item.code)).slice(0, 6));
        setState(payload.items.length ? 'ready' : 'empty');
      }).catch(() => { if (!cancelled) setState('error'); });
    }, 300);
    return () => { cancelled = true; clearTimeout(timer); };
  }, [term, open, existing.join(',')]);

  if (!open) {
    return (
      <div onClick={() => setOpen(true)} style={{
        marginTop: 10, padding: '9px 0', cursor: 'pointer',
        borderTop: `1px solid ${CH_HAIR}`,
        fontSize: 12, fontWeight: 600, color: CH_MED,
      }}>+ Ajouter un acte</div>
    );
  }
  return (
    <div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${CH_HAIR}` }}>
      <input
        autoFocus
        value={term}
        onChange={(event) => setTerm(event.target.value)}
        placeholder="Acte, code, catégorie…"
        style={{
          width: '100%', padding: '9px 12px', borderRadius: 12,
          border: `1px solid ${CH_HAIR}`, background: 'rgba(10,10,10,0.02)',
          fontSize: 13, color: CH_INK, fontFamily: 'inherit', outline: 'none',
        }}
      />
      {state === 'loading' && <div style={{ fontSize: 11, color: CH_SUB, padding: '8px 2px' }}>Recherche…</div>}
      {state === 'empty' && <div style={{ fontSize: 11, color: CH_SUB, padding: '8px 2px' }}>Aucun acte du référentiel ne correspond.</div>}
      {state === 'error' && <div style={{ fontSize: 11, color: CH_SUB, padding: '8px 2px' }}>Le référentiel n’a pas répondu.</div>}
      {hits.map((hit) => (
        <div key={hit.code} onClick={() => { onAdd(hit.code); setOpen(false); setTerm(''); }} style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          gap: 10, padding: '9px 2px', cursor: 'pointer',
          borderTop: `1px solid ${CH_HAIR}`,
        }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="mono" style={{ fontSize: 11, fontWeight: 700, color: CH_INK }}>{hit.code}</div>
            <div style={{ fontSize: 11.5, color: CH_SUB, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{hit.label}</div>
          </div>
          <span className="mono" style={{ fontSize: 11, color: CH_INK, flexShrink: 0 }}>{hit.tariff || 'tarif non publié'}</span>
        </div>
      ))}
    </div>
  );
}

function WidgetCCAM({ data, actions, footer, onToggle, onAdd, onRaise, busyCode }) {
  const acts = Array.isArray(data.complementaires) ? data.complementaires : [];
  const allActs = [data.principal, ...acts];

  // Seuls les actes coches et tarifes prennent un rang, donc un taux. Les autres sont
  // traverses sans consommer de place : c'est la regle appliquee par le serveur.
  const ranks = (act) => act.selected !== false
    && act.amount_minor !== null && act.amount_minor !== undefined;
  const ranksBefore = (index) => allActs.slice(0, index).filter(ranks).length;

  return (
    <div style={{
      background: CH_SURF, borderRadius: 22, padding: '16px 18px',
      border: `1px solid ${CH_HAIR}`, boxShadow: VT.shadowCard,
      // Un codage écarté reste lisible, en retrait : le fil garde la trace de ce
      // qui a été proposé et de ce qu'on en a fait.
      opacity: data.discarded_at ? 0.55 : 1,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
        <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: CH_SUB, letterSpacing: '0.5px', textTransform: 'uppercase' }}>Codage CCAM — {data.intervention}</span>
        {actions}
      </div>
      {/* Acte principal */}
      <div onClick={() => onToggle?.(data.principal.code)} style={{
        cursor: onToggle ? 'pointer' : 'default',
        opacity: data.principal.selected === false ? 0.45 : 1,
        padding: '12px 14px', borderRadius: 16,
        background: 'rgba(10,10,10,0.03)', marginBottom: 8,
        border: `1px solid ${CH_HAIR}`,
      }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
          <span className="mono" style={{ fontSize: 9, fontWeight: 700, color: CH_INK, letterSpacing: '0.4px', textTransform: 'uppercase' }}>
            {onToggle ? `${data.principal.selected === false ? '○' : '●'} ` : ''}Acte principal
            {ccamRate(data.principal) ? ` — ${ccamRate(data.principal)}` : ''}
          </span>
          {/* Ici s'affichaient successivement « 100% » écrit en dur, puis une pastille
              verte « vérifié ». Les deux mentaient de la même façon : posées contre le
              code, elles se lisent « ce codage est bon », alors que la seule chose
              établie est que le code existe dans le référentiel. Le choix de l'acte,
              lui, n'est vérifié par personne. Ce qui a réellement été relu est écrit en
              toutes lettres au pied du widget, où il ne peut pas être pris pour un
              acquittement. */}
        </div>
        <div className="mono" style={{ fontSize: 14, fontWeight: 700, color: CH_INK, letterSpacing: '0.2px' }}>{data.principal.code}</div>
        <div style={{ fontSize: 13, fontWeight: 500, color: CH_INK, marginTop: 4 }}>{data.principal.label}</div>
        <div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
          <div>
            <span className="mono" style={{ fontSize: 9, fontWeight: 600, color: CH_SUB, letterSpacing: '0.3px' }}>TARIF</span>
            <div className="mono" style={{ fontSize: 15, fontWeight: 700, color: CH_INK, marginTop: 2 }}>{ccamBilled(data.principal)}</div>
          </div>
          {data.principal.activite && (
            <div>
              <span className="mono" style={{ fontSize: 9, fontWeight: 600, color: CH_SUB, letterSpacing: '0.3px' }}>ACTIVITÉ</span>
              <div className="mono" style={{ fontSize: 15, fontWeight: 700, color: CH_INK, marginTop: 2 }}>{data.principal.activite}</div>
            </div>
          )}
        </div>
      </div>
      {/* Actes complémentaires */}
      {acts.length > 0 && (
        <div>
          <div className="mono" style={{ fontSize: 9, fontWeight: 700, color: CH_SUB, letterSpacing: '0.4px', textTransform: 'uppercase', padding: '8px 0 6px' }}>Actes complémentaires</div>
          {acts.map((c, i) => (
            <div key={i} onClick={() => onToggle?.(c.code)} className="ccam-row" style={{
              display: 'flex', alignItems: 'center', justifyContent: 'space-between',
              padding: '10px 0',
              cursor: onToggle ? 'pointer' : 'default',
              opacity: c.selected === false ? 0.45 : 1,
              borderTop: `1px solid ${CH_HAIR}`,
            }}>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div className="mono" style={{ fontSize: 12, fontWeight: 700, color: CH_INK, letterSpacing: '0.2px' }}>
                  {onToggle ? `${c.selected === false ? '○' : '●'} ` : ''}{c.code}
                </div>
                <div style={{ fontSize: 12, fontWeight: 500, color: CH_SUB, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.label}</div>
              </div>
              <div className="ccam-amounts" style={{ textAlign: 'right', flexShrink: 0, marginLeft: 12 }}>
                <div className="mono" style={{ fontSize: 12, fontWeight: 600, color: CH_INK }}>{ccamBilled(c)}</div>
                <div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', marginTop: 2 }}>
                  {ccamRate(c) && (
                    <span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 999, background: 'rgba(10,10,10,0.04)', color: CH_SUB, fontFamily: "'JetBrains Mono', monospace" }}>{ccamRate(c)}</span>
                  )}
                </div>
              </div>
              {/* Remonter n'est offert que sur un acte coché et tarifé, et seulement
                  s'il reste un rang à lui prendre : le rang d'association se compte sur
                  ceux-là. Un clic vaut une place, pas un saut en tête — depuis le
                  troisième rang il en faut deux. Le clic ne doit pas remonter à la
                  ligne, qui coche et décoche. */}
              {onRaise && ranks(c) && ranksBefore(i + 1) > 0 && (
                <button
                  onClick={(event) => { event.stopPropagation(); onRaise(c.code); }}
                  disabled={Boolean(busyCode)}
                  className={busyCode ? 'ccam-promote' : 'ccam-promote press'}
                  title={ranksBefore(i + 1) === 1
                    ? `Faire de ${c.code} l’acte principal`
                    : `Remonter ${c.code} d’un rang`}
                  aria-label={ranksBefore(i + 1) === 1
                    ? `Faire de ${c.code} l’acte principal`
                    : `Remonter ${c.code} d’un rang`}
                  style={{
                    // `margin-left` et le positionnement vivent dans la feuille de
                    // style : ils diffèrent selon que le pointeur sait survoler.
                    width: 26, height: 26, flexShrink: 0,
                    display: 'flex', alignItems: 'center', justifyContent: 'center',
                    padding: 0, borderRadius: 999, border: 'none',
                    background: 'rgba(10,10,10,0.04)', color: CH_SUB,
                    cursor: busyCode ? 'default' : 'pointer',
                    // Le style en ligne l'emporte sur la règle de survol : une
                    // promotion en cours reste visible même si le curseur s'en va.
                    ...(busyCode === c.code ? { opacity: 1 } : {}),
                  }}>
                  {busyCode === c.code ? (
                    <VascoSpinner size={11} color={CH_SUB} thickness={2} />
                  ) : (
                    <svg width="13" height="13" viewBox="0 0 24 24" fill="none"
                      stroke="currentColor" strokeWidth="2" strokeLinecap="round"
                      strokeLinejoin="round">
                      <path d="M12 19V5" /><path d="M5 12l7-7 7 7" />
                    </svg>
                  )}
                </button>
              )}
            </div>
          ))}
        </div>
      )}
      {/* Ajouter un acte que le modèle n'a pas retenu. Il ne voit que ce que sa
          recherche a ramené ; le praticien n'a pas à s'y limiter. */}
      {onAdd && <CCAMAddAct onAdd={onAdd} existing={allActs.map((a) => a.code)} />}
      {/* Total */}
      <div style={{
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        padding: '12px 0 0', marginTop: 4, borderTop: `1px solid ${CH_HAIR2}`,
      }}>
        <span style={{ fontSize: 13, fontWeight: 700, color: CH_INK }}>
          Total estimé{allActs.some((a) => a.selected === false)
            ? ` (${allActs.filter((a) => a.selected !== false).length}/${allActs.length} actes)` : ''}
        </span>
        <span className="mono" style={{ fontSize: 18, fontWeight: 800, color: CH_INK, letterSpacing: '-0.3px' }}>{ccamAmount(data.total_minor)}</span>
      </div>
      {/* Les commandes se lisent après le total : on décide de ce codage une fois
          qu'on a vu ce qu'il engage. */}
      {footer}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// Chat history — old conversations
// ═══════════════════════════════════════════════════════════
const CHAT_HISTORY = [
  {
    id: 'current', title: 'Suivi M. Martin — Pontage', date: "Aujourd'hui", preview: 'CR opératoire, constantes, ordonnance…',
    messages: DEMO_MESSAGES,
  },
  {
    id: 'h1', title: 'Bilan pré-op Mme Durand', date: '29 avril', preview: 'Echo-doppler, bilan sanguin, consultation anesthésie',
    messages: [
      { role: 'user', text: 'Quel bilan pré-op faut-il pour l\'endartériectomie de Mme Durand ?' },
      { role: 'assistant', text: 'Pour l\'endartériectomie carotidienne de Mme Durand, voici le bilan pré-opératoire recommandé :\n\n• ECG de repos\n• Échocardiographie transthoracique\n• Bilan biologique : NFS, TP/TCA, ionogramme, créatinine, glycémie à jeun, HbA1c\n• Doppler des TSA (déjà réalisé)\n• Consultation d\'anesthésie\n• Arrêt du Clopidogrel 5 jours avant\n\nLe diabète T2 de la patiente nécessite un protocole insuline relais en péri-opératoire.' },
      { role: 'user', text: 'Rédige le courrier pour l\'anesthésiste' },
      { role: 'assistant', text: '', widget: 'courrier', data: {
        to: 'Dr. Morel — Service d\'anesthésie',
        body: 'Cher Confrère,\n\nJe vous adresse Mme Durand Hélène, 68 ans, pour consultation d\'anesthésie en vue d\'une endartériectomie carotidienne droite programmée.\n\nAntécédents notables : Diabète T2 sous Metformine, AIT récent (02/05/26), sténose carotide D 78% symptomatique.\n\nTraitement actuel : Clopidogrel 75mg (à arrêter J-5), Metformine 1000mg ×2, Atorvastatine 20mg.\n\nMerci de votre prise en charge.\n\nConfraternellement,\nDr. Farmehdi',
      }},
    ],
  },
  {
    id: 'h2', title: 'Urgence M. Robert — Ischémie', date: '28 avril', preview: 'Ischémie aiguë MI, embolectomie en urgence',
    messages: [
      { role: 'user', text: 'Patient en urgence : ischémie aiguë MI gauche, ACFA connue, AVK arrêté depuis 3 jours. Que faire ?' },
      { role: 'assistant', text: 'Ischémie aiguë de membre sur probable embolie artérielle (ACFA + arrêt AVK). Conduite à tenir immédiate :\n\n1. Héparinothérapie IV en bolus (80 UI/kg) puis continue\n2. Antalgiques IV (morphine si nécessaire)\n3. Bilan en urgence : NFS, iono, créat, CPK, lactates, GDS\n4. Doppler artériel en urgence pour confirmer le niveau d\'occlusion\n5. Bloc en urgence pour embolectomie au Fogarty\n\nClassification de Rutherford à préciser (IIa probable vu le délai). Le membre est-il encore viable ?' },
      { role: 'user', text: 'Oui membre viable, programme le bloc' },
      { role: 'assistant', text: 'RDV programmé :', widget: 'rdv', data: {
        patient: 'M. Robert Lucas', day: '28', month: 'Avr',
        time: '14:00', lieu: 'Bloc 5 — Urgence',
        motif: 'Embolectomie Fogarty MI gauche',
      }},
    ],
  },
  {
    id: 'h3', title: 'Ordonnance Mme Lefèvre', date: '27 avril', preview: 'Ordonnance de sortie post-pontage J+5',
    messages: [
      { role: 'user', text: 'Ordonnance de sortie pour Mme Lefèvre, pontage fémoro-jambier J+5' },
      { role: 'assistant', text: 'Ordonnance générée :', widget: 'ordonnance', data: {
        patient: 'Mme Lefèvre Denise',
        meds: [
          { name: 'Aspirine 75mg', posology: '1 comprimé par jour', duration: 'Au long cours' },
          { name: 'Atorvastatine 40mg', posology: '1 comprimé le soir', duration: '12 mois' },
          { name: 'Enoxaparine 4000 UI', posology: '1 injection SC par jour', duration: '30 jours' },
          { name: 'Paracétamol 1g', posology: 'Si douleur, max 4/jour', duration: '15 jours' },
        ],
      }},
    ],
  },
  {
    id: 'h4', title: 'Staff AAA M. Bernard', date: '25 avril', preview: 'Discussion EVAR vs chirurgie ouverte',
    messages: [
      { role: 'user', text: 'Résume le dossier de M. Bernard pour le staff' },
      { role: 'assistant', text: '', widget: 'patient', data: {
        name: 'Paul Bernard', initials: 'PB', age: 79, sex: 'M', ipp: '3041·90112',
        status: 'ambu', bed: null,
        allergies: 'Iode',
        treatments: 'Apixaban 5mg ×2, Tiotropium',
        mt: 'Dr. Ferrand, Luc',
      }},
      { role: 'user', text: 'Arguments pour EVAR vs chirurgie ouverte ?' },
      { role: 'assistant', text: 'Pour M. Bernard (79 ans, BPCO stade III, sous AOD) :\n\n🟢 Arguments EVAR :\n• Moins invasif, récupération rapide\n• BPCO = risque anesthésique élevé en chirurgie ouverte\n• Anatomie compatible (collet infra-rénal > 15mm probable)\n• AOD plus facile à gérer en péri-op EVAR\n\n🔴 Arguments chirurgie ouverte :\n• Durabilité supérieure à long terme\n• Pas de surveillance TDM annuelle (allergie iode = problème)\n• Pas de risque d\'endofuite\n\n➡️ Recommandation : EVAR privilégié. L\'allergie à l\'iode nécessite un protocole de prémédication (corticoïdes + antihistaminiques) ou utilisation de CO2 comme agent de contraste.' },
    ],
  },
  {
    id: 'h5', title: 'Codage CCAM — Pontage Martin', date: '24 avril', preview: 'Codes CCAM pontage fémoro-poplité',
    messages: [
      { role: 'user', text: 'Donne-moi les codes CCAM pour le pontage fémoro-poplité de M. Martin' },
      { role: 'assistant', text: 'Voici les codes CCAM correspondants :', widget: 'ccam', data: {
        intervention: 'Pontage fémoro-poplité G',
        principal: {
          code: 'EDCA003',
          label: 'Pontage fémoro-poplité ou fémoro-jambier par abord direct, avec veine ou prothèse',
          tarif: '522,41 €',
          activite: '1',
        },
        complementaires: [
          { code: 'ZZQP004', label: 'Écho-doppler artériel peropératoire', tarif: '75,60 €', modificateur: '50%' },
          { code: 'EDCA001', label: 'Endartériectomie de l\'artère fémorale commune associée', tarif: '209,00 €', modificateur: '50%' },
          { code: 'YYYY030', label: 'Supplément pour CEC ou anesthésie > 2h', tarif: '63,00 €', modificateur: '100%' },
        ],
        total: '766,31 €',
      }},
      { role: 'user', text: 'Et le GHM correspondant ?' },
      { role: 'assistant', text: 'Pour ce type d\'intervention, le GHM probable est :\n\n• **05C04** — Chirurgie majeure de revascularisation, niveau 2\n• Durée moyenne de séjour : 8,2 jours\n• Tarif GHS : 6 842 €\n\nFacteurs de surclassement possibles :\n— Âge > 70 ans (✓ M. Martin 72 ans)\n— CMA niveau 2 (HTA + tabagisme)\n— Durée > 10 jours → passage au niveau 3\n\nJe vous recommande de bien coder les CMA associées (HTA, AOMI stade IIb) pour optimiser le GHM.' },
    ],
  },
  {
    id: 'h6', title: 'Suivi FAV Mme Petit', date: '22 avril', preview: 'Contrôle FAV radio-céphalique J+14',
    messages: [
      { role: 'user', text: 'Constantes de Mme Petit à J+14 de la FAV' },
      { role: 'assistant', text: 'Constantes relevées :', widget: 'constantes', data: {
        patient: 'Mme Petit',
        date: '22/04/26', time: '13:15',
        vitals: [
          { label: 'TA Syst.', value: '13.5', unit: 'cmHg', status: 'normal' },
          { label: 'TA Diast.', value: '7.8', unit: 'cmHg', status: 'normal' },
          { label: 'FC', value: '82', unit: 'bpm', status: 'normal' },
          { label: 'Débit FAV', value: '450', unit: 'ml/min', status: 'normal' },
        ],
      }},
      { role: 'user', text: 'C\'est bon, programme le contrôle à 1 mois' },
      { role: 'assistant', text: 'RDV programmé :', widget: 'rdv', data: {
        patient: 'Mme Petit Aline', day: '22', month: 'Mai',
        time: '13:00', lieu: 'Salle 2',
        motif: 'Contrôle FAV à 1 mois — écho-doppler',
      }},
    ],
  },
];

// ═══════════════════════════════════════════════════════════
// History panel — slide-over
// ═══════════════════════════════════════════════════════════
function HistoryPanel({ conversations, activeId, onPick, onClose, onNew, onDeleteConv, onRename }) {
  return (
    <div style={{
      position: 'absolute', inset: 0, zIndex: 50,
      display: 'flex',
    }}>
      {/* Backdrop */}
      <div onClick={onClose} style={{
        position: 'absolute', inset: 0,
        background: 'rgba(10,10,10,0.25)',
        transition: 'opacity .2s ease',
      }} />
      {/* Panel */}
      <div style={{
        position: 'relative', width: '82%', maxWidth: 340,
        background: CH_BG, height: '100%',
        boxShadow: '4px 0 24px rgba(10,10,10,0.12)',
        display: 'flex', flexDirection: 'column',
        animation: 'slideRight .25s cubic-bezier(.2,.7,.2,1)',
      }}>
        <style>{`@keyframes slideRight { from { transform: translateX(-100%); } to { transform: translateX(0); } }`}</style>

        {/* Header */}
        <div style={{ paddingTop: 62, padding: '62px 16px 12px' }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
            <div style={{ fontSize: 18, fontWeight: 800, color: CH_INK, letterSpacing: '-0.4px' }}>Historique</div>
            <button onClick={onClose} className="press" style={{
              width: 32, height: 32, borderRadius: 12,
              background: 'rgba(10,10,10,0.04)', border: 'none',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              cursor: 'pointer',
            }}>
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={CH_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                <path d="M18 6L6 18M6 6l12 12"/>
              </svg>
            </button>
          </div>

          {/* New chat button */}
          <button onClick={() => { onNew(); onClose(); }} className="press" style={{
            width: '100%', marginTop: 12, padding: '10px 14px',
            background: CH_INK, color: '#fff', border: 'none', borderRadius: 16,
            fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
            cursor: 'pointer',
          }}>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
              <path d="M12 5v14M5 12h14"/>
            </svg>
            Nouvelle conversation
          </button>
        </div>

        {/* Conversations list */}
        <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto', padding: '4px 12px 24px' }}>
          {/* Tout supprimé : on explique et on redonne la porte de sortie,
              plutôt qu'un panneau blanc sans issue. */}
          {conversations.length === 0 && (
            <div style={{ textAlign: 'center', padding: '48px 22px 0' }}>
              <div style={{
                width: 52, height: 52, borderRadius: '50%', margin: '0 auto 14px',
                background: 'rgba(10,10,10,0.05)',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
              }}>
                <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={CH_SUB}
                  strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
                </svg>
              </div>
              <div style={{ fontSize: 14.5, fontWeight: 700, color: CH_INK, letterSpacing: '-0.2px' }}>
                Aucune conversation
              </div>
              <div style={{ fontSize: 12.5, fontWeight: 500, color: CH_SUB, marginTop: 6, lineHeight: 1.45 }}>
                Vos échanges avec l'assistant s'afficheront ici. Dictez un compte rendu ou posez une question pour démarrer.
              </div>
              <button onClick={() => { onNew(); onClose(); }} className="press" style={{
                marginTop: 16, padding: '9px 16px', borderRadius: 999,
                background: 'transparent', border: `1px solid ${CH_HAIR2}`,
                fontFamily: 'inherit', fontSize: 12.5, fontWeight: 700, color: CH_INK,
                cursor: 'pointer',
              }}>Démarrer une conversation</button>
            </div>
          )}
          {conversations.map((c) => {
            const isActive = c.id === activeId;
            return (
              <SwipeToDelete key={c.id} onDelete={() => onDeleteConv(c.id)}>
                <button onClick={() => { onPick(c.id); onClose(); }} className="press" style={{
                  width: '100%', textAlign: 'left', padding: '12px 14px',
                  background: isActive ? 'rgba(10,10,10,0.06)' : 'transparent',
                  border: 'none', borderRadius: 16, cursor: 'pointer',
                  fontFamily: 'inherit', marginBottom: 2,
                  transition: 'background .1s ease',
                }}>
                  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
                    <div style={{
                      fontSize: 13.5, fontWeight: 600, color: CH_INK,
                      whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                      flex: 1,
                    }}>{c.title}</div>
                    {onRename && (
                      <span role="button" tabIndex={0} aria-label={`Renommer ${c.title}`}
                        title="Renommer"
                        onClick={(event) => { event.stopPropagation(); onRename(c); }}
                        onKeyDown={(event) => {
                          if (event.key === 'Enter' || event.key === ' ') {
                            event.preventDefault(); event.stopPropagation(); onRename(c);
                          }
                        }}
                        style={{
                          display: 'flex', alignItems: 'center', justifyContent: 'center',
                          width: 26, height: 26, borderRadius: 9, flexShrink: 0, cursor: 'pointer',
                          background: 'rgba(10,10,10,0.04)',
                        }}>
                        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={CH_SUB}
                          strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                          <path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
                        </svg>
                      </span>
                    )}
                    <span className="mono" style={{ fontSize: 10, fontWeight: 500, color: CH_SUB, flexShrink: 0 }}>{c.date}</span>
                  </div>
                  <div style={{
                    fontSize: 12, fontWeight: 500, color: CH_SUB, marginTop: 3,
                    whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                  }}>{c.preview}</div>
                </button>
              </SwipeToDelete>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════
// AIChatScreen — main component
// ═══════════════════════════════════════════════════════════
function AIChatScreen({
  accent, onTabChange, runtime = null, onCreatePatient = null,
  resumeSave = null, onResumeHandled = null, onOpenPatient = null,
}) {
  const connected = Boolean(runtime);
  const [activeConvId, setActiveConvId] = React.useState(connected ? null : 'current');
  const [conversations, setConversations] = React.useState(connected ? [] : CHAT_HISTORY);
  const [showHistory, setShowHistory] = React.useState(false);
  const [input, setInput] = React.useState('');
  const [showAttach, setShowAttach] = React.useState(false);
  const [serverBusy, setServerBusy] = React.useState(connected);
  const [serverError, setServerError] = React.useState(null);
  // Id de la conversation où l'assistant rédige (null = aucune). On stocke
  // l'id plutôt qu'un booléen : changer de fil pendant l'attente ne doit pas
  // faire apparaître les points dans la conversation qu'on vient d'ouvrir.
  const [thinkingIn, setThinkingIn] = React.useState(null);
  const [thinkingStage, setThinkingStage] = React.useState('generating');

  // Le menu d'attachement était positionné en dur : sur écran étroit il
  // débordait. On mesure après ouverture et on le recale dans la fenêtre.
  const attachMenuRef = React.useRef(null);
  const [attachShift, setAttachShift] = React.useState(0);
  React.useLayoutEffect(() => {
    if (!showAttach) { setAttachShift(0); return; }
    const el = attachMenuRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const margin = 8;
    if (r.left < margin) setAttachShift(margin - r.left);
    else if (r.right > window.innerWidth - margin) setAttachShift(window.innerWidth - margin - r.right);
  }, [showAttach]);
  const [listening, setListening] = React.useState(false);
  const [listenElapsed, setListenElapsed] = React.useState(0);
  // Niveaux audio réels des dernières trames, alimentés par `onaudioprocess`
  // (~12 mesures par seconde avec une trame de 4096 échantillons).
  const [levels, setLevels] = React.useState([]);
  const levelsRef = React.useRef([]);
  // Assez de mesures pour ne pas crier au silence sur une simple respiration.
  const silentInput = levels.length >= CH_WAVE_BARS
    && levels.every((level) => level < CH_SILENCE_RMS);
  const [voicePhase, setVoicePhase] = React.useState(null);
  const [voiceDraft, setVoiceDraft] = React.useState(null);
  const [pendingAttachments, setPendingAttachments] = React.useState([]);
  const [attachmentPhase, setAttachmentPhase] = React.useState(null);
  const voiceRecorderRef = React.useRef(null);
  const attachFileRef = React.useRef(null);
  const attachCameraRef = React.useRef(null);
  const attachGalleryRef = React.useRef(null);
  const scrollRef = React.useRef(null);

  const loadServerConversations = React.useCallback(async () => {
    if (!runtime) return;
    setServerBusy(true);
    setServerError(null);
    try {
      const summaries = await runtime.listConversations();
      if (summaries.length === 0) {
        setConversations([]);
        setActiveConvId(null);
        return;
      }
      const first = await runtime.getConversation(summaries[0].id);
      setConversations([first, ...summaries.slice(1)]);
      setActiveConvId(first.id);
    } catch (error) {
      setServerError(error?.message || 'Les conversations sont indisponibles.');
    } finally {
      setServerBusy(false);
    }
  }, [runtime]);

  React.useEffect(() => {
    if (!connected) return;
    loadServerConversations();
  }, [connected, loadServerConversations]);

  // Toutes les conversations peuvent être supprimées : `activeConv` vaut alors
  // undefined et l'écran plantait sur `.messages`. On tient le cas vide.
  const activeConv = conversations.find(c => c.id === activeConvId) || conversations[0];
  // Le fil courant, lisible depuis un rappel sans le prendre en dépendance : les
  // actions de widget sont créées une fois et ne doivent pas se recréer à chaque
  // changement de conversation.
  const activeConvRef = React.useRef(null);
  activeConvRef.current = activeConv;
  // Carte à ramener sous les yeux après un enregistrement repris depuis un autre écran.
  const [focusWidgetId, setFocusWidgetId] = React.useState(null);
  const [editingWidget, setEditingWidget] = React.useState(null);
  const [widgetEditBusy, setWidgetEditBusy] = React.useState(false);
  const [retryingId, setRetryingId] = React.useState(null);
  const [imagePreview, setImagePreview] = React.useState(null);
  const attachRowRef = React.useRef(null);

  // Une molette ou un trackpad produisent un delta vertical. Sans conversion,
  // la rangée ne bouge pas et le défilement, bien que présent, reste hors de
  // portée : c'est exactement ce qui la rendait inutilisable.
  React.useEffect(() => {
    const row = attachRowRef.current;
    if (!row) return undefined;
    const onWheel = (event) => {
      if (row.scrollWidth <= row.clientWidth) return;
      if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
      event.preventDefault();
      row.scrollLeft += event.deltaY;
    };
    row.addEventListener('wheel', onWheel, { passive: false });
    return () => row.removeEventListener('wheel', onWheel);
  }, [pendingAttachments.length]);
  // Le serveur déclare lui-même ce qu'il sait faire : deviner côté client
  // conduirait à offrir un micro ou une pièce jointe qu'aucun fournisseur
  // n'assure, donc à un échec au moment du geste.
  const [serverCapabilities, setServerCapabilities] = React.useState(null);

  React.useEffect(() => {
    if (!connected) return undefined;
    let cancelled = false;
    (async () => {
      try {
        const value = await runtime.capabilities();
        if (!cancelled) setServerCapabilities(value);
      } catch {
        // L'absence de capacités n'empêche pas de discuter : les commandes
        // restent refusées une par une par le serveur.
      }
    })();
    return () => { cancelled = true; };
  }, [connected, runtime]);

  const voiceReason = connected && serverCapabilities && !serverCapabilities.voice
    ? (serverCapabilities.voice_reason || 'La dictée n’est pas disponible sur ce serveur.')
    : null;
  const attachmentsReason = connected && serverCapabilities && !serverCapabilities.attachments
    ? (serverCapabilities.attachments_reason || 'Les pièces jointes ne sont pas disponibles sur ce serveur.')
    : null;

  // L'association patient est une commande serveur versionnée, pas un simple
  // affichage : elle conditionne les widgets que le fournisseur peut proposer.
  // Feuille active : { kind, ... }. Une seule à la fois, comme les autres
  // surfaces modales de l'écran.
  const [sheet, setSheet] = React.useState(null);

  const pickConversationPatient = async () => {
    const conversation = conversations.find((item) => item.id === activeConvId) || conversations[0];
    if (!conversation) return;
    // `PATIENTS_FULL` contient les fixtures de la maquette tant que l'onglet
    // Patients n'a pas été ouvert : leurs identifiants (`p-2210`) ne sont pas
    // des UUID serveur. Se fier à la longueur du tableau enverrait donc un
    // identifiant inexistant. Seul le drapeau de chargement fait foi.
    let roster;
    try {
      roster = await window.VascoApi.loadPatients();
    } catch (error) {
      showToast(error?.message || 'Liste des patients indisponible', 'error');
      return;
    }
    // Un espace encore vide n'est pas un échec : la feuille le dit elle-même,
    // là où l'on cherchait à choisir.
    setSheet({ kind: 'patient', conversation, roster: roster || [] });
  };

  // Enregistrer un compte rendu suppose un dossier où l'écrire. Quand le fil n'en désigne
  // aucun, le patient se choisit ici, avant l'appel : la feuille rend l'identifiant, et
  // c'est l'enregistrement lui-même qui rattache le fil, en une seule commande serveur.
  const reportPatientResolver = React.useRef(null);

  const settleReportPatient = (patientId) => {
    const resolve = reportPatientResolver.current;
    reportPatientResolver.current = null;
    setSheet(null);
    resolve?.(patientId || null);
  };

  const requestReportPatient = async (widgetRef) => {
    let roster;
    try {
      roster = await window.VascoApi.loadPatients();
    } catch (error) {
      showToast(error?.message || 'Liste des patients indisponible', 'error');
      return null;
    }
    return new Promise((resolve) => {
      reportPatientResolver.current = resolve;
      setSheet({ kind: 'report_patient', roster: roster || [], widget: widgetRef });
    });
  };


  const applyConversationPatient = async (conversation, patientId) => {
    setSheet(null);
    try {
      const updated = await runtime.setConversationPatient(conversation, patientId);
      setConversations((current) => current.map((item) => (
        item.id === updated.id
          ? { ...item, patient: updated.patient, version: updated.version }
          : item
      )));
      // L'en-tête de la conversation porte désormais le patient associé.
    } catch (error) {
      showToast(error?.message || 'Association impossible', 'error');
    }
  };

  const renameConversation = (conversation) => {
    setSheet({ kind: 'rename', conversation });
  };

  const applyConversationRename = async (conversation, title) => {
    setSheet(null);
    if (!title || title === conversation.title) return;
    try {
      const updated = await runtime.renameConversation(conversation, title);
      setConversations((current) => current.map((item) => (
        item.id === updated.id ? { ...item, title: updated.title, version: updated.version } : item
      )));
      // Le nouveau titre s'affiche dans la liste et dans l'en-tête.
    } catch (error) {
      showToast(error?.message || 'Renommage impossible', 'error');
    }
  };

  // La reprise relance une génération durable : il faut attendre son issue
  // comme pour un envoi, sinon la bulle resterait figée sur l'échec précédent.
  const retryMessage = async (message) => {
    if (!connected || retryingId) return;
    const conversation = conversations.find((item) => item.id === activeConvId) || conversations[0];
    if (!conversation) return;
    setRetryingId(message.id);
    try {
      await runtime.retryMessage(conversation, message);
      const deadline = Date.now() + 65000;
      while (Date.now() < deadline) {
        await new Promise((resolve) => setTimeout(resolve, 1200));
        const detail = await runtime.getConversation(conversation.id);
        const updated = detail.messages.find((item) => item.id === message.id);
        if (!updated || (updated.status !== 'pending' && updated.status !== 'processing')) {
          setConversations((current) => current.map((item) => (
            item.id === detail.id ? detail : item
          )));
          if (updated?.status === 'failed') {
            showToast(updated.errorMessage || 'La nouvelle tentative a échoué', 'error');
          }
          return;
        }
      }
      showToast('La nouvelle tentative met trop de temps', 'error');
    } catch (error) {
      showToast(error?.message || 'Nouvelle tentative impossible', 'error');
    } finally {
      setRetryingId(null);
    }
  };

  const saveWidgetEdit = async (replacement) => {
    if (!editingWidget || widgetEditBusy) return;
    setWidgetEditBusy(true);
    try {
      const result = await runtime.widgetAction(
        { id: editingWidget.widgetId, version: editingWidget.widgetVersion, type: editingWidget.widget },
        'edit',
        { replacement },
      );
      // La feuille d'édition se ferme sur le widget mis à jour : c'est la
      // preuve de l'enregistrement.
      applyWidgetUpdate(result.widget);
      setEditingWidget(null);
    } catch (error) {
      showToast(error?.message || 'Enregistrement impossible', 'error');
      // Une version périmée rend le brouillon inutilisable : le fil rechargé
      // porte la version courante.
      if (error?.status === 409) setEditingWidget(null);
    } finally {
      setWidgetEditBusy(false);
    }
  };

  // Enregistrer un compte rendu peut rattacher le fil à un patient côté serveur.
  // L'écran ne l'apprend pas de la réponse, qui ne porte que le widget : sans cette
  // relecture, l'en-tête continuerait d'afficher « Associer un patient » au-dessus
  // d'un compte rendu qui vient d'entrer dans le dossier de quelqu'un.
  const refreshConversationPatient = React.useCallback(async (conversationId) => {
    if (!runtime || !conversationId) return;
    try {
      const detail = await runtime.getConversation(conversationId);
      setConversations((current) => current.map((item) => (
        item.id === detail.id
          ? { ...item, patient: detail.patient, version: detail.version }
          : item
      )));
    } catch { /* L'en-tête reste tel quel : rien d'écrit ne dépend de lui. */ }
  }, [runtime]);

  // Une action de widget renvoie la version à jour : la réinjecter évite qu'un
  // second geste reparte sur une version périmée et soit refusé en 409.
  const applyWidgetUpdate = React.useCallback((widget) => {
    if (!widget?.id) return;
    const conversation = activeConvRef.current;
    if (widget.data?.patient_id && conversation && !conversation.patient) {
      void refreshConversationPatient(conversation.id);
    }
    setConversations((current) => current.map((conversation) => ({
      ...conversation,
      messages: (conversation.messages || []).map((message) => (
        message.widgetId === widget.id
          ? {
            ...message,
            data: widget.data,
            widgetVersion: widget.version,
            widgetStatus: widget.status,
            widgetCapabilities: widget.capabilities || [],
          }
          : message
      )),
    })));
  }, [refreshConversationPatient]);
  const messages = activeConv ? activeConv.messages : [];

  // Le fil se recolle en bas quand il s'allonge, quand la réponse en cours grandit ou
  // quand on change de conversation — pas quand une carte se met à jour. Réinjecter la
  // version d'un widget reconstruit la liste des messages : s'y fier jetterait la vue à
  // la fin du fil à chaque geste sur une carte, y compris une carte lue au milieu.
  const last = messages[messages.length - 1];
  const scrollSignature = [
    activeConvId, messages.length, last?.id ?? '', last?.text?.length ?? 0, last?.status ?? '',
  ].join('|');

  // Une carte à ramener sous les yeux prime sur le bas du fil, et elle le fait à
  // chaque recalcul tant qu'elle est visée : le fil se charge en deux temps — le
  // résumé puis les messages — et un unique défilement serait défait par l'arrivée
  // du second.
  const scrollToCard = React.useCallback((widgetId) => {
    const view = scrollRef.current;
    const card = view?.querySelector(`[data-widget-id="${widgetId}"]`);
    if (!view || !card) return false;
    // La maquette est mise à l'échelle par une transformation CSS : les rectangles
    // écran sont en pixels mis à l'échelle, `scrollTop` en pixels de mise en page.
    const viewBox = view.getBoundingClientRect();
    const scale = viewBox.height / (view.clientHeight || 1);
    const offset = (card.getBoundingClientRect().top - viewBox.top) / (scale || 1);
    view.scrollTop = Math.max(0, view.scrollTop + offset - 24);
    return true;
  }, []);

  // La visée est lue par une référence, jamais prise en dépendance : la fin de la
  // visée ne doit pas relancer le défilement, sans quoi le fil sauterait en bas
  // une seconde et demie après avoir montré la carte.
  const focusRef = React.useRef(null);
  focusRef.current = focusWidgetId;

  React.useEffect(() => {
    if (focusRef.current) return;
    if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  }, [scrollSignature, thinkingIn]);

  React.useEffect(() => {
    if (!focusWidgetId) return undefined;
    scrollToCard(focusWidgetId);
    // La visée ne dure que le temps que le fil se stabilise : passé ce délai, le fil
    // redevient un fil ordinaire qui suit le bas quand un message arrive.
    const timer = window.setTimeout(() => setFocusWidgetId(null), 1600);
    return () => window.clearTimeout(timer);
  }, [focusWidgetId, scrollSignature, scrollToCard]);

  // Reprise après création d'un patient : l'écran a été quitté au milieu d'un
  // enregistrement, on le termine au retour plutôt que de laisser le praticien
  // retrouver la carte et réappuyer sur un bouton qu'il a déjà pressé.
  React.useEffect(() => {
    if (!resumeSave?.widgetId || !resumeSave?.patientId || !runtime) return;
    if (serverBusy || messages.length === 0) return;
    const target = messages.find((message) => message.widgetId === resumeSave.widgetId);
    // La carte peut avoir disparu — fil supprimé, session changée. On ne rejoue rien
    // alors : mieux vaut ne rien enregistrer que de deviner où.
    if (!target) { onResumeHandled?.(); return; }
    let cancelled = false;
    (async () => {
      try {
        const result = await runtime.widgetAction(
          { id: target.widgetId, version: target.widgetVersion, type: target.widget },
          'save',
          { patientId: resumeSave.patientId },
        );
        if (cancelled) return;
        applyWidgetUpdate(result.widget);
        // La vue est revenue en bas du fil au remontage : on vise la carte
        // enregistrée, sinon la preuve du geste reste hors champ.
        setFocusWidgetId(resumeSave.widgetId);
      } catch (error) {
        if (!cancelled) showToast(error?.message || 'Enregistrement impossible', 'error');
      } finally {
        if (!cancelled) onResumeHandled?.();
      }
    })();
    return () => { cancelled = true; };
  }, [resumeSave, runtime, serverBusy, messages, applyWidgetUpdate, onResumeHandled]);

  // Comptes rendus dictés arrivant du dashboard. L'écran étant démonté quand on
  // ne le regarde pas, AgentInbox garde l'envoi en file et le livre au montage.
  React.useEffect(() => {
    if (connected) return;
    if (typeof AgentInbox === 'undefined') return;
    return AgentInbox.subscribe((cr) => {
      const head = `${cr.typeLabel.charAt(0).toUpperCase()}${cr.typeLabel.slice(1)}`;
      const intro = cr.patient
        ? `${head} dicté pour ${cr.patient} (${cr.duration}) :`
        : `${head} dicté sans patient associé (${cr.duration}) :`;
      const userMsg = { role: 'user', text: `${intro}\n\n${cr.text}` };

      const reply = cr.patient
        ? `J'ai analysé le ${cr.typeLabel} de ${cr.patient}.\n\nJe peux enchaîner sur : la génération du courrier au médecin traitant, le codage CCAM de l'intervention, la planification du prochain contrôle, ou l'ajout au dossier patient.\n\nQue voulez-vous que je fasse ?`
        : `J'ai bien reçu cette dictée, mais elle n'est rattachée à aucun dossier.\n\nVous pouvez : l'associer à un patient existant, créer un nouveau dossier à partir de son contenu, ou la laisser ici et la traiter plus tard.\n\nJe la garde de côté en attendant.`;

      // Sans patient, la dictée ouvre sa propre conversation plutôt que de
      // s'ajouter au fil d'un autre patient.
      const targetId = cr.newConversation ? 'cr-' + Date.now() : 'current';

      if (cr.newConversation) {
        setConversations((convs) => [{
          id: targetId,
          title: 'Dictée sans patient',
          date: "Aujourd'hui",
          preview: cr.text.slice(0, 60),
          messages: [userMsg],
        }, ...convs]);
      } else {
        setConversations((convs) => convs.map((c) => c.id === targetId
          ? { ...c, messages: [...c.messages, userMsg] } : c));
      }
      setActiveConvId(targetId);

      setThinkingIn(targetId);
      setTimeout(() => {
        setConversations((convs) => convs.map((c) => c.id === targetId
          ? { ...c, messages: [...c.messages, { role: 'assistant', text: reply }] } : c));
        setThinkingIn((cur) => (cur === targetId ? null : cur));
      }, 1400);
    });
  }, [connected]);

  // Listening timer
  React.useEffect(() => {
    if (!listening) { setListenElapsed(0); return; }
    const start = Date.now();
    const id = setInterval(() => setListenElapsed(Math.floor((Date.now() - start) / 1000)), 250);
    return () => clearInterval(id);
  }, [listening]);

  React.useEffect(() => () => {
    const recorder = voiceRecorderRef.current;
    voiceRecorderRef.current = null;
    if (!recorder) return;
    recorder.processor.onaudioprocess = null;
    recorder.source.disconnect();
    recorder.processor.disconnect();
    recorder.silentOutput.disconnect();
    recorder.stream.getTracks().forEach((track) => track.stop());
    recorder.context.close().catch(() => {});
  }, []);

  const FAKE_DICTATIONS = [
    'Quelles sont les constantes de M. Martin ?',
    'Rédige un courrier pour le Dr. Legrand',
    'Ordonnance de sortie pour Mme Durand',
    'Programme un RDV de contrôle pour Mme Petit',
    'Donne-moi le code CCAM pour un pontage fémoro-poplité',
  ];
  const ensureConversation = async (title = 'Nouvelle conversation') => {
    if (activeConv) return activeConv;
    if (!runtime) return null;
    const conversation = await runtime.createConversation(title);
    setConversations((current) => [conversation, ...current]);
    setActiveConvId(conversation.id);
    return conversation;
  };

  const startListening = async () => {
    if (!connected) {
      setListening(true);
      return;
    }
    if (listening || voicePhase || thinkingIn) return;
    setServerError(null);
    try {
      const conversation = await ensureConversation('Dictée vocale');
      if (!conversation) throw new Error('La conversation est indisponible.');
      if (!navigator.mediaDevices?.getUserMedia) {
        throw new Error('Ce navigateur ne permet pas l’enregistrement vocal.');
      }
      const AudioContextClass = window.AudioContext || window.webkitAudioContext;
      if (!AudioContextClass) throw new Error('L’enregistrement vocal n’est pas disponible.');
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const context = new AudioContextClass();
      const source = context.createMediaStreamSource(stream);
      const processor = context.createScriptProcessor(4096, 1, 1);
      const silentOutput = context.createGain();
      const chunks = [];
      silentOutput.gain.value = 0;
      processor.onaudioprocess = (event) => {
        const frame = event.inputBuffer.getChannelData(0);
        chunks.push(new Float32Array(frame));
        // Niveau réellement capté, pour que l'onde affichée dise la vérité :
        // un micro muet doit se voir pendant l'enregistrement, pas seulement
        // à la fin par « Aucun son n'a été enregistré ».
        let sum = 0;
        for (let i = 0; i < frame.length; i += 1) sum += frame[i] * frame[i];
        const rms = Math.sqrt(sum / frame.length);
        const next = levelsRef.current.concat(rms).slice(-CH_WAVE_BARS);
        levelsRef.current = next;
        setLevels(next);
      };
      source.connect(processor);
      processor.connect(silentOutput);
      silentOutput.connect(context.destination);
      voiceRecorderRef.current = {
        conversation,
        context,
        source,
        processor,
        silentOutput,
        stream,
        chunks,
        sampleRate: context.sampleRate,
        startedAt: performance.now(),
      };
      levelsRef.current = [];
      setLevels([]);
      setVoiceDraft(null);
      setListening(true);
    } catch (error) {
      showToast(error?.message || 'Le micro n’a pas pu démarrer', 'error');
    }
  };

  const stopListening = async () => {
    setListening(false);
    levelsRef.current = [];
    setLevels([]);
    if (!connected) {
      const fake = FAKE_DICTATIONS[Math.floor(Math.random() * FAKE_DICTATIONS.length)];
      setInput(fake);
      return;
    }
    const recorder = voiceRecorderRef.current;
    voiceRecorderRef.current = null;
    if (!recorder) return;
    recorder.processor.onaudioprocess = null;
    recorder.source.disconnect();
    recorder.processor.disconnect();
    recorder.silentOutput.disconnect();
    recorder.stream.getTracks().forEach((track) => track.stop());
    await recorder.context.close().catch(() => {});
    const durationMs = Math.max(1, performance.now() - recorder.startedAt);
    const audio = encodePcmWav(recorder.chunks, recorder.sampleRate);
    if (audio.size <= 44) {
      showToast('Aucun son n’a été enregistré', 'error');
      return;
    }
    setActiveConvId(recorder.conversation.id);
    try {
      setVoicePhase('uploading');
      const transcript = await runtime.transcribeVoice(
        recorder.conversation,
        audio,
        durationMs,
        (phase) => setVoicePhase(phase),
      );
      setInput(transcript.text);
      setVoiceDraft({
        captureId: transcript.captureId,
        transcriptVersion: transcript.transcriptVersion,
        conversationId: recorder.conversation.id,
      });
      // Le bandeau « Transcription prête » de la zone de saisie dit déjà la même
      // chose, à l'endroit où le texte vient d'arriver.
    } catch (error) {
      showToast(error?.message || 'La transcription a échoué', 'error');
    } finally {
      setVoicePhase(null);
    }
  };

  const handleAttachmentFiles = async (fileList, kind) => {
    const files = Array.from(fileList || []);
    if (!files.length) return;
    if (!connected) {
      setInput((current) => `${current}${current ? ' ' : ''}[pièce jointe]`);
      return;
    }
    if (pendingAttachments.length + files.length > 5) {
      showToast('Maximum 5 pièces jointes par message', 'error');
      return;
    }
    // Refuser d'abord le lot entier : sans cela, sélectionner cinq fichiers
    // dont un Word téléverserait les quatre premiers avant d'échouer.
    const rejected = files.filter((file) => !runtime.attachmentMediaType(file));
    if (rejected.length) {
      showToast(
        `${rejected.map((file) => file.name).join(', ')} : format non pris en charge. `
        + `Formats acceptés : ${CH_ACCEPTED_LABEL}.`,
        'error',
      );
      return;
    }
    setServerError(null);
    try {
      for (const file of files) {
        setAttachmentPhase({ stage: 'preparing', filename: file.name });
        const attachment = await runtime.uploadAttachment(file, kind, (progress) => {
          setAttachmentPhase({ stage: progress.stage, filename: file.name });
        });
        // L'aperçu vient du fichier local : inutile de le retélécharger depuis
        // le serveur pour montrer ce que l'on vient tout juste de choisir.
        setPendingAttachments((current) => [...current, {
          ...attachment,
          previewUrl: file.type?.startsWith('image/') ? URL.createObjectURL(file) : null,
        }]);
      }
      // Aucune notification de succès : la pièce s'affiche désormais dans le
      // brouillon, vignette comprise. L'annoncer en plus était redondant.
    } catch (error) {
      showToast(error?.message || 'La pièce jointe n’a pas pu être ajoutée', 'error');
    } finally {
      setAttachmentPhase(null);
    }
  };

  const removePendingAttachment = (attachment) => {
    // L'aperçu local retient le fichier entier tant qu'il n'est pas libéré.
    if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
    setPendingAttachments((current) => current.filter((item) => item.id !== attachment.id));
    runtime?.cancelAttachment(attachment).catch(() => {});
  };

  const openConversation = async (id) => {
    setActiveConvId(id);
    if (!runtime) return;
    setServerBusy(true);
    setServerError(null);
    try {
      const detail = await runtime.getConversation(id);
      setConversations((current) => current.map((conversation) => (
        conversation.id === id ? detail : conversation
      )));
    } catch (error) {
      setServerError(error?.message || 'Cette conversation est indisponible.');
    } finally {
      setServerBusy(false);
    }
  };

  const send = async () => {
    if (!input.trim() && pendingAttachments.length === 0) return;
    if (connected) {
      if (thinkingIn || voicePhase || attachmentPhase) return;
      const q = input.trim();
      const attachmentsForSend = [...pendingAttachments];
      const voiceForSend = voiceDraft;
      // Le composeur se vide entièrement dès l'envoi, comme le champ texte.
      // Attendre la fin de la génération laissait la pièce jointe affichée
      // pendant toute la réponse, en donnant l'impression qu'elle n'était pas
      // partie. En cas d'échec, le brouillon complet est restitué plus bas.
      setInput('');
      setPendingAttachments([]);
      setVoiceDraft(null);
      setServerError(null);
      let target = voiceForSend
        ? conversations.find((conversation) => conversation.id === voiceForSend.conversationId)
        : activeConv;
      if (voiceForSend && target) setActiveConvId(target.id);
      if (
        q.toLocaleLowerCase('fr-FR') === '/demo compactage'
        && attachmentsForSend.length === 0
        && !voiceForSend
      ) {
        if (!target) {
          setInput(q);
          showToast('Créez d’abord une conversation pour lancer l’aperçu', 'error');
          return;
        }
        const previewUserMessage = { role: 'user', text: q, status: 'completed' };
        setConversations((current) => current.map((conversation) => (
          conversation.id === target.id
            ? {
              ...conversation,
              messages: [...conversation.messages, previewUserMessage],
              preview: 'Aperçu du compactage',
            }
            : conversation
        )));
        setThinkingIn(target.id);
        setThinkingStage('compaction');
        window.setTimeout(() => {
          setConversations((current) => current.map((conversation) => (
            conversation.id === target.id
              ? {
                ...conversation,
                messages: [...conversation.messages, {
                  role: 'assistant',
                  status: 'completed',
                  text: 'Aperçu terminé. En situation réelle, cette indication disparaît dès que la réponse reprend.',
                }],
              }
              : conversation
          )));
          setThinkingIn((current) => (current === target.id ? null : current));
          setThinkingStage('generating');
        }, 5000);
        return;
      }
      try {
        if (!target) {
          const title = q || attachmentsForSend[0]?.filename || 'Nouvelle conversation';
          target = await runtime.createConversation(title.slice(0, 80));
          setConversations((current) => [target, ...current]);
          setActiveConvId(target.id);
        }
        const optimistic = {
          role: 'user', text: q, status: 'completed', attachments: attachmentsForSend,
        };
        setConversations((current) => current.map((conversation) => (
          conversation.id === target.id
            ? {
              ...conversation,
              messages: [...conversation.messages, optimistic],
              preview: (q || attachmentsForSend[0]?.filename || '').slice(0, 80),
            }
            : conversation
        )));
        setThinkingIn(target.id);
        setThinkingStage('generating');
        const completed = await runtime.sendMessage(target, q, {
          attachmentIds: attachmentsForSend.map((attachment) => attachment.id),
          voiceTranscription: voiceForSend,
          onProgress: (stage) => {
            if (stage === 'compaction' || stage === 'generating') setThinkingStage(stage);
          },
        });
        setConversations((current) => [
          completed,
          ...current.filter((conversation) => conversation.id !== completed.id),
        ]);
        setActiveConvId(completed.id);
        // Le fil rechargé porte désormais ses propres aperçus : les URL locales
        // du brouillon n'ont plus d'usage et doivent être libérées.
        attachmentsForSend.forEach((attachment) => {
          if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
        });
      } catch (error) {
        // Le brouillon est restitué tel quel : rien ne doit être perdu parce
        // que la génération a échoué.
        if (q) setInput(q);
        if (attachmentsForSend.length) setPendingAttachments(attachmentsForSend);
        if (voiceForSend) setVoiceDraft(voiceForSend);
        const isGenerationFailure = typeof error?.code === 'string'
          && error.code.startsWith('assistant_');
        setServerError(isGenerationFailure
          ? null
          : (error?.message || 'La réponse de l’Assistant est indisponible.'));
        showToast(
          isGenerationFailure
            ? 'La réponse n’a pas été générée'
            : (error?.message || 'La réponse de l’Assistant est indisponible'),
          'error',
        );
        if (target) {
          try {
            const refreshed = await runtime.getConversation(target.id);
            setConversations((current) => current.map((conversation) => (
              conversation.id === target.id ? refreshed : conversation
            )));
          } catch { /* Le message reste récupérable au prochain chargement. */ }
        }
      } finally {
        setThinkingIn((current) => (current === target?.id ? null : current));
        setThinkingStage('generating');
      }
      return;
    }
    const newMsg = { role: 'user', text: input.trim() };
    const q = input.trim();
    setInput('');

    // Plus aucune conversation (tout supprimé) : écrire en ouvre une, plutôt
    // que d'envoyer le message dans le vide.
    if (!activeConv) {
      const id = 'new-' + Date.now();
      setConversations([{
        id, title: q.slice(0, 32), date: "Aujourd'hui", preview: q.slice(0, 60),
        messages: [newMsg],
      }]);
      setActiveConvId(id);
      setThinkingIn(id);
      setTimeout(() => {
        setConversations((convs) => convs.map((c) => c.id === id
          ? { ...c, messages: [...c.messages, { role: 'assistant', text: `Je traite votre demande : "${q}". Cette fonctionnalité sera connectée à l'agent VASCO.` }] } : c));
        setThinkingIn((cur) => (cur === id ? null : cur));
      }, 1200);
      return;
    }

    setConversations(convs => convs.map(c =>
      c.id === activeConvId ? { ...c, messages: [...c.messages, newMsg] } : c
    ));
    const target = activeConvId;
    setThinkingIn(target);
    setTimeout(() => {
      setConversations(convs => convs.map(c =>
        c.id === target ? { ...c, messages: [...c.messages, { role: 'assistant', text: `Je traite votre demande : "${q}". Cette fonctionnalité sera connectée à l'agent VASCO.` }] } : c
      ));
      setThinkingIn((cur) => (cur === target ? null : cur));
    }, 1200);
  };

  const newConversation = async () => {
    if (connected) {
      if (serverBusy || thinkingIn) return;
      setServerBusy(true);
      setServerError(null);
      try {
        const conversation = await runtime.createConversation('Nouvelle conversation');
        setConversations((current) => [conversation, ...current]);
        setActiveConvId(conversation.id);
      } catch (error) {
        setServerError(error?.message || 'La conversation n’a pas pu être créée.');
        showToast(error?.message || 'Création impossible', 'error');
      } finally {
        setServerBusy(false);
      }
      return;
    }
    const id = 'new-' + Date.now();
    const conv = {
      id, title: 'Nouvelle conversation', date: "Aujourd'hui", preview: '',
      messages: [{ role: 'assistant', text: 'Bonjour Docteur, comment puis-je vous aider ?' }],
    };
    setConversations(c => [conv, ...c]);
    setActiveConvId(id);
  };

  const deleteConversation = async (id) => {
    const conversation = conversations.find((item) => item.id === id);
    if (runtime && conversation) {
      setServerError(null);
      try {
        await runtime.deleteConversation(conversation);
      } catch (error) {
        setServerError(error?.message || 'La conversation n’a pas pu être supprimée.');
        showToast(error?.message || 'Suppression impossible', 'error');
        return;
      }
    }
    setConversations((current) => {
      const rest = current.filter((item) => item.id !== id);
      if (activeConvId === id) setActiveConvId(rest.length ? rest[0].id : null);
      return rest;
    });
  };

  const canSend = Boolean(input.trim() || pendingAttachments.length > 0);
  const inputBusy = Boolean(voicePhase || attachmentPhase || thinkingIn);

  return (
    <div style={{
      width: '100%', height: '100%', background: CH_BG, color: CH_INK,
      display: 'flex', flexDirection: 'column', position: 'relative',
      fontFamily: "'Inter Tight', -apple-system, system-ui, sans-serif",
    }}>
      {/* Header */}
      <div style={{
        paddingTop: 62, padding: '62px 16px 10px',
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
      }}>
        <button onClick={() => setShowHistory(true)} className="press" style={{
          width: 34, height: 34, borderRadius: 12,
          background: CH_SURF, border: `1px solid ${CH_HAIR}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          cursor: 'pointer', padding: 0,
        }}>
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={CH_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
            <line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
          </svg>
        </button>
        <div style={{ textAlign: 'center' }}>
          <div style={{ fontSize: 16, fontWeight: 700, color: CH_INK, letterSpacing: '-0.3px' }}>VASCO</div>
          {connected && activeConv ? (
            <button onClick={pickConversationPatient} className="press" style={{
              display: 'inline-flex', alignItems: 'center', gap: 5, marginTop: 2,
              padding: '2px 9px', borderRadius: 999, cursor: 'pointer',
              background: activeConv.patient ? CH_MED_TINT : 'rgba(10,10,10,0.04)',
              border: 'none', fontFamily: 'inherit',
              fontSize: 10.5, fontWeight: 650,
              color: activeConv.patient ? CH_MED : CH_SUB,
            }}>
              <svg width="10" height="10" viewBox="0 0 24 24" fill="none"
                stroke={activeConv.patient ? CH_MED : CH_SUB} strokeWidth="2"
                strokeLinecap="round" strokeLinejoin="round">
                <circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0 1 16 0v1"/>
              </svg>
              {activeConv.patient ? activeConv.patient.display_name : 'Associer un patient'}
            </button>
          ) : (
            <div className="mono" style={{ fontSize: 9, fontWeight: 500, color: CH_SUB, letterSpacing: '0.3px', marginTop: 1 }}>Assistant IA</div>
          )}
        </div>
        <button onClick={newConversation} className="press" style={{
          width: 34, height: 34, borderRadius: 12,
          background: CH_SURF, border: `1px solid ${CH_HAIR}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          cursor: 'pointer', padding: 0,
        }}>
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={CH_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 5v14M5 12h14"/>
          </svg>
        </button>
      </div>

      {/* Messages */}
      <div ref={scrollRef} className="vasco-scroll" style={{
        flex: 1, overflowY: 'auto', padding: '12px 0 8px',
      }}>
        {serverBusy && !activeConv && (
          <div style={{ textAlign: 'center', padding: '76px 24px 0', color: CH_SUB }}>
            <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 14 }}>
              <VascoSpinner size={24} color={accent} />
            </div>
            <div style={{ fontSize: 13, fontWeight: 600 }}>Chargement des conversations…</div>
          </div>
        )}

        {serverError && !activeConv && !serverBusy && (
          <div style={{ textAlign: 'center', padding: '62px 30px 0' }}>
            <div style={{ fontSize: 15, fontWeight: 700, color: CH_INK }}>Assistant indisponible</div>
            <div style={{ fontSize: 13, fontWeight: 500, color: CH_SUB, lineHeight: 1.45, marginTop: 8 }}>
              {serverError}
            </div>
            <button onClick={loadServerConversations} className="press" style={{
              marginTop: 16, padding: '9px 16px', borderRadius: 999,
              background: CH_INK, color: '#fff', border: 'none',
              fontFamily: 'inherit', fontSize: 12.5, fontWeight: 700, cursor: 'pointer',
            }}>Réessayer</button>
          </div>
        )}

        {!activeConv && !serverBusy && !serverError && (
          <div style={{ textAlign: 'center', padding: '64px 34px 0' }}>
            <div style={{
              width: 60, height: 60, borderRadius: '50%', margin: '0 auto 16px',
              background: CH_MED_TINT,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
            }}>
              <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke={CH_MED}
                strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
                <path d="M8 10h.01M12 10h.01M16 10h.01" />
              </svg>
            </div>
            <div style={{ fontSize: 16, fontWeight: 700, color: CH_INK, letterSpacing: '-0.2px' }}>
              Aucune conversation
            </div>
            <div style={{ fontSize: 13, fontWeight: 500, color: CH_SUB, marginTop: 8, lineHeight: 1.5 }}>
              Posez une question, dictez un compte rendu, ou repartez d'un fil vierge.
            </div>
            <button onClick={newConversation} className="press" style={{
              marginTop: 18, height: VT.hMd || 40, padding: '0 20px', borderRadius: 999,
              background: CH_INK, border: 'none',
              fontFamily: 'inherit', fontSize: 13, fontWeight: 700, color: '#fff',
              cursor: 'pointer',
            }}>Nouvelle conversation</button>
          </div>
        )}

        {messages.map((msg, i) => (
          <ChatBubble key={i} msg={msg} accent={accent}
            runtime={connected ? runtime : null}
            onWidgetUpdated={applyWidgetUpdate}
            onEditWidget={setEditingWidget}
            onRetry={retryMessage}
            retrying={retryingId === msg.id}
            onPreviewImage={setImagePreview}
            onNeedPatient={connected && !activeConv?.patient ? requestReportPatient : null}
            onOpenPatient={connected ? onOpenPatient : null} />
        ))}

        {/* L'assistant rédige — même géométrie qu'une bulle assistant, pour
            que la réponse la remplace sans décaler le fil. */}
        {thinkingIn !== null && activeConvId !== null && thinkingIn === activeConvId && (
          <div style={{
            display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
            marginBottom: 16, padding: '0 16px',
          }}>
            <div role="status" aria-live="polite" style={{
              padding: thinkingStage === 'compaction' ? '12px 16px' : '13px 18px',
              borderRadius: '18px 18px 18px 4px',
              background: thinkingStage === 'compaction' ? CH_MED_TINT : 'rgba(10,10,10,0.04)',
            }}>
              {thinkingStage === 'compaction' ? (
                <div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
                  <svg aria-hidden="true" width="17" height="17" viewBox="0 0 24 24"
                    fill="none" style={{ flexShrink: 0 }}>
                    <circle cx="12" cy="12" r="9" stroke={accent} strokeWidth="2.4" opacity="0.2" />
                    <path d="M12 3a9 9 0 0 1 9 9" stroke={accent} strokeWidth="2.4"
                      strokeLinecap="round">
                      <animateTransform attributeName="transform" type="rotate" from="0 12 12"
                        to="360 12 12" dur="0.75s" repeatCount="indefinite" />
                    </path>
                  </svg>
                  <span style={{
                    color: CH_MED, fontSize: 12.5, fontWeight: 700, lineHeight: 1.35,
                  }}>
                    Conversation longue - optimisation du contexte…
                  </span>
                </div>
              ) : (
                <TypingDots color={accent} />
              )}
            </div>
          </div>
        )}
      </div>

      {serverError && activeConv && (
        <div role="status" style={{
          margin: '0 16px 6px', padding: '8px 10px', borderRadius: 12,
          background: 'rgba(166,60,47,0.08)', color: '#8f3328',
          fontSize: 11.5, fontWeight: 600, lineHeight: 1.35,
        }}>{serverError}</div>
      )}

      {(voicePhase || attachmentPhase || voiceDraft || pendingAttachments.length > 0) && (
        <div style={{ padding: '0 16px 6px', display: 'flex', flexDirection: 'column', gap: 6 }}>
          {voicePhase && (
            <div role="status" style={{
              display: 'flex', alignItems: 'center', gap: 8,
              padding: '8px 10px', borderRadius: 12,
              background: CH_MED_TINT, color: CH_MED,
              fontSize: 11.5, fontWeight: 700,
            }}>
              <VascoSpinner size={14} color={CH_MED} />
              {voicePhase === 'uploading' ? 'Envoi de la dictée…' : 'Transcription de la dictée…'}
            </div>
          )}
          {attachmentPhase && (
            <div role="status" style={{
              display: 'flex', alignItems: 'center', gap: 8,
              padding: '8px 10px', borderRadius: 12,
              background: CH_MED_TINT, color: CH_MED,
              fontSize: 11.5, fontWeight: 700,
            }}>
              <VascoSpinner size={14} color={CH_MED} />
              {attachmentPhase.stage === 'checking' ? 'Vérification' : 'Envoi'} - {attachmentPhase.filename}
            </div>
          )}
          {voiceDraft && !voicePhase && (
            <div style={{
              display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
              padding: '8px 10px', borderRadius: 12,
              background: CH_MED_TINT, color: CH_MED,
              fontSize: 11.5, fontWeight: 700,
            }}>
              <span>Transcription prête - relisez avant envoi</span>
              <button type="button" onClick={() => setVoiceDraft(null)} aria-label="Retirer la provenance vocale"
                style={{ border: 'none', background: 'transparent', color: CH_MED, cursor: 'pointer', fontSize: 16 }}>×</button>
            </div>
          )}
          {pendingAttachments.length > 0 && (
            // Rangée défilante. `minWidth: 0` est indispensable — sans lui, un
            // enfant de conteneur flex prend la largeur de son contenu et
            // déborde au lieu de défiler. La barre est rendue visible et un
            // geste vertical de molette est converti en défilement horizontal,
            // faute de quoi le défilement existe sans être utilisable.
            <div ref={attachRowRef} className="vasco-attach-row" style={{
              display: 'flex', flexWrap: 'nowrap', gap: 6,
              minWidth: 0, width: '100%', overflowX: 'auto',
              paddingBottom: 4, scrollSnapType: 'x proximity',
              WebkitOverflowScrolling: 'touch', overscrollBehaviorX: 'contain',
            }}>
              <style>{`
                .vasco-attach-row::-webkit-scrollbar { height: 5px; }
                .vasco-attach-row::-webkit-scrollbar-thumb {
                  background: rgba(10,10,10,0.22); border-radius: 3px;
                }
                .vasco-attach-row::-webkit-scrollbar-track { background: transparent; }
                .vasco-attach-row { scrollbar-width: thin; }
              `}</style>
              {pendingAttachments.map((attachment) => (
                <div key={attachment.id} style={{
                  display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0,
                  width: 176, minWidth: 176, scrollSnapAlign: 'start',
                  padding: '6px 6px 6px 8px', borderRadius: 12,
                  background: CH_SURF, border: `1px solid ${CH_HAIR2}`,
                }}>
                  {attachment.previewUrl && (
                    <img src={attachment.previewUrl} alt="" style={{
                      width: 34, height: 34, borderRadius: 8, objectFit: 'cover',
                      flexShrink: 0, border: `1px solid ${CH_HAIR2}`,
                    }} />
                  )}
                  <div style={{ minWidth: 0, flex: 1 }}>
                    <div style={{
                      overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                      fontSize: 11.5, fontWeight: 700, color: CH_INK,
                    }}>{attachment.filename}</div>
                    {/* La mention d'inspection est portée une seule fois sous
                        la rangée : la répéter par carte ne laissait plus de
                        place au nom du fichier. */}
                    <div style={{ fontSize: 9.5, fontWeight: 600, color: CH_SUB, marginTop: 1 }}>
                      Prêt · {readableFileSize(attachment.sizeBytes)}
                    </div>
                  </div>
                  <button type="button" onClick={() => removePendingAttachment(attachment)}
                    aria-label={`Retirer ${attachment.filename}`} style={{
                      border: 'none', background: 'transparent', color: CH_SUB,
                      cursor: 'pointer', fontSize: 16, padding: 2,
                    }}>×</button>
                </div>
              ))}
            </div>
          )}
          {pendingAttachments.some((attachment) => attachment.scanMode === 'simulated') && (
            <div style={{ fontSize: 10, fontWeight: 500, color: CH_SUB }}>
              Inspection simulée locale — ce n'est pas un antivirus réel
            </div>
          )}
        </div>
      )}

      {/* Input bar */}
      <div style={{
        padding: '8px 16px 8px',
        background: CH_BG,
      }}>
        <div style={{
          display: 'flex', alignItems: 'flex-end', gap: 8,
          background: CH_SURF, borderRadius: 22,
          border: `1px solid ${CH_HAIR2}`,
          padding: '6px 6px 6px 16px',
          boxShadow: '0 1px 4px rgba(10,10,10,0.04)',
        }}>
          {/* Plus button + popover */}
          <div style={{ position: 'relative', flexShrink: 0, marginBottom: 2 }}>
            <button onClick={() => setShowAttach(!showAttach)}
              disabled={inputBusy || Boolean(attachmentsReason)}
              aria-label={attachmentsReason
                ? `Pièces jointes indisponibles — ${attachmentsReason}`
                : 'Ajouter une photo ou un document'}
              title={attachmentsReason || undefined}
              className="press" style={{
              width: 28, height: 28, borderRadius: '50%',
              background: showAttach ? CH_MED : 'rgba(10,10,10,0.06)', border: 'none',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              cursor: inputBusy ? 'default' : 'pointer', transition: 'background .15s ease',
              opacity: inputBusy ? 0.45 : 1,
            }}>
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={showAttach ? '#fff' : CH_SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                <path d="M12 5v14M5 12h14"/>
              </svg>
            </button>
            {showAttach && (
              <>
              {/* Ferme au clic à côté : sans ça le menu restait ouvert et
                  se superposait à la saisie. */}
              <div onClick={() => setShowAttach(false)} style={{
                position: 'fixed', inset: 0, zIndex: 40,
              }} />
              <div ref={attachMenuRef} style={{
                position: 'absolute', bottom: 36, left: 0,
                transform: attachShift ? `translateX(${attachShift}px)` : 'none',
                background: CH_SURF, borderRadius: 16, padding: '6px',
                boxShadow: '0 4px 20px rgba(10,10,10,0.12), 0 1px 4px rgba(10,10,10,0.06)',
                border: `1px solid ${CH_HAIR}`,
                animation: 'attachIn .15s ease',
                minWidth: 180, zIndex: 41,
              }}>
                <style>{`@keyframes attachIn { from { opacity:0; transform: translateY(6px) scale(.96); } to { opacity:1; transform: translateY(0) scale(1); } }`}</style>
                {[
                  { label: 'Fichier', icon: <><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></>, action: () => { attachFileRef.current && attachFileRef.current.click(); setShowAttach(false); } },
                  { label: 'Prendre une photo', icon: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></>, action: () => { attachCameraRef.current && attachCameraRef.current.click(); setShowAttach(false); } },
                  { label: 'Galerie photo', icon: <><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></>, action: () => { attachGalleryRef.current && attachGalleryRef.current.click(); setShowAttach(false); } },
                ].map((opt) => (
                  <button key={opt.label} onClick={opt.action} className="press" style={{
                    width: '100%', display: 'flex', alignItems: 'center', gap: 10,
                    padding: '10px 12px', borderRadius: 12,
                    background: 'transparent', border: 'none',
                    cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
                  }}>
                    <div style={{
                      width: 30, height: 30, borderRadius: 8,
                      background: 'rgba(10,10,10,0.04)',
                      display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
                    }}>
                      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={CH_INK} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">{opt.icon}</svg>
                    </div>
                    <span style={{ fontSize: 13, fontWeight: 600, color: CH_INK }}>{opt.label}</span>
                  </button>
                ))}
                {/* Les formats refusés — Word en tête — doivent se savoir avant
                    la sélection, pas après un message d'erreur. */}
                <div style={{
                  borderTop: `1px solid ${CH_HAIR}`, marginTop: 4, paddingTop: 8,
                  padding: '8px 12px 2px', fontSize: 10.5, fontWeight: 500,
                  color: CH_SUB, lineHeight: 1.4,
                }}>{CH_ACCEPTED_LABEL}</div>
              </div>
              </>
            )}
            {/* Hidden file inputs */}
            <input ref={attachFileRef} type="file"
              accept=".pdf,.docx,.txt,.jpg,.jpeg,.png,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,image/jpeg,image/png"
              multiple style={{ display: 'none' }} onChange={(event) => {
                handleAttachmentFiles(event.target.files, 'file'); event.target.value = '';
              }} />
            <input ref={attachCameraRef} type="file" accept="image/jpeg,image/png" capture="environment"
              style={{ display: 'none' }} onChange={(event) => {
                handleAttachmentFiles(event.target.files, 'photo'); event.target.value = '';
              }} />
            <input ref={attachGalleryRef} type="file" accept="image/jpeg,image/png" multiple
              style={{ display: 'none' }} onChange={(event) => {
                handleAttachmentFiles(event.target.files, 'gallery'); event.target.value = '';
              }} />
          </div>

          {listening ? (
            /* Écoute — onde + chrono. Bleu médical, pas rouge : le rouge est
               réservé au danger, et le micro du dashboard est déjà passé au
               bleu. Un rouge ici faisait croire à une alerte. (07/08) */
            <React.Fragment>
              <div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0' }}>
                {/* Pulsing dot */}
                <div style={{
                  width: 8, height: 8, borderRadius: '50%', background: CH_MED, flexShrink: 0,
                  animation: 'micDot 1.2s ease-in-out infinite',
                }} />
                {/* Onde audio — hauteur des barres = niveau réellement capté */}
                <div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2, height: 24 }}
                  role="img"
                  aria-label={silentInput ? 'Aucun son détecté' : 'Niveau sonore capté'}>
                  {Array.from({ length: CH_WAVE_BARS }).map((_, i) => {
                    // Sans serveur, la maquette n'enregistre rien : l'onde y
                    // reste décorative et assumée comme telle.
                    if (!connected) {
                      return (
                        <div key={i} style={{
                          flex: 1, borderRadius: 2, background: CH_MED,
                          height: `${20 + Math.sin((Date.now() / 200) + i * 0.8) * 60}%`,
                          minHeight: 3, maxHeight: '100%', opacity: 0.6,
                          transition: 'height .1s ease',
                        }} />
                      );
                    }
                    const level = levels[i];
                    // Racine carrée : sans elle, une voix normale reste
                    // écrasée en bas de l'échelle linéaire.
                    const height = level === undefined
                      ? 6
                      : Math.max(6, Math.min(100, Math.sqrt(level * CH_WAVE_GAIN) * 100));
                    return (
                      <div key={i} style={{
                        flex: 1, borderRadius: 2,
                        background: silentInput ? CH_SUB : CH_MED,
                        height: `${height}%`,
                        minHeight: 3, maxHeight: '100%',
                        opacity: level === undefined ? 0.25 : 0.65,
                        transition: 'height .08s linear',
                      }} />
                    );
                  })}
                </div>
                {/* Chrono, ou avertissement si le micro ne capte rien */}
                {silentInput ? (
                  <span style={{
                    fontSize: 11, fontWeight: 650, color: CH_SUB, flexShrink: 0, whiteSpace: 'nowrap',
                  }}>Aucun son détecté</span>
                ) : (
                  <span className="mono" style={{ fontSize: 12, fontWeight: 600, color: CH_MED, flexShrink: 0 }}>
                    {String(Math.floor(listenElapsed / 60)).padStart(2, '0')}:{String(listenElapsed % 60).padStart(2, '0')}
                  </span>
                )}
              </div>
              {/* Stop button */}
              <button onClick={stopListening} aria-label="Arrêter la dictée" className="press" style={{
                width: 34, height: 34, borderRadius: '50%',
                background: CH_MED, border: 'none',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                cursor: 'pointer', flexShrink: 0,
                animation: 'micPulseBtn 2s ease-in-out infinite',
              }}>
                <div style={{ width: 12, height: 12, borderRadius: 3, background: '#fff' }} />
              </button>
            </React.Fragment>
          ) : (
            /* Normal mode — input + mic + send */
            <React.Fragment>
              <input
                value={input}
                onChange={(e) => setInput(e.target.value)}
                onKeyDown={(e) => { if (e.key === 'Enter') send(); }}
                placeholder="Demander à VASCO…"
                style={{
                  flex: 1, minWidth: 0, border: 'none', outline: 'none',
                  background: 'transparent', fontFamily: 'inherit',
                  fontSize: 14, fontWeight: 500, color: CH_INK,
                  padding: '6px 0',
                }}
              />

              {/* Mic button */}
              <button onClick={startListening} disabled={inputBusy || Boolean(voiceReason)}
                aria-label={voiceReason ? `Dictée indisponible — ${voiceReason}` : 'Démarrer la dictée'}
                title={voiceReason || undefined}
                className="press" style={{
                width: 28, height: 28, borderRadius: '50%',
                background: 'transparent', border: 'none',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                cursor: inputBusy || voiceReason ? 'default' : 'pointer', flexShrink: 0, marginBottom: 2,
                opacity: inputBusy || voiceReason ? 0.45 : 1,
              }}>
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={CH_SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                  <rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><path d="M12 18v3"/>
                </svg>
              </button>

              {/* Send button */}
              <button onClick={send} aria-label="Envoyer le message" className="press" style={{
                width: 34, height: 34, borderRadius: '50%',
                background: canSend && !inputBusy ? CH_MED : 'rgba(10,10,10,0.06)',
                border: 'none',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                cursor: canSend && !inputBusy ? 'pointer' : 'default',
                flexShrink: 0, transition: 'background .15s ease',
              }}>
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={canSend && !inputBusy ? '#fff' : CH_SUB} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M12 19V5M5 12l7-7 7 7"/>
                </svg>
              </button>
            </React.Fragment>
          )}

          <style>{`
            @keyframes micDot {
              0%, 100% { opacity: 1; transform: scale(1); }
              50% { opacity: 0.4; transform: scale(0.7); }
            }
            @keyframes micPulseBtn {
              0%, 100% { box-shadow: 0 0 0 0 rgba(61,107,143,0.30); }
              50% { box-shadow: 0 0 0 8px rgba(61,107,143,0); }
            }
          `}</style>
        </div>
      </div>

      {/* Bottom nav */}
      {typeof BottomNav !== 'undefined' && (
        <BottomNav active="IA" onChange={(id) => { if (onTabChange) onTabChange(id); }} accent={accent} />
      )}

      {/* History panel */}
      {showHistory && (
        <HistoryPanel
          conversations={conversations}
          activeId={activeConvId}
          onPick={openConversation}
          onClose={() => setShowHistory(false)}
          onNew={newConversation}
          onDeleteConv={deleteConversation}
          onRename={connected ? renameConversation : null}
        />
      )}

      {editingWidget && (
        <WidgetEditSheet
          msg={editingWidget}
          busy={widgetEditBusy}
          onClose={() => setEditingWidget(null)}
          onSave={saveWidgetEdit}
        />
      )}

      {imagePreview && (
        <ChatImagePreview preview={imagePreview} onClose={() => setImagePreview(null)} />
      )}

      {sheet?.kind === 'rename' && (
        <ChPromptSheet
          title="Renommer la conversation"
          value={sheet.conversation.title || ''}
          confirmLabel="Renommer"
          onCancel={() => setSheet(null)}
          onSubmit={(title) => void applyConversationRename(sheet.conversation, title)}
        />
      )}

      {sheet?.kind === 'patient' && (
        <ChPromptSheet
          title="Associer un patient"
          message={sheet.roster.length === 0
            ? 'Aucun patient n’existe encore dans cet espace : créez-en un depuis Patients pour pouvoir l’associer à ce fil.'
            : 'Le patient associé détermine les données que l’Assistant est autorisé à utiliser dans ce fil.'}
          mode="choice"
          options={[
            { value: null, label: 'Aucun patient', active: !sheet.conversation.patient },
            ...sheet.roster.map((patient) => ({
              value: patient.id,
              label: `${patient.last} ${patient.first}`,
              active: sheet.conversation.patient?.id === patient.id,
            })),
          ]}
          onCancel={() => setSheet(null)}
          onSubmit={(patientId) => void applyConversationPatient(sheet.conversation, patientId)}
        />
      )}

      {sheet?.kind === 'report_patient' && (
        <ChPromptSheet
          title="Enregistrer au dossier de"
          message={sheet.roster.length === 0
            ? 'Aucun patient n’existe encore dans cet espace : créez-en un pour pouvoir y enregistrer ce compte rendu.'
            : 'Le compte rendu entre dans le dossier choisi, et le fil lui reste rattaché.'}
          mode="choice"
          options={[
            // Sans écran de création accessible, l'entrée serait un bouton qui ne fait
            // rien : elle n'apparaît que si l'application sait où l'envoyer.
            ...(onCreatePatient ? [{ value: CH_NEW_PATIENT, label: '＋ Nouveau patient' }] : []),
            ...sheet.roster.map((patient) => ({
              value: patient.id,
              label: `${patient.last} ${patient.first}`,
            })),
          ]}
          onCancel={() => settleReportPatient(null)}
          onSubmit={(value) => {
            // Créer un patient est un formulaire en trois étapes, sur son propre écran :
            // l'enregistrement s'interrompt ici, et se reprend d'un geste au retour — le
            // compte rendu est intact dans le fil, et l'écran de création y ramène.
            if (value === CH_NEW_PATIENT) {
              const pending = sheet.widget;
              settleReportPatient(null);
              onCreatePatient?.(pending);
              return;
            }
            settleReportPatient(value);
          }}
        />
      )}
    </div>
  );
}

window.AIChatScreen = AIChatScreen;
// La dictée clinique capte le micro de la même façon que l'assistant : un seul
// encodeur WAV, pour que les deux envoient exactement le même format.
window.encodePcmWav = encodePcmWav;
