"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { io, type Socket } from "socket.io-client";
import type { Call } from "@slackwsh/contracts";
import { api, getValidAccessToken } from "../lib/api";
import {
  createCallSession,
  type CallDataEvent,
  type CallQuality,
  type CallSession,
  type InboundSignal,
  type PeerState,
} from "../lib/webrtc";
import {
  IconAnalytics,
  IconMic,
  IconMicOff,
  IconPhone,
  IconPhoneOff,
  IconShare,
  IconSmile,
  IconUsers,
  IconVideo,
  IconX,
} from "./icons";
import { UserAvatar } from "./UserAvatar";
import { gatewaySocketOptions, gatewayUrl } from "../lib/gateway-socket";
import { hasTurnConfigured, turnCredentialsMissing } from "../lib/public-env";
import { startOutboundRingback, stopOutboundRingback } from "../lib/call-ringtone";

function formatClock(seconds: number) {
  const m = Math.floor(seconds / 60);
  const s = seconds % 60;
  return `${m}:${String(s).padStart(2, "0")}`;
}

/**
 * Renders one participant's media.
 *
 * The <video> element stays mounted even when there is nothing to look at,
 * because it is also what plays the audio — unmounting it on an audio-only call
 * would silence the person. `srcObject` is assigned imperatively since it takes
 * a MediaStream, which has no attribute form React could set.
 */
function CallTile({
  name,
  userId,
  avatarUrl,
  stream,
  speaking,
  label,
  mirrored,
}: {
  name: string;
  userId: string | number;
  avatarUrl?: string | null;
  stream: MediaStream | null;
  speaking: boolean;
  label?: string;
  mirrored?: boolean;
}) {
  const videoRef = useRef<HTMLVideoElement | null>(null);
  const [hasVideo, setHasVideo] = useState(false);

  useEffect(() => {
    const element = videoRef.current;
    if (!element) return;
    element.srcObject = stream;
    if (stream) void element.play().catch(() => undefined);

    function sync() {
      const track = stream?.getVideoTracks()[0];
      setHasVideo(Boolean(track && track.enabled && !track.muted));
    }
    sync();
    if (!stream) return;
    // A track that arrives or goes dark after the stream does — camera toggled
    // on the far side, or a screen share starting.
    stream.addEventListener("addtrack", sync);
    stream.addEventListener("removetrack", sync);
    const track = stream.getVideoTracks()[0];
    track?.addEventListener("mute", sync);
    track?.addEventListener("unmute", sync);
    const poll = window.setInterval(sync, 1000);
    return () => {
      stream.removeEventListener("addtrack", sync);
      stream.removeEventListener("removetrack", sync);
      track?.removeEventListener("mute", sync);
      track?.removeEventListener("unmute", sync);
      window.clearInterval(poll);
    };
  }, [stream]);

  return (
    <div className={speaking ? "call-tile speaking" : "call-tile"}>
      {/* Always muted, local or remote: sound is RemoteAudio's job. A tile that
          also played audio would double every remote voice. */}
      <video
        ref={videoRef}
        className={hasVideo ? (mirrored ? "call-tile-video mirrored" : "call-tile-video") : "call-tile-video hidden"}
        autoPlay
        playsInline
        muted
      />
      {!hasVideo && (
        <UserAvatar className="call-tile-avatar" userId={userId} name={name} avatarUrl={avatarUrl} />
      )}
      <div className="call-tile-foot">
        <span className="call-tile-who">{name}</span>
        {label && <span className="call-tile-label">{label}</span>}
      </div>
    </div>
  );
}

/**
 * Plays one peer's audio, and nothing else.
 *
 * Deliberately separate from that peer's video tile. Audio used to ride the
 * <video> element, which coupled being audible to being laid out — a tile that
 * was hidden, unmounted, or re-keyed by a grid change could take the sound with
 * it. This element renders once per peer outside the grid, so audio survives
 * anything the layout does.
 */
function RemoteAudio({
  userId,
  stream,
  onBlocked,
}: {
  userId: number;
  stream: MediaStream | null;
  onBlocked: () => void;
}) {
  const ref = useRef<HTMLAudioElement | null>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element || !stream) return;
    element.srcObject = stream;
    // A rejected play() is the browser's autoplay policy, not a broken call —
    // surface it so the UI can offer a click, rather than swallowing it and
    // leaving a silent call that looks connected.
    void element.play().catch(() => onBlocked());
  }, [stream, onBlocked]);

  return <audio ref={ref} data-call-audio={userId} autoPlay playsInline />;
}

/**
 * The in-call surface: real WebRTC media over a mesh (see lib/webrtc.ts).
 *
 * Owns its own socket rather than reading from RealtimeProvider's window events,
 * following /channel's precedent for a scoped connection. Signalling is a tight
 * request/response loop between two specific clients, so routing it through the
 * app-wide event bus would add a hop and force every page to care about SDP.
 */
export function CallOverlay({
  workspaceId,
  call,
  myUserId,
  nameById,
  avatarById,
  onLeave,
  onEnd,
}: {
  workspaceId: string;
  call: Call;
  myUserId: number;
  nameById: Map<string, string>;
  avatarById: Map<string, string | null | undefined>;
  onLeave: () => void;
  onEnd: () => void;
}) {
  const [peers, setPeers] = useState<PeerState[]>([]);
  const [localStream, setLocalStream] = useState<MediaStream | null>(null);
  const [localLevel, setLocalLevel] = useState(0);
  const [micOn, setMicOn] = useState(true);
  const [camOn, setCamOn] = useState(call.kind === "video");
  const [sharing, setSharing] = useState(false);
  const [mediaError, setMediaError] = useState<string | null>(null);
  const [soundBlocked, setSoundBlocked] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  /**
   * The session is state, not a ref, and that distinction is load-bearing: the
   * effect that pushes the peer list has to re-run once the session exists.
   * Held in a ref, it was still null when that effect first ran (the session is
   * created behind two awaits, including getUserMedia), so the peer list was
   * discarded and whichever side mounted with its peer already joined never
   * dialled — the call connected and carried no media.
   */
  const [session, setSession] = useState<CallSession | null>(null);
  const [quality, setQuality] = useState<CallQuality | null>(null);
  const [reactions, setReactions] = useState<Array<{ id: number; userId: number; emoji: string }>>([]);
  const [chatLines, setChatLines] = useState<Array<{ id: number; userId: number; text: string }>>([]);
  const [chatOpen, setChatOpen] = useState(false);
  const [chatDraft, setChatDraft] = useState("");
  const [statsOpen, setStatsOpen] = useState(false);
  const [devicesOpen, setDevicesOpen] = useState(false);
  const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
  const [videoInputs, setVideoInputs] = useState<MediaDeviceInfo[]>([]);
  const [pstnOpen, setPstnOpen] = useState(false);
  const [pstnNumber, setPstnNumber] = useState("");
  const [recordBusy, setRecordBusy] = useState(false);
  const [recording, setRecording] = useState(call.recordingStatus === "recording");
  const eventSeq = useRef(0);

  // Joined *or* ringing: an invitee who is still being rung will answer in a
  // moment, and having the connection already open means media flows the
  // instant they do rather than a negotiation round-trip later.
  const joinedPeerIds = useMemo(
    () =>
      call.participants
        .filter((p) => p.state === "joined" && Number(p.userId) !== myUserId)
        .map((p) => Number(p.userId)),
    [call.participants, myUserId],
  );
  // Depend on the contents, not the array identity, so a re-render with the
  // same peers doesn't churn the mesh.
  const peerKey = joinedPeerIds.join(",");
  const ringingParticipants = useMemo(
    () => call.participants.filter((p) => p.state === "ringing" && Number(p.userId) !== myUserId),
    [call.participants, myUserId],
  );
  const ringingCount = ringingParticipants.length;

  // One session for the life of the overlay. Keyed on the call id only: a
  // change in who is on the call is handled by setPeers, not by tearing the
  // local microphone down and asking for it again.
  //
  // Own signalling socket (not RealtimeProvider): presence hydration can delay
  // the shared socket for many seconds, and CallOverlay must not wait on it.
  useEffect(() => {
    let cancelled = false;
    let socket: Socket | null = null;
    // Named `mesh` rather than `session` so it does not shadow the state of the
    // same name: this is the effect's own handle, used for teardown and for the
    // queued-signal check below, before the state has been published.
    let mesh: CallSession | null = null;

    async function start() {
      const token = await getValidAccessToken();
      if (!token || cancelled) return;

      let livekit: { url: string; token: string } | undefined;
      try {
        const minted = await api.callMediaToken(workspaceId, call.id);
        if (minted.configured && minted.url && minted.token) {
          livekit = { url: minted.url, token: minted.token };
        }
      } catch {
        livekit = undefined;
      }

      function onData(event: CallDataEvent) {
        const id = ++eventSeq.current;
        if (event.type === "reaction" && event.emoji) {
          setReactions((current) => [...current.slice(-8), { id, userId: event.fromUserId, emoji: event.emoji! }]);
          window.setTimeout(() => {
            setReactions((current) => current.filter((row) => row.id !== id));
          }, 2800);
        }
        if (event.type === "chat" && event.text) {
          setChatLines((current) => [...current.slice(-40), { id, userId: event.fromUserId, text: event.text! }]);
          setChatOpen(true);
        }
      }

      const sessionCallbacks = {
        onState: ({ peers: nextPeers, localLevel: nextLevel, quality: nextQuality }: {
          peers: PeerState[];
          localLevel: number;
          quality: CallQuality;
        }) => {
          if (cancelled) return;
          setPeers(nextPeers);
          setLocalLevel(nextLevel);
          setQuality(nextQuality);
        },
        onLocalStream: (stream: MediaStream | null) => {
          if (!cancelled) setLocalStream(stream);
        },
        onError: (err: Error) => {
          if (!cancelled) setMediaError(err.message);
        },
        onData,
      };

      if (livekit) {
        try {
          mesh = await createCallSession({
            selfUserId: myUserId,
            video: call.kind === "video",
            livekit,
            ...sessionCallbacks,
          });
          if (cancelled) {
            mesh.close();
            return;
          }
          setSession(mesh);
          return;
        } catch (err) {
          mesh = null;
          if (cancelled) return;
          if (err instanceof Error && err.name === "NotAllowedError") {
            setMediaError("Microphone access was blocked. Allow it in your browser to be heard.");
            return;
          }
          // LiveKit env is set but the SFU is down (no Docker) — mesh still rings.
          setMediaError("LiveKit unavailable — using peer-to-peer fallback.");
        }
      }

      socket = io(gatewayUrl(), gatewaySocketOptions((cb) => getValidAccessToken().then((t) => cb({ accessToken: t }))));
      const activeSocket = socket;

      await new Promise<void>((resolve, reject) => {
        if (activeSocket.connected) {
          resolve();
          return;
        }
        // Do not reject on connect_error — socket.io retries and may fall back
        // from websocket to polling; only the timeout is fatal.
        const timer = window.setTimeout(() => reject(new Error("Call signalling timed out connecting")), 20_000);
        activeSocket.once("connect", () => {
          window.clearTimeout(timer);
          resolve();
        });
      }).catch((err) => {
        if (!cancelled) {
          setMediaError(err instanceof Error ? err.message : "Call signalling failed to connect");
        }
      });
      if (cancelled || !activeSocket.connected) {
        activeSocket.disconnect();
        return;
      }

      /**
       * Subscribe before the session exists, and hold anything that arrives.
       *
       * socket.io discards an event with no listener, and the session is two
       * awaits away (one of them the microphone permission prompt).
       */
      const pending: InboundSignal[] = [];
      activeSocket.on(
        "call:signal",
        (payload: { callId: number; fromUserId: number; signal: string; data: unknown }) => {
          if (cancelled || String(payload.callId) !== String(call.id)) return;
          const inbound: InboundSignal = {
            fromUserId: Number(payload.fromUserId),
            signal: payload.signal as InboundSignal["signal"],
            data: payload.data,
          };
          if (mesh) mesh.handleSignal(inbound);
          else pending.push(inbound);
        },
      );

      try {
        mesh = await createCallSession({
          selfUserId: myUserId,
          video: call.kind === "video",
          sendSignal: ({ toUserId, signal, data }) =>
            activeSocket.emit("call:signal", { workspaceId, callId: call.id, toUserId, signal, data }),
          ...sessionCallbacks,
        });
      } catch (err) {
        // No microphone, or permission refused. The call still exists
        // server-side, so say so rather than silently showing an empty grid.
        if (!cancelled) {
          setMediaError(
            err instanceof Error && err.name === "NotAllowedError"
              ? "Microphone access was blocked. Allow it in your browser to be heard."
              : "Couldn't reach your microphone.",
          );
        }
        return;
      }
      if (cancelled) {
        mesh.close();
        return;
      }

      for (const inbound of pending.splice(0)) mesh.handleSignal(inbound);
      setSession(mesh);
    }

    void start();

    return () => {
      cancelled = true;
      setSession(null);
      mesh?.close();
      socket?.disconnect();
    };
  }, [call.id, call.kind, myUserId, workspaceId]);

  // Reconnect the mesh whenever the joined set changes — someone answering or
  // hanging up is a participant-list change, delivered by the calls page. Note
  // the `session` dependency: without it this never runs for the side that
  // mounts with its peer already on the call.
  useEffect(() => {
    if (!session) return;
    const ids = peerKey === "" ? [] : peerKey.split(",").map(Number);
    session.setPeers(ids);
    // Tell every joined peer we can receive SDP now — recovers offers that
    // landed while we were still on the ringer / mic prompt.
    if (ids.length > 0) session.announceReady(ids);
  }, [session, peerKey]);

  useEffect(() => {
    if (session?.transport === "livekit") return;
    if (!peers.some((p) => p.connection === "failed")) return;
    if (turnCredentialsMissing()) {
      setMediaError(
        "TURN URLs are set but username/password are missing in runtime-config.js. Add NEXT_PUBLIC_TURN_USERNAME and NEXT_PUBLIC_TURN_CREDENTIAL, then redeploy.",
      );
      return;
    }
    if (hasTurnConfigured() && peers.some((p) => p.connection === "failed" && !p.sawRelay)) {
      setMediaError(
        "TURN did not allocate a relay (wrong password, coturn down, firewall, or desktop CSP). On the server: check coturn logs, ports 3478 + 49160–49200, and external-ip.",
      );
      return;
    }
    if (hasTurnConfigured()) {
      setMediaError(
        "TURN relay was gathered but peers still could not connect. Confirm both sides use the same app build and that UDP 49160–49200 is open on the TURN host.",
      );
      return;
    }
    setMediaError(
      "Could not reach the other device. Cross-Wi‑Fi calls need a TURN server — see ops/coturn/README.md.",
    );
  }, [peers, session]);

  useEffect(() => {
    // Duration runs from the answer, matching the server's own definition, so
    // the live clock and the history entry agree.
    const since = call.answeredAt ?? call.startedAt;
    const base = new Date(since).getTime();
    function tick() {
      setElapsed(Math.max(0, Math.round((Date.now() - base) / 1000)));
    }
    tick();
    const id = window.setInterval(tick, 1000);
    return () => window.clearInterval(id);
  }, [call.answeredAt, call.startedAt]);

  // Ringback while we wait for someone to answer; silence once the call is live.
  useEffect(() => {
    if (call.status === "ringing") startOutboundRingback();
    else stopOutboundRingback();
    return () => stopOutboundRingback();
  }, [call.status]);

  const toggleMic = useCallback(() => {
    setMicOn((on) => {
      session?.setMicEnabled(!on);
      session?.resumeAudio();
      return !on;
    });
  }, [session]);

  const toggleCam = useCallback(() => {
    setCamOn((on) => {
      session?.setCameraEnabled(!on);
      return !on;
    });
  }, [session]);

  const enumerateDevices = useCallback(() => {
    if (!navigator.mediaDevices?.enumerateDevices) return;
    void navigator.mediaDevices.enumerateDevices().then((list) => {
      setAudioInputs(list.filter((d) => d.kind === "audioinput"));
      setVideoInputs(list.filter((d) => d.kind === "videoinput"));
    });
  }, []);

  useEffect(() => {
    enumerateDevices();
    navigator.mediaDevices?.addEventListener?.("devicechange", enumerateDevices);
    return () => navigator.mediaDevices?.removeEventListener?.("devicechange", enumerateDevices);
  }, [enumerateDevices]);

  const toggleRecord = useCallback(async () => {
    if (recordBusy) return;
    setRecordBusy(true);
    try {
      if (call.recordingStatus === "recording" || recording) {
        await api.stopCallRecording(workspaceId, call.id);
        setRecording(false);
        setMediaError("Recording is processing…");
      } else {
        await api.startCallRecording(workspaceId, call.id);
        setRecording(true);
      }
    } catch (err) {
      setMediaError(err instanceof Error ? err.message : "Recording is unavailable");
    } finally {
      setRecordBusy(false);
    }
  }, [call.id, call.recordingStatus, recordBusy, recording, workspaceId]);

  const sendReaction = useCallback(
    (emoji: string) => {
      session?.sendReaction(emoji);
      session?.resumeAudio();
    },
    [session],
  );

  const sendChat = useCallback(() => {
    const text = chatDraft.trim();
    if (!text || !session) return;
    session.sendChat(text);
    setChatDraft("");
  }, [chatDraft, session]);

  const dialPstn = useCallback(async () => {
    const e164 = pstnNumber.trim();
    if (!e164) return;
    try {
      await api.dialPstn(workspaceId, call.id, e164);
      setPstnOpen(false);
      setPstnNumber("");
    } catch (err) {
      setMediaError(err instanceof Error ? err.message : "PSTN dial failed");
    }
  }, [call.id, pstnNumber, workspaceId]);

  const toggleShare = useCallback(async () => {
    if (!session) return;
    if (sharing) {
      await session.stopScreenShare();
      setSharing(false);
      return;
    }
    setSharing(await session.startScreenShare());
  }, [session, sharing]);

  /**
   * Retries playback of every remote audio element from a real click.
   *
   * Chrome's autoplay policy is per-document user activation, and a client-side
   * navigation clears it — so the person who answers from the ringer arrives on
   * /calls with no activation and their `play()` can be rejected, leaving a
   * connected call completely silent with nothing on screen to explain it.
   */
  const enableSound = useCallback(() => {
    session?.resumeAudio();
    for (const element of document.querySelectorAll<HTMLAudioElement>("audio[data-call-audio]")) {
      void element.play().catch(() => undefined);
    }
    setSoundBlocked(false);
  }, [session]);

  // Stable identity so RemoteAudio's effect doesn't re-run every render.
  const handleSoundBlocked = useCallback(() => setSoundBlocked(true), []);

  // Keep every ringing invitee visible instead of collapsing the group into
  // one generic waiting tile.
  const tileCount = peers.length + ringingParticipants.length + 1;

  const title =
    call.title ??
    (call.participants.length === 2
      ? nameById.get(String(call.participants.find((p) => Number(p.userId) !== myUserId)?.userId)) ?? "Call"
      : `${call.participants.length} people`);

  return (
    <div className="call-backdrop" role="presentation">
      <div className="call-stage" role="dialog" aria-modal="true" aria-label={`${title} call`}>
        <div className="call-head">
          <span className={call.status === "active" ? "call-live" : "call-live ringing"} aria-hidden="true" />
          <span className="call-title">{title}</span>
          <span className="call-clock">{call.status === "ringing" ? "Ringing…" : formatClock(elapsed)}</span>
          {quality && (
            <span className={`call-qos ${quality.label}`} title="Call quality">
              {quality.label}
            </span>
          )}
          <span className="call-spacer" />
          <span className="call-count">
            <IconUsers size={13} />
            {peers.length + 1}
            {ringingCount > 0 && <em>+{ringingCount} ringing</em>}
          </span>
          <button className="call-close" type="button" onClick={onLeave} aria-label="Leave call">
            <IconX />
          </button>
        </div>

        {mediaError && <p className="call-error">{mediaError}</p>}
        {soundBlocked && (
          <button className="call-unmute" type="button" onClick={enableSound}>
            Your browser blocked audio playback — click to enable sound
          </button>
        )}

        {/* Outside the grid on purpose: sound must not depend on layout. */}
        {peers.map((peer) => (
          <RemoteAudio
            key={`audio-${peer.userId}`}
            userId={peer.userId}
            stream={peer.stream}
            onBlocked={handleSoundBlocked}
          />
        ))}

        {/* Count local, joined, and ringing participants in the grid. */}
        {reactions.length > 0 && (
          <div className="call-reactions" aria-live="polite">
            {reactions.map((row) => (
              <span key={row.id} className="call-reaction">
                {row.emoji} {nameById.get(String(row.userId)) ?? ""}
              </span>
            ))}
          </div>
        )}

        <div className={`call-grid count-${Math.min(tileCount, 6)}`}>
          <CallTile
            name={`${nameById.get(String(myUserId)) ?? "You"} (you)`}
            userId={myUserId}
            avatarUrl={avatarById.get(String(myUserId))}
            stream={localStream}
            mirrored={!sharing}
            speaking={micOn && localLevel > 0.15}
            label={micOn ? undefined : "Muted"}
          />
          {peers.map((peer) => (
            <CallTile
              key={peer.userId}
              name={nameById.get(String(peer.userId)) ?? `User ${peer.userId}`}
              userId={peer.userId}
              avatarUrl={avatarById.get(String(peer.userId))}
              stream={peer.stream}
              speaking={peer.level > 0.15}
              label={
                peer.connection === "connected"
                  ? undefined
                  : peer.connection === "failed"
                    ? "Can't reach (network/NAT)"
                    : "Connecting…"
              }
            />
          ))}
          {ringingParticipants.map((participant) => (
            <CallTile
              key={`ringing-${participant.userId}`}
              name={nameById.get(String(participant.userId)) ?? `User ${participant.userId}`}
              userId={participant.userId}
              avatarUrl={avatarById.get(String(participant.userId))}
              stream={null}
              speaking={false}
              label="Ringing…"
            />
          ))}
        </div>

        <div className="call-controls">
          <button
            className={micOn ? "call-control" : "call-control off"}
            type="button"
            aria-label={micOn ? "Mute" : "Unmute"}
            aria-pressed={!micOn}
            onClick={toggleMic}
          >
            {micOn ? <IconMic /> : <IconMicOff />}
          </button>
          <button
            className={camOn ? "call-control" : "call-control off"}
            type="button"
            aria-label={camOn ? "Turn camera off" : "Turn camera on"}
            aria-pressed={!camOn}
            onClick={toggleCam}
            disabled={call.kind !== "video" && session?.transport !== "livekit"}
            title={call.kind === "video" || session?.transport === "livekit" ? undefined : "This is an audio call"}
          >
            <IconVideo />
          </button>
          <button
            className={sharing ? "call-control on" : "call-control"}
            type="button"
            aria-label={sharing ? "Stop sharing your screen" : "Share your screen"}
            aria-pressed={sharing}
            onClick={() => void toggleShare()}
          >
            <IconShare />
          </button>
          <button
            className="call-control"
            type="button"
            aria-label="Send a reaction"
            onClick={() => sendReaction("👍")}
            onContextMenu={(event) => {
              event.preventDefault();
              sendReaction("🎉");
            }}
            title="React · right-click for 🎉"
          >
            <IconSmile />
          </button>
          <button
            className={chatOpen ? "call-control on" : "call-control"}
            type="button"
            aria-label="In-call chat"
            aria-pressed={chatOpen}
            onClick={() => setChatOpen((open) => !open)}
          >
            <IconUsers />
          </button>
          <button
            className={statsOpen ? "call-control on" : "call-control"}
            type="button"
            aria-label="Call quality"
            aria-pressed={statsOpen}
            onClick={() => setStatsOpen((open) => !open)}
          >
            <IconAnalytics />
          </button>
          <button
            className={recording ? "call-control on" : "call-control"}
            type="button"
            aria-label={recording ? "Stop recording" : "Record call"}
            disabled={recordBusy || session?.transport !== "livekit"}
            onClick={() => void toggleRecord()}
            title={session?.transport === "livekit" ? undefined : "Recording needs LiveKit"}
          >
            <span className={recording ? "call-rec-dot on" : "call-rec-dot"} />
          </button>
          <button
            className={pstnOpen ? "call-control on" : "call-control"}
            type="button"
            aria-label="Dial a phone number"
            aria-pressed={pstnOpen}
            disabled={session?.transport !== "livekit"}
            onClick={() => setPstnOpen((open) => !open)}
            title="PSTN dial-out"
          >
            <IconPhone />
          </button>
          <button
            className={devicesOpen ? "call-control on" : "call-control"}
            type="button"
            aria-label="Choose devices"
            aria-pressed={devicesOpen}
            onClick={() => {
              enumerateDevices();
              setDevicesOpen((open) => !open);
            }}
          >
            <IconMic size={16} />
          </button>
          <span className="call-controls-spacer" />
          <button className="call-leave" type="button" onClick={onLeave}>
            <IconPhoneOff size={16} />
            Leave
          </button>
          <button className="call-end" type="button" onClick={onEnd} title="End the call for everyone">
            End for all
          </button>
        </div>

        {statsOpen && (
          <div className="call-panel">
            <strong>Quality</strong>
            <span>{quality?.label ?? "unknown"}</span>
            <span>{session?.transport === "livekit" ? "LiveKit SFU" : "Mesh fallback"}</span>
            {quality?.packetLoss != null && <span>Loss {Math.round(quality.packetLoss * 100)}%</span>}
            {quality?.rttMs != null && <span>RTT {Math.round(quality.rttMs)} ms</span>}
            {call.summary && <span>{call.summary}</span>}
          </div>
        )}
        {devicesOpen && (
          <div className="call-panel">
            <label>
              Microphone
              <select
                onChange={(event) => void session?.switchAudioDevice(event.target.value)}
                defaultValue=""
              >
                <option value="" disabled>
                  Select microphone
                </option>
                {audioInputs.map((device) => (
                  <option key={device.deviceId} value={device.deviceId}>
                    {device.label || "Microphone"}
                  </option>
                ))}
              </select>
            </label>
            <label>
              Camera
              <select
                onChange={(event) => void session?.switchVideoDevice(event.target.value)}
                defaultValue=""
              >
                <option value="" disabled>
                  Select camera
                </option>
                {videoInputs.map((device) => (
                  <option key={device.deviceId} value={device.deviceId}>
                    {device.label || "Camera"}
                  </option>
                ))}
              </select>
            </label>
          </div>
        )}
        {pstnOpen && (
          <form
            className="call-panel"
            onSubmit={(event) => {
              event.preventDefault();
              void dialPstn();
            }}
          >
            <label>
              Dial (E.164)
              <input
                value={pstnNumber}
                onChange={(event) => setPstnNumber(event.target.value)}
                placeholder="+15551234567"
                inputMode="tel"
              />
            </label>
            <button type="submit">Call</button>
          </form>
        )}
        {chatOpen && (
          <div className="call-chat">
            <div className="call-chat-log">
              {chatLines.length === 0 && <span>In-call chat is only visible to people on this call.</span>}
              {chatLines.map((line) => (
                <p key={line.id}>
                  <strong>{nameById.get(String(line.userId)) ?? "Someone"}</strong> {line.text}
                </p>
              ))}
            </div>
            <form
              onSubmit={(event) => {
                event.preventDefault();
                sendChat();
              }}
            >
              <input
                value={chatDraft}
                onChange={(event) => setChatDraft(event.target.value)}
                placeholder="Message the call…"
                maxLength={280}
              />
            </form>
          </div>
        )}
      </div>
    </div>
  );
}
