// V1-photo · Editorial — sans-serif edition. Same palette, fonts parametrised
// via `fontKey` prop. Result is a Stories-style slideshow (one landscape frame
// at a time, manual advance).
//
// i18n: UI strings + animal/location names are localized via the `lang` prop
// (passed down through LangCtx). Default language is `ru`.

const V1Photo = (() => {
  const D = window.FIGHT_DATA;

  const theme = {
    bg: '#f3eee4',
    bg2: '#e9e2d4',
    surface: '#ffffff',
    surfaceHi: '#fbf7ee',
    ink: '#191614',
    inkSoft: '#3a332c',
    dim: '#7c7468',
    line: '#d8d0bf',
    accent: '#a8412a',
    sideA: '#a8412a',
    sideB: '#2e4a3c',
    radius: 6,
    cardRadius: 4,
  };

  const FONT_PRESETS = {
    manrope: {
      name: 'Manrope',
      display: '"Manrope", system-ui, sans-serif',
      body: '"Manrope", system-ui, sans-serif',
      heavy: 800, bold: 700, med: 600, reg: 500,
      tightLs: '-0.02em', labelLs: '0.14em',
    },
    bricolage: {
      name: 'Bricolage Grotesque',
      display: '"Bricolage Grotesque", system-ui, sans-serif',
      body: '"Bricolage Grotesque", system-ui, sans-serif',
      heavy: 700, bold: 600, med: 500, reg: 400,
      tightLs: '-0.025em', labelLs: '0.12em',
    },
    outfit: {
      name: 'Outfit',
      display: '"Outfit", system-ui, sans-serif',
      body: '"Outfit", system-ui, sans-serif',
      heavy: 700, bold: 600, med: 500, reg: 400,
      tightLs: '-0.015em', labelLs: '0.16em',
    },
  };
  const FontCtx = React.createContext(FONT_PRESETS.manrope);
  const useF = () => React.useContext(FontCtx);

  // ── i18n ──────────────────────────────────────────────────────────────────
  const STR = {
    ru: {
      chooseFighter: '+ Выбери бойца',
      sideA: 'Команда А',
      sideB: 'Команда Б',
      setting: 'Место',
      startFight: 'Начать бой ▸',
      pickFirst: 'Выбери первого бойца',
      pickSecond: 'Выбери второго бойца',
      pickPlace: 'Где они встретятся?',
      close: 'Закрыть',
      back: '‹ Назад',
      yourPrediction: 'Твой прогноз',
      whoWins: 'Кто победит?',
      itsDraw: 'Ничья',
      preparingFrames: 'Готовлю кадры боя',
      framesReady: (n) => `${n} из 3 готово`,
      frameNames: ['Кадр 1 · встреча', 'Кадр 2 · схватка', 'Кадр 3 · финал'],
      writingScript: 'Пишу сценарий',
      scriptHint: 'Это может занять до 20 секунд',
      scriptPreviewTitle: 'Сценарий боя',
      scriptSynopsis: 'Сюжет',
      scriptFrame: (i) => `Кадр ${i + 1}`,
      go: '▸ Погнали',
      regenScript: '✎ Перепиши',
      regenerating: '⏳ Переписываю…',
      rematch: '↻ Реванш',
      makeVideo: '▶ Сделать видео',
      rendering: '⏳ Делаю…',
      playVideo: '▶ Смотреть',
      archive: 'Архив',
      noFightsYet: 'Пока нет боёв. Запусти первый!',
      battleN: (n) => {
        const mod10 = n % 10, mod100 = n % 100;
        if (mod10 === 1 && mod100 !== 11) return `${n} бой`;
        if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) return `${n} боя`;
        return `${n} боёв`;
      },
      vsCount: '×',
      editAnimal: 'Карточка бойца',
      strengths: 'Сильные стороны',
      weaknesses: 'Слабые стороны',
      regenPortrait: '🔄 Нарисовать заново',
      generatingPortrait: '⏳ Рисую…',
      regenTraits: '✎ Переписать стороны',
      generatingTraits: '⏳ Пишу…',
      noTraitsYet: 'Пока пусто. Жми «Переписать стороны».',
      noPortraitYet: 'Портрета пока нет',
      done: 'Готово',
      attachReference: '📎 Прикрепить фото',
      reference: 'Референс',
      removeReference: 'Убрать',
      addAnimal: '➕ Добавить зверя',
      addAnimalTitle: 'Новый боец',
      nameRu: 'Имя по-русски',
      nameEn: 'Имя по-английски',
      emoji: 'Эмодзи (необязательно)',
      create: 'Создать',
      cancel: 'Отмена',
      deleteAnimal: '🗑 Удалить из ростера',
      deleteConfirm: 'Удалить этого зверя из ростера?',
    },
    en: {
      chooseFighter: '+ Choose fighter',
      sideA: 'Side A',
      sideB: 'Side B',
      setting: 'Setting',
      startFight: 'Start the fight ▸',
      pickFirst: 'Pick the first fighter',
      pickSecond: 'Pick the second fighter',
      pickPlace: 'Where do they meet?',
      close: 'Close',
      back: '‹ Back',
      yourPrediction: 'Your prediction',
      whoWins: 'Who will win?',
      itsDraw: "It's a draw",
      preparingFrames: 'Preparing battle frames',
      framesReady: (n) => `${n} of 3 ready`,
      frameNames: ['Frame 1 · meeting', 'Frame 2 · clash', 'Frame 3 · finale'],
      writingScript: 'Writing the script',
      scriptHint: 'This can take up to 20 seconds',
      scriptPreviewTitle: 'Battle script',
      scriptSynopsis: 'Synopsis',
      scriptFrame: (i) => `Frame ${i + 1}`,
      go: '▸ Go',
      regenScript: '✎ Rewrite',
      regenerating: '⏳ Rewriting…',
      rematch: '↻ Rematch',
      makeVideo: '▶ Make video',
      rendering: '⏳ Rendering…',
      playVideo: '▶ Play video',
      archive: 'Archive',
      noFightsYet: 'No fights yet. Start one!',
      battleN: (n) => `${n} battle${n === 1 ? '' : 's'}`,
      vsCount: '×',
      editAnimal: 'Fighter card',
      strengths: 'Strengths',
      weaknesses: 'Weaknesses',
      regenPortrait: '🔄 Redraw',
      generatingPortrait: '⏳ Drawing…',
      regenTraits: '✎ Rewrite traits',
      generatingTraits: '⏳ Writing…',
      noTraitsYet: 'Empty for now. Hit "Rewrite traits".',
      noPortraitYet: 'No portrait yet',
      done: 'Done',
      attachReference: '📎 Attach photo',
      reference: 'Reference',
      removeReference: 'Remove',
      addAnimal: '➕ Add fighter',
      addAnimalTitle: 'New fighter',
      nameRu: 'Russian name',
      nameEn: 'English name',
      emoji: 'Emoji (optional)',
      create: 'Create',
      cancel: 'Cancel',
      deleteAnimal: '🗑 Remove from roster',
      deleteConfirm: 'Remove this fighter from the roster?',
    },
  };
  const LangCtx = React.createContext('ru');
  const useT = () => STR[React.useContext(LangCtx)] || STR.ru;
  const useLang = () => React.useContext(LangCtx);
  const nm = (item, lang) => (lang === 'ru' ? item?.name_ru : item?.name_en) || item?.name_en || '';

  // ── Photo placeholder ─────────────────────────────────────────────────────
  // If `imageUrl` is provided, the real AI-generated image is laid on top of the
  // placeholder with a smooth fade-in. The placeholder behind it keeps the
  // composition stable while the image loads (and is the visible content if
  // generation fails).
  function PhotoCard({ location, a, b, countA, countB, stage, winner, mood, caption, label, full = false, hideCaption = false, hideLabel = false, imageUrl = null, imageLoading = false }) {
    const F = useF();
    const palette = window.locationPalette[location?.id] || ['#333', '#666', '#aaa'];
    const [dark, mid, light] = palette;
    const sky = `linear-gradient(180deg, ${mid} 0%, ${light}aa 60%, ${dark} 100%)`;
    const ground = `linear-gradient(180deg, transparent 60%, ${dark}cc 80%, ${dark} 100%)`;
    const stagePos = {
      start: { aX: 18, bX: 18 },
      mid:   { aX: 36, bX: 36 },
      end:   { aX: winner === 'A' ? 22 : 50, bX: winner === 'B' ? 22 : 50 },
    }[stage] || { aX: 22, bX: 22 };
    const aFaded = stage === 'end' && winner === 'B';
    const bFaded = stage === 'end' && winner === 'A';
    const aWin = stage === 'end' && winner === 'A';
    const bWin = stage === 'end' && winner === 'B';
    const fighterSize = full ? 130 : 78;
    const clashSize = full ? 90 : 54;

    return (
      <div style={{ position: 'absolute', inset: 0, background: '#000', overflow: 'hidden' }}>
        <div style={{ position: 'absolute', inset: 0, background: sky }} />
        <div style={{ position: 'absolute', left: 0, right: 0, top: '40%', height: '20%', background: `linear-gradient(transparent, ${light}33, transparent)`, filter: 'blur(8px)' }} />
        <div style={{ position: 'absolute', inset: 0, background: ground }} />
        <div style={{ position: 'absolute', top: '12%', right: '15%', width: full ? 80 : 50, height: full ? 80 : 50, borderRadius: '50%', background: `radial-gradient(circle, ${light}ee, transparent 70%)`, filter: 'blur(2px)' }} />

        <div style={{ position: 'absolute', bottom: '14%', left: `${stagePos.aX}%`, transformOrigin: 'bottom center', transform: `scale(${aWin ? 1.18 : 1})`, opacity: aFaded ? 0.32 : 1, filter: aFaded ? 'grayscale(0.8) brightness(0.7)' : 'drop-shadow(0 6px 8px rgba(0,0,0,0.7))', transition: 'all 0.5s' }}>
          <div style={{ fontSize: fighterSize, lineHeight: 1 }}>{a?.emoji || '?'}</div>
          {countA > 1 && <div style={{ position: 'absolute', top: -4, right: -10, background: theme.surface, color: theme.ink, fontFamily: F.body, fontSize: 11, padding: '2px 7px', borderRadius: 999, fontWeight: F.bold, border: `1px solid ${theme.line}` }}>×{countA}</div>}
        </div>
        <div style={{ position: 'absolute', bottom: '14%', right: `${stagePos.bX}%`, transformOrigin: 'bottom center', transform: `scale(${bWin ? 1.18 : 1}) scaleX(-1)`, opacity: bFaded ? 0.32 : 1, filter: bFaded ? 'grayscale(0.8) brightness(0.7)' : 'drop-shadow(0 6px 8px rgba(0,0,0,0.7))', transition: 'all 0.5s' }}>
          <div style={{ fontSize: fighterSize, lineHeight: 1 }}>{b?.emoji || '?'}</div>
          {countB > 1 && <div style={{ position: 'absolute', top: -4, left: -10, transform: 'scaleX(-1)', background: theme.surface, color: theme.ink, fontFamily: F.body, fontSize: 11, padding: '2px 7px', borderRadius: 999, fontWeight: F.bold, border: `1px solid ${theme.line}` }}>×{countB}</div>}
        </div>

        {stage === 'mid' && (
          <div style={{ position: 'absolute', top: '46%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: clashSize, animation: 'sceneShake 0.4s infinite', filter: 'drop-shadow(0 0 6px rgba(255,200,80,0.8))' }}>💥</div>
        )}

        <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(20,15,10,0.22) 100%)', pointerEvents: 'none' }} />
        {mood === 'realistic' && <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(transparent 40%, rgba(0,0,0,0.35))', pointerEvents: 'none' }} />}

        {/* Real AI-generated image — fades in on top of the placeholder once loaded */}
        {imageUrl && (
          <img
            src={imageUrl}
            alt=""
            style={{
              position: 'absolute', inset: 0, width: '100%', height: '100%',
              objectFit: 'cover', display: 'block', zIndex: 2,
              animation: 'photoFadeIn 0.4s ease-out',
            }}
            onError={(e) => { e.currentTarget.style.display = 'none'; }}
          />
        )}

        {/* Subtle spinner while generation is in flight */}
        {imageLoading && !imageUrl && (
          <div style={{
            position: 'absolute', top: 12, right: 12, zIndex: 3,
            width: 32, height: 32, borderRadius: '50%',
            background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)',
            border: '1px solid rgba(255,255,255,0.22)',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
          }}>
            <div style={{
              width: 14, height: 14, borderRadius: '50%',
              border: '2px solid rgba(255,255,255,0.25)',
              borderTopColor: '#fff',
              animation: 'photoSpin 0.9s linear infinite',
            }} />
          </div>
        )}

        {label && !hideLabel && (
          <div style={{ position: 'absolute', top: 10, left: 14, color: '#fff', fontFamily: F.body, fontSize: full ? 11 : 9, fontWeight: F.bold, letterSpacing: F.labelLs, textTransform: 'uppercase', textShadow: '0 1px 2px rgba(0,0,0,0.5)', opacity: 0.95, zIndex: 4 }}>
            {label}
          </div>
        )}
        {caption && !hideCaption && (
          <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: full ? '14px 20px' : '8px 12px', background: 'linear-gradient(transparent, rgba(0,0,0,0.85))', color: '#fff', zIndex: 4 }}>
            <div style={{ fontFamily: F.display, fontSize: full ? 24 : 14, fontWeight: F.heavy, lineHeight: 1.15, letterSpacing: F.tightLs }}>{caption}</div>
          </div>
        )}
      </div>
    );
  }

  // ── Picker ────────────────────────────────────────────────────────────────
  // `onEdit` is optional; when provided, each card gets a small ✎ button
  // that opens the animal editor instead of picking the animal.
  // `onAdd`  is optional; when provided, a "➕ Add fighter" tile appears
  // first in the grid.
  function Picker({ items, onPick, onClose, onEdit, onAdd, title, columns = 7 }) {
    const F = useF();
    const T = useT();
    const lang = useLang();
    return (
      <div style={{ position: 'absolute', inset: 0, background: 'rgba(25,22,20,0.55)', zIndex: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(8px)' }} onClick={onClose}>
        <div onClick={(e) => e.stopPropagation()} style={{ width: '88%', maxHeight: '88%', background: theme.surface, border: `1px solid ${theme.line}`, borderRadius: theme.radius, padding: '14px 18px', display: 'flex', flexDirection: 'column', boxShadow: '0 16px 48px rgba(60,40,20,0.18)' }}>
          <div style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
            <div style={{ flex: 1, fontFamily: F.display, fontSize: 20, fontWeight: F.heavy, color: theme.ink, letterSpacing: F.tightLs }}>{title}</div>
            <button onClick={onClose} style={{ background: 'transparent', border: `1px solid ${theme.line}`, color: theme.dim, fontFamily: F.body, fontWeight: F.bold, fontSize: 11, padding: '4px 12px', cursor: 'pointer', borderRadius: theme.cardRadius, letterSpacing: F.labelLs, textTransform: 'uppercase' }}>{T.close}</button>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 8, overflow: 'auto', paddingRight: 4 }}>
            {onAdd && (
              <button key="__add__" onClick={onAdd} style={{
                background: 'transparent', border: `1px dashed ${theme.accent}`, borderRadius: theme.cardRadius,
                padding: 0, cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center',
                color: theme.accent, overflow: 'hidden',
              }}>
                <div style={{ width: '100%', aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28 }}>＋</div>
                <div style={{ width: '100%', padding: '4px 4px 6px', fontSize: 10, fontFamily: F.body, fontWeight: F.bold, letterSpacing: F.labelLs, textTransform: 'uppercase', textAlign: 'center' }}>
                  {T.addAnimal}
                </div>
              </button>
            )}
            {items.map((it) => {
              const hasPortrait = !!it.image_url;
              return (
                <div key={it.id} style={{ position: 'relative' }}>
                  <button onClick={() => { onPick(it); onClose(); }} style={{
                    width: '100%',
                    background: theme.surfaceHi, border: `1px solid ${theme.line}`, borderRadius: theme.cardRadius,
                    padding: 0, cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0,
                    transition: 'all 0.12s', color: theme.ink, overflow: 'hidden',
                  }}
                    onMouseEnter={(e) => { e.currentTarget.style.borderColor = theme.accent; e.currentTarget.style.transform = 'translateY(-1px)'; }}
                    onMouseLeave={(e) => { e.currentTarget.style.borderColor = theme.line; e.currentTarget.style.transform = ''; }}>
                    {hasPortrait ? (
                      <img src={it.image_url} alt="" style={{ width: '100%', aspectRatio: '1 / 1', objectFit: 'cover', display: 'block' }} />
                    ) : (
                      <div style={{ width: '100%', aspectRatio: '1 / 1', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, background: theme.surfaceHi }}>{it.emoji}</div>
                    )}
                    <div style={{ width: '100%', padding: '4px 4px 6px', fontSize: 11, fontFamily: F.body, fontWeight: F.med, color: theme.inkSoft, textAlign: 'center', background: theme.surface }}>
                      {nm(it, lang)}
                    </div>
                  </button>
                  {onEdit && (
                    <button
                      onClick={(e) => { e.stopPropagation(); onEdit(it); }}
                      aria-label="edit"
                      title={T.editAnimal}
                      style={{
                        position: 'absolute', top: 4, right: 4,
                        width: 22, height: 22, borderRadius: '50%',
                        background: 'rgba(255,255,255,0.85)', color: theme.ink,
                        border: `1px solid ${theme.line}`,
                        fontSize: 11, lineHeight: 1, cursor: 'pointer',
                        display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
                        boxShadow: '0 2px 6px rgba(0,0,0,0.18)',
                      }}>✎</button>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      </div>
    );
  }

  // ── Animal editor (modal) ─────────────────────────────────────────────────
  // Lets the user regenerate a portrait and rewrite the strengths/weaknesses
  // list via the LLM. The portrait uses the currently-selected image model
  // from settings; traits always use Gemini server-side.
  function AnimalEditor({ animal, model, lang, onClose, onUpdate, onDelete }) {
    const F = useF();
    const T = useT();
    const [busyPortrait, setBusyPortrait] = React.useState(false);
    const [busyTraits, setBusyTraits] = React.useState(false);
    const [busyDelete, setBusyDelete] = React.useState(false);
    const [error, setError] = React.useState(null);
    const [zoomed, setZoomed] = React.useState(false);

    const remove = () => {
      if (busyDelete) return;
      if (!window.confirm(T.deleteConfirm + '\n\n' + (lang === 'ru' ? animal.name_ru : animal.name_en))) return;
      setBusyDelete(true);
      setError(null);
      fetch(`/api/animals/${animal.id}`, { method: 'DELETE' })
        .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.error || 'http ' + r.status)))))
        .then(() => { onDelete && onDelete(animal); })
        .catch((err) => { console.warn('[animals:delete]', err.message); setError(err.message); })
        .finally(() => setBusyDelete(false));
    };
    // Reference photo (e.g. a snap from a book). Optional. If present, sent
    // along with the next portrait regenerate so the model uses it as the
    // visual source for the animal.
    const [reference, setReference] = React.useState(null);
    const fileInputRef = React.useRef(null);

    // Downscale a user-picked file so we don't ship 10MB phone photos to
    // the AI APIs. ~1024px on the long side at JPEG 0.85 is more than enough
    // for the model to read the animal's appearance.
    const compressImage = async (file) => {
      const bitmap = await createImageBitmap(file);
      const maxDim = 1024;
      const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
      const w = Math.round(bitmap.width * scale);
      const h = Math.round(bitmap.height * scale);
      const canvas = document.createElement('canvas');
      canvas.width = w; canvas.height = h;
      canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
      return new Promise((resolve, reject) => {
        canvas.toBlob((blob) => {
          if (!blob) return reject(new Error('compress failed'));
          const fr = new FileReader();
          fr.onload = () => resolve({ name: file.name, dataURL: fr.result });
          fr.onerror = () => reject(fr.error);
          fr.readAsDataURL(blob);
        }, 'image/jpeg', 0.85);
      });
    };

    const onPickFile = async (e) => {
      const file = e.target.files?.[0];
      // Reset the input so picking the same file twice still fires onChange.
      if (fileInputRef.current) fileInputRef.current.value = '';
      if (!file) return;
      setError(null);
      try {
        const compressed = await compressImage(file);
        setReference(compressed);
      } catch (err) {
        console.warn('[ref]', err);
        setError('Не удалось прочитать файл');
      }
    };

    const regenPortrait = () => {
      setBusyPortrait(true);
      setError(null);
      const body = { model };
      if (reference?.dataURL) {
        const comma = reference.dataURL.indexOf(',');
        const head = reference.dataURL.slice(0, comma); // "data:image/jpeg;base64"
        const data = reference.dataURL.slice(comma + 1);
        const mimeMatch = head.match(/^data:([^;]+);/);
        body.reference = {
          mimeType: mimeMatch ? mimeMatch[1] : 'image/jpeg',
          data,
        };
      }
      fetch(`/api/animals/${animal.id}/image`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      })
        .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.detail || j.error || 'http ' + r.status)))))
        .then((updated) => { onUpdate(updated); })
        .catch((err) => { console.warn('[animals:image]', err.message); setError(err.message); })
        .finally(() => setBusyPortrait(false));
    };

    const regenTraits = () => {
      setBusyTraits(true);
      setError(null);
      fetch(`/api/animals/${animal.id}/traits`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({}),
      })
        .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.detail || j.error || 'http ' + r.status)))))
        .then((updated) => { onUpdate(updated); })
        .catch((err) => { console.warn('[animals:traits]', err.message); setError(err.message); })
        .finally(() => setBusyTraits(false));
    };

    const hasPortrait = !!animal.image_url;
    const strengthsLoc  = animal[`strengths_${lang}`]  || animal.strengths  || [];
    const weaknessesLoc = animal[`weaknesses_${lang}`] || animal.weaknesses || [];
    const hasTraits = strengthsLoc.length > 0 || weaknessesLoc.length > 0;

    return (
      <div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(15,12,9,0.7)', zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(8px)' }}>
        <div onClick={(e) => e.stopPropagation()} style={{
          width: '92%', maxWidth: 620, maxHeight: '92%', overflow: 'auto',
          background: theme.surface, border: `1px solid ${theme.line}`, borderRadius: theme.radius,
          padding: 14, display: 'flex', flexDirection: 'column', gap: 12,
          boxShadow: '0 24px 64px rgba(0,0,0,0.4)',
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
            <div style={{ fontSize: 26 }}>{animal.emoji}</div>
            <div style={{ flex: 1 }}>
              <div style={{ fontFamily: F.body, fontSize: 9, fontWeight: F.bold, color: theme.dim, letterSpacing: F.labelLs, textTransform: 'uppercase' }}>{T.editAnimal}</div>
              <div style={{ fontFamily: F.display, fontSize: 22, fontWeight: F.heavy, letterSpacing: F.tightLs, color: theme.ink }}>{nm(animal, lang)}</div>
            </div>
            <button onClick={onClose} style={{ background: 'transparent', border: `1px solid ${theme.line}`, color: theme.dim, fontFamily: F.body, fontWeight: F.bold, fontSize: 11, padding: '4px 12px', cursor: 'pointer', borderRadius: theme.cardRadius, letterSpacing: F.labelLs, textTransform: 'uppercase' }}>{T.done}</button>
          </div>

          <div style={{ display: 'flex', gap: 12, alignItems: 'stretch' }}>
            {/* Portrait */}
            <div style={{ flex: '0 0 180px', display: 'flex', flexDirection: 'column', gap: 6 }}>
              <div
                onClick={hasPortrait && !busyPortrait ? () => setZoomed(true) : undefined}
                title={hasPortrait ? 'Открыть целиком' : undefined}
                style={{
                  width: 180, height: 180, borderRadius: theme.cardRadius,
                  background: theme.bg2, border: `1px solid ${theme.line}`,
                  overflow: 'hidden', position: 'relative',
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  cursor: hasPortrait && !busyPortrait ? 'zoom-in' : 'default',
                }}>
                {hasPortrait ? (
                  // Small preview uses `cover` — same crop as on the fighter
                  // slot, so the user sees what the portrait will look like
                  // in-game. Clicking opens the full uncropped image below.
                  <img src={animal.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
                ) : (
                  <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, color: theme.dim }}>
                    <div style={{ fontSize: 72 }}>{animal.emoji}</div>
                    <div style={{ fontFamily: F.body, fontSize: 10, fontWeight: F.bold, letterSpacing: F.labelLs, textTransform: 'uppercase' }}>{T.noPortraitYet}</div>
                  </div>
                )}
                {busyPortrait && (
                  <div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                    <div style={{ width: 26, height: 26, border: '3px solid rgba(255,255,255,0.25)', borderTopColor: '#fff', borderRadius: '50%', animation: 'photoSpin 0.9s linear infinite' }} />
                  </div>
                )}
              </div>
              {/* Reference photo strip: either an "attach" button or a
                  thumbnail with a remove ✕. Sits between the portrait
                  preview and the regenerate button so the user sees the
                  reference will be used in the next regen. */}
              <input
                ref={fileInputRef}
                type="file"
                accept="image/*"
                onChange={onPickFile}
                style={{ display: 'none' }}
              />
              {reference ? (
                <div style={{
                  width: 180, padding: '4px 6px',
                  display: 'flex', alignItems: 'center', gap: 6,
                  background: theme.surfaceHi, border: `1px solid ${theme.line}`,
                  borderRadius: theme.cardRadius,
                }}>
                  <img src={reference.dataURL} alt="" style={{
                    width: 34, height: 34, objectFit: 'cover',
                    borderRadius: 4, flexShrink: 0,
                  }} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontFamily: F.body, fontSize: 8, fontWeight: F.bold, color: theme.accent, letterSpacing: F.labelLs, textTransform: 'uppercase' }}>{T.reference}</div>
                    <div style={{ fontFamily: F.body, fontSize: 10, color: theme.inkSoft, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{reference.name}</div>
                  </div>
                  <button onClick={() => setReference(null)} aria-label={T.removeReference} style={{
                    width: 22, height: 22, borderRadius: '50%',
                    background: 'transparent', border: `1px solid ${theme.line}`,
                    color: theme.dim, fontSize: 11, lineHeight: 1, cursor: 'pointer',
                    display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
                    flexShrink: 0,
                  }}>✕</button>
                </div>
              ) : (
                <button onClick={() => fileInputRef.current?.click()} disabled={busyPortrait} style={{
                  width: 180, padding: '6px 10px',
                  background: 'transparent', border: `1px dashed ${theme.line}`, color: theme.inkSoft,
                  borderRadius: theme.cardRadius,
                  fontFamily: F.body, fontWeight: F.bold, fontSize: 10, letterSpacing: F.labelLs, textTransform: 'uppercase',
                  cursor: busyPortrait ? 'default' : 'pointer', opacity: busyPortrait ? 0.55 : 1,
                }}>
                  {T.attachReference}
                </button>
              )}

              <button onClick={regenPortrait} disabled={busyPortrait} style={{
                width: 180, padding: '8px 10px',
                background: busyPortrait ? theme.bg2 : theme.accent, color: busyPortrait ? theme.dim : '#fff7ee',
                border: 'none', borderRadius: theme.cardRadius,
                fontFamily: F.body, fontWeight: F.bold, fontSize: 11, letterSpacing: F.labelLs, textTransform: 'uppercase',
                cursor: busyPortrait ? 'default' : 'pointer',
              }}>
                {busyPortrait ? T.generatingPortrait : T.regenPortrait}
              </button>
            </div>

            {/* Traits */}
            <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 }}>
              <TraitsList label={T.strengths} items={strengthsLoc} accent={theme.sideB} F={F} placeholder={!hasTraits ? T.noTraitsYet : null} />
              <TraitsList label={T.weaknesses} items={weaknessesLoc} accent={theme.sideA} F={F} placeholder={null} />
              <button onClick={regenTraits} disabled={busyTraits} style={{
                marginTop: 'auto',
                padding: '8px 12px',
                background: 'transparent', border: `1px solid ${theme.line}`, color: theme.inkSoft,
                borderRadius: theme.cardRadius,
                fontFamily: F.body, fontWeight: F.bold, fontSize: 11, letterSpacing: F.labelLs, textTransform: 'uppercase',
                cursor: busyTraits ? 'default' : 'pointer', opacity: busyTraits ? 0.55 : 1,
              }}>
                {busyTraits ? T.generatingTraits : T.regenTraits}
              </button>
            </div>
          </div>

          {error && (
            <div style={{ padding: '8px 12px', background: '#fdecea', border: '1px solid #e07a4a', borderRadius: theme.cardRadius, color: '#a8412a', fontFamily: F.body, fontSize: 12 }}>
              ⚠ {error}
            </div>
          )}

          {/* Destructive action sits at the very bottom, dimmed, behind a
              confirm dialog, so it can't be tapped by accident. */}
          {onDelete && (
            <div style={{ display: 'flex', justifyContent: 'center', paddingTop: 4 }}>
              <button onClick={remove} disabled={busyDelete} style={{
                background: 'transparent', border: 'none',
                color: busyDelete ? theme.dim : theme.dim,
                fontFamily: F.body, fontWeight: F.bold, fontSize: 11,
                padding: '4px 10px', cursor: busyDelete ? 'default' : 'pointer',
                letterSpacing: F.labelLs, textTransform: 'uppercase',
                textDecoration: 'underline',
                opacity: busyDelete ? 0.5 : 1,
              }}>
                {T.deleteAnimal}
              </button>
            </div>
          )}
        </div>

        {/* Zoomed full-image overlay — `contain` so the whole portrait is
            visible, including any margin the model left around the creature. */}
        {zoomed && hasPortrait && (
          <div
            onClick={(e) => { e.stopPropagation(); setZoomed(false); }}
            style={{
              position: 'absolute', inset: 0, zIndex: 60,
              background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(10px)',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              cursor: 'zoom-out',
            }}>
            <img src={animal.image_url} alt="" style={{
              maxWidth: '92%', maxHeight: '92%', objectFit: 'contain',
              boxShadow: '0 16px 48px rgba(0,0,0,0.6)',
              borderRadius: theme.cardRadius,
            }} />
            <button
              onClick={(e) => { e.stopPropagation(); setZoomed(false); }}
              aria-label="Close"
              style={{
                position: 'absolute', top: 12, right: 12, width: 32, height: 32, borderRadius: '50%',
                background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(6px)', border: '1px solid rgba(255,255,255,0.22)',
                color: '#fff', fontSize: 14, lineHeight: 1, cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
              }}>✕</button>
          </div>
        )}
      </div>
    );
  }

  // ── Add-animal form (modal) ───────────────────────────────────────────────
  // Tiny form: Russian name, English name, optional emoji. The server
  // generates the id from the English name and seeds the row with
  // power=50; image/traits are filled in later via the editor.
  function AnimalAddForm({ onClose, onCreate }) {
    const F = useF();
    const T = useT();
    const lang = useLang();
    const [nameRu, setNameRu] = React.useState('');
    const [nameEn, setNameEn] = React.useState('');
    const [emoji, setEmoji] = React.useState('');
    const [busy, setBusy] = React.useState(false);
    const [error, setError] = React.useState(null);

    const canSubmit = !busy && (nameRu.trim() || nameEn.trim());

    const submit = () => {
      if (!canSubmit) return;
      setBusy(true);
      setError(null);
      fetch('/api/animals', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name_ru: nameRu.trim() || null,
          name_en: nameEn.trim() || null,
          emoji: emoji.trim() || null,
        }),
      })
        .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.error || 'http ' + r.status)))))
        .then((created) => { onCreate(created); })
        .catch((err) => { console.warn('[animals:create]', err.message); setError(err.message); })
        .finally(() => setBusy(false));
    };

    const fieldStyle = {
      width: '100%', padding: '8px 10px',
      background: theme.surface, border: `1px solid ${theme.line}`, borderRadius: theme.cardRadius,
      fontFamily: F.body, fontSize: 14, color: theme.ink,
      outline: 'none',
    };

    return (
      <div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(15,12,9,0.7)', zIndex: 70, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(8px)' }}>
        <div onClick={(e) => e.stopPropagation()} style={{
          width: '88%', maxWidth: 380,
          background: theme.surface, border: `1px solid ${theme.line}`, borderRadius: theme.radius,
          padding: 16, display: 'flex', flexDirection: 'column', gap: 10,
          boxShadow: '0 24px 64px rgba(0,0,0,0.4)',
        }}>
          <div style={{ fontFamily: F.display, fontSize: 18, fontWeight: F.heavy, letterSpacing: F.tightLs, color: theme.ink }}>
            {T.addAnimalTitle}
          </div>

          <div>
            <div style={{ fontFamily: F.body, fontSize: 9, fontWeight: F.bold, color: theme.dim, letterSpacing: F.labelLs, textTransform: 'uppercase', marginBottom: 4 }}>{T.nameRu}</div>
            <input autoFocus value={nameRu} onChange={(e) => setNameRu(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} style={fieldStyle} maxLength={40} />
          </div>
          <div>
            <div style={{ fontFamily: F.body, fontSize: 9, fontWeight: F.bold, color: theme.dim, letterSpacing: F.labelLs, textTransform: 'uppercase', marginBottom: 4 }}>{T.nameEn}</div>
            <input value={nameEn} onChange={(e) => setNameEn(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} style={fieldStyle} maxLength={40} />
          </div>
          <div>
            <div style={{ fontFamily: F.body, fontSize: 9, fontWeight: F.bold, color: theme.dim, letterSpacing: F.labelLs, textTransform: 'uppercase', marginBottom: 4 }}>{T.emoji}</div>
            <input value={emoji} onChange={(e) => setEmoji(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submit()} style={{ ...fieldStyle, fontSize: 22 }} maxLength={16} placeholder="🐾" />
          </div>

          {error && (
            <div style={{ padding: '6px 10px', background: '#fdecea', border: '1px solid #e07a4a', borderRadius: theme.cardRadius, color: '#a8412a', fontFamily: F.body, fontSize: 11 }}>
              ⚠ {error}
            </div>
          )}

          <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
            <button onClick={onClose} style={{
              flex: 1, padding: '10px 12px',
              background: 'transparent', border: `1px solid ${theme.line}`, color: theme.inkSoft,
              borderRadius: theme.cardRadius,
              fontFamily: F.body, fontWeight: F.bold, fontSize: 12, letterSpacing: F.labelLs, textTransform: 'uppercase',
              cursor: 'pointer',
            }}>{T.cancel}</button>
            <button onClick={submit} disabled={!canSubmit} style={{
              flex: 2, padding: '10px 12px',
              background: canSubmit ? theme.accent : theme.bg2, color: canSubmit ? '#fff7ee' : theme.dim,
              border: 'none', borderRadius: theme.cardRadius,
              fontFamily: F.body, fontWeight: F.heavy, fontSize: 13, letterSpacing: F.labelLs, textTransform: 'uppercase',
              cursor: canSubmit ? 'pointer' : 'default',
            }}>
              {busy ? '…' : T.create}
            </button>
          </div>
        </div>
      </div>
    );
  }

  function TraitsList({ label, items, accent, F, placeholder }) {
    return (
      <div>
        <div style={{ fontFamily: F.body, fontSize: 9, fontWeight: F.bold, color: accent, letterSpacing: F.labelLs, textTransform: 'uppercase', marginBottom: 4 }}>{label}</div>
        {items.length === 0 ? (
          <div style={{ fontFamily: F.body, fontSize: 12, color: theme.dim, fontStyle: 'italic' }}>
            {placeholder || '—'}
          </div>
        ) : (
          <ul style={{ margin: 0, padding: '0 0 0 16px', display: 'flex', flexDirection: 'column', gap: 2 }}>
            {items.map((s, i) => (
              <li key={i} style={{ fontFamily: F.body, fontSize: 12, color: theme.ink, lineHeight: 1.35 }}>{s}</li>
            ))}
          </ul>
        )}
      </div>
    );
  }

  // ── Fighter slot ──────────────────────────────────────────────────────────
  function FighterSlot({ animal, count, onSetCount, onPick, side, sideColor }) {
    const F = useF();
    const T = useT();
    const lang = useLang();
    return (
      <div style={{ width: 200, display: 'flex', flexDirection: 'column', gap: 6 }}>
        <button onClick={onPick} style={{
          width: '100%', height: 170, position: 'relative', overflow: 'hidden',
          background: theme.surface, border: `1px solid ${theme.line}`,
          borderTop: `3px solid ${sideColor}`,
          borderRadius: theme.cardRadius, cursor: 'pointer', padding: 0,
          transition: 'transform 0.15s, box-shadow 0.15s',
        }}
          onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = `0 10px 20px rgba(60,40,20,0.12)`; }}
          onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}>
          {animal ? (
            <React.Fragment>
              <div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse at center top, ${theme.surfaceHi}, ${theme.bg2})` }} />
              {animal.image_url ? (
                <img src={animal.image_url} alt="" style={{
                  position: 'absolute', inset: 0, width: '100%', height: '100%',
                  objectFit: 'cover', display: 'block',
                  transform: side === 'B' ? 'scaleX(-1)' : '',
                }} />
              ) : (
                <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                  <div style={{ fontSize: 86, filter: 'drop-shadow(0 6px 8px rgba(60,40,20,0.25))', transform: side === 'B' ? 'scaleX(-1)' : '' }}>{animal.emoji}</div>
                </div>
              )}
              {animal.image_url && (
                <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(255,255,255,0.0) 50%, rgba(0,0,0,0.55))', pointerEvents: 'none' }} />
              )}
              <div style={{ position: 'absolute', top: 8, left: 10, fontFamily: F.body, fontSize: 9, color: animal.image_url ? '#fff' : sideColor, fontWeight: F.bold, letterSpacing: F.labelLs, textTransform: 'uppercase', textShadow: animal.image_url ? '0 1px 2px rgba(0,0,0,0.6)' : 'none' }}>
                {side === 'A' ? T.sideA : T.sideB}
              </div>
              <div style={{ position: 'absolute', bottom: 8, left: 10, right: 10 }}>
                <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 22, color: animal.image_url ? '#fff' : theme.ink, letterSpacing: F.tightLs, lineHeight: 1.05, textShadow: animal.image_url ? '0 1px 3px rgba(0,0,0,0.75)' : 'none' }}>{nm(animal, lang)}</div>
              </div>
            </React.Fragment>
          ) : (
            <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: theme.dim, fontFamily: F.body, fontWeight: F.med, fontSize: 14 }}>{T.chooseFighter}</div>
          )}
        </button>
        <div style={{ display: 'flex', alignItems: 'center', background: theme.surface, border: `1px solid ${theme.line}`, borderRadius: theme.cardRadius, overflow: 'hidden' }}>
          <button onClick={() => onSetCount(Math.max(1, count - 1))} style={{ flex: 1, height: 30, background: 'transparent', border: 'none', color: theme.inkSoft, fontFamily: F.display, fontSize: 20, fontWeight: F.bold, cursor: 'pointer' }}>−</button>
          <div style={{ flex: 2, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: F.body, fontSize: 14, color: theme.ink, fontWeight: F.bold, borderLeft: `1px solid ${theme.line}`, borderRight: `1px solid ${theme.line}` }}>
            ×{count}
          </div>
          <button onClick={() => onSetCount(Math.min(99, count + 1))} style={{ flex: 1, height: 30, background: 'transparent', border: 'none', color: sideColor, fontFamily: F.display, fontSize: 20, cursor: 'pointer', fontWeight: F.heavy }}>+</button>
        </div>
      </div>
    );
  }

  // ── Main App ──────────────────────────────────────────────────────────────
  function App({ violence = 'realistic', model = 'openai', mode = 'template', lang = 'ru', fontKey = 'manrope', onOpenSettings }) {
    const F = FONT_PRESETS[fontKey] || FONT_PRESETS.manrope;
    return (
      <LangCtx.Provider value={lang}>
        <FontCtx.Provider value={F}>
          <AppInner violence={violence} model={model} mode={mode} lang={lang} fontKey={fontKey} onOpenSettings={onOpenSettings} />
        </FontCtx.Provider>
      </LangCtx.Provider>
    );
  }

  function AppInner({ violence, model, mode, lang, fontKey, onOpenSettings }) {
    const F = useF();
    const T = useT();
    // Roster bootstraps from the bundled list in data.js (so first render is
    // instant) and is then replaced by the canonical server copy — which
    // also carries image_url, strengths, weaknesses.
    const [roster, setRoster] = React.useState(D.animals);
    const [animalA, setAnimalA] = React.useState(() => D.animals.find((a) => a.id === 'trex'));
    const [animalB, setAnimalB] = React.useState(() => D.animals.find((a) => a.id === 'dragon'));
    const [editingAnimal, setEditingAnimal] = React.useState(null);
    const [countA, setCountA] = React.useState(1);
    const [countB, setCountB] = React.useState(1);
    const [location, setLocation] = React.useState(D.locations.find((l) => l.id === 'jungle'));
    const [picker, setPicker] = React.useState(null);

    // Pull the canonical roster on mount. If it fails we just keep the
    // bundled fallback — UI stays usable.
    React.useEffect(() => {
      fetch('/api/animals')
        .then((r) => (r.ok ? r.json() : Promise.reject(new Error('http ' + r.status))))
        .then((list) => {
          if (!Array.isArray(list) || list.length === 0) return;
          setRoster(list);
          // Refresh the currently-selected fighters with the server copy so
          // image / traits show up immediately on the loader / picker.
          setAnimalA((cur) => list.find((a) => a.id === cur?.id) || cur);
          setAnimalB((cur) => list.find((a) => a.id === cur?.id) || cur);
        })
        .catch((err) => { console.warn('[animals] fetch failed, using bundled list:', err.message); });
    }, []);

    // When the editor reports an update, patch the roster and (if needed) the
    // current selections so the new portrait / traits propagate everywhere.
    const handleAnimalUpdate = (updated) => {
      setRoster((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
      setAnimalA((cur) => (cur?.id === updated.id ? updated : cur));
      setAnimalB((cur) => (cur?.id === updated.id ? updated : cur));
      setEditingAnimal(updated);
    };

    const handleAnimalDelete = (removed) => {
      setRoster((prev) => {
        const next = prev.filter((a) => a.id !== removed.id);
        // If a selected fighter was just deleted, replace it with the first
        // remaining animal so the home screen stays valid.
        const fallback = next[0] || null;
        setAnimalA((cur) => (cur?.id === removed.id ? fallback : cur));
        setAnimalB((cur) => (cur?.id === removed.id ? (next.find((a) => a.id !== fallback?.id) || fallback) : cur));
        return next;
      });
      setEditingAnimal(null);
    };

    // "Add a new animal" flow — opens a small form. On success the new
    // animal is added to the roster and the editor opens automatically so
    // the user can generate a portrait / traits next.
    const [addingAnimal, setAddingAnimal] = React.useState(false);
    const handleAnimalCreate = (created) => {
      setRoster((prev) => [...prev, created]);
      setAddingAnimal(false);
      setEditingAnimal(created);
    };
    // Phases:
    //   setup           — picker / start screen
    //   vote            — "who do you think wins?" predict screen
    //   script-loading  — only in script mode: LLM is writing the script
    //   script-preview  — only in script mode: show the script, let the user
    //                     hit "Go", "Rewrite" or "Back"
    //   result          — slideshow with the 3 generated images
    //   gallery         — archive list
    //   gallery-view    — replay one past fight in ResultView
    const [phase, setPhase] = React.useState('setup');
    const [vote, setVote] = React.useState(null);
    const [outcome, setOutcome] = React.useState(null);
    // Server-assigned id for the current fight, once POST /api/fights succeeds.
    // ResultView uses this to trigger image generation.
    const [currentFightId, setCurrentFightId] = React.useState(null);
    // The LLM-written battle script (only used in script mode).
    const [currentScript, setCurrentScript] = React.useState(null);
    const [scriptError, setScriptError] = React.useState(null);
    const [regening, setRegening] = React.useState(false);
    const GKEY = `beast-fights-gallery`;
    const [gallery, setGallery] = React.useState(() => {
      try { return JSON.parse(localStorage.getItem(GKEY) || '[]'); } catch { return []; }
    });

    // Fetch the canonical archive from the server on mount; fall back to local cache.
    React.useEffect(() => {
      fetch('/api/fights?limit=50')
        .then((r) => (r.ok ? r.json() : Promise.reject(new Error('http ' + r.status))))
        .then((serverFights) => {
          setGallery(serverFights);
          try { localStorage.setItem(GKEY, JSON.stringify(serverFights)); } catch {}
        })
        .catch((err) => { console.warn('[fights] fetch failed, using local cache:', err.message); });
    }, []);

    const submitVote = (v) => {
      setVote(v);
      const r = window.simulateBattle(animalA, animalB, countA, countB, violence);
      setOutcome(r);
      setCurrentFightId(null);
      setCurrentScript(null);
      setScriptError(null);

      const payload = {
        a: animalA, b: animalB,
        countA, countB,
        location,
        result: r,
        vote: v,
        style: 'photo',
        violence,
        model,
        mode,
        lang,
      };

      const optimistic = { id: `tmp-${Date.now()}`, ...payload, created_at: new Date().toISOString() };
      setGallery((prev) => {
        const next = [optimistic, ...prev].slice(0, 50);
        try { localStorage.setItem(GKEY, JSON.stringify(next)); } catch {}
        return next;
      });

      fetch('/api/fights', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      })
        .then((r2) => (r2.ok ? r2.json() : Promise.reject(new Error('http ' + r2.status))))
        .then((saved) => {
          setCurrentFightId(saved.id);
          setGallery((prev) => {
            const next = prev.map((g) => (g.id === optimistic.id ? saved : g));
            try { localStorage.setItem(GKEY, JSON.stringify(next)); } catch {}
            return next;
          });
        })
        .catch((err) => {
          console.warn('[fights] save failed, kept local:', err.message);
          // Sentinel value so ResultView knows there's no server id coming and
          // can fall back to the placeholder slideshow instead of waiting.
          setCurrentFightId(false);
        });

      // Script mode: go to a holding screen while the LLM writes the script.
      // useEffect below fires the /script call as soon as the fight has an id.
      // Template mode: skip the script and go straight to the slideshow.
      const nextPhase = mode === 'script' ? 'script-loading' : 'result';
      setTimeout(() => setPhase(nextPhase), 400);
    };

    // Whenever we land in script-loading with a real fight id, kick off the
    // script generation. When it returns, flip to the preview screen.
    React.useEffect(() => {
      if (phase !== 'script-loading') return;
      if (typeof currentFightId !== 'number') return;
      let cancelled = false;
      fetch(`/api/fights/${currentFightId}/script`, { method: 'POST' })
        .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.detail || j.error || 'http ' + r.status)))))
        .then((payload) => {
          if (cancelled) return;
          setCurrentScript(payload.script);
          setPhase('script-preview');
        })
        .catch((err) => {
          if (cancelled) return;
          console.warn('[script] failed:', err.message);
          setScriptError(err.message);
        });
      return () => { cancelled = true; };
    }, [phase, currentFightId]);

    const regenScript = () => {
      if (typeof currentFightId !== 'number') return;
      setRegening(true);
      setScriptError(null);
      fetch(`/api/fights/${currentFightId}/script/regen`, { method: 'POST' })
        .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(new Error(j.detail || j.error || 'http ' + r.status)))))
        .then((payload) => {
          setCurrentScript(payload.script);
        })
        .catch((err) => {
          console.warn('[script:regen] failed:', err.message);
          setScriptError(err.message);
        })
        .finally(() => setRegening(false));
    };
    // Replay a past fight from the archive — reuses ResultView with the
    // saved fight's id. Images for that fight are already cached server-side
    // (matching cache_key), so they load instantly. Original style / violence /
    // model are honoured (fall back to current settings if older entries are
    // missing them).
    const [replayFight, setReplayFight] = React.useState(null);
    const openFromGallery = (g) => {
      if (!g || typeof g.id !== 'number') return; // skip optimistic temp entries
      setReplayFight(g);
      setPhase('gallery-view');
    };
    const closeReplay = () => { setReplayFight(null); setPhase('gallery'); };
    const reset = () => { setPhase('setup'); setOutcome(null); setVote(null); setCurrentFightId(null); };

    if (phase === 'vote') return <VoteView a={animalA} b={animalB} onVote={submitVote} onCancel={() => setPhase('setup')} />;
    if (phase === 'script-loading') {
      return <ScriptLoading a={animalA} b={animalB} error={scriptError} onCancel={reset} />;
    }
    if (phase === 'script-preview' && currentScript) {
      return (
        <ScriptPreview
          script={currentScript}
          a={animalA} b={animalB}
          regening={regening}
          error={scriptError}
          onGo={() => setPhase('result')}
          onRegen={regenScript}
          onCancel={reset}
        />
      );
    }
    if (phase === 'result' && outcome) return <ResultView fightId={currentFightId} a={animalA} b={animalB} countA={countA} countB={countB} location={location} outcome={outcome} vote={vote} violence={violence} lang={lang} onReset={reset} onGallery={() => setPhase('gallery')} />;
    if (phase === 'gallery-view' && replayFight) {
      return (
        <ResultView
          fightId={replayFight.id}
          a={replayFight.a} b={replayFight.b}
          countA={replayFight.countA} countB={replayFight.countB}
          location={replayFight.location}
          outcome={replayFight.result}
          vote={replayFight.vote}
          violence={replayFight.violence || violence}
          lang={lang}
          onReset={closeReplay}
          onGallery={closeReplay}
        />
      );
    }
    if (phase === 'gallery') return <Gallery gallery={gallery} onClose={() => setPhase('setup')} onOpen={openFromGallery} />;

    return (
      <div style={{ width: '100%', height: '100%', background: theme.bg, color: theme.ink, fontFamily: F.body, position: 'relative', overflow: 'hidden' }}>
        <div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse at top, ${theme.surfaceHi}, ${theme.bg2})` }} />

        {/* masthead */}
        <div style={{ position: 'absolute', top: 0, left: 0, right: 0, padding: '10px 18px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: `1px solid ${theme.line}`, background: theme.bg }}>
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
            <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 22, color: theme.ink, letterSpacing: F.tightLs, lineHeight: 1 }}>Beast<span style={{ color: theme.accent }}>·</span>Fights</div>
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <button onClick={() => setPhase('gallery')} style={{ background: 'transparent', border: `1px solid ${theme.line}`, color: theme.inkSoft, fontFamily: F.body, fontWeight: F.bold, fontSize: 10, padding: '4px 12px', cursor: 'pointer', borderRadius: theme.cardRadius, letterSpacing: F.labelLs, textTransform: 'uppercase' }}>
              {T.archive} · {gallery.length}
            </button>
            {onOpenSettings && (
              <button onClick={onOpenSettings} aria-label="settings" style={{
                background: 'transparent', border: `1px solid ${theme.line}`, color: theme.inkSoft,
                width: 28, height: 28, borderRadius: theme.cardRadius, cursor: 'pointer', padding: 0,
                display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, lineHeight: 1,
              }}>⚙</button>
            )}
          </div>
        </div>

        {/* center */}
        <div style={{ position: 'absolute', top: 44, bottom: 0, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, padding: '0 24px' }}>
          <FighterSlot animal={animalA} count={countA} onSetCount={setCountA} onPick={() => setPicker('A')} side="A" sideColor={theme.sideA} />

          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 9, width: 190 }}>
            <div style={{ fontFamily: F.display, fontSize: 56, lineHeight: 1, letterSpacing: F.tightLs, fontWeight: F.heavy }}>
              <span style={{ color: theme.sideA }}>v</span><span style={{ color: theme.sideB }}>s</span>
            </div>
            <div style={{ width: 28, height: 1, background: theme.line }} />
            <button onClick={() => setPicker('loc')} style={{ background: theme.surface, border: `1px solid ${theme.line}`, borderRadius: theme.cardRadius, padding: '8px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, width: '100%', color: theme.ink, fontFamily: F.body, transition: 'all 0.12s' }}
              onMouseEnter={(e) => { e.currentTarget.style.borderColor = theme.accent; }}
              onMouseLeave={(e) => { e.currentTarget.style.borderColor = theme.line; }}>
              <span style={{ fontSize: 24 }}>{location?.emoji}</span>
              <div style={{ textAlign: 'left', flex: 1, minWidth: 0 }}>
                <div style={{ fontFamily: F.body, fontSize: 9, color: theme.dim, fontWeight: F.bold, letterSpacing: F.labelLs, textTransform: 'uppercase' }}>{T.setting}</div>
                <div style={{ fontFamily: F.display, fontSize: 15, fontWeight: F.bold, letterSpacing: F.tightLs, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{nm(location, lang)}</div>
              </div>
            </button>
            <button onClick={() => setPhase('vote')} disabled={!animalA || !animalB} style={{
              width: '100%', background: theme.accent, color: '#fff7ee',
              border: 'none', borderRadius: theme.cardRadius, padding: '12px 0',
              fontFamily: F.display, fontSize: 17, fontWeight: F.heavy, letterSpacing: F.tightLs,
              cursor: 'pointer', whiteSpace: 'nowrap',
              boxShadow: `0 6px 16px ${theme.accent}40`,
              transition: 'transform 0.1s',
            }}
              onMouseEnter={(e) => e.currentTarget.style.transform = 'translateY(-1px)'}
              onMouseLeave={(e) => e.currentTarget.style.transform = ''}>
              {T.startFight}
            </button>
          </div>

          <FighterSlot animal={animalB} count={countB} onSetCount={setCountB} onPick={() => setPicker('B')} side="B" sideColor={theme.sideB} />
        </div>

        {picker === 'A' && <Picker items={roster} onPick={setAnimalA} onClose={() => setPicker(null)} onEdit={setEditingAnimal} onAdd={() => setAddingAnimal(true)} title={T.pickFirst} />}
        {picker === 'B' && <Picker items={roster.filter((a) => a.id !== animalA?.id)} onPick={setAnimalB} onClose={() => setPicker(null)} onEdit={setEditingAnimal} onAdd={() => setAddingAnimal(true)} title={T.pickSecond} />}
        {picker === 'loc' && <Picker items={D.locations} onPick={setLocation} onClose={() => setPicker(null)} title={T.pickPlace} columns={6} />}

        {editingAnimal && (
          <AnimalEditor
            animal={editingAnimal}
            model={model}
            lang={lang}
            onClose={() => setEditingAnimal(null)}
            onUpdate={handleAnimalUpdate}
            onDelete={handleAnimalDelete}
          />
        )}

        {addingAnimal && (
          <AnimalAddForm
            onClose={() => setAddingAnimal(false)}
            onCreate={handleAnimalCreate}
          />
        )}
      </div>
    );
  }

  // ── Vote ──────────────────────────────────────────────────────────────────
  function VoteView({ a, b, onVote, onCancel }) {
    const F = useF();
    const T = useT();
    const lang = useLang();
    return (
      <div style={{ width: '100%', height: '100%', background: theme.bg, color: theme.ink, fontFamily: F.body, position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
        <div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse at top, ${theme.surfaceHi}, ${theme.bg2})` }} />
        <button onClick={onCancel} style={{ position: 'absolute', top: 12, left: 16, background: 'transparent', border: 'none', color: theme.inkSoft, fontFamily: F.body, fontWeight: F.bold, fontSize: 11, cursor: 'pointer', letterSpacing: F.labelLs, textTransform: 'uppercase' }}>{T.back}</button>
        <div style={{ fontFamily: F.body, fontSize: 11, color: theme.dim, fontWeight: F.bold, letterSpacing: F.labelLs, textTransform: 'uppercase', zIndex: 1 }}>{T.yourPrediction}</div>
        <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 38, letterSpacing: F.tightLs, zIndex: 1 }}>{T.whoWins}</div>
        <div style={{ display: 'flex', gap: 30, alignItems: 'center', zIndex: 1 }}>
          {[{ key: 'A', animal: a, color: theme.sideA }, { key: 'B', animal: b, color: theme.sideB }].map((side, i) => (
            <React.Fragment key={side.key}>
              <button onClick={() => onVote(side.key)} style={{
                width: 170, padding: 16, background: theme.surface, border: `1px solid ${theme.line}`, borderTop: `3px solid ${side.color}`, borderRadius: theme.cardRadius,
                cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, color: theme.ink, transition: 'transform 0.12s, box-shadow 0.12s',
              }}
                onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.boxShadow = '0 12px 24px rgba(60,40,20,0.15)'; }}
                onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}>
                <div style={{ fontSize: 80, filter: 'drop-shadow(0 4px 8px rgba(60,40,20,0.3))', transform: side.key === 'B' ? 'scaleX(-1)' : '' }}>{side.animal?.emoji}</div>
                <div style={{ fontFamily: F.display, fontSize: 18, fontWeight: F.heavy, letterSpacing: F.tightLs }}>{nm(side.animal, lang)}</div>
              </button>
              {i === 0 && <div style={{ fontFamily: F.display, fontWeight: F.med, fontSize: 22, color: theme.dim, letterSpacing: F.tightLs }}>vs</div>}
            </React.Fragment>
          ))}
        </div>
        <button onClick={() => onVote('draw')} style={{ background: 'transparent', border: `1px solid ${theme.line}`, color: theme.inkSoft, padding: '6px 18px', fontFamily: F.body, fontWeight: F.bold, fontSize: 11, letterSpacing: F.labelLs, textTransform: 'uppercase', cursor: 'pointer', borderRadius: theme.cardRadius, zIndex: 1 }}>
          {T.itsDraw}
        </button>
      </div>
    );
  }

  // ── Side panel on loaders: one fighter's strengths/weaknesses ────────────
  // Empty → renders nothing (the column collapses), so the layout still
  // works for animals that haven't had traits generated yet.
  function SideTraits({ animal, side, F, T }) {
    const lang = useLang();
    const strengths  = animal?.[`strengths_${lang}`]  || animal?.strengths  || [];
    const weaknesses = animal?.[`weaknesses_${lang}`] || animal?.weaknesses || [];
    if (strengths.length === 0 && weaknesses.length === 0) return null;
    const isLeft = side === 'A';
    const accent = isLeft ? theme.sideA : theme.sideB;
    return (
      <div style={{
        position: 'absolute', top: 16, bottom: 16,
        [isLeft ? 'left' : 'right']: 14,
        width: 190,
        display: 'flex', flexDirection: 'column', gap: 10,
        color: '#fff', zIndex: 2,
        pointerEvents: 'none',
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          <div style={{ fontSize: 28, lineHeight: 1, transform: isLeft ? '' : 'scaleX(-1)' }}>{animal?.emoji || ''}</div>
          <div style={{
            fontFamily: F.display, fontSize: 14, fontWeight: F.heavy, letterSpacing: F.tightLs,
            color: '#fff',
            whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
          }}>{(lang === 'ru' ? animal?.name_ru : animal?.name_en) || ''}</div>
        </div>

        {strengths.length > 0 && (
          <div>
            <div style={{ fontFamily: F.body, fontSize: 8, fontWeight: F.bold, color: accent, letterSpacing: F.labelLs, textTransform: 'uppercase', marginBottom: 4 }}>{T.strengths}</div>
            <ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 3 }}>
              {strengths.map((s, i) => (
                <li key={i} style={{ fontFamily: F.body, fontSize: 11, color: 'rgba(255,255,255,0.92)', lineHeight: 1.3, paddingLeft: 9, position: 'relative' }}>
                  <span style={{ position: 'absolute', left: 0, top: 4, width: 4, height: 4, borderRadius: '50%', background: accent }} />
                  {s}
                </li>
              ))}
            </ul>
          </div>
        )}

        {weaknesses.length > 0 && (
          <div>
            <div style={{ fontFamily: F.body, fontSize: 8, fontWeight: F.bold, color: 'rgba(255,255,255,0.55)', letterSpacing: F.labelLs, textTransform: 'uppercase', marginBottom: 4 }}>{T.weaknesses}</div>
            <ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 3 }}>
              {weaknesses.map((s, i) => (
                <li key={i} style={{ fontFamily: F.body, fontSize: 11, color: 'rgba(255,255,255,0.55)', lineHeight: 1.3, paddingLeft: 9, position: 'relative' }}>
                  <span style={{ position: 'absolute', left: 0, top: 4, width: 4, height: 4, borderRadius: '50%', background: 'rgba(255,255,255,0.35)' }} />
                  {s}
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>
    );
  }

  // ── Script mode: short loading screen while the LLM writes the script ───
  function ScriptLoading({ a, b, error, onCancel }) {
    const F = useF();
    const T = useT();
    return (
      <div style={{
        width: '100%', height: '100%',
        background: 'radial-gradient(ellipse at center, #2a1f18 0%, #100c08 80%)',
        color: '#fff', fontFamily: F.body,
        position: 'relative', overflow: 'hidden',
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
        gap: 18,
      }}>
        <button onClick={onCancel} aria-label="Close" style={{
          position: 'absolute', top: 12, right: 12, width: 32, height: 32, borderRadius: '50%',
          background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)', border: '1px solid rgba(255,255,255,0.22)',
          color: '#fff', fontSize: 14, lineHeight: 1, cursor: 'pointer',
          display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, zIndex: 3,
        }}>✕</button>

        <SideTraits animal={a} side="A" F={F} T={T} />
        <SideTraits animal={b} side="B" F={F} T={T} />

        <div style={{ display: 'flex', alignItems: 'center', gap: 28 }}>
          <div style={{ position: 'relative', animation: 'photoFadeIn 0.6s, sceneShake 2.4s ease-in-out infinite' }}>
            <div style={{ fontSize: 76, lineHeight: 1, filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.6))' }}>{a?.emoji || '?'}</div>
          </div>
          <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 26, letterSpacing: F.tightLs, color: '#c0a888' }}>VS</div>
          <div style={{ position: 'relative', animation: 'photoFadeIn 0.6s, sceneShake 2.4s ease-in-out infinite reverse' }}>
            <div style={{ fontSize: 76, lineHeight: 1, transform: 'scaleX(-1)', filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.6))' }}>{b?.emoji || '?'}</div>
          </div>
        </div>

        {!error && (
          <>
            <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 6 }}>
              <div style={{
                width: 22, height: 22,
                border: '2.5px solid rgba(255,255,255,0.18)',
                borderTopColor: '#fff',
                borderRadius: '50%',
                animation: 'photoSpin 0.9s linear infinite',
                flexShrink: 0,
              }} />
              <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 22, letterSpacing: F.tightLs }}>
                ✍️ {T.writingScript}…
              </div>
            </div>
            <div style={{ fontFamily: F.body, fontSize: 12, color: 'rgba(255,255,255,0.5)', letterSpacing: F.labelLs, textTransform: 'uppercase' }}>
              {T.scriptHint}
            </div>
          </>
        )}

        {error && (
          <>
            <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 20, color: '#e07a4a', letterSpacing: F.tightLs }}>⚠ {error}</div>
            <button onClick={onCancel} style={{
              background: 'rgba(255,255,255,0.14)', backdropFilter: 'blur(6px)', border: '1px solid rgba(255,255,255,0.22)',
              color: '#fff', fontFamily: F.body, fontWeight: F.bold, fontSize: 12, letterSpacing: F.labelLs, textTransform: 'uppercase',
              padding: '10px 22px', cursor: 'pointer', borderRadius: theme.cardRadius,
            }}>{T.back}</button>
          </>
        )}
      </div>
    );
  }

  // ── Script mode: preview screen with title, synopsis and 3 prompts ───────
  function ScriptPreview({ script, a, b, regening, error, onGo, onRegen, onCancel }) {
    const F = useF();
    const T = useT();
    return (
      <div style={{
        width: '100%', height: '100%',
        background: theme.bg, color: theme.ink, fontFamily: F.body,
        position: 'relative', overflow: 'hidden',
        display: 'flex', flexDirection: 'column',
      }}>
        {/* Header */}
        <div style={{
          display: 'flex', alignItems: 'center', gap: 10,
          padding: '10px 16px',
          borderBottom: `1px solid ${theme.line}`,
          flexShrink: 0,
        }}>
          <button onClick={onCancel} style={{
            background: 'transparent', border: `1px solid ${theme.line}`, color: theme.inkSoft,
            fontFamily: F.body, fontWeight: F.bold, fontSize: 10,
            padding: '5px 12px', cursor: 'pointer',
            borderRadius: theme.cardRadius, letterSpacing: F.labelLs, textTransform: 'uppercase',
          }}>{T.back}</button>
          <div style={{ fontSize: 22 }}>{a?.emoji}</div>
          <div style={{ fontFamily: F.body, fontWeight: F.med, fontSize: 12, color: theme.dim }}>vs</div>
          <div style={{ fontSize: 22, transform: 'scaleX(-1)' }}>{b?.emoji}</div>
          <div style={{ flex: 1 }} />
          <div style={{
            fontFamily: F.body, fontSize: 10, fontWeight: F.bold,
            color: theme.dim, letterSpacing: F.labelLs, textTransform: 'uppercase',
          }}>📜 {T.scriptPreviewTitle}</div>
        </div>

        {/* Scrollable script content */}
        <div style={{ flex: 1, overflow: 'auto', padding: '12px 18px' }}>
          <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 22, letterSpacing: F.tightLs, color: theme.ink, marginBottom: 4 }}>
            {script.title}
          </div>
          <div style={{ fontFamily: F.body, fontSize: 13, color: theme.inkSoft, fontWeight: F.med, lineHeight: 1.4, marginBottom: 14 }}>
            <span style={{ color: theme.dim, fontWeight: F.bold, fontSize: 9, letterSpacing: F.labelLs, textTransform: 'uppercase', marginRight: 6 }}>{T.scriptSynopsis}:</span>
            {script.synopsis}
          </div>

          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
            {script.frames.map((frame, i) => (
              <div key={frame.stage} style={{
                background: theme.surface,
                border: `1px solid ${theme.line}`,
                borderRadius: theme.cardRadius,
                padding: '8px 12px',
              }}>
                <div style={{ fontFamily: F.body, fontSize: 10, fontWeight: F.bold, color: theme.accent, letterSpacing: F.labelLs, textTransform: 'uppercase', marginBottom: 4 }}>
                  {T.scriptFrame(i)} · {frame.stage}
                </div>
                <div style={{ fontFamily: F.body, fontSize: 11, color: theme.inkSoft, lineHeight: 1.45 }}>
                  {frame.prompt}
                </div>
              </div>
            ))}
          </div>

          {error && (
            <div style={{ marginTop: 12, padding: '8px 12px', background: '#fdecea', border: '1px solid #e07a4a', borderRadius: theme.cardRadius, color: '#a8412a', fontFamily: F.body, fontSize: 12 }}>
              ⚠ {error}
            </div>
          )}
        </div>

        {/* Action bar */}
        <div style={{
          display: 'flex', gap: 8,
          padding: '10px 16px',
          borderTop: `1px solid ${theme.line}`,
          flexShrink: 0,
          background: theme.bg,
        }}>
          <button onClick={onRegen} disabled={regening} style={{
            background: 'transparent', border: `1px solid ${theme.line}`, color: theme.inkSoft,
            fontFamily: F.body, fontWeight: F.bold, fontSize: 12,
            padding: '10px 16px', cursor: regening ? 'default' : 'pointer',
            borderRadius: theme.cardRadius, letterSpacing: F.labelLs, textTransform: 'uppercase',
            opacity: regening ? 0.55 : 1,
          }}>
            {regening ? T.regenerating : T.regenScript}
          </button>
          <div style={{ flex: 1 }} />
          <button onClick={onGo} disabled={regening} style={{
            background: theme.accent, color: '#fff7ee', border: 'none',
            fontFamily: F.body, fontWeight: F.heavy, fontSize: 13,
            padding: '10px 24px', cursor: regening ? 'default' : 'pointer',
            borderRadius: theme.cardRadius, letterSpacing: F.labelLs, textTransform: 'uppercase',
            boxShadow: `0 4px 12px ${theme.accent}60`,
            opacity: regening ? 0.55 : 1,
          }}>
            {T.go}
          </button>
        </div>
      </div>
    );
  }

  // ── Loading screen shown while the 3 AI frames are still generating ──────
  // `ready` is a map { start, mid, end } where each value is true once that
  // frame has finished generating.
  function LoadingScreen({ a, b, countA, countB, ready, onClose }) {
    const F = useF();
    const T = useT();
    const stages = ['start', 'mid', 'end'];
    const readyCount = stages.filter((s) => ready[s]).length;

    return (
      <div style={{
        width: '100%', height: '100%',
        background: 'radial-gradient(ellipse at center, #2a1f18 0%, #100c08 80%)',
        color: '#fff', fontFamily: F.body,
        position: 'relative', overflow: 'hidden',
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
        gap: 14,
      }}>
        <button onClick={onClose} aria-label="Close" style={{
          position: 'absolute', top: 12, right: 12, width: 32, height: 32, borderRadius: '50%',
          background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)', border: '1px solid rgba(255,255,255,0.22)',
          color: '#fff', fontSize: 14, lineHeight: 1, cursor: 'pointer',
          display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, zIndex: 3,
        }}>✕</button>

        <SideTraits animal={a} side="A" F={F} T={T} />
        <SideTraits animal={b} side="B" F={F} T={T} />

        {/* Two creature emojis with a vs in the middle, gently swaying. */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 28 }}>
          <div style={{ position: 'relative', animation: 'photoFadeIn 0.6s, sceneShake 2.4s ease-in-out infinite' }}>
            <div style={{ fontSize: 76, lineHeight: 1, filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.6))' }}>{a?.emoji || '?'}</div>
            {countA > 1 && (
              <div style={{ position: 'absolute', top: -4, right: -12,
                background: '#fff', color: '#191614', fontSize: 12, padding: '2px 7px',
                borderRadius: 999, fontWeight: F.heavy }}>×{countA}</div>
            )}
          </div>

          <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 26, letterSpacing: F.tightLs, color: '#c0a888' }}>VS</div>

          <div style={{ position: 'relative', animation: 'photoFadeIn 0.6s, sceneShake 2.4s ease-in-out infinite reverse' }}>
            <div style={{ fontSize: 76, lineHeight: 1, transform: 'scaleX(-1)', filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.6))' }}>{b?.emoji || '?'}</div>
            {countB > 1 && (
              <div style={{ position: 'absolute', top: -4, left: -12, transform: 'scaleX(-1)',
                background: '#fff', color: '#191614', fontSize: 12, padding: '2px 7px',
                borderRadius: 999, fontWeight: F.heavy }}>×{countB}</div>
            )}
          </div>
        </div>

        <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 20, letterSpacing: F.tightLs, marginTop: 6 }}>
          {T.preparingFrames}…
        </div>

        {/* Three labeled rows — one per frame, with status icon.
            Frame 1 generates first (the "anchor"), frames 2 & 3 can only start
            after frame 1 is done — so until then they are visually "queued"
            rather than spinning. */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 4, minWidth: 260 }}>
          {stages.map((s, i) => {
            const status =
              ready[s]        ? 'done'
              : s === 'start' ? 'active'
              : ready.start   ? 'active'
              :                 'queued';

            const isDone   = status === 'done';
            const isActive = status === 'active';
            const isQueued = status === 'queued';

            return (
              <div key={s} style={{
                display: 'flex', alignItems: 'center', gap: 12,
                padding: '8px 14px',
                background: isDone   ? `${theme.accent}22`
                          : isActive ? 'rgba(255,255,255,0.08)'
                          :            'rgba(255,255,255,0.03)',
                border: `1px solid ${
                  isDone   ? theme.accent + '88'
                  : isActive ? 'rgba(255,255,255,0.18)'
                  :            'rgba(255,255,255,0.08)'
                }`,
                borderRadius: 6,
                opacity: isQueued ? 0.55 : 1,
                transition: 'all 0.35s',
              }}>
                {isDone && (
                  <div style={{
                    width: 18, height: 18, borderRadius: '50%',
                    background: theme.accent, color: '#fff',
                    fontSize: 12, fontWeight: F.heavy,
                    display: 'flex', alignItems: 'center', justifyContent: 'center',
                    flexShrink: 0,
                  }}>✓</div>
                )}
                {isActive && (
                  <div style={{
                    width: 18, height: 18,
                    border: '2px solid rgba(255,255,255,0.18)',
                    borderTopColor: '#fff',
                    borderRadius: '50%',
                    animation: 'photoSpin 0.9s linear infinite',
                    flexShrink: 0,
                  }} />
                )}
                {isQueued && (
                  <div style={{
                    width: 18, height: 18,
                    display: 'flex', alignItems: 'center', justifyContent: 'center',
                    fontSize: 13, color: 'rgba(255,255,255,0.5)',
                    flexShrink: 0,
                  }}>⏱</div>
                )}
                <div style={{
                  fontFamily: F.body, fontWeight: F.bold, fontSize: 13,
                  color: isDone ? '#fff'
                       : isActive ? 'rgba(255,255,255,0.85)'
                       : 'rgba(255,255,255,0.5)',
                  letterSpacing: F.labelLs, textTransform: 'uppercase',
                }}>
                  {T.frameNames[i]}
                </div>
              </div>
            );
          })}
        </div>

        <div style={{ fontFamily: F.body, fontWeight: F.med, fontSize: 12, color: 'rgba(255,255,255,0.5)', letterSpacing: F.labelLs, textTransform: 'uppercase', marginTop: 4 }}>
          {T.framesReady(readyCount)}
        </div>
      </div>
    );
  }

  // ── Result slideshow ──────────────────────────────────────────────────────
  function ResultView({ fightId, a, b, countA, countB, location, outcome, vote, violence, lang, onReset, onGallery }) {
    const F = useF();
    const T = useT();
    const [frame, setFrame] = React.useState(0);
    const [videoState, setVideoState] = React.useState('idle');
    const stages = ['start', 'mid', 'end'];
    // fightId tri-state:
    //   number → save succeeded, we can request images
    //   false  → save failed, skip AI and show the placeholder slideshow
    //   null   → still saving, keep showing the loading screen
    const hasServerFight = typeof fightId === 'number';
    const saveFailed = fightId === false;

    // Fetch the three AI images in parallel as soon as we have a server-side
    // fight id. We then wait until ALL three are done (success or fail) before
    // revealing the slideshow — that way the kid can swipe through frames
    // instantly instead of catching them mid-load.
    //
    // Why we start in the "loading even without a fightId" state: the POST to
    // /api/fights happens in parallel with setPhase('result') (which fires after
    // a 400ms tween). On a slow network the user lands on this view before the
    // fight has a server id — without this default the gating below would
    // immediately fall through to the placeholder slideshow.
    const [images, setImages] = React.useState({ start: null, mid: null, end: null });
    const [loading, setLoading] = React.useState({ start: true, mid: true, end: true });

    React.useEffect(() => {
      if (!hasServerFight) return;
      let cancelled = false;
      // Reset state on every fightId change — covers both the initial null→id
      // transition and the (rare) refetch case.
      setImages({ start: null, mid: null, end: null });
      setLoading({ start: true, mid: true, end: true });
      stages.forEach((stage) => {
        fetch(`/api/fights/${fightId}/images?stage=${stage}`, { method: 'POST' })
          .then((r) => (r.ok ? r.json() : Promise.reject(new Error('http ' + r.status))))
          .then((payload) => {
            if (cancelled) return;
            setImages((prev) => ({ ...prev, [stage]: payload.url }));
            setLoading((prev) => ({ ...prev, [stage]: false }));
          })
          .catch((err) => {
            if (cancelled) return;
            console.warn(`[images] ${stage} failed:`, err.message);
            setLoading((prev) => ({ ...prev, [stage]: false }));
          });
      });
      return () => { cancelled = true; };
    }, [fightId]);

    // Gating: show the loading screen until every image request has settled
    // (success or fail). If the fight failed to save, fall through immediately
    // to the placeholder slideshow.
    const ready = { start: !!images.start, mid: !!images.mid, end: !!images.end };
    const allDone = saveFailed || (hasServerFight && stages.every((s) => !loading[s]));

    if (!allDone) {
      return (
        <LoadingScreen a={a} b={b} countA={countA} countB={countB}
                       ready={ready} onClose={onReset} />
      );
    }

    const captions = lang === 'ru'
      ? ['Они встретились', 'Бой в разгаре', outcome.winner === 'draw' ? 'Ничья' : `Победил ${(outcome.winner === 'A' ? a : b).name_ru}`]
      : ['They meet', 'The clash', outcome.winner === 'draw' ? "It's a draw" : `${(outcome.winner === 'A' ? a : b).name_en} wins`];
    const frameLabels = lang === 'ru'
      ? ['Кадр 1 · начало', 'Кадр 2 · схватка', 'Кадр 3 · финал']
      : ['Frame 1 · opening', 'Frame 2 · clash', 'Frame 3 · finale'];

    const goTo = (i) => { setFrame(i); };
    const prev = () => goTo(Math.max(0, frame - 1));
    const next = () => goTo(Math.min(stages.length - 1, frame + 1));

    return (
      <div style={{ width: '100%', height: '100%', background: '#000', color: theme.ink, fontFamily: F.body, position: 'relative', overflow: 'hidden' }}>
        <div style={{ position: 'absolute', inset: 0 }}>
          <PhotoCard
            location={location} a={a} b={b} countA={countA} countB={countB}
            stage={stages[frame]} winner={outcome.winner} mood={violence}
            caption={captions[frame]} label={frameLabels[frame]}
            full hideCaption hideLabel
            imageUrl={images[stages[frame]]}
            imageLoading={loading[stages[frame]]}
          />
        </div>

        <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 90, background: 'linear-gradient(rgba(0,0,0,0.5), transparent)', pointerEvents: 'none', zIndex: 3 }} />
        <button onClick={onReset} aria-label="Close" style={{
          position: 'absolute', top: 12, right: 12, width: 32, height: 32, borderRadius: '50%',
          background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)', border: '1px solid rgba(255,255,255,0.22)',
          color: '#fff', fontSize: 14, lineHeight: 1, cursor: 'pointer', zIndex: 7,
          display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
        }}>✕</button>

        <button onClick={prev} disabled={frame === 0} style={{
          position: 'absolute', left: 0, top: 50, bottom: 90, width: '28%', background: 'transparent', border: 'none',
          cursor: frame === 0 ? 'default' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 14, zIndex: 4,
        }}>
          <div style={{ width: 38, height: 38, borderRadius: '50%', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', color: '#fff', fontFamily: F.display, fontSize: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: frame === 0 ? 0 : 0.85 }}>‹</div>
        </button>
        <button onClick={next} disabled={frame === stages.length - 1} style={{
          position: 'absolute', right: 0, top: 50, bottom: 90, width: '28%', background: 'transparent', border: 'none',
          cursor: frame === stages.length - 1 ? 'default' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', paddingRight: 14, zIndex: 4,
        }}>
          <div style={{ width: 38, height: 38, borderRadius: '50%', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', color: '#fff', fontFamily: F.display, fontSize: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: frame === stages.length - 1 ? 0 : 0.85 }}>›</div>
        </button>

        {frame === stages.length - 1 && (
          <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '70px 16px 16px', background: 'linear-gradient(transparent, rgba(0,0,0,0.85))', display: 'flex', gap: 10, alignItems: 'center', justifyContent: 'center', zIndex: 6 }}>
            <button onClick={onReset} style={{ background: 'rgba(255,255,255,0.14)', backdropFilter: 'blur(6px)', border: '1px solid rgba(255,255,255,0.22)', color: '#fff', fontFamily: F.body, fontWeight: F.bold, fontSize: 12, letterSpacing: F.labelLs, textTransform: 'uppercase', padding: '10px 18px', cursor: 'pointer', borderRadius: theme.cardRadius, whiteSpace: 'nowrap' }}>{T.rematch}</button>
            <button onClick={() => { if (videoState === 'idle') { setVideoState('gen'); setTimeout(() => setVideoState('ready'), 1800); } }} style={{
              background: videoState === 'ready' ? theme.sideB : theme.accent, color: '#fff7ee', border: 'none',
              fontFamily: F.body, fontWeight: F.heavy, fontSize: 12, letterSpacing: F.labelLs, textTransform: 'uppercase', padding: '10px 20px', cursor: 'pointer', borderRadius: theme.cardRadius, whiteSpace: 'nowrap',
              boxShadow: `0 4px 12px ${(videoState === 'ready' ? theme.sideB : theme.accent)}60`,
            }}>
              {videoState === 'idle' && T.makeVideo}
              {videoState === 'gen' && T.rendering}
              {videoState === 'ready' && T.playVideo}
            </button>
          </div>
        )}
      </div>
    );
  }

  // ── Gallery ───────────────────────────────────────────────────────────────
  function Gallery({ gallery, onClose, onOpen }) {
    const F = useF();
    const T = useT();
    const lang = useLang();
    return (
      <div style={{ width: '100%', height: '100%', background: theme.bg, color: theme.ink, fontFamily: F.body, padding: '12px 18px', display: 'flex', flexDirection: 'column' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10, paddingBottom: 8, borderBottom: `1px solid ${theme.line}` }}>
          <button onClick={onClose} style={{ background: 'transparent', border: `1px solid ${theme.line}`, color: theme.inkSoft, fontFamily: F.body, fontWeight: F.bold, fontSize: 10, padding: '5px 12px', cursor: 'pointer', borderRadius: theme.cardRadius, letterSpacing: F.labelLs, textTransform: 'uppercase' }}>{T.back}</button>
          <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 24, letterSpacing: F.tightLs }}>{T.archive}</div>
          <div style={{ fontFamily: F.body, fontWeight: F.med, fontSize: 13, color: theme.dim }}>· {T.battleN(gallery.length)}</div>
        </div>
        {gallery.length === 0 ? (
          <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: theme.dim, fontFamily: F.body, fontWeight: F.med, fontSize: 14 }}>{T.noFightsYet}</div>
        ) : (
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, overflow: 'auto' }}>
            {gallery.map((g) => {
              // Optimistic entries (still saving) have a string id like 'tmp-…'.
              // They can't be replayed yet because images need a real server id.
              const clickable = typeof g.id === 'number' && !!onOpen;
              return (
                <button
                  key={g.id}
                  onClick={clickable ? () => onOpen(g) : undefined}
                  disabled={!clickable}
                  style={{
                    background: theme.surface,
                    border: `1px solid ${theme.line}`,
                    borderRadius: theme.cardRadius,
                    padding: 10,
                    display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
                    cursor: clickable ? 'pointer' : 'default',
                    fontFamily: 'inherit', color: 'inherit', textAlign: 'center',
                    transition: 'all 0.12s',
                  }}
                  onMouseEnter={(e) => { if (clickable) { e.currentTarget.style.borderColor = theme.accent; e.currentTarget.style.transform = 'translateY(-1px)'; } }}
                  onMouseLeave={(e) => { e.currentTarget.style.borderColor = theme.line; e.currentTarget.style.transform = ''; }}
                >
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 30 }}>
                    <span style={{ opacity: g.result.winner === 'B' ? 0.3 : 1, filter: g.result.winner === 'B' ? 'grayscale(1)' : '' }}>{g.a.emoji}</span>
                    <span style={{ fontFamily: F.body, fontWeight: F.med, fontSize: 12, color: theme.dim }}>vs</span>
                    <span style={{ opacity: g.result.winner === 'A' ? 0.3 : 1, filter: g.result.winner === 'A' ? 'grayscale(1)' : '' }}>{g.b.emoji}</span>
                  </div>
                  <div style={{ fontFamily: F.display, fontWeight: F.heavy, fontSize: 14, color: theme.accent, letterSpacing: F.tightLs, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '100%' }}>
                    {g.result.winner === 'A' ? nm(g.a, lang) : g.result.winner === 'B' ? nm(g.b, lang) : T.itsDraw}
                  </div>
                  <div style={{ fontSize: 18 }}>{g.location.emoji}</div>
                </button>
              );
            })}
          </div>
        )}
      </div>
    );
  }

  return App;
})();

window.V1Photo = V1Photo;
