/* ═══════════════════════════════════════════════════════════════════════════
   Leadly PWA — React application
   Single-file JSX, transformed by Babel-standalone in the browser.
   Loads React + ReactDOM from CDN (see index.html).

   This file is intentionally one big bundle because there's no build step
   in the deployment. Sections are clearly marked.
   ═══════════════════════════════════════════════════════════════════════════ */

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

// ─── CONSTANTS ──────────────────────────────────────────────────────────────
const WS_RECONNECT_INTERVAL = 3000;
// Backoff is capped here so worst-case the user waits ~30s for a retry on a
// real outage. We RESET to WS_RECONNECT_INTERVAL on a successful onopen, and
// also reset whenever visibilitychange/online fires (since those events
// strongly imply state changed and the previous failure may be stale).
const WS_RECONNECT_MAX_INTERVAL = 30000;
// If we've heard nothing from the server (no pong, no message) for this many
// ms while the tab is visible, we treat the socket as dead and force a
// reconnect. This catches mobile-throttled cases where the OS closed the TCP
// connection silently while we were backgrounded but the WebSocket object's
// readyState is still misleadingly OPEN until the next send fails.
const WS_STALE_MS = 35000;
const TYPING_INDICATOR_TIMEOUT = 8000;
const MAX_AUDIO_DURATION_S = 120;

// ─── HELPERS ────────────────────────────────────────────────────────────────
function pad2(n) { return String(n).padStart(2, "0"); }

function formatTimeShort(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  if (isNaN(d.getTime())) return "";
  let h = d.getHours();
  const m = pad2(d.getMinutes());
  const isPm = h >= 12;
  h = h % 12 || 12;
  return `${h}:${m}${isPm ? " م" : " ص"}`;
}

function formatDayLabel(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  const today = new Date();
  const yesterday = new Date(today.getTime() - 86400000);
  const sameDay = (a, b) =>
    a.getDate() === b.getDate() &&
    a.getMonth() === b.getMonth() &&
    a.getFullYear() === b.getFullYear();
  if (sameDay(d, today))     return "اليوم";
  if (sameDay(d, yesterday)) return "أمس";
  const months = ["يناير","فبراير","مارس","أبريل","مايو","يونيو","يوليو","أغسطس","سبتمبر","أكتوبر","نوفمبر","ديسمبر"];
  return `${d.getDate()} ${months[d.getMonth()]}`;
}

function getSlug() {
  // /c/{slug} or /c/{slug}/...
  const m = window.location.pathname.match(/^\/c\/([^/]+)/);
  return m ? m[1] : null;
}

function linkifyArabic(text) {
  // Wrap http(s) URLs in <a> tags. Returns array of strings + JSX.
  const parts = [];
  const re = /(https?:\/\/[^\s]+)/g;
  let lastIdx = 0;
  let match;
  let key = 0;
  while ((match = re.exec(text)) !== null) {
    if (match.index > lastIdx) parts.push(text.slice(lastIdx, match.index));
    parts.push(
      <a key={`k${key++}`} href={match[1]} target="_blank" rel="noopener noreferrer">
        {match[1]}
      </a>
    );
    lastIdx = match.index + match[0].length;
  }
  if (lastIdx < text.length) parts.push(text.slice(lastIdx));
  return parts;
}

// ─── HONORIFIC ─────────────────────────────────────────────────────────────
// Mirrors index.js honorific() — picks the right title from titlePrefix
// (set via the agent's update_patient_record tool) falling back to a
// gender-based default. Used ONLY by the polling-fallback post-verify
// greeting; the WS-push path receives the body pre-built by the server.
const _CLIENT_FEMALE_NAMES = new Set([
  "منى","مني","نور","ريم","سارة","دينا","إيمان","ايمان","هبة","شيرين",
  "لمياء","غادة","رانيا","نادية","ميرنا","زينب","ولاء","مريم","فاطمة","عائشة",
  "أميرة","اميرة","نهال","ياسمين","هناء","سمر","رشا","دعاء","إسراء","اسراء",
  "شيماء","شيمه","أسماء","اسماء","رنا","لينا","لبنى","سلمى","ندى","هند",
  "نيرة","علا","عبير","أمل","امل","روان","ريهام","مروة","أميمة","صفاء",
  "وفاء","انتصار","إنجي","انجي","سوسن","حنان","إيناس","ايناس","أروى",
]);
const _CLIENT_MALE_OVERRIDE = new Set([
  "علاء","وفاء","هناء","سناء","رجاء","ضياء","بهاء","زياء","علي","إسلام","نور",
  "رضا","مروان","أشرف","كمال",
]);
function clientHonorific(name, titlePrefix) {
  if (titlePrefix === "doctor")    return "د.";
  if (titlePrefix === "engineer")  return "م.";
  if (titlePrefix === "professor") return "أ.د.";
  if (!name) return "أستاذ";
  const first = String(name).trim().split(/\s+/)[0];
  if (_CLIENT_MALE_OVERRIDE.has(first)) return "أستاذ";
  if (_CLIENT_FEMALE_NAMES.has(first))  return "أستاذة";
  return "أستاذ";
}

// ─── ICONS (inline SVG so no font loading) ──────────────────────────────────
const Icons = {
  send: () => <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>,
  mic: () => <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5-3c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>,
  attach: () => <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>,
  emoji: () => <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg>,
  close: () => <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>,
  arrowBack: () => <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>,
  menu: () => <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>,
  tickSingle: () => <svg viewBox="0 0 18 18" width="16" height="11"><path d="M1.5 9.5L6 14l11-11" fill="none" stroke="#8696a0" strokeWidth="2"/></svg>,
  tickDouble: ({ read }) => (
    <svg viewBox="0 0 22 18" width="18" height="11">
      <path d="M1 9.5L5.5 14l11-11" fill="none" stroke={read ? "#53bdeb" : "#8696a0"} strokeWidth="2"/>
      <path d="M6 9.5L10.5 14l11-11" fill="none" stroke={read ? "#53bdeb" : "#8696a0"} strokeWidth="2"/>
    </svg>
  ),
  play: () => <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>,
  image: () => <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>,
  camera: () => <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>,
  reply:  () => <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z"/></svg>,
};

// ─── API CLIENT ─────────────────────────────────────────────────────────────
const Api = {
  async me() {
    const r = await fetch("/api/patient/me", { credentials: "same-origin" });
    if (r.status === 401) return { unauthorized: true };
    return await r.json();
  },
  async clinics() {
    const r = await fetch("/api/patient/clinics", { credentials: "same-origin" });
    if (!r.ok) return { ok: false };
    return await r.json();
  },
  async history(limit = 500) {
    // Fetch the patient's past conversation with this clinic. Used to seed
    // the chat on mount — without this the PWA is history-amnesiac across
    // device switches, app reopens, and server restarts.
    //
    // May 18 2026: bumped default from 50 → 500 to match the new
    // HISTORY_PERSIST_CAP=1000 on the server. Older 50 was a hard floor
    // that hid long histories from the patient. 500 covers ~250 turns,
    // i.e. weeks of regular conversation, while still avoiding ~MB-size
    // initial loads. Server hard-caps at 1000 if a higher value is sent.
    //
    // Returns { ok, messages: [{role, content, ts}] }. Empty array is a
    // normal "no history" response, not an error.
    const r = await fetch(`/api/patient/history?limit=${limit}`, { credentials: "same-origin" });
    if (!r.ok) return { ok: false, messages: [] };
    return await r.json();
  },
  async switchClinic(tenantId) {
    const r = await fetch("/api/patient/switch-clinic", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "same-origin",
      body: JSON.stringify({ tenantId }),
    });
    return await r.json();
  },
  async logout() {
    const r = await fetch("/api/patient/logout", {
      method: "POST",
      credentials: "same-origin",
    });
    return await r.json();
  },
  async dismissInstallPrompt() {
    const r = await fetch("/api/patient/install-prompt-dismissed", {
      method: "POST",
      credentials: "same-origin",
    });
    return await r.json();
  },
  async uploadImage(file, onProgress) {
    return _uploadWithProgress("/api/patient/upload/image", file, onProgress);
  },
  async uploadAudio(blob, onProgress) {
    return _uploadWithProgress("/api/patient/upload/audio", blob, onProgress, "voice.webm");
  },
};

function _uploadWithProgress(url, fileOrBlob, onProgress, filename) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const fd = new FormData();
    if (fileOrBlob instanceof File) fd.append("file", fileOrBlob);
    else fd.append("file", fileOrBlob, filename || "upload.bin");
    xhr.open("POST", url);
    xhr.withCredentials = true;
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total);
    };
    xhr.onload = () => {
      try { resolve(JSON.parse(xhr.responseText)); }
      catch { reject(new Error("invalid_response")); }
    };
    xhr.onerror = () => reject(new Error("upload_error"));
    xhr.send(fd);
  });
}

// ─── WEBSOCKET CLIENT (with reconnect + session-refresh on 401) ─────────────
function useWebSocket(onMessage, onStatus) {
  const wsRef = useRef(null);
  const reconnectRef = useRef(null);
  const isOpenRef = useRef(false);
  const onMsgRef = useRef(onMessage);
  const onStatusRef = useRef(onStatus);
  onMsgRef.current = onMessage;
  onStatusRef.current = onStatus;

  // Track consecutive WS opens that failed (closed before ever firing
  // onopen). A run of these strongly implies the server is rejecting
  // the handshake — typically a 401 because the anonymous session
  // expired in Redis (1h TTL) while the cookie is still in the browser.
  // Recovering: fetch /c/{slug}, which the server uses to re-issue a
  // fresh anonymous session cookie (see index.js /c/:slug handler).
  const failsRef = useRef(0);
  const refreshAttemptsRef = useRef(0);
  const MAX_REFRESH_ATTEMPTS = 2;

  // ── Mobile-background recovery state (May 18, 2026) ───────────────────
  // The original implementation broke on a very common mobile flow:
  // patient opens chat, locks phone or switches apps for 30+ seconds,
  // comes back. Mobile browsers (especially Chrome on Android) FREEZE
  // background tabs: setTimeout/setInterval stop firing. So the 25s
  // keepalive ping stops, the server times the connection out, and our
  // 3-second reconnect timer never runs because it's frozen too. When
  // the patient returns, the banner reads "جاري إعادة الاتصال..." forever
  // because the React state says "disconnected" but no code is actually
  // retrying. The patient has to refresh manually.
  //
  // Worse: even if the timer eventually fires after thaw, on some Android
  // builds the underlying TCP connection was silently closed by the OS,
  // but the WebSocket object's readyState still reads OPEN until the next
  // send fails. So the reconnect logic doesn't even trigger because we
  // think we're connected.
  //
  // Three additions fix this:
  //   1. `lastServerMsgRef` — wall-clock of the last server payload.
  //      visibilitychange/online compare against it to detect a stale
  //      socket that didn't generate a close event yet.
  //   2. `forceReconnect()` — closes any existing socket and immediately
  //      tries to open a new one, bypassing the (possibly frozen)
  //      reconnect timer. Wired to visibilitychange and online events.
  //   3. Exponential backoff capped at WS_RECONNECT_MAX_INTERVAL — so on
  //      a genuine outage we don't hammer the server.
  const lastServerMsgRef = useRef(Date.now());
  const backoffRef = useRef(WS_RECONNECT_INTERVAL);

  // Re-fetch the page to coax the server into issuing a new session
  // cookie. We don't navigate — we just hit the route with credentials
  // included, which is enough for the server's Set-Cookie to take effect
  // on this origin. Then we let the WS retry pick up the new cookie.
  const refreshSession = useCallback(async () => {
    if (refreshAttemptsRef.current >= MAX_REFRESH_ATTEMPTS) {
      // Genuine server-side problem — stop the loop and let the user
      // decide. The "error" status surfaces a tap-to-refresh banner.
      onStatusRef.current?.("error");
      return false;
    }
    refreshAttemptsRef.current += 1;
    try {
      // Use the current path so we hit /c/{slug} for our specific tenant.
      // credentials:include is critical — sends/receives cookies.
      await fetch(window.location.pathname, {
        method: "GET",
        credentials: "include",
        cache: "no-store",
        headers: { "Accept": "text/html" },
      });
      failsRef.current = 0;
      return true;
    } catch (err) {
      console.warn("[WS] session refresh failed:", err);
      return false;
    }
  }, []);

  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) return;
    const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
    const ws = new WebSocket(`${proto}//${window.location.host}/pwa/ws`);
    wsRef.current = ws;
    // Tracks whether onopen ever fired before this socket closed. If it
    // did, this attempt succeeded (close was a normal disconnect we should
    // just reconnect from). If it didn't, the handshake itself failed.
    let everOpened = false;

    ws.onopen = () => {
      isOpenRef.current = true;
      everOpened = true;
      failsRef.current = 0;
      refreshAttemptsRef.current = 0;  // reset — we recovered
      backoffRef.current = WS_RECONNECT_INTERVAL;  // reset backoff
      lastServerMsgRef.current = Date.now();
      onStatusRef.current?.("connected");
    };

    ws.onclose = () => {
      isOpenRef.current = false;
      onStatusRef.current?.("disconnected");
      if (reconnectRef.current) clearTimeout(reconnectRef.current);

      if (!everOpened) {
        // Handshake never succeeded. Likely a 401 from /pwa/ws because
        // the session expired or the cookie is broken. Count failures;
        // after 2 in a row, try to get a fresh session before reconnecting.
        failsRef.current += 1;
        if (failsRef.current >= 2) {
          reconnectRef.current = setTimeout(async () => {
            const refreshed = await refreshSession();
            if (refreshed) {
              // Reset and try again from a clean state
              failsRef.current = 0;
              connect();
            }
            // If refresh failed twice, refreshSession() flips onStatus
            // to "error" and we stop. User can manually reload.
          }, backoffRef.current);
          backoffRef.current = Math.min(backoffRef.current * 2, WS_RECONNECT_MAX_INTERVAL);
          return;
        }
      }
      // Normal reconnect path — schedule a retry with current backoff,
      // then increase backoff for next time. Reset happens on onopen
      // success and on visibility/online events.
      reconnectRef.current = setTimeout(connect, backoffRef.current);
      backoffRef.current = Math.min(backoffRef.current * 2, WS_RECONNECT_MAX_INTERVAL);
    };

    ws.onerror = () => {
      // onclose will fire next; let it handle reconnect
    };

    ws.onmessage = (ev) => {
      lastServerMsgRef.current = Date.now();
      try {
        const msg = JSON.parse(ev.data);
        onMsgRef.current?.(msg);
      } catch {}
    };
  }, [refreshSession]);

  // Force a reconnect NOW — used by visibility/online recovery. Closes
  // any existing socket (which triggers onclose, which schedules the next
  // attempt) but cancels any pending timer first and resets backoff so
  // the new attempt fires within ~100ms instead of after a frozen 3–30s.
  const forceReconnect = useCallback(() => {
    if (reconnectRef.current) {
      clearTimeout(reconnectRef.current);
      reconnectRef.current = null;
    }
    backoffRef.current = WS_RECONNECT_INTERVAL;  // reset
    const existing = wsRef.current;
    if (existing && existing.readyState !== WebSocket.CLOSED) {
      // close() triggers onclose → onclose schedules connect() after
      // backoffRef ms. Fast-track by also scheduling an immediate retry.
      try { existing.close(); } catch {}
    }
    // Immediate retry (small delay to let any in-flight close settle)
    reconnectRef.current = setTimeout(connect, 100);
  }, [connect]);

  useEffect(() => {
    connect();
    // Keepalive ping every 25s
    const pingInterval = setInterval(() => {
      if (wsRef.current?.readyState === WebSocket.OPEN) {
        wsRef.current.send(JSON.stringify({ type: "ping" }));
      }
    }, 25_000);

    // ── Mobile-background recovery handlers ─────────────────────────────
    // When the tab becomes visible OR the network comes back online,
    // check whether our WS is actually still alive. Two signals matter:
    //   (a) readyState != OPEN — obvious, force-reconnect.
    //   (b) readyState == OPEN but lastServerMsg is older than
    //       WS_STALE_MS — this is the silent-dead-socket case on mobile:
    //       OS killed the TCP, but the WS object hasn't noticed yet.
    //       Force-close + reconnect.
    // If readyState IS open AND lastServerMsg is recent, do nothing —
    // we're genuinely fine.
    const recheckConnection = () => {
      const ws = wsRef.current;
      const stale = Date.now() - lastServerMsgRef.current > WS_STALE_MS;
      if (!ws || ws.readyState !== WebSocket.OPEN || stale) {
        forceReconnect();
      }
    };
    const onVisibility = () => {
      if (document.visibilityState === "visible") {
        recheckConnection();
      }
    };
    const onOnline = () => {
      // Network came back. Always recheck — even if WS thinks it's open,
      // the underlying TCP is almost certainly dead.
      recheckConnection();
    };
    const onPageshow = (e) => {
      // Some mobile browsers fire pageshow on tab restore from bfcache
      // when visibilitychange doesn't fire reliably. Belt and braces.
      if (e.persisted) recheckConnection();
    };

    document.addEventListener("visibilitychange", onVisibility);
    window.addEventListener("online", onOnline);
    window.addEventListener("pageshow", onPageshow);

    return () => {
      clearInterval(pingInterval);
      document.removeEventListener("visibilitychange", onVisibility);
      window.removeEventListener("online", onOnline);
      window.removeEventListener("pageshow", onPageshow);
      if (reconnectRef.current) clearTimeout(reconnectRef.current);
      wsRef.current?.close();
    };
  }, [connect, forceReconnect]);

  const send = useCallback((payload) => {
    const ws = wsRef.current;
    if (ws?.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(payload));
      return true;
    }
    return false;
  }, []);

  return { send, isConnected: isOpenRef };
}

// ─── VOICE RECORDER ─────────────────────────────────────────────────────────
function useVoiceRecorder() {
  const [isRecording, setIsRecording] = useState(false);
  const [duration, setDuration] = useState(0);
  const mediaRecorderRef = useRef(null);
  const chunksRef = useRef([]);
  const startTimeRef = useRef(0);
  const intervalRef = useRef(null);
  const streamRef = useRef(null);

  const start = useCallback(async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      streamRef.current = stream;
      const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
        ? "audio/webm;codecs=opus"
        : MediaRecorder.isTypeSupported("audio/mp4")
        ? "audio/mp4"
        : "";
      const mr = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
      chunksRef.current = [];
      mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
      mr.start();
      mediaRecorderRef.current = mr;
      startTimeRef.current = Date.now();
      setDuration(0);
      setIsRecording(true);
      intervalRef.current = setInterval(() => {
        const d = (Date.now() - startTimeRef.current) / 1000;
        setDuration(d);
        if (d >= MAX_AUDIO_DURATION_S) stop();
      }, 100);
      return true;
    } catch (err) {
      console.error("Recording error:", err);
      alert("لازم تسمحي بالمايكروفون عشان نسجّل");
      return false;
    }
  }, []);

  const stop = useCallback(() => {
    return new Promise((resolve) => {
      const mr = mediaRecorderRef.current;
      if (!mr || mr.state === "inactive") return resolve(null);
      mr.onstop = () => {
        const blob = new Blob(chunksRef.current, { type: mr.mimeType || "audio/webm" });
        streamRef.current?.getTracks().forEach(t => t.stop());
        streamRef.current = null;
        clearInterval(intervalRef.current);
        setIsRecording(false);
        setDuration(0);
        resolve(blob);
      };
      mr.stop();
    });
  }, []);

  const cancel = useCallback(() => {
    const mr = mediaRecorderRef.current;
    if (mr && mr.state !== "inactive") {
      mr.onstop = null;
      mr.stop();
    }
    streamRef.current?.getTracks().forEach(t => t.stop());
    streamRef.current = null;
    clearInterval(intervalRef.current);
    setIsRecording(false);
    setDuration(0);
    chunksRef.current = [];
  }, []);

  return { isRecording, duration, start, stop, cancel };
}

// ═══════════════════════════════════════════════════════════════════════════
// MESSAGE COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════

function MessageBubble({ msg, onImageClick, installPromptCtx, onReply }) {
  if (msg.system) {
    return (
      <div className="msg-row system">
        <div className="msg-bubble">{msg.body}</div>
      </div>
    );
  }
  const outgoing = msg.role === "user";

  // ── SWIPE-TO-REPLY (May 18, 2026, outgoing fix May 19, 2026) ─────────────
  // Touch handlers that detect a horizontal swipe gesture on the bubble.
  //
  // In this RTL layout, `justify-content: flex-end` (the outgoing rule)
  // resolves to the LEFT side of the screen, so:
  //   - Incoming (Yasmin/bot) bubbles sit on the RIGHT side of the screen
  //   - Outgoing (user) bubbles sit on the LEFT side of the screen
  // Both reply affordances render on the OPPOSITE side of the bubble
  // (the side the bubble would naturally swipe toward to expose them).
  //
  // The May 18 original direction filter was `outgoing ? dx < 0 : dx > 0`,
  // which made outgoing bubbles only respond to a right-to-left swipe.
  // Because the outgoing bubble already sits at the LEFT edge of the row,
  // swiping LEFT moved it further off-screen and felt unintuitive — users
  // reported the gesture "didn't work" on their own messages.
  //
  // Fix: outgoing now accepts the SAME swipe direction as incoming —
  // a left-to-right swipe (`dx > 0`). The bubble translates RIGHT, toward
  // the reply affordance on the right of the row. Consistent direction
  // for both bubble types, no mental model to remember.
  //
  // The bubble follows the finger up to a small offset for tactile feedback,
  // then snaps back. If the swipe crosses the THRESHOLD, the reply mode is
  // activated and the gesture ends.
  //
  // Long-press is a fallback for users who don't know about the swipe — at
  // ~500ms of holding the bubble, we fire onReply directly (no menu needed
  // since "reply" is the only action right now; future could add Copy/Delete).
  const SWIPE_THRESHOLD     = 50;   // px past which the swipe registers as reply
  const SWIPE_MAX_TRANSLATE = 70;   // visual cap on how far the bubble moves
  const LONG_PRESS_MS       = 500;
  const [translateX, setTranslateX] = useState(0);
  const [pressed, setPressed]       = useState(false);
  const touchStartRef = useRef(null);
  const longPressTimerRef = useRef(null);
  const fireReply = () => {
    if (!onReply || msg.kind) return;  // skip widget messages
    if (msg.imageDeleted) return;       // skip deleted-photo placeholders
    if (!msg.body && !msg.imageUrl && !msg.audioUrl) return;
    onReply(msg);
  };
  const handleTouchStart = (e) => {
    if (e.touches.length !== 1) return;
    const t = e.touches[0];
    touchStartRef.current = { x: t.clientX, y: t.clientY, time: Date.now() };
    setPressed(true);
    longPressTimerRef.current = setTimeout(() => {
      // Long press fired. Provide a tiny haptic tick if supported, and
      // trigger reply. The setPressed(false) below cancels visual feedback.
      if (navigator.vibrate) navigator.vibrate(10);
      fireReply();
      setPressed(false);
      touchStartRef.current = null;
    }, LONG_PRESS_MS);
  };
  const handleTouchMove = (e) => {
    if (!touchStartRef.current || e.touches.length !== 1) return;
    const t = e.touches[0];
    const dx = t.clientX - touchStartRef.current.x;
    const dy = t.clientY - touchStartRef.current.y;
    // If the motion is primarily vertical, this is a scroll — release the
    // bubble and let the normal scroll behavior continue.
    if (Math.abs(dy) > Math.abs(dx) + 6) {
      clearTimeout(longPressTimerRef.current);
      setTranslateX(0);
      setPressed(false);
      touchStartRef.current = null;
      return;
    }
    // Any horizontal motion cancels the long-press timer — the user is
    // swiping, not pressing.
    if (Math.abs(dx) > 5) clearTimeout(longPressTimerRef.current);
    // Direction filtering: both incoming AND outgoing now respond to a
    // left-to-right swipe (dx > 0). May 19, 2026 — was previously mirrored
    // (outgoing required dx < 0) but outgoing's LEFT-edge position made
    // that direction unintuitive. Consistent positive-dx swipe for both
    // bubble types. Wrong-direction (negative dx) swipes are ignored so
    // the user can still scroll naturally.
    const validDirection = dx > 0;
    if (!validDirection) {
      setTranslateX(0);
      return;
    }
    // Visual follow capped at SWIPE_MAX_TRANSLATE (positive only)
    const capped = Math.min(dx, SWIPE_MAX_TRANSLATE);
    setTranslateX(capped);
  };
  const handleTouchEnd = () => {
    clearTimeout(longPressTimerRef.current);
    setPressed(false);
    if (touchStartRef.current) {
      const dx = translateX;
      // If swipe crossed the threshold (always positive now), fire reply.
      const crossed = dx >= SWIPE_THRESHOLD;
      if (crossed) {
        if (navigator.vibrate) navigator.vibrate(8);
        fireReply();
      }
    }
    setTranslateX(0);
    touchStartRef.current = null;
  };
  const handleTouchCancel = () => {
    clearTimeout(longPressTimerRef.current);
    setPressed(false);
    setTranslateX(0);
    touchStartRef.current = null;
  };

  // ── QUOTE HEADER (inside bubble) ──────────────────────────────────────
  // For user bubbles that are themselves replies to a past message, render
  // a small quote strip at the top of the bubble showing the snippet of
  // what's being replied to. Mirrors WhatsApp / Telegram's "quoted reply"
  // visual. The snippet is whatever the client captured at swipe time
  // (max ~80 chars). Clicking the quote header scrolls to the original
  // message (best-effort — if the message has scrolled out of the rendered
  // list due to long history, the click is a no-op).
  const scrollToQuoted = (e) => {
    e.stopPropagation();
    if (!msg.replyTo?.id) return;
    const target = document.querySelector(`[data-msg-id="${msg.replyTo.id}"]`);
    if (target) {
      target.scrollIntoView({ behavior: "smooth", block: "center" });
      target.classList.add("msg-flash");
      setTimeout(() => target.classList.remove("msg-flash"), 1200);
    }
  };

  const bubbleStyle = translateX !== 0
    ? { transform: `translateX(${translateX}px)`, transition: pressed ? "none" : "transform 0.18s ease-out" }
    : { transition: "transform 0.18s ease-out" };

  return (
    <div className={`msg-row ${outgoing ? "outgoing" : "incoming"}`} data-msg-id={msg.id}>
      {/* Reply-affordance icon revealed under the bubble during swipe.
          May 19, 2026: now uses className "right" for BOTH bubble types.
          Combined with the RTL CSS override, this resolves to the LEFT side
          of the row in RTL — the side both bubbles swipe AWAY from (both
          now swipe `dx > 0`). The icon is revealed behind the bubble as it
          translates rightward. Previously outgoing used "left" which landed
          on the RIGHT side — the side outgoing swiped TOWARD — visually
          inconsistent with incoming's reveal-behind pattern. */}
      {translateX !== 0 && (
        <div className="reply-affordance right">
          <Icons.reply />
        </div>
      )}
      <div
        className={`msg-bubble ${pressed ? "pressed" : ""}`}
        style={bubbleStyle}
        onTouchStart={onReply ? handleTouchStart : undefined}
        onTouchMove={onReply ? handleTouchMove : undefined}
        onTouchEnd={onReply ? handleTouchEnd : undefined}
        onTouchCancel={onReply ? handleTouchCancel : undefined}
      >
        {msg.replyTo && (
          <div className="msg-quote" onClick={scrollToQuoted} role="button" tabIndex={0}>
            <div className="msg-quote-bar" />
            <div className="msg-quote-content">
              <div className="msg-quote-label">
                {msg.replyTo.role === "user" ? "أنت" : "البوت"}
              </div>
              <div className="msg-quote-text">{msg.replyTo.snippet}</div>
            </div>
          </div>
        )}
        {msg.imageUrl && !msg.imageDeleted && (
          <img
            className="msg-image"
            src={msg.imageUrl}
            alt=""
            onClick={() => onImageClick?.(msg.imageUrl)}
          />
        )}
        {msg.imageDeleted && (
          <div className="msg-image-deleted">
            <span className="msg-image-deleted-icon">🗑️</span>
            <span className="msg-image-deleted-text">تم حذف هذه الصورة</span>
          </div>
        )}
        {msg.audioUrl && (
          <div className="msg-voice">
            <button className="msg-voice-btn" onClick={() => {
              const audio = new Audio(msg.audioUrl);
              audio.play().catch(() => {});
            }}>
              <Icons.play />
            </button>
            <div className="msg-voice-waveform">
              {Array.from({ length: 28 }).map((_, i) => (
                <div key={i} style={{ height: `${4 + (Math.sin(i * 1.3) * 5 + 5)}px` }} />
              ))}
            </div>
            <span className="msg-voice-duration">
              {msg.audioDuration ? `${Math.floor(msg.audioDuration / 60)}:${pad2(Math.floor(msg.audioDuration % 60))}` : ""}
            </span>
          </div>
        )}
        {msg.body && !msg.imageDeleted && (
          <div className="msg-text">{linkifyArabic(msg.body)}</div>
        )}
        {msg.kind === "verify_prompt" && (
          <VerifyPromptWidget code={msg.code} waLink={msg.waLink} phone={msg.phone} />
        )}
        {msg.kind === "install_prompt" && installPromptCtx && (
          <InstallPromptWidget ctx={installPromptCtx} msgId={msg.id} />
        )}
        <span className="msg-meta">
          <span className="msg-time">{formatTimeShort(msg.ts)}</span>
          {outgoing && (
            <span className={`msg-ticks ${msg.status === "read" ? "read" : ""}`}>
              {msg.status === "sending" && <Icons.tickSingle />}
              {(msg.status === "delivered" || msg.status === "sent") && <Icons.tickDouble read={false} />}
              {msg.status === "read" && <Icons.tickDouble read={true} />}
            </span>
          )}
        </span>
      </div>
    </div>
  );
}

// ─── VERIFY PROMPT (inline OTP bubble) ──────────────────────────────────
// Renders the OTP code + WhatsApp button inline in the chat, replacing the
// modal that used to overlay everything. Patients who instinctively dismiss
// pop-ups (thinking they're ads) couldn't recover from the modal — they'd
// lose the button and not know how to continue. As a chat message, it
// stays in the conversation history forever and they can return to it.
function VerifyPromptWidget({ code, waLink, phone }) {
  return (
    <div className="verify-inline">
      <div className="verify-inline-header">
        <span className="verify-inline-icon">❗</span>
        <span>تأكيد رقمك</span>
      </div>
      <div className="verify-inline-body">
        عشان نحمي حسابك، محتاجين تأكيد إن الرقم
        {" "}
        <strong dir="ltr">{phone}</strong>
        {" "}
        فعلاً بتاع حضرتك.
      </div>
      <div className="verify-inline-code-row">
        <span className="verify-inline-code-label">الكود بتاعك:</span>
        <span className="verify-inline-code">{code}</span>
      </div>
      <div className="verify-inline-instructions">
        دوس على الزرار اللي تحت 👇 هيفتحلك واتساب والكود مكتوب جاهز.<br/>
        <strong>كل اللي عليك بعد كده: تدوس على زرار الإرسال في واتساب.</strong>
      </div>
      <a
        href={waLink}
        target="_blank"
        rel="noopener noreferrer"
        className="verify-inline-btn"
      >
        <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{marginInlineEnd:8}}>
          <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0 0 20.885 3.488"/>
        </svg>
        افتح واتساب وابعت الكود
      </a>
      <div className="verify-inline-footnote">
        بعد ما تبعت الكود، هترجع هنا تلقائياً ✅ — الكود صالح 5 دقايق.
      </div>
    </div>
  );
}

// Build a verify-error message body from the rejection reason. Same copy
// the prior modal used, condensed to a single message. Adds the ❗ emoji
// at the start to draw attention (per design spec).
function buildVerifyErrorBody(reason, details) {
  const title =
    reason === "rate_limit_session"        ? "محاولات كتير قوي" :
    reason === "rate_limit_phone"          ? "محاولات كتير على نفس الرقم" :
    reason === "expired"                   ? "الكود اللي بعتناه خلصت صلاحيته" :
    reason === "no_verify_number_configured" ? "في إعداد ناقص — اتواصل مع العيادة" :
    reason === "sender_mismatch"           ? "الكود جه من رقم مختلف" :
    reason === "claim_failed"              ? "حصلت مشكلة في تأكيد رقمك" :
    reason === "code_not_recognized"       ? "الكود اللي حضرتك بعته مش مظبوط" :
    reason === "non_text_message"          ? "ابعت الكود ك رسالة نصية" :
    reason === "non_code_message"          ? "ابعت الكود نفسه من غير كلام تاني" :
    reason === "internal_error"            ? "حصلت مشكلة مؤقتة" :
    reason === "redis_unavailable"         ? "في مشكلة مؤقتة في الاتصال" :
    reason === "invalid_phone"             ? "الرقم اللي حضرتك دخلته مش مظبوط" :
    "حصلت مشكلة";

  let detailLine = "";
  if (reason === "sender_mismatch") {
    if (details.expected && details.got) {
      detailLine =
        `الرقم اللي حضرتك دخلته: ${details.expected}\n` +
        `الرسالة جت من: ${details.got}\n\n` +
        `لو الرقم اللي حضرتك دخلته صح، ابعت الكود من واتساب نفس الرقم. ولو كان غلط، ابدأ من الأول وادخل الرقم الصح بـ كود الدولة.`;
    } else if (details.expectedLast4 && details.gotLast4 && details.expectedLast4 !== details.gotLast4) {
      detailLine = `الرقم اللي حضرتك دخلته بينتهي بـ ${details.expectedLast4}، بس الرسالة جت من رقم بينتهي بـ ${details.gotLast4}. ابعت الكود من نفس الرقم اللي دخلته.`;
    } else {
      detailLine = "الرسالة جت من واتساب رقم مختلف عن اللي حضرتك دخلته. ابعت الكود من واتساب نفس الرقم اللي دخلته في الشات.";
    }
  } else if (reason === "rate_limit_session" || reason === "rate_limit_phone") {
    detailLine = "حاول تاني بعد ساعة من فضلك.";
  } else if (reason === "expired") {
    detailLine = "ابعت رقمك تاني عشان نبعتلك كود جديد.";
  } else if (reason === "code_not_recognized") {
    detailLine = "إما الكود اتكتب غلط في رسالة واتساب أو خلصت مدته. ابعت رقمك تاني عشان نبعتلك كود جديد.";
  } else if (reason === "non_text_message") {
    const typeWord =
      details.messageType === "audio" || details.messageType === "voice" ? "رسالة صوتية" :
      details.messageType === "image" ? "صورة" : "رسالة مش نصية";
    detailLine = `بعتنا الكود في الشات، بس وصلتنا منك ${typeWord}. لو سمحت ابعت الكود ك رسالة نصية عادية (الكود بيبدأ بـ LDY-).`;
  } else if (reason === "non_code_message") {
    detailLine = "وصلتنا رسالتك بس مش فيها الكود. ابعت الكود اللي بدايته LDY- من الشات بالظبط.";
  } else if (reason === "claim_failed") {
    detailLine = "حصلت مشكلة مؤقتة. جرب تاني، ولو الموضوع كمل اتواصل مع العيادة.";
  } else if (reason === "internal_error") {
    detailLine = "حصلت مشكلة عندنا. جرب تبعت رقمك تاني.";
  } else if (reason === "redis_unavailable") {
    detailLine = "في مشكلة مؤقتة. ابعت رقمك تاني خلال شوية ثواني.";
  } else if (reason === "invalid_phone") {
    detailLine = "الرقم لازم يكون رقم مصري (مثلاً 01211944805) أو رقم دولي بكود الدولة (مثلاً +18326811515).";
  } else {
    detailLine = "حاول تاني، ولو الموضوع كمل اتواصل مع العيادة.";
  }

  return `❗ ${title}\n\n${detailLine}`;
}


// Renders inline below the install-prompt message body. Behavior:
//   • Android / Chrome / Edge: a single button that fires the captured
//     beforeinstallprompt event — user sees the native "Add to Home Screen"
//     dialog. Once installed (appinstalled event), we collapse the widget.
//   • iOS Safari / Chrome on iOS: beforeinstallprompt is not supported.
//     Show inline step-by-step instructions with the share icon.
//   • Already installed (display-mode: standalone): collapse to a checkmark.
//   • Dismissed (X button): POST dismissal, collapse to nothing.
function InstallPromptWidget({ ctx, msgId }) {
  const { deferredPrompt, isStandalone, onInstalled, onDismissed, dismissedIds } = ctx;
  const [localDismissed, setLocalDismissed] = useState(false);
  const [localInstalled, setLocalInstalled] = useState(false);
  const [showIosSteps, setShowIosSteps] = useState(false);

  // If this specific message was dismissed in a prior render cycle (server-
  // persisted), don't render the actionable parts.
  const isDismissed = localDismissed || dismissedIds.has(msgId);

  // Already running as installed PWA — no need to prompt.
  if (isStandalone || localInstalled) {
    return (
      <div className="install-prompt-installed">
        ✅ تم التثبيت — ياسمين دلوقتي على شاشتك
      </div>
    );
  }

  if (isDismissed) {
    return null;
  }

  // iOS detection — Safari and Chrome on iOS both use WebKit; neither
  // supports beforeinstallprompt. We rely on userAgent + the absence of
  // a captured prompt event.
  const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
  const isIos = /iPad|iPhone|iPod/.test(ua) ||
                (/Macintosh/.test(ua) && typeof navigator !== "undefined" && navigator.maxTouchPoints > 1);

  const handleInstallTap = async () => {
    if (deferredPrompt) {
      // Android / Chrome / Edge path — native A2HS dialog.
      try {
        deferredPrompt.prompt();
        const choice = await deferredPrompt.userChoice;
        if (choice.outcome === "accepted") {
          setLocalInstalled(true);
          onInstalled();
        }
        // If they dismiss the native dialog, do nothing — they can retap
        // the button if they change their mind. We only mark the message
        // as fully dismissed if they hit our X.
      } catch {
        // Browser refused (e.g. prompt already used). Fall back to iOS-style steps.
        setShowIosSteps(true);
      }
    } else {
      // No native prompt available — iOS or unsupported browser. Show steps.
      setShowIosSteps(true);
    }
  };

  const handleDismiss = async () => {
    setLocalDismissed(true);
    onDismissed(msgId);
    try { await Api.dismissInstallPrompt(); } catch {}
  };

  return (
    <div className="install-prompt-widget">
      {!isIos && deferredPrompt && !showIosSteps && (
        <button className="install-prompt-btn" onClick={handleInstallTap}>
          <span className="install-prompt-icon">📱</span>
          <span>ضيف ياسمين على موبايلك</span>
        </button>
      )}
      {(isIos || showIosSteps) && (
        <div className="install-prompt-ios-steps">
          <div className="install-prompt-ios-title">عشان تضيف ياسمين على شاشة موبايلك:</div>
          <ol className="install-prompt-ios-list">
            <li>
              دوس على زرار المشاركة
              <svg width="16" height="20" viewBox="0 0 16 20" style={{verticalAlign:"middle", margin:"0 4px"}} aria-hidden="true">
                <path fill="currentColor" d="M8 0L4 4l1.4 1.4L7 3.8V13h2V3.8l1.6 1.6L12 4 8 0zM2 8v10a2 2 0 002 2h8a2 2 0 002-2V8h-2v10H4V8H2z"/>
              </svg>
              في شريط المتصفح اللي تحت
            </li>
            <li>اختار <strong>"Add to Home Screen" / "إضافة إلى الشاشة الرئيسية"</strong></li>
            <li>اضغط <strong>"Add" / "أضف"</strong></li>
          </ol>
        </div>
      )}
      {!isIos && !deferredPrompt && !showIosSteps && (
        <div className="install-prompt-fallback">
          عشان تضيفني، افتح قائمة المتصفح واختار <strong>"Add to Home Screen"</strong>.
        </div>
      )}
      <button className="install-prompt-dismiss" onClick={handleDismiss} aria-label="إغلاق">
        <Icons.close />
      </button>
    </div>
  );
}

function TypingIndicator() {
  return (
    <div className="typing-row">
      <div className="typing-bubble">
        <div className="typing-dot" />
        <div className="typing-dot" />
        <div className="typing-dot" />
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════════════
// HEADER + CLINIC SWITCHER
// ═══════════════════════════════════════════════════════════════════════════

function Header({ session, onOpenClinicSwitcher, connectionStatus }) {
  if (!session) return null;
  const clinicName = session.tenantName || "العيادة";
  return (
    <header className="header">
      <div className="header-avatar">Y</div>
      <div className="header-info">
        <div className="header-name">ياسمين</div>
        <div className="header-status">
          <span className={`status-dot ${connectionStatus === "connected" ? "online" : "offline"}`} />
          <span className="clinic-switcher" onClick={onOpenClinicSwitcher}>
            {clinicName}
            <span className="clinic-switcher-arrow"> ▾</span>
          </span>
          <span className="status-sep"> · </span>
          <span className="status-label">
            {connectionStatus === "connected" ? "متاح"
             : connectionStatus === "disconnected" ? "جاري إعادة الاتصال..."
             : connectionStatus === "error" ? "غير متصل"
             : "..."}
          </span>
        </div>
      </div>
    </header>
  );
}

function ClinicSwitcher({ open, onClose, clinics, activeTenantId, onPick }) {
  if (!open) return null;
  return (
    <>
      <div className="clinic-dropdown-backdrop" onClick={onClose} />
      <div className="clinic-dropdown">
        <div className="clinic-dropdown-header">اختاري العيادة</div>
        {clinics.map(c => (
          <div
            key={c.tenantId}
            className={`clinic-item ${c.tenantId === activeTenantId ? "active" : ""}`}
            onClick={() => onPick(c)}
          >
            <div className="clinic-item-avatar">
              {c.logoUrl ? <img src={c.logoUrl} alt="" /> : (c.name?.charAt(0) || "?")}
            </div>
            <div className="clinic-item-name">{c.name}</div>
            {c.tenantId === activeTenantId && <div className="clinic-item-check">✓</div>}
          </div>
        ))}
        {clinics.length === 0 && (
          <div style={{ padding: "20px", color: "var(--text-muted)", textAlign: "center", fontSize: "13px" }}>
            مفيش عيادات تانية
          </div>
        )}
      </div>
    </>
  );
}

// ═══════════════════════════════════════════════════════════════════════════
// COMPOSER (input + attach + voice)
// ═══════════════════════════════════════════════════════════════════════════

function SuggestedChips({ onPick, suggestions }) {
  if (!suggestions || suggestions.length === 0) return null;
  const handlePick = (text) => {
    try { navigator.vibrate?.(10); } catch {}
    onPick(text);
  };
  return (
    <div className="suggested-chips">
      {suggestions.map((s, i) => (
        <button key={i} className="suggested-chip" onClick={() => handlePick(s)}>
          {s}
        </button>
      ))}
    </div>
  );
}

// Default suggestions shown on the empty state for new patients. The server
// can override these via session.suggestedReplies (per-clinic / per-vertical
// override from tenant.config).
const DEFAULT_SUGGESTED_REPLIES = [
  "احجزلي موعد",
  "أسعار الخدمات",
  "مواعيد العيادة",
  "عناوين الفروع",
];

function Composer({ onSend, onSendImage, onSendVoice, disabled, replyTo, onCancelReply }) {
  const [text, setText] = useState("");
  const [showAttach, setShowAttach] = useState(false);
  const inputRef = useRef(null);
  const fileInputRef = useRef(null);
  const cameraInputRef = useRef(null);
  const recorder = useVoiceRecorder();

  // Close the attach menu when the system back button is pressed in
  // standalone PWA mode. See App-level "PWA back-button trap" for context
  // on how pwa:back is dispatched.
  useEffect(() => {
    if (!showAttach) return;
    const onBack = (e) => {
      if (e.defaultPrevented) return;  // an App-level overlay already handled it
      setShowAttach(false);
      e.preventDefault();
    };
    window.addEventListener("pwa:back", onBack);
    return () => window.removeEventListener("pwa:back", onBack);
  }, [showAttach]);

  const handleSubmit = () => {
    const t = text.trim();
    if (!t) return;
    try { navigator.vibrate?.(10); } catch {}
    onSend(t);
    setText("");
    if (inputRef.current) inputRef.current.style.height = "auto";
  };

  const handleKey = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSubmit();
    }
  };

  const autoResize = (e) => {
    setText(e.target.value);
    e.target.style.height = "auto";
    e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px";
  };

  const handleFile = async (e) => {
    setShowAttach(false);
    const file = e.target.files?.[0];
    if (!file) return;
    e.target.value = ""; // reset so the same file can be picked again
    await onSendImage(file);
  };

  const startVoice = async () => {
    const ok = await recorder.start();
    if (!ok) return;
  };

  const sendVoice = async () => {
    const blob = await recorder.stop();
    if (blob && blob.size > 1000) await onSendVoice(blob, recorder.duration);
  };

  const hasText = text.trim().length > 0;

  if (recorder.isRecording) {
    return (
      <div className="composer-area">
        <div className="composer recording">
          <button className="composer-action" onClick={recorder.cancel} style={{ background: "#9e9e9e" }}>
            <Icons.close />
          </button>
          <div className="recording-indicator">
            <div className="recording-pulse" />
            <span className="recording-time">
              {Math.floor(recorder.duration / 60)}:{pad2(Math.floor(recorder.duration % 60))}
            </span>
            <span className="recording-hint">جاري التسجيل...</span>
          </div>
          <button className="composer-action" onClick={sendVoice}>
            <Icons.send />
          </button>
        </div>
      </div>
    );
  }

  return (
    <>
      {showAttach && (
        <>
          <div className="attach-menu-backdrop" onClick={() => setShowAttach(false)} />
          <div className="attach-menu">
            <div className="attach-item" onClick={() => fileInputRef.current?.click()}>
              <div className="attach-icon image"><Icons.image /></div>
              <div className="attach-item-label">صورة</div>
            </div>
            <div className="attach-item" onClick={() => cameraInputRef.current?.click()}>
              <div className="attach-icon camera"><Icons.camera /></div>
              <div className="attach-item-label">كاميرا</div>
            </div>
          </div>
        </>
      )}

      <input ref={fileInputRef} type="file" accept="image/*" style={{ display: "none" }} onChange={handleFile} />
      <input ref={cameraInputRef} type="file" accept="image/*" capture="environment" style={{ display: "none" }} onChange={handleFile} />

      {/* Composer-area: a single self-contained block at the bottom of the
          chat that holds the optional reply-strip and the always-present
          composer row. Wrapping these together (rather than rendering them
          as separate flex items of .app) guarantees the composer's
          flex-row layout — input wrap + send/mic button on a single line —
          is independent of whether a reply is active, and that the parent
          flex column allocates one stable block of space for the entire
          bottom area. The composer's internal layout is therefore immune
          to anything the strip does. */}
      <div className="composer-area">
        {replyTo && (
          <div className="reply-strip">
            <div className="reply-strip-bar" />
            <div className="reply-strip-content">
              <div className="reply-strip-label">
                {replyTo.role === "user" ? "أنت" : "البوت"}
              </div>
              <div className="reply-strip-text">{replyTo.snippet}</div>
            </div>
            <button className="reply-strip-cancel" onClick={onCancelReply} aria-label="إلغاء الرد">
              <Icons.close />
            </button>
          </div>
        )}

        <div className="composer">
          <div className="composer-input-wrap">
            <button className="composer-emoji" onClick={() => setShowAttach(true)} aria-label="مرفقات">
              <Icons.attach />
            </button>
            <textarea
              ref={inputRef}
              className="composer-input"
              value={text}
              onChange={autoResize}
              onKeyDown={handleKey}
              placeholder="اكتب رسالة"
              rows="1"
              disabled={disabled}
              dir="auto"
            />
          </div>
          {hasText ? (
            <button className="composer-action" onClick={handleSubmit} disabled={disabled} aria-label="إرسال">
              <Icons.send />
            </button>
          ) : (
            <button className="composer-action" onClick={startVoice} disabled={disabled} aria-label="تسجيل صوتي">
              <Icons.mic />
            </button>
          )}
        </div>
      </div>
    </>
  );
}

// ═══════════════════════════════════════════════════════════════════════════
// MAIN APP
// ═══════════════════════════════════════════════════════════════════════════

// ═══════════════════════════════════════════════════════════════════════════
// PWA: SERVICE WORKER + PUSH NOTIFICATIONS
// ═══════════════════════════════════════════════════════════════════════════
//
// Three responsibilities:
//   1. Register /sw.js as a service worker (offline app shell, push handler)
//   2. Subscribe the (authenticated) patient to web push so the clinic can
//      ping them when they're not looking at the PWA
//   3. Surface a soft "Enable notifications" prompt — only AFTER the patient
//      has had a meaningful exchange (we don't ambush first-time visitors)
//
// SW registration runs at module load (unconditional, safe to do before
// authentication). Push subscription is gated on phone-claimed state and
// runs from within App via a useEffect.
//
// We DON'T auto-request notification permission on first load — that's
// the #1 way to get users to permanently deny. Instead, App renders a
// dismissible banner after the patient has sent ≥ 1 message AND the
// permission is still "default" (i.e. they haven't decided yet). User
// taps "Enable" → we call Notification.requestPermission().

// Singleton: ensure we only register the SW once per page load.
let _swRegistration = null;
async function _registerServiceWorker() {
  if (!("serviceWorker" in navigator)) return null;
  if (_swRegistration) return _swRegistration;
  try {
    // Scope `/` because the SW source sends Service-Worker-Allowed: /
    const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
    _swRegistration = reg;
    // Listen for the SW to take control on first install; can be useful
    // for surfacing a "App ready offline" toast in the future.
    return reg;
  } catch (err) {
    console.warn("[PWA] Service worker registration failed:", err?.message || err);
    return null;
  }
}

// Register the SW immediately on page load. Doesn't block anything; runs
// in parallel with React mount.
if (typeof window !== "undefined") {
  // Wait for window load so we don't compete with React's first paint
  if (document.readyState === "complete") {
    _registerServiceWorker();
  } else {
    window.addEventListener("load", _registerServiceWorker, { once: true });
  }
}

// Helper: base64url → Uint8Array, needed to convert the VAPID public key
// (which the server emits as base64url) into the BufferSource format that
// pushManager.subscribe() expects.
function _urlBase64ToUint8Array(base64String) {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  const raw = atob(base64);
  const out = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
  return out;
}

// Attempt to subscribe to push. Idempotent — if a subscription already
// exists, we POST it to the server again (cheap; covers the case where
// the browser rotated the endpoint and the server's record is stale).
// Only callable from within a user-gesture handler since some browsers
// require that for permission requests.
async function _subscribeToPush() {
  if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
    return { ok: false, reason: "unsupported" };
  }
  const reg = _swRegistration || (await _registerServiceWorker());
  if (!reg) return { ok: false, reason: "no_sw" };

  // Permission flow: if denied, we're stuck — only user can change it in
  // browser settings. If default, ask. If granted, proceed.
  if (Notification.permission === "denied") return { ok: false, reason: "denied" };
  if (Notification.permission === "default") {
    const perm = await Notification.requestPermission();
    if (perm !== "granted") return { ok: false, reason: "denied" };
  }

  // Existing subscription? Reuse.
  let sub = await reg.pushManager.getSubscription();
  if (!sub) {
    // Fetch VAPID public key from server; it's per-deployment, not per-tenant.
    let publicKey;
    try {
      const r = await fetch("/api/patient/push/vapid-key", { credentials: "same-origin" });
      if (!r.ok) return { ok: false, reason: "vapid_fetch_failed" };
      const data = await r.json();
      publicKey = data.publicKey;
    } catch (err) {
      return { ok: false, reason: "vapid_fetch_error" };
    }
    if (!publicKey) return { ok: false, reason: "no_vapid_key" };

    try {
      sub = await reg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: _urlBase64ToUint8Array(publicKey),
      });
    } catch (err) {
      console.warn("[PWA] push subscribe failed:", err?.message || err);
      return { ok: false, reason: "subscribe_failed" };
    }
  }

  // POST subscription to server (binds to current patient session)
  try {
    const r = await fetch("/api/patient/push/subscribe", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ subscription: sub.toJSON ? sub.toJSON() : sub }),
    });
    if (!r.ok) {
      const body = await r.json().catch(() => ({}));
      return { ok: false, reason: body?.error || "save_failed" };
    }
    return { ok: true };
  } catch (err) {
    return { ok: false, reason: "post_failed" };
  }
}

// Read/write a single localStorage flag we use to remember "user dismissed
// the enable-notifications banner this session". Survives reloads of the
// same PWA install but not a fresh install. We don't store anything
// identifying — just a "dismissed-at" timestamp.
const PUSH_BANNER_DISMISS_KEY = "leadly:push_banner_dismissed_at";
function _pushBannerWasDismissed() {
  try {
    const v = localStorage.getItem(PUSH_BANNER_DISMISS_KEY);
    if (!v) return false;
    // Re-show after 7 days so we get another chance if they've actively
    // continued using the app.
    return (Date.now() - Number(v)) < 7 * 24 * 3600 * 1000;
  } catch { return false; }
}
function _markPushBannerDismissed() {
  try { localStorage.setItem(PUSH_BANNER_DISMISS_KEY, String(Date.now())); } catch {}
}

// React hook: integrates the SW + push lifecycle with the App component.
// Returns { shouldShowPrompt, enableNotifications, dismissPrompt }.
// App renders a banner using these when shouldShowPrompt is true.
function usePushNotifications(session, exchangeCount) {
  const [permission, setPermission] = useState(
    (typeof Notification !== "undefined") ? Notification.permission : "denied"
  );
  const [bannerDismissed, setBannerDismissed] = useState(_pushBannerWasDismissed);
  const [busy, setBusy] = useState(false);

  // When the patient becomes authenticated, attempt a silent re-subscribe.
  // This catches the case where they already granted permission earlier
  // (different device, different visit) and we just need to refresh the
  // server's record of their subscription endpoint.
  useEffect(() => {
    if (!session || session.anonymous || !session.phone) return;
    if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
    // Permission already granted — silently re-sync the subscription
    _subscribeToPush().catch(() => {});
  }, [session?.phone, session?.anonymous]);

  // Heuristic for "should we ask?":
  //   - User is authenticated (phone claimed)
  //   - They've had at least 3 message exchanges (so they're engaged)
  //   - Permission is still "default" (we haven't asked yet)
  //   - They haven't dismissed the banner in the last 7 days
  //   - Browser supports it
  const supported = typeof Notification !== "undefined" && "serviceWorker" in navigator && "PushManager" in window;
  const shouldShowPrompt =
    supported &&
    !!session && !session.anonymous && !!session.phone &&
    exchangeCount >= 3 &&
    permission === "default" &&
    !bannerDismissed;

  const enableNotifications = useCallback(async () => {
    if (busy) return;
    setBusy(true);
    try {
      const r = await _subscribeToPush();
      // Reflect final permission state regardless of whether subscribe succeeded
      if (typeof Notification !== "undefined") setPermission(Notification.permission);
      if (!r.ok) {
        // Soft-dismiss the banner so we don't loop the user — they can re-enable
        // later from a settings menu (future work).
        _markPushBannerDismissed();
        setBannerDismissed(true);
      } else {
        // Successful subscribe — also mark dismissed so the banner goes away
        _markPushBannerDismissed();
        setBannerDismissed(true);
      }
    } finally {
      setBusy(false);
    }
  }, [busy]);

  const dismissPrompt = useCallback(() => {
    _markPushBannerDismissed();
    setBannerDismissed(true);
  }, []);

  return { shouldShowPrompt, enableNotifications, dismissPrompt, busy };
}

// SW→page message handling: SW dispatches { type:"navigate", url } when
// a notification is clicked and the focused tab needs to deep-link. The
// App listens for these and pushes a route change; for now the chat
// lives at "/" so we just no-op, but the hook is here for future routes.
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
  navigator.serviceWorker.addEventListener("message", (evt) => {
    const data = evt.data;
    if (!data || typeof data !== "object") return;
    if (data.type === "navigate" && typeof data.url === "string") {
      // Single-page app, single route → ignore. Future: route to a deep link.
    }
  });
}

function App() {
  const [session, setSession] = useState(null);
  // ── STALE-CLOSURE GUARD (May 19, 2026) ────────────────────────────────
  // handleWsMessage below is useCallback([]) — empty deps array. Without
  // this ref, its closure captures the INITIAL session value (null) and
  // never updates. That made the session-fallback condition
  // `clientThinksAnon = !session || session.anonymous` permanently true,
  // so every routine WS reconnect of an already-phone-bound session
  // mistakenly re-fired Api.me() → setSession() → session-promotion
  // useEffect → IIFE → setMessages(seeded), creating race-window for
  // duplicates. The ref is read inside handleWsMessage (sessionRef.current)
  // instead of the captured `session`.
  const sessionRef = useRef(null);
  sessionRef.current = session;
  const [loading, setLoading] = useState(true);
  const [unauthorized, setUnauthorized] = useState(false);
  const [messages, setMessages] = useState([]);
  const [isTyping, setIsTyping] = useState(false);
  const [connectionStatus, setConnectionStatus] = useState("connecting");
  const [clinicSwitcherOpen, setClinicSwitcherOpen] = useState(false);
  const [clinics, setClinics] = useState([]);
  const [imagePreview, setImagePreview] = useState(null);
  // ── Reply-to state (May 18, 2026) ─────────────────────────────────────
  // Set when the user swipes/long-presses a past bubble. The composer
  // renders a quote strip above the input showing the snippet. When the
  // user sends, this gets attached to the outgoing message and cleared.
  const [replyTo, setReplyTo] = useState(null);  // { id, role, snippet } | null

  // ── Push notifications (May 21, 2026) ─────────────────────────────────
  // The SW is registered at module load. Push subscription is gated on
  // (a) phone-claimed and (b) the patient having had a few exchanges, so
  // we don't ambush first-time visitors with a permission prompt. The
  // hook returns { shouldShowPrompt, enableNotifications, dismissPrompt };
  // the banner renders below when shouldShowPrompt is true.
  const userMessageCount = useMemo(
    () => messages.filter(m => m.role === "user").length,
    [messages]
  );
  const push = usePushNotifications(session, userMessageCount);

  // ─── INSTALL PROMPT (PWA Add-to-Home-Screen) ──────────────────────────
  // We capture the browser's beforeinstallprompt event on mount and stash
  // the prompt object so a tap on the install-prompt widget (rendered
  // inline in the chat after the first booking confirmation) can fire it.
  // Safari/iOS doesn't fire this event — the widget falls back to step-by-
  // step visual instructions in that case.
  const [deferredPrompt, setDeferredPrompt] = useState(null);
  const [isStandalone, setIsStandalone] = useState(false);
  const [dismissedInstallIds, setDismissedInstallIds] = useState(() => new Set());

  useEffect(() => {
    // Detect if we're already running as an installed PWA. Both modern
    // browsers (display-mode: standalone) and iOS Safari (navigator.standalone).
    const checkStandalone = () => {
      const mq = typeof window !== "undefined" && window.matchMedia
        ? window.matchMedia("(display-mode: standalone)").matches
        : false;
      const iosStandalone = typeof navigator !== "undefined" && navigator.standalone === true;
      return mq || iosStandalone;
    };
    setIsStandalone(checkStandalone());

    const handleBeforeInstall = (e) => {
      // Prevent the mini-infobar Chrome shows by default. We render our
      // own bubble UI when the right moment comes (after first booking).
      e.preventDefault();
      setDeferredPrompt(e);
    };
    const handleInstalled = () => {
      setIsStandalone(true);
      setDeferredPrompt(null);
    };
    window.addEventListener("beforeinstallprompt", handleBeforeInstall);
    window.addEventListener("appinstalled", handleInstalled);
    return () => {
      window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
      window.removeEventListener("appinstalled", handleInstalled);
    };
  }, []);

  const installPromptCtx = useMemo(() => ({
    deferredPrompt,
    isStandalone,
    onInstalled: () => { setIsStandalone(true); setDeferredPrompt(null); },
    onDismissed: (id) => setDismissedInstallIds(prev => {
      const next = new Set(prev); next.add(id); return next;
    }),
    dismissedIds: dismissedInstallIds,
  }), [deferredPrompt, isStandalone, dismissedInstallIds]);

  // ─── PWA BACK-BUTTON TRAP & ROBUSTNESS ──────────────────────────────────
  //
  // Problem we're solving: when the PWA is installed and the user opens it
  // from their home screen, the browser's history stack still contains
  // whatever they navigated through to get to /c/{slug} (typically the
  // Leadly homepage). Pressing the system back button on Android then
  // exits the PWA back to that page — which is jarring because they
  // expect the installed app to behave like an app, not a webpage.
  //
  // The fix is a sentinel-state trap: on mount we pushState a marker; on
  // popstate (back pressed) we re-push. The user is held inside the app
  // until they explicitly exit via the home button or app switcher.
  //
  // We layer overlay-closing on top: if any overlay (image preview,
  // attach menu, clinic switcher, iOS install steps) is open when back
  // is pressed, that overlay closes FIRST and the popstate is consumed.
  // Only on a second press (with no overlay open) does back become a
  // true no-op. This matches native app behavior.
  //
  // Mechanism: we dispatch a cancelable `pwa:back` CustomEvent on the
  // window. Each overlay-owning component adds a capture-phase listener
  // that closes itself and calls preventDefault(). If preventDefault was
  // called, we know an overlay handled it. If not, we re-push and stay.
  //
  // We ONLY engage the trap in standalone mode. In the browser the back
  // button should keep its normal behavior — that's what users expect
  // on a webpage and disabling it would feel broken/buggy. We re-check
  // the display-mode on visibilitychange in case the user installs while
  // the page is open (rare but possible).
  useEffect(() => {
    if (!isStandalone) return;

    const SENTINEL = "__pwaBackSentinel";

    // Push the sentinel if it isn't already there. We check history.state
    // because if the user reloads, we don't want to push a SECOND sentinel
    // on top of the first — that would mean two back presses to escape
    // the trap (once to consume the extra sentinel, once for the real one),
    // which leaks history depth over time.
    const currentState = window.history.state || {};
    if (!currentState[SENTINEL]) {
      window.history.pushState({ ...currentState, [SENTINEL]: true }, "", window.location.href);
    }

    const onPopState = (e) => {
      // Browser fired popstate because the user pressed back. The current
      // history entry is now the entry BEFORE our sentinel. Dispatch the
      // cancelable event first to give any open overlay a chance to close.
      const evt = new CustomEvent("pwa:back", { cancelable: true });
      window.dispatchEvent(evt);

      // Re-push the sentinel either way:
      //   - If an overlay handled it (preventDefault): we still need the
      //     trap in place for the NEXT back press.
      //   - If nothing handled it: we want to stay on this URL.
      // pushState always advances; that's correct because popstate already
      // moved us back one entry, so re-pushing gets us back to where we
      // were. Net effect: zero navigation, but the stack stays at [real,
      // sentinel] forever (instead of growing).
      window.history.pushState({ ...(window.history.state || {}), [SENTINEL]: true }, "", window.location.href);
    };

    window.addEventListener("popstate", onPopState);
    return () => {
      window.removeEventListener("popstate", onPopState);
    };
  }, [isStandalone]);

  // Re-check standalone mode when the page becomes visible again. This
  // catches the case where the user installed the PWA in one tab and the
  // already-open PWA tab/window updates its state without a reload.
  useEffect(() => {
    const recheck = () => {
      const mq = typeof window !== "undefined" && window.matchMedia
        ? window.matchMedia("(display-mode: standalone)").matches
        : false;
      const ios = typeof navigator !== "undefined" && navigator.standalone === true;
      const now = mq || ios;
      setIsStandalone(prev => prev !== now ? now : prev);
    };
    // Also catch display-mode changes (user switches between browser and
    // installed PWA on the same URL — possible on desktop Chrome).
    const mqList = typeof window !== "undefined" && window.matchMedia
      ? window.matchMedia("(display-mode: standalone)")
      : null;
    document.addEventListener("visibilitychange", recheck);
    if (mqList && mqList.addEventListener) mqList.addEventListener("change", recheck);
    return () => {
      document.removeEventListener("visibilitychange", recheck);
      if (mqList && mqList.removeEventListener) mqList.removeEventListener("change", recheck);
    };
  }, []);

  // ─── BODY CLASS: reflect display mode in CSS ────────────────────────────
  // CSS rules that should only apply when running as an installed PWA
  // (e.g. no-user-select on chrome elements) hang off `body.is-standalone`.
  // This avoids the "feels like a webpage" effect that the no-select rule
  // would cause when the user is browsing in a normal tab.
  useEffect(() => {
    if (typeof document === "undefined") return;
    if (isStandalone) document.body.classList.add("is-standalone");
    else              document.body.classList.remove("is-standalone");
  }, [isStandalone]);

  // ─── BACK-BUTTON: close App-level overlays before the trap re-pushes ──
  //
  // VERIFICATION STATE declared up here (not lower with the rest of state)
  // because the back-button useEffect below references it in both its
  // body and its deps array. JavaScript `const` is not hoisted, so the
  // useState() declaration MUST appear before the useEffect that depends
  // on it — otherwise the entire App component throws a ReferenceError
  // ("Cannot access 'verifyPending' before initialization") on first
  // render, React unmounts the tree, and the user sees a blank screen
  // (only the static HTML skeleton remains).
  // null         — no verification in progress
  // { code, waLink, verifyNumber, expiresAt, phone } — verification screen
  //                shown, polling /api/patient/verify-status every 2s
  // When verification completes, we reload Api.me() to refresh `session`
  // and the chat continues with phone-bound mode.
  const [verifyPending, setVerifyPending] = useState(null);
  const [verifyError, setVerifyError] = useState(null);
  const [verifyErrorDetails, setVerifyErrorDetails] = useState(null);

  //
  // When pwa:back fires, this handler runs first (capture phase). If an
  // App-level overlay is open, close it AND preventDefault so the trap
  // knows the back was consumed. Order of close precedence (from most
  // visually dominant to least):
  //   1. Image preview (fullscreen lightbox)
  //   2. Clinic switcher (drawer)
  // Child components (Composer's attach menu, InstallPrompt's iOS steps)
  // register their own listeners. The first one that calls preventDefault
  // wins; the rest no-op for this press.
  useEffect(() => {
    const onBack = (e) => {
      if (imagePreview) {
        setImagePreview(null);
        e.preventDefault();
        return;
      }
      if (clinicSwitcherOpen) {
        setClinicSwitcherOpen(false);
        e.preventDefault();
        return;
      }
      // verification screen — let the user back out of it. They can re-trigger
      // by typing their phone again.
      if (verifyPending) {
        setVerifyPending(null);
        setVerifyError(null);
        setVerifyErrorDetails(null);
        e.preventDefault();
        return;
      }
    };
    // Capture phase so this runs before any deeper-mounted listener. We want
    // App-level overlays to close in preference to child-level ones (an open
    // image lightbox is more visible than a half-open attach menu).
    window.addEventListener("pwa:back", onBack, true);
    return () => window.removeEventListener("pwa:back", onBack, true);
  }, [imagePreview, clinicSwitcherOpen, verifyPending]);

  const messagesEndRef = useRef(null);
  const typingTimeoutRef = useRef(null);

  // Tracks whether we've ever seen an anonymous session in this React
  // instance. The thanks-after-verification message fires only when we
  // observe the transition anon → phone-bound (not on initial mount of
  // an already-claimed session). Used by the effect below.
  const sawAnonymousRef = useRef(false);
  // ─── POST-VERIFY THANKS — SESSION-PROMOTION DETECTOR ────────────────
  // Three independent paths can complete an OTP flow:
  //   1. WS `verify_complete` message (server pushes; can miss if the
  //      anon WS is mid-close during the anon→phone transition)
  //   2. /api/patient/verify-status polling returning "verified"
  //      (can miss if the patient backgrounded the tab and polling
  //      paused, then the modal was closed by a different path)
  //   3. Api.me() returning a phone-bound session where previously
  //      anonymous (this effect — runs after EVERY session change)
  // Path 3 is the safety net. If 1 or 2 already injected the thanks
  // message, this no-ops (id-dedup). If both failed, this catches it.
  useEffect(() => {
    if (!session) return;
    if (session.anonymous) {
      sawAnonymousRef.current = true;
      return;
    }
    // Phone-bound session — check if this is a fresh promotion from
    // anonymous (we'd remember sawAnonymous=true) OR if the user just
    // landed on a phone-bound session for the first time this mount
    // (sawAnonymous still false). Only inject in the FIRST case.
    if (!sawAnonymousRef.current) return;

    // After a fresh anon→phone-bound transition, the server may have
    // history on file for this phone — e.g. the patient used WhatsApp
    // with this clinic before, then opened the PWA and verified. Pull
    // history once now so the chat hydrates with their full conversation
    // instead of looking like a blank slate. If history is empty (truly
    // brand-new patient), we'll just inject the post-verify thanks
    // message below as the only entry.
    //
    // IMPORTANT: the WS `verify_complete` handler may have already added
    // a personalized "أهلاً يا أستاذ Ahmed ✅" bubble before this effect
    // runs. A naive `setMessages(seeded)` here wipes that bubble — the
    // patient experienced "verification happened then nothing changed,
    // had to type Hello? to get a response." Fix: preserve the
    // post-verify-thanks bubble by appending it AFTER the seeded
    // history (it belongs to this session moment, not the historical
    // archive, so it sits at the end).
    (async () => {
      try {
        const histResult = await Api.history();
        if (histResult.ok && histResult.messages && histResult.messages.length > 0) {
          const seeded = histResult.messages.map((m, i) => {
            const entry = {
              // Same id-propagation as the BOOT effect — server-provided
              // stable ids (like "post-verify-thanks") win over hist-N.
              id:     m.id || `hist-${i}`,
              role:   m.role,
              body:   m.content,
              ts:     m.ts || new Date(0).toISOString(),
              status: "read",
            };
            // Preserve rich-render fields so hydrated messages look the
            // same as their live counterparts:
            //   - kind:           install_prompt / verify_prompt → widget renders
            //   - replyTo:        swipe-reply quote → header renders on the bubble
            //   - imageUrl:       patient's uploaded photo → <img> renders
            //   - imageDeleted:   doctor wiped this photo → placeholder renders
            //   - imageMessageId: stable ID linking bubble ↔ patient record
            if (m.kind)           entry.kind           = m.kind;
            if (m.replyTo)        entry.replyTo        = m.replyTo;
            if (m.imageUrl)       entry.imageUrl       = m.imageUrl;
            if (m.imageMessageId) entry.imageMessageId = m.imageMessageId;
            if (m.imageDeleted)   entry.imageDeleted   = m.imageDeleted;
            return entry;
          });
          // ── DEDUP THE POST-VERIFY-THANKS BUBBLE (May 19, 2026) ─────────
          // The verify_complete WS handler injects a local bubble with
          // id "post-verify-thanks". The OTP-verify webhook ALSO
          // persists the same thanks body to session.history with the
          // same stable id. If we naively appended the local bubble
          // alongside the seeded entry, we'd get two identical
          // bubbles. Logic:
          //   - If seeded already contains "post-verify-thanks":
          //     server has persisted it. Drop the local bubble — the
          //     seeded entry is canonical (it'll survive refresh).
          //   - Otherwise: keep the local bubble. Server persistence
          //     either hasn't fired yet (race) or failed (no
          //     resolvedTenantObj). The local bubble is the user's
          //     only evidence the verify worked.
          setMessages(prev => {
            const localThanks = prev.find(m => m.id === "post-verify-thanks");
            const seededHasThanks = seeded.some(s => s.id === "post-verify-thanks");
            // ── DIAG (May 19, 2026) ──────────────────────────────────────
            // Surface which dedup branch this IIFE invocation took so if
            // the duplicate bubble recurs after the stale-closure fix,
            // the render log will tell us exactly what went wrong.
            console.log("[DIAG dedup-thanks] session-promotion IIFE setMessages:", {
              prevLen:           prev.length,
              prevHasLocalThanks:!!localThanks,
              seededLen:         seeded.length,
              seededHasThanks,
              branch:            !localThanks ? "return seeded (no local)"
                                 : seededHasThanks ? "return seeded (drop local)"
                                 : "append local to seeded (server missing thanks-id)",
              ts:                new Date().toISOString(),
            });
            if (!localThanks) return seeded;
            return seededHasThanks ? seeded : [...seeded, localThanks];
          });
        }
      } catch (err) {
        console.warn("[App] post-verify history fetch failed (non-fatal):", err.message);
      }
    })();

    setMessages(prev => {
      if (prev.some(m => m.id === "post-verify-thanks")) return prev;
      return [...prev, {
        id:     "post-verify-thanks",
        role:   "assistant",
        body:   "تمام، تم تأكيد رقمك ✅\nإزاي أقدر أساعدك دلوقتي؟",
        ts:     new Date().toISOString(),
        status: "read",
      }];
    });
  }, [session]);

  // ─── BOOT ───────────────────────────────────────────────────────────
  useEffect(() => {
    (async () => {
      const result = await Api.me();
      if (result.unauthorized) {
        setUnauthorized(true);
        setLoading(false);
        return;
      }
      if (result.ok) {
        setSession(result);
        // Seed welcome message ONLY for authenticated sessions. For anonymous
        // sessions (no phone bound yet), the WebSocket layer pushes a
        // hardcoded greeting that asks for the phone — if we ALSO seed a
        // client-side welcome here, the patient sees two greetings back-to-
        // back ("welcome, how can I help?" + "send me your phone first"),
        // which looks broken.
        if (!result.anonymous) {
          // ── HISTORY HYDRATION (May 15, 2026) ─────────────────────────
          // Before May 15 the PWA was history-amnesiac: every open showed
          // only NEW messages from this session. Closing and reopening
          // the app, or opening on a different device, lost all visible
          // history.
          //
          // Now: fetch /api/patient/history. If the server has any past
          // turns for this patient with this clinic, render them as the
          // initial messages list. Otherwise fall through to the canned
          // welcome message.
          //
          // The history is the same data the model sees, so the patient's
          // own messages (sent from any prior device) appear alongside
          // the assistant's replies — fixing the "looks like the bot is
          // talking to itself" effect on cross-device opens.
          const histResult = await Api.history();
          if (histResult.ok && histResult.messages && histResult.messages.length > 0) {
            // Convert server-shape {role, content, ts} → client-shape
            // {id, role, body, ts, status}. Server messages are all in
            // their final state — assistant messages are "read", user
            // messages are "read" (they were processed by the agent that
            // sent the reply). Generate stable IDs so re-renders don't
            // duplicate. Use a `hist-` prefix so we can recognize
            // hydrated messages later (e.g. to avoid re-rendering them
            // when WebSocket delivers a duplicate).
            // ── May 18, 2026 — preserve kind on hydration ─────────────────
            // The server now passes m.kind for tool-emitted messages that
            // need rich rendering (install_prompt, future: image, file, etc).
            // MessageBubble at line 566 checks msg.kind === "install_prompt"
            // to swap in the InstallPromptWidget instead of plain text.
            // Without kind in the hydrated entries, the install bubble
            // shows as plain text on PWA reopen with no install button.
            const seeded = histResult.messages.map((m, i) => {
              const entry = {
                // Use the server-provided stable id when present (e.g.
                // "post-verify-thanks" from the OTP-verify webhook).
                // Fallback to a positional hist-N id otherwise. Stable
                // ids let the verify_complete handler's local bubble
                // and the hydrated entry share a key — React's
                // reconciliation collapses the duplicate.
                id:     m.id || `hist-${i}`,
                role:   m.role,
                body:   m.content,
                ts:     m.ts || new Date(0).toISOString(),
                status: "read",
              };
              if (m.kind)           entry.kind           = m.kind;
              if (m.replyTo)        entry.replyTo        = m.replyTo;
              if (m.imageUrl)       entry.imageUrl       = m.imageUrl;
              if (m.imageMessageId) entry.imageMessageId = m.imageMessageId;
              if (m.imageDeleted)   entry.imageDeleted   = m.imageDeleted;
              return entry;
            });
            setMessages(seeded);
          } else {
            // No prior history — show the standard welcome.
            const isReturning = result.patient?.appointmentHistory?.length > 0;
            let greeting;
            if (isReturning && result.patient?.name) {
              const displayName = result.patient.firstName ||
                                  String(result.patient.name).trim().split(/\s+/)[0];
              const title = clientHonorific(displayName, result.patient.titlePrefix);
              greeting = `أهلاً يا ${title} ${displayName}\nمعاك ياسمين 🌸\nإزاي أقدر أساعدك النهارده؟`;
            } else {
              greeting = `أهلاً تاني في ${result.tenantName}\nمعاك ياسمين 🌸`;
            }
            setMessages([{
              id: "welcome",
              role: "assistant",
              body: greeting,
              ts: new Date().toISOString(),
              status: "read",
            }]);
          }
        }
      }
      setLoading(false);

      // Fetch clinic list in background
      const c = await Api.clinics();
      if (c.ok) setClinics(c.clinics || []);
    })();
  }, []);

  // ─── WEBSOCKET ──────────────────────────────────────────────────────
  const handleWsMessage = useCallback((msg) => {
    if (msg.type === "message" && msg.role === "assistant") {
      // Dedupe by id when present. Some server pushes use a stable id
      // ("post-verify-thanks", "welcome", etc.) to allow safe re-delivery
      // when WS reconnects mid-flow. Without this guard, a duplicate
      // push with the same id would stack identical bubbles on screen.
      setMessages(prev => {
        if (msg.id && prev.some(m => m.id === msg.id)) return prev;
        const next = {
          id: msg.id,
          role: "assistant",
          body: msg.body,
          ts: msg.ts || new Date().toISOString(),
          status: "read",
          // Pass through any special render-kind opt from the server
          // (e.g. kind:"install_prompt" → MessageBubble renders the
          // install widget below the message body).
          kind: msg.kind || null,
        };
        // Assistant rarely sends with replyTo today, but the API supports
        // it (server pushes can attach a quoted reference if a tool wants
        // to call out which prior message it's responding to).
        if (msg.replyTo) next.replyTo = msg.replyTo;
        return [...prev, next];
      });
      setIsTyping(false);
    } else if (msg.type === "message" && msg.role === "user") {
      // ── PATIENT-MESSAGE ECHO FROM ANOTHER DEVICE (May 15, 2026) ────
      // The same patient is logged in on another device (e.g. phone)
      // and just sent a message. The server echoed it here so this
      // client (e.g. laptop) shows the patient's bubble in real time
      // — without this, only the assistant's reply would appear and
      // the conversation would look like Yasmin monologuing.
      //
      // Dedup by id is critical because the SENDER's own device adds
      // the message locally via handleSend with the same id; if there
      // were ever a race where the sender's echo somehow came back to
      // them, this would prevent a duplicate bubble.
      setMessages(prev => {
        if (msg.id && prev.some(m => m.id === msg.id)) return prev;
        const next = {
          id: msg.id,
          role: "user",
          body: msg.body,
          ts: msg.ts || new Date().toISOString(),
          status: msg.status || "delivered",
        };
        // Preserve replyTo on the echo — the original sender's device
        // attached it when sending; without this, the other devices
        // would render the user bubble without its quote header.
        if (msg.replyTo) next.replyTo = msg.replyTo;
        return [...prev, next];
      });
    } else if (msg.type === "typing") {
      setIsTyping(msg.state === true);
      if (msg.state && typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
      if (msg.state) {
        typingTimeoutRef.current = setTimeout(() => setIsTyping(false), TYPING_INDICATOR_TIMEOUT);
      }
    } else if (msg.type === "receipt") {
      setMessages(prev => prev.map(m =>
        m.id === msg.messageId ? { ...m, status: msg.status } : m
      ));
    } else if (msg.type === "image_deleted") {
      // ── May 18, 2026 — Doctor wiped a photo (server-pushed live event) ────
      // Server received DELETE /api/doctor/patient/:phone/image/:messageId
      // and now broadcasts to the patient's open WS so the bubble updates
      // instantly. We swap the bubble's imageUrl for the placeholder text
      // and flag it as deleted so MessageBubble renders the "deleted"
      // state. If the patient isn't online when the delete fires, they
      // see the same placeholder on next chat reopen via the history
      // hydration path above (imageDeleted is preserved end-to-end).
      //
      // The match is on imageMessageId (preferred — stable across the
      // upload → persist → delete chain) with a fallback to msg.messageId
      // matching the bubble id directly for safety.
      setMessages(prev => prev.map(m => {
        const matches =
          (m.imageMessageId && m.imageMessageId === msg.messageId) ||
          (m.id === msg.messageId);
        if (!matches) return m;
        return {
          ...m,
          imageUrl: null,
          imageDeleted: true,
          body: msg.placeholder || "🗑️ تم حذف هذه الصورة",
        };
      }));
    } else if (msg.type === "session") {
      // ── May 18, 2026 — DIAGNOSTIC LOGGING ──────────────────────────────
      // Verify whether the new phone-bound WS's session message actually
      // reaches this handler. If "no thanks bubble after verification"
      // recurs, the console pattern tells us where the bug lives:
      //   - This log fires + verify_complete log doesn't → race confirmed,
      //     the session-promotion fallback below is what saves us.
      //   - Both logs fire → original path worked; the fallback is redundant
      //     for this run (but harmless — id-dedup on "post-verify-thanks").
      //   - Neither fires → deeper WS layer issue; bug is not what we think.
      console.log("[DIAG verify-flow] WS session message received:", {
        anonymous:    msg.session?.anonymous,
        phone:        msg.session?.phone,
        tenantId:     msg.session?.tenantId,
        connected:    msg.session?.connected,
        clientState:  sessionRef.current ? { anonymous: sessionRef.current.anonymous, phone: sessionRef.current.phone } : "null (Api.me pending)",
        ts:           new Date().toISOString(),
      });
      // ── SESSION-PROMOTION FALLBACK FOR LOST verify_complete ────────────
      // The original verify_complete WS push lives on the ANON socket. If
      // it gets lost when the anon socket closes during the reconnect to
      // the now-phone-bound socket, the React side never learns the
      // session promoted — no post-verify thanks bubble, no Api.me() refresh.
      //
      // This branch is the safety net. The new phone-bound WS sends a
      // `session` message with anonymous:false on connect. When we receive
      // it AND we still think we're anon locally, we refresh session via
      // Api.me() (the full session shape — wider than the WS payload, which
      // is missing patient/tenantSlug/tenantLogo). setSession then triggers
      // the post-verify-thanks effect at line 1279.
      //
      // Idempotent vs. the original verify_complete path:
      //   - If verify_complete DID arrive, it already called Api.me() + setSession.
      //     By the time this branch runs, session.anonymous is false →
      //     clientThinksAnon is false → skip. No double-refresh.
      //   - If verify_complete was lost, this branch fires Api.me() →
      //     setSession → effect at 1279 → thanks bubble (deduped by id).
      const serverSaysPhoneBound = msg.session && !msg.session.anonymous && msg.session.phone;
      // STALE-CLOSURE FIX (May 19, 2026): read via sessionRef.current, not
      // the closure-captured `session`. handleWsMessage is useCallback([])
      // so `session` here would forever be the initial null from useState,
      // making clientThinksAnon permanently true and firing this fallback
      // on every routine reconnect of an already-phone-bound session.
      const currentSession        = sessionRef.current;
      const clientThinksAnon      = !currentSession || currentSession.anonymous;
      if (serverSaysPhoneBound && clientThinksAnon) {
        console.log("[DIAG verify-flow] session-promotion fallback firing (verify_complete may have been lost)");
        (async () => {
          try {
            const fresh = await Api.me();
            if (fresh.ok) {
              setSession(fresh);
            } else {
              console.warn("[App] Api.me returned non-ok during session-promotion fallback:", fresh);
            }
          } catch (err) {
            console.warn("[App] Session-promotion Api.me refresh failed (non-fatal):", err.message);
          }
        })();
      }
    } else if (msg.type === "verify_required") {
      // Server says: phone needs to be verified via WhatsApp. We used to
      // pop a modal overlay here, but many patients dismissed the modal
      // out of instinct (assumed it was an ad/notification) and got stuck
      // — the verification flow died because they never saw the button.
      //
      // New design: inject an inline chat bubble that LOOKS like a normal
      // Yasmin message with an embedded code + "open WhatsApp" button. The
      // patient reads it at their own pace, taps the link in context, and
      // never encounters a blocking overlay. We still set verifyPending
      // so the 2-second polling loop runs (detects when the webhook
      // promotes the session).
      setVerifyError(null);
      setVerifyPending({
        code:         msg.code,
        verifyNumber: msg.verifyNumber,
        waLink:       msg.waLink,
        expiresAt:    msg.expiresAt,
        ttlSeconds:   msg.ttlSeconds,
        phone:        msg.phone,
      });
      // Inject the inline bubble. Stable id "verify-prompt-{code}" so
      // re-firing with the same code (rare — usually re-fire issues a NEW
      // code) doesn't double-render; a fresh code adds a new bubble.
      setMessages(prev => {
        const bubbleId = `verify-prompt-${msg.code}`;
        if (prev.some(m => m.id === bubbleId)) return prev;
        return [...prev, {
          id:           bubbleId,
          role:         "assistant",
          ts:           new Date().toISOString(),
          status:       "read",
          kind:         "verify_prompt",
          code:         msg.code,
          waLink:       msg.waLink,
          verifyNumber: msg.verifyNumber,
          phone:        msg.phone,
        }];
      });
    } else if (msg.type === "verify_complete") {
      // ── May 18, 2026 — DIAGNOSTIC LOGGING ──────────────────────────────
      // If this fires, the original verify_complete push WAS received and
      // the existing thanks-bubble logic should work. If it does NOT fire
      // but the "session" diagnostic above DOES, we've confirmed the race
      // (push lost on anon-WS close) and the planned fix is correct.
      console.log("[DIAG verify-flow] WS verify_complete received:", {
        phone:        msg.phone,
        hasThanksBody: !!msg.thanksBody,
        ts:           new Date().toISOString(),
      });
      // Webhook just verified the OTP. Close the modal and refresh the
      // session via Api.me() so the chat continues phone-bound.
      setVerifyPending(null);
      // Inject the thanks message locally as a fallback in case the
      // server's follow-up message push didn't reach this WS (e.g., if
      // the WS reconnected between the webhook firing and the message
      // push). The id is fixed ("post-verify-thanks") so a duplicate
      // server-push won't render twice — React's keying drops the
      // second one with the same id.
      //
      // Body comes from the server: if Yasmin knows the patient (returning
      // patient with a name on file) the server passes a personalized
      // greeting like "أهلاً يا أستاذ أحمد ✅ — تم تأكيد رقمك...". For
      // brand-new patients with no record, server sends the generic
      // "تمام، تم تأكيد رقمك..." string.
      const fallbackBody = msg.thanksBody || "تمام، تم تأكيد رقمك ✅\nإزاي أقدر أساعدك دلوقتي؟";
      setMessages(prev => {
        if (prev.some(m => m.id === "post-verify-thanks")) return prev;
        return [...prev, {
          id:     "post-verify-thanks",
          role:   "assistant",
          body:   fallbackBody,
          ts:     new Date().toISOString(),
          status: "read",
        }];
      });
      (async () => {
        const fresh = await Api.me();
        if (fresh.ok) setSession(fresh);
      })();
    } else if (msg.type === "verify_error") {
      // Verification rejected (sender_mismatch, code_not_recognized,
      // non_text_message, non_code_message, claim_failed, rate limits,
      // internal errors, etc.). Used to flip the modal to an error state;
      // now we inject an inline error bubble in the chat instead.
      // verifyPending stays cleared so polling stops.
      setVerifyPending(null);
      setVerifyError(null); // no modal — error is now in chat
      setVerifyErrorDetails(null);
      const errBody = buildVerifyErrorBody(msg.error || "unknown", {
        expected:      msg.expected      || null,
        got:           msg.got           || null,
        expectedLast4: msg.expectedLast4 || null,
        gotLast4:      msg.gotLast4      || null,
        messageType:   msg.messageType   || null,
      });
      setMessages(prev => [...prev, {
        id:     `verify-error-${Date.now()}-${Math.random().toString(36).slice(2,6)}`,
        role:   "assistant",
        body:   errBody,
        ts:     new Date().toISOString(),
        status: "read",
        kind:   "verify_error",
      }]);
    } else if (msg.type === "error") {
      console.warn("WS error:", msg.message);
    }
  }, []);

  const { send } = useWebSocket(handleWsMessage, setConnectionStatus);

  // ─── VERIFY POLLING ─────────────────────────────────────────────────
  // While the verify modal is shown, poll the server for status. This is
  // a fallback for the case where the WS connection drops between the
  // patient submitting their phone and the OTP arriving at the webhook —
  // the webhook's WS push wouldn't reach us, but the polling will.
  useEffect(() => {
    if (!verifyPending) return;
    if (!session?.sessionId) return;
    let cancelled = false;
    const tick = async () => {
      try {
        const r = await fetch(`/api/patient/verify-status?sessionId=${encodeURIComponent(session.sessionId)}`, {
          credentials: "same-origin",
        });
        if (!r.ok) return;
        const data = await r.json();
        if (cancelled) return;
        if (data.status === "verified") {
          setVerifyPending(null);
          // Fetch the now-promoted session first so we can build a
          // personalized post-verify message that matches what the server-
          // pushed bubble would have said. (WS push usually arrives first;
          // this is the fallback for when it doesn't.)
          const fresh = await Api.me();
          if (cancelled) return;
          if (fresh.ok) setSession(fresh);
          // Build the personalized body. The server applies the same
          // honorific logic on the WS-push path; we mirror just enough of
          // it client-side to keep the two channels visually identical.
          let thanksBody = "تمام، تم تأكيد رقمك ✅\nإزاي أقدر أساعدك دلوقتي؟";
          if (fresh.ok && fresh.patient && (fresh.patient.firstName || fresh.patient.name)) {
            const displayName = fresh.patient.firstName ||
                                String(fresh.patient.name).trim().split(/\s+/)[0];
            const title = clientHonorific(displayName, fresh.patient.titlePrefix);
            thanksBody = `أهلاً يا ${title} ${displayName} ✅\nتم تأكيد رقمك. إزاي أقدر أساعدك النهارده؟`;
          }
          setMessages(prev => {
            if (prev.some(m => m.id === "post-verify-thanks")) return prev;
            return [...prev, {
              id:     "post-verify-thanks",
              role:   "assistant",
              body:   thanksBody,
              ts:     new Date().toISOString(),
              status: "read",
            }];
          });
        } else if (data.status === "rejected") {
          // Explicit rejection — sender_mismatch, code_not_recognized,
          // non_text_message, non_code_message, claim_failed, etc.
          // Inject an inline error bubble (no modal). Stop polling.
          setVerifyPending(null);
          const errBody = buildVerifyErrorBody(data.reason || "rejected", {
            expected:      data.expected      || null,
            got:           data.got           || null,
            expectedLast4: data.expectedLast4 || null,
            gotLast4:      data.gotLast4      || null,
            messageType:   data.messageType   || null,
          });
          setMessages(prev => [...prev, {
            id:     `verify-error-${Date.now()}-${Math.random().toString(36).slice(2,6)}`,
            role:   "assistant",
            body:   errBody,
            ts:     new Date().toISOString(),
            status: "read",
            kind:   "verify_error",
          }]);
        } else if (data.status === "expired") {
          setVerifyPending(null);
          const errBody = buildVerifyErrorBody("expired", {});
          setMessages(prev => [...prev, {
            id:     `verify-error-${Date.now()}-${Math.random().toString(36).slice(2,6)}`,
            role:   "assistant",
            body:   errBody,
            ts:     new Date().toISOString(),
            status: "read",
            kind:   "verify_error",
          }]);
        }
        // "pending" → keep polling. "none" also falls through — that
        // can happen briefly after a Redis blip; the next tick recovers.
      } catch {
        // network blip — try again on the next tick
      }
    };
    const interval = setInterval(tick, 2000);
    tick(); // run immediately on mount
    return () => { cancelled = true; clearInterval(interval); };
  }, [verifyPending, session?.sessionId]);

  // ─── SCROLL TO BOTTOM ───────────────────────────────────────────────
  useEffect(() => {
    if (messagesEndRef.current) {
      messagesEndRef.current.scrollTop = messagesEndRef.current.scrollHeight;
    }
  }, [messages, isTyping]);

  // ─── SEND HANDLERS ──────────────────────────────────────────────────
  const handleSend = (text) => {
    const id = crypto.randomUUID ? crypto.randomUUID() : `m${Date.now()}`;
    const ts = new Date().toISOString();
    // Snapshot the current replyTo (may be null) so the outgoing message
    // carries the reference. Clear immediately after capture so the next
    // send doesn't accidentally include the same reference.
    const currentReplyTo = replyTo;
    setReplyTo(null);
    setMessages(prev => [...prev, {
      id, role: "user", body: text, ts, status: "sending",
      ...(currentReplyTo ? { replyTo: currentReplyTo } : {}),
    }]);
    const payload = { type: "message", id, body: text };
    if (currentReplyTo) payload.replyTo = currentReplyTo;
    const ok = send(payload);
    if (!ok) {
      setMessages(prev => prev.map(m =>
        m.id === id ? { ...m, status: "sending" } : m
      ));
    }
  };

  const handleSendImage = async (file) => {
    const localUrl = URL.createObjectURL(file);
    const id = `img-${Date.now()}`;
    const ts = new Date().toISOString();
    setMessages(prev => [...prev, {
      id, role: "user", imageUrl: localUrl, ts, status: "sending", uploading: true,
    }]);
    try {
      const result = await Api.uploadImage(file);
      if (!result.ok) throw new Error(result.error || "upload_failed");
      setMessages(prev => prev.map(m =>
        m.id === id ? { ...m, imageUrl: result.url, uploading: false, status: "delivered" } : m
      ));
      // Include vision description so the agent has context about what was
      // sent, not just a URL. Server-side analyseGeneralImage produces a
      // short Arabic description like "صورة لذراع بشعر بني داكن".
      // Also pass Instapay-detection flags through — server-side
      // analyseImage runs in parallel with general vision and surfaces
      // amount/ref/recipientMatch when the image looks like a receipt.
      send({
        type: "image",
        url: result.url,
        caption: "",
        description: result.description || null,
        imageType:   result.imageType   || null,
        isInstapay:             result.isInstapay   || false,
        instapayAmount:         result.instapayAmount || null,
        instapayRef:            result.instapayRef    || null,
        instapayRecipientMatch: result.instapayRecipientMatch ?? null,
      });
    } catch (err) {
      console.error("Image upload failed:", err);
      setMessages(prev => prev.map(m =>
        m.id === id ? { ...m, status: "failed", error: true } : m
      ));
    }
  };

  const handleSendVoice = async (blob, durationSec) => {
    const localUrl = URL.createObjectURL(blob);
    const id = `voice-${Date.now()}`;
    const ts = new Date().toISOString();
    setMessages(prev => [...prev, {
      id, role: "user", audioUrl: localUrl, audioDuration: durationSec, ts, status: "sending",
    }]);
    try {
      const result = await Api.uploadAudio(blob);
      if (!result.ok) throw new Error(result.error || "upload_failed");
      setMessages(prev => prev.map(m =>
        m.id === id ? { ...m, audioUrl: result.url, status: "delivered" } : m
      ));
      send({
        type: "voice",
        mediaUrl: result.url,
        transcribedText: result.transcribedText || null,
      });
    } catch (err) {
      console.error("Voice upload failed:", err);
      setMessages(prev => prev.map(m =>
        m.id === id ? { ...m, status: "failed", error: true } : m
      ));
    }
  };

  // ─── CLINIC SWITCH ──────────────────────────────────────────────────
  const handleClinicPick = async (clinic) => {
    if (clinic.tenantId === session?.tenantId) {
      setClinicSwitcherOpen(false);
      return;
    }
    const result = await Api.switchClinic(clinic.tenantId);
    if (result.ok) {
      // Reload to refetch session data scoped to the new tenant
      window.location.href = `/c/${clinic.slug}`;
    } else {
      alert("معلش، مش قادرين نحول العيادة دلوقتي");
      setClinicSwitcherOpen(false);
    }
  };

  // ─── RENDER ─────────────────────────────────────────────────────────
  if (loading) {
    return (
      <div className="boot-loader">
        <div className="boot-spinner" />
        <div className="boot-text">جاري التحميل...</div>
      </div>
    );
  }

  if (unauthorized) {
    return (
      <div style={{
        display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
        height: "100vh", padding: "20px", textAlign: "center"
      }}>
        <div style={{ fontSize: "56px", marginBottom: "12px" }}>🔒</div>
        <h2 style={{ color: "var(--text)", marginBottom: "8px" }}>الرابط منتهي الصلاحية</h2>
        <p style={{ color: "var(--text-muted)", marginBottom: "24px", lineHeight: 1.6 }}>
          الرابط اللي استخدمتيه مش شغّال.<br />
          ابعتي رسالة للعيادة على واتساب وهنبعتلك رابط جديد.
        </p>
        <div className="powered-by">Powered by Leadly</div>
      </div>
    );
  }

  return (
    <div className="app">
      <Header
        session={session}
        connectionStatus={connectionStatus}
        onOpenClinicSwitcher={() => setClinicSwitcherOpen(true)}
      />

      {connectionStatus === "disconnected" && (
        <div className="conn-banner">جاري إعادة الاتصال...</div>
      )}
      {connectionStatus === "error" && (
        <div className="conn-banner conn-banner-error" onClick={() => window.location.reload()}>
          مش قادر أتصل بالعيادة دلوقتي — اضغط لإعادة التحميل
        </div>
      )}

      <div className="messages" ref={messagesEndRef}>
        <div className="messages-inner">
          {push.shouldShowPrompt && (
            <div className="push-banner">
              <div className="push-banner-icon">🔔</div>
              <div className="push-banner-body">
                <div className="push-banner-title">يوصلك إشعار لما العيادة ترد عليكِ</div>
                <div className="push-banner-sub">حتى لو الشات مقفول. هتقدري توقفيها أي وقت من إعدادات الموبايل.</div>
              </div>
              <div className="push-banner-actions">
                <button
                  type="button"
                  className="push-banner-enable"
                  onClick={push.enableNotifications}
                  disabled={push.busy}
                >
                  {push.busy ? "..." : "فعّلي"}
                </button>
                <button
                  type="button"
                  className="push-banner-dismiss"
                  onClick={push.dismissPrompt}
                  aria-label="إغلاق"
                >
                  ×
                </button>
              </div>
            </div>
          )}
          {messages.length > 0 && (
            <div className="day-divider">{formatDayLabel(messages[0]?.ts)}</div>
          )}
          {messages.map(m => (
            <MessageBubble
              key={m.id}
              msg={m}
              onImageClick={setImagePreview}
              installPromptCtx={installPromptCtx}
              onReply={(target) => {
                // Build a short snippet from the message body for the quote
                // preview. For media messages without text, use a fallback
                // label so the user still sees what they're replying to.
                let snippet;
                if (target.body)            snippet = target.body;
                else if (target.imageUrl)   snippet = "📷 صورة";
                else if (target.audioUrl)   snippet = "🎤 رسالة صوتية";
                else                        snippet = "—";
                // Cap at ~80 chars for compact display + WS payload size.
                if (snippet.length > 80) snippet = snippet.slice(0, 77) + "…";
                setReplyTo({
                  id:      target.id,
                  role:    target.role,
                  snippet: snippet.replace(/\s+/g, " ").trim(),
                });
              }}
            />
          ))}
          {isTyping && <TypingIndicator />}
        </div>
      </div>

      {/* Suggested-reply chips: visible only when the chat is "empty" for the
          patient — Yasmin's opener is the only thing on screen, and the
          patient hasn't typed anything yet. The chips disappear once they
          do, never to return (we don't want them re-appearing mid-chat). */}
      {!messages.some(m => m.role === "user") && (
        <SuggestedChips
          suggestions={session?.suggestedReplies || DEFAULT_SUGGESTED_REPLIES}
          onPick={handleSend}
        />
      )}

      <Composer
        onSend={handleSend}
        onSendImage={handleSendImage}
        onSendVoice={handleSendVoice}
        disabled={connectionStatus !== "connected"}
        replyTo={replyTo}
        onCancelReply={() => setReplyTo(null)}
      />

      <div className="powered-by">Powered by <a href="https://leadly-egypt.com" target="_blank" rel="noopener">Leadly</a></div>

      <ClinicSwitcher
        open={clinicSwitcherOpen}
        onClose={() => setClinicSwitcherOpen(false)}
        clinics={clinics}
        activeTenantId={session?.tenantId}
        onPick={handleClinicPick}
      />

      {imagePreview && (
        <div className="image-modal" onClick={() => setImagePreview(null)}>
          <img src={imagePreview} alt="" />
          <button className="image-modal-close" onClick={() => setImagePreview(null)}>
            <Icons.close />
          </button>
        </div>
      )}

      </div>
  );
}

// ─── MOUNT ──────────────────────────────────────────────────────────────────
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
