// pages-comm.jsx — Chats + Checks (NEW)

const { useState: useStateC, useMemo: useMemoC, useRef: useRefC, useEffect: useEffectC } = React;

/* ============================
   CHATS
============================ */

// PHASE D — визуальное выделение «тяжёлых» клиентов по кол-ву сообщений.
// Чем больше сожжено токенов/времени — тем заметнее. Без сложного дизайна.
function _heavyMessagesClass(n) {
  const m = Number(n) || 0;
  if (m >= 1000) return "bg-rose-500/20 text-rose-600 font-bold ring-1 ring-rose-500/40";
  if (m >= 500) return "bg-orange-500/20 text-orange-700 font-semibold";
  if (m >= 250) return "bg-amber-500/20 text-amber-600";
  if (m >= 100) return "bg-yellow-500/15 text-yellow-700";
  return "text-zinc-500";
}

function ChatsPage() {
  // ВНИМАНИЕ ЗА РЕФАКТОРИНГОМ:
  // Это первая страница мигрированная на runtime.js (Фаза 2).
  // Раньше тут был useEffect+setInterval+stopped с ручным polling 20с.
  // Теперь useQuery делает всё: dedupe, AbortController, polling через
  // глобальный scheduler, visibility-pause. UI state (sort/filter/search/
  // selected) остаётся локальным — это правильно, это user intent а не data.
  const [sort, setSort] = useStateC("recent");   // recent (last_message) | messages
  const [direction, setDirection] = useStateC("desc");  // desc (больше/новее сверху) | asc
  const [filter, setFilter] = useStateC("all");  // all | blocked
  const [search, setSearch] = useStateC("");
  const [selected, setSelected] = useStateC(null);
  const [busyBlock, setBusyBlock] = useStateC(null);  // convId в процессе block/unblock

  const scrollMemoryRef = useRefC(new Map());

  // Фильтрация/сортировка/поиск на СЕРВЕРЕ (полный dataset). Сортировка — по
  // времени или по кол-ву сообщений, направление desc/asc (toggle).
  const serverSort = sort === "messages" ? "messages" : "last_message";
  const blockedOnly = filter === "blocked";

  const fetchChatsList = React.useCallback(
    (signal) => fetchChats(500, {
      signal, sort: serverSort, direction, blockedOnly, q: search,
    }),
    [serverSort, direction, blockedOnly, search]
  );
  const { data: chatsData, loading, error, refetch } = useQuery(
    ["chats", "list", serverSort, direction, filter, search],
    fetchChatsList,
    { pollInterval: 3_000 } // лайв-список диалогов (было 20с)
  );
  const chats = chatsData || [];

  // PHASE C/D — Block / Unblock без перезагрузки страницы.
  const handleBlockToggle = React.useCallback(async (chat) => {
    if (!chat || busyBlock) return;
    setBusyBlock(chat.id);
    try {
      if (chat.blocked) await unblockChat(chat.id);
      else await blockChat(chat.id);
      await refetch();  // обновить актуальный blocked-статус
    } catch (e) {
      console.error("block toggle failed", e);
    } finally {
      setBusyBlock(null);
    }
  }, [busyBlock, refetch]);

  // Sprint α — counts по всему dataset (не зависит от limit). Раньше
  // «Всего чатов: 100» залипало потому что считалось как chats.length.
  // Polling реже (30 сек) — totals меняются медленно.
  const fetchCounts = React.useCallback(
    (signal) => fetchChatsCounts({ signal }),
    []
  );
  const { data: countsData } = useQuery(
    ["chats", "counts"],
    fetchCounts,
    { pollInterval: 30_000 }
  );

  // Auto-select первого чата при первой загрузке — раньше это было
  // внутри refresh(). Теперь — отдельный эффект на смену списка.
  React.useEffect(() => {
    if (!selected && chats.length > 0) {
      setSelected(chats[0].id);
    }
  }, [chats, selected]);

  // Фильтрация/сортировка/поиск делаются на сервере (см. fetchChatsList).
  const filtered = chats;

  const stats = useMemoC(() => {
    // Sprint α — для totalChats/withCheck/paid используем counts (по всему
    // dataset, не по странице). totalMessages пока остаётся sum по странице
    // потому что серверный endpoint counts его не считает (Sprint α scope —
    // только counts чатов, не суммарные сообщения).
    return {
      totalMessages: chats.reduce((s, c) => s + c.messages, 0),
      withCheck: countsData?.hasCheck ?? chats.filter((c) => c.hasCheck).length,
      paid: countsData?.paid ?? chats.filter((c) => c.paid).length,
      totalChats: countsData?.total ?? chats.length,
    };
  }, [chats, countsData]);

  const current = chats.find((c) => c.id === selected);

  return (
    <div className="space-y-6">
      <SectionTitle title="Переписки" subtitle="История диалогов клиентов с аккаунтами" />

      <div className="grid grid-cols-4 gap-4">
        <Metric label="Всего чатов" value={stats.totalChats} sublabel="в логах" />
        <Metric label="Сообщений" value={stats.totalMessages} sublabel="суммарно" />
        <Metric label="С чеком" value={stats.withCheck} sublabel={"из " + stats.totalChats} />
        <Metric label="Оплачено" value={stats.paid} sublabel="закрытых сделок" />
      </div>

      {/* Второй ряд — активность чатов по времени */}
      <div className="grid grid-cols-3 gap-4">
        <Metric label="Чатов сегодня" value={countsData?.today ?? 0} sublabel="новая активность" />
        <Metric label="Чатов вчера" value={countsData?.yesterday ?? 0} sublabel="за вчера" />
        <Metric label="Чатов за 7 дней" value={countsData?.last7d ?? 0} sublabel="за неделю" />
      </div>

      <Card className="p-0 overflow-hidden flex" style={{ height: "calc(100vh - 420px)", minHeight: 500 }}>
        <div className="w-[340px] shrink-0 border-r border-black/[0.06] flex flex-col min-h-0">
          <div className="p-3 border-b border-black/[0.06] space-y-2">
            <div className="relative">
              <Icon name="search" className="w-4 h-4 text-zinc-500 absolute left-3 top-1/2 -translate-y-1/2" />
              <input
                value={search}
                onChange={(e) => setSearch(e.target.value)}
                placeholder="Поиск: имя / username / id…"
                className="w-full bg-white/50 border border-black/10 rounded-lg pl-9 pr-3 py-2 text-sm placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50"
              />
            </div>
            {/* Фильтр: все / заблокированные */}
            <div className="flex items-center gap-1 p-0.5 bg-black/[0.04] rounded-lg border border-black/[0.06]">
              {[
                { id: "all", label: "Все" },
                { id: "blocked", label: "Заблокированные" },
              ].map((f) => (
                <button
                  key={f.id}
                  onClick={() => setFilter(f.id)}
                  className={"flex-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors " + (filter === f.id ? "bg-white/70 text-zinc-100 shadow-sm" : "text-zinc-500 hover:text-zinc-200")}
                >
                  {f.label}
                </button>
              ))}
            </div>
            {/* Сортировка: критерий + направление (toggle ↓/↑) */}
            <div className="flex items-center gap-2">
              <div className="flex items-center gap-1 p-0.5 bg-black/[0.04] rounded-lg border border-black/[0.06] flex-1">
                {[
                  { id: "recent", label: "По времени" },
                  { id: "messages", label: "По сообщениям" },
                ].map((o) => (
                  <button
                    key={o.id}
                    onClick={() => setSort(o.id)}
                    className={"flex-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors " + (sort === o.id ? "bg-indigo-500/80 text-white" : "text-zinc-500 hover:text-zinc-200")}
                  >
                    {o.label}
                  </button>
                ))}
              </div>
              <button
                onClick={() => setDirection(direction === "desc" ? "asc" : "desc")}
                title={direction === "desc" ? "Сначала больше / новее" : "Сначала меньше / старее"}
                className="px-3 py-1.5 rounded-lg bg-white/50 border border-black/10 hover:bg-white/70 text-zinc-300 text-xs font-medium flex items-center gap-1 shrink-0 whitespace-nowrap"
              >
                {direction === "desc" ? "↓ больше" : "↑ меньше"}
              </button>
            </div>
          </div>

          <div className="flex-1 overflow-y-auto stagger-rows">
            {loading && Array.from({ length: 8 }).map((_, i) => (
              <div key={"sk" + i} className="w-full px-3.5 py-2.5 border-b border-black/[0.05] flex items-start gap-2.5">
                <div className="w-10 h-10 rounded-full bg-black/[0.05] animate-pulse" />
                <div className="flex-1 space-y-1.5">
                  <div className="h-3 bg-black/[0.05] rounded animate-pulse w-1/2" />
                  <div className="h-2 bg-black/[0.04] rounded animate-pulse w-1/3" />
                  <div className="h-2.5 bg-black/[0.04] rounded animate-pulse w-3/4" />
                </div>
              </div>
            ))}
            {!loading && filtered.length === 0 && (
              <div className="text-center text-sm text-zinc-500 py-8">
                {chats.length === 0 ? "Диалогов пока нет" : "Ничего не найдено"}
              </div>
            )}
            {!loading && filtered.map((c) => {
              const isActive = selected === c.id;
              return (
                <div
                  key={c.id}
                  onClick={() => setSelected(c.id)}
                  title={(c.username ? "@" + String(c.username).replace(/^@/, "") + " · " : "") + "id " + c.clientId}
                  className={"relative w-full px-3.5 py-2.5 border-b border-black/[0.05] flex items-start gap-2.5 text-left cursor-pointer transition-colors " + (isActive ? "bg-[rgba(231,220,196,0.55)]" : "hover:bg-black/[0.03]") + (c.blocked ? " opacity-80" : "")}
                >
                  {isActive && <span className="absolute left-0 top-2.5 bottom-2.5 w-[3px] rounded-r-full bg-indigo-400" />}
                  <div className="relative shrink-0">
                    <Avatar name={c.name} color={c.account.color} avatarUrl={c.account.avatarUrl} size={40} />
                    {c.hasCheck && (
                      <div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-emerald-500 border-2 border-[#f7f6f2] flex items-center justify-center">
                        <Icon name="receipt" className="w-2 h-2 text-white" />
                      </div>
                    )}
                  </div>
                  <div className="flex-1 min-w-0">
                    <div className="flex items-baseline justify-between gap-2">
                      <div className="font-medium text-[13px] truncate flex items-center gap-1.5 min-w-0">
                        <span className="truncate">{c.name}</span>
                        {c.blocked && (
                          <span className="shrink-0 px-1.5 py-0.5 rounded bg-rose-500/15 text-rose-500 text-[9px] font-bold uppercase tracking-wide">Blocked</span>
                        )}
                      </div>
                      <div className="text-[11px] text-zinc-500 shrink-0 tabular-nums">{c.lastAt}</div>
                    </div>
                    <div className="text-xs text-zinc-500 truncate mt-0.5">{c.preview}</div>
                    <div className="flex items-center gap-1.5 mt-1.5">
                      <span className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-full bg-black/[0.05] text-[10.5px] text-zinc-500 min-w-0" title={"аккаунт: " + c.account.name}>
                        <span className={"w-3.5 h-3.5 rounded-full bg-gradient-to-br " + (c.account.color || "from-[#8a7355] to-[#463527]") + " text-white text-[8px] font-semibold inline-flex items-center justify-center shrink-0"}>{(c.account.name || "?").slice(0, 1).toUpperCase()}</span>
                        <span className="truncate max-w-[88px]">{c.account.name}</span>
                      </span>
                      <span className="flex-1" />
                      {c.paid && <Pill tone="emerald" className="!px-1.5 !py-0 !text-[10px]">оплачен</Pill>}
                      <span className="flex items-center gap-1 shrink-0" title="всего сообщений (incoming+outgoing)">
                        <span className={"text-[10.5px] tabular-nums px-1.5 py-0.5 rounded-full " + _heavyMessagesClass(c.messages)}>
                          {c.messages}
                        </span>
                      </span>
                      <button
                        onClick={(e) => { e.stopPropagation(); handleBlockToggle(c); }}
                        disabled={busyBlock === c.id}
                        className={"px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors disabled:opacity-50 border " + (c.blocked ? "bg-white/50 border-black/10 text-zinc-300 hover:bg-white/70" : "bg-rose-500/10 border-rose-500/30 text-rose-500 hover:bg-rose-500/20")}
                      >
                        {busyBlock === c.id ? "…" : (c.blocked ? "Unblock" : "Block")}
                      </button>
                    </div>
                  </div>
                </div>
              );
            })}
          </div>
        </div>

        <div className="flex-1 flex flex-col min-h-0">
          {current ? (
            <>
              <div className="px-4 py-3 border-b border-black/[0.06] flex items-center gap-3 shrink-0">
                <Avatar name={current.name} color={current.account.color} avatarUrl={current.account.avatarUrl} size={40} />
                <div className="flex-1 min-w-0">
                  <div className="font-medium text-sm truncate">
                    {current.name}
                    {current.username ? <span className="text-zinc-500 font-normal text-[12px] ml-2">@{String(current.username).replace(/^@/, "")}</span> : null}
                  </div>
                  <div className="text-[11.5px] text-zinc-500 truncate">через {current.account.name} · {current.messages} сообщений · id {current.clientId}</div>
                </div>
                <div className="flex items-center gap-1.5">
                  {current.hasCheck && <Pill tone="emerald"><Icon name="receipt" className="w-3 h-3" /> чек</Pill>}
                  {current.paid && <Pill tone="indigo">оплачен</Pill>}
                </div>
              </div>
              <ChatScroll chatId={current.id} scrollMemory={scrollMemoryRef} />
              <ChatComposer />
            </>
          ) : (
            <div className="flex-1 flex items-center justify-center text-zinc-500 text-sm">Выберите чат слева</div>
          )}
        </div>
      </Card>
    </div>
  );
}

function DialogBubble({ side, text, time, status, kind, mediaUrl, mediaCount, fileName }) {
  const isRight = side === "right";
  const isPhoto = kind === "photo";
  const isVideo = kind === "video";
  const isCircle = kind === "video_note";
  const isVoice = kind === "voice";
  const isDocument = kind === "document";   // Шаг 4: PDF / любой файл
  const hasMedia = mediaUrl && (isPhoto || isVideo || isCircle || isDocument);

  return (
    <div className={"flex msg-anim " + (isRight ? "justify-end" : "justify-start")}>
      <div className={"max-w-[70%] rounded-2xl text-[13px] leading-snug overflow-hidden " + (isRight ? "bg-[#3e424a] text-[#f4f3ef] rounded-br-md" : "bg-white/60 border border-white/60 text-zinc-50 rounded-bl-md")}>
        {hasMedia && isPhoto && (
          <a href={mediaUrl} target="_blank" rel="noreferrer" className="block">
            <img src={mediaUrl} alt="" loading="lazy" className="max-w-full max-h-[320px] object-contain" />
          </a>
        )}
        {hasMedia && isVideo && (
          <video src={mediaUrl} controls preload="metadata" className="max-w-full max-h-[320px]" />
        )}
        {hasMedia && isCircle && (
          <video src={mediaUrl} controls loop preload="metadata" className="w-[200px] h-[200px] rounded-full object-cover" />
        )}
        {/* Шаг 4: document-bubble. Иконка + имя файла + клик-скачать. */}
        {hasMedia && isDocument && (
          <a
            href={mediaUrl}
            target="_blank"
            rel="noreferrer"
            className={"flex items-center gap-2.5 px-3 py-2.5 hover:bg-black/[0.06] transition-colors " + (isRight ? "text-[#f4f3ef]" : "text-zinc-50")}
            title="Скачать"
          >
            <Icon name="file" className="w-5 h-5 shrink-0 opacity-80" />
            <span className="text-sm truncate flex-1 underline decoration-dotted underline-offset-2">{fileName || "файл"}</span>
          </a>
        )}
        {!hasMedia && (isPhoto || isVideo || isCircle) && (
          <div className="px-3 py-3 text-[12px] opacity-70 italic">
            {kind === "video_note" ? "🎥 кружок" : kind === "video" ? "🎬 видео" : "🖼 фото"}{mediaCount > 1 ? ` ×${mediaCount}` : ""} (недоступно)
          </div>
        )}
        {!hasMedia && isDocument && (
          <div className="flex items-center gap-2 px-3 py-2.5 text-[12px] opacity-70 italic">
            <Icon name="file" className="w-4 h-4 shrink-0" />
            <span>{fileName || "файл"} (недоступно)</span>
          </div>
        )}
        {isVoice && (
          <div className="px-3 py-2 flex items-center gap-2 text-[13px]">
            <Icon name="play" className="w-4 h-4 shrink-0" fill="currentColor" />
            <span className="whitespace-pre-wrap break-words flex-1">{text || "голосовое"}</span>
          </div>
        )}
        <div className="px-3 py-2">
          {/* Шаг 3: text под медиа — caption клиента или сопровождение бота.
              Для voice/document текст показывается внутри их собственных
              блоков выше, тут пропускаем чтобы не дублировать. */}
          {!isVoice && !isDocument && text && <div className="whitespace-pre-wrap break-words">{text}</div>}
          <div className={"text-[10.5px] mt-1 flex items-center justify-end gap-1 " + (isRight ? "text-white/50" : "text-zinc-500")}>
            <span>{time}</span>
            {isRight && status && (
              <span className="inline-flex items-center text-white/60" title={status === "read" ? "Прочитано" : "Отправлено"}>
                {status === "read" ? (
                  <svg viewBox="0 0 16 12" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                    <polyline points="1 7 5 11 11 1"/>
                    <polyline points="6 11 12 5"/>
                  </svg>
                ) : (
                  <svg viewBox="0 0 12 12" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                    <polyline points="1 7 5 11 11 1"/>
                  </svg>
                )}
              </span>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

/* ============================
   Chat history generator + scroller
============================ */

// Tiny seeded RNG so each chat has stable history
function mulberry32(seed) {
  return function () {
    let t = (seed += 0x6D2B79F5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const CLIENT_LINES = [
  "Привет 👋",
  "Скинь прайс пожалуйста",
  "А что есть из видео?",
  "А виртик у тебя есть?",
  "Сколько за 3 фото?",
  "Можно набор 5+3?",
  "А кружки делаешь?",
  "Скинь реквизиты",
  "Privat подойдёт?",
  "Сбер можно?",
  "Карту тинькоффа дашь?",
  "USDT принимаешь?",
  "Через Stars можно?",
  "Сейчас оплачу",
  "Оплатил, держи чек",
  "Чек скинул, проверь пожалуйста",
  "Получила?",
  "Ну как там?",
  "Долго ждать?",
  "Спасибо ❤️",
  "Огонь, ещё что-то есть?",
  "А приват сколько стоит?",
  "Можно фото без лица?",
  "А с игрушкой видео есть?",
  "Звонок 30 мин сколько?",
  "Завтра напишу ещё",
  "Ок, договорились",
  "Спасибо, всё ок 🌸",
  "Слушай, а ещё контент будет?",
  "А скидку дашь если возьму набор?",
];

const SELLER_LINES = [
  "Привет 💋",
  "Скидываю прайс 🌸",
  "Хорошо, сейчас отправлю реквизиты",
  "Карта Privat: 4441 1110 6974 7248 — как оплатишь, кидай чек сюда 🌷",
  "Сбер: 2202 2018 4421 9043, держатель Анна К.",
  "Принимаю и USDT (TRC-20)",
  "Принято, проверяю ⏳",
  "Чек ок, отправляю 💧",
  "Готово, держи 🌸",
  "Спасибо солнышко ❤️",
  "Конечно, заходи в любое время 💋",
  "Сейчас сделаю и пришлю",
  "Записала тебя",
  "Ну ты огонь 🔥",
  "Прайс актуален, скидывать?",
  "Минимум 3 фото — 100₴",
  "Набор 5+3 = 400₴, выгоднее",
  "Кружки делаю, 150₴ за один",
  "Скинула, посмотри",
  "Спасибо, рада что понравилось 🌷",
  "Виртик 30 мин — 400₴, переписка с фоточками",
  "Приват навсегда 1200₴",
  "Скидку могу дать на набор — 350 вместо 400",
];

function generateChatHistory(chat) {
  const seed = chat.id.split("").reduce((s, c) => s + c.charCodeAt(0), 0);
  const rnd = mulberry32(seed);

  const totalMessages = Math.max(20, Math.min(80, chat.messages + 12 + Math.floor(rnd() * 20)));
  const messages = [];

  // Build messages spread across the last 5 days (most clustered today/yesterday)
  let dayOffset = Math.min(4, Math.floor(rnd() * 4) + 1); // start N days ago
  let hour = 9 + Math.floor(rnd() * 4);
  let minute = Math.floor(rnd() * 60);
  // Distribute msgs: 60% today, 25% yesterday, 15% earlier
  for (let i = 0; i < totalMessages; i++) {
    const remaining = totalMessages - i;
    if (dayOffset > 0 && i / totalMessages > 1 - (dayOffset === 1 ? 0.35 : dayOffset === 2 ? 0.5 : 0.85)) {
      // jump to next day, reset hour
      dayOffset--;
      hour = 9 + Math.floor(rnd() * 3);
      minute = Math.floor(rnd() * 30);
    }
    // Advance time 1–25 minutes
    minute += 1 + Math.floor(rnd() * 24);
    while (minute >= 60) { minute -= 60; hour++; }
    if (hour >= 24) { hour = 9; }

    const isClient = i === 0 ? true : rnd() < 0.5;
    const pool = isClient ? CLIENT_LINES : SELLER_LINES;
    const text = pool[Math.floor(rnd() * pool.length)];
    const time = String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0");

    // Inject a check image somewhere mid-conversation (~75% of the way)
    const injectCheck = chat.hasCheck && i === Math.floor(totalMessages * 0.75) && isClient;

    messages.push({
      id: chat.id + "_m" + i,
      side: isClient ? "left" : "right",
      text,
      time,
      dayOffset,
      kind: injectCheck ? "check" : "text",
      status: !isClient ? (i < totalMessages - 2 ? "read" : "sent") : null,
    });
  }
  return messages;
}

function formatDayLabel(offset) {
  if (offset === 0) return "Сегодня";
  if (offset === 1) return "Вчера";
  const days = ["дн", "дн", "дн", "дн"];
  const labels = ["2 дня назад", "3 дня назад", "4 дня назад", "5 дней назад"];
  return labels[offset - 2] || `${offset} дней назад`;
}

/* ============================================================
 *  ChatScroll — Sprint β
 *  ============================================================
 *  Invariants (см. contract обсуждения β):
 *    1. Anchor from bottom: distance до низа const при polling/prepend.
 *    2. Merge by id everywhere — единый dedupe primitive.
 *    3. Stable ordering: (ts ISO, idx) tiebreak.
 *    4. Referential identity: одинаковые messages re-use existing object.
 *    5. hasMore=false — final до chatId change (no polling storm).
 *    6. loadingMore — hard lock от parallel prepend.
 *    7. chatIdRef snapshot — stale responses ignored.
 *    8. <img/video onLoad> → re-anchor (capture phase listener).
 *    9. scrollMemory map persists scroll position per chat for A→B→A.
 *   10. UX continuity > freshness — все merges батчатся через
 *       useLayoutEffect ДО paint, юзер не видит "промежуточных" frame.
 */

// Sprint β — единая функция merge для всех путей (polling/prepend/initial).
// preserveExistingReferences: для каждого сообщения проверяем — если в
// existing уже есть с тем же id И поля shallow-equal → keep existing
// reference. Иначе берём incoming. Это критично для будущих reactions/edits.
function _shallowMessageEqual(a, b) {
  if (a === b) return true;
  if (!a || !b) return false;
  // Поля которые могут стабильно меняться: text, status, mediaUrl, kind, fileName
  return (
    a.id === b.id &&
    a.side === b.side &&
    a.text === b.text &&
    a.time === b.time &&
    a.kind === b.kind &&
    a.status === b.status &&
    a.mediaUrl === b.mediaUrl &&
    a.mediaCount === b.mediaCount &&
    a.fileName === b.fileName &&
    a.ts === b.ts &&
    a.idx === b.idx &&
    a.dayOffset === b.dayOffset
  );
}

function _mergeMessages(existing, incoming) {
  // Map by id, primary order = incoming wins, identity preserved on equality.
  const byId = new Map();
  for (const m of existing) byId.set(m.id, m);
  let changed = false;
  for (const m of incoming) {
    const prev = byId.get(m.id);
    if (prev && _shallowMessageEqual(prev, m)) {
      // re-use existing reference — referential identity preserved.
      continue;
    }
    // Sprint β-fix #1: collision warning. Если id тот же но содержимое
    // разное — это потенциальный data-loss bug (overwrite). После фикса
    // backend id-генератора такого быть не должно. Если warning появится —
    // значит мы поймали реальный edge case.
    if (prev && !_shallowMessageEqual(prev, m)) {
      console.warn(
        "[merge collision] same id, different content — possible data loss",
        { id: m.id, prev, incoming: m }
      );
      if (typeof window !== "undefined" && window.__chatScrollStats) {
        window.__chatScrollStats.collisions = (window.__chatScrollStats.collisions || 0) + 1;
      }
    }
    byId.set(m.id, m);
    changed = true;
  }
  if (!changed && byId.size === existing.length) return existing;
  // Stable sort: ts primary (ISO string comparable), idx secondary.
  const list = Array.from(byId.values());
  list.sort((a, b) => {
    const tA = a.ts || "";
    const tB = b.ts || "";
    if (tA !== tB) return tA < tB ? -1 : 1;
    const iA = typeof a.idx === "number" ? a.idx : 0;
    const iB = typeof b.idx === "number" ? b.idx : 0;
    return iA - iB;
  });
  return list;
}

function ChatScroll({ chatId, scrollMemory }) {
  const scrollRef = useRefC(null);
  const [atBottom, setAtBottom] = useStateC(true);

  // Накопленные сообщения (включая prepend через infinite scroll).
  // useQuery даёт только initial 200; prepend добавляет через loadMore.
  const [allMessages, setAllMessages] = useStateC([]);
  const [hasMore, setHasMore] = useStateC(true);
  const [loadingMore, setLoadingMore] = useStateC(false);

  // Refs которые не триггерят re-render
  const chatIdRef = useRefC(chatId);
  const savedDistanceRef = useRefC(0);  // distance from bottom — invariant для anchor
  const atBottomRef = useRefC(true);
  const loadingMoreRef = useRefC(false);
  const hasMoreRef = useRefC(true);
  const abortRef = useRefC(null);
  const prevMessagesLenRef = useRefC(0);  // для скана delta height
  // HUD stats — глобальный bucket, читается debug-hud.jsx через polling.
  // Никаких subscriptions, никаких React-renders по этим инкрементам.
  if (typeof window !== "undefined" && !window.__chatScrollStats) {
    window.__chatScrollStats = {
      prepends: 0,
      merges: 0,
      scrollRestores: 0,
      staleIgnored: 0,
      mediaLoadRestores: 0,
      currentChatId: null,
      messagesCount: 0,
      hasMore: true,
      loadingMore: false,
      atBottom: true,
      oldestTs: null,
    };
  }
  const statsBump = (key) => {
    if (typeof window === "undefined" || !window.__chatScrollStats) return;
    window.__chatScrollStats[key] = (window.__chatScrollStats[key] || 0) + 1;
  };
  const statsSet = (patch) => {
    if (typeof window === "undefined" || !window.__chatScrollStats) return;
    Object.assign(window.__chatScrollStats, patch);
  };

  // Initial load via useQuery (Sprint α infrastructure). При chatId change
  // useQuery key меняется → новый fetch с AbortController, старый прерывается.
  const fetcher = React.useCallback(
    (signal) => fetchChatMessages(chatId, 200, { signal }),
    [chatId]
  );
  const { data: initialData, loading: loadingMsgs } = useQuery(
    ["chat-messages", chatId],
    fetcher,
    { pollInterval: 2_000, enabled: !!chatId } // лайв открытого чата (было 12с)
  );

  // При смене chatId — reset infinite-scroll state. Cache в useQuery для
  // {chatId} остаётся (instant return при возврате на этот чат).
  React.useEffect(() => {
    chatIdRef.current = chatId;
    setHasMore(true);
    hasMoreRef.current = true;
    setLoadingMore(false);
    loadingMoreRef.current = false;
    setAllMessages([]);  // сбрасываем — initialData приедет следующим эффектом
    if (abortRef.current) {
      try { abortRef.current.abort(); } catch {}
      abortRef.current = null;
    }
  }, [chatId]);

  // Когда initialData (polling tick или first load) приходит — merge с
  // существующим allMessages (там могут быть prepend-сообщения сверху).
  // Это main entry для polling → merge → useLayoutEffect → anchor restore.
  React.useEffect(() => {
    if (!initialData) return;
    setAllMessages((prev) => {
      const merged = _mergeMessages(prev, initialData);
      if (merged !== prev) statsBump("merges");
      return merged;
    });
  }, [initialData]);

  // ===== ANCHOR PRESERVATION =====
  // useLayoutEffect выполняется после DOM mutation ДО paint. Юзер никогда
  // не видит "scroll прыгнул". Алгоритм:
  //   - Если atBottom (юзер был на дне): scroll вниз → "новое сообщение
  //     появилось" feel.
  //   - Иначе: восстанавливаем distance от низа → scroll не сдвинется.
  //
  // Этот эффект триггерится на каждое allMessages change.
  React.useLayoutEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    if (atBottomRef.current) {
      el.scrollTop = el.scrollHeight;
      savedDistanceRef.current = 0;
    } else {
      // Restore distance from bottom — главный invariant.
      const targetTop = el.scrollHeight - el.clientHeight - savedDistanceRef.current;
      el.scrollTop = Math.max(0, targetTop);
    }
    statsBump("scrollRestores");
    prevMessagesLenRef.current = allMessages.length;
    statsSet({ messagesCount: allMessages.length, oldestTs: allMessages[0]?.ts || null });
  }, [allMessages]);

  // ===== INITIAL POSITIONING при смене chatId =====
  // Если в scrollMemory есть сохранённая позиция → restore. Иначе → bottom.
  React.useLayoutEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const saved = scrollMemory && scrollMemory.current
      ? scrollMemory.current.get(chatId)
      : null;
    // Откладываем до полного рендера сообщений (через rAF чтобы DOM измерения
    // были после layout)
    const jump = () => {
      if (!scrollRef.current || chatIdRef.current !== chatId) return;
      if (saved && saved.atBottom === false) {
        // restore exact scroll position
        scrollRef.current.scrollTop = saved.scrollTop;
        atBottomRef.current = false;
        setAtBottom(false);
        savedDistanceRef.current =
          scrollRef.current.scrollHeight -
          scrollRef.current.clientHeight -
          scrollRef.current.scrollTop;
      } else {
        // default: bottom
        scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
        atBottomRef.current = true;
        setAtBottom(true);
        savedDistanceRef.current = 0;
      }
    };
    jump();
    const raf1 = requestAnimationFrame(jump);
    const raf2 = requestAnimationFrame(() => requestAnimationFrame(jump));
    const t = setTimeout(jump, 80);
    return () => {
      cancelAnimationFrame(raf1);
      cancelAnimationFrame(raf2);
      clearTimeout(t);
      // Save position для A→B→A — пишем в memory map.
      const el2 = scrollRef.current;
      if (el2 && scrollMemory && scrollMemory.current) {
        scrollMemory.current.set(chatId, {
          scrollTop: el2.scrollTop,
          atBottom: atBottomRef.current,
        });
      }
    };
  }, [chatId]);

  // ===== SCROLL HANDLER =====
  // Дешёвый — refs только, setState только при пересечении atBottom threshold.
  // Триггерит infinite-scroll загрузку при scrollTop < 200 (если hasMore).
  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const onScroll = () => {
      const dist = el.scrollHeight - el.clientHeight - el.scrollTop;
      savedDistanceRef.current = dist;
      const isBottom = dist < 40;
      if (isBottom !== atBottomRef.current) {
        atBottomRef.current = isBottom;
        setAtBottom(isBottom);
        statsSet({ atBottom: isBottom });
      }
      // Infinite scroll trigger
      if (
        el.scrollTop < 200
        && hasMoreRef.current
        && !loadingMoreRef.current
        && allMessages.length > 0
      ) {
        loadMore();
      }
    };
    el.addEventListener("scroll", onScroll);
    return () => el.removeEventListener("scroll", onScroll);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allMessages.length, chatId]);

  // ===== INFINITE SCROLL PREPEND =====
  const loadMore = React.useCallback(async () => {
    if (loadingMoreRef.current || !hasMoreRef.current) return;
    if (allMessages.length === 0) return;
    const oldest = allMessages[0];
    if (!oldest || !oldest.ts) return;

    loadingMoreRef.current = true;
    setLoadingMore(true);
    statsSet({ loadingMore: true });

    const snapshot = chatIdRef.current;
    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const older = await fetchChatMessages(chatId, 200, {
        before: oldest.ts,
        signal: controller.signal,
      });
      // Stale response check (юзер ушёл в другой чат пока ждали)
      if (chatIdRef.current !== snapshot) {
        statsBump("staleIgnored");
        return;
      }
      if (!older || older.length === 0) {
        hasMoreRef.current = false;
        setHasMore(false);
        statsSet({ hasMore: false });
        return;
      }
      // Если бэк вернул МЕНЬШЕ limit — это последняя страница.
      if (older.length < 200) {
        hasMoreRef.current = false;
        setHasMore(false);
        statsSet({ hasMore: false });
      }
      // Single merge primitive — то же что для polling.
      setAllMessages((prev) => {
        const merged = _mergeMessages(prev, older);
        if (merged !== prev) {
          statsBump("merges");
          statsBump("prepends");
        }
        return merged;
      });
    } catch (e) {
      if (e && e.name === "AbortError") return;
      console.error("loadMore failed", e);
    } finally {
      loadingMoreRef.current = false;
      setLoadingMore(false);
      statsSet({ loadingMore: false });
      if (abortRef.current === controller) abortRef.current = null;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allMessages, chatId]);

  // ===== MEDIA onLOAD — re-anchor =====
  // Когда <img>/<video> доgružает контент, scrollHeight меняется ПОСЛЕ
  // нашего useLayoutEffect. Слушаем load в capture phase — он bubbles от
  // любого <img>/<video> внутри scroll-контейнера. Если юзер не atBottom
  // и distance был сохранён — восстанавливаем.
  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const onLoad = (e) => {
      const tag = e.target && e.target.tagName;
      if (tag !== "IMG" && tag !== "VIDEO") return;
      if (atBottomRef.current) {
        el.scrollTop = el.scrollHeight;
      } else {
        const target = el.scrollHeight - el.clientHeight - savedDistanceRef.current;
        el.scrollTop = Math.max(0, target);
      }
      statsBump("mediaLoadRestores");
    };
    el.addEventListener("load", onLoad, true);  // capture
    return () => el.removeEventListener("load", onLoad, true);
  }, []);

  // ===== SCROLL TO BOTTOM (FAB button) =====
  const scrollToBottom = () => {
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
  };

  // Sprint β — update HUD stats for current chat
  React.useEffect(() => {
    statsSet({
      currentChatId: chatId,
      messagesCount: allMessages.length,
      hasMore,
      loadingMore,
      atBottom,
      oldestTs: allMessages[0]?.ts || null,
    });
  }, [chatId, allMessages.length, hasMore, loadingMore, atBottom]);

  // Build rendered items with day separators
  const rendered = [];
  let lastDay = null;
  for (const m of allMessages) {
    if (m.dayOffset !== lastDay) {
      rendered.push({ kind: "day", id: "day-" + m.dayOffset, label: formatDayLabel(m.dayOffset) });
      lastDay = m.dayOffset;
    }
    rendered.push(m);
  }

  // ref alias для совместимости с rendering ниже
  const ref = scrollRef;
  const messages = allMessages;

  return (
    <div className="flex-1 relative min-h-0 bg-black/[0.02]">
      <div
        ref={ref}
        className="absolute inset-0 overflow-y-auto px-5 py-4 space-y-1 hide-scrollbar"
        style={{ scrollBehavior: "auto" }}
      >
        {/* Sprint β — Infinite scroll indicator. Появляется когда юзер
            прокрутил наверх и фоном грузятся старые сообщения. Тонкий
            ободок, не отвлекающий. */}
        {loadingMore && (
          <div className="flex justify-center py-2">
            <div className="text-[11px] text-zinc-500 flex items-center gap-2">
              <div className="w-3 h-3 border-2 border-black/10 border-t-zinc-400 rounded-full animate-spin" />
              <span>Загружаю старые сообщения…</span>
            </div>
          </div>
        )}
        {/* Sprint β — индикатор «больше нечего грузить» (final stop) */}
        {!hasMore && allMessages.length >= 200 && (
          <div className="flex justify-center py-2">
            <div className="text-[10px] text-zinc-600 uppercase tracking-wider">
              начало переписки
            </div>
          </div>
        )}
        {loadingMsgs && Array.from({ length: 6 }).map((_, i) => (
          <div key={"sk" + i} className={"flex " + (i % 2 === 0 ? "justify-start" : "justify-end")}>
            <div className={"px-3 py-2 rounded-2xl bg-black/[0.05] animate-pulse " + (i % 2 === 0 ? "rounded-bl-md" : "rounded-br-md")} style={{ width: 160 + (i % 3) * 40, height: 32 }} />
          </div>
        ))}
        {!loadingMsgs && rendered.length === 0 && (
          <div className="flex-1 flex items-center justify-center text-zinc-500 text-sm py-10">Сообщений пока нет</div>
        )}
        {!loadingMsgs && rendered.map((it) => {
          if (it.kind === "day") {
            return (
              <div key={it.id} className="flex justify-center my-4 sticky top-0 z-[1]">
                <div className="px-3 py-1 rounded-full bg-white/60 backdrop-blur border border-white/60 text-[11px] text-zinc-500 font-medium">
                  {it.label}
                </div>
              </div>
            );
          }
          if (it.kind === "check") {
            return <CheckBubble key={it.id} time={it.time} />;
          }
          return (
            <DialogBubble
              key={it.id}
              side={it.side}
              text={it.text}
              time={it.time}
              status={it.status}
              kind={it.kind}
              mediaUrl={it.mediaUrl}
              mediaCount={it.mediaCount}
              fileName={it.fileName}
            />
          );
        })}
        <div className="h-2" />
      </div>

      {/* Scroll-to-bottom FAB */}
      <button
        onClick={scrollToBottom}
        className={"absolute bottom-3 right-4 w-9 h-9 rounded-full bg-white/80 backdrop-blur border border-black/10 text-zinc-200 hover:bg-white flex items-center justify-center shadow-lg transition-all " + (atBottom ? "opacity-0 translate-y-2 pointer-events-none" : "opacity-100 translate-y-0")}
        title="К последним сообщениям"
      >
        <Icon name="chevDown" className="w-4 h-4" />
      </button>
    </div>
  );
}

function CheckBubble({ chat, time }) {
  // A receipt image preview from the client
  return (
    <div className="flex justify-start msg-anim">
      <div className="max-w-[70%] bg-white/60 border border-white/60 text-zinc-50 rounded-2xl rounded-bl-md p-1.5 overflow-hidden">
        <div
          className="rounded-xl relative overflow-hidden aspect-[4/5] border border-black/[0.06]"
          style={{
            background: "linear-gradient(135deg, #efe9da 0%, #e7dcc4 60%, #d9d2bf 100%)",
            width: 200,
          }}
        >
          <div className="absolute inset-0 opacity-[0.05]" style={{
            backgroundImage: "repeating-linear-gradient(135deg, #3c3a34 0 2px, transparent 2px 8px)"
          }} />
          <div className="absolute inset-0 p-3 flex flex-col justify-between text-zinc-100">
            <div className="flex items-center justify-between text-[10px] uppercase tracking-wider opacity-80">
              <span>Privat</span>
              <Icon name="card" className="w-3.5 h-3.5" />
            </div>
            <div>
              <div className="text-2xl font-semibold tabular-nums">2 500 <span className="opacity-60 text-sm">₽</span></div>
              <div className="text-[10px] opacity-60 mt-1">**** 7248 · OK</div>
            </div>
          </div>
          <div className="absolute bottom-2 right-2 px-1.5 py-0.5 rounded bg-white/70 text-zinc-300 text-[9px] font-mono">
            чек.jpg
          </div>
        </div>
        <div className="flex items-center justify-between gap-2 mt-1 pl-1 pr-0.5 pb-0.5">
          <span className="px-1.5 py-0.5 rounded-full bg-indigo-400/20 text-indigo-600 text-[9px] font-semibold uppercase tracking-wide">чек</span>
          <span className="text-[10.5px] text-zinc-500">{time}</span>
        </div>
      </div>
    </div>
  );
}

function ChatComposer() {
  // ВАЖНО: ручная отправка пока не реализована. Раньше send() просто
  // очищал textarea (silent fail — юзер думал что отправилось). Сейчас
  // composer полностью disabled с явным объяснением — никаких ложных
  // сигналов что Enter что-то сделает.
  const tooltip = "Ручная отправка пока не реализована. Бот сам отвечает клиентам через LLM. Чтобы вмешаться — пиши клиенту со своего Telegram напрямую.";
  return (
    <div className="p-3 border-t border-black/[0.06] shrink-0 flex items-end gap-2 opacity-60" title={tooltip}>
      <button
        disabled
        className="w-9 h-9 rounded-lg bg-white/40 border border-black/[0.08] text-zinc-500 flex items-center justify-center shrink-0 cursor-not-allowed"
        title={tooltip}
      >
        <Icon name="plus" className="w-4 h-4" />
      </button>
      <div className="flex-1 bg-white/40 border border-black/[0.08] rounded-2xl px-3.5 py-2">
        <textarea
          disabled
          rows={1}
          placeholder="Ручная отправка пока не реализована"
          className="w-full bg-transparent text-sm placeholder:text-zinc-500 focus:outline-none resize-none max-h-32 cursor-not-allowed"
          style={{ minHeight: 20 }}
          title={tooltip}
        />
      </div>
      <button
        disabled
        className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-white/40 border border-black/[0.08] text-zinc-500 cursor-not-allowed"
        title={tooltip}
      >
        <Icon name="arrowRight" className="w-4 h-4" />
      </button>
    </div>
  );
}

/* ============================
   CHECKS — NEW
============================ */

function CurrencySymbol({ c }) {
  return <>{c === "RUB" ? "₽" : c === "UAH" ? "₴" : c === "USDT" ? "$" : c === "STARS" ? "⭐" : c}</>;
}

// Дата → "YYYY-MM-DD" в локальном времени (для input[type=date] и пресетов).
function _isoDate(d) {
  return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}

// Русская плюрализация: _plural(2, "чек", "чека", "чеков") → "чека".
function _plural(n, one, few, many) {
  const m10 = n % 10, m100 = n % 100;
  if (m10 === 1 && m100 !== 11) return one;
  if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
  return many;
}

function ChecksPage() {
  const [checks, setChecks] = useStateC([]);
  const [loading, setLoading] = useStateC(true);
  const [status, setStatus] = useStateC("all");
  const [lightbox, setLightbox] = useStateC(null);
  // Период по умолчанию — последние 30 дней (скользящее окно).
  const [from, setFrom] = useStateC(() => { const d = new Date(); d.setDate(d.getDate() - 29); return _isoDate(d); });
  const [to, setTo] = useStateC(() => _isoDate(new Date()));

  // Тянем ВСЕ чеки, фильтруем по статусу+периоду на клиенте. Polling 20с.
  useEffectC(() => {
    let stopped = false;
    const refresh = async () => {
      const data = await fetchReceipts("all", 3000);
      if (stopped) return;
      setChecks(data);
      setLoading(false);
    };
    refresh();
    const id = setInterval(refresh, 45_000);
    return () => { stopped = true; clearInterval(id); };
  }, []);

  const filtered = useMemoC(() => {
    const fromT = from ? new Date(from + "T00:00:00").getTime() : -Infinity;
    const toT = to ? new Date(to + "T23:59:59").getTime() : Infinity;
    return checks.filter((c) => {
      if (status !== "all" && c.status !== status) return false;
      const t = c.receivedAt ? new Date(c.receivedAt).getTime() : NaN;
      if (!isNaN(t) && (t < fromT || t > toT)) return false;
      return true;
    }).sort((a, b) => ((new Date(b.receivedAt).getTime() || 0) - (new Date(a.receivedAt).getTime() || 0)));
  }, [checks, status, from, to]);

  const summary = useMemoC(() => {
    // Суммируем только распознанные суммы; чеки без суммы/валюты (UNKNOWN) в
    // итог не идут, но в счётчик count входят.
    const byCcy = {};
    for (const c of filtered) {
      const ccy = (c.currency || "").toUpperCase();
      if (!ccy || ccy === "UNKNOWN" || !c.amount) continue;
      byCcy[ccy] = (byCcy[ccy] || 0) + c.amount;
    }
    return { count: filtered.length, byCcy };
  }, [filtered]);

  const setPreset = (kind) => {
    const now = new Date();
    if (kind === "today") { setFrom(_isoDate(now)); setTo(_isoDate(now)); }
    else if (kind === "week") { const s = new Date(now); s.setDate(s.getDate() - 6); setFrom(_isoDate(s)); setTo(_isoDate(now)); }
    else if (kind === "month") { const s = new Date(now); s.setDate(s.getDate() - 29); setFrom(_isoDate(s)); setTo(_isoDate(now)); }
  };
  const statusWord = (s) => (s === "confirmed" ? "подтверждён" : s === "pending" ? "на проверке" : "отклонён");
  const dotClass = (s) => (s === "confirmed" ? "bg-emerald-400" : s === "pending" ? "bg-amber-400" : "bg-rose-400");

  return (
    <div className="space-y-5">
      <SectionTitle title="Чеки" subtitle="Оплаты за период — скрины, суммы, владельцы" />

      <div className="glass rounded-2xl px-4 py-3 flex items-center gap-3 flex-wrap">
        <span className="text-[13px] text-zinc-400">Период</span>
        <input type="date" value={from} max={to || undefined} onChange={(e) => setFrom(e.target.value)}
          className="bg-white/70 border border-black/10 rounded-lg px-2.5 py-1.5 text-sm outline-none focus:border-black/25" />
        <span className="text-zinc-400">→</span>
        <input type="date" value={to} min={from || undefined} onChange={(e) => setTo(e.target.value)}
          className="bg-white/70 border border-black/10 rounded-lg px-2.5 py-1.5 text-sm outline-none focus:border-black/25" />
        <div className="flex gap-1.5">
          {[["today", "Сегодня"], ["week", "7 дней"], ["month", "Месяц"]].map(([k, l]) => (
            <button key={k} onClick={() => setPreset(k)}
              className="text-xs px-2.5 py-1.5 rounded-lg bg-black/[0.04] hover:bg-black/[0.08] text-zinc-300 transition-colors">{l}</button>
          ))}
        </div>
        <div className="ml-auto">
          <SegmentedControl value={status} onChange={setStatus} size="sm"
            options={CHECKS_STATUSES.map((s) => ({ id: s.id, label: s.label }))} />
        </div>
      </div>

      <div className="text-[13px] text-zinc-400 px-1">
        {summary.count} {_plural(summary.count, "чек", "чека", "чеков")} за период
        {Object.entries(summary.byCcy).map(([ccy, sum]) => (
          <span key={ccy}> · <span className="text-zinc-200 font-medium tabular-nums">{sum.toLocaleString("ru")} <CurrencySymbol c={ccy} /></span></span>
        ))}
      </div>

      <Card className="overflow-hidden">
        {loading && <div className="p-10 text-center text-sm text-zinc-400">Загрузка…</div>}
        {!loading && filtered.length === 0 && (
          <EmptyState icon="receipt" title="Чеков за период нет" hint="Смени период или статус-фильтр." />
        )}
        {!loading && filtered.map((c, idx) => (
          <div key={c.id} onClick={() => setLightbox(c)}
            className={"flex items-center gap-4 px-4 py-3.5 cursor-pointer transition-colors hover:bg-black/[0.03]" + (idx < filtered.length - 1 ? " border-b border-black/[0.055]" : "")}>
            <div className="flex items-center gap-2.5 w-[170px] shrink-0">
              <Avatar name={c.account.name} color={c.account.color} avatarUrl={c.account.avatarUrl} size={36} />
              <div className="min-w-0">
                <div className="text-sm font-medium truncate">{c.account.name}</div>
                <div className="text-[11.5px] text-zinc-400 truncate">владелец: {c.ownerName || "—"}</div>
              </div>
            </div>
            <div className="flex-1 min-w-0">
              <div className="text-[13.5px] flex items-center gap-2">
                <span className={"w-[7px] h-[7px] rounded-full shrink-0 " + dotClass(c.status)} />
                {c.at || "—"}
              </div>
              <div className="text-[11.5px] text-zinc-400 mt-1 ml-[15px]">{c.method} · {c.isFake ? "фейк" : statusWord(c.status)}{c.status === "confirmed" && c.delivered ? " · выдан" : ""}</div>
              {c.amount && c.currency && c.currency !== "UNKNOWN"
                ? <div className="text-lg font-medium mt-1.5 ml-[15px] tabular-nums">{c.amount.toLocaleString("ru")} <CurrencySymbol c={c.currency} /></div>
                : <div className="text-[13px] text-zinc-400 mt-1.5 ml-[15px]">сумма не распознана</div>}
            </div>
            <div className="relative w-24 h-[124px] rounded-xl overflow-hidden bg-white border border-black/10 shrink-0 flex items-center justify-center">
              {c.hasPhoto ? (
                <img src={receiptPhotoUrl(c.id)} alt="чек" loading="lazy" className="w-full h-full object-cover" />
              ) : (
                <div className="flex flex-col items-center gap-1 text-zinc-400">
                  <Icon name="receipt" className="w-5 h-5" />
                  <span className="text-[10px]">нет фото</span>
                </div>
              )}
              {c.hasPhoto && <span className="absolute bottom-1 right-1 text-zinc-500 bg-white/85 rounded p-0.5"><Icon name="external" className="w-3 h-3" /></span>}
            </div>
          </div>
        ))}
      </Card>

      {lightbox && <CheckLightbox check={lightbox} onClose={() => setLightbox(null)} />}
    </div>
  );
}

function CheckLightbox({ check, onClose }) {
  useEffectC(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  const sWord = check.isFake ? "фейк" : check.status === "confirmed" ? "подтверждён" : check.status === "pending" ? "на проверке" : "отклонён";
  const sBadge = check.status === "confirmed" ? "bg-emerald-400/15 text-emerald-600" : check.status === "pending" ? "bg-amber-400/15 text-amber-600" : "bg-rose-400/15 text-rose-600";
  const sDot = check.status === "confirmed" ? "bg-emerald-500" : check.status === "pending" ? "bg-amber-500" : "bg-rose-500";
  const R = ({ label, children }) => (
    <div className="flex justify-between gap-3">
      <span className="text-zinc-400 shrink-0">{label}</span>
      <span className="text-right min-w-0 truncate">{children}</span>
    </div>
  );
  return (
    <div onClick={onClose} className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-6 overlay-in">
      <div onClick={(e) => e.stopPropagation()} className="glass rounded-3xl w-full max-w-3xl max-h-[88vh] overflow-hidden flex flex-col md:flex-row">
        <div className="md:w-[58%] bg-black/5 flex items-center justify-center p-4 min-h-[280px] max-h-[88vh]">
          {check.hasPhoto ? (
            <img src={receiptPhotoUrl(check.id)} alt="чек" className="max-w-full max-h-[82vh] object-contain rounded-xl" />
          ) : (
            <div className="flex flex-col items-center gap-2 text-zinc-400 py-16">
              <Icon name="receipt" className="w-10 h-10" />
              <div className="text-sm">Фото чека не сохранено</div>
            </div>
          )}
        </div>
        <div className="md:w-[42%] p-5 overflow-y-auto flex flex-col gap-4">
          <div className="flex items-start justify-between gap-3">
            <div>
              <div className="text-xl font-semibold tabular-nums">{check.amount && check.currency && check.currency !== "UNKNOWN" ? <>{check.amount.toLocaleString("ru")} <CurrencySymbol c={check.currency} /></> : <span className="text-base text-zinc-400 font-medium">сумма не распознана</span>}</div>
              <div className="text-[12px] text-zinc-400 mt-0.5">{check.method}</div>
            </div>
            <button onClick={onClose} aria-label="Закрыть" className="p-1.5 rounded-lg hover:bg-black/[0.06] text-zinc-400"><Icon name="x" className="w-4 h-4" /></button>
          </div>
          <div className={"inline-flex self-start items-center gap-2 text-[12px] px-2.5 py-1 rounded-full " + sBadge}>
            <span className={"w-[7px] h-[7px] rounded-full " + sDot} /> {sWord}
          </div>
          <div className="border-t border-black/[0.06] pt-4 space-y-2.5 text-sm">
            <R label="Аккаунт"><span className="inline-flex items-center gap-2"><Avatar name={check.account.name} color={check.account.color} avatarUrl={check.account.avatarUrl} size={20} />{check.account.name}</span></R>
            <R label="Владелец">{check.ownerName || "—"}</R>
            <R label="Клиент">{check.client}{check.clientUsername ? " · @" + check.clientUsername : ""}</R>
            <R label="Когда">{check.at || "—"}</R>
            {check.summary && (
              <div className="pt-1">
                <div className="text-zinc-400 mb-1">AI-распознавание</div>
                <div className="text-[12.5px] leading-snug">{check.summary}</div>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

function ReceiptActions({ checkId, onReviewed }) {
  // Кнопки «Подтвердить» / «Отклонить» с inline-индикатором.
  // После успеха — СРАЗУ дёргаем onReviewed(checkId, newStatus) чтобы
  // родительский ChecksPage обновил локальный state БЕЗ ожидания 20-сек
  // polling-а. Раньше тут был silent-fail ощущение: «нажал — ничего не
  // изменилось» 20 секунд.
  const [busy, setBusy] = useStateC("");  // "approve" | "reject" | ""
  const [err, setErr] = useStateC("");
  const action = async (kind) => {
    setBusy(kind);
    setErr("");
    try {
      if (kind === "approve") await approveReceipt(checkId);
      else await rejectReceipt(checkId);
      // Optimistic + refetch — статус в UI обновится мгновенно
      if (onReviewed) {
        try {
          await onReviewed(checkId, kind === "approve" ? "confirmed" : "rejected");
        } catch {}
      }
    } catch (e) {
      setErr(String(e.message || e).slice(0, 100));
    } finally {
      setBusy("");
    }
  };
  return (
    <div className="p-4 border-t border-zinc-800/80 shrink-0">
      <div className="flex gap-2">
        <button
          onClick={() => action("reject")}
          disabled={!!busy}
          className="flex-1 flex items-center justify-center gap-2 px-3.5 py-2.5 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-400 hover:bg-rose-500/20 disabled:opacity-50 text-sm font-medium"
        >
          <Icon name="x" className="w-3.5 h-3.5" /> {busy === "reject" ? "Отклоняю…" : "Отклонить"}
        </button>
        <button
          onClick={() => action("approve")}
          disabled={!!busy}
          className="flex-1 flex items-center justify-center gap-2 px-3.5 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium"
        >
          <Icon name="check" className="w-3.5 h-3.5" /> {busy === "approve" ? "Подтверждаю…" : "Подтвердить"}
        </button>
      </div>
      {err && (
        <div className="mt-2 text-[11px] text-rose-400 bg-rose-500/10 rounded-lg px-2 py-1.5">{err}</div>
      )}
    </div>
  );
}

Object.assign(window, { ChatsPage, ChecksPage });
