// Axiomic landing demo chat (Commit C of feat/demo-chat-foundation).
//
// Two modes, automatic fallback:
//   * "llm"   — backend SSE stream (Anthropic Sonnet via Commit B). Token
//               cap, judge moderation, per-IP / global daily budget enforced
//               server-side. Lead form rendered from a server-emitted
//               ``lead_form`` event (NOT raw model HTML).
//   * "regex" — in-page BOT_SCRIPT (the canned conversation we shipped
//               before the LLM backend existed). Used as fallback when
//               /start returns 503 (demo_chat_disabled / budget) or 403
//               (turnstile_failed). Also used per-turn within "llm" mode
//               when a /message call fails or the conv is killed.
//
// Session persistence
// -------------------
// We persist a small marker in ``localStorage["axiomic_demo_chat_session_v1"]``
// so a refresh inside the same browser tab doesn't burn the per-IP quota
// on a fresh /start. The actual session_id + conv_id are stored on the
// signed-cookie session by the backend; localStorage just carries the
// FACT that we have a server-side session so the UI can resume optimally.
// The marker is CLEARED when:
//   * /message returns 410 conv_killed (judge or daily-cost gate)
//   * lead capture form succeeds (conversation is logically "done")
//
// Turnstile
// ---------
// Invisible widget — siteverify only fires AFTER the user actually types
// something. Lazy ``turnstile.execute()`` produces the token that goes in
// the /start body. Dev short-circuits when the backend secret is the CF
// official ``1x00…00AA`` test pair (see public_demo_chat._verify_turnstile).

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

const STORAGE_KEY = "axiomic_demo_chat_session_v1";
// TTL for the persisted "regex" downgrade marker. localStorage has no
// expiration, so a transient backend outage on day N would otherwise
// permanently downgrade the visitor to scripted-bot mode on day N+1
// (until they manually clear storage). 6 hours is long enough to ride
// out a real outage within a session, short enough to recover on the
// next-day visit when the backend is healthy again.
const MODE_REGEX_TTL_MS = 6 * 60 * 60 * 1000;

// Read the persisted mode marker, honouring the 6h TTL. Returns "regex"
// only when a fresh, non-expired marker is present; any malformed /
// missing / expired value yields "" (caller defaults to optimistic LLM).
//
// Storage shape is the JSON ``{mode: "regex", expiresAt: <ms epoch>}``.
// We tolerate the legacy bare-string format ("regex") by treating it as
// expired — the next time we set it, we'll write the new shape.
function _readPersistedMode() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return "";
    // Fast-path: if it doesn't start with `{`, it's the legacy bare
    // string. Treat as expired so we don't trust it across days.
    if (raw[0] !== "{") return "";
    const parsed = JSON.parse(raw);
    if (!parsed || typeof parsed !== "object") return "";
    if (parsed.mode !== "regex") return "";
    const exp = typeof parsed.expiresAt === "number" ? parsed.expiresAt : 0;
    if (Date.now() >= exp) {
      // Expired — drop it so we start fresh in LLM mode this visit.
      try {
        localStorage.removeItem(STORAGE_KEY);
      } catch (_e) {
        /* ignore */
      }
      return "";
    }
    return "regex";
  } catch (_e) {
    return "";
  }
}

const BOT_SCRIPT = [
  {
    match: /(здравств|привет|добр|hi|hello)/i,
    reply: [
      "Здравствуйте! Я — AI-консультант Axiomic. Расскажу, как мы помогаем строительным компаниям не терять заявки.",
      "С чего удобнее начать: как это работает, сколько стоит, или что получаете через месяц?",
    ],
  },
  {
    match: /(цен|стоим|тариф|почём|сколько стои|сколько это)/i,
    reply: [
      "20 000 ₽ / месяц. Одна подписка, без доплат за диалоги и каналы.",
      "Первая неделя — бесплатно. Подключаем мессенджеры, грузим ваши документы, настраиваем воронку. Работаете 7 дней в продакшене, дальше решаете.",
      "Для сравнения: один менеджер с окладом + KPI + налоги выходит в 90–130 тыс ₽/мес и не работает ночью.",
    ],
  },
  {
    match: /(проб|бесплат|тест|демо|триал|trial)/i,
    reply: [
      "Да, неделя бесплатно, в продакшене. Без карты.",
      "Подключаем Telegram, ВКонтакте, Авито и MAX. Загружаем ваш прайс и каталог в базу знаний. С первого дня AI отвечает клиентам и создаёт лиды в Битрикс24.",
      "Через 7 дней в кабинете увидите, сколько диалогов закрыли без менеджера и сколько лидов ушло в CRM.",
    ],
  },
  {
    match: /(выгод|оkупае|окупае|экономи|прибыл|деньг|roi)/i,
    reply: [
      "Считаем по-строительному, в деньгах:",
      "Если у вас сейчас приходит 200 заявок/мес и менеджеры успевают обработать 60% — это 80 упущенных лидов. При конверсии в сделку 5% и среднем чеке 3 млн ₽ — это 12 млн ₽ упущенной выручки. В месяц.",
      "Axiomic отвечает на 100% заявок за 3 секунды. Даже если вернуть треть упущенных — подписка окупается десятки раз.",
    ],
  },
  {
    match: /(как работа|как устро|принцип|схем)/i,
    reply: [
      "Пять шагов, без участия менеджера до последнего:",
      "1. Клиент пишет в Telegram / ВК / Авито — AI отвечает за 1–3 сек.\n2. Уточняет: метраж, регион, сроки, бюджет.\n3. Цитирует ваш прайс и каталог (RAG — не выдумывает).\n4. Квалифицирует по вашим критериям.\n5. Создаёт сделку в Битрикс24 с полной историей диалога.",
      "Менеджер открывает Битрикс утром — там горячие лиды, готовые к звонку.",
    ],
  },
  {
    match: /(срок|быстр|когда|внедр|запуск|неделю|дней)/i,
    reply: [
      "7 рабочих дней от договора до первого диалога в продакшене.",
      "День 1–2: подключаем мессенджеры и Битрикс24 (webhook за 10 минут).\nДень 3–4: загружаем ваши прайсы, каталоги, регламенты в базу знаний.\nДень 5–6: настраиваем воронку и сценарии под вашу нишу.\nДень 7: старт в боевом режиме.",
      "Без кода. Без ваших разработчиков. Без интегратора за 500 000 ₽.",
    ],
  },
  {
    match: /(документ|прайс|база знан|rag|каталог|обуч)/i,
    reply: [
      "Загружаете PDF, Excel, Word — прайсы, каталоги, регламенты, типовые проекты.",
      "Сейчас в среднем клиенте: 796 документов, 2 категории, 639 тыс символов в индексе. AI находит точную строку в вашем прайсе и цитирует — не округляет, не выдумывает цены.",
      "Обновили прайс — заменили файл в кабинете. Через 2 минуты AI уже отвечает по новым ценам.",
    ],
  },
  {
    match: /(канал|мессендж|telegram|whatsapp|авито|вконтакт|вк|max)/i,
    reply: [
      "Четыре канала из коробки: Telegram, ВКонтакте, Авито, MAX.",
      "Все диалоги в одном инбоксе в кабинете. Одна база знаний, одна воронка, одна аналитика.",
      "WhatsApp и amoCRM — в разработке, Q3 2026.",
    ],
  },
  {
    match: /(замен|увол|менеджер|человек|сотрудн)/i,
    reply: [
      "Нет. Axiomic закрывает первую линию: мгновенный ответ, квалификация, передача тёплых в CRM.",
      "Ваши менеджеры перестают тратить день на «а сколько стоит?» и «а вы делаете фундамент?». Получают готовых к разговору клиентов.",
      "По запросу или ключевой фразе AI молча передаёт диалог оператору — клиент не видит «перехода».",
    ],
  },
  {
    match: /(битрикс|crm|сделк|лид|amocrm)/i,
    reply: [
      "Битрикс24 — из коробки. Подключение через webhook, 10 минут.",
      "Когда AI квалифицировал клиента — создаётся сделка с заполненными полями, тегами и полной историей диалога. Менеджер открывает Битрикс — видит готового клиента с контекстом.",
      "Зеркалирование не требуется: если у вас подключён Консультант через Открытые линии, AI-ответы уже видны оператору в чате.",
    ],
  },
  {
    match: /(безопас|данн|152|ndа|nda|конфид)/i,
    reply: [
      "Серверы в РФ, шифрование TLS + AES-256. В реестре операторов ПДн Роскомнадзора (152-ФЗ).",
      "Диалоги не используются для обучения сторонних моделей. По запросу — NDA и аудит безопасности перед подписанием.",
    ],
  },
  {
    match: /(боится|поймёт|поймет|палит|спалит|бот|робот)/i,
    reply: [
      "Axiomic не притворяется человеком — это против закона о рекламе.",
      "Но тон и темп не вызывают отторжения: задержка перед ответом, «печатает…», живые формулировки, опечатки опционально. По данным пилотных клиентов — до 72% клиентов доходят до звонка, не задав «это бот?».",
    ],
  },
  {
    match: /(ноч|выходн|круглосуточ|24|7)/i,
    reply: [
      "24 / 7. Без выходных, без отпусков, без больничных.",
      "По статистике — 40% заявок в стройке приходят после 19:00 и по субботам. Сейчас эти клиенты уходят к тому, кто ответил. С Axiomic — отвечаете вы.",
    ],
  },
  {
    match: /(рабоч|час|график)/i,
    reply: [
      "Можно настроить: например, «AI консультирует 24/7, но о передаче менеджеру пишет: свяжется в рабочие часы 9:00–18:00 МСК».",
      "Клиент получает ответ мгновенно. Менеджер — уведомление в Telegram в своё рабочее время.",
    ],
  },
];

const FALLBACK = [
  "Хороший вопрос — сразу свяжу вас с человеком, ответит точнее.",
  "Заодно: что вам сейчас важнее понять — цена, сроки внедрения или как это окупится?",
];

const INITIAL_MESSAGES = [
  {
    who: "bot",
    text: "Здравствуйте! Я — AI-консультант Axiomic. Помогаю строительным компаниям не терять заявки, которые приходят ночью, в выходные и с Авито.",
    time: "14:02",
  },
  {
    who: "bot",
    text: "Могу рассказать про цены, сроки запуска или как это окупается. Или спросите своими словами — отвечу.",
    time: "14:02",
  },
];

const SUGGESTIONS = [
  "Сколько стоит?",
  "Есть бесплатный период?",
  "Как быстро можно запустить?",
  "Как это окупится?",
];

function findReply(text) {
  for (const entry of BOT_SCRIPT) {
    if (entry.match.test(text)) return entry.reply;
  }
  return FALLBACK;
}

function fmtTime() {
  const d = new Date();
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}

// ── Turnstile invisible widget ─────────────────────────────────────────────
//
// Loaded by the host page (index.html). We render it lazily on first user
// input so a passive landing visitor never burns a CF rate slot. The
// official always-passes test sitekey is the default (set by host page);
// production deploys overwrite ``window.CF_TURNSTILE_SITE_KEY``.
//
// Invisibility is controlled by the SITEKEY mode in the CF dashboard
// (NOT by a `size` param — CF rejects the literal value 'invisible' with
// "expected compact, flexible, or normal"). We hide the host container
// via inline CSS instead, so even a "managed" sitekey stays out of the
// visitor's way for the demo widget.
//
// ``execute()`` returns a token via the `callback` we registered at render
// time. A second concurrent caller while a token is in flight piggybacks
// on the in-flight promise (single shared resolver) — preventing the
// previous "second call overwrites first resolver, first promise leaks
// forever" race.
const TURNSTILE_RENDER_HOST_ID = "axiomic-chat-turnstile";
let _turnstileWidgetId = null;
// Single shared in-flight promise. While non-null, all concurrent callers
// of getTurnstileToken() await the same resolution. Set to null on
// callback / error / timeout so the next call triggers a fresh execute().
let _turnstileInFlight = null;
let _turnstileInFlightResolve = null;

function _resolveInFlight(token) {
  if (_turnstileInFlightResolve) {
    const r = _turnstileInFlightResolve;
    _turnstileInFlight = null;
    _turnstileInFlightResolve = null;
    r(token);
  }
}

function _ensureTurnstileHost() {
  if (document.getElementById(TURNSTILE_RENDER_HOST_ID)) return;
  const div = document.createElement("div");
  div.id = TURNSTILE_RENDER_HOST_ID;
  // Off-screen rather than display:none — some Turnstile modes require
  // the host to participate in layout for the iframe to mount.
  div.style.position = "absolute";
  div.style.left = "-9999px";
  div.style.top = "-9999px";
  div.style.width = "1px";
  div.style.height = "1px";
  div.style.overflow = "hidden";
  document.body.appendChild(div);
}

function _renderTurnstileOnce() {
  if (_turnstileWidgetId !== null) return;
  if (typeof window === "undefined" || !window.turnstile) return;
  _ensureTurnstileHost();
  const siteKey = window.CF_TURNSTILE_SITE_KEY || "1x00000000000000000000AA";
  try {
    _turnstileWidgetId = window.turnstile.render(`#${TURNSTILE_RENDER_HOST_ID}`, {
      sitekey: siteKey,
      // No `size` — invisibility is configured per-sitekey in CF dashboard.
      // Passing the rejected value here would make CF reject render().
      callback: (token) => _resolveInFlight(token || ""),
      "error-callback": () => _resolveInFlight(""),
      "timeout-callback": () => _resolveInFlight(""),
    });
  } catch (_err) {
    _turnstileWidgetId = null;
  }
}

async function getTurnstileToken() {
  // Try to render the widget — if Turnstile script never loaded, return
  // empty token; backend will fail-close to ``fallback: regex`` and the
  // chat continues in script mode.
  _renderTurnstileOnce();
  if (_turnstileWidgetId === null || !window.turnstile) return "";
  // Fast-path: invisible Turnstile auto-executes on render() (~620ms
  // after script load). If a fresh token is already sitting in the
  // widget (auto-execute completed before user clicked, or a prior
  // execute() resolved without our callback being wired in time),
  // return it directly without re-executing. Tokens are valid ~5min
  // per CF docs and the widget rotates them as needed, so a stale
  // token from the same page-load session is acceptable here.
  try {
    if (typeof window.turnstile.getResponse === "function") {
      const existing = window.turnstile.getResponse(_turnstileWidgetId);
      if (existing && typeof existing === "string" && existing.length > 0) {
        return existing;
      }
    }
  } catch (_getRespErr) {
    // getResponse can throw if the widget isn't fully rendered yet;
    // fall through to the execute() path.
  }
  // Coalesce concurrent callers onto a single in-flight promise so a
  // second call can never overwrite the first resolver (which would
  // leak the first promise forever).
  if (_turnstileInFlight) return _turnstileInFlight;
  // Track whether the resolver fired synchronously inside the executor
  // (via _resolveInFlight from a sync reset/execute callback or our
  // own catch path). If it did, the promise is already settled to ""
  // and we must NOT publish it to _turnstileInFlight — doing so would
  // make every subsequent caller hit the cached "" instead of
  // triggering a fresh execute().
  let settledSync = false;
  const promise = new Promise((resolve) => {
    // Wrap resolve so we can both detect sync settlement and clear
    // the module-level resolver atomically.
    const wrappedResolve = (token) => {
      settledSync = true;
      resolve(token);
    };
    // Clear any prior in-flight widget state before re-executing.
    // CF emits "Call to execute() on a widget that is already executing"
    // when a previous execute() hasn't resolved (e.g., page-load
    // auto-execute still pending → user clicks chip → execute fires
    // again on same widget). reset() is idempotent per CF docs.
    //
    // IMPORTANT: reset() can synchronously fire error-callback /
    // timeout-callback on the widget. If we'd already assigned
    // _turnstileInFlightResolve = wrappedResolve, that callback would
    // null our resolver before execute() runs → caller hangs until
    // the 6s safety timeout. So we reset FIRST, then publish the
    // resolver to the module scope, then execute().
    try { window.turnstile.reset(_turnstileWidgetId); } catch (_resetErr) { /* ignore */ }
    _turnstileInFlightResolve = wrappedResolve;
    try {
      window.turnstile.execute(_turnstileWidgetId);
    } catch (_err) {
      _resolveInFlight("");
      return;
    }
    // Safety timeout — if CF never calls back within 6s, fall back.
    setTimeout(() => {
      // Only resolve if we're still the active in-flight call (the
      // callback path nulls _turnstileInFlightResolve on resolve).
      if (_turnstileInFlightResolve === wrappedResolve) {
        _resolveInFlight("");
      }
    }, 6000);
  });
  // Only publish to module global if not already settled synchronously.
  // If settledSync=true, the resolver path already nulled both globals;
  // we return the resolved promise to *this* caller but leave the
  // module slot clear so the NEXT call triggers a fresh execute().
  if (!settledSync) {
    _turnstileInFlight = promise;
  }
  return promise;
}

// ── SSE consumer ───────────────────────────────────────────────────────────
//
// EventSource is GET-only; /message is POST with a JSON body, so we use
// fetch + ReadableStream.getReader() + TextDecoder. Each frame is
// "event: <name>\ndata: <json>\n\n". We accumulate the buffer until a
// terminating "\n\n" then dispatch ``onEvent({event, data})``.

async function consumeSSE(url, body, onEvent, signal) {
  let resp;
  try {
    resp = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
      credentials: "include",
      body: JSON.stringify(body),
      signal,
    });
  } catch (e) {
    if (e && e.name === "AbortError") return { ok: false, status: 0, error: "aborted" };
    return { ok: false, status: 0, error: "network" };
  }
  if (!resp.ok) {
    let errText = `HTTP ${resp.status}`;
    try {
      const j = await resp.json();
      if (j && j.error) errText = j.error;
    } catch (_e) {
      /* keep default */
    }
    return { ok: false, status: resp.status, error: errText };
  }
  const reader = resp.body && resp.body.getReader ? resp.body.getReader() : null;
  if (!reader) return { ok: false, status: 0, error: "no_stream" };
  // Ensure the underlying stream is cancelled on signal-abort so the
  // ReadableStream lock is released (otherwise the connection stays
  // hot until GC / TCP RST).
  const onAbort = () => {
    try {
      reader.cancel();
    } catch (_e) {
      /* ignore */
    }
  };
  if (signal) {
    if (signal.aborted) {
      onAbort();
      return { ok: false, status: 0, error: "aborted" };
    }
    signal.addEventListener("abort", onAbort, { once: true });
  }
  const decoder = new TextDecoder("utf-8");
  let buffer = "";
  try {
    while (true) {
      let chunk;
      try {
        chunk = await reader.read();
      } catch (e) {
        if (e && e.name === "AbortError") return { ok: false, status: 0, error: "aborted" };
        throw e;
      }
      const { value, done } = chunk;
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      let idx;
      while ((idx = buffer.indexOf("\n\n")) !== -1) {
        const frame = buffer.slice(0, idx);
        buffer = buffer.slice(idx + 2);
        let evName = "message";
        let dataLines = [];
        for (const line of frame.split("\n")) {
          if (line.startsWith("event: ")) evName = line.slice(7).trim();
          else if (line.startsWith("data: ")) dataLines.push(line.slice(6));
        }
        if (dataLines.length === 0) continue;
        const rawData = dataLines.join("\n");
        let parsed = null;
        try {
          parsed = JSON.parse(rawData);
        } catch (_e) {
          parsed = rawData;
        }
        try {
          onEvent({ event: evName, data: parsed });
        } catch (_handlerErr) {
          /* handler bug — keep stream alive */
        }
      }
    }
    return { ok: true, status: 200 };
  } finally {
    if (signal) signal.removeEventListener("abort", onAbort);
    // Best-effort: release the reader lock (safe to call on a closed
    // reader; throws are swallowed).
    try {
      reader.releaseLock();
    } catch (_e) {
      /* ignore */
    }
  }
}

// ── Lead form (inline) ─────────────────────────────────────────────────────

function LeadForm({ initialFields, onSubmitted, onCancel }) {
  const [name, setName] = useState(initialFields?.name || "");
  const [contact, setContact] = useState(initialFields?.contact || "");
  const [company, setCompany] = useState(initialFields?.company || "");
  const [comment, setComment] = useState(initialFields?.comment || "");
  const [status, setStatus] = useState({ kind: "idle", text: "" });

  const handleSubmit = async (e) => {
    e.preventDefault();
    const cleanName = name.trim();
    const cleanContact = contact.trim();
    if (!cleanName || !cleanContact) {
      setStatus({ kind: "err", text: "Укажите имя и контакт." });
      return;
    }
    setStatus({ kind: "busy", text: "Отправляем…" });
    try {
      const resp = await fetch("/api/public-lead", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "same-origin",
        body: JSON.stringify({
          name: cleanName,
          contact: cleanContact,
          company: company.trim(),
          comment: comment.trim(),
          cta_source: "demo-chat",
        }),
      });
      const j = await resp.json().catch(() => ({ ok: false, error: "Ошибка сервера." }));
      if (!resp.ok || !j.ok) {
        setStatus({ kind: "err", text: j.error || "Не удалось отправить." });
        return;
      }
      setStatus({ kind: "ok", text: "Заявка отправлена! Свяжемся в ближайшее время." });
      setTimeout(() => onSubmitted && onSubmitted(), 1200);
    } catch (_e) {
      setStatus({ kind: "err", text: "Нет связи. Попробуйте позже." });
    }
  };

  const inpStyle = {
    width: "100%",
    padding: "8px 10px",
    borderRadius: 8,
    border: "1px solid rgba(255,255,255,0.12)",
    background: "rgba(255,255,255,0.04)",
    color: "inherit",
    fontSize: 13,
    boxSizing: "border-box",
    fontFamily: "inherit",
  };

  return (
    <form
      onSubmit={handleSubmit}
      style={{
        marginTop: 8,
        padding: 14,
        borderRadius: 12,
        background: "rgba(255,255,255,0.03)",
        border: "1px solid rgba(255,255,255,0.08)",
        display: "flex",
        flexDirection: "column",
        gap: 8,
      }}
      aria-label="Форма заявки"
    >
      <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
        Оставьте контакт — свяжемся в течение часа
      </div>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Как к вам обращаться?"
        maxLength={80}
        required
        style={inpStyle}
        aria-label="Имя"
      />
      <input
        type="text"
        value={contact}
        onChange={(e) => setContact(e.target.value)}
        placeholder="Телефон, Telegram или email"
        maxLength={120}
        required
        style={inpStyle}
        aria-label="Контакт"
      />
      <input
        type="text"
        value={company}
        onChange={(e) => setCompany(e.target.value)}
        placeholder="Компания (опционально)"
        maxLength={120}
        style={inpStyle}
        aria-label="Компания"
      />
      <textarea
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        placeholder="Что хотите решить?"
        maxLength={400}
        rows={2}
        style={{ ...inpStyle, resize: "vertical", fontFamily: "inherit" }}
        aria-label="Комментарий"
      />
      <div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 4 }}>
        <button
          type="submit"
          disabled={status.kind === "busy"}
          style={{
            padding: "8px 16px",
            borderRadius: 8,
            background: "var(--accent, #22c55e)",
            color: "#0a0a0b",
            border: "none",
            fontSize: 13,
            fontWeight: 700,
            cursor: status.kind === "busy" ? "not-allowed" : "pointer",
            opacity: status.kind === "busy" ? 0.6 : 1,
          }}
        >
          {status.kind === "busy" ? "Отправляем…" : "Отправить"}
        </button>
        {onCancel && (
          <button
            type="button"
            onClick={onCancel}
            style={{
              padding: "8px 12px",
              borderRadius: 8,
              background: "transparent",
              color: "rgba(255,255,255,0.6)",
              border: "1px solid rgba(255,255,255,0.12)",
              fontSize: 13,
              cursor: "pointer",
            }}
          >
            Не сейчас
          </button>
        )}
        {status.text && (
          <span
            style={{
              fontSize: 12,
              color:
                status.kind === "err"
                  ? "#f43f5e"
                  : status.kind === "ok"
                  ? "var(--accent, #22c55e)"
                  : "rgba(255,255,255,0.6)",
            }}
            role={status.kind === "err" ? "alert" : undefined}
          >
            {status.text}
          </span>
        )}
      </div>
    </form>
  );
}

// ── ChatDemo (main) ────────────────────────────────────────────────────────

function ChatDemo() {
  const [messages, setMessages] = useState(INITIAL_MESSAGES);
  const [input, setInput] = useState("");
  const [typing, setTyping] = useState(false);
  const [showSuggestions, setShowSuggestions] = useState(true);
  // "llm" | "regex" — start optimistic; downgrade on first failure for the
  // remainder of the page lifetime. localStorage marker hints at a prior
  // server-side session but is NEVER load-bearing (cookie is source of truth).
  const [mode, setMode] = useState(() => {
    return _readPersistedMode() === "regex" ? "regex" : "llm";
  });
  const [busy, setBusy] = useState(false);
  const [killed, setKilled] = useState(false);
  const [leadForm, setLeadForm] = useState(null); // {fields: {...}} | null
  const [errBanner, setErrBanner] = useState("");
  const bodyRef = useRef(null);
  // Double-submit guard (memory feedback: ai-chat / playground / dialogs).
  const sendAttemptRef = useRef(0);
  // Track whether we've ever bootstrapped a server-side conversation for
  // THIS tab. Bootstrap is idempotent server-side (cookie carries conv_id),
  // but we don't need to call /start more than once per tab.
  const startedRef = useRef(false);
  // Active AbortController for the in-flight /message SSE stream. Aborted
  // on rapid re-submit, killed/error frames, fallback-to-regex, and unmount.
  const sseControllerRef = useRef(null);
  // Active setTimeout IDs for playScript so we can clear them on rapid
  // resubmit or unmount (avoids state-after-unmount React warnings and
  // overlapping script playback).
  const scriptTimersRef = useRef([]);
  // Banner auto-dismiss timer — also needs unmount cleanup.
  const bannerTimerRef = useRef(null);

  const clearScriptTimers = useCallback(() => {
    for (const id of scriptTimersRef.current) {
      try {
        clearTimeout(id);
      } catch (_e) {
        /* ignore */
      }
    }
    scriptTimersRef.current = [];
  }, []);

  const abortSSE = useCallback(() => {
    const c = sseControllerRef.current;
    sseControllerRef.current = null;
    if (c) {
      try {
        c.abort();
      } catch (_e) {
        /* ignore */
      }
    }
  }, []);

  useEffect(() => {
    if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
  }, [messages, typing, leadForm]);

  // Unmount cleanup — abort any in-flight SSE + clear pending script
  // timeouts + banner timer to avoid setState-after-unmount warnings.
  useEffect(() => {
    return () => {
      abortSSE();
      clearScriptTimers();
      if (bannerTimerRef.current) {
        clearTimeout(bannerTimerRef.current);
        bannerTimerRef.current = null;
      }
    };
  }, [abortSSE, clearScriptTimers]);

  const persistMode = useCallback((m) => {
    try {
      if (m === "regex") {
        // Store as JSON {mode, expiresAt} so a transient outage doesn't
        // permanently downgrade the visitor (see MODE_REGEX_TTL_MS).
        localStorage.setItem(
          STORAGE_KEY,
          JSON.stringify({
            mode: "regex",
            expiresAt: Date.now() + MODE_REGEX_TTL_MS,
          }),
        );
      } else {
        localStorage.removeItem(STORAGE_KEY);
      }
    } catch (_e) {
      /* private browsing — ignore */
    }
  }, []);

  const showBanner = useCallback((text, ttl = 4000) => {
    setErrBanner(text);
    if (bannerTimerRef.current) {
      clearTimeout(bannerTimerRef.current);
    }
    bannerTimerRef.current = setTimeout(() => {
      setErrBanner("");
      bannerTimerRef.current = null;
    }, ttl);
  }, []);

  const fallbackToRegex = useCallback(
    (reason) => {
      setMode("regex");
      persistMode("regex");
      if (reason) {
        // Soft banner — do NOT show technical detail to the visitor.
        showBanner(reason, 4000);
      }
    },
    [persistMode, showBanner],
  );

  const playScript = useCallback(
    (replies) => {
      // Cancel any previously-scheduled script lines so a rapid resubmit
      // can't overlap two playbacks (each would race to clear `typing`).
      clearScriptTimers();
      setTyping(true);
      const baseDelay = 700 + Math.random() * 400;
      replies.forEach((line, i) => {
        const id = setTimeout(() => {
          if (i === 0) setTyping(false);
          setMessages((m) => [...m, { who: "bot", text: line, time: fmtTime() }]);
          if (i < replies.length - 1) {
            setTyping(true);
            const id2 = setTimeout(() => setTyping(false), 500);
            scriptTimersRef.current.push(id2);
          }
        }, baseDelay + i * 1100);
        scriptTimersRef.current.push(id);
      });
    },
    [clearScriptTimers],
  );

  // Ensure server-side conversation exists. Idempotent. Returns true on
  // success; false → caller should fall back to regex.
  const ensureStarted = useCallback(async () => {
    if (startedRef.current) return true;
    if (mode === "regex") return false;
    const token = await getTurnstileToken();
    try {
      const resp = await fetch("/api/public-demo-chat/start", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ turnstile_token: token }),
      });
      let body = null;
      try {
        body = await resp.json();
      } catch (_e) {
        body = null;
      }
      if (!resp.ok || !body || !body.ok) {
        // 503 / 403 / 429 → fall back. 503 with daily_budget_exceeded is
        // not the visitor's fault; phrase the banner neutrally.
        const err = (body && body.error) || `HTTP ${resp.status}`;
        fallbackToRegex(
          err === "turnstile_failed"
            ? "Проверка анти-бот не прошла. Отвечаю по сценарию."
            : err === "rate_limited"
            ? "Слишком частые запросы. Отвечаю по сценарию."
            : err === "daily_budget_exceeded"
            ? "Демо-чат на сегодня исчерпан. Отвечаю по сценарию."
            : "Бэкенд недоступен. Отвечаю по сценарию.",
        );
        return false;
      }
      startedRef.current = true;
      return true;
    } catch (_e) {
      fallbackToRegex("Бэкенд недоступен. Отвечаю по сценарию.");
      return false;
    }
  }, [mode, fallbackToRegex]);

  const sendViaBackend = useCallback(
    async (text) => {
      // We append a placeholder assistant bubble that we'll mutate as
      // delta frames arrive. Bubble index pointer survives setMessages
      // closures via functional updates that splice the last bubble.
      let assistantBubbleIndex = -1;
      setMessages((m) => {
        const next = [...m, { who: "bot", text: "", time: fmtTime() }];
        assistantBubbleIndex = next.length - 1;
        return next;
      });
      setTyping(true);

      // Abort any previous in-flight stream before starting a new one,
      // then install our controller for this turn.
      abortSSE();
      const controller =
        typeof AbortController !== "undefined" ? new AbortController() : null;
      sseControllerRef.current = controller;

      let gotAny = false;
      const result = await consumeSSE(
        "/api/public-demo-chat/message",
        { message: text },
        ({ event, data }) => {
          if (event === "delta") {
            gotAny = true;
            const chunk = data && typeof data.text === "string" ? data.text : "";
            if (!chunk) return;
            setTyping(false);
            setMessages((m) => {
              if (assistantBubbleIndex < 0 || assistantBubbleIndex >= m.length) return m;
              const next = m.slice();
              const prev = next[assistantBubbleIndex];
              next[assistantBubbleIndex] = { ...prev, text: prev.text + chunk };
              return next;
            });
          } else if (event === "lead_form") {
            const fields = data && typeof data === "object" ? data.fields || {} : {};
            setLeadForm({ fields });
          } else if (event === "killed") {
            setKilled(true);
            try {
              localStorage.removeItem(STORAGE_KEY);
            } catch (_e) {
              /* ignore */
            }
            startedRef.current = false;
            // Release reader/connection — backend already wrote done.
            try {
              controller && controller.abort();
            } catch (_e) {
              /* ignore */
            }
          } else if (event === "error") {
            // Stream-level error from the model SDK. If we never saw a
            // delta, fall back per-turn so the visitor isn't left with
            // an empty bubble.
            if (!gotAny) {
              setMessages((m) => {
                if (assistantBubbleIndex < 0 || assistantBubbleIndex >= m.length) return m;
                const next = m.slice();
                next.splice(assistantBubbleIndex, 1);
                return next;
              });
              setTyping(false);
              playScript(findReply(text));
            }
            try {
              controller && controller.abort();
            } catch (_e) {
              /* ignore */
            }
          } else if (event === "done") {
            setTyping(false);
            // No-op visually — bubble already populated by deltas.
          }
        },
        controller ? controller.signal : undefined,
      );

      // Clear ref only if we're still the active controller (a newer
      // turn may have already installed its own controller).
      if (sseControllerRef.current === controller) {
        sseControllerRef.current = null;
      }

      setTyping(false);

      // Aborted (by unmount, rapid resubmit, or killed/error handler)
      // — leave UI as-is; the new turn will manage it.
      if (!result.ok && result.error === "aborted") {
        return;
      }

      if (!result.ok) {
        // /message returned a non-2xx (no SSE body to stream). Remove the
        // empty placeholder bubble and decide whether to fall back
        // per-turn or permanently.
        setMessages((m) => {
          if (assistantBubbleIndex < 0 || assistantBubbleIndex >= m.length) return m;
          if (m[assistantBubbleIndex] && m[assistantBubbleIndex].text === "") {
            const next = m.slice();
            next.splice(assistantBubbleIndex, 1);
            return next;
          }
          return m;
        });
        if (result.status === 410) {
          // conv_killed — backend cleared the cookie; clear our marker
          // and downgrade for the rest of the session.
          setKilled(true);
          try {
            localStorage.removeItem(STORAGE_KEY);
          } catch (_e) {
            /* ignore */
          }
          startedRef.current = false;
          fallbackToRegex("Этот диалог закрыт. Отвечаю по сценарию.");
        } else if (
          result.status === 429 ||
          result.status === 413 ||
          result.status === 503
        ) {
          // Per-turn soft fallback — don't downgrade mode (visitor might
          // come back tomorrow).
          playScript(findReply(text));
          showBanner(
            result.error === "per_conv_msg_limit_exceeded"
              ? "Лимит сообщений в одном диалоге."
              : result.error === "input_cap_exceeded"
              ? "Слишком длинный диалог. Ответил по сценарию."
              : "Не получилось ответить. Отвечаю по сценарию.",
            4500,
          );
        } else {
          // 4xx / 5xx — fall back fully.
          fallbackToRegex("Бэкенд недоступен. Отвечаю по сценарию.");
          playScript(findReply(text));
        }
      } else if (!gotAny) {
        // Stream ended without any delta — likely an SDK error before
        // first token. Per-turn fallback.
        setMessages((m) => {
          if (assistantBubbleIndex < 0 || assistantBubbleIndex >= m.length) return m;
          if (m[assistantBubbleIndex] && m[assistantBubbleIndex].text === "") {
            const next = m.slice();
            next.splice(assistantBubbleIndex, 1);
            return next;
          }
          return m;
        });
        playScript(findReply(text));
      }
    },
    [playScript, fallbackToRegex, abortSSE, showBanner],
  );

  const sendMessage = useCallback(
    async (text) => {
      const clean = (text || "").trim();
      if (!clean) return;
      if (killed) return;
      const attempt = ++sendAttemptRef.current;
      // Cancel any in-flight stream and pending script lines so a rapid
      // resubmit can't overlap two responses.
      abortSSE();
      clearScriptTimers();
      // Append user bubble immediately for snappy UX.
      const userMsg = { who: "user", text: clean, time: fmtTime() };
      setMessages((m) => [...m, userMsg]);
      setInput("");
      setShowSuggestions(false);
      setBusy(true);
      try {
        if (mode === "llm") {
          const ok = await ensureStarted();
          if (ok && attempt === sendAttemptRef.current) {
            await sendViaBackend(clean);
            return;
          }
          // Either start failed (fallbackToRegex set mode) OR a newer
          // attempt has superseded ours — bail in both cases.
          if (attempt !== sendAttemptRef.current) return;
        }
        // regex mode (or downgrade just happened)
        playScript(findReply(clean));
      } finally {
        if (attempt === sendAttemptRef.current) setBusy(false);
      }
    },
    [mode, killed, ensureStarted, sendViaBackend, playScript, abortSSE, clearScriptTimers],
  );

  const handleSubmit = (e) => {
    e.preventDefault();
    if (busy) return;
    sendMessage(input);
  };

  const handleLeadSubmitted = useCallback(() => {
    setLeadForm(null);
    try {
      localStorage.removeItem(STORAGE_KEY);
    } catch (_e) {
      /* ignore */
    }
    // After a successful lead capture the conversation is logically
    // "done" — let the AI thank the user (one bot bubble) and stop
    // soliciting further messages by leaving busy=false but adding a
    // gentle wrap-up.
    setMessages((m) => [
      ...m,
      {
        who: "bot",
        text: "Спасибо! Записал ваши контакты — менеджер свяжется в течение часа.",
        time: fmtTime(),
      },
    ]);
  }, []);

  const handleLeadCancel = useCallback(() => setLeadForm(null), []);

  return (
    <div className="chat" role="region" aria-label="Демо-консультация">
      <div className="chat__header">
        <div className="chat__who">
          <div className="chat__avatar">
            <img src="/landing-assets/v2/logo.png" alt="" className="chat__avatar-img" aria-hidden="true" />
          </div>
          <div>
            <div className="chat__name">AI-консультант Axiomic</div>
            <div className="chat__status">
              <span className="d"></span> Отвечает за 1–3 сек · 24/7
            </div>
          </div>
        </div>
        <div className="chat__channel">
          <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
            <path d="M22 4l-10 16-4-7-8-3 22-6z" />
          </svg>
          Демо
        </div>
      </div>
      <div className="chat__body" ref={bodyRef}>
        {messages.map((m, i) => (
          <div key={i} className={`msg msg--${m.who}`}>
            {(m.text || "").split("\n").map((line, j) => (
              <div key={j}>{line || " "}</div>
            ))}
            <span className="msg__meta">
              {m.time}
              {m.who === "bot" ? " · AI" : ""}
            </span>
          </div>
        ))}
        {typing && (
          <div className="msg msg--bot typing" aria-label="Бот печатает">
            <span></span>
            <span></span>
            <span></span>
          </div>
        )}
        {leadForm && (
          <LeadForm
            initialFields={leadForm.fields || {}}
            onSubmitted={handleLeadSubmitted}
            onCancel={handleLeadCancel}
          />
        )}
        {killed && (
          <div
            style={{
              marginTop: 8,
              padding: "8px 12px",
              borderRadius: 8,
              background: "rgba(244,63,94,0.08)",
              border: "1px solid rgba(244,63,94,0.25)",
              color: "#f43f5e",
              fontSize: 12,
            }}
            role="status"
          >
            Диалог завершён. Перезагрузите страницу, чтобы начать новый.
          </div>
        )}
      </div>
      {errBanner && (
        <div
          style={{
            padding: "6px 14px",
            fontSize: 11,
            color: "rgba(255,255,255,0.55)",
            borderTop: "1px solid rgba(255,255,255,0.06)",
            background: "rgba(255,255,255,0.02)",
          }}
          role="status"
        >
          {errBanner}
        </div>
      )}
      {showSuggestions && !killed && (
        <div className="chat__suggestions">
          {SUGGESTIONS.map((s, i) => (
            <button key={i} onClick={() => sendMessage(s)} disabled={busy}>
              {s}
            </button>
          ))}
        </div>
      )}
      <form className="chat__input" onSubmit={handleSubmit}>
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder={killed ? "Диалог закрыт" : "Спросите про продукт…"}
          aria-label="Сообщение"
          disabled={killed || busy}
        />
        <button type="submit" disabled={!input.trim() || busy || killed} aria-label="Отправить">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M22 2L11 13" />
            <path d="M22 2l-7 20-4-9-9-4 20-7z" />
          </svg>
        </button>
      </form>
    </div>
  );
}

Object.assign(window, { ChatDemo });
