import { useEffect, useState, type ReactNode } from 'react'
import { EyeOff, Mic, MicOff, ScreenShare, ScreenShareOff, Users } from 'lucide-react'
import { Track, type Participant } from 'livekit-client'
import {
  CarouselLayout,
  FocusLayoutContainer,
  isTrackReference,
  useRoomContext,
  useSpeakingParticipants,
  useTrackToggle,
  useTracks,
  type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { StageGrid } from '@/components/room/StageGrid'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
import { SCREEN_SHARE_CAPTURE_OPTIONS } from '@/lib/screenShareOptions'
import type { StageLayoutMode } from '@/lib/stageLayoutMode'

/**
 * Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
 * источники треков для `useTracks`. Важно: внутренний `useMemo` хука
 * `useTracks` держит СЫРОЙ параметр `sources` в списке зависимостей (не
 * только `trackReferences`/`participants`), поэтому инлайновый литерал
 * массива в вызове (`useTracks([{ source: ... }], ...)`) пересоздавался бы
 * заново при каждом рендере `RoomStage` и ломал мемоизацию: `tracks` был бы
 * НОВОЙ ссылкой на каждый рендер даже без реальных изменений участников.
 * Раньше это было безобидно (код ниже не сравнивал `tracks` по ссылке
 * между рендерами), но стало критично после того, как ниже появилось
 * состояние, которое обновляется именно по признаку «пришёл новый `tracks`»
 * (см. комментарий у `prevTracks`) — без этой константы получался бесконечный
 * цикл рендеров.
 *
 * Демонстрация экрана: к камере добавлен `Track.Source.ScreenShare`
 * без плейсхолдера (демонстрация либо есть, либо участника просто нет в этом
 * наборе — в отличие от камеры, «пустая» демонстрация не показывается вовсе).
 * Штатный паттерн смешанных источников — как в `VideoConference` из самой
 * `@livekit/components-react`.
 */
const STAGE_TRACK_SOURCES = [
  { source: Track.Source.Camera, withPlaceholder: true },
  { source: Track.Source.ScreenShare, withPlaceholder: false },
]

/**
 * Удержание фокуса при смене говорящего, мс. Действует в ОБОИХ вариантах
 * сцены — и в основном окне, и в мини-плеере (до 0.0.25 в PiP удержания не
 * было вовсе, фокус там переключался мгновенно).
 *
 * Сцена следует за спикером (`followSpeaker`, задача 3.2), и без
 * удержания короткие реплики («ага», «угу») уводили бы большую плитку на
 * секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants`
 * поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но
 * реплику длиной в полсекунды он честно отдаёт как смену состава.
 *
 * Значение подобрано от периода самого события: LiveKit пересчитывает активных
 * спикеров примерно раз в 0.5 с, то есть короткая реплика — это 1–2 обновления.
 * 1.2 с ≈ 2–3 обновления: блик до 1.2 с не проходит вовсе (значение успевает
 * вернуться обратно, таймер перезапускается), а осмысленная фраза переключает
 * фокус с задержкой, которая на глаз читается как плавность, а не как тормоз.
 * Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно
 * запаздывает переход на нового докладчика.
 *
 * В мини-плеере удержание тем более уместно: там плитка ОДНА, и мгновенное
 * переключение читается не как «камера следует за разговором», а как мигание
 * всего окна целиком.
 */
const SPEAKER_HOLD_MS = 1200

/**
 * Возвращает состав говорящих, «успокоенный» удержанием: новое значение
 * применяется, только если оно продержалось `holdMs` без изменений. Короткая
 * реплика меняет состав и возвращает его обратно раньше таймера — тогда
 * применять уже нечего (cleanup эффекта гасит таймер, а новое значение
 * сравнивается по ссылке с текущим).
 *
 * `holdMs <= 0` — удержания нет, значение отдаётся как есть. Сейчас этим
 * режимом никто не пользуется (обе сцены удерживают состав), но параметр
 * оставлен: он и делает функцию пригодной для повторного использования.
 */
function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] {
  const [steady, setSteady] = useState(speakers)

  useEffect(() => {
    if (holdMs <= 0 || speakers === steady) return
    const timer = setTimeout(() => setSteady(speakers), holdMs)
    return () => clearTimeout(timer)
  }, [speakers, steady, holdMs])

  return holdMs <= 0 ? speakers : steady
}

/**
 * Есть ли у трека ЖИВОЕ видео: это настоящий трек (не плейсхолдер выключенной
 * камеры) и он не в мьюте. По этому признаку режим «Живые плитки» делит
 * участников на сетку и карусель, а `pickStageFocus` отдаёт предпочтение
 * говорящему с картинкой.
 */
function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
  return isTrackReference(track) && !track.publication.isMuted
}

/**
 * Кнопка микрофона поверх единственной плитки мини-плеера (задача A5): в
 * Document PiP своего тулбара нет вовсе, а звонок продолжается — без неё
 * замьютить себя можно, только развернув основное окно. `useTrackToggle` —
 * тот же хук, что и в `RoomToolbar`, читает/меняет состояние ЧЕРЕЗ
 * `RoomContext`, а не через DOM, поэтому работает одинаково что в основном
 * окне, что в портале, рендерящем эту сцену внутрь `pipWindow.document.body`
 * (React-контекст порталом не разрывается, в отличие от DOM-дерева).
 */
function PipMicToggle() {
  const mic = useTrackToggle({ source: Track.Source.Microphone })
  return (
    <button
      type="button"
      {...mic.buttonProps}
      className={`room-pip-btn room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
      aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
    >
      {mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
    </button>
  )
}

/**
 * Кнопка демонстрации экрана в мини-плеере — сестра `PipMicToggle` (0.0.29).
 * Тот же `useTrackToggle` и те же `SCREEN_SHARE_CAPTURE_OPTIONS`, что у кнопки
 * основного тулбара (`RoomToolbar`), поэтому обе кнопки — два вида одного
 * состояния: включённая из мини-окна демонстрация показывает основную кнопку
 * активной и наоборот, рассинхрону взяться неоткуда (состояние читается из
 * `RoomContext`, а не из DOM).
 *
 * 🔬 ПОЧЕМУ ЭТО ВООБЩЕ РАБОТАЕТ ИЗ ДРУГОГО ОКНА. `getDisplayMedia()` требует
 * транзиентной активации пользователя, а клик здесь происходит в PiP-окне,
 * тогда как сам код (React-дерево целиком остаётся в основном документе,
 * порталом уезжает только DOM) вызывает `navigator` ОСНОВНОГО окна. Замерено
 * в Chrome 150 отдельной пробой: после клика в Document PiP
 * `navigator.userActivation.isActive === true` в ОБОИХ окнах — активация
 * доезжает до опенера, вызов проходит, системный пикер выбора экрана
 * открывается отдельным окном поверх остальных (а не прячется за заглушкой
 * «Конференция открыта в отдельном мини-окне» в основном окне). Проверять это
 * пришлось живьём: спецификация такого поведения не обещает, и на других
 * движках оно может отличаться — но Document PiP есть только в Chrome/Edge,
 * так что других движков здесь и не бывает (в Safari мини-окно — нативный
 * video-PiP без собственного DOM, кнопке там просто негде жить, в Firefox
 * мини-окна нет вовсе).
 *
 * Ошибку показываем тостом основного окна: пользователь его сейчас не видит
 * (там заглушка), но увидит, как только вернётся, — а единственный частый
 * «сбой», отмена пикера, и так молча игнорируется, как в основном тулбаре.
 */
function PipScreenShareToggle() {
  const toast = useToast()
  const screenShare = useTrackToggle({
    source: Track.Source.ScreenShare,
    captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS,
    onDeviceError: (error) => {
      if (error.name === 'NotAllowedError') return
      toast.show('Не удалось начать демонстрацию экрана', 'error')
    },
  })
  return (
    <button
      type="button"
      {...screenShare.buttonProps}
      className={`room-pip-btn room-pip-share-toggle${screenShare.enabled ? ' is-on' : ''}`}
      aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрация экрана'}
    >
      {screenShare.enabled ? (
        <ScreenShareOff className="lucide" aria-hidden="true" />
      ) : (
        <ScreenShare className="lucide" aria-hidden="true" />
      )}
    </button>
  )
}

/**
 * Основная сцена конференции: превью остальных участников + крупная плитка
 * активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
 * участниках, равномерная сетка при одном/двух).
 *
 * РЕЖИМЫ ПОКАЗА (`layoutMode`, выбор пользователя, см. `lib/stageLayoutMode.ts`):
 *   - `standard` — как было до 0.0.11: крупная плитка (кого показать, решает
 *     `pickStageFocus`) + карусель остальных сбоку;
 *   - `tiles` — все участники равными плитками (`StageGrid`), без фокуса;
 *   - `live-tiles` — сетка только из участников с включённой камерой,
 *     остальные (плейсхолдер/мьют, см. `hasLiveVideo`) — в карусели сбоку.
 *     Если камеру не включил никто, сетка была бы пустой, поэтому в этом
 *     случае показываем всех — режим вырождается в `tiles`.
 *
 * ДЕМОНСТРАЦИЯ ЭКРАНА перебивает выбранный режим: пока в комнате есть хоть
 * одна активная демонстрация, сцена ведёт себя как `standard` (демонстрация
 * крупно, все камеры в карусели). Это сознательно: смысл плиточных режимов —
 * равноправие участников, а демонстрация по определению неравноправна, ради
 * неё её и включают. Режим при этом не меняется — он живёт в состоянии
 * `RoomPage`, поэтому по завершении демонстрации сцена сама возвращается к
 * выбранному пользователем виду.
 *
 * СКРЫТИЕ ОСТАЛЬНЫХ (`hideOthers`): карусель не рендерится вовсе, основная
 * область занимает всю сцену. Скрыть можно тремя способами — меню «Вид»,
 * шторка настроек на мобильном и кнопка «Скрыть» прямо над колонкой миниатюр
 * (`onHideOthers`, только широкий экран). Вернуть — кнопкой «Показать
 * остальных» на сцене (`onShowOthers`): она видна всегда, пока кто-то скрыт,
 * чтобы участники не «потерялись» без понятного способа их вернуть. В режиме
 * `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
 * см. `StageViewOptions`.
 *
 * ФОКУС И ЗАКРЕПЛЕНИЕ ПЕРЕЖИВАЮТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере —
 * ОТДЕЛЬНЫЙ экземпляр этого компонента (портал в PiP-окно), и своё состояние
 * фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
 * `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно
 * открывалось на самом пользователе вместо того, что он видел крупно. В Safari
 * бага не было видно: там Document PiP не используется, а video-фолбэк
 * (`useRoomPiP`) берёт `<video>` прямо из фокус-плитки основного окна. Лечится
 * передачей ключа наружу и обратно: `onFocusKeyChange` → состояние в
 * `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны
 * — возврат из мини-плеера тоже не сбрасывает фокус.
 *
 * Ровно тем же мостиком с 0.0.25 ездит и ЗАКРЕПЛЕНИЕ (`initialPinnedKey` /
 * `onPinnedKeyChange`): раньше `pinnedKey` был чисто локальным `useState`, и
 * закрепление, сделанное в основном окне, в мини-плеер не попадало вовсе.
 * Отдельный «общий» источник правды здесь не нужен: экземпляр сцены в каждый
 * момент ровно один (пока открыт Document PiP, основное окно показывает
 * заглушку — см. `RoomPage`), поэтому состояние достаточно передать по эстафете.
 *
 * Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
 * горизонтальная лента, см. design/mockups/room.html после правки: узкая
 * колонка сбоку, скролл по вертикали). Это штатное поведение самого
 * `@livekit/components-react` (2.9.23) — `FocusLayoutContainer` рендерит
 * `.lk-focus-layout` (`grid-template-columns: 1fr 5fr` — первая, узкая
 * колонка ровно под превью), а `CarouselLayout` без явного `orientation`
 * сам определяет направление по РАЗМЕРАМ СВОЕГО КОНТЕЙНЕРА (`height >= width`
 * → `vertical`) — важно НЕ передавать сюда className, перекрывающий базовый
 * `lk-carousel` (см. её исходник: `{...props}` после `className="lk-carousel"`
 * в JSX полностью заменяет, а не объединяет класс — раньше здесь было
 * `className="preview-row"`, из-за этого терялись все стили направления/
 * авторазмера тайлов, и лента визуально превращалась в горизонтальный ряд).
 * Адаптив уже встроен в @livekit/components-styles: на ≤600px
 * `.lk-focus-layout` сам переключается в колонку (спикер сверху, превью —
 * узкая горизонтальная полоса снизу, `.lk-carousel{order:1}`) — без
 * дополнительного JS с нашей стороны. Точечные доводки размеров/скроллбара —
 * styles/room.css (`.stage-tiles`/`.lk-carousel`).
 *
 * Демонстрация экрана: при наличии хотя бы одной активной
 * демонстрации `FocusLayoutContainer` форсируется БЕЗУСЛОВНО (независимо от
 * числа участников) — в фокусе демонстрация, в карусели ВСЕ camera-треки,
 * включая камеру самого демонстратора (решение архитектора). Без демонстрации
 * поведение не меняется — прежняя раскладка по спикеру/первому треку.
 *
 * Проп `variant="pip"` — для рендера
 * ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
 * показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
 * режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
 *
 * ВЫБОР ФОКУСА ОДИНАКОВ В ОБОИХ ВАРИАНТАХ (с 0.0.25). До этого мини-плеер был
 * намеренно «упрощён»: без удержания говорящего, без удержания демонстрации
 * экрана (`holdScreenShare`), без приоритета говорящего с включённой камерой и
 * без закрепления. На практике это читалось как поломка: в мини-окне
 * демонстрация экрана слетала от любой чужой реплики, а закрепление,
 * сделанное в основном окне, не действовало. Теперь `pickStageFocus`
 * получает одни и те же правила независимо от варианта — разным остаётся
 * ровно одно: `localKey` (см. ниже) и то, что PiP рисует одну плитку вместо
 * раскладки.
 *
 * Единственное сознательное отличие — `localKey`: у мини-плеера есть
 * последний фолбэк «показать себя», у основного окна его нет (там фолбэк —
 * первый трек по порядку, поведение не менялось). Строка из того же сюжета,
 * что и `initialFocusKey`: без неё свежеоткрытое мини-окно на пустой комнате
 * выбирало произвольного участника.
 */
export function RoomStage({
  variant = 'full',
  layoutMode = 'standard',
  hideOthers = false,
  onShowOthers,
  onHideOthers,
  initialFocusKey = null,
  onFocusKeyChange,
  initialPinnedKey = null,
  onPinnedKeyChange,
  onPinFocus,
  raisedHandIdentities,
  conferenceId,
  stageMaxTiles,
}: {
  variant?: 'full' | 'pip'
  /** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
  layoutMode?: StageLayoutMode
  /** Скрыть карусель остальных участников; игнорируется при `variant="pip"`. */
  hideOthers?: boolean
  /** Вернуть скрытых участников — кнопка на сцене (см. докстринг выше). */
  onShowOthers?: () => void
  /** Скрыть остальных — кнопка над каруселью (только широкий экран). */
  onHideOthers?: () => void
  /** Чем инициализировать фокус при монтировании — см. докстринг про мини-плеер. */
  initialFocusKey?: string | null
  /** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
  onFocusKeyChange?: (key: string | null) => void
  /** Чем инициализировать закрепление при монтировании — тот же мостик через `RoomPage`, что и у фокуса. */
  initialPinnedKey?: string | null
  /** Сообщать наружу закрепление, чтобы оно пережило переезд сцены в мини-плеер и обратно. */
  onPinnedKeyChange?: (key: string | null) => void
  /**
   * Участника только что закрепили (не открепили) в режиме без крупной
   * плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
   * поэтому просит переключить на `standard`, где закреплённого есть куда
   * показать крупно. Открепление НЕ вызывает колбэк — вид назад не
   * переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
   */
  onPinFocus?: () => void
  /** Identity участников с поднятой рукой (задача B1) — бейдж на плитке; игнорируется при `variant="pip"`. */
  raisedHandIdentities?: Set<string>
  /** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
  conferenceId?: string
  /** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
  stageMaxTiles?: number
}) {
  const room = useRoomContext()
  const isCompact = useIsCompactViewport()
  const tracks = useTracks(STAGE_TRACK_SOURCES, {
    onlySubscribed: false,
  })
  // Активные спикеры уже отсортированы SDK по громкости
  // (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`,
  // событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не
  // дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`).
  // Поверх этого сцена ещё и удерживает состав (см. `useSteadySpeakers` и
  // `SPEAKER_HOLD_MS`) — в обоих вариантах одинаково.
  const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), SPEAKER_HOLD_MS)

  const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
  const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
  const hasScreenShare = screenShareTracks.length > 0

  // Кто «в фокусе» — храним по составному ключу (см. `stageTrackKey` и
  // обоснование в `stageFocus.ts`) в состоянии, а не пересчитываем заново из
  // порядка `tracks` на каждом рендере. Порядок этого массива не
  // гарантированно стабилен между кадрами (у @livekit/components-core он
  // может поменяться при переходе placeholder → реальный трек, при
  // подключении нового участника или при старте/остановке демонстрации), и
  // если брать `tracks[0]`/`.find()` напрямую при каждом рендере, «в фокусе»
  // и «в карусели» на паре соседних рендеров может оказаться то один, то
  // другой участник — тогда CarouselLayout получает на входе список, где
  // полностью сменился единственный элемент (был трек A, стал трек B), а её
  // собственный `useVisualStableUpdate` считает это не reflow'ом, а
  // поломанным состоянием и кидает (пойманную и залогированную, но всё
  // равно шумную) `Error: Element not part of the array`.
  //
  // Вся логика выбора «что дальше в фокусе» вынесена в чистую функцию
  // `pickStageFocus` (last-wins для нового шэра, удержание текущего фокуса,
  // фолбэк на активного спикера/первый трек) — здесь только вызов в
  // санкционированном React-паттерне «adjusting state during rendering» (не
  // useEffect — eslint-plugin-react-hooks не даёт ни читать ref во время
  // рендера, ни синхронно звать setState внутри эффекта), под охраной
  // сравнения с предыдущим `tracks` — сравниваем по ссылке, `useTracks` отдаёт
  // новый массив только когда реально что-то изменилось (см. комментарий у
  // `STAGE_TRACK_SOURCES` про стабильность ссылки).
  //
  // Пересчёт триггерится ещё и сменой `speakingParticipants` (тоже сравнение
  // по ссылке — хук отдаёт новый массив только при реальном изменении состава/
  // порядка говорящих), и сменой закрепления (`pinnedKey`) — оба входа
  // `pickStageFocus` меняют результат без изменения самих треков.
  const [prevTracks, setPrevTracks] = useState(tracks)
  const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
  // Стартовое значение — фокус, доставшийся от предыдущего экземпляра сцены
  // (см. `initialFocusKey` и докстринг про мини-плеер). Дальше живёт своей
  // жизнью: `pickStageFocus` пересчитывает его на каждое значимое изменение.
  const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey)
  // Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
  // плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
  // Стартовое значение приходит от предыдущего экземпляра сцены (тот же
  // мостик через `RoomPage`, что и у фокуса), поэтому закрепление, сделанное
  // в основном окне, действует и в мини-плеере.
  const [pinnedKey, setPinnedKey] = useState<string | null>(initialPinnedKey)
  const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null)

  const cameraKeys = cameraTracks.map(stageTrackKey)
  const screenShareKeys = screenShareTracks.map(stageTrackKey)
  // Закреплённый участник вышел из комнаты (его ключа нет ни среди камер — а
  // камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди
  // демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном
  // состоянии и булавка не «висела» на исчезнувшем ключе.
  //
  // `tracksKnown` — обязательная охрана, а не перестраховка: на ПЕРВОМ рендере
  // нового экземпляра сцены `useTracks` отдаёт ПУСТОЙ массив (реальный состав
  // приезжает следующим рендером, из подписки на события комнаты). Без этой
  // проверки пустой набор читается как «все вышли», и закрепление, приехавшее
  // через `initialPinnedKey`, обнулялось сразу при монтировании — то есть
  // мини-плеер терял его каждый раз (найдено живой отладкой при 0.0.25).
  // Пустых наборов при живой комнате не бывает: камера есть у каждого
  // участника хотя бы плейсхолдером.
  const tracksKnown = cameraKeys.length > 0 || screenShareKeys.length > 0
  const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey))

  const tracksChanged = tracks !== prevTracks
  const speakingChanged = speakingParticipants !== prevSpeakingParticipants
  const pinnedChanged = pinnedKey !== prevPinnedKey

  if (pinnedKey !== null && tracksKnown && !pinnedAlive) {
    setPinnedKey(null)
  }

  if (tracksChanged || speakingChanged || pinnedChanged) {
    const prevKeys = prevTracks.map(stageTrackKey)
    if (tracksChanged) setPrevTracks(tracks)
    if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants)
    if (pinnedChanged) setPrevPinnedKey(pinnedKey)
    // Говорящие — упорядоченные по громкости камера-ключи: нужен именно
    // порядок (взять самого громкого) и именно throttled-источник SDK
    // (`useSpeakingParticipants`, в основном окне ещё и с удержанием), чтобы
    // followSpeaker не дёргался на каждый чих. Сырой `participant.isSpeaking`,
    // на котором основное окно жило до 0.0.6, дребезжит и для слежения за
    // спикером не годится.
    const speakingCameraKeys = speakingParticipants
      .map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
      .filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
      .map(stageTrackKey)
    const result = pickStageFocus({
      cameraKeys,
      screenShareKeys,
      speakingCameraKeys,
      cameraKeysWithVideo: cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
      prevKeys,
      prevFocusKey: focusKey,
      pinnedKey: pinnedAlive ? pinnedKey : null,
      followSpeaker: true,
      holdScreenShare: true,
      // Только для PiP — в основном окне фолбэк на «первый трек» не менялся.
      localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
    })
    if (result.focusKey !== focusKey) {
      setFocusKey(result.focusKey)
    }
  }

  // Отдаём фокус наружу (в `RoomPage`), чтобы он пережил размонтирование этой
  // сцены и достался следующей — см. `initialFocusKey`. Именно эффект, а не
  // вызов в теле рендера: setState ЧУЖОГО компонента во время рендера React
  // запрещает.
  useEffect(() => {
    onFocusKeyChange?.(focusKey)
  }, [focusKey, onFocusKeyChange])

  // То же самое для закрепления — см. `initialPinnedKey`.
  useEffect(() => {
    onPinnedKeyChange?.(pinnedKey)
  }, [pinnedKey, onPinnedKeyChange])

  const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
  const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
  // При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
  // ВСЕ демонстрации, проигравшие фокус (второй демонстратор при last-wins —
  // см. `pickStageFocus`): её трек продолжает публиковаться, и, по решению
  // архитектора, должен остаться видимой обычной плиткой в карусели, а не
  // молча пропадать из UI. Сравнение — по тому же ключу `identity:source`,
  // что и у `pickStageFocus` (устойчивее прямого сравнения ссылок между
  // рендерами). Когда демонстратор один — его screenshare как раз и есть
  // focusTrack, фильтр исключает его из карусели (дубля нет).
  const carouselTracks = hasScreenShare
    ? [...cameraTracks, ...screenShareTracks.filter((t) => stageTrackKey(t) !== focusTrackKey)]
    : tracks.filter((t) => t !== focusTrack)

  /**
   * Остановить СВОЮ демонстрацию — передаётся в фокус-плитку, чип «Вы
   * демонстрируете экран» и его видимость решает сама `RoomParticipantTile`
   * (показывает только когда трек фокуса — своя демонстрация экрана).
   */
  function handleStopSharing() {
    void room.localParticipant.setScreenShareEnabled(false)
  }

  /**
   * Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
   * закрепление. Ключ приходит из самой плитки (она знает свой трек из
   * контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
   *
   * Закрепление доступно из ЛЮБОГО режима показа (задача 3.1 → 3.3): в
   * плиточных режимах крупной плитки нет, поэтому само закрепление (не
   * открепление) просит `RoomPage` переключить вид на `standard` —
   * закреплённого иначе негде показать крупно.
   */
  function handleTogglePin(key: string) {
    const willPin = pinnedKey !== key
    setPinnedKey(willPin ? key : null)
    if (willPin) onPinFocus?.()
  }

  // Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
  // грида, одна плитка на весь контейнер (см. `.room-single-tile`,
  // `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
  // и по тем же правилам, что и в основном окне (закрепление, удержание
  // демонстрации, антидребезг говорящего) — переиспользуем как есть.
  // Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет,
  // закрепление делается в основном окне и приезжает сюда через
  // `initialPinnedKey`. А вот микрофон и демонстрация экрана в мини-окне есть:
  // это не «вид», а действия, которые нужны прямо посреди разговора, и ради них
  // разворачивать основное окно (то есть закрывать мини-окно) бессмысленно.
  //
  // СОБСТВЕННАЯ ДЕМОНСТРАЦИЯ в мини-окне ПОКАЗЫВАЕТСЯ — по тем же правилам
  // `pickStageFocus`, что и в основном окне (last-wins на старте, возврат на
  // говорящего/закреплённого после остановки). Решение оператора при приёмке
  // 0.0.29: демонстратор должен видеть, что именно он демонстрирует, ровно то
  // же, что видят остальные. Пробная версия прятала свой шэр из PiP (чтобы
  // вместо него была видна аудитория и чтобы при выборе «весь экран» не
  // получался зеркальный туннель «мини-окно внутри мини-окна») — отклонена.
  // Туннель при выборе «весь экран» остаётся известным и принятым поведением.
  if (variant === 'pip') {
    return (
      <section className="stage room-single-tile">
        {focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
        <div className="room-pip-controls">
          <PipMicToggle />
          <PipScreenShareToggle />
        </div>
      </section>
    )
  }

  // Демонстрация экрана перебивает выбранный режим (обоснование — в докстринге).
  const effectiveMode: StageLayoutMode = hasScreenShare ? 'standard' : layoutMode
  const liveCameraTracks = cameraTracks.filter(hasLiveVideo)

  // Кого показываем сбоку в карусели. В «плитках» — никого (все равноправны),
  // в «живых плитках» — тех, у кого камера выключена (а если камеры нет ни у
  // кого, карусель пуста: все ушли в сетку), в «стандарте» — всех, кроме
  // плитки в фокусе.
  const sideTracks: TrackReferenceOrPlaceholder[] =
    effectiveMode === 'tiles'
      ? []
      : effectiveMode === 'live-tiles'
        ? liveCameraTracks.length > 0
          ? cameraTracks.filter((t) => !hasLiveVideo(t))
          : []
        : carouselTracks
  const showCarousel = !hideOthers && sideTracks.length > 0
  // Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
  // сама кнопка вызывает переключение на `standard`, где закреплённый и
  // попадёт в фокус (см. `handleTogglePin`/`onPinFocus`). Бейдж поднятой
  // руки (задача B1) и кнопки принудительного мьюта (задача B2) едут тем же
  // спредом — тоже нужны на КАЖДОЙ плитке.
  const tileProps = { pinnedKey, onTogglePin: handleTogglePin, raisedHandIdentities, conferenceId }

  function renderMain(): ReactNode {
    if (effectiveMode === 'tiles') {
      return (
        <StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
          <RoomParticipantTile {...tileProps} />
        </StageGrid>
      )
    }
    if (effectiveMode === 'live-tiles') {
      return (
        <StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
          <RoomParticipantTile {...tileProps} />
        </StageGrid>
      )
    }
    // «Стандарт» с единственным участником (карусель пуста и скрывать нечего) —
    // прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
    if (sideTracks.length === 0 && !hideOthers) {
      return (
        <StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
          <RoomParticipantTile {...tileProps} />
        </StageGrid>
      )
    }
    // FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
    // (см. её исходник), поэтому вместо неё используем свою обёртку
    // напрямую с тем же trackRef (аватар в фокус-плитке).
    return (
      focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...tileProps} />
    )
  }

  return (
    <section className="stage">
      {showCarousel ? (
        <FocusLayoutContainer className="stage-tiles">
          {/* Обёртка колонки миниатюр: сама карусель не может быть прямым
              ребёнком грида, потому что над ней стоит кнопка «Скрыть». Из-за
              этого мобильный `.lk-carousel{order:1}` библиотеки перестаёт
              действовать — порядок переносим на `.stage-side` (см. room.css). */}
          <div className="stage-side">
            {/* Быстрый способ убрать карусель, не открывая меню «Вид». Только
                на широком экране: на мобильном колонка превращается в узкую
                полосу снизу, где кнопке не место, — там переключатель живёт в
                шторке настроек. Вернуть участников можно кнопкой на сцене. */}
            {!isCompact && onHideOthers && (
              <button type="button" className="stage-side-hide" onClick={onHideOthers}>
                <EyeOff className="lucide" aria-hidden="true" />
                <span>Скрыть</span>
              </button>
            )}
            <CarouselLayout tracks={sideTracks}>
              <RoomParticipantTile {...tileProps} />
            </CarouselLayout>
          </div>
          {renderMain()}
        </FocusLayoutContainer>
      ) : (
        // Карусели нет — основная область занимает сцену целиком.
        <div className="stage-tiles stage-main-only">{renderMain()}</div>
      )}
      {hideOthers && sideTracks.length > 0 && (
        <button type="button" className="stage-show-others" onClick={onShowOthers}>
          <Users className="lucide" aria-hidden="true" />
          <span>Показать остальных ({sideTracks.length})</span>
        </button>
      )}
    </section>
  )
}
