import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { PictureInPicture2 } from 'lucide-react'
import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react'
import { DisconnectReason, type RoomOptions } from 'livekit-client'
import '@livekit/components-styles'
import '@/styles/room.css'
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { useChat } from '@/hooks/useChat'
import { useFullscreen } from '@/hooks/useFullscreen'
import { useRoomPiP } from '@/hooks/useRoomPiP'
import { isDesktopDevice } from '@/lib/isDesktopDevice'
import { isVirtualBackgroundSupported } from '@/lib/virtualBackground'
import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { VirtualBackgroundController } from '@/components/room/VirtualBackgroundController'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'

/** Сколько мс держать топбар/тулбар видимыми в полноэкранном режиме без взаимодействия, прежде чем спрятать их снова. */
const FULLSCREEN_CONTROLS_AUTO_HIDE_MS = 4000

/** Полоса у нижнего края экрана (px) — наведение мыши в неё в полноэкранном режиме на десктопе показывает тулбар без клика. */
const FULLSCREEN_FOOTER_HOVER_ZONE_PX = 72

/**
 * Сколько раз подряд страница возвращается в конференцию сама после разрыва,
 * которого пользователь не просил. Ограничение — защита от бесконечного цикла
 * «вошёл → сразу выбросило»: например, если конференция кончилась ровно в
 * момент разрыва или сервер по какой-то причине не принимает участника.
 * Исчерпав бюджет, страница ведёт себя как раньше — уводит в лобби.
 */
const AUTO_REJOIN_LIMIT = 5

/**
 * Соединение, прожившее дольше этого времени, считается состоявшимся: бюджет
 * авто-перезаходов обнуляется. Без этого редкие разрывы за длинную встречу
 * (телефон засыпал пять раз за час) постепенно съели бы лимит и шестое
 * пробуждение выбросило бы участника в лобби.
 */
const AUTO_REJOIN_BUDGET_RESET_MS = 30_000

interface RoomJoinState {
  livekitUrl: string
  token: string
  /** Название конференции (`ConferenceOut.title`/`ResolveOut.title`) — для топбара, см. `RoomTopbar` и задачу A6. */
  title?: string | null
  conferenceId?: string
  /** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
  number?: string
  /** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
  chatEnabled?: boolean
  /** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся. */
  handQueueEnabled?: boolean
  /**
   * Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
   * `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
   * первого рендера `LiveKitRoom` (см. `roomOptions` ниже и докстринг про
   * стабильность его ссылки): `joinState` целиком появляется одним актом
   * (`setJoinState`), а до этого момента `LiveKitRoom` не рендерится вовсе
   * (ранний `return` на «Подключаемся…» ниже) — значит, оба значения уже
   * на руках к моменту публикации трека, без отдельного асинхронного
   * похода за настройками после подключения и без риска переподключения.
   */
  publishQualityCap?: PublishQualityCap
  stageMaxTiles?: number
  /** `JoinOut.virtual_background_enabled` — при `false` кнопки «Фон» нет вовсе. */
  virtualBackgroundEnabled?: boolean
  /**
   * Выбор пользователя на превью JoinPage (сессия 33, «проверка устройств на
   * входе») — войти с уже включённым микрофоном/камерой. Отсутствует (→
   * `false`) для авторизованного входа мимо гостевой карточки, при
   * выключенном модуле и при переподключении после F5 (эффект ниже) —
   * тогда действует прежнее поведение `audio={false} video={false}` (0.0.15).
   */
  enterWithVideo?: boolean
  enterWithAudio?: boolean
}

/**
 * Экран комнаты конференции (тёмная тема, `data-theme="room"`).
 *
 * Публичный маршрут: гость входит без JWT-аутентификации приложения — его
 * LiveKit-токен получен через `POST /conferences/{id}/guest-join` (см.
 * JoinPage) и передан сюда через navigation state, ровно как и токен
 * авторизованного пользователя. RoomPage сам никогда не проверяет сессию —
 * подключение к LiveKit целиком определяется переданными `token`/`livekitUrl`.
 *
 * `:slug` в адресе — тот же идентификатор (slug или номер), что пользователь
 * вводил на JoinPage; используется только для повторного резолва при прямой
 * перезагрузке страницы (join-state из state теряется при F5). Если состояния
 * нет — не пытаемся угадать креды сами: гостю это в принципе невозможно
 * (нет сохранённых имени/пароля), поэтому просто уводим на `/join?q=:slug`,
 * где резолв и вход повторяются штатным флоу.
 */
export function RoomPage() {
  const { slug } = useParams<{ slug: string }>()
  const location = useLocation()
  const navigate = useNavigate()
  const { status } = useAuth()

  const initialState = (location.state as RoomJoinState | null) ?? null
  const [joinState, setJoinState] = useState<RoomJoinState | null>(initialState)
  const [error, setError] = useState<string | null>(null)

  // Пользователь нажал «Выйти» — ЕДИНСТВЕННЫЙ признак, по которому уход со
  // страницы считается намеренным. Отдельный флаг нужен потому, что причина
  // `CLIENT_INITIATED` приходит не только от кнопки: livekit-client вешает
  // обработчик на событие `freeze` (Page Lifecycle) и при заморозке вкладки
  // сам зовёт `room.disconnect()` — с той же самой причиной. Android Chrome
  // фоновую вкладку замораживает, так что без этого флага заснувший телефон
  // выглядел бы ровно как «пользователь вышел» и уезжал бы в лобби.
  const leaveRequestedRef = useRef(false)
  // Когда установилось текущее соединение — чтобы отличить «разрыв после
  // нормальной работы» от «выбрасывает сразу после входа» (см. AUTO_REJOIN_*).
  const connectedAtRef = useRef(0)
  const autoRejoinCountRef = useRef(0)

  const handleLeaveClick = useCallback(() => {
    leaveRequestedRef.current = true
  }, [])

  const handleConnected = useCallback(() => {
    connectedAtRef.current = Date.now()
    // Мы снова в комнате — намерение выйти, если оно откуда-то осталось
    // невыполненным, к этому соединению уже не относится.
    leaveRequestedRef.current = false
  }, [])

  // useCallback: стабильная ссылка на обработчик — иначе на каждом рендере
  // RoomPage пересобирался бы объект-пропс `onDisconnected`, а с ним и
  // внутренний useEffect @livekit/components-react (он в списке зависимостей
  // хука useLiveKitRoom), лишний раз пересоздавая подписки на события Room.
  //
  // Разрыв ≠ выход. До 0.0.27 любое отключение уводило со страницы, и на
  // мобильном это ломало обычный сценарий: экран телефона гаснет, Chrome
  // срезает фоновой вкладке ресурсы, ICE перестаёт отвечать — и LiveKit
  // закрывает участника через 5 с после потери соединения
  // (`PEER_CONNECTION_DISCONNECTED`, замерено на проде: 37 с после блокировки
  // экрана, 23 с после сворачивания браузера). Восстановить сессию после
  // этого нельзя — участника на сервере уже нет (`could not restart
  // participant`), нужен полный повторный вход. livekit-client такой вход
  // пытается сделать сам, но его бюджет повторов (10 попыток, ~44 с) в
  // фоновой вкладке успевает сгореть — и тогда приходит `Disconnected`,
  // после которого пользователь оказывался в лобби вместо конференции.
  //
  // Теперь непреднамеренный разрыв просто сбрасывает `joinState`, а дальше
  // работает уже написанный путь авто-перезахода (эффект ниже): страница
  // молча резолвит конференцию, берёт свежий токен и входит заново. Никакого
  // второго механизма восстановления не появляется.
  const handleDisconnected = useCallback(
    (reason?: DisconnectReason) => {
      const leaveForGood = () => navigate(status === 'authenticated' ? '/lobby' : '/join')

      if (leaveRequestedRef.current) {
        leaveForGood()
        return
      }

      // Разрывы, после которых возвращаться некуда или нельзя: участника
      // выгнал организатор, конференцию закрыли, либо этой же личностью вошли
      // с другого устройства (перезаход устроил бы пинг-понг между ними).
      if (
        reason === DisconnectReason.PARTICIPANT_REMOVED ||
        reason === DisconnectReason.ROOM_DELETED ||
        reason === DisconnectReason.ROOM_CLOSED ||
        reason === DisconnectReason.DUPLICATE_IDENTITY
      ) {
        leaveForGood()
        return
      }

      const sessionMs = connectedAtRef.current ? Date.now() - connectedAtRef.current : 0
      if (sessionMs > AUTO_REJOIN_BUDGET_RESET_MS) {
        autoRejoinCountRef.current = 0
      }
      if (autoRejoinCountRef.current >= AUTO_REJOIN_LIMIT) {
        leaveForGood()
        return
      }

      autoRejoinCountRef.current += 1
      connectedAtRef.current = 0
      setJoinState(null)
    },
    [navigate, status],
  )

  useEffect(() => {
    if (joinState || !slug) return
    // Сессия ещё восстанавливается (silent-refresh) — подождём, чтобы не
    // отправить авторизованного пользователя в гостевой флоу по ошибке.
    if (status === 'loading') return

    if (status !== 'authenticated') {
      navigate(`/join?q=${encodeURIComponent(slug)}`, { replace: true })
      return
    }

    // Авторизованный пользователь перезагрузил страницу комнаты — пробуем
    // молча переподключиться тем же slug/номером, что был в адресе.
    let cancelled = false

    async function reconnect() {
      try {
        const info = await resolveConference(slug!)
        if (info.requires_password) {
          if (!cancelled) navigate(`/join?q=${encodeURIComponent(slug!)}`, { replace: true })
          return
        }
        const result = await joinConference(info.id)
        if (!cancelled) {
          setJoinState({
            livekitUrl: result.livekit_url,
            token: result.token,
            title: info.title,
            conferenceId: result.conference_id,
            chatEnabled: result.chat_enabled,
            handQueueEnabled: result.hand_queue_enabled,
            publishQualityCap: result.publish_quality_cap,
            stageMaxTiles: result.stage_max_tiles,
            virtualBackgroundEnabled: result.virtual_background_enabled,
          })
        }
      } catch (err) {
        if (cancelled) return
        if (err instanceof ApiError && (err.status === 404 || err.status === 410)) {
          setError('Конференция недоступна или уже завершилась')
        } else if (err instanceof ApiError && err.status === 403 && errorDetail(err) === 'password_required') {
          navigate(`/join?q=${encodeURIComponent(slug!)}`, { replace: true })
        } else {
          setError('Не удалось подключиться к конференции. Попробуйте войти ещё раз')
        }
      }
    }

    void reconnect()
    return () => {
      cancelled = true
    }
  }, [joinState, slug, status, navigate])

  // WS-соединение чата живёт независимо от того, открыта ли панель, — иначе
  // при сворачивании панели терялась бы история и обрывался счётчик
  // непрочитанных. Хук вызывается безусловно (до ранних return ниже) —
  // conferenceId/token до входа в комнату ещё не известны, enabled это учитывает.
  const chat = useChat({
    conferenceId: joinState?.conferenceId,
    token: joinState?.token,
    enabled: Boolean(joinState?.chatEnabled),
  })
  const [chatOpen, setChatOpen] = useState(false)
  // Сколько сообщений уже «видели» (на момент последнего открытия панели) —
  // считается прямо в теле рендера, под охраной сравнения («adjusting state
  // during rendering», тот же санкционированный паттерн, что и focus-логика
  // в RoomStage.tsx); useEffect тут не подходит — react-hooks/set-state-in-effect,
  // а ref нельзя ни читать, ни писать во время рендера — react-hooks/refs.
  const [chatSeenCount, setChatSeenCount] = useState(0)
  if (chatOpen && chatSeenCount !== chat.messages.length) {
    setChatSeenCount(chat.messages.length)
  }
  // Счётчик непрочитанных — сообщения, накопленные с момента последнего
  // открытия панели (включая историю при первом подключении, если панель
  // открывается впервые уже после её загрузки); при закрытой панели счётчик
  // сохраняется (см. design/mockups/room.html, .collapsed-demo).
  const chatUnreadCount = Math.max(0, chat.messages.length - chatSeenCount)

  // Чат выключен на сервере (close-код 4404) — панель нужно скрыть, даже если
  // изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
  const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable

  // Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
  // на каждой плитке сцены (см. `RoomParticipantTile`). Выключенный модуль
  // (`handQueueEnabled=false`) гасит и эти значки — иначе на плитке мог бы
  // повиснуть индикатор от руки, поднятой до отключения модуля (сервер
  // хранит очередь в Redis независимо от тоггла, см. `services/hand_queue.py`).
  const raisedHandIdentities = useMemo(
    () =>
      joinState?.handQueueEnabled
        ? new Set(chat.handQueue.map((entry) => entry.identity))
        : new Set<string>(),
    [chat.handQueue, joinState?.handQueueEnabled],
  )

  // Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
  // для video-PiP-фолбэка.
  const roomRootRef = useRef<HTMLDivElement>(null)
  const fullscreen = useFullscreen(roomRootRef)
  const pip = useRoomPiP(roomRootRef)
  const [settingsOpen, setSettingsOpen] = useState(false)
  const [backgroundOpen, setBackgroundOpen] = useState(false)

  // Может ли этот участник вообще пользоваться заменой фона. Считается ОДИН
  // раз при монтировании: и тип устройства, и поддержка конвейера обработки
  // кадров браузером в пределах вкладки не меняются.
  //
  // ⚠️ Про десктоп — именно `isDesktopDevice` (возможности устройства), а НЕ
  // `useIsCompactViewport` (ширина окна): узкое окно на десктопе — всё ещё
  // десктоп, а широкий планшет — всё ещё планшет, который сегментация греет.
  // Подробности — докстринг `lib/isDesktopDevice.ts`.
  const [backgroundCapable] = useState(
    () => isDesktopDevice() && isVirtualBackgroundSupported(),
  )
  const backgroundVisible = backgroundCapable && Boolean(joinState?.virtualBackgroundEnabled)

  // Топбар и тулбар в полноэкранном режиме — оверлей поверх сцены (см.
  // `.room-fullscreen-overlay` в room.css), а не часть потока: показываются
  // по тапу/клику по сцене вне элементов управления и прячутся сами через
  // FULLSCREEN_CONTROLS_AUTO_HIDE_MS бездействия. На десктопе есть ещё второй
  // способ показать их — навести мышь в нижнюю полосу экрана (без клика),
  // как в большинстве видеоплееров; клик остаётся основным способом на
  // тач-устройствах, где наведения не бывает. Обычный (не полноэкранный)
  // режим этот стейт не использует вовсе.
  const [fullscreenControlsVisible, setFullscreenControlsVisible] = useState(true)
  // Счётчик «попроси показать и отсчитать заново» — растёт на каждый клик/
  // наведение, даже если панель УЖЕ видима (иначе непрерывное наведение не
  // продлевало бы таймер: setState(true) поверх уже true не меняет состояние
  // и не перезапускает эффект ниже).
  const [fullscreenControlsTick, setFullscreenControlsTick] = useState(0)

  // Показ при входе в полноэкранный режим — «подгонка состояния во время
  // рендера» (см. тот же приём у `chatSeenCount` выше), а не setState в теле
  // эффекта (react-hooks/set-state-in-effect): само планирование таймера
  // авто-скрытия — ниже, отдельным эффектом, и не вызывает setState
  // синхронно в своём теле.
  const [prevFullscreenActive, setPrevFullscreenActive] = useState(fullscreen.active)
  if (fullscreen.active !== prevFullscreenActive) {
    setPrevFullscreenActive(fullscreen.active)
    if (fullscreen.active) {
      setFullscreenControlsVisible(true)
      setFullscreenControlsTick((tick) => tick + 1)
    }
  }

  useEffect(() => {
    if (!fullscreen.active || !fullscreenControlsVisible) return
    const timer = setTimeout(() => setFullscreenControlsVisible(false), FULLSCREEN_CONTROLS_AUTO_HIDE_MS)
    return () => clearTimeout(timer)
  }, [fullscreen.active, fullscreenControlsVisible, fullscreenControlsTick])

  // Наведение мыши в нижнюю полосу экрана — только десктопный способ показать
  // элементы управления без клика; на тач-устройствах `mousemove` в таком виде
  // не приходит, слушатель им не вредит и не мешает.
  useEffect(() => {
    if (!fullscreen.active) return
    function handleMouseMove(e: MouseEvent) {
      if (window.innerHeight - e.clientY <= FULLSCREEN_FOOTER_HOVER_ZONE_PX) {
        setFullscreenControlsVisible(true)
        setFullscreenControlsTick((tick) => tick + 1)
      }
    }
    window.addEventListener('mousemove', handleMouseMove)
    return () => window.removeEventListener('mousemove', handleMouseMove)
  }, [fullscreen.active])

  // Клик/тап по сцене переключает видимость — но не по элементам управления
  // внутри неё (кнопки плиток, булавка закрепления, «Показать остальных» и
  // т.п.: `closest` поднимается от места клика и гасит переключение, если
  // по дороге встретился интерактивный элемент) и не по открытой панели чата
  // (`.chat-panel` — та рендерится внутри того же `.room-main`, но клики по
  // тексту сообщений не должны прятать/показывать тулбар).
  const handleStageAreaClick = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      if (!fullscreen.active) return
      const target = e.target as HTMLElement
      if (target.closest('button, a, input, select, textarea, [role="dialog"], .chat-panel')) return
      setFullscreenControlsVisible((visible) => !visible)
      setFullscreenControlsTick((tick) => tick + 1)
    },
    [fullscreen.active],
  )

  // Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в
  // шторке настроек, а сцена их только читает (общий предок).
  //
  // Режим показа персистится между заходами в комнату (`lib/stageLayoutMode.ts`,
  // localStorage) — это устойчивое предпочтение: кто любит «плитки», хочет их
  // и завтра. А вот «скрыть остальных» намеренно НЕ сохраняется: это разовое
  // действие по ходу разговора, и войти в новую конференцию сразу без половины
  // участников — сюрприз, а не удобство. Сбрасывается вместе с уходом со
  // страницы комнаты.
  const [layoutMode, setLayoutMode] = useState<StageLayoutMode>(loadStageLayoutMode)
  const [hideOthers, setHideOthers] = useState(false)

  const handleLayoutModeChange = useCallback((mode: StageLayoutMode) => {
    setLayoutMode(mode)
    saveStageLayoutMode(mode)
  }, [])

  // Закрепление участника из плиточного режима переключает вид на «Стандарт»
  // (см. докстринг `RoomStage`, `onPinFocus`) — но это НЕ то же самое, что
  // явный выбор пользователя: переключение не сохраняется в localStorage
  // (в отличие от `handleLayoutModeChange`), иначе разовое закрепление в этой
  // встрече поменяло бы устойчивое предпочтение режима для всех следующих.
  const handlePinFocus = useCallback(() => {
    setLayoutMode('standard')
  }, [])

  // Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
  // внутри `RoomStage`, потому что при открытии мини-плеера сцена
  // размонтируется в основном окне и монтируется заново в PiP-окне (портал
  // ниже) — без этого мостика новый экземпляр начинал бы выбор фокуса с нуля и
  // открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`.
  const [stageFocusKey, setStageFocusKey] = useState<string | null>(null)

  // Закрепление участника (булавка на плитке) — по той же причине и тем же
  // мостиком, что и `stageFocusKey`: экземпляр `RoomStage` при открытии
  // мини-плеера пересоздаётся, и до 0.0.25 закрепление, сделанное в основном
  // окне, в мини-окно не попадало вовсе (там был свой чистый `useState`).
  const [stagePinnedKey, setStagePinnedKey] = useState<string | null>(null)

  // Сохранённый выбор устройств — читаем через собственный вызов
  // usePersistentUserChoices (независимый от того, что использует
  // DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
  // ссылка на `roomOptions` должна быть стабильной между рендерами — иначе
  // LiveKitRoom получает «новый» объект `options` и переподключается
  // Это безопасно мемоизировать по `userChoices`,
  // потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
  // вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
  // не делаем (сохранение — только в DeviceSettingsDialog).
  //
  // `joinState?.publishQualityCap` в зависимостях безопасен по той же
  // причине: `joinState` выставляется РОВНО ОДИН раз (см. докстринг
  // `RoomJoinState.publishQualityCap`) до первого рендера `LiveKitRoom`, а
  // не меняется постфактум — значит, `roomOptions` не пересоздастся у уже
  // подключённого участника.
  const { userChoices } = usePersistentUserChoices()
  const roomOptions = useMemo<RoomOptions>(
    () => ({
      publishDefaults: buildPublishDefaults(joinState?.publishQualityCap ?? 'off'),
      // Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
      // подписан на полное качество всех чужих треков независимо от размера
      // плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
      // не смотрит. На тесте 28.07.2026 (19 участников, ~8 камер) это дало
      // устойчивые 140–169 Мбит/с исходящего трафика при пике 240, 662 события
      // `remote bwe: channel congestion detected` и 146 переходов аллокатора
      // STABLE → DEFICIENT — то есть видимый участникам лаг.
      //
      // adaptiveStream: подписка на слой по фактическому размеру плитки на
      // экране + пауза треков, которые сейчас не отрисованы. Именно на нём
      // начинает экономить уже написанный код: «скрыть остальных»
      // (RoomStage) не рендерит карусель, а пагинация StageGrid рендерит
      // только текущую страницу — неприаттаченные треки считаются невидимыми.
      // dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков.
      adaptiveStream: true,
      dynacast: true,
      // `usePersistentUserChoices` хранит «устройство не выбрано» как литерал
      // `"default"` (`@livekit/components-core`, `defaultUserChoices`), а не
      // пустую строку — `|| undefined` его не отсекает, и в Chrome это давало
      // `OverconstrainedError` на первом визите (см. тот же сентинел в
      // `useDeviceCheckAccess.ts::openStream`).
      audioCaptureDefaults: {
        deviceId:
          userChoices.audioDeviceId && userChoices.audioDeviceId !== 'default'
            ? userChoices.audioDeviceId
            : undefined,
      },
      videoCaptureDefaults: {
        deviceId:
          userChoices.videoDeviceId && userChoices.videoDeviceId !== 'default'
            ? userChoices.videoDeviceId
            : undefined,
      },
      // Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
      // usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
      // (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
      // как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog
      // (setActiveMediaDevice), а не пересозданием roomOptions.
      audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
    }),
    [userChoices, joinState?.publishQualityCap],
  )

  if (error) {
    return (
      <div data-theme="room" className="room-error">
        <p>{error}</p>
        <button type="button" className="btn btn-primary" onClick={() => navigate('/lobby')}>
          Вернуться в лобби
        </button>
      </div>
    )
  }

  if (!joinState) {
    return (
      <div data-theme="room" className="room-loader" role="status" aria-live="polite">
        Подключаемся к конференции…
      </div>
    )
  }

  return (
    <div data-theme="room" ref={roomRootRef} className={fullscreen.active ? 'room-fullscreen-overlay' : undefined}>
      <LiveKitRoom
        serverUrl={joinState.livekitUrl}
        token={joinState.token}
        connect
        audio={Boolean(joinState.enterWithAudio)}
        video={Boolean(joinState.enterWithVideo)}
        options={roomOptions}
        onConnected={handleConnected}
        onDisconnected={handleDisconnected}
      >
        {/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно
            одним экземпляром на всю страницу. `RoomAudioRenderer` — это набор
            скрытых `<audio>`, к которым LiveKit привязывает чужие аудиотреки
            (`track.attach(el)`). Пока он жил в сцене, открытие мини-плеера
            переносило эти элементы в ДРУГОЙ документ (Document PiP — отдельное
            окно со своим `document`), а возврат — обратно, и после возврата
            звук чужих участников пропадал: Chrome теряет аудиовыход у
            remote-трека, переехавшего между документами. Замерено: пакеты
            продолжают приходить (`packetsReceived` растёт), а
            `totalSamplesDuration`/`totalAudioEnergy` замирают, и трек молчит
            даже в свежесозданном `<audio>` со свежим `MediaStream`. Тот же
            цикл detach/attach В ПРЕДЕЛАХ ОДНОГО документа безвреден — дело
            именно в переезде. Здесь элементы живут в основном документе
            непрерывно, весь цикл «открыл мини-окно → вернул» их не касается. */}
        <RoomAudioRenderer />
        <div data-lk-theme="default" className="room-shell">
          <RoomTopbar
            title={joinState.title ?? null}
            slug={slug}
            number={joinState.number}
            overlayVisible={fullscreenControlsVisible}
          />
          <div className="room-main" onClick={handleStageAreaClick}>
            {pip.mode === 'document' ? (
              // Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
              // основное окно вместо неё показывает заглушку с возвратом.
              <div className="room-pip-placeholder">
                <PictureInPicture2 aria-hidden="true" />
                <p>Конференция открыта в отдельном мини-окне</p>
                <button type="button" className="btn btn-secondary" onClick={pip.toggle}>
                  Вернуть в это окно
                </button>
              </div>
            ) : (
              <RoomStage
                layoutMode={layoutMode}
                hideOthers={hideOthers}
                onShowOthers={() => setHideOthers(false)}
                onHideOthers={() => setHideOthers(true)}
                initialFocusKey={stageFocusKey}
                onFocusKeyChange={setStageFocusKey}
                initialPinnedKey={stagePinnedKey}
                onPinnedKeyChange={setStagePinnedKey}
                onPinFocus={handlePinFocus}
                raisedHandIdentities={raisedHandIdentities}
                conferenceId={joinState.conferenceId}
                stageMaxTiles={joinState.stageMaxTiles}
              />
            )}
            {chatVisible && chatOpen && (
              <ChatPanel
                messages={chat.messages}
                status={chat.status}
                statusMessage={chat.statusMessage}
                onSend={chat.sendMessage}
                onClose={() => setChatOpen(false)}
              />
            )}
          </div>
          <RoomToolbar
            chatVisible={chatVisible}
            chatOpen={chatOpen}
            chatUnreadCount={chatUnreadCount}
            onToggleChat={() => setChatOpen((open) => !open)}
            onToggleSettings={() => setSettingsOpen(true)}
            fullscreenSupported={fullscreen.supported}
            fullscreenActive={fullscreen.active}
            onToggleFullscreen={fullscreen.toggle}
            pipSupported={pip.supported}
            pipActive={pip.active}
            onTogglePiP={pip.toggle}
            handQueueEnabled={Boolean(joinState.handQueueEnabled)}
            backgroundVisible={backgroundVisible}
            backgroundOpen={backgroundOpen}
            onToggleBackground={() => setBackgroundOpen((open) => !open)}
            handQueue={chat.handQueue}
            onRaiseHand={chat.raiseHand}
            onLowerHand={() => chat.lowerHand()}
            onLowerHandById={(identity) => chat.lowerHand(identity)}
            layoutMode={layoutMode}
            onLayoutModeChange={handleLayoutModeChange}
            hideOthers={hideOthers}
            onHideOthersChange={setHideOthers}
            overlayVisible={fullscreenControlsVisible}
            onLeave={handleLeaveClick}
          />
        </div>
        {/* Смонтирован всё время, пока модуль включён, — фон живёт независимо
            от того, открыто ли окно выбора (см. докстринг компонента). */}
        {backgroundVisible && (
          <VirtualBackgroundController
            open={backgroundOpen}
            onClose={() => setBackgroundOpen(false)}
            canManageOwn={status === 'authenticated'}
          />
        )}
        {settingsOpen && (
          <DeviceSettingsDialog
            onClose={() => setSettingsOpen(false)}
            layoutMode={layoutMode}
            onLayoutModeChange={handleLayoutModeChange}
            hideOthers={hideOthers}
            onHideOthersChange={setHideOthers}
          />
        )}
        {/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
            рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
            `variant="pip"` — мини-плеер
            показывает только активное окно (одну плитку), без карусели/грида
            основного окна. `initialFocusKey` — то, что было крупно в основном
            окне: без него мини-окно открывалось на самом пользователе;
            `initialPinnedKey` — закрепление оттуда же. */}
        {pip.pipWindow &&
          createPortal(
            <RoomStage
              variant="pip"
              initialFocusKey={stageFocusKey}
              onFocusKeyChange={setStageFocusKey}
              initialPinnedKey={stagePinnedKey}
              onPinnedKeyChange={setStagePinnedKey}
            />,
            pip.pipWindow.document.body,
          )}
        <ForcedMuteWatcher event={chat.lastForcedMute} />
      </LiveKitRoom>
    </div>
  )
}
