/*
 * Chat app — Nepl AI Hub
 *
 * Backend integration point:
 *   window.sendToBackend(modelId, messages) => Promise<string>
 *
 *   Replace the stub at the bottom of this file with your real API call.
 *   `messages` is an array of {role: 'user'|'assistant'|'system', content: string}.
 *   Return a string with the assistant's reply.
 */

const { useState, useEffect, useRef, useMemo, useCallback } = React;
const STORAGE_KEY = 'nepl.chat.v1';

// ─── persistence ─────────────────────────────────────────────────────────────
function loadState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (raw) return JSON.parse(raw);
  } catch (_) {}
  return null;
}
function saveState(state) {
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (_) {}
}

function uid() { return Math.random().toString(36).slice(2, 10); }
function nowTs() { return Date.now(); }

// How many of the most recent messages we send to the model. Older messages
// stay visible in the UI/history, but are not pushed to OpenRouter so we
// don't blow the context window or pay for redundant tokens.
const CONTEXT_MESSAGE_LIMIT = 20;

// Build the array of messages we send to the API. Drops error messages
// (so a previous failure doesn't poison the next prompt) and keeps only
// the last CONTEXT_MESSAGE_LIMIT entries.
function buildApiMessages(allMessages) {
  const clean = allMessages.filter(m => !m.error);
  const trimmed = clean.slice(-CONTEXT_MESSAGE_LIMIT);
  return trimmed.map(m => ({ role: m.role, content: m.content }));
}

function newConversation(modelId = window.DEFAULT_MODEL_ID || 'gpt-5.5') {
  return {
    id: uid(),
    title: 'Новый чат',
    modelId,
    messages: [],
    createdAt: nowTs(),
    updatedAt: nowTs(),
  };
}

// ─── message rendering ───────────────────────────────────────────────────────
function renderBubble(text) {
  const parts = text.split(/```(\w*)\n([\s\S]*?)```/g);
  const out = [];
  for (let i = 0; i < parts.length; i++) {
    if (i % 3 === 0) {
      const t = parts[i];
      if (t) out.push(<span key={i}>{t}</span>);
    } else if (i % 3 === 2) {
      out.push(<pre key={i}>{parts[i]}</pre>);
    }
  }
  return out;
}

// ─── quick prompts for empty state ───────────────────────────────────────────
const QUICK_PROMPTS = [
  { label: 'Идея', text: 'Дай 5 нестандартных идей для подкаста про технологии.' },
  { label: 'Код', text: 'Напиши функцию на Python, которая считает количество гласных в строке.' },
  { label: 'Письмо', text: 'Помоги составить вежливый отказ от рабочего проекта.' },
  { label: 'Учёба', text: 'Объясни простыми словами, что такое трансформер в ML.' },
];

// ─── model picker dropdown ───────────────────────────────────────────────────
const ModelPicker = ({ modelId, onChange }) => {
  const [open, setOpen] = useState(false);
  const [expanded, setExpanded] = useState(null);
  const ref = useRef(null);
  const current = window.getModel(modelId) || window.MODELS[0];

  useEffect(() => {
    const onDown = (e) => {
      if (ref.current && !ref.current.contains(e.target)) setOpen(false);
    };
    if (open) document.addEventListener('mousedown', onDown);
    return () => document.removeEventListener('mousedown', onDown);
  }, [open]);

  useEffect(() => {
    if (open) setExpanded(current.provider);
  }, [open]);

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button className="ca-model-pick" onClick={() => setOpen(v => !v)}>
        <span className="badge"><window.ModelMark model={current} size={14}/></span>
        <span style={{ fontSize: 13.5, fontWeight: 500 }}>{current.name}</span>
        <Icon name="arrow-down" size={12} stroke={2}/>
      </button>
      {open && (
        <div className="ca-model-dropdown ca-model-dropdown-wide">
          {window.PROVIDERS.map(p => {
            const isOpen = expanded === p.id;
            const isActive = current.provider === p.id;
            return (
              <div key={p.id} className={`ca-pgroup ${isOpen ? 'open' : ''} ${p.soon ? 'soon' : ''}`}>
                <button
                  className={`ca-pgroup-head ${isActive ? 'active' : ''}`}
                  onClick={() => setExpanded(isOpen ? null : p.id)}
                >
                  <span className="badge" style={{ color: p.markColor }}>
                    <window.ProviderMark providerId={p.id} size={14} color={p.markColor}/>
                  </span>
                  <span className="pg-title">
                    <span className="pg-name">{p.family}</span>
                  </span>
                  <span className="pg-chev"><Icon name="arrow-down" size={12}/></span>
                </button>
                {isOpen && (
                  <div className="ca-pgroup-list">
                    {p.variants.map(v => {
                      const tier = window.TIERS[v.tier];
                      const isPicked = v.id === modelId;
                      return (
                        <button
                          key={v.id}
                          className={`ca-vrow ${isPicked ? 'active' : ''} ${p.soon ? 'disabled' : ''}`}
                          onClick={() => { if (!p.soon) { onChange(v.id); setOpen(false); } }}
                        >
                          <span className="vrow-name">{v.name}</span>
                          {tier && (
                            <span className="vrow-tier" style={{
                              color: tier.color,
                              borderColor: `${tier.color}55`,
                              background: `${tier.color}1a`,
                            }}>{tier.label}</span>
                          )}
                        </button>
                      );
                    })}
                  </div>
                )}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
};

// ─── chat app ────────────────────────────────────────────────────────────────
const ChatApp = () => {
  const initial = loadState() || (() => {
    const conv = newConversation(window.DEFAULT_MODEL_ID);
    return { conversations: [conv], activeId: conv.id };
  })();

  const [state, setState] = useState(initial);
  const [draft, setDraft] = useState('');
  const [thinking, setThinking] = useState(false);
  const [menuOpen, setMenuOpen] = useState(false);
  const [search, setSearch] = useState('');
  const [useBackend, setUseBackend] = useState(false);
  const [expandedProviders, setExpandedProviders] = useState(() => {
    const s = loadState();
    const c = s ? s.conversations.find(x => x.id === s.activeId) : null;
    const mid = c?.modelId || window.DEFAULT_MODEL_ID;
    const m = window.getModel(mid);
    return new Set([m?.provider || 'openai']);
  });
  const scrollRef = useRef(null);
  const textareaRef = useRef(null);
  // Tracks the in-flight request so the Stop button can actually cancel it.
  const abortRef = useRef(null);

  const active = state.conversations.find(c => c.id === state.activeId) || state.conversations[0];
  const model = useMemo(
    () => window.getModel(active?.modelId) || window.getModel(window.DEFAULT_MODEL_ID) || window.MODELS[0],
    [active?.modelId]
  );

  // Persist on every state change
  useEffect(() => { saveState(state); }, [state]);

  // Autoscroll
  useEffect(() => {
    if (scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [active?.messages?.length, thinking]);

  // Autoresize composer
  useEffect(() => {
    const el = textareaRef.current;
    if (el) {
      el.style.height = 'auto';
      el.style.height = Math.min(el.scrollHeight, 200) + 'px';
    }
  }, [draft]);

  // ─── conversation actions ─────────────────────────────────────────────────
  const updateActive = useCallback((updater) => {
    setState(prev => ({
      ...prev,
      conversations: prev.conversations.map(c =>
        c.id === prev.activeId ? { ...updater(c), updatedAt: nowTs() } : c
      ),
    }));
  }, []);

  // Update a specific conversation by id (regardless of which one is active
  // right now). Used so a late reply lands in the chat where the user
  // actually pressed Send, even if they switched chats while waiting.
  const updateConv = useCallback((convId, updater) => {
    setState(prev => ({
      ...prev,
      conversations: prev.conversations.map(c =>
        c.id === convId ? { ...updater(c), updatedAt: nowTs() } : c
      ),
    }));
  }, []);

  const onNewChat = () => {
    const conv = newConversation(model.id);
    setState(prev => ({
      ...prev,
      conversations: [conv, ...prev.conversations],
      activeId: conv.id,
    }));
    setMenuOpen(false);
  };

  const onPickConv = (id) => {
    setState(prev => ({ ...prev, activeId: id }));
    setMenuOpen(false);
  };

  const onDeleteConv = (id, e) => {
    e.stopPropagation();
    setState(prev => {
      const rest = prev.conversations.filter(c => c.id !== id);
      if (rest.length === 0) {
        const conv = newConversation(window.DEFAULT_MODEL_ID);
        return { conversations: [conv], activeId: conv.id };
      }
      return {
        ...prev,
        conversations: rest,
        activeId: prev.activeId === id ? rest[0].id : prev.activeId,
      };
    });
  };

  const onPickModel = (modelId) => {
    updateActive(c => ({ ...c, modelId }));
  };

  // ─── send ─────────────────────────────────────────────────────────────────
  const send = async (text) => {
    const value = (text ?? draft).trim();
    if (!value || thinking) return;

    // Snapshot the conversation we're sending to, so a late reply lands in
    // the right chat even if the user switches chats while waiting.
    const conv = state.conversations.find(c => c.id === state.activeId);
    if (!conv) return;
    const convId = conv.id;
    const sendModelId = conv.modelId;

    const userMsg = { id: uid(), role: 'user', content: value, ts: nowTs() };
    const isFirstMessage = conv.messages.length === 0;
    const titleSource = value.slice(0, 40);
    const newTitle = isFirstMessage
      ? (value.length > 40 ? titleSource + '…' : titleSource)
      : conv.title;

    updateConv(convId, c => ({
      ...c,
      title: newTitle,
      messages: [...c.messages, userMsg],
    }));

    setDraft('');
    setThinking(true);

    // Build API payload from clean history + the new user message.
    const apiMessages = buildApiMessages([...conv.messages, userMsg]);

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

    try {
      const reply = await window.sendToBackend(sendModelId, apiMessages, {
        useBackend,
        signal: controller.signal,
      });
      const botMsg = {
        id: uid(),
        role: 'assistant',
        content: reply,
        modelId: sendModelId,
        ts: nowTs(),
      };
      updateConv(convId, c => ({ ...c, messages: [...c.messages, botMsg] }));
    } catch (err) {
      const aborted = err && (err.name === 'AbortError' || err.aborted);
      const botMsg = {
        id: uid(),
        role: 'assistant',
        content: aborted
          ? 'Запрос остановлен.'
          : `Ошибка соединения с моделью: ${err.message || err}.`,
        modelId: sendModelId,
        ts: nowTs(),
        error: true,
      };
      updateConv(convId, c => ({ ...c, messages: [...c.messages, botMsg] }));
    } finally {
      if (abortRef.current === controller) abortRef.current = null;
      setThinking(false);
    }
  };

  // Cancel the in-flight request, if any.
  const onStop = () => {
    if (abortRef.current) {
      try { abortRef.current.abort(); } catch (_) {}
    }
  };

  const onKey = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      send();
    }
  };

  // ─── filtered history ────────────────────────────────────────────────────
  const history = useMemo(() => {
    const list = [...state.conversations].sort((a, b) => b.updatedAt - a.updatedAt);
    if (!search.trim()) return list;
    const q = search.toLowerCase();
    return list.filter(c =>
      c.title.toLowerCase().includes(q) ||
      c.messages.some(m => m.content.toLowerCase().includes(q))
    );
  }, [state.conversations, search]);

  // ─── render ──────────────────────────────────────────────────────────────
  if (!active) return null;

  return (
    <div className={`chat-app ${menuOpen ? 'menu-open' : ''}`}>
      {menuOpen && (
        <div
          className="ca-side-backdrop"
          onClick={() => setMenuOpen(false)}
          aria-hidden="true"
        />
      )}
      {/* SIDEBAR */}
      <aside className="ca-side">
        <div className="ca-brand">
          <a href="index.html" className="mark" aria-label="Home"></a>
          <a href="index.html" className="name">
            Nepl<span>AI Hub</span>
          </a>
        </div>

        <button className="ca-new" onClick={onNewChat}>
          <Icon name="plus" size={16}/> Новый чат
        </button>

        <div className="ca-search">
          <Icon name="search" size={14}/>
          <input
            placeholder="Поиск по истории"
            value={search}
            onChange={e => setSearch(e.target.value)}
          />
        </div>

        <div className="ca-side-scroll">
          <div className="ca-section">
            Модели <span style={{ color: 'var(--ink-3)' }}>{window.MODELS.filter(m => !m.soon).length}</span>
          </div>
          <div className="ca-models ca-providers">
            {window.PROVIDERS.map(p => {
              const isOpen = expandedProviders.has(p.id);
              const isActiveProvider = model.provider === p.id;
              return (
                <div key={p.id} className={`ca-pgroup ${isOpen ? 'open' : ''} ${p.soon ? 'soon' : ''}`}>
                  <button
                    className={`ca-pgroup-head ${isActiveProvider ? 'active' : ''}`}
                    onClick={() => {
                      const next = new Set(expandedProviders);
                      next.has(p.id) ? next.delete(p.id) : next.add(p.id);
                      setExpandedProviders(next);
                    }}
                  >
                    <span className="badge" style={{ color: p.markColor }}>
                      <window.ProviderMark providerId={p.id} size={14} color={p.markColor}/>
                    </span>
                    <span className="pg-title">
                      <span className="pg-name">{p.family}</span>
                    </span>
                    <span className="pg-chev"><Icon name="arrow-down" size={12}/></span>
                  </button>
                  {isOpen && (
                    <div className="ca-pgroup-list">
                      {p.variants.map(v => {
                        const isActive = v.id === active.modelId;
                        const tier = window.TIERS[v.tier];
                        return (
                          <button
                            key={v.id}
                            className={`ca-vrow ${isActive ? 'active' : ''} ${p.soon ? 'disabled' : ''}`}
                            onClick={() => !p.soon && onPickModel(v.id)}
                          >
                            <span className="vrow-name">{v.name}</span>
                            {tier && (
                              <span className="vrow-tier" style={{
                                color: tier.color,
                                borderColor: `${tier.color}55`,
                                background: `${tier.color}1a`,
                              }}>{tier.label}</span>
                            )}
                          </button>
                        );
                      })}
                    </div>
                  )}
                </div>
              );
            })}
          </div>

          <div className="ca-section">
            История <span style={{ color: 'var(--ink-3)' }}>{state.conversations.length}</span>
          </div>
          <div className="ca-history">
            {history.map(c => {
              const m = window.getModel(c.modelId) || window.MODELS[0];
              return (
                <div
                  key={c.id}
                  className={`ca-hist-item ${c.id === state.activeId ? 'active' : ''}`}
                  onClick={() => onPickConv(c.id)}
                >
                  <span style={{
                    width: 8, height: 8, borderRadius: 2,
                    background: m.accent, flexShrink: 0,
                  }}></span>
                  <span className="title">{c.title}</span>
                  <button className="del" onClick={(e) => onDeleteConv(c.id, e)} aria-label="delete">
                    ×
                  </button>
                </div>
              );
            })}
            {history.length === 0 && (
              <div style={{ padding: '12px 10px', fontSize: 12, color: 'var(--ink-3)' }}>
                Ничего не нашлось
              </div>
            )}
          </div>
        </div>

        <div className="ca-user">
          <div className="av">N</div>
          <div className="info">
            <div className="nm">Гость</div>
            <div className="pl">demo · $5/мес</div>
          </div>
          <Icon name="sliders" size={14}/>
        </div>
      </aside>

      {/* MAIN */}
      <main className="ca-main">
        <div className="ca-topbar">
          <button className="ca-burger" onClick={() => setMenuOpen(v => !v)} aria-label="menu">
            <Icon name="layers" size={18}/>
          </button>
          <div className="ca-title-block">
            <div className="ca-title">{active.title}</div>
            <div className="ca-title-meta">
              <span className="dot"></span>
              {model.name} · готов отвечать
            </div>
          </div>
          <ModelPicker modelId={active.modelId} onChange={onPickModel}/>
        </div>

        {/* Backend hint banner */}
        <div className="ca-backend-banner" style={{ display: 'none' }}>
          <span className="px"></span>
          backend: demo
        </div>

        <div className="ca-scroll" ref={scrollRef}>
          {active.messages.length === 0 ? (
            <div className="ca-empty">
              <div className="em-mark"></div>
              <div>
                <h1>Чем сегодня <span className="gradient-text">помочь?</span></h1>
                <p className="lead" style={{ marginTop: 14 }}>
                  Сейчас отвечает <b style={{ color: '#fff' }}>{model.name}</b>.
                  Можешь переключить модель сверху — история сохранится.
                </p>
              </div>
              <div className="quick">
                {QUICK_PROMPTS.map(p => (
                  <button key={p.text} className="ca-quick-chip" onClick={() => send(p.text)}>
                    <span className="pl">{p.label}</span>
                    <span className="tx">{p.text}</span>
                  </button>
                ))}
              </div>
            </div>
          ) : (
            <div className="ca-thread">
              {active.messages.map(m => {
                if (m.role === 'user') {
                  return (
                    <div key={m.id} className="ca-msg user">
                      <div className="avatar">Ты</div>
                      <div className="body">
                        <div className="ca-bubble">{renderBubble(m.content)}</div>
                      </div>
                    </div>
                  );
                }
                const mod = window.getModel(m.modelId) || model;
                return (
                  <div key={m.id} className="ca-msg bot">
                    <div className="avatar" style={{ color: mod.markColor }}>
                      <window.ModelMark model={mod} size={18}/>
                    </div>
                    <div className="body">
                      <div className="meta">
                        <span className="sw" style={{ background: mod.accent }}></span>
                        {mod.name}
                      </div>
                      <div className="ca-bubble">{renderBubble(m.content)}</div>
                      <div className="ca-actions">
                        <button onClick={() => navigator.clipboard?.writeText(m.content)}>
                          <Icon name="copy" size={11}/> Копировать
                        </button>
                        <button onClick={() => send(`Перефразируй прошлый ответ короче.`)}>
                          <Icon name="refresh" size={11}/> Ещё ответ
                        </button>
                      </div>
                    </div>
                  </div>
                );
              })}
              {thinking && (
                <div className="ca-msg bot">
                  <div className="avatar" style={{ color: model.markColor }}>
                    <window.ModelMark model={model} size={18}/>
                  </div>
                  <div className="body">
                    <div className="meta">
                      <span className="sw" style={{ background: model.accent }}></span>
                      {model.name} · думает…
                    </div>
                    <div className="ca-bubble">
                      <span className="typing-dots"><span></span><span></span><span></span></span>
                    </div>
                  </div>
                </div>
              )}
            </div>
          )}
        </div>

        {/* COMPOSER */}
        <div className="ca-composer">
          <div className="ca-composer-inner">
            <div className="ca-composer-box">
              <textarea
                ref={textareaRef}
                rows={1}
                placeholder={`Сообщение для ${model.name}…`}
                value={draft}
                onChange={e => setDraft(e.target.value)}
                onKeyDown={onKey}
              />
              <button
                className={`ca-send ${thinking ? 'stop' : ''}`}
                onClick={() => thinking ? onStop() : send()}
                disabled={!thinking && !draft.trim()}
                aria-label={thinking ? 'stop' : 'send'}
              >
                <Icon name={thinking ? 'stop' : 'send'} size={18}/>
              </button>
            </div>
            <div className="ca-composer-bar">
              <span className="hint">⏎ отправить · ⇧⏎ перенос</span>
            </div>
          </div>
        </div>
      </main>
    </div>
  );
};

// ─── BACKEND STUB ────────────────────────────────────────────────────────────
//
//   This is where you wire up your real backend.
//   Replace `mockResponder` with a fetch() to your API and return the reply text.
//
//   Example:
//
//   window.sendToBackend = async (modelId, messages) => {
//     const res = await fetch('/api/chat', {
//       method: 'POST',
//       headers: { 'Content-Type': 'application/json' },
//       body: JSON.stringify({ model: modelId, messages }),
//     });
//     if (!res.ok) throw new Error(`HTTP ${res.status}`);
//     const data = await res.json();
//     return data.reply;
//   };
// ─────────────────────────────────────────────────────────────────────────────

const DEMO_RESPONSES = {
  openai: {
    code: `Готово. Компактная функция на Python:

\`\`\`python
def count_vowels(s: str) -> int:
    return sum(1 for c in s.lower() if c in "аеёиоуыэюяaeiou")
\`\`\`

Сложность O(n), памяти O(1).`,
    ideas: `Вот 5 идей подкаста про технологии:

1. «До и после релиза» — путь продукта от идеи до запуска.
2. «Первый коммит» — самые первые строки кода известных проектов.
3. «Тихие герои» — инженеры за кулисами больших сервисов.
4. «Мы это удалили» — фичи, которые команды решились убрать.
5. «Один день стажёра» — взгляд изнутри.`,
    default: `Конечно, помогу. Уточни контекст и для кого нужен ответ — соберу под задачу.`,
  },
  anthropic: {
    code: `Решение с явным типом возврата и поддержкой Unicode:

\`\`\`python
def count_vowels(s: str) -> int:
    vowels = set("аеёиоуыэюяaeiou")
    return sum(1 for c in s.lower() if c in vowels)
\`\`\`

Если важны диакритики — добавь \`unicodedata.normalize\`.`,
    ideas: `Подскажу пять направлений и сразу скажу, какое сильнее.

1. «До и после релиза».
2. «Письма основателям».
3. «Тихие герои».
4. «Один разговор» — часовое интервью.
5. «Архив решений».

Сам бы выбрал «Архив решений» — плотно и с нервом.`,
    default: `Хороший вопрос. Уточню: для какого контекста и кому нужен ответ? Так тон попадёт точнее.`,
  },
  deepseek: {
    code: `\`\`\`python
def count_vowels(s: str) -> int:
    return sum(c in "аеёиоуыэюяaeiou" for c in s.lower())
\`\`\`

O(n), без аллокаций. Хочешь pytest — добавлю.`,
    ideas: `1. «Архив решений».
2. «Первый коммит».
3. «Подкаст одного экрана».
4. «Долгие письма».
5. «Тестовое задание».

Аудитория разработчиков — бери 1 и 2.`,
    default: `Опиши задачу абзацем и приведи пример — отдам решение и оценю сложность.`,
  },
  xai: {
    default: `Кидай суть одной фразой — отвечу без воды. Хочешь — дам три варианта от простого к интересному.`,
    code: `\`\`\`python
def count_vowels(s):
    return sum(c in "аеёиоуыэюяaeiou" for c in s.lower())
\`\`\`
Минимум, без церемоний. Хочешь типы — скажи.`,
  },
  google: {
    default: `Готов работать текстом, таблицей или разбирать картинку. Что именно нужно? Файл — прикрепляй.`,
    code: `\`\`\`python
def count_vowels(text: str) -> int:
    """Считает кириллические и латинские гласные."""
    return sum(1 for c in text.lower() if c in "аеёиоуыэюяaeiou")
\`\`\`
Если нужна таблица символов по языкам — могу сгенерировать.`,
  },
};

function classifyPrompt(text) {
  const t = text.toLowerCase();
  if (/код|python|функц|алгоритм|typescript|js|программ/.test(t)) return 'code';
  if (/идея|подкаст|варианты|brainstorm/.test(t)) return 'ideas';
  return 'default';
}

async function mockResponder(modelId, messages) {
  const last = messages.filter(m => m.role === 'user').slice(-1)[0];
  const text = last ? last.content : '';
  const kind = classifyPrompt(text);
  const m = window.getModel(modelId);
  const providerId = m?.provider || 'openai';
  const block = DEMO_RESPONSES[providerId] || DEMO_RESPONSES.openai;
  let reply = block[kind] || block.default;
  // Tier-aware preamble so users notice variants behave differently.
  if (m && m.tier === 'reasoning') {
    reply = `*Думаю шаг за шагом…*\n\n` + reply;
  } else if (m && m.tier === 'pro') {
    reply = `*${m.name} · pro-режим, развернуто:*\n\n` + reply;
  } else if (m && (m.tier === 'fast' || m.tier === 'cheap')) {
    reply = reply.split('\n\n').slice(0, 1).join('\n\n');
  }
  // Simulated thinking delay
  await new Promise(r => setTimeout(r, 600 + Math.random() * 900));
  return reply;
}

// Default stub — set window.sendToBackend BEFORE chat-app loads to override.
if (!window.sendToBackend) {
  window.sendToBackend = async (modelId, messages, opts = {}) => {
    // If a real API endpoint is wired and toggle is on, hit it.
    if (opts.useBackend && window.NEPL_API_ENDPOINT) {
      const res = await fetch(window.NEPL_API_ENDPOINT, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ model: modelId, messages }),
        signal: opts.signal,
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      return data.reply ?? data.content ?? '';
    }
    return mockResponder(modelId, messages);
  };
}

// Mount
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<ChatApp />);
