import type { ConversationSummary } from '@chat-app/shared/chat';
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useAuth } from '../context/AuthContext';
import { type CallMode, useCall } from '../context/CallContext';
import {
  getPttSettings,
  type PttSettings,
  subscribePttSettings,
} from '../lib/pttSettings';
import {
  listSounds,
  subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import {
  getLiveCaptionsSettings,
  isLiveCaptionsSupported,
  subscribeLiveCaptionsSettings,
  updateLiveCaptionsSettings,
} from '../lib/liveCaptions';
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay';
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel';
import { UserProfilePopover } from './UserProfilePopover';

// Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
// The fullscreen variant absolute-positions itself over the conversation
// container so the rail+chat-list remain visible on the left.
interface Props {
  conversation: ConversationSummary;
}

interface Tile {
  // `user` = person with avatar/video/mute info. Speaking ring applies here.
  // `screen` = a separate screen-share window from a user. No speaking
  //           indicator, no mute, no avatar — just the stream.
  kind: 'user' | 'screen';
  // Stable id used for focus tracking + React keys. `user:${userId}` or
  // `screen:${userId}`.
  id: string;
  userId: string;
  displayName: string;
  avatarUrl: string | null;
  self: boolean;
  muted: boolean;
  deafened: boolean;
  video: boolean;
  videoTrack: MediaStreamTrack | null;
  // True iff this is the current user's own screen-share tile.
  sharing: boolean;
  // True iff this is a remote user's screen-share tile.
  remoteSharing: boolean;
  // LiveKit-measured connection quality for this participant. Drives the
  // Discord-style Wifi badge on user-tiles. Undefined for screen-tiles.
  connectionQuality?: TileConnectionQuality;
  // Discord-style host marker — only true for the call initiator's tile in
  // group calls. Drives the crown.
  isHost: boolean;
  // True iff the user pinned this tile via right-click → "Anpinnen".
  // Mirrors the value of CallContext.focusedId for this tile id.
  pinned: boolean;
  // True iff this user-tile's owner is currently publishing a screen share.
  // Drives the LIVE pill on the avatar tile so the share-tile next to it
  // visually belongs to the same person. Always false on screen-kind tiles.
  streaming: boolean;
}

export function InCallPanel({ conversation }: Props) {
  const { t } = useTranslation(['app']);
  const {
    state,
    room,
    remoteParticipants,
    isMuted,
    isE2EEActive,
    isScreenSharing,
    isCameraEnabled,
    isDeafened,
    remoteDeafen,
    remoteMute,
    remoteScreenShares,
    callMode,
    focusedId,
    toggleMute,
    stopScreenShare,
    toggleCamera,
    toggleDeafen,
    hangup,
    setCallMode,
    setFocusedId,
    micError,
    clearMicError,
    retryMic,
    dismissedShareUserIds,
    connectionQualities,
    callHostId,
  } = useCall();
  const { session } = useAuth();
  const myId = session?.user.id ?? null;
  const activeSpeakers = useActiveSpeakers(room);
  const [soundboardOpen, setSoundboardOpen] = useState(false);
  const [participantsOpen, setParticipantsOpen] = useState(false);
  // Discord-style screen-share configure modal. Opens when the user clicks
  // the Share button while not yet sharing — collects FPS/resolution/audio
  // before triggering the OS source picker.
  const [sharePickerOpen, setSharePickerOpen] = useState(false);
  // Discord-style debug stats overlay (Ctrl+Shift+S toggles).
  const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
  // Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
  // controls bar can show an "active" state without polling. Captions
  // broadcasting is wired in CallContext via useLiveCaptions; this only
  // tracks the toggle state for the button.
  const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
    () => getLiveCaptionsSettings().enabled,
  );
  useEffect(
    () => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
    [],
  );
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      // Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
      if (
        (e.ctrlKey || e.metaKey) &&
        e.shiftKey &&
        !e.altKey &&
        e.code === 'KeyS'
      ) {
        e.preventDefault();
        setStatsOverlayOpen((v) => !v);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);
  const [volumeMenu, setVolumeMenu] = useState<
    {
      userId: string;
      displayName: string;
      x: number;
      y: number;
      tileId: string;
      self: boolean;
    } | null
  >(null);
  const [shareMenu, setShareMenu] = useState<
    { userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
  >(null);
  // Discord-style profile popover triggered from the right-click context menu
  // on a participant tile. Anchored at the same coords as the volume menu.
  const [profileMenu, setProfileMenu] = useState<
    { userId: string; x: number; y: number } | null
  >(null);
  // Soundboard-count so the in-call bar only surfaces the music button when
  // the user actually has something to play. Matches Discord's "hide soundboard
  // when empty" behaviour — no point dangling a button that opens to a blank
  // "Keine Sounds" popover. Subscribes live so a sound added mid-call makes
  // the button pop in without reopening the call.
  const [soundboardCount, setSoundboardCount] = useState<number | null>(null);
  useEffect(() => {
    let cancelled = false;
    const refresh = async () => {
      try {
        const all = await listSounds();
        if (!cancelled) setSoundboardCount(all.length);
      } catch {
        if (!cancelled) setSoundboardCount(0);
      }
    };
    void refresh();
    const unsub = subscribeSoundboardChanges(() => {
      void refresh();
    });
    return () => {
      cancelled = true;
      unsub();
    };
  }, []);
  // Close the popover if the user just cleared their last sound while it was
  // open — keeps the panel from lingering over an empty list.
  useEffect(() => {
    if (soundboardCount === 0) setSoundboardOpen(false);
  }, [soundboardCount]);

  // Single right-click dispatcher for all tiles. User-tiles open the volume
  // menu; screen-tiles open the share-specific menu (volume + mute + stop
  // watching). Self-tiles get no menu — no volume to control, and you can
  // stop your own share from the control bar.
  const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => {
    e.preventDefault();
    if (tile.kind === 'user') {
      // Self-tile gets the pin row only — there's no remote volume to control.
      setShareMenu(null);
      setVolumeMenu({
        userId: tile.userId,
        displayName: tile.displayName,
        x: e.clientX,
        y: e.clientY,
        tileId: tile.id,
        self: tile.self,
      });
      return;
    }
    // Self screen-tiles: same — pin row is still useful, share menu is not.
    if (tile.self) return;
    // Screen-tile. Check whether the participant has a published screen-share
    // audio track so the menu can hide the volume/mute rows when there's
    // nothing to control.
    const participant = remoteParticipants.find((p) => p.identity === tile.userId);
    let hasAudio = false;
    if (participant) {
      for (const pub of participant.audioTrackPublications.values()) {
        if (pub.source === Track.Source.ScreenShareAudio) {
          hasAudio = true;
          break;
        }
      }
    }
    setVolumeMenu(null);
    setShareMenu({
      userId: tile.userId,
      displayName: tile.displayName.replace(/\s·\sBildschirm$/, ''),
      hasAudio,
      x: e.clientX,
      y: e.clientY,
    });
  };

  const active =
    (state.kind === 'connected' ||
      state.kind === 'connecting' ||
      state.kind === 'reconnecting' ||
      state.kind === 'outgoing') &&
    state.conversationId === conversation.id;
  if (!active) return null;

  const tiles = buildTiles({
    conversation,
    myId,
    room,
    remoteParticipants,
    isMuted,
    isDeafened,
    remoteDeafen,
    remoteMute,
    isScreenSharing,
    isCameraEnabled,
    // Sharer ids that survived the user's dismiss-set. If the user did
    // "Zuschauen beenden" on someone's share, they drop out of the tile
    // grid until that sharer stops + restarts (TrackUnsubscribed clears
    // dismissedShareUserIds — see CallContext).
    remoteSharerIds: new Set(
      remoteScreenShares
        .map((s) => s.participantId)
        .filter((id) => !dismissedShareUserIds.has(id)),
    ),
    connectionQualities,
    // Discord-style host crown — only show in group calls (>2 members).
    // 1:1s have no concept of "host" so the crown stays hidden.
    hostUserId: conversation.members.length > 2 ? callHostId : null,
    // Pin marker — focusedId === tile.id means the user explicitly pinned.
    pinnedTileId: focusedId,
  });

  // Duration keeps ticking during reconnecting so the user sees the call is
  // still alive — but the status label below takes precedence in the header
  // so the "Verbinde neu…" message is prominent, not buried under the timer.
  const duration =
    state.kind === 'connected'
      ? <LiveDuration startedAt={state.startedAt} />
      : null;

  const statusLabel =
    state.kind === 'outgoing'
      ? t('app:call.outgoing_ringing')
      : state.kind === 'connecting'
        ? t('app:call.connecting')
        : state.kind === 'reconnecting'
          ? t('app:call.reconnecting', { defaultValue: 'Verbinde neu…' })
          : remoteParticipants.length === 0
            ? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
            : t('app:call.connected');

  // Discord-style precedence:
  //   1. focusedId set         → 'focus', that tile is the stage.
  //   2. ≥2 shares, no pin     → 'bento', shares fill the stage, webcams strip.
  //   3. exactly 1 share, no pin → 'focus' (auto-promote share).
  //   4. no shares, no pin     → 'equal-grid'.
  type StageLayout =
    | { kind: 'equal-grid' }
    | { kind: 'focus'; bigTileId: string }
    | { kind: 'bento'; shareIds: string[] };

  const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
  const stageLayout: StageLayout = (() => {
    if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
      return { kind: 'focus', bigTileId: focusedId };
    }
    if (shareIds.length >= 2) return { kind: 'bento', shareIds };
    if (shareIds.length === 1 && shareIds[0]) {
      return { kind: 'focus', bigTileId: shareIds[0] };
    }
    return { kind: 'equal-grid' };
  })();

  // Tile that owns the big stage when layout is 'focus'. Resolved lazily by
  // callers below — kept here just so the speaker prop on CallStage/Fullscreen
  // stays consistent with the layout decision.
  const bigTile =
    stageLayout.kind === 'focus'
      ? tiles.find((t) => t.id === stageLayout.bigTileId)
      : undefined;

  const controls = (
    <CallControls
      muted={isMuted || !(room?.localParticipant?.isMicrophoneEnabled ?? false)}
      sharing={isScreenSharing}
      video={isCameraEnabled}
      deafened={isDeafened}
      onToggleMute={toggleMute}
      // Click opens the Discord-style source picker (thumbnails + quality +
      // audio). Clicking again while a share is live stops it. Right-click
      // also opens the picker in case the user wants to swap sources.
      onToggleShare={() => {
        if (isScreenSharing) {
          void stopScreenShare();
        } else {
          // Discord-parity: open the configure-then-share modal instead of
          // jumping straight into the OS picker. Modal calls
          // startScreenShare with the chosen overrides on confirm.
          setSharePickerOpen(true);
        }
      }}
      onShareContextMenu={(e) => {
        e.preventDefault();
        if (!isScreenSharing) setSharePickerOpen(true);
      }}
      onToggleVideo={() => void toggleCamera()}
      onToggleDeafen={toggleDeafen}
      onOpenParticipants={() => setParticipantsOpen((v) => !v)}
      participantsOpen={participantsOpen}
      // Soundboard-Button nur wenn mind. ein Sound existiert. Bis der Count
      // aus IndexedDB geladen ist (null), auch nicht rendern — verhindert
      // einen Flash des Buttons beim Call-Start wenn der User eh keine
      // Sounds hat.
      {...(soundboardCount && soundboardCount > 0
        ? {
            onToggleSoundboard: () => setSoundboardOpen((v) => !v),
            soundboardOpen,
          }
        : {})}
      // Live-Captions only when SpeechRecognition is available in the
      // runtime — Firefox lacks it, would just show a dead button.
      {...(isLiveCaptionsSupported()
        ? {
            onToggleCaptions: () =>
              updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
            captionsOn: captionsEnabled,
          }
        : {})}
      onHangup={() => void hangup()}
      compact={callMode !== 'fullscreen'}
      glass={callMode === 'fullscreen'}
      disabledMedia={state.kind !== 'connected' && state.kind !== 'reconnecting'}
    />
  );

  // Only participant-tiles feed the popover (screen-share tiles aren't
  // people). Own row is always first, rest follows conversation order.
  const participantRows: ParticipantRow[] = tiles
    .filter((t) => t.kind === 'user')
    .map((t) => ({
      userId: t.userId,
      displayName: t.displayName,
      avatarUrl: t.avatarUrl,
      self: t.self,
      muted: t.muted,
      deafened: t.deafened,
    }));

  if (callMode === 'fullscreen') {
    return (
      <>
        {micError && (
          <div className="pointer-events-none fixed inset-x-0 top-5 z-[65] flex justify-center px-4">
            <div className="pointer-events-auto max-w-[520px] w-full">
              <MicErrorBanner
                message={micError}
                onRetry={() => void retryMic()}
                onDismiss={clearMicError}
              />
            </div>
          </div>
        )}
        <FullscreenCall
          tiles={tiles}
          speaker={bigTile}
          remoteScreenShares={remoteScreenShares}
          conversationMembers={conversation.members}
          activeSpeakers={activeSpeakers}
          e2ee={isE2EEActive}
          onExit={() => setCallMode('grid')}
          onFocusTile={(id) => {
            // Toggle: click the already-focused tile to return to grid.
            setFocusedId(focusedId === id ? null : id);
          }}
          onTileContextMenu={openTileContextMenu}
          controls={controls}
          // Any active popover / menu / banner pins the controls so the user
          // can interact with them without the chrome fading out under their
          // cursor while they're mid-action.
          keepControlsVisible={
            soundboardOpen ||
            participantsOpen ||
            volumeMenu !== null ||
            shareMenu !== null ||
            micError !== null
          }
        />
        {volumeMenu && (
          <ParticipantVolumeMenu
            userId={volumeMenu.userId}
            displayName={volumeMenu.displayName}
            x={volumeMenu.x}
            y={volumeMenu.y}
            pinned={focusedId === volumeMenu.tileId}
            onTogglePin={() => {
              // We're already in fullscreen here — toggling focusedId is enough,
              // no mode-switch needed.
              setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
            }}
            // Self-tiles get no slider — the volume control would adjust the
            // remote volume of someone the local user isn't hearing.
            {...(volumeMenu.self ? { renderVolume: false } : {})}
            {...(volumeMenu.self
              ? {}
              : {
                  onShowProfile: () =>
                    setProfileMenu({
                      userId: volumeMenu.userId,
                      x: volumeMenu.x,
                      y: volumeMenu.y,
                    }),
                })}
            onClose={() => setVolumeMenu(null)}
          />
        )}
        {shareMenu && (
          <ScreenShareContextMenu
            userId={shareMenu.userId}
            displayName={shareMenu.displayName}
            hasAudio={shareMenu.hasAudio}
            x={shareMenu.x}
            y={shareMenu.y}
            onClose={() => setShareMenu(null)}
          />
        )}
        <SoundboardPopover
          open={soundboardOpen}
          onClose={() => setSoundboardOpen(false)}
        />
        <ParticipantsPopover
          open={participantsOpen}
          rows={participantRows}
          activeSpeakers={activeSpeakers}
          onClose={() => setParticipantsOpen(false)}
        />
        {profileMenu && (
          <UserProfilePopover
            userId={profileMenu.userId}
            profile={
              conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
            }
            x={profileMenu.x}
            y={profileMenu.y}
            onClose={() => setProfileMenu(null)}
          />
        )}
        {sharePickerOpen && (
          <ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
        )}
        {statsOverlayOpen && room && (
          <CallStatsOverlay
            room={room}
            members={conversation.members}
            onClose={() => setStatsOverlayOpen(false)}
          />
        )}
        {captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
      </>
    );
  }

  const title =
    conversation.type === 'group'
      ? conversation.name ?? t('app:chats.new_group')
      : conversation.peer?.displayName ?? '—';

  // Focus mode dedicates the entire call-panel vertical slot to the speaker so
  // the tile can grow in height (grid mode's 420px cap leaves it squashed).
  const sectionClass =
    stageLayout.kind === 'focus'
      ? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
      : 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
  const sectionHeight = stageLayout.kind === 'focus' ? '75%' : '50%';

  return (
    <section
      aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
      className={sectionClass}
      style={{ height: sectionHeight }}
    >
      <div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
        <div className="flex min-w-0 flex-col gap-0.5">
          <div className="flex items-center gap-2 text-sm font-semibold text-fg">
            <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />
            <span className="truncate">{title}</span>
            <span className="text-fg-muted/50" aria-hidden="true">·</span>
            <span className="tabular-nums text-fg-muted">
              {duration ?? statusLabel}
              {state.kind !== 'connected' && (
                <SpinnerIcon className="ml-1 inline h-3 w-3" />
              )}
            </span>
          </div>
          {isE2EEActive && (
            <div className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
              <LockIcon className="h-3 w-3" />
              <span>{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}</span>
            </div>
          )}
        </div>
        <ModeToggles mode={callMode} onChange={setCallMode} />
      </div>

      {micError && (
        <MicErrorBanner
          message={micError}
          onRetry={() => void retryMic()}
          onDismiss={clearMicError}
        />
      )}

      <CallStage
        tiles={tiles}
        speaker={bigTile}
        stageLayout={stageLayout}
        activeSpeakers={activeSpeakers}
        e2ee={isE2EEActive}
        remoteScreenShares={remoteScreenShares}
        conversationMembers={conversation.members}
        onFocusTile={(id) => {
          setFocusedId(focusedId === id ? null : id);
        }}
        onTileContextMenu={openTileContextMenu}
        compact
      />

      {controls}

      <PttHint />

      {volumeMenu && (
        <ParticipantVolumeMenu
          userId={volumeMenu.userId}
          displayName={volumeMenu.displayName}
          x={volumeMenu.x}
          y={volumeMenu.y}
          pinned={focusedId === volumeMenu.tileId}
          onTogglePin={() => {
            setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
          }}
          {...(volumeMenu.self ? { renderVolume: false } : {})}
          {...(volumeMenu.self
            ? {}
            : {
                onShowProfile: () =>
                  setProfileMenu({
                    userId: volumeMenu.userId,
                    x: volumeMenu.x,
                    y: volumeMenu.y,
                  }),
              })}
          onClose={() => setVolumeMenu(null)}
        />
      )}

      {profileMenu && (
        <UserProfilePopover
          userId={profileMenu.userId}
          profile={
            conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
          }
          x={profileMenu.x}
          y={profileMenu.y}
          onClose={() => setProfileMenu(null)}
        />
      )}

      {shareMenu && (
        <ScreenShareContextMenu
          userId={shareMenu.userId}
          displayName={shareMenu.displayName}
          hasAudio={shareMenu.hasAudio}
          x={shareMenu.x}
          y={shareMenu.y}
          onClose={() => setShareMenu(null)}
        />
      )}

      <SoundboardPopover
        open={soundboardOpen}
        onClose={() => setSoundboardOpen(false)}
      />

      <ParticipantsPopover
        open={participantsOpen}
        rows={participantRows}
        activeSpeakers={activeSpeakers}
        onClose={() => setParticipantsOpen(false)}
      />

      {sharePickerOpen && (
        <ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
      )}

      {statsOverlayOpen && room && (
        <CallStatsOverlay
          room={room}
          members={conversation.members}
          onClose={() => setStatsOverlayOpen(false)}
        />
      )}

      {captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
    </section>
  );
}

// Fixed-position overlay so the popover sits above both docked + fullscreen
// call modes without needing a portal or parent-relative anchoring.
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
  if (!open) return null;
  return (
    <div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
      <div className="pointer-events-auto">
        <SoundboardPanel onClose={onClose} />
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

interface BuildArgs {
  conversation: ConversationSummary;
  myId: string | null;
  room: Room | null;
  remoteParticipants: RemoteParticipant[];
  isMuted: boolean;
  isDeafened: boolean;
  remoteDeafen: Record<string, boolean>;
  remoteMute: Record<string, boolean>;
  isScreenSharing: boolean;
  isCameraEnabled: boolean;
  remoteSharerIds: Set<string>;
  connectionQualities: Record<string, ConnectionQuality>;
  /** Host userId (call initiator). Null in re-joined calls and 1:1 calls.
   *  Used to mark exactly one user's tile with the crown. */
  hostUserId: string | null;
  /** Tile id (e.g. "user:abc") the user has pinned via right-click. Null
   *  while no pin is active or while auto-tracking the active speaker. */
  pinnedTileId: string | null;
}

function cameraTrackFor(
  participant: { videoTrackPublications: Map<string, { source: Track.Source; track?: { mediaStreamTrack: MediaStreamTrack } | undefined }> } | null,
): MediaStreamTrack | null {
  if (!participant) return null;
  for (const pub of participant.videoTrackPublications.values()) {
    if (pub.source === Track.Source.Camera && pub.track) {
      return pub.track.mediaStreamTrack ?? null;
    }
  }
  return null;
}

function buildTiles({
  conversation,
  myId,
  room,
  remoteParticipants,
  isMuted,
  isDeafened,
  remoteDeafen,
  remoteMute,
  isScreenSharing,
  isCameraEnabled,
  remoteSharerIds,
  connectionQualities,
  hostUserId,
  pinnedTileId,
}: BuildArgs): Tile[] {
  const remoteById = new Map<string, RemoteParticipant>();
  for (const p of remoteParticipants) {
    if (p.identity) remoteById.set(p.identity, p);
  }
  const out: Tile[] = [];
  if (myId) {
    const me = conversation.members.find((m) => m.userId === myId) ?? null;
    // Source-of-truth for own mic: the LocalParticipant publication state.
    // `isMuted` reflects the toggle button, but if mic never published (no
    // device / permission denied) the toggle stays false while the real
    // state is "muted". Combine both so the badge always matches reality.
    const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false;
    const myQuality = connectionQualities[myId] as TileConnectionQuality | undefined;
    out.push({
      kind: 'user',
      id: 'user:' + myId,
      userId: myId,
      displayName: me?.profile?.displayName ?? '?',
      avatarUrl: me?.profile?.avatarUrl ?? null,
      self: true,
      muted: isMuted || !micLive,
      deafened: isDeafened,
      video: isCameraEnabled,
      videoTrack: cameraTrackFor(room?.localParticipant ?? null),
      sharing: false,
      remoteSharing: false,
      ...(myQuality ? { connectionQuality: myQuality } : {}),
      isHost: hostUserId === myId,
      pinned: pinnedTileId === 'user:' + myId,
      streaming: isScreenSharing,
    });
    if (isScreenSharing) {
      out.push({
        kind: 'screen',
        id: 'screen:' + myId,
        userId: myId,
        displayName: (me?.profile?.displayName ?? '?') + ' · Bildschirm',
        avatarUrl: me?.profile?.avatarUrl ?? null,
        self: true,
        muted: false,
        deafened: false,
        video: false,
        videoTrack: null,
        sharing: true,
        remoteSharing: false,
        isHost: false,
        pinned: pinnedTileId === 'screen:' + myId,
        streaming: false,
      });
    }
  }
  for (const m of conversation.members) {
    if (m.userId === myId) continue;
    const rp = remoteById.get(m.userId);
    if (!rp) continue;
    const peerQuality = connectionQualities[m.userId] as TileConnectionQuality | undefined;
    out.push({
      kind: 'user',
      id: 'user:' + m.userId,
      userId: m.userId,
      displayName: m.profile?.displayName ?? '?',
      avatarUrl: m.profile?.avatarUrl ?? null,
      self: false,
      // Peer's self-reported mute state via data channel. LiveKit's own
      // `isMicrophoneEnabled` no longer flips on mute since the pipeline
      // output track stays published. See remoteMute broadcast in CallContext.
      muted: remoteMute[m.userId] ?? false,
      // Deafen state arrives via LiveKit data channel; see CallContext.
      deafened: remoteDeafen[m.userId] ?? false,
      video: rp.isCameraEnabled,
      videoTrack: cameraTrackFor(rp),
      sharing: false,
      remoteSharing: false,
      ...(peerQuality ? { connectionQuality: peerQuality } : {}),
      isHost: hostUserId === m.userId,
      pinned: pinnedTileId === 'user:' + m.userId,
      streaming: remoteSharerIds.has(m.userId),
    });
    if (remoteSharerIds.has(m.userId)) {
      out.push({
        kind: 'screen',
        id: 'screen:' + m.userId,
        userId: m.userId,
        displayName: (m.profile?.displayName ?? '?') + ' · Bildschirm',
        avatarUrl: m.profile?.avatarUrl ?? null,
        self: false,
        muted: false,
        deafened: false,
        video: false,
        videoTrack: null,
        sharing: false,
        remoteSharing: true,
        isHost: false,
        pinned: pinnedTileId === 'screen:' + m.userId,
        streaming: false,
      });
    }
  }
  return out;
}

function LiveDuration({ startedAt }: { startedAt: string }) {
  const [, tick] = useState(0);
  useEffect(() => {
    const id = window.setInterval(() => tick((v) => v + 1), 1000);
    return () => window.clearInterval(id);
  }, []);
  return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
}

function formatElapsed(ms: number): string {
  const total = Math.max(0, Math.floor(ms / 1000));
  const hh = Math.floor(total / 3600);
  const mm = Math.floor((total % 3600) / 60);
  const ss = total % 60;
  const pad = (n: number) => n.toString().padStart(2, '0');
  return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}

function ModeToggles({
  mode,
  onChange,
}: {
  mode: CallMode;
  onChange: (mode: CallMode) => void;
}) {
  return (
    <div className="flex items-center gap-1">
      <ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
        <GridIcon className="h-4 w-4" />
      </ModeButton>
      <ModeButton
        active={mode === 'fullscreen'}
        onClick={() => onChange('fullscreen')}
        label="Vollbild"
      >
        <MaximizeIcon className="h-4 w-4" />
      </ModeButton>
    </div>
  );
}

function ModeButton({
  active,
  onClick,
  label,
  children,
}: {
  active: boolean;
  onClick: () => void;
  label: string;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={label}
      aria-pressed={active}
      title={label}
      className={
        'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
        (active
          ? 'border-accent bg-accent text-accent-fg'
          : 'border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg')
      }
    >
      {children}
    </button>
  );
}

interface StageProps {
  tiles: Tile[];
  speaker: Tile | undefined;
  /** Discriminated layout decision driven by InCallPanel's StageLayout
   *  selector. Drives the bento-vs-grid-vs-focus render branch. */
  stageLayout:
    | { kind: 'equal-grid' }
    | { kind: 'focus'; bigTileId: string }
    | { kind: 'bento'; shareIds: string[] };
  activeSpeakers: Set<string>;
  e2ee: boolean;
  remoteScreenShares: {
    track: import('livekit-client').RemoteTrack;
    participantId: string;
    participantName: string;
  }[];
  conversationMembers: ConversationSummary['members'];
  onFocusTile: (id: string) => void;
  onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
  compact?: boolean;
}

// Dispatches a Tile to the right renderer. Screen-tiles show the screenshare
// stream directly (no avatar, no speaking ring, no mic indicator); user-tiles
// render via CallParticipantTile with all its chrome.
function TileRender({
  tile,
  activeSpeakers,
  e2ee,
  remoteScreenShares,
  conversationMembers,
  size,
  focused,
  cinema,
  onClick,
  onDoubleClick,
  onContextMenu,
}: {
  tile: Tile;
  activeSpeakers: Set<string>;
  e2ee: boolean;
  remoteScreenShares: StageProps['remoteScreenShares'];
  conversationMembers: StageProps['conversationMembers'];
  size?: 'default' | 'small';
  focused?: boolean;
  /** True when rendered inside FullscreenCall's big-tile slot. Drives
   *  chrome-suppression on the inner ScreenShareViewer so its toggle
   *  doesn't visually collide with the cinema-mode strip-hidden button. */
  cinema?: boolean;
  onClick?: () => void;
  onDoubleClick?: () => void;
  onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element {
  if (tile.kind === 'screen') {
    if (tile.self) {
      return <LocalSharePreview displayName={tile.displayName} {...(onClick ? { onClick } : {})} />;
    }
    const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
    const member = conversationMembers.find((m) => m.userId === tile.userId);
    if (!share) return <div className="h-full w-full" />;
    return (
      <div
        onClick={onClick}
        onDoubleClick={onDoubleClick}
        onContextMenu={onContextMenu}
        className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
      >
        <ScreenShareViewer
          share={share}
          avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
          displayName={member?.profile?.displayName ?? tile.displayName}
          hideFullscreenToggle={cinema === true}
        />
      </div>
    );
  }
  return (
    <CallParticipantTile
      userId={tile.userId}
      displayName={tile.displayName}
      avatarUrl={tile.avatarUrl}
      me={tile.self}
      muted={tile.muted}
      deafened={tile.deafened}
      speaking={activeSpeakers.has(tile.userId)}
      video={tile.video}
      videoTrack={tile.videoTrack}
      e2ee={e2ee}
      isHost={tile.isHost}
      pinned={tile.pinned}
      streaming={tile.streaming}
      {...(tile.connectionQuality ? { connectionQuality: tile.connectionQuality } : {})}
      {...(size ? { size } : {})}
      {...(focused ? { focused } : {})}
      {...(onClick ? { onClick } : {})}
      {...(onDoubleClick ? { onDoubleClick } : {})}
      {...(onContextMenu ? { onContextMenu } : {})}
    />
  );
}

function CallStage({
  tiles,
  speaker,
  stageLayout,
  activeSpeakers,
  e2ee,
  remoteScreenShares,
  conversationMembers,
  onFocusTile,
  onTileContextMenu,
  compact = false,
}: StageProps) {
  if (stageLayout.kind === 'focus' && speaker) {
    const others = tiles.filter((p) => p.id !== speaker.id);
    return (
      <div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
        <div className="min-h-0 flex-1">
          <FocusedTile
            tile={speaker}
            e2ee={e2ee}
            activeSpeakers={activeSpeakers}
            remoteScreenShares={remoteScreenShares}
            conversationMembers={conversationMembers}
            onDoubleClick={() => onFocusTile(speaker.id)}
            {...(onTileContextMenu
              ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
              : {})}
          />
        </div>
        {others.length > 0 && (
          <div className="flex h-[180px] gap-2.5 overflow-x-auto">
            {others.map((p) => (
              <div
                key={p.id}
                // Docked strip: thumbs grow to share the row width evenly
                // (flex-1) but stay bounded so 1-2 tiles don't stretch into
                // 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
                // with the share above; min-w keeps them readable when many.
                className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
              >
                <TileRender
                  tile={p}
                  activeSpeakers={activeSpeakers}
                  e2ee={e2ee}
                  remoteScreenShares={remoteScreenShares}
                  conversationMembers={conversationMembers}
                  size="small"
                  onClick={() => onFocusTile(p.id)}
                  {...(onTileContextMenu
                    ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
                    : {})}
                />
              </div>
            ))}
          </div>
        )}
      </div>
    );
  }

  if (stageLayout.kind === 'bento') {
    const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
    const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
    const bentoCols = gridColsFor(shares.length);
    return (
      <div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
        <div className="min-h-0 flex-1">
          <div
            className={
              'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
            }
          >
            {shares.map((s) => (
              <div
                key={s.id}
                className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
              >
                <TileRender
                  tile={s}
                  activeSpeakers={activeSpeakers}
                  e2ee={e2ee}
                  remoteScreenShares={remoteScreenShares}
                  conversationMembers={conversationMembers}
                  onClick={() => onFocusTile(s.id)}
                  {...(onTileContextMenu
                    ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
                    : {})}
                />
              </div>
            ))}
          </div>
        </div>
        {webcams.length > 0 && (
          <div className="flex h-[180px] gap-2.5 overflow-x-auto">
            {webcams.map((w) => (
              <div
                key={w.id}
                // Same docked-strip sizing as the focus branch above.
                className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
              >
                <TileRender
                  tile={w}
                  activeSpeakers={activeSpeakers}
                  e2ee={e2ee}
                  remoteScreenShares={remoteScreenShares}
                  conversationMembers={conversationMembers}
                  size="small"
                  onClick={() => onFocusTile(w.id)}
                  {...(onTileContextMenu
                    ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
                    : {})}
                />
              </div>
            ))}
          </div>
        )}
      </div>
    );
  }

  // Grid (equal-grid fallthrough)
  const gridClass = gridColsFor(tiles.length);
  // aspect-video on every cell pushed the row past the section height on
  // wide chat panels — a single cell at full width forced height = width
  // × 9/16 (~400px on a 700px panel), which clipped the controls bar
  // below. Let cells fill grid tracks normally for n>=2, and only enforce
  // a 16:9 silhouette (capped width, centred) for the solo-user case.
  const isSolo = tiles.length === 1;
  return (
    <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
      <div
        className={
          'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
          gridClass
        }
      >
        {tiles.map((p) => (
          <div
            key={p.id}
            className={
              isSolo
                ? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
                : 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
            }
          >
            <TileRender
              tile={p}
              activeSpeakers={activeSpeakers}
              e2ee={e2ee}
              remoteScreenShares={remoteScreenShares}
              conversationMembers={conversationMembers}
              onClick={() => onFocusTile(p.id)}
              {...(onTileContextMenu
                ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
                : {})}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

function FocusedTile({
  tile,
  e2ee,
  activeSpeakers,
  remoteScreenShares,
  conversationMembers,
  onContextMenu,
  onDoubleClick,
}: {
  tile: Tile;
  e2ee: boolean;
  activeSpeakers: Set<string>;
  remoteScreenShares: StageProps['remoteScreenShares'];
  conversationMembers: StageProps['conversationMembers'];
  onContextMenu?: (e: React.MouseEvent) => void;
  onDoubleClick?: () => void;
}) {
  return (
    <div className="h-full [&>div]:h-full">
      <TileRender
        tile={tile}
        activeSpeakers={activeSpeakers}
        e2ee={e2ee}
        remoteScreenShares={remoteScreenShares}
        conversationMembers={conversationMembers}
        focused
        {...(onContextMenu ? { onContextMenu } : {})}
        {...(onDoubleClick ? { onDoubleClick } : {})}
      />
    </div>
  );
}

const GRID_PAGE_SIZE = 12;

function gridColsFor(n: number): string {
  // Discord-style: column count only. Cells are `aspect-video` so their
  // height follows from their width, and the container centers them
  // vertically when the row stack is shorter than the available area.
  if (n <= 1) return 'grid-cols-1';
  if (n === 2) return 'grid-cols-2';
  if (n === 3) return 'grid-cols-3';
  if (n === 4) return 'grid-cols-2';
  if (n <= 6) return 'grid-cols-3';
  if (n <= 9) return 'grid-cols-3';
  return 'grid-cols-4';
}

// Promote self + active speakers to the front of the tile list. Stable
// otherwise. Used by both pagination (so page 1 always carries the most
// "useful" tiles) and active-speaker promotion in fullscreen.
function prioritizeTiles(tiles: Tile[], activeSpeakers: Set<string>): Tile[] {
  const score = (t: Tile): number => {
    if (t.self) return 3;
    if (activeSpeakers.has(t.userId)) return 2;
    if (t.kind === 'screen') return 1;
    return 0;
  };
  return [...tiles].sort((a, b) => score(b) - score(a));
}

// ---------------------------------------------------------------------------
// Fullscreen cinema mode
// ---------------------------------------------------------------------------

interface FullscreenProps {
  tiles: Tile[];
  speaker: Tile | undefined;
  remoteScreenShares: StageProps['remoteScreenShares'];
  conversationMembers: StageProps['conversationMembers'];
  activeSpeakers: Set<string>;
  e2ee: boolean;
  onExit: () => void;
  onFocusTile: (id: string) => void;
  onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
  controls: React.ReactNode;
  /** When true, controls stay visible regardless of mouse idle (used while
   *  a popover / menu / error banner is open). */
  keepControlsVisible?: boolean;
}

const CONTROLS_IDLE_MS = 5_000;

function FullscreenCall({
  tiles,
  speaker,
  remoteScreenShares,
  conversationMembers,
  activeSpeakers,
  e2ee,
  onExit: _onExit,
  onFocusTile,
  onTileContextMenu,
  controls,
  keepControlsVisible = false,
}: FullscreenProps) {
  const [hintGone, setHintGone] = useState(false);
  const [page, setPage] = useState(0);
  // Discord-style auto-hide: controls fade out after 5s of mouse idle in
  // fullscreen so tiles aren't partially obscured. Any mousemove (or a
  // popover opening via keepControlsVisible) brings them back immediately.
  const [controlsVisible, setControlsVisible] = useState(true);
  // Session-only toggle to collapse the participant strip while watching a
  // focused tile (screen share, speaker). Matches Discord's "Hide non-video
  // participants" — gives the focused content the full fullscreen height.
  const [stripHidden, setStripHidden] = useState(false);
  useEffect(() => {
    const id = window.setTimeout(() => setHintGone(true), 3500);
    return () => window.clearTimeout(id);
  }, []);
  useEffect(() => {
    if (keepControlsVisible) {
      setControlsVisible(true);
      return;
    }
    let timer: number | null = window.setTimeout(
      () => setControlsVisible(false),
      CONTROLS_IDLE_MS,
    );
    const reset = () => {
      setControlsVisible(true);
      if (timer !== null) window.clearTimeout(timer);
      timer = window.setTimeout(
        () => setControlsVisible(false),
        CONTROLS_IDLE_MS,
      );
    };
    window.addEventListener('mousemove', reset);
    window.addEventListener('touchstart', reset);
    return () => {
      if (timer !== null) window.clearTimeout(timer);
      window.removeEventListener('mousemove', reset);
      window.removeEventListener('touchstart', reset);
    };
  }, [keepControlsVisible]);

  const hasFocus = speaker !== undefined;
  const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];

  const fsShareIds = tiles
    .filter((t) => t.kind === 'screen')
    .map((t) => t.id);
  const bentoMode = !hasFocus && fsShareIds.length >= 2;
  const bentoShares = bentoMode
    ? tiles.filter((t) => fsShareIds.includes(t.id))
    : [];
  const bentoWebcams = bentoMode
    ? tiles.filter((t) => !fsShareIds.includes(t.id))
    : [];

  // Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
  // exist, slice them into pages and prioritize active speakers onto page 1.
  // Otherwise keep a stable order (Discord-style) so tiles don't shuffle
  // whenever someone speaks.
  const needsPagination = tiles.length > GRID_PAGE_SIZE;
  const sortedGridTiles = useMemo(
    () => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
    [tiles, activeSpeakers, needsPagination],
  );
  const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
  useEffect(() => {
    if (page >= pageCount) setPage(0);
  }, [pageCount, page]);
  const visibleTiles = sortedGridTiles.slice(
    page * GRID_PAGE_SIZE,
    (page + 1) * GRID_PAGE_SIZE,
  );
  const gridClass = gridColsFor(visibleTiles.length);

  return (
    <div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
      {/* Content area. pb-28 reserves ~112px space at the bottom for the
          floating controls bar plus extra clearance so the tiles' bottom
          name-chip (positioned `bottom-2` inside each tile) doesn't sit
          directly underneath the controls — pb-24 was tight enough that
          on wide screens with audio-only avatars the chip got eclipsed. */}
      <div className="relative flex min-h-0 flex-1 flex-col pb-28">
        {hasFocus ? (
          <>
            <div
              className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
              onClick={() => onFocusTile(speaker!.id)}
              onDoubleClick={() => onFocusTile(speaker!.id)}
              title="Zurück zur Übersicht"
            >
              <TileRender
                tile={speaker!}
                activeSpeakers={activeSpeakers}
                e2ee={e2ee}
                remoteScreenShares={remoteScreenShares}
                conversationMembers={conversationMembers}
                focused
                cinema
                {...(onTileContextMenu
                  ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
                  : {})}
              />
            </div>
            {others.length > 0 && !stripHidden && (
              <div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
                {others.map((p) => (
                  <div
                    key={p.id}
                    className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
                  >
                    <TileRender
                      tile={p}
                      activeSpeakers={activeSpeakers}
                      e2ee={e2ee}
                      remoteScreenShares={remoteScreenShares}
                      conversationMembers={conversationMembers}
                      size="small"
                      onClick={() => onFocusTile(p.id)}
                      {...(onTileContextMenu
                        ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
                        : {})}
                    />
                  </div>
                ))}
              </div>
            )}
          </>
        ) : bentoMode ? (
          <div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
            <div className="min-h-0 flex-1">
              <div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
                {bentoShares.map((s) => (
                  <div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
                    <TileRender
                      tile={s}
                      activeSpeakers={activeSpeakers}
                      e2ee={e2ee}
                      remoteScreenShares={remoteScreenShares}
                      conversationMembers={conversationMembers}
                      onClick={() => onFocusTile(s.id)}
                      {...(onTileContextMenu
                        ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
                        : {})}
                    />
                  </div>
                ))}
              </div>
            </div>
            {bentoWebcams.length > 0 && (
              <div className="flex h-[180px] gap-2 overflow-x-auto">
                {bentoWebcams.map((w) => (
                  <div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
                    <TileRender
                      tile={w}
                      activeSpeakers={activeSpeakers}
                      e2ee={e2ee}
                      remoteScreenShares={remoteScreenShares}
                      conversationMembers={conversationMembers}
                      size="small"
                      onClick={() => onFocusTile(w.id)}
                      {...(onTileContextMenu
                        ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
                        : {})}
                    />
                  </div>
                ))}
              </div>
            )}
          </div>
        ) : (
          <div className="min-h-0 flex-1 p-4">
            <div
              className={
                'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
                gridClass
              }
            >
              {visibleTiles.map((p) => {
                const isSolo = visibleTiles.length === 1;
                return (
                  <div
                    key={p.id}
                    // Same fix as docked equal-grid: aspect-video w-full on
                    // wide screens pushed cell height to ~ width × 9/16,
                    // which dragged the tile's bottom name-chip down behind
                    // the floating controls bar. For n>=2 fill the grid
                    // tracks normally; for solo, cap width + centre.
                    className={
                      isSolo
                        ? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
                        : 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
                    }
                  >
                    <TileRender
                      tile={p}
                      activeSpeakers={activeSpeakers}
                      e2ee={e2ee}
                      remoteScreenShares={remoteScreenShares}
                      conversationMembers={conversationMembers}
                      onClick={() => onFocusTile(p.id)}
                      {...(onTileContextMenu
                        ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
                        : {})}
                    />
                  </div>
                );
              })}
            </div>
            {pageCount > 1 && (
              <div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
                <button
                  type="button"
                  onClick={() => setPage((p) => (p === 0 ? pageCount - 1 : p - 1))}
                  className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
                  aria-label="Vorherige Seite"
                >
                  ‹
                </button>
                <span className="tabular-nums">
                  {page + 1} / {pageCount}
                </span>
                <button
                  type="button"
                  onClick={() => setPage((p) => (p + 1) % pageCount)}
                  className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
                  aria-label="Nächste Seite"
                >
                  ›
                </button>
              </div>
            )}
          </div>
        )}
      </div>

      {!hintGone && (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
        >
          Esc zum Verlassen
        </div>
      )}

      {/* Hide-participant-strip toggle, only meaningful when there's a focus
          + extras to hide. Fades alongside the bottom control bar so idle
          fullscreen still goes clean. */}
      {hasFocus && others.length > 0 && (
        <button
          type="button"
          onClick={() => setStripHidden((v) => !v)}
          aria-pressed={stripHidden}
          title={
            stripHidden
              ? 'Teilnehmer einblenden'
              : 'Teilnehmer ausblenden'
          }
          aria-label={
            stripHidden
              ? 'Teilnehmer einblenden'
              : 'Teilnehmer ausblenden'
          }
          className={
            'absolute right-5 top-5 z-20 flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
            (controlsVisible
              ? 'pointer-events-auto opacity-100 '
              : 'pointer-events-none opacity-0 ') +
            (stripHidden
              ? 'border-accent bg-accent/20 text-accent-fg'
              : 'border-white/15 bg-white/10 text-white hover:bg-white/15')
          }
        >
          <StripToggleIcon hidden={stripHidden} />
        </button>
      )}

      <div
        className={
          'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
          (controlsVisible
            ? 'pointer-events-auto opacity-100'
            : 'pointer-events-none opacity-0')
        }
      >
        {controls}
      </div>
    </div>
  );
}

// Users-icon with a diagonal slash when the strip is hidden — mirrors the
// MicOff/HeadphonesOff naming convention used elsewhere.
function StripToggleIcon({ hidden }: { hidden: boolean }) {
  return (
    <svg
      width="18"
      height="18"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
      <circle cx="9" cy="7" r="4" />
      <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
      <path d="M16 3.13a4 4 0 0 1 0 7.75" />
      {hidden && <line x1="2" y1="2" x2="22" y2="22" />}
    </svg>
  );
}

function PttHint() {
  const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
  useEffect(() => subscribePttSettings(setPtt), []);
  if (!ptt.enabled) return null;
  return (
    <p className="border-t border-line bg-surface-3 py-2 text-center text-[11px] text-fg-muted">
      Push-to-Talk:&nbsp;
      <kbd className="rounded border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-fg">
        {ptt.keyLabel}
      </kbd>
    </p>
  );
}

// Non-terminal mic error banner. Shown inside the call panel when mic setup
// fails — the call itself stays alive, the user just can't be heard. Retry
// invokes the pipeline setup again with the current audioSettings so a
// permission granted in OS settings mid-call works without rejoin.
function MicErrorBanner({
  message,
  onRetry,
  onDismiss,
}: {
  message: string;
  onRetry: () => void;
  onDismiss: () => void;
}) {
  return (
    <div
      role="alert"
      className="flex items-start gap-3 border-b border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-xs text-rose-700 dark:text-rose-200"
    >
      <MicOffIconInline />
      <p className="flex-1 leading-relaxed">{message}</p>
      <button
        type="button"
        onClick={onRetry}
        className="cursor-pointer rounded-md bg-rose-600 px-2.5 py-1 text-[11px] font-semibold text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
      >
        Erneut versuchen
      </button>
      <button
        type="button"
        onClick={onDismiss}
        aria-label="Schließen"
        className="cursor-pointer rounded-md p-1 text-rose-700/70 transition hover:bg-rose-500/10 hover:text-rose-700 dark:text-rose-200/70 dark:hover:text-rose-100"
      >
        <span aria-hidden="true">×</span>
      </button>
    </div>
  );
}

// Preview card for the local sharer's screen-tile. Shows a "Live" label and,
// when the share carries a ScreenShareAudio track, a small overlay button to
// mute that outgoing audio publication without tearing down the share. The
// mute state lives in CallContext so the toggle survives re-renders and
// resets cleanly when the share ends.
function LocalSharePreview({
  displayName,
  onClick,
}: {
  displayName: string;
  onClick?: () => void;
}) {
  const { room, outgoingShareAudioMuted, toggleOutgoingShareAudioMute } = useCall();
  const [hasShareAudio, setHasShareAudio] = useState(false);
  useEffect(() => {
    if (!room) {
      setHasShareAudio(false);
      return;
    }
    const compute = () => {
      const lp = room.localParticipant;
      let found = false;
      for (const pub of lp.audioTrackPublications.values()) {
        if (pub.source === Track.Source.ScreenShareAudio) {
          found = true;
          break;
        }
      }
      setHasShareAudio(found);
    };
    compute();
    const onPub = () => compute();
    room.on(RoomEvent.LocalTrackPublished, onPub);
    room.on(RoomEvent.LocalTrackUnpublished, onPub);
    return () => {
      room.off(RoomEvent.LocalTrackPublished, onPub);
      room.off(RoomEvent.LocalTrackUnpublished, onPub);
    };
  }, [room]);

  return (
    <div
      onClick={onClick}
      className={
        'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
        (onClick ? 'cursor-pointer' : '')
      }
    >
      <div className="flex flex-col items-center gap-2 p-4 text-center">
        <div className="text-xs font-semibold uppercase tracking-wider">Live</div>
        <div className="text-sm">{displayName}</div>
      </div>
      {hasShareAudio && (
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            void toggleOutgoingShareAudioMute();
          }}
          aria-pressed={outgoingShareAudioMuted}
          aria-label={
            outgoingShareAudioMuted
              ? 'Sound der Übertragung wieder senden'
              : 'Sound der Übertragung stumm'
          }
          title={
            outgoingShareAudioMuted
              ? 'Sound der Übertragung wieder senden'
              : 'Sound der Übertragung stumm'
          }
          className={
            'absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border backdrop-blur-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
            (outgoingShareAudioMuted
              ? 'border-rose-500/60 bg-rose-500/20 text-rose-600 hover:bg-rose-500/30 dark:text-rose-300'
              : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-200')
          }
        >
          {outgoingShareAudioMuted ? <SpeakerOffIconInline /> : <SpeakerOnIconInline />}
        </button>
      )}
    </div>
  );
}

function SpeakerOnIconInline() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
      <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
      <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
    </svg>
  );
}

function SpeakerOffIconInline() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
      <line x1="23" y1="9" x2="17" y2="15" />
      <line x1="17" y1="9" x2="23" y2="15" />
    </svg>
  );
}

// Tiny inline variant so we don't pull MicOffIcon's default sizing.
function MicOffIconInline() {
  return (
    <svg
      width="14"
      height="14"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className="mt-0.5 shrink-0"
    >
      <line x1="1" y1="1" x2="23" y2="23" />
      <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
      <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
      <line x1="12" y1="19" x2="12" y2="23" />
    </svg>
  );
}
