// Patients — list view + new patient wizard.
// Brutalist editorial. Same INK / PAPER / mono / serif tokens.

const VT = window.VT;

// Ce fichier déclarait ses propres P_INK / P_PAPER / P_SUB / P_HAIR / P_HAIR2.
// Babel compile les `const` de haut niveau en `var` : elles atterrissaient sur
// window et écrasaient celles de tokens.jsx, chargé juste avant. La fiche
// patient et le wizard rendaient donc sur l'ancien fond #fafaf7 au lieu du
// crème verrouillé. Tout passe maintenant par VT.

// ─────────────────────────────────────────────────────────────
// Reusable atoms
// ─────────────────────────────────────────────────────────────
function PCaption({ children, color, style = {} }) {
  return (
    <span className="mono" style={{
      fontSize: 10, fontWeight: 600, letterSpacing: '0.6px',
      textTransform: 'uppercase', color: color || VT.sub,
      ...style
    }}>{children}</span>);

}

function Initials({ first, last }) {
  const txt = `${(first || '').slice(0, 1)}${(last || '').slice(0, 1)}`.toUpperCase();
  return (
    <div style={{
      width: 40, height: 40, flexShrink: 0,
      borderRadius: VT.rMd,
      background: VT.surface,
      boxShadow: VT.shadowSm,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontSize: 12, fontWeight: 700, letterSpacing: '0.3px',
      color: VT.ink, fontFamily: "'JetBrains Mono', ui-monospace, monospace"
    }}>{txt}</div>);

}

function StatusChip({ status, accent }) {
  const meta = STATUS_META[status];
  if (!meta) return null;
  // Premium: soft tinted pill, rounded
  let bg = '#f3f4f6',fg = VT.ink,border = 'transparent';
  if (status === 'rea') {bg = `${accent}14`;fg = accent;} else
  if (status === 'hosp') {bg = VT.ink;fg = '#fff';} else
  if (status === 'ambu') {bg = VT.successTintSoft;fg = VT.success;} else
  if (status === 'palliatif') {bg = '#f3f4f6';fg = VT.sub;} else
  if (status === 'decede') {bg = '#f3f4f6';fg = VT.sub;}
  return (
    <span className="mono" style={{
      display: 'inline-flex', alignItems: 'center', gap: 5,
      padding: '3px 9px',
      background: bg, color: fg,
      borderRadius: 999,
      fontSize: 9.5, fontWeight: 600, letterSpacing: '0.4px',
      textTransform: 'uppercase',
      textDecoration: meta.strike ? 'line-through' : 'none'
    }}>{meta.label}</span>);

}

// ─────────────────────────────────────────────────────────────
// Search bar (in-page, not the system search)
// ─────────────────────────────────────────────────────────────
function PatientsSearch({ value, onChange }) {
  return (
    <div style={{
      margin: '0 20px',
      display: 'flex', alignItems: 'center', gap: 10,
      background: VT.surface,
      borderRadius: VT.rMd,
      boxShadow: VT.shadowSm,
      padding: '10px 14px'
    }}>
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={VT.sub} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
        <circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
      </svg>
      <input
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder="Rechercher un patient…"
        style={{
          flex: 1, minWidth: 0,
          background: 'transparent', border: 'none', outline: 'none',
          fontFamily: 'inherit', fontSize: 14, fontWeight: 500, color: VT.ink
        }} />
      
      {value &&
      <button onClick={() => onChange('')} className="press"
      style={{
        background: 'none', border: 'none', cursor: 'pointer',
        padding: 0, color: VT.sub,
        fontFamily: 'inherit', fontSize: 11, fontWeight: 600, letterSpacing: '0.3px'
      }}>EFFACER</button>
      }
    </div>);

}

// ─────────────────────────────────────────────────────────────
// Patient row
// ─────────────────────────────────────────────────────────────
function PatientRow({ p, accent, isLast, onOpen, onActions }) {
  return (
    <div className="press" onClick={onOpen} style={{
      display: 'flex', alignItems: 'center', gap: 12,
      padding: '12px 18px',
      borderBottom: isLast ? 'none' : `1px solid ${VT.hair}`,
      cursor: 'pointer'
    }}>
      <Initials first={p.first} last={p.last} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{
          fontSize: 15, fontWeight: 600, color: VT.ink,
          letterSpacing: '-0.2px', whiteSpace: 'nowrap',
          overflow: 'hidden', textOverflow: 'ellipsis',
          lineHeight: 1.25
        }}>
          {p.last} <span style={{ fontWeight: 500 }}>{p.first}</span>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 5, flexWrap: 'wrap' }}>
          <StatusChip status={p.status} accent={accent} />
          <span className="mono" style={{
            fontSize: 10, fontWeight: 500, color: VT.sub, letterSpacing: '0.2px'
          }}>
            {p.age}{p.sex} · {p.ddn}
          </span>
          {p.nextRdv &&
          <span className="mono" style={{
            fontSize: 10, fontWeight: 600, color: accent, letterSpacing: '0.3px',
            textTransform: 'uppercase'
          }}>· {p.nextRdv.when}</span>
          }
        </div>
      </div>
      <button onClick={(e) => {e.stopPropagation();onActions && onActions(p);}} className="press"
      style={{
        background: 'none', border: 'none', cursor: 'pointer',
        padding: 6, marginRight: -6,
        color: VT.sub, borderRadius: 999
      }}
      aria-label="Actions">
        <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
          <circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" />
        </svg>
      </button>
    </div>);

}

// ─────────────────────────────────────────────────────────────
// Filter chips (status segments at top)
// ─────────────────────────────────────────────────────────────
function FilterChips({ active, counts, onChange }) {
  const items = [
  { id: 'all', label: 'Tous', count: counts.all },
  { id: 'hosp', label: 'Hospi.', count: counts.hosp },
  { id: 'ambu', label: 'Ambu.', count: counts.ambu },
  { id: 'rea', label: 'Réa', count: counts.rea },
  { id: 'pall', label: 'Pall.', count: counts.pall }];

  return (
    <div style={{
      display: 'flex', gap: 6, padding: '14px 20px 4px',
      overflowX: 'auto', whiteSpace: 'nowrap'
    }} className="vasco-scroll">
      {items.map((it) => {
        const on = it.id === active;
        return (
          <button key={it.id} onClick={() => onChange(it.id)} className="press"
          style={{
            background: on ? VT.ink : VT.surface,
            color: on ? '#fff' : VT.ink,
            border: 'none',
            borderRadius: 999,
            boxShadow: on ? 'none' : VT.shadowSm,
            padding: '7px 13px',
            cursor: 'pointer', fontFamily: 'inherit',
            fontSize: 12, fontWeight: 600, letterSpacing: '0.1px',
            display: 'inline-flex', alignItems: 'center', gap: 6,
            flexShrink: 0,
            transition: 'background .15s ease'
          }}>
            {it.label}
            <span className="mono" style={{
              fontSize: 9.5, fontWeight: 500,
              opacity: on ? 0.65 : 0.5
            }}>{it.count}</span>
          </button>);

      })}
    </div>);

}

// ─────────────────────────────────────────────────────────────
// QR code sheet
// ─────────────────────────────────────────────────────────────
// Le code n'encode que l'IPP, exactement ce que « Copier l'IPP » met dans le
// presse-papier : un carré affiché à l'écran se photographie de loin, on n'y
// met donc aucune donnée de santé. L'image vient du serveur sur une route
// authentifiée — l'écran n'en dessine aucune de son côté, et dit son attente
// comme son échec plutôt que de laisser un cadre vide.
function PatientQrSheet({ patient, onClose, runtime = null }) {
  // Trois états seulement, et jamais deux à la fois : ce qui s'affiche dans le
  // cadre est soit l'attente, soit le code, soit la raison de son absence.
  const [state, setState] = React.useState({ status: 'loading', url: null, error: null });
  const patientId = patient ? patient.id : null;

  React.useEffect(() => {
    if (!patientId) return undefined;
    if (!runtime || !runtime.qrCode) {
      setState({
        status: 'error', url: null,
        error: 'QR code indisponible : cet écran n’est pas relié au serveur'
      });
      return undefined;
    }
    let cancelled = false;
    let objectUrl = null;
    setState({ status: 'loading', url: null, error: null });
    runtime.qrCode(patientId).
    then((code) => {
      objectUrl = code.url;
      // La feuille refermée pendant la requête : l'URL d'objet est libérée
      // tout de suite, sinon elle resterait accrochée au document.
      if (cancelled) { URL.revokeObjectURL(objectUrl); objectUrl = null; return; }
      setState({ status: 'ready', url: code.url, error: null });
    }).
    catch((failure) => {
      if (cancelled) return;
      setState({ status: 'error', url: null, error: failure?.message || 'QR code indisponible' });
    });
    return () => {
      cancelled = true;
      if (objectUrl) URL.revokeObjectURL(objectUrl);
    };
  }, [runtime, patientId]);

  if (!patient) return null;

  return (
    <>
      <div onClick={onClose} style={{
        position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)', zIndex: 102,
        animation: 'fadeIn .2s ease'
      }} />
      <div role="dialog" aria-label="QR code du dossier" style={{
        position: 'absolute', left: 8, right: 8, bottom: 12, zIndex: 103,
        background: VT.surface,
        borderRadius: VT.rXl,
        boxShadow: VT.shadowLg,
        paddingTop: 6, paddingBottom: 8,
        animation: 'slideUp .25s cubic-bezier(.2,.7,.2,1)',
        overflow: 'hidden'
      }}>
        {/* grabber */}
        <div style={{
          width: 36, height: 4, borderRadius: 2,
          background: VT.hair2, margin: '6px auto 4px'
        }} />
        <div style={{ padding: '10px 20px 0' }}>
          <div className="mono" style={{
            fontSize: 10, fontWeight: 600, color: VT.sub, letterSpacing: '0.5px', textTransform: 'uppercase'
          }}>QR CODE</div>
          <div style={{
            fontSize: 17, fontWeight: 700, color: VT.ink, letterSpacing: '-0.3px', marginTop: 2
          }}>
            {patient.last} <span style={{ fontWeight: 500 }}>{patient.first}</span>
          </div>
        </div>
        <div style={{
          padding: '14px 20px 2px',
          display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12
        }}>
          <div aria-live="polite" style={{
            width: 208, height: 208,
            borderRadius: VT.rLg,
            background: '#fff',
            border: `1px solid ${VT.hair2}`,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            overflow: 'hidden'
          }}>
            {state.status === 'loading' &&
            <span className="mono" style={{
              fontSize: 10, fontWeight: 600, color: VT.sub, letterSpacing: '0.5px'
            }}>CHARGEMENT…</span>
            }
            {state.status === 'error' &&
            <div style={{
              padding: '0 20px', textAlign: 'center',
              fontSize: 12.5, fontWeight: 500, color: VT.danger, lineHeight: 1.45
            }}>{state.error}</div>
            }
            {state.status === 'ready' &&
            <img src={state.url} alt={`QR code ouvrant le dossier ${patient.ipp}`}
            style={{ display: 'block', width: 192, height: 192 }} />
            }
          </div>
          <div style={{ textAlign: 'center' }}>
            <div className="mono" style={{
              fontSize: 15, fontWeight: 600, color: VT.ink, letterSpacing: '0.6px'
            }}>{patient.ipp}</div>
            <div style={{
              fontSize: 11.5, fontWeight: 500, color: VT.sub, marginTop: 5, lineHeight: 1.4
            }}>Scanné, le code ouvre l’application sur ce dossier ; la connexion
              reste exigée. Il ne porte que l’IPP, aucune donnée de santé.</div>
          </div>
        </div>
        <div style={{ height: 1, background: VT.hair, margin: '14px 16px 0' }} />
        <button onClick={onClose} className="press" style={{
          display: 'block', width: '100%', padding: '13px 20px',
          background: 'none', border: 'none', cursor: 'pointer',
          fontFamily: 'inherit', fontSize: 14.5, fontWeight: 600, color: VT.ink,
          textAlign: 'center'
        }}>Fermer</button>
      </div>
      <style>{`
        @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
        @keyframes slideUp { from { transform: translateY(110%); } to { transform: translateY(0); } }
      `}</style>
    </>);

}

// ─────────────────────────────────────────────────────────────
// Impression du dossier
// ─────────────────────────────────────────────────────────────
// L'application vit dans un cadre de téléphone posé au milieu de la page :
// `window.print()` sur le document principal imprimerait ce cadre, son ombre et
// le fond de la page autour. La feuille est donc composée dans un document à
// part, porté par un iframe caché, et c'est lui qui imprime.
//
// Rien n'y est inventé : chaque ligne vient du dossier servi par l'API. Un
// champ vide n'est pas rempli d'un tiret, il ne s'imprime pas — sur du papier
// qui suivra le patient, une case remplie pour faire joli se lit comme un fait.
const PATIENT_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; }
  .name .given { font-weight: 400; }
  .head-meta {
    display: flex; justify-content: space-between; gap: 12pt;
    font-size: 9pt; color: #555; margin-top: 6pt;
  }
  section { margin-bottom: 14pt; page-break-inside: avoid; }
  h2 {
    font-size: 8.5pt; letter-spacing: 1pt; text-transform: uppercase;
    color: #555; font-weight: 700; margin: 0 0 5pt;
    border-bottom: 0.5pt solid #bbb; padding-bottom: 3pt;
  }
  dl { margin: 0; display: grid; grid-template-columns: 42mm 1fr; column-gap: 6pt; row-gap: 3pt; }
  dt { color: #555; }
  dd { margin: 0; }
  ul { margin: 0; padding-left: 14pt; }
  p { margin: 0; }
  footer { margin-top: 18pt; border-top: 0.5pt solid #bbb; padding-top: 5pt; font-size: 8pt; color: #666; }
`;

function patientPrintEscape(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

// Le tiret cadratin est la marque d'un champ absent dans les écrans ; sur la
// feuille il n'a rien à dire, la ligne disparaît avec lui.
function patientPrintValue(value) {
  const text = typeof value === 'string' ? value.trim() : value;
  return text || text === 0 ? (text === '—' ? null : String(text)) : null;
}

function patientPrintList(values) {
  return (Array.isArray(values) ? values : []).map(patientPrintValue).filter(Boolean);
}

function patientPrintSections(p) {
  const rows = (pairs) => pairs
    .map(([label, value]) => [label, patientPrintValue(value)])
    .filter(([, value]) => value !== null);
  const status = ((window.STATUS_META || {})[p.status] || {}).label;
  const nextRdv = p.nextRdv
    ? [patientPrintValue(p.nextRdv.when), patientPrintValue(p.nextRdv.what)].filter(Boolean).join(' · ')
    : null;

  return [
  { title: 'Identité', rows: rows([
    ['Né(e) le', p.ddn],
    ['Âge', p.age || p.age === 0 ? `${p.age} ans` : null],
    ['Sexe', p.sex],
    ['Statut clinique', status],
    ['Lit', p.bed]]) },

  { title: 'Coordonnées', rows: rows([
    ['Téléphone', p.phone],
    ['Courriel', p.email],
    ['Adresse', p.address],
    ['N° de sécurité sociale', p.secu],
    ['Médecin traitant', p.mt]]) },

  { title: 'Résumé clinique', text: patientPrintValue(p.summary) },
  { title: 'Alertes épinglées', items: patientPrintList(p.pinned) },
  { title: 'Clinique', rows: rows([['Groupe sanguin', p.blood]]) },
  { title: 'Allergies', items: patientPrintList(p.allergies) },
  { title: 'Traitements en cours', items: patientPrintList(p.treatments) },
  { title: 'Antécédents personnels', items: patientPrintList(p.atcdPerso) },
  { title: 'Antécédents familiaux', items: patientPrintList(p.atcdFam) },
  { title: 'Prochain rendez-vous', text: nextRdv || null },
  { title: 'Notes', text: patientPrintValue(p.generalNotes) }].
  filter((section) => section.text || (section.rows || section.items || []).length > 0);
}

function patientPrintHtml(p, printedAt) {
  const esc = patientPrintEscape;
  const identity = `${esc(p.last || '')} ${esc(p.first || '')}`.trim();
  const body = patientPrintSections(p).map((section) => {
    const title = `<h2>${esc(section.title)}</h2>`;
    if (section.rows) {
      const lines = section.rows
        .map(([label, value]) => `<dt>${esc(label)}</dt><dd>${esc(value)}</dd>`).join('');
      return `<section>${title}<dl>${lines}</dl></section>`;
    }
    if (section.items) {
      const lines = section.items.map((item) => `<li>${esc(item)}</li>`).join('');
      return `<section>${title}<ul>${lines}</ul></section>`;
    }
    return `<section>${title}<p>${esc(section.text)}</p></section>`;
  }).join('');

  return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>Dossier ${identity} — IPP ${esc(p.ipp || '')}</title>
<style>${PATIENT_PRINT_STYLES}</style>
</head>
<body>
<div class="head">
  <div class="kicker">Dossier patient</div>
  <div class="name">${esc(p.last || '')} <span class="given">${esc(p.first || '')}</span></div>
  <div class="head-meta">
    <span>IPP ${esc(p.ipp || '—')}</span>
    <span>Imprimé le ${esc(printedAt)}</span>
  </div>
</div>
${body}
<footer>Document imprimé depuis VASCO à partir du dossier ${esc(p.ipp || '')}. Les champs non renseignés n'y figurent pas.</footer>
</body>
</html>`;
}

// Le navigateur peut imprimer en arrière-plan : retirer l'iframe dès le retour
// de `print()` annulerait le travail en cours. On la retire donc sur
// `afterprint`, avec une garde en dernier recours pour ne rien laisser traîner
// dans la page si l'événement ne vient jamais.
const PATIENT_PRINT_CLEANUP_MS = 60000;

function patientPrintFrame(html) {
  return new Promise((resolve, reject) => {
    const frame = document.createElement('iframe');
    frame.setAttribute('aria-hidden', 'true');
    frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:210mm;height:297mm;border:0;';
    document.body.appendChild(frame);

    const view = frame.contentWindow;
    const doc = frame.contentDocument || (view && view.document);
    if (!view || !doc) {
      frame.remove();
      reject(new Error('Impression impossible — document refusé par le navigateur'));
      return;
    }
    doc.open();
    doc.write(html);
    doc.close();

    let removed = false;
    const remove = () => { if (!removed) { removed = true; frame.remove(); } };
    view.addEventListener('afterprint', remove, { once: true });
    window.setTimeout(remove, PATIENT_PRINT_CLEANUP_MS);

    // `print()` appelé dans la foulée de `write()` sort des pages blanches sur
    // WebKit : le document n'a pas encore été mis en page. Un tour de boucle
    // suffit à le poser.
    window.setTimeout(() => {
      try {
        view.focus();
        view.print();
        resolve();
      } catch (error) {
        remove();
        reject(new Error('Impression impossible — refusée par le navigateur'));
      }
    }, 0);
  });
}

// Le dossier ouvert dans l'écran est déjà complet. Depuis le répertoire, la
// ligne du menu ne porte qu'un résumé : on relit alors le dossier avant
// d'imprimer, plutôt que d'imprimer une feuille amputée de ses coordonnées et
// de ses antécédents.
function printPatientRecord(patient, runtime = null) {
  const api = window.VascoApi && window.VascoApi.patient;
  const load = (runtime && runtime.load) || (api && api.load) || null;
  const loaded = Number.isInteger(patient.version) || !load
    ? Promise.resolve(patient)
    : load(patient.id);
  return loaded.then((record) => {
    const full = record || patient;
    if (full.capabilities && full.capabilities.print === false) {
      throw new Error('Impression indisponible pour ce dossier');
    }
    const printedAt = new Intl.DateTimeFormat('fr-FR', {
      dateStyle: 'long', timeStyle: 'short'
    }).format(new Date());
    return patientPrintFrame(patientPrintHtml(full, printedAt));
  });
}

window.printPatientRecord = printPatientRecord;
// La mécanique de la feuille — document à part, nettoyage sur `afterprint`, tour de
// boucle avant `print()` — vaut pour tout ce qui s'imprime depuis l'application. La
// fiche ordonnance de l'Assistant la reprend telle quelle : deux copies de ces
// précautions finiraient par diverger, et c'est la seconde qui sortirait blanche.
window.vascoPrintFrame = patientPrintFrame;
window.vascoPrintEscape = patientPrintEscape;

// ─────────────────────────────────────────────────────────────
// Bottom action sheet
// ─────────────────────────────────────────────────────────────
// Cette feuille est le menu « ··· » du dossier patient. Elle ne porte que des
// gestes qui aboutissent : chaque entrée suit la capacité rendue par le
// serveur, et la seule commande dont le serveur refuse encore le geste (le
// partage) reste absente plutôt que promise « bientôt ». « Supprimer » archive
// le dossier (`archive`) : il quitte les listes, la recherche et le détail
// sans rien effacer en base.
function ActionSheet({ patient, accent, onClose, onOpen, runtime = null,
  onNewAppointment, onNewNote, onEdit, onDelete }) {
  // Déclarés avant le retour anticipé : un crochet appelé sous condition change
  // de rang d'un rendu à l'autre, et React s'arrête.
  const [doneAction, markDone] = useActionDone();
  const [qrOpen, setQrOpen] = React.useState(false);
  const [confirmDelete, setConfirmDelete] = React.useState(false);
  const [archiving, setArchiving] = React.useState(false);

  // La feuille reste montée avec patient=null quand elle est fermée : on remet
  // la confirmation à zéro à chaque ouverture, sinon elle réapparaîtrait sur le
  // patient suivant.
  React.useEffect(() => { setConfirmDelete(false); setArchiving(false); }, [patient]);

  if (!patient) return null;
  const Icon = ({ d }) =>
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={VT.ink} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">{d}</svg>;

  const capabilities = patient.capabilities || {};

  // Les capacités d'un dossier sont de simples booléens : contrairement à
  // l'agenda, le serveur ne joint pas de code de motif. On ne dit donc de la
  // cause que ce dont on est sûr — le dossier d'un patient décédé refuse le
  // rendez-vous et l'appel, un dossier sans numéro refuse l'appel — et l'on
  // retombe sur une formule générale plutôt que d'inventer un motif.
  const deceased = patient.status === 'decede';
  const GENERIC_REFUSAL = 'Indisponible pour ce dossier';
  const callRefusal = deceased ? 'Patient décédé'
    : !patient.phone ? 'Aucun numéro renseigné' : GENERIC_REFUSAL;

  // Une copie n'a aucun effet visible ailleurs : c'est donc la ligne du menu qui
  // porte sa confirmation, le temps d'être lue. La feuille ne se referme plus
  // aussitôt, sinon la confirmation partirait avec elle.
  const copyIPP = async () => {
    try {
      await copyToClipboard(patient.ipp);
      markDone('copy');
    } catch (e) {
      showToast('Copie impossible — presse-papier refusé', 'error');
      onClose();
    }
  };

  // L'impression est un geste local : le serveur n'a rien à en connaître, il
  // dit seulement si ce dossier peut être imprimé. La feuille est composée au
  // clic, depuis le dossier relu si la ligne du répertoire n'en portait qu'un
  // résumé, et un échec se dit — jamais de papier silencieusement vide.
  const print = () => {
    if (capabilities.print === false) { showToast('Impression indisponible pour ce dossier', 'error'); onClose(); return; }
    onClose();
    printPatientRecord(patient, runtime)
      .catch((failure) => showToast(failure?.message || 'Impression impossible', 'error'));
  };

  // Un appel est tracé côté serveur avant que le lien ne s'ouvre, comme sur le
  // bouton du dossier : la même action ne peut pas être suivie ici et pas là.
  const call = () => {
    if (capabilities.call === false) { showToast('Appel indisponible pour ce dossier', 'error'); onClose(); return; }
    onClose();
    if (!runtime) { requestCall(`${patient.last} ${patient.first}`, patient.phone); return; }
    runtime.authorizeAction(patient.id, 'call')
      .then(() => requestCall(`${patient.last} ${patient.first}`, patient.phone))
      .catch((failure) => showToast(failure?.message || 'Appel impossible', 'error'));
  };

  // La suppression est un archivage côté serveur : le dossier quitte la vue,
  // rien ne s'efface. Le refus, lui, est dit tel que le serveur l'a formulé —
  // un conflit de version veut dire que le dossier a changé depuis son
  // chargement, et qu'il faut le relire avant de le retirer.
  const archive = () => {
    if (archiving) return;
    if (!runtime?.archive) { showToast('Suppression indisponible', 'error'); onClose(); return; }
    setArchiving(true);
    runtime.archive(patient.id, patient.version).
    then(() => {
      showToast(`${patient.last} ${patient.first} supprimé`, 'success');
      onDelete && onDelete(patient);
      onClose();
    }).
    catch((failure) => {
      showToast(failure?.message || 'Suppression impossible', 'error');
      setArchiving(false);
    });
  };

  const items = [
  onOpen && { id: 'view', label: 'Voir le dossier', icon: <Icon d={<><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" /><circle cx="12" cy="12" r="3" /></>} />, onClick: () => {onOpen(patient);onClose();} },
  onEdit && { id: 'edit', label: 'Modifier', disabled: capabilities.update === false,
    reason: capabilities.update === false ? GENERIC_REFUSAL : null, icon: <Icon d={<><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4z" /></>} />, onClick: () => {
    if (capabilities.update === false) { showToast('Modification indisponible pour ce dossier', 'error'); onClose(); return; }
    onClose(); onEdit(patient);
  } },
  onNewAppointment && { id: 'rdv', label: 'Nouveau rendez-vous', disabled: capabilities.create_appointment === false,
    reason: capabilities.create_appointment === false ? (deceased ? 'Patient décédé' : GENERIC_REFUSAL) : null, icon: <Icon d={<><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></>} />, onClick: () => {
    if (capabilities.create_appointment === false) { showToast('Rendez-vous indisponible pour ce dossier', 'error'); onClose(); return; }
    onClose(); onNewAppointment(patient);
  } },
  onNewNote && { id: 'note', label: 'Nouvelle note', disabled: capabilities.create_note === false,
    reason: capabilities.create_note === false ? GENERIC_REFUSAL : null, icon: <Icon d={<><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4z" /></>} />, onClick: () => {
    if (capabilities.create_note === false) { showToast('Note indisponible pour ce dossier', 'error'); onClose(); return; }
    onClose(); onNewNote(patient);
  } },
  { id: 'copy', label: 'Copier l\'IPP', icon: <Icon d={<><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>} />, onClick: copyIPP },
  // Le QR ne s'ouvre que si le serveur le sert : sans capacité, la ligne reste
  // là mais éteinte, et dit pourquoi au lieu de faire monter une feuille vide.
  { id: 'qr', label: 'QR code', disabled: capabilities.qr !== true,
    reason: capabilities.qr !== true ? GENERIC_REFUSAL : null, icon: <Icon d={<><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><path d="M14 14h3v3h-3zM20 14h1v1M14 20h1M17 20h4M20 17h1" /></>} />, onClick: () => {
    if (capabilities.qr !== true) { showToast('QR code indisponible pour ce dossier', 'error'); return; }
    setQrOpen(true);
  } },
  { id: 'print', label: 'Imprimer', disabled: capabilities.print === false,
    reason: capabilities.print === false ? GENERIC_REFUSAL : null, icon: <Icon d={<><path d="M6 9V3h12v6" /><rect x="6" y="13" width="12" height="8" /><path d="M6 17H3v-5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5h-3" /></>} />, onClick: print },
  { id: 'call', label: patient.phone ? `Appeler ${patient.phone}` : 'Appeler', disabled: capabilities.call === false,
    reason: capabilities.call === false ? callRefusal : null, icon: <Icon d={<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.1-8.7A2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7 12.8 12.8 0 0 0 .7 2.8 2 2 0 0 1-.5 2.1L8 9.9a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.5 12.8 12.8 0 0 0 2.8.7A2 2 0 0 1 22 16.9z" />} />, onClick: call },
  // La ligne n'apparaît que si le serveur autorise l'archivage pour ce dossier
  // et que l'écran sait le lui demander : sans quoi elle ouvrirait une
  // confirmation irréversible sur un geste qui n'aboutirait pas.
  capabilities.archive === true && runtime?.archive &&
  { id: 'delete', label: 'Supprimer', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b91c1c" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /><path d="M10 11v6M14 11v6" /></svg>, danger: true, onClick: () => setConfirmDelete(true) }].
  filter(Boolean);


  return (
    <>
      <div onClick={onClose} style={{
        position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)', zIndex: 100,
        animation: 'fadeIn .2s ease'
      }} />
      <div style={{
        position: 'absolute', left: 8, right: 8, bottom: 12, zIndex: 101,
        background: VT.surface,
        borderRadius: VT.rXl,
        boxShadow: VT.shadowLg,
        paddingTop: 6, paddingBottom: 8,
        animation: 'slideUp .25s cubic-bezier(.2,.7,.2,1)',
        overflow: 'hidden'
      }}>
        {/* grabber */}
        <div style={{
          width: 36, height: 4, borderRadius: 2,
          background: VT.hair2, margin: '6px auto 4px'
        }} />
        <div style={{ padding: '10px 20px 10px' }}>
          <div className="mono" style={{
            fontSize: 10, fontWeight: 600, color: VT.sub, letterSpacing: '0.5px', textTransform: 'uppercase'
          }}>PATIENT</div>
          <div style={{
            fontSize: 17, fontWeight: 700, color: VT.ink, letterSpacing: '-0.3px', marginTop: 2
          }}>
            {patient.last} <span style={{ fontWeight: 500 }}>{patient.first}</span>
          </div>
        </div>
        <div style={{ height: 1, background: VT.hair, margin: '0 16px' }} />
        {confirmDelete ?
        <div style={{ padding: '16px 20px 8px' }}>
            <div style={{ fontSize: 15, fontWeight: 700, color: VT.ink, letterSpacing: '-0.2px' }}>
              Supprimer ce patient ?
            </div>
            <div style={{ fontSize: 13, fontWeight: 500, color: VT.sub, marginTop: 6, lineHeight: 1.4 }}>
              {patient.last} {patient.first} sera retiré de votre liste. Cette action est irréversible.
            </div>
            <div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
              <button onClick={() => setConfirmDelete(false)} className="press" disabled={archiving} style={{
              flex: 1, height: VT.hLg, borderRadius: VT.rLg,
              background: 'none', border: `1px solid ${VT.hair2}`,
              fontFamily: 'inherit', fontSize: 14, fontWeight: 700, color: VT.ink,
              cursor: 'pointer', opacity: archiving ? 0.45 : 1
            }}>
                Annuler
              </button>
              {/* Le libellé passe à « Suppression… » le temps de l'aller-retour :
                  le serveur peut prendre plusieurs secondes, et un bouton qui
                  ne bouge pas laisse croire que le geste n'est pas parti. */}
              <button onClick={archive} className="press" disabled={archiving} style={{
              flex: 1, height: VT.hLg, borderRadius: VT.rLg,
              background: VT.danger, border: 'none',
              fontFamily: 'inherit', fontSize: 14, fontWeight: 700, color: '#fff',
              cursor: 'pointer', opacity: archiving ? 0.7 : 1
            }}>
                {archiving ? 'Suppression…' : 'Supprimer'}
              </button>
            </div>
          </div> :

        /* Une ligne éteinte porte sa raison sous son libellé. Elle ne vivait
           que dans le toast déclenché par l'appui : invisible pour qui lit la
           feuille, et hors de portée d'un lecteur d'écran avant le geste.
           L'`aria-label` la joint maintenant au libellé. Seuls l'icône et le
           libellé s'éteignent — une raison à 45 % d'opacité ne se lit pas. */
        items.map((it, i) =>
        <button key={it.id} onClick={it.onClick || onClose} className="press"
        aria-disabled={!!it.disabled}
        aria-label={it.disabled && it.reason ? `${it.label} — ${it.reason}` : undefined}
        title={it.disabled && it.reason ? `${it.label} — ${it.reason}` : undefined}
        style={{
          display: 'flex', alignItems: 'center', gap: 14,
          width: '100%', padding: '13px 20px',
          background: 'none', border: 'none', cursor: 'pointer',
          fontFamily: 'inherit', fontSize: 14.5, fontWeight: 500,
          color: it.danger ? '#b91c1c' : VT.ink,
          textAlign: 'left'
        }}>
            <span style={{ display: 'flex', alignItems: 'center', width: 22, opacity: it.disabled ? 0.45 : 1 }}>{it.icon}</span>
            <span style={{ flex: 1, minWidth: 0 }}>
              <span style={{
                display: 'block',
                color: doneAction === it.id ? VT.success : undefined,
                opacity: it.disabled ? 0.45 : 1,
              }}>
                {doneAction === it.id ? `IPP ${patient.ipp} copié` : it.label}
              </span>
              {it.disabled && it.reason &&
              <span style={{
                display: 'block', marginTop: 2,
                fontSize: 12, fontWeight: 600, color: VT.sub, lineHeight: 1.35,
              }}>{it.reason}</span>
              }
            </span>
          </button>
        )}
      </div>
      {/* Le QR se pose par-dessus le menu et le rend en le refermant : on est
          venu chercher un code, pas quitter la fiche. */}
      {qrOpen &&
      <PatientQrSheet patient={patient} runtime={runtime} onClose={() => setQrOpen(false)} />
      }
      <style>{`
        @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
        @keyframes slideUp { from { transform: translateY(110%); } to { transform: translateY(0); } }
      `}</style>
    </>);

}

// ─────────────────────────────────────────────────────────────
// Patient list screen
// ─────────────────────────────────────────────────────────────
function PatientList({ accent, onOpenPatient, onNewPatient, onBack, runtime = null }) {
  const [query, setQuery] = React.useState('');
  const [filter, setFilter] = React.useState('all');
  const [sheet, setSheet] = React.useState(null);
  // Le serveur a archivé ces dossiers : ils ont quitté ses listes. On les retire
  // ici aussi, sans attendre un rechargement — muter le tableau global ne
  // redessine rien.
  const [deletedIds, setDeletedIds] = React.useState([]);

  const alive = React.useMemo(
    () => PATIENTS_FULL.filter((p) => !deletedIds.includes(p.id)),
    [deletedIds]);

  const counts = React.useMemo(() => ({
    all: alive.length,
    hosp: alive.filter((p) => p.status === 'hosp' || p.status === 'rea').length,
    ambu: alive.filter((p) => p.status === 'ambu').length,
    rea: alive.filter((p) => p.status === 'rea').length,
    pall: alive.filter((p) => p.status === 'palliatif' || p.status === 'decede').length
  }), [alive]);

  const visible = React.useMemo(() => {
    const q = query.trim().toLowerCase();
    return alive.filter((p) => {
      if (filter === 'hosp' && !(p.status === 'hosp')) return false;
      if (filter === 'ambu' && p.status !== 'ambu') return false;
      if (filter === 'rea' && p.status !== 'rea') return false;
      if (filter === 'pall' && !(p.status === 'palliatif' || p.status === 'decede')) return false;
      if (!q) return true;
      const blob = `${p.last} ${p.first} ${p.ipp}`.toLowerCase();
      return blob.includes(q);
    });
    // `alive` fait partie des dépendances : sans lui, un dossier supprimé
    // restait affiché jusqu'au prochain changement de recherche ou de filtre.
  }, [query, filter, alive]);

  return (
    <div style={{
      width: '100%', height: '100%',
      background: VT.paper, color: VT.ink,
      display: 'flex', flexDirection: 'column',
      fontFamily: "'Inter Tight', -apple-system, system-ui, sans-serif",
      position: 'relative', overflow: 'hidden'
    }}>
      <div className="vasco-scroll" style={{ flex: 1, overflowY: 'auto', paddingTop: 62 }}>
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          padding: '8px 14px 4px'
        }}>
          <button onClick={onBack} className="press"
          style={{
            background: VT.surface, border: 'none', cursor: 'pointer',
            padding: 8, borderRadius: 999, boxShadow: VT.shadowSm,
            display: 'flex', alignItems: 'center', justifyContent: 'center'
          }} aria-label="Retour">
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={VT.ink} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <path d="M15 6l-6 6 6 6" />
            </svg>
          </button>
          <button className="press" style={{
            background: VT.surface, border: 'none', cursor: 'pointer',
            borderRadius: 999, boxShadow: VT.shadowSm,
            display: 'flex', alignItems: 'center', gap: 6,
            fontFamily: 'inherit', fontWeight: 600, color: VT.ink, width: "96.6719px", padding: "8px 14px", fontSize: "12.5px"
          }}>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={VT.ink} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
              <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
            </svg>
            Importer
          </button>
        </div>

        <div style={{ padding: '12px 20px 4px' }}>
          <h1 style={{
            margin: 0,
            fontSize: 38, fontWeight: 700, lineHeight: 1.0,
            letterSpacing: '-1.2px', color: VT.ink
          }}>Patients</h1>
        </div>
        <div style={{ padding: '4px 20px 0', display: 'flex', alignItems: 'baseline', gap: 6 }}>
          <span className="mono" style={{ fontSize: 12, fontWeight: 600, color: VT.ink, letterSpacing: '0.2px' }}>
            {visible.length}
          </span>
          <span style={{ fontSize: 13, fontWeight: 500, color: VT.sub }}>
            {visible.length === 1 ? 'patient' : 'patients'}
            {filter !== 'all' && <> · filtre actif</>}
          </span>
        </div>

        <div style={{ marginTop: 16 }}>
          <PatientsSearch value={query} onChange={setQuery} />
        </div>

        <FilterChips active={filter} counts={counts} onChange={setFilter} />

        <div style={{
          margin: '14px 12px 0',
          background: VT.surface,
          borderRadius: VT.rLg,
          boxShadow: VT.shadowSm,
          overflow: 'hidden'
        }}>
          {visible.length === 0 ?
          <div style={{ padding: '36px 28px', textAlign: 'center' }}>
              <div className="mono" style={{ fontSize: 10, fontWeight: 600, color: VT.sub, letterSpacing: '0.5px' }}>AUCUN RÉSULTAT</div>
              <div className="serif" style={{
              fontSize: 15, lineHeight: 1.4, color: VT.sub, marginTop: 8
            }}>Aucun patient ne correspond à « {query} ».</div>
            </div> :
          visible.map((p, i) =>
          <PatientRow key={p.id} p={p} accent={accent}
          isLast={i === visible.length - 1}
          onOpen={() => onOpenPatient(p.id)}
          onActions={setSheet} />
          )}
        </div>

        <div style={{ height: 110 }} />
      </div>

      <button onClick={onNewPatient} className="press" style={{
        position: 'absolute', right: 18, bottom: 18,
        width: 56, height: 56,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        background: VT.ink, color: '#fff',
        border: 'none', cursor: 'pointer', fontFamily: 'inherit',
        borderRadius: '50%',
        boxShadow: VT.shadowFab,
        padding: 0
      }} aria-label="Nouveau patient">
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M12 5v14M5 12h14" />
        </svg>
      </button>

      <ActionSheet patient={sheet} accent={accent} runtime={runtime}
      onClose={() => setSheet(null)}
      onOpen={(p) => onOpenPatient(p.id)}
      onDelete={(p) => setDeletedIds((prev) => [...prev, p.id])} />
    </div>);

}

window.PatientList = PatientList;
window.PStatusChip = StatusChip;
window.PInitials = Initials;
window.PCaption = PCaption;
// La fiche patient ouvre la même feuille depuis son bouton « QR code ».
window.PatientQrSheet = PatientQrSheet;
// Les P_* ne sont plus réexportés ici : tokens.jsx en est la seule source.