const { useEffect, useMemo, useRef, useState } = React;

const API_BASE = "";
const RT = {
  MessageCreated: "message.created",
  MessageUpdated: "message.updated",
  MessageDeleted: "message.deleted",
  UnreadChanged: "unread.changed",
  ChannelListUpdated: "channelList.updated",
  ServerListUpdated: "serverList.updated",
  DmCreated: "dm.created",
  VoicePresenceUpdated: "voice.presence"
};

function App() {
  const [mode, setMode] = useState("login");
  const [token, setToken] = useState(localStorage.getItem("sloncord_token") || "");
  const [profile, setProfile] = useState(null);

  const [uiMode, setUiMode] = useState("server"); // "server" | "dm"

  const [servers, setServers] = useState([]);
  const [selectedServerId, setSelectedServerId] = useState("");
  const [dmChannels, setDmChannels] = useState([]);
  const [newServerName, setNewServerName] = useState("");
  const [newServerDescription, setNewServerDescription] = useState("");
  const [showCreateServer, setShowCreateServer] = useState(false);
  const channelActionLock = useRef(false);

  const [selectedChannelId, setSelectedChannelId] = useState("");
  const [messages, setMessages] = useState([]);
  const [members, setMembers] = useState([]);

  const [newMessage, setNewMessage] = useState("");
  const [newChannelName, setNewChannelName] = useState("");
  const [newVoiceChannelName, setNewVoiceChannelName] = useState("");
  const [showCreateTextChannel, setShowCreateTextChannel] = useState(false);
  const [showCreateVoiceChannel, setShowCreateVoiceChannel] = useState(false);
  const [activeVoiceChannelId, setActiveVoiceChannelId] = useState("");
  const [voicePeerNames, setVoicePeerNames] = useState({});
  const [inviteNickname, setInviteNickname] = useState("");
  const [userProfiles, setUserProfiles] = useState({}); // userId -> { nickname, avatarFileId }
  const [avatarUrlByFileId, setAvatarUrlByFileId] = useState({}); // fileId -> dataUrl
  const [userAvatarUrlByUserId, setUserAvatarUrlByUserId] = useState({}); // userId -> dataUrl
  const [serverAvatarUrlByServerId, setServerAvatarUrlByServerId] = useState({}); // serverId -> dataUrl
  const [serverMenuOpen, setServerMenuOpen] = useState(false);
  const [showRenameServer, setShowRenameServer] = useState(false);
  const [renameServerForm, setRenameServerForm] = useState({ name: "", description: "" });

  const [status, setStatus] = useState("");
  const [error, setError] = useState("");
  const [authForm, setAuthForm] = useState({ login: "", password: "", nickname: "" });
  const [editForm, setEditForm] = useState({ nickname: "", bio: "" });
  const [showEditProfile, setShowEditProfile] = useState(false);

  const [voiceState, setVoiceState] = useState({
    connected: false,
    joining: false,
    mediaLinkReady: false,
    sharingScreen: false,
    speakingUserIds: [],
    screenShareUserIds: [],
    room: "",
    peers: 0,
    muted: false,
    deafened: false,
    remotePeerUserIds: [],
    rosterUserIds: []
  });
  const [screenView, setScreenView] = useState({ open: false, peerId: "", mode: "full" }); // mode: full | window
  const screenVideoRef = useRef(null);
  const voicePresenceFetched = useRef(new Set());
  const [voicePresenceByChannelId, setVoicePresenceByChannelId] = useState({});
  const voiceRef = useRef(null);
  const connectVoiceInFlight = useRef(false);
  const channelsRef = useRef([]);
  const voiceNamesFetched = useRef(new Set());

  const remoteAudioHostRef = useRef(null);
  const remoteVideoHostRef = useRef(null);
  const hubRef = useRef(null);

  const [editing, setEditing] = useState({ id: "", text: "" });

  const [isNarrow, setIsNarrow] = useState(() => typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches);
  /** На телефонах: список чатов или открытое обсуждение */
  const [mobileView, setMobileView] = useState("list");
  const [membersSheet, setMembersSheet] = useState(false);

  const selectedChannelIdRef = useRef("");
  const uiModeRef = useRef("server");
  const profileRef = useRef(null);
  const dmChannelsRef = useRef([]);
  selectedChannelIdRef.current = selectedChannelId;
  uiModeRef.current = uiMode;
  profileRef.current = profile;
  dmChannelsRef.current = dmChannels;

  const channels = useMemo(() => {
    if (uiMode === "dm") return [];
    const s = servers.find((x) => String(x.id) === String(selectedServerId));
    return s?.channels || [];
  }, [servers, selectedServerId, uiMode]);

  const selectedServer = useMemo(
    () => servers.find((s) => String(s.id) === String(selectedServerId)) || null,
    [servers, selectedServerId]
  );

  const activeVoiceInfo = useMemo(() => {
    const vid = String(activeVoiceChannelId || "");
    if (!vid) return null;
    for (const s of servers || []) {
      const ch = (s.channels || []).find((c) => String(c.id) === vid);
      if (ch) return { server: s, channel: ch };
    }
    return null;
  }, [servers, activeVoiceChannelId]);

  const textChannels = useMemo(
    () => channels.filter((c) => c.kind === "public" || c.kind === "text"),
    [channels]
  );
  const voiceChannels = useMemo(() => channels.filter((c) => c.kind === "voice"), [channels]);

  const selectedChannel = useMemo(() => {
    if (uiMode === "dm")
      return dmChannels.find((c) => c.id === selectedChannelId) || null;
    const c = channels.find((c) => c.id === selectedChannelId);
    if (!c) return null;
    if (c.kind === "voice") return null;
    return c;
  }, [channels, dmChannels, selectedChannelId, uiMode]);

  channelsRef.current = channels;

  function clearAlerts() {
    setStatus("");
    setError("");
  }

  useEffect(() => {
    if (!status && !error) return undefined;
    const t = setTimeout(() => {
      setStatus("");
      setError("");
    }, 3000);
    return () => clearTimeout(t);
  }, [status, error]);

  async function api(path, options = {}) {
    const headers = { "Content-Type": "application/json", ...(options.headers || {}) };
    if (token) headers.Authorization = `Bearer ${token}`;
    const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
    const text = await response.text();
    let data = null;
    if (text) {
      try {
        data = JSON.parse(text);
      } catch {
        data = text;
      }
    }
    if (!response.ok) {
      const message = data?.error || data?.title || `Ошибка ${response.status}`;
      throw new Error(message);
    }
    return data;
  }

  async function ensureAvatarUrl(fileId) {
    const fid = String(fileId || "");
    if (!fid) return "";
    if (avatarUrlByFileId[fid]) return avatarUrlByFileId[fid];
    try {
      const data = await api(`/avatars/${fid}`, { method: "GET" });
      const ct = data?.contentType || "image/png";
      const b64 = data?.fileBase64 || "";
      if (!b64) return "";
      const url = `data:${ct};base64,${b64}`;
      setAvatarUrlByFileId((prev) => ({ ...prev, [fid]: url }));
      return url;
    } catch {
      return "";
    }
  }

  async function ensureUserProfile(userId) {
    const id = String(userId || "");
    if (!id) return null;
    if (userProfiles[id]) return userProfiles[id];
    try {
      const p = await api(`/profile/${id}`, { method: "GET" });
      const up = { nickname: p.nickname || id, avatarFileId: p.avatarFileId || "" };
      setUserProfiles((prev) => ({ ...prev, [id]: up }));
      setVoicePeerNames((prev) => ({ ...prev, [id]: up.nickname }));
      if (up.avatarFileId) {
        const url = await ensureAvatarUrl(up.avatarFileId);
        if (url) setUserAvatarUrlByUserId((prev) => ({ ...prev, [id]: url }));
      }
      return up;
    } catch {
      return null;
    }
  }

  // use global RT constants (top of file)

  function patchUnreadState(channelId, unread) {
    const id = String(channelId);
    const n = Number(unread) || 0;
    setServers((prev) =>
      prev.map((s) => ({
        ...s,
        channels: (s.channels || []).map((c) => (String(c.id) === id ? { ...c, unreadCount: n } : c))
      }))
    );
    setDmChannels((prev) => prev.map((c) => (String(c.id) === id ? { ...c, unreadCount: n } : c)));
  }

  function showLocalNotification(title, body) {
    try {
      if (!("Notification" in window)) return;
      if (Notification.permission !== "granted") return;
      // eslint-disable-next-line no-new
      new Notification(title, { body, silent: false });
    } catch {
      // ignore
    }
  }

  async function enablePushNotifications() {
    try {
      if (!("serviceWorker" in navigator) || !("PushManager" in navigator)) {
        return;
      }

      if (!window.isSecureContext) {
        return;
      }

      const perm = await Notification.requestPermission();
      if (perm !== "granted") {
        return;
      }

      await navigator.serviceWorker.register("/push-sw.js");

      const { publicKey } = await api("/push/vapid-public-key", { method: "GET" });
      if (!publicKey) {
        setError("Сервер не отдал VAPID public key");
        return;
      }

      const reg = await navigator.serviceWorker.ready;
      let sub = await reg.pushManager.getSubscription();
      if (!sub) {
        sub = await reg.pushManager.subscribe({
          userVisibleOnly: true,
          applicationServerKey: urlBase64ToUint8Array(publicKey)
        });
      }

      const json = sub.toJSON();
      await api("/push/subscribe", {
        method: "POST",
        body: JSON.stringify({
          endpoint: json.endpoint,
          p256dh: json.keys?.p256dh,
          auth: json.keys?.auth
        })
      });
    } catch (e) {
      // ignore
    }
  }

  async function loadInitial() {
    if (!token) return;
    try {
      const me = await api("/profile", { method: "GET" });
      setProfile(me);
      setEditForm({ nickname: me.nickname || "", bio: me.bio || "" });

      let list = (await api("/servers", { method: "GET" })) || [];

      const dms = await api("/dm/channels", { method: "GET" });
      setDmChannels(dms || []);

      const params = new URLSearchParams(window.location.search);
      const inviteCode = params.get("invite");
      if (inviteCode?.trim()) {
        try {
          await api("/servers/join", {
            method: "POST",
            body: JSON.stringify({ inviteCode: inviteCode.trim() })
          });
          const next = new URLSearchParams(window.location.search);
          next.delete("invite");
          const url = `${window.location.pathname}${next.toString() ? `?${next}` : ""}`;
          window.history.replaceState({}, "", url);
          list = (await api("/servers", { method: "GET" })) || [];
          setStatus("Вы присоединились к серверу");
        } catch (err) {
          setError(err.message || String(err));
        }
      }

      setServers(list);

      if (uiMode === "server" && list.length) {
        setSelectedServerId(list[0].id);
        const firstText = list[0].channels?.find((c) => c.kind === "public" || c.kind === "text");
        if (firstText) setSelectedChannelId(firstText.id);
      } else if (uiMode === "dm" && dms?.length) {
        setSelectedChannelId(dms[0].id);
      }
    } catch (e) {
      setError(e.message);
      logout();
    }
  }

  useEffect(() => {
    loadInitial();
  }, [token]);

  const pushInitTried = useRef(false);
  useEffect(() => {
    if (!token) return;
    if (pushInitTried.current) return;
    pushInitTried.current = true;

    if (!("serviceWorker" in navigator) || !("PushManager" in navigator)) {
      setError("Web Push не поддерживается этим браузером. Откройте сайт в Chrome/Safari или установите как PWA.");
      return;
    }
    if (!window.isSecureContext) {
      setError("Web Push требует HTTPS (кроме localhost).");
      return;
    }
    enablePushNotifications().catch(() => {});
  }, [token]);

  const voiceRestoreTried = useRef(false);
  useEffect(() => {
    if (!token) return;
    if (voiceRestoreTried.current) return;
    if (voiceState.connected || voiceState.joining) return;
    if (!servers || servers.length === 0) return;
    let last = "";
    try {
      last = localStorage.getItem("sloncord_last_voice_channel_id") || "";
    } catch {
      last = "";
    }
    if (!last) {
      voiceRestoreTried.current = true;
      return;
    }
    // Try reconnect once after reload.
    voiceRestoreTried.current = true;
    connectVoiceToChannel(last).catch(() => {});
  }, [token, servers, voiceState.connected, voiceState.joining]);

  useEffect(() => {
    if (!token) return undefined;
    if (!window.signalR) {
      setError("Не удалось загрузить SignalR (проверьте сеть/CDN)");
      return undefined;
    }

    const signalR = window.signalR;
    const url = `${API_BASE}/hubs/sloncord?access_token=${encodeURIComponent(token)}`;
    const connection = new signalR.HubConnectionBuilder()
      .withUrl(url, { withCredentials: false })
      .withAutomaticReconnect()
      .configureLogging(signalR.LogLevel.Warning)
      .build();
    hubRef.current = connection;

    connection.on(RT.MessageCreated, (payload) => {
      const chId = payload?.channelId;
      const msg = payload?.message;
      if (!chId || !msg) return;
      const me = profileRef.current;
      if (String(chId) === String(selectedChannelIdRef.current)) {
        setMessages((prev) => {
          if (prev.some((m) => String(m.id) === String(msg.id))) return prev;
          return [...prev, msg];
        });
        if (String(msg.senderUserId) !== String(me?.id) && document.hidden) {
          const title = uiModeRef.current === "dm" ? `DM: ${msg.senderNickname}` : `Канал`;
          const body = `${msg.senderNickname}: ${msg.text || "[файл]"}`;
          showLocalNotification(title, body);
        }
      } else if (String(msg.senderUserId) !== String(me?.id) && document.hidden) {
        const title = "Sloncord";
        const body = `${msg.senderNickname}: ${msg.text || "[файл]"}`;
        showLocalNotification(title, body);
      }
    });

    connection.on(RT.MessageUpdated, (payload) => {
      const chId = payload?.channelId;
      const msg = payload?.message;
      if (!chId || !msg) return;
      if (String(chId) !== String(selectedChannelIdRef.current)) return;
      setMessages((prev) => prev.map((m) => (String(m.id) === String(msg.id) ? msg : m)));
    });

    connection.on(RT.MessageDeleted, (payload) => {
      const chId = payload?.channelId;
      const messageId = payload?.messageId;
      if (!chId || !messageId) return;
      if (String(chId) !== String(selectedChannelIdRef.current)) return;
      setMessages((prev) => prev.filter((m) => String(m.id) !== String(messageId)));
    });

    connection.on(RT.UnreadChanged, (payload) => {
      const chId = payload?.channelId;
      if (chId == null) return;
      patchUnreadState(chId, payload.unread);
    });

    connection.on(RT.ChannelListUpdated, (payload) => {
      const kind = payload?.kind;
      const list = payload?.channels;
      if (!kind || !Array.isArray(list)) return;
      if (kind === "dm") setDmChannels(list);
      (async () => {
        try {
          if (connection.state === signalR.HubConnectionState.Connected) {
            await connection.invoke("ResyncGroups");
          }
        } catch {
          // ignore
        }
      })();
    });

    connection.on(RT.ServerListUpdated, (payload) => {
      const list = payload?.servers;
      if (!Array.isArray(list)) return;
      setServers(list);
      (async () => {
        try {
          if (connection.state === signalR.HubConnectionState.Connected) {
            await connection.invoke("ResyncGroups");
          }
        } catch {
          // ignore
        }
      })();
    });

    connection.on(RT.VoicePresenceUpdated, (payload) => {
      const chId = payload?.channelId;
      if (!chId) return;
      setVoicePresenceByChannelId((prev) => ({
        ...prev,
        [String(chId)]: {
          channelId: String(chId),
          userIds: (payload.userIds || []).map((x) => String(x)),
          screenShareUserIds: (payload.screenShareUserIds || []).map((x) => String(x))
        }
      }));
    });

    connection.on(RT.DmCreated, (payload) => {
      const ch = payload?.channel;
      if (!ch) return;
      setDmChannels((prev) => {
        const next = [ch, ...prev.filter((c) => String(c.id) !== String(ch.id))];
        return next;
      });
      setStatus("Новый личный чат");
    });

    connection.onreconnected(async () => {
      try {
        await connection.invoke("ResyncGroups");
      } catch {
        // ignore
      }
    });

    (async () => {
      try {
        await connection.start();
        await connection.invoke("ResyncGroups");
      } catch (e) {
        setError(e.message || String(e));
      }
    })();

    return () => {
      (async () => {
        try {
          await connection.stop();
        } catch {
          // ignore
        }
      })();
    };
  }, [token]);

  useEffect(() => {
    if (!token) return undefined;
    return () => {
      voiceRef.current?.destroy();
      voiceRef.current = null;
    };
  }, [token]);

  useEffect(() => {
    const mq = window.matchMedia("(max-width: 900px)");
    const onChange = () => {
      setIsNarrow(mq.matches);
      if (!mq.matches) {
        setMobileView("list");
        setMembersSheet(false);
      }
    };
    mq.addEventListener("change", onChange);
    onChange();
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(() => {
    if (!membersSheet) return undefined;
    const onKey = (e) => {
      if (e.key === "Escape") setMembersSheet(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [membersSheet]);

  useEffect(() => {
    if (!selectedChannelId || !token) return undefined;
    if (uiMode === "server") {
      const ch = channelsRef.current.find((c) => String(c.id) === String(selectedChannelId));
      if (ch?.kind === "voice") return undefined;
    }

    (async () => {
      try {
        if (uiMode === "server") {
          await api(`/channels/${selectedChannelId}/read`, { method: "POST" });
        } else {
          await api(`/dm/${selectedChannelId}/read`, { method: "POST" });
        }
      } catch {
        // ignore
      } finally {
        patchUnreadState(selectedChannelId, 0);
        await refreshConversationData(selectedChannelId, uiMode);
      }
    })();
  }, [selectedChannelId, token, uiMode, profile?.id]);

  useEffect(() => {
    if (!token) return;
    const raw = (voiceState.rosterUserIds && voiceState.rosterUserIds.length > 0)
      ? voiceState.rosterUserIds
      : voiceState.remotePeerUserIds;
    if (!raw?.length) return;
    (async () => {
      for (const rid of raw) {
        const id = String(rid);
        if (id === String(profileRef.current?.id)) continue;
        if (voiceNamesFetched.current.has(id)) continue;
        voiceNamesFetched.current.add(id);
        await ensureUserProfile(id);
      }
    })();
  }, [token, voiceState.rosterUserIds, voiceState.remotePeerUserIds]);

  useEffect(() => {
    if (!token) return;
    const ids = new Set();
    Object.values(voicePresenceByChannelId || {}).forEach((p) => {
      (p?.userIds || []).forEach((id) => ids.add(String(id)));
      (p?.screenShareUserIds || []).forEach((id) => ids.add(String(id)));
    });
    if (ids.size === 0) return;
    (async () => {
      for (const id of Array.from(ids)) {
        if (!id) continue;
        if (id === String(profileRef.current?.id)) continue;
        if (voiceNamesFetched.current.has(id)) continue;
        voiceNamesFetched.current.add(id);
        await ensureUserProfile(id);
      }
    })();
  }, [token, voicePresenceByChannelId]);

  useEffect(() => {
    if (!token) return;
    // Fetch presence for voice channels so the list is visible even when not connected.
    (async () => {
      for (const ch of voiceChannels) {
        const id = String(ch.id);
        if (!id) continue;
        if (voicePresenceFetched.current.has(id)) continue;
        voicePresenceFetched.current.add(id);
        try {
          const p = await api(`/channels/${id}/voice/presence`, { method: "GET" });
          setVoicePresenceByChannelId((prev) => ({ ...prev, [id]: p }));
        } catch {
          // ignore
        }
      }
    })();
  }, [token, voiceChannels]);

  useEffect(() => {
    if (!token) return;
    // Preload server avatars
    (async () => {
      for (const s of servers || []) {
        const sid = String(s.id);
        const fid = String(s.avatarFileId || "");
        if (!sid || !fid) continue;
        if (serverAvatarUrlByServerId[sid]) continue;
        const url = await ensureAvatarUrl(fid);
        if (url) setServerAvatarUrlByServerId((prev) => ({ ...prev, [sid]: url }));
      }
    })();
  }, [token, servers]);

  useEffect(() => {
    if (!token) return;
    // Preload avatars used in the currently loaded message list
    (async () => {
      for (const msg of messages || []) {
        const fid = String(msg?.senderAvatarFileId || "");
        if (!fid) continue;
        if (avatarUrlByFileId[fid]) continue;
        await ensureAvatarUrl(fid);
      }
    })();
  }, [token, messages]);

  useEffect(() => {
    if (!serverMenuOpen) return undefined;
    function onKey(e) {
      if (e.key === "Escape") setServerMenuOpen(false);
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [serverMenuOpen]);

  async function refreshConversationData(channelId, mode) {
    try {
      if (mode === "server") {
        const mch = channelsRef.current.find((c) => String(c.id) === String(channelId));
        if (mch?.kind === "voice") {
          setMessages([]);
          setMembers([]);
          return;
        }
        const msgs = await api(`/channels/${channelId}/messages`, { method: "GET" });
        setMessages(msgs || []);
        const users = await api(`/channels/${channelId}/members`, { method: "GET" });
        setMembers(users || []);
      } else {
        const msgs = await api(`/dm/${channelId}/messages`, { method: "GET" });
        setMessages(msgs || []);

        const ch = dmChannelsRef.current.find((c) => c.id === channelId);
        const meId = profileRef.current?.id;
        const otherId = ch?.memberUserIds?.find((id) => String(id) !== String(meId));
        if (!otherId) {
          setMembers([]);
        } else {
          try {
            const other = await api(`/profile/${otherId}`, { method: "GET" });
            setMembers(other ? [other] : []);
          } catch {
            setMembers([]);
          }
        }
      }
    } catch (e) {
      setError(e.message);
    }
  }

  async function register() {
    clearAlerts();
    try {
      await api("/auth/register", {
        method: "POST",
        body: JSON.stringify({
          login: authForm.login,
          password: authForm.password,
          nickname: authForm.nickname
        })
      });
      setStatus("Аккаунт создан. Теперь войдите.");
      setMode("login");
    } catch (e) {
      setError(e.message);
    }
  }

  async function login() {
    clearAlerts();
    try {
      const data = await api("/auth/login", {
        method: "POST",
        body: JSON.stringify({ login: authForm.login, password: authForm.password })
      });
      setToken(data.token);
      localStorage.setItem("sloncord_token", data.token);
      setStatus("Вход выполнен");
    } catch (e) {
      setError(e.message);
    }
  }

  function logout() {
    voiceRef.current?.destroy();
    voiceRef.current = null;
    try {
      hubRef.current?.stop();
    } catch {
      // ignore
    }
    hubRef.current = null;

    setToken("");
    setProfile(null);
    setServers([]);
    setSelectedServerId("");
    setDmChannels([]);
    setSelectedChannelId("");
    setActiveVoiceChannelId("");
    setVoicePeerNames({});
    voiceNamesFetched.current.clear();
    setMessages([]);
    setMembers([]);
    setUiMode("server");
    setVoiceState({
      connected: false,
      joining: false,
      mediaLinkReady: false,
      room: "",
      peers: 0,
      muted: false,
      deafened: false,
      remotePeerUserIds: [],
      rosterUserIds: []
    });
    localStorage.removeItem("sloncord_token");
  }

  async function createNewServer() {
    clearAlerts();
    if (!newServerName.trim() || channelActionLock.current) return;
    channelActionLock.current = true;
    try {
      const created = await api("/servers", {
        method: "POST",
        body: JSON.stringify({ name: newServerName.trim(), description: (newServerDescription || "").trim() })
      });
      const list = (await api("/servers", { method: "GET" })) || [];
      setServers(list);
      setNewServerName("");
      setNewServerDescription("");
      setShowCreateServer(false);
      if (created?.id) {
        setSelectedServerId(created.id);
        const t = (created.channels || []).find((c) => c.kind === "public" || c.kind === "text");
        if (t) setSelectedChannelId(t.id);
      }
      setUiMode("server");
      if (window.matchMedia("(max-width: 900px)").matches) setMobileView("chat");
      setStatus("Сервер создан");
    } catch (e) {
      setError(e.message);
    } finally {
      channelActionLock.current = false;
    }
  }

  async function deleteServer(serverId) {
    clearAlerts();
    try {
      if (!token) return;
      await api(`/servers/${serverId}`, { method: "DELETE" });
      const list = (await api("/servers", { method: "GET" })) || [];
      setServers(list);
      if (String(selectedServerId) === String(serverId)) {
        setSelectedServerId(list[0]?.id || "");
        const ft = (list[0]?.channels || []).find((c) => c.kind === "public" || c.kind === "text");
        setSelectedChannelId(ft?.id || "");
      }
      setStatus("Сервер удален");
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  async function leaveServer(serverId) {
    clearAlerts();
    try {
      if (!token) return;
      await api(`/servers/${serverId}/leave`, { method: "POST" });
      const list = (await api("/servers", { method: "GET" })) || [];
      setServers(list);
      if (String(selectedServerId) === String(serverId)) {
        setSelectedServerId(list[0]?.id || "");
        const ft = (list[0]?.channels || []).find((c) => c.kind === "public" || c.kind === "text");
        setSelectedChannelId(ft?.id || "");
      }
      setStatus("Вы покинули сервер");
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  async function createChannel(nameOverride) {
    clearAlerts();
    const name = (nameOverride ?? newChannelName).trim();
    if (!name || !selectedServerId || channelActionLock.current) return;
    channelActionLock.current = true;
    try {
      await api(`/servers/${selectedServerId}/channels`, {
        method: "POST",
        body: JSON.stringify({ name, type: "text" })
      });
      const list = (await api("/servers", { method: "GET" })) || [];
      setServers(list);
      setNewChannelName("");
      setShowCreateTextChannel(false);
      setUiMode("server");
      if (window.matchMedia("(max-width: 900px)").matches) setMobileView("chat");
      setStatus("Канал создан");
    } catch (e) {
      setError(e.message);
    } finally {
      channelActionLock.current = false;
    }
  }

  async function createVoiceChannel(nameOverride) {
    clearAlerts();
    const name = (nameOverride ?? newVoiceChannelName).trim();
    if (!name || !selectedServerId || channelActionLock.current) return;
    channelActionLock.current = true;
    try {
      await api(`/servers/${selectedServerId}/channels`, {
        method: "POST",
        body: JSON.stringify({ name, type: "voice" })
      });
      const list = (await api("/servers", { method: "GET" })) || [];
      setServers(list);
      setNewVoiceChannelName("");
      setShowCreateVoiceChannel(false);
      setUiMode("server");
      setStatus("Голосовой канал создан");
    } catch (e) {
      setError(e.message);
    } finally {
      channelActionLock.current = false;
    }
  }

  function copyServerInvite() {
    clearAlerts();
    if (!selectedServer?.inviteCode) {
      setError("Нет кода приглашения");
      return;
    }
    const u = `${window.location.origin}${window.location.pathname}?invite=${encodeURIComponent(selectedServer.inviteCode)}`;
    try {
      void navigator.clipboard.writeText(u);
      setStatus("Ссылка приглашения скопирована");
    } catch {
      setError("Не удалось скопировать ссылку");
    }
  }

  async function uploadServerAvatar(serverId, file) {
    clearAlerts();
    try {
      if (!token) return;
      if (!file) return;
      const base64 = await fileToBase64(file);
      const res = await api(`/servers/${serverId}/avatar`, {
        method: "PUT",
        body: JSON.stringify({
          fileBase64: base64,
          fileName: file.name || "server.png",
          contentType: file.type || "image/png"
        })
      });
      const fid = res?.avatarFileId || "";
      if (fid) {
        const url = await ensureAvatarUrl(fid);
        if (url) setServerAvatarUrlByServerId((prev) => ({ ...prev, [String(serverId)]: url }));
      }
      setStatus("Аватар сервера обновлен");
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  async function saveRenameServer() {
    clearAlerts();
    try {
      if (!selectedServer) return;
      await api(`/servers/${selectedServer.id}`, {
        method: "PUT",
        body: JSON.stringify({
          name: renameServerForm.name,
          description: renameServerForm.description
        })
      });
      setShowRenameServer(false);
      setServerMenuOpen(false);
      setStatus("Сервер обновлен");
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  async function startDmByNickname() {
    clearAlerts();
    if (!inviteNickname.trim()) return;
    try {
      const dm = await api("/dm/start-by-nickname", {
        method: "POST",
        body: JSON.stringify({ nickname: inviteNickname })
      });
      setInviteNickname("");

      const next = [...dmChannels.filter((c) => c.id !== dm.id), dm];
      setDmChannels(next);
      setUiMode("dm");
      setSelectedChannelId(dm.id);
      if (window.matchMedia("(max-width: 900px)").matches) setMobileView("chat");
      setStatus("DM открыт");
    } catch (e) {
      setError(e.message);
    }
  }

  async function sendTextMessage() {
    clearAlerts();
    if (!selectedChannel || !newMessage.trim()) return;
    try {
      if (uiMode === "server") {
        await api(`/channels/${selectedChannelId}/messages`, {
          method: "POST",
          body: JSON.stringify({ text: newMessage })
        });
      } else {
        await api(`/dm/${selectedChannelId}/messages`, {
          method: "POST",
          body: JSON.stringify({ text: newMessage })
        });
      }
      setNewMessage("");
      await refreshConversationData(selectedChannelId, uiMode);
    } catch (e) {
      setError(e.message);
    }
  }

  async function sendFile(file) {
    clearAlerts();
    if (!selectedChannel || !file) return;

    try {
      const base64 = await fileToBase64(file);
      if (uiMode === "server") {
        await api(`/channels/${selectedChannelId}/messages`, {
          method: "POST",
          body: JSON.stringify({
            text: "",
            fileBase64: base64,
            fileName: file.name,
            contentType: file.type || "application/octet-stream"
          })
        });
      } else {
        await api(`/dm/${selectedChannelId}/messages`, {
          method: "POST",
          body: JSON.stringify({
            text: "",
            fileBase64: base64,
            fileName: file.name,
            contentType: file.type || "application/octet-stream"
          })
        });
      }
      await refreshConversationData(selectedChannelId, uiMode);
      setStatus(`Файл ${file.name} отправлен`);
    } catch (e) {
      setError(e.message);
    }
  }

  async function downloadFile(fileId, originalName) {
    clearAlerts();
    try {
      const data = await api(`/files/${fileId}`, { method: "GET" });
      const bytes = atob(data.fileBase64);
      const arr = new Uint8Array(bytes.length);
      for (let i = 0; i < bytes.length; i += 1) {
        arr[i] = bytes.charCodeAt(i);
      }
      const blob = new Blob([arr], { type: data.file.contentType || "application/octet-stream" });
      const url = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = originalName || "file.bin";
      link.click();
      URL.revokeObjectURL(url);
    } catch (e) {
      setError(e.message);
    }
  }

  function startEditMessage(msg) {
    setEditing({ id: msg.id, text: msg.text || "" });
  }

  async function saveEditMessage() {
    if (!editing.id || !selectedChannel) return;
    clearAlerts();
    try {
      const path =
        uiMode === "server"
          ? `/channels/${selectedChannelId}/messages/${editing.id}`
          : `/dm/${selectedChannelId}/messages/${editing.id}`;
      await api(path, { method: "PUT", body: JSON.stringify({ text: editing.text }) });
      setEditing({ id: "", text: "" });
    } catch (e) {
      setError(e.message);
    }
  }

  async function deleteMessage(msgId) {
    if (!selectedChannel) return;
    clearAlerts();
    try {
      const path =
        uiMode === "server"
          ? `/channels/${selectedChannelId}/messages/${msgId}`
          : `/dm/${selectedChannelId}/messages/${msgId}`;
      await api(path, { method: "DELETE" });
    } catch (e) {
      setError(e.message);
    }
  }

  async function saveProfile() {
    clearAlerts();
    try {
      const updated = await api("/profile", {
        method: "PUT",
        body: JSON.stringify({ nickname: editForm.nickname, bio: editForm.bio })
      });
      setProfile(updated);
      setShowEditProfile(false);
      setStatus("Профиль обновлен");
    } catch (e) {
      setError(e.message);
    }
  }

  async function uploadMyAvatar(file) {
    clearAlerts();
    try {
      if (!token) return;
      if (!file) return;
      const base64 = await fileToBase64(file);
      const updated = await api("/profile/avatar", {
        method: "PUT",
        body: JSON.stringify({
          fileBase64: base64,
          fileName: file.name || "avatar.png",
          contentType: file.type || "image/png"
        })
      });
      setProfile(updated);
      setStatus("Аватар обновлен");
      if (updated?.avatarFileId) {
        const url = await ensureAvatarUrl(updated.avatarFileId);
        if (url) setUserAvatarUrlByUserId((prev) => ({ ...prev, [String(updated.id)]: url }));
      }
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  function destroyVoiceInstance() {
    try {
      voiceRef.current?.destroy();
    } catch {
      // ignore
    }
    voiceRef.current = null;
  }

  async function connectVoiceToChannel(voiceChannelId) {
    if (connectVoiceInFlight.current) {
      return;
    }
    clearAlerts();
    connectVoiceInFlight.current = true;
    try {
      if (!token) return;
      if (!voiceChannelId) return;

      if (!window.isSecureContext) {
        setError("Голосовой чат в браузере требует HTTPS (или localhost). Откройте сайт по https://…");
        return;
      }
      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
        setError("Этот браузер/контекст не даёт доступ к микрофону (нужен HTTPS/localhost и поддержка WebRTC).");
        return;
      }

      const me = profile?.id ? profile : await api("/profile", { method: "GET" });
      setProfile(me);

      setActiveVoiceChannelId(String(voiceChannelId));
      voiceNamesFetched.current.clear();
      setVoicePeerNames({});

      destroyVoiceInstance();
      setVoiceState({
        connected: false,
        joining: true,
        mediaLinkReady: false,
        room: "",
        peers: 0,
        muted: false,
        deafened: false,
        remotePeerUserIds: [],
        rosterUserIds: []
      });

      const host = remoteAudioHostRef.current;
      const videoHost = remoteVideoHostRef.current;
      const session = createVoiceSession({
        token,
        roomId: `channel:${voiceChannelId}`,
        selfUserId: me.id,
        remoteAudioHost: host,
        remoteVideoHost: videoHost,
        onState: setVoiceState
      });
      voiceRef.current = session;
      await session.join();
      try {
        localStorage.setItem("sloncord_last_voice_channel_id", String(voiceChannelId));
      } catch {
        // ignore
      }
      setStatus("В голосовом канале. Сигнальные обмены идут в фоне.");
    } catch (e) {
      setActiveVoiceChannelId("");
      setError(e.message || String(e));
      setVoiceState((prev) => ({
        ...prev,
        connected: false,
        joining: false,
        mediaLinkReady: false
      }));
    } finally {
      connectVoiceInFlight.current = false;
    }
  }

  function leaveVoice() {
    clearAlerts();
    try {
      destroyVoiceInstance();
      try {
        localStorage.removeItem("sloncord_last_voice_channel_id");
      } catch {
        // ignore
      }
      setActiveVoiceChannelId("");
      voiceNamesFetched.current.clear();
      setVoicePeerNames({});
      setVoiceState({
        connected: false,
        joining: false,
        mediaLinkReady: false,
        room: "",
        peers: 0,
        muted: false,
        deafened: false,
        remotePeerUserIds: [],
        rosterUserIds: []
      });
      setStatus("Голосовой чат отключен");
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  async function onVoiceChannelRowClick(voiceId) {
    if (voiceState.joining) {
      return;
    }
    if (String(activeVoiceChannelId) === String(voiceId) && voiceState.connected) {
      leaveVoice();
      return;
    }
    await connectVoiceToChannel(voiceId);
  }

  function toggleMic() {
    const s = voiceRef.current;
    if (!s) return;
    s.toggleMute();
  }

  function toggleDeafen() {
    const s = voiceRef.current;
    if (!s) return;
    s.toggleDeafen();
  }

  async function toggleScreenShare() {
    const s = voiceRef.current;
    if (!s) return;
    try {
      await s.toggleScreenShare();
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  async function deleteChannel(channelId) {
    clearAlerts();
    try {
      if (!token) return;
      await api(`/channels/${channelId}`, { method: "DELETE" });
      if (String(selectedChannelId) === String(channelId)) {
        setSelectedChannelId("");
        setMessages([]);
        setMembers([]);
      }
      setStatus("Канал удален");
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  async function leaveChannel(channelId) {
    clearAlerts();
    try {
      if (!token) return;
      await api(`/channels/${channelId}/leave`, { method: "POST" });
      if (String(selectedChannelId) === String(channelId)) {
        setSelectedChannelId("");
        setMessages([]);
        setMembers([]);
      }
      if (String(activeVoiceChannelId) === String(channelId)) {
        leaveVoice();
      }
      setStatus("Вы покинули канал");
    } catch (e) {
      setError(e.message || String(e));
    }
  }

  function renderTextWithLinks(text) {
    if (!text) return null;
    const s = String(text);
    const re = /(https?:\/\/[^\s<>()]+|www\.[^\s<>()]+)/gi;
    const parts = [];
    let last = 0;
    let m;
    while ((m = re.exec(s)) !== null) {
      const start = m.index;
      const end = start + m[0].length;
      if (start > last) parts.push(s.slice(last, start));
      const raw = m[0];
      const href = raw.startsWith("http") ? raw : `https://${raw}`;
      parts.push(
        <a key={`lnk-${start}-${end}`} href={href} target="_blank" rel="noreferrer">
          {raw}
        </a>
      );
      last = end;
    }
    if (last < s.length) parts.push(s.slice(last));
    return <span className="msg-text">{parts}</span>;
  }

  function openScreenView(peerId) {
    const pid = String(peerId);
    const safe = pid.replace(/[^a-f0-9-]/gi, "x");
    const el = document.getElementById(`remote-video-${safe}`);
    const stream = el?.srcObject;
    if (!stream) {
      setError("Демонстрация пока не доступна (нет видеопотока).");
      return;
    }
    setScreenView({ open: true, peerId: pid, mode: "full" });
    setTimeout(() => {
      const v = screenVideoRef.current;
      if (!v) return;
      v.srcObject = stream;
      v.play?.().catch(() => {});
    }, 0);
  }

  function closeScreenView() {
    setScreenView({ open: false, peerId: "", mode: "full" });
    const v = screenVideoRef.current;
    if (v) {
      try {
        v.pause?.();
      } catch {
        // ignore
      }
      try {
        v.srcObject = null;
      } catch {
        // ignore
      }
    }
  }

  function toggleScreenViewMode() {
    setScreenView((prev) => ({ ...prev, mode: prev.mode === "full" ? "window" : "full" }));
  }

  const dataUi = !isNarrow ? "desktop" : mobileView;
  const openMembersSheet = () => setMembersSheet(true);
  const closeMembersSheet = () => setMembersSheet(false);
  const selectTextChannel = (channelId) => {
    setSelectedChannelId(channelId);
    if (isNarrow) setMobileView("chat");
  };

  if (!token) {
    return (
      <div className="auth-wrap">
        <div className="auth-card">
          <h1>Sloncord</h1>
          <p className="muted">Веб-клиент в стиле Discord</p>
          <input
            placeholder="Login"
            value={authForm.login}
            onChange={(e) => setAuthForm({ ...authForm, login: e.target.value })}
          />
          <input
            type="password"
            placeholder="Password"
            value={authForm.password}
            onChange={(e) => setAuthForm({ ...authForm, password: e.target.value })}
          />
          {mode === "register" && (
            <input
              placeholder="Nickname"
              value={authForm.nickname}
              onChange={(e) => setAuthForm({ ...authForm, nickname: e.target.value })}
            />
          )}
          <div className="row">
            {mode === "login" ? (
              <>
                <button className="small-btn" onClick={login}>Войти</button>
                <button className="small-btn" onClick={() => setMode("register")}>Регистрация</button>
              </>
            ) : (
              <>
                <button className="small-btn" onClick={register}>Создать аккаунт</button>
                <button className="small-btn" onClick={() => setMode("login")}>Назад ко входу</button>
              </>
            )}
          </div>
          {status && <p className="success">{status}</p>}
          {error && <p className="danger">{error}</p>}
        </div>
      </div>
    );
  }

  return (
    <div className="app-shell">
      <div className={`layout${isNarrow ? " layout--narrow" : ""}`} data-ui={dataUi}>
      <div className="layout-nav" aria-label="Навигация">
      <aside className="guilds">
        <div
          className={`guild-icon ${uiMode === "dm" ? "active" : ""}`}
          title="Личные сообщения"
          onClick={() => {
            setUiMode("dm");
            if (dmChannels[0]?.id) selectTextChannel(dmChannels[0].id);
          }}
        >
          ✉
        </div>
        {servers.map((srv) => (
          <div
            key={srv.id}
            className={`guild-icon ${uiMode === "server" && String(selectedServerId) === String(srv.id) ? "active" : ""}`}
            title={srv.name}
            onClick={() => {
              setUiMode("server");
              setSelectedServerId(srv.id);
              const ft = (srv.channels || []).find((c) => c.kind === "public" || c.kind === "text");
              if (ft?.id) selectTextChannel(ft.id);
            }}
          >
            {serverAvatarUrlByServerId[String(srv.id)] ? (
              <img alt="" src={serverAvatarUrlByServerId[String(srv.id)]} className="guild-icon-img" />
            ) : (
              (srv.name || "?").trim().slice(0, 1).toUpperCase()
            )}
            {Number(srv.unreadCount) > 0 && (
              <span className="guild-badge" aria-label="Непрочитанные сообщения">
                {Number(srv.unreadCount) > 99 ? "99+" : srv.unreadCount}
              </span>
            )}
          </div>
        ))}
        <div
          className="guild-icon guild-icon-add"
          title="Новый сервер"
          onClick={() => {
            setUiMode("server");
            setShowCreateServer(true);
          }}
        >
          +
        </div>
      </aside>

      <aside className="channels">
        {uiMode === "dm" ? (
          <div className="channels-body">
            <div className="panel-header">Личные сообщения</div>
            <div className="channels-list">
              {dmChannels.map((channel) => (
                <div
                  key={channel.id}
                  className={`channel-item ${selectedChannelId === channel.id ? "active" : ""}`}
                  onClick={() => selectTextChannel(channel.id)}
                >
                  <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                    @ {channel.name}
                  </span>
                  {Number(channel.unreadCount) > 0 && (
                    <span className="unread-badge">{Number(channel.unreadCount) > 99 ? "99+" : channel.unreadCount}</span>
                  )}
                </div>
              ))}
            </div>
          </div>
        ) : (
          <div className="channels-body">
            {selectedServer && (
              <div className="server-top">
                <div className="server-top-row">
                  <div className="server-top-title">{selectedServer.name}</div>
                  <button
                    type="button"
                    className="server-top-menu-btn"
                    aria-label="Меню сервера"
                    title="Меню сервера"
                    onClick={() => setServerMenuOpen((v) => !v)}
                  >
                    ▾
                  </button>
                </div>
                {selectedServer.description && (
                  <div className="server-top-desc muted">{selectedServer.description}</div>
                )}
              </div>
            )}
            <div className="panel-header panel-header-row">
              <span>Текстовые каналы</span>
              <button type="button" className="icon-btn" title="Создать текстовый канал" onClick={() => setShowCreateTextChannel(true)}>+</button>
            </div>
            <div className="channels-list">
              {textChannels.map((channel) => (
                <div
                  key={channel.id}
                  className={`channel-item ${selectedChannelId === channel.id ? "active" : ""}`}
                  onClick={() => selectTextChannel(channel.id)}
                >
                  <span className="chan-name" title={channel.name}># {channel.name}</span>
                  {Number(channel.unreadCount) > 0 && (
                    <span className="unread-badge">{Number(channel.unreadCount) > 99 ? "99+" : channel.unreadCount}</span>
                  )}
                </div>
              ))}
            </div>

            <div className="panel-header panel-header--sub panel-header-row">
              <span>Голосовые каналы</span>
              <button type="button" className="icon-btn" title="Создать голосовой канал" onClick={() => setShowCreateVoiceChannel(true)}>+</button>
            </div>
            <div className="channels-list">
              {voiceChannels.map((channel) => (
                <div key={channel.id} className="channel-voice-group">
                  <div
                    role="button"
                    tabIndex={0}
                    onKeyDown={(e) => {
                      if (e.key === "Enter" || e.key === " ") {
                        e.preventDefault();
                        onVoiceChannelRowClick(channel.id);
                      }
                    }}
                    className={`channel-item channel-item--voice ${
                      String(activeVoiceChannelId) === String(channel.id) && (voiceState.connected || voiceState.joining) ? "active" : ""
                    }`}
                    onClick={() => onVoiceChannelRowClick(channel.id)}
                  >
                    <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                      🔊 {channel.name}
                    </span>
                    <span className="channel-item-spacer" />
                    {String(channel.ownerUserId) === String(profile?.id) ? (
                      <button
                        type="button"
                        className="icon-btn danger"
                        title="Удалить канал"
                        aria-label="Удалить канал"
                        onClick={(e) => {
                          e.preventDefault();
                          e.stopPropagation();
                          deleteChannel(channel.id);
                        }}
                      >
                        ✕
                      </button>
                    ) : (
                      <button
                        type="button"
                        className="icon-btn"
                        title="Покинуть канал"
                        aria-label="Покинуть канал"
                        onClick={(e) => {
                          e.preventDefault();
                          e.stopPropagation();
                          leaveChannel(channel.id);
                        }}
                      >
                        ↩
                      </button>
                    )}
                  </div>

                  {(() => {
                    const isActive = String(activeVoiceChannelId) === String(channel.id);
                    const isConnectedHere = isActive && (voiceState.connected || voiceState.joining);

                    const pid = String(channel.id);
                    const presence = voicePresenceByChannelId[pid];
                    const presenceIds = (presence?.userIds || []).map((x) => String(x));
                    const presenceSharers = new Set((presence?.screenShareUserIds || []).map((x) => String(x)));

                    // If we're connected to this voice channel, render ONLY local roster (no duplication).
                    if (isConnectedHere) {
                      const sharers = new Set((voiceState.screenShareUserIds || []).map((x) => String(x)));
                      return (
                        <ul className="voice-members-inline">
                          <li className="voice-members-inline__me">
                            <span className="voice-member-avatar">
                              {userAvatarUrlByUserId[String(profile?.id)] ? (
                                <img alt="" src={userAvatarUrlByUserId[String(profile?.id)]} />
                              ) : (
                                <span className="avatar-fallback">{(profile?.nickname || "?").trim().slice(0, 1).toUpperCase()}</span>
                              )}
                            </span>
                            {profile?.nickname} <span className="muted">(вы)</span>
                          </li>
                          {(voiceState.rosterUserIds || [])
                            .filter((id) => String(id) !== String(profile?.id))
                            .map((id) => (
                              <li
                                key={`vm-${id}`}
                                className={`${(voiceState.speakingUserIds || []).some((x) => String(x) === String(id)) ? "is-speaking" : ""}`}
                              >
                                <span className="voice-member-avatar">
                                  {userAvatarUrlByUserId[String(id)] ? (
                                    <img alt="" src={userAvatarUrlByUserId[String(id)]} />
                                  ) : (
                                    <span className="avatar-fallback">{(voicePeerNames[String(id)] || "?").trim().slice(0, 1).toUpperCase()}</span>
                                  )}
                                </span>
                                <span className="voice-member-name">{voicePeerNames[String(id)] || "…"}</span>
                                {sharers.has(String(id)) && <span className="live-pill">в эфире</span>}
                                {sharers.has(String(id)) && (
                                  <button
                                    type="button"
                                    className="small-btn small-btn--mini"
                                    onClick={(e) => {
                                      e.preventDefault();
                                      e.stopPropagation();
                                      openScreenView(id);
                                    }}
                                  >
                                    Подключиться
                                  </button>
                                )}
                              </li>
                            ))}
                          {(voiceState.rosterUserIds || []).length === 0 && <li className="muted">Загрузка списка…</li>}
                        </ul>
                      );
                    }

                    // Not connected: render presence list always, if there is any.
                    if (!presenceIds.length) {
                      return null;
                    }
                    return (
                      <ul className="voice-members-inline voice-members-inline--presence">
                        {presenceIds.map((id) => (
                          <li key={`vp-${pid}-${id}`}>
                            <span className="voice-member-avatar">
                              {userAvatarUrlByUserId[String(id)] ? (
                                <img alt="" src={userAvatarUrlByUserId[String(id)]} />
                              ) : (
                                <span className="avatar-fallback">{(voicePeerNames[String(id)] || "?").trim().slice(0, 1).toUpperCase()}</span>
                              )}
                            </span>
                            <span className="voice-member-name">{voicePeerNames[String(id)] || "…"}</span>
                            {presenceSharers.has(String(id)) && <span className="live-pill">в эфире</span>}
                            {presenceSharers.has(String(id)) && (
                              <button
                                type="button"
                                className="small-btn small-btn--mini"
                                onClick={(e) => {
                                  e.preventDefault();
                                  e.stopPropagation();
                                  openScreenView(id);
                                }}
                              >
                                Подключиться
                              </button>
                            )}
                          </li>
                        ))}
                      </ul>
                    );
                  })()}
                </div>
              ))}
            </div>
          </div>
        )}

        <div className="user-card">
          <div className="user-card-main">
            <div className="user-card-identity">
              <div><b>{profile?.nickname}</b></div>
              <div className="muted">{profile?.login}</div>
            </div>
          </div>
          {(voiceState.connected || voiceState.joining) && activeVoiceChannelId && (
            <div className="voice-connection-panel" role="region" aria-label="Голосовая связь">
              <div className="voice-connection-row">
                <div className={`voice-connection-dot ${voiceState.connected ? "" : "is-joining"}`} aria-hidden />
                <div className="voice-connection-titles">
                  <div className="voice-connection-title">{voiceState.connected ? "Голосовая связь" : "Подключение…"}</div>
                  <div className="voice-connection-sub muted">
                    {(activeVoiceInfo?.server?.name || "Сервер")}
                    {" · "}
                    {(activeVoiceInfo?.channel?.name || "Канал")}
                  </div>
                </div>
              </div>
              <div className="voice-connection-actions">
                <button
                  type="button"
                  className={`user-voice-btn ${voiceState.muted ? "is-off" : ""}`}
                  onClick={toggleMic}
                  title={voiceState.muted ? "Включить микрофон" : "Выключить микрофон"}
                  aria-pressed={voiceState.muted}
                >
                  🎤
                </button>
                <button
                  type="button"
                  className={`user-voice-btn ${voiceState.deafened ? "is-off" : ""}`}
                  onClick={toggleDeafen}
                  title={voiceState.deafened ? "Включить звук" : "Режим без звука"}
                  aria-pressed={voiceState.deafened}
                >
                  🎧
                </button>
                <button
                  type="button"
                  className={`user-voice-btn ${voiceState.sharingScreen ? "is-off" : ""}`}
                  onClick={toggleScreenShare}
                  title={voiceState.sharingScreen ? "Остановить демонстрацию" : "Демонстрация экрана"}
                  aria-pressed={!!voiceState.sharingScreen}
                >
                  🖥️
                </button>
                <button type="button" className="voice-connection-hangup" onClick={leaveVoice} title="Отключиться" aria-label="Отключиться">
                  ⏏
                </button>
              </div>
            </div>
          )}
          <div className="toolbar" style={{ marginTop: "8px", flexWrap: "wrap" }}>
            <button className="small-btn" onClick={() => setShowEditProfile(true)}>Профиль</button>
            <button className="small-btn" style={{ background: "var(--danger)" }} onClick={logout}>Выйти</button>
          </div>
        </div>
      </aside>
      </div>

      <main className="chat">
        <div ref={remoteAudioHostRef} className="remote-audio-host" aria-hidden="true" />
        <div ref={remoteVideoHostRef} className="remote-video-host" aria-hidden="true" />
        <header className="chat-header">
          <div className="chat-header-top">
            {isNarrow && mobileView === "chat" && (
              <button
                type="button"
                className="back-btn"
                onClick={() => { setMobileView("list"); closeMembersSheet(); }}
                aria-label="К списку чатов"
              >
                ←
              </button>
            )}
            <div className="chat-header-title">
              {selectedChannel ? (uiMode === "dm" ? `@ ${selectedChannel.name}` : `# ${selectedChannel.name}`) : "Выберите канал"}
            </div>
            {isNarrow && mobileView === "chat" && members.length > 0 && (
              <button type="button" className="small-btn members-fab" onClick={openMembersSheet}>
                Участники
              </button>
            )}
          </div>
          <div className="chat-header-actions toolbar" style={{ flexWrap: "wrap", justifyContent: isNarrow ? "flex-start" : "flex-end" }}>
            {uiMode === "dm" && (
              <>
                <input
                  placeholder="Никнейм для нового DM"
                  value={inviteNickname}
                  onChange={(e) => setInviteNickname(e.target.value)}
                  style={{ background: "var(--bg-input)", border: 0, borderRadius: "8px", color: "var(--text-main)", padding: "8px 10px" }}
                />
                <button className="small-btn" onClick={startDmByNickname}>Написать</button>
              </>
            )}
          </div>
        </header>

        <section className="messages">
          {!selectedChannel && (
            <>
              <div className="muted hide-when-narrow">Создайте или выберите чат слева</div>
              <div className="muted only-narrow">Выберите чат в списке</div>
            </>
          )}
          {messages.map((msg) => (
            <div key={msg.id} className="message">
              <div className="msg-avatar">
                {msg.senderAvatarFileId && avatarUrlByFileId[String(msg.senderAvatarFileId)] ? (
                  <img alt="" src={avatarUrlByFileId[String(msg.senderAvatarFileId)]} />
                ) : (
                  <span className="avatar-fallback">
                    {(String(msg.senderNickname || "?").trim().slice(0, 1) || "?").toUpperCase()}
                  </span>
                )}
              </div>
              <div className="message-top">
                <div className="message-top-left">
                  <span className="msg-author">{msg.senderNickname}</span>
                  <span className="msg-time">
                    {new Date(msg.createdAtUtc).toLocaleString()}
                    {msg.editedAtUtc ? " · изм." : ""}
                  </span>
                </div>
                {String(msg.senderUserId) === String(profile?.id) && (
                  <div className="message-actions">
                    {editing.id === msg.id ? (
                      <>
                        <button onClick={saveEditMessage}>Сохранить</button>
                        <button onClick={() => setEditing({ id: "", text: "" })}>Отмена</button>
                      </>
                    ) : (
                      <>
                        <button onClick={() => startEditMessage(msg)}>Правка</button>
                        <button className="danger" onClick={() => deleteMessage(msg.id)}>Удалить</button>
                      </>
                    )}
                  </div>
                )}
              </div>
              {editing.id === msg.id ? (
                <textarea
                  rows="3"
                  value={editing.text}
                  onChange={(e) => setEditing({ ...editing, text: e.target.value })}
                  style={{ width: "100%", borderRadius: "8px", border: 0, padding: "10px", background: "var(--bg-input)", color: "var(--text-main)" }}
                />
              ) : (
                msg.text && <div>{renderTextWithLinks(msg.text)}</div>
              )}
              {msg.file && (
                <div className="msg-file">
                  <span>{msg.file.originalName}</span>
                  <button onClick={() => downloadFile(msg.file.id, msg.file.originalName)}>Скачать</button>
                </div>
              )}
            </div>
          ))}
        </section>

        <footer className="composer">
          <input
            type="text"
            placeholder={selectedChannel ? "Написать сообщение…" : "Сначала выберите чат"}
            value={newMessage}
            onChange={(e) => setNewMessage(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && sendTextMessage()}
            disabled={!selectedChannel}
          />
          <label className="small-btn" style={{ display: "inline-flex", alignItems: "center" }}>
            Файл
            <input
              type="file"
              hidden
              onChange={(e) => {
                const file = e.target.files?.[0];
                if (file) sendFile(file);
                e.target.value = "";
              }}
            />
          </label>
          <button onClick={sendTextMessage} disabled={!selectedChannel}>Отправить</button>
        </footer>
      </main>

      <aside className="members">
        <div className="panel-header">{uiMode === "dm" ? "Собеседник" : "Участники"}</div>
        <div className="members-list">
          {members.map((m) => (
            <div key={m.id} className="member">
              <div><b>{m.nickname}</b></div>
              <div className="muted">{m.login}</div>
            </div>
          ))}
        </div>
      </aside>
    </div>

    {serverMenuOpen && selectedServer && (
      <div className="server-menu-backdrop" onClick={() => setServerMenuOpen(false)} role="presentation">
        <div className="server-menu" role="menu" aria-label="Меню сервера" onClick={(e) => e.stopPropagation()}>
          <button type="button" className="server-menu-item" onClick={() => { copyServerInvite(); setServerMenuOpen(false); }}>
            Ссылка-приглашение
          </button>
          {String(selectedServer.ownerUserId) === String(profile?.id) ? (
            <>
              <button
                type="button"
                className="server-menu-item"
                onClick={() => {
                  setRenameServerForm({ name: selectedServer.name || "", description: selectedServer.description || "" });
                  setShowRenameServer(true);
                }}
              >
                Переименовать сервер
              </button>
              <label className="server-menu-item" style={{ cursor: "pointer" }}>
                Изменить аватар сервера
                <input
                  type="file"
                  accept="image/*"
                  hidden
                  onChange={(e) => {
                    const f = e.target.files?.[0];
                    if (f) uploadServerAvatar(selectedServer.id, f);
                    e.target.value = "";
                    setServerMenuOpen(false);
                  }}
                />
              </label>
              <button
                type="button"
                className="server-menu-item danger"
                onClick={() => { deleteServer(selectedServer.id); setServerMenuOpen(false); }}
              >
                Удалить сервер
              </button>
            </>
          ) : (
            <button
              type="button"
              className="server-menu-item"
              onClick={() => { leaveServer(selectedServer.id); setServerMenuOpen(false); }}
            >
              Покинуть сервер
            </button>
          )}
        </div>
      </div>
    )}

    {showRenameServer && (
      <div className="sheet-backdrop sheet-backdrop--center" onClick={() => setShowRenameServer(false)} role="presentation">
        <div className="sheet-panel sheet-panel--center" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
          <div className="sheet-header">
            <span>Сервер</span>
            <button type="button" className="sheet-close" onClick={() => setShowRenameServer(false)} aria-label="Закрыть">×</button>
          </div>
          <div className="sheet-body">
            <input
              placeholder="Название сервера"
              value={renameServerForm.name}
              onChange={(e) => setRenameServerForm((p) => ({ ...p, name: e.target.value }))}
            />
            <textarea
              className="form-field form-field--textarea"
              placeholder="Описание (необязательно)"
              rows="3"
              value={renameServerForm.description}
              onChange={(e) => setRenameServerForm((p) => ({ ...p, description: e.target.value }))}
            />
            <div className="row">
              <button type="button" className="small-btn" onClick={saveRenameServer}>Сохранить</button>
              <button type="button" className="small-btn" style={{ background: "#4a4f57" }} onClick={() => setShowRenameServer(false)}>Отмена</button>
            </div>
          </div>
        </div>
      </div>
    )}

    {showEditProfile && (
        <div style={{
          position: "fixed",
          inset: 0,
          background: "rgba(0,0,0,0.5)",
          display: "grid",
          placeItems: "center",
          zIndex: 180,
          padding: "16px",
          paddingTop: "max(16px, env(safe-area-inset-top, 0px))"
        }}>
          <div className="auth-card" style={{ maxHeight: "90dvh", overflow: "auto" }}>
            <h2>Редактирование профиля</h2>
            <div className="profile-avatar-edit">
              <div className="avatar avatar--lg">
                {userAvatarUrlByUserId[String(profile?.id)] ? (
                  <img alt="" src={userAvatarUrlByUserId[String(profile?.id)]} />
                ) : (
                  <span>{(profile?.nickname || "?").trim().slice(0, 1).toUpperCase()}</span>
                )}
              </div>
              <label className="small-btn" style={{ display: "inline-flex", alignItems: "center" }}>
                Загрузить аватар
                <input
                  type="file"
                  accept="image/*"
                  hidden
                  onChange={(e) => {
                    const f = e.target.files?.[0];
                    if (f) uploadMyAvatar(f);
                    e.target.value = "";
                  }}
                />
              </label>
            </div>
            <input
              placeholder="Nickname"
              value={editForm.nickname}
              onChange={(e) => setEditForm({ ...editForm, nickname: e.target.value })}
            />
            <textarea
              placeholder="Bio"
              rows="4"
              value={editForm.bio}
              onChange={(e) => setEditForm({ ...editForm, bio: e.target.value })}
            />
            <div className="row">
              <button className="small-btn" onClick={saveProfile}>Сохранить</button>
              <button className="small-btn" style={{ background: "#4a4f57" }} onClick={() => setShowEditProfile(false)}>Отмена</button>
            </div>
          </div>
        </div>
      )}

      {membersSheet && isNarrow && (
        <div className="sheet-backdrop" onClick={closeMembersSheet} role="presentation">
          <div
            className="sheet-panel"
            role="dialog"
            aria-modal="true"
            aria-labelledby="members-sheet-title"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="sheet-header">
              <span id="members-sheet-title">{uiMode === "dm" ? "Собеседник" : "Участники"}</span>
              <button type="button" className="sheet-close" onClick={closeMembersSheet} aria-label="Закрыть">
                ×
              </button>
            </div>
            <div className="sheet-body members-list">
              {members.map((m) => (
                <div key={m.id} className="member">
                  <div><b>{m.nickname}</b></div>
                  <div className="muted">{m.login}</div>
                </div>
              ))}
            </div>
          </div>
        </div>
      )}

    {screenView.open && (
        <div className={`screen-overlay ${screenView.mode === "window" ? "screen-overlay--window" : ""}`} role="dialog" aria-modal="true">
          <div className="screen-overlay-top">
            <div className="screen-overlay-title">Демонстрация экрана</div>
            <div className="screen-overlay-actions">
              <button type="button" className="small-btn" onClick={toggleScreenViewMode}>
                {screenView.mode === "full" ? "Окно" : "На весь экран"}
              </button>
              <button type="button" className="small-btn" style={{ background: "var(--danger)" }} onClick={closeScreenView}>
                Отключиться
              </button>
            </div>
          </div>
          <div className="screen-overlay-body">
            <video ref={screenVideoRef} className="screen-video" autoPlay playsInline />
          </div>
        </div>
      )}

    {showCreateServer && (
        <div className="sheet-backdrop sheet-backdrop--center" onClick={() => setShowCreateServer(false)} role="presentation">
          <div className="sheet-panel sheet-panel--center" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
            <div className="sheet-header">
              <span>Новый сервер</span>
              <button type="button" className="sheet-close" onClick={() => setShowCreateServer(false)} aria-label="Закрыть">
                ×
              </button>
            </div>
            <div className="sheet-body">
              <input
                placeholder="Название сервера"
                className="form-field"
                value={newServerName}
                onChange={(e) => setNewServerName(e.target.value)}
              />
              <textarea
                placeholder="Описание сервера"
                rows="3"
                className="form-field form-field--textarea"
                value={newServerDescription}
                onChange={(e) => setNewServerDescription(e.target.value)}
              />
              <div className="row">
                <button type="button" className="small-btn" onClick={createNewServer}>Создать</button>
                <button type="button" className="small-btn" style={{ background: "#4a4f57" }} onClick={() => setShowCreateServer(false)}>Отмена</button>
              </div>
            </div>
          </div>
        </div>
      )}

    {showCreateTextChannel && (
        <div className="sheet-backdrop sheet-backdrop--center" onClick={() => setShowCreateTextChannel(false)} role="presentation">
          <div className="sheet-panel sheet-panel--center" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
            <div className="sheet-header">
              <span>Новый текстовый канал</span>
              <button type="button" className="sheet-close" onClick={() => setShowCreateTextChannel(false)} aria-label="Закрыть">×</button>
            </div>
            <div className="sheet-body">
              <input
                placeholder="Название канала"
                className="form-field"
                value={newChannelName}
                onChange={(e) => setNewChannelName(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === "Enter") {
                    e.preventDefault();
                    createChannel(newChannelName);
                  }
                }}
              />
              <div className="row">
                <button type="button" className="small-btn" onClick={() => createChannel(newChannelName)}>Создать</button>
                <button type="button" className="small-btn" style={{ background: "#4a4f57" }} onClick={() => setShowCreateTextChannel(false)}>Отмена</button>
              </div>
            </div>
          </div>
        </div>
      )}

    {showCreateVoiceChannel && (
        <div className="sheet-backdrop sheet-backdrop--center" onClick={() => setShowCreateVoiceChannel(false)} role="presentation">
          <div className="sheet-panel sheet-panel--center" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
            <div className="sheet-header">
              <span>Новый голосовой канал</span>
              <button type="button" className="sheet-close" onClick={() => setShowCreateVoiceChannel(false)} aria-label="Закрыть">×</button>
            </div>
            <div className="sheet-body">
              <input
                placeholder="Название канала"
                className="form-field"
                value={newVoiceChannelName}
                onChange={(e) => setNewVoiceChannelName(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === "Enter") {
                    e.preventDefault();
                    createVoiceChannel(newVoiceChannelName);
                  }
                }}
              />
              <div className="row">
                <button type="button" className="small-btn" onClick={() => createVoiceChannel(newVoiceChannelName)}>Создать</button>
                <button type="button" className="small-btn" style={{ background: "#4a4f57" }} onClick={() => setShowCreateVoiceChannel(false)}>Отмена</button>
              </div>
            </div>
          </div>
        </div>
      )}

    {(status || error) && (
        <div className="toast-bar">
          {status && <div className="success">{status}</div>}
          {error && <div className="danger">{error}</div>}
        </div>
      )}
    </div>
  );
}

function createVoiceSession({ token, roomId, selfUserId, remoteAudioHost, remoteVideoHost, onState }) {
  const iceServers = [
    { urls: "stun:stun.l.google.com:19302" },
    {
      urls: "turn:openrelay.metered.ca:80",
      username: "openrelayproject",
      credential: "openrelayproject"
    },
    {
      urls: "turn:openrelay.metered.ca:443?transport=tcp",
      username: "openrelayproject",
      credential: "openrelayproject"
    }
  ];
  const peers = new Map();
  let lastRoster = [];
  let onJoinServerAck = null;
  let screenStream = null;
  let screenTrack = null;
  let audioCtx = null;
  let speakingTimer = null;

  let ws = null;
  let localStream = null;
  let destroyed = false;
  let muted = false;
  let deafened = false;
  let pingTimer = null;
  let reconnectTimer = null;
  let reconnectFailures = 0;

  const protocol = window.location.protocol === "https:" ? "wss" : "ws";
  const wsUrl = `${protocol}://${window.location.host}/ws/voice?token=${encodeURIComponent(token)}`;

  function setState(patch) {
    onState((prev) => (typeof patch === "function" ? patch(prev) : { ...prev, ...patch }));
  }

  function countPeers() {
    let n = 0;
    peers.forEach((p) => {
      if (p.pc.connectionState !== "closed") n += 1;
    });
    return n;
  }

  function applyDeafenToAllAudio() {
    peers.forEach((p) => {
      if (p.audioEl) p.audioEl.muted = deafened;
    });
    if (remoteAudioHost) {
      remoteAudioHost.querySelectorAll("audio").forEach((el) => {
        el.muted = deafened;
      });
    }
  }

  function updatePeerMetrics() {
    setState((prev) => ({
      ...prev,
      peers: countPeers(),
      remotePeerUserIds: Array.from(peers.keys(), (k) => String(k))
    }));
    recomputeLinkReady();
  }

  function recomputeLinkReady() {
    if (selfUserId == null) {
      return;
    }
    if (!lastRoster.length) {
      setState({ mediaLinkReady: false });
      return;
    }
    const me = String(selfUserId);
    const others = lastRoster.filter((id) => String(id) !== me);
    if (others.length === 0) {
      setState({ mediaLinkReady: true });
      return;
    }
    for (const oid of others) {
      const p = peers.get(String(oid));
      if (!p) {
        setState({ mediaLinkReady: false });
        return;
      }
      if (!["connected", "completed"].includes(p.pc.iceConnectionState)) {
        setState({ mediaLinkReady: false });
        return;
      }
    }
    setState({ mediaLinkReady: true });
  }

  function ensureAudioElement(peerId) {
    const pid = String(peerId).replace(/[^a-f0-9-]/gi, "x");
    const id = `remote-audio-${pid}`;
    let el = document.getElementById(id);
    if (el) return el;
    el = document.createElement("audio");
    el.id = id;
    el.autoplay = true;
    el.playsInline = true;
    el.controls = false;
    el.setAttribute("playsinline", "true");
    if (deafened) el.muted = true;
    remoteAudioHost.appendChild(el);
    return el;
  }

  function ensureVideoElement(peerId) {
    const pid = String(peerId).replace(/[^a-f0-9-]/gi, "x");
    const id = `remote-video-${pid}`;
    let el = document.getElementById(id);
    if (el) return el;
    el = document.createElement("video");
    el.id = id;
    el.autoplay = true;
    el.playsInline = true;
    el.controls = false;
    el.muted = true; // remote video shouldn't echo; audio is separate
    el.setAttribute("playsinline", "true");
    (remoteVideoHost || document.querySelector(".voice-video-stage"))?.appendChild(el);
    return el;
  }

  function removeVideoElement(peerId) {
    const pid = String(peerId).replace(/[^a-f0-9-]/gi, "x");
    const el = document.getElementById(`remote-video-${pid}`);
    if (el) el.remove();
  }

  function removeAudioElement(peerId) {
    const pid = String(peerId).replace(/[^a-f0-9-]/gi, "x");
    const el = document.getElementById(`remote-audio-${pid}`);
    if (el) el.remove();
  }

  function clearVoiceTimers() {
    if (pingTimer) {
      clearInterval(pingTimer);
      pingTimer = null;
    }
    if (reconnectTimer) {
      clearTimeout(reconnectTimer);
      reconnectTimer = null;
    }
    if (speakingTimer) {
      clearInterval(speakingTimer);
      speakingTimer = null;
    }
  }

  function startVoicePing() {
    if (pingTimer) {
      clearInterval(pingTimer);
    }
    pingTimer = setInterval(() => {
      if (ws && ws.readyState === WebSocket.OPEN) {
        try {
          sendWs({ type: "ping" });
        } catch {
          // ignore
        }
      }
    }, 20000);
  }

  function scheduleVoiceReconnect() {
    if (destroyed) {
      return;
    }
    if (reconnectTimer) {
      clearTimeout(reconnectTimer);
      reconnectTimer = null;
    }
    reconnectFailures += 1;
    if (reconnectFailures > 18) {
      setState((prev) => ({
        ...prev,
        connected: false,
        joining: false,
        mediaLinkReady: false,
        room: "",
        peers: 0,
        remotePeerUserIds: [],
        rosterUserIds: []
      }));
      return;
    }
    const base = 400;
    const cap = 20000;
    const exp = Math.min(16, Math.max(0, reconnectFailures - 1));
    const delay = Math.min(cap, base * Math.pow(1.6, exp));
    reconnectTimer = setTimeout(() => {
      reconnectTimer = null;
      (async () => {
        if (destroyed) return;
        if (!localStream) return;
        try {
          try {
            destroyAllPeers();
          } catch {
            // ignore
          }
          await openWebSocketAndJoin();
          setState((prev) => ({ ...prev, connected: true, room: roomId, peers: countPeers() }));
          reconnectFailures = 0;
        } catch {
          scheduleVoiceReconnect();
        }
      })();
    }, delay);
  }

  async function openWebSocketAndJoin() {
    if (destroyed) {
      return;
    }
    return new Promise((resolve, reject) => {
      const s = new WebSocket(wsUrl);
      s.onopen = () => {
        if (destroyed) {
          try {
            s.close();
          } catch {
            // ignore
          }
          reject(new Error("Отменено"));
          return;
        }
        ws = s;
        s.onmessage = (ev) => onMessage(ev);
        s.onclose = () => {
          if (destroyed) {
            return;
          }
          if (pingTimer) {
            clearInterval(pingTimer);
            pingTimer = null;
          }
          try {
            destroyAllPeers();
          } catch {
            // ignore
          }
          setState((prev) => ({ ...prev, connected: true, room: roomId, peers: 0, remotePeerUserIds: [] }));
          scheduleVoiceReconnect();
        };
        s.onerror = () => {
          // onclose will fire and schedule reconnect
        };
        try {
          sendWs({ type: "joinRoom", roomId });
        } catch (e) {
          reject(e);
          return;
        }
        startVoicePing();
        resolve();
      };
      s.onerror = () => {
        try {
          s.close();
        } catch {
          // ignore
        }
        reject(new Error("Не удалось подключить WebSocket"));
      };
    });
  }

  async function sendWs(obj) {
    if (!ws || ws.readyState !== WebSocket.OPEN) {
      return;
    }
    ws.send(JSON.stringify(obj));
  }

  function wirePeerConnection(peerId, pc) {
    pc.onicecandidate = async (ev) => {
      if (!ev.candidate) return;
      try {
        await sendWs({
          type: "signal",
          targetUserId: peerId,
          signalType: "ice",
          payload: JSON.stringify(ev.candidate.toJSON ? ev.candidate.toJSON() : ev.candidate)
        });
      } catch {
        // ignore
      }
    };

    pc.ontrack = (ev) => {
      const p = peers.get(peerId);
      if (!p) return;
      const stream = ev.streams[0] || (ev.track ? new MediaStream([ev.track]) : null);
      if (stream) {
        if (ev.track && ev.track.kind === "video") {
          if (p.videoEl) {
            p.videoEl.srcObject = stream;
            p.videoEl.play?.().catch(() => {});
          }
          p.hasVideo = true;
          setState((prev) => ({
            ...prev,
            screenShareUserIds: Array.from(new Set([...(prev.screenShareUserIds || []), String(peerId)]))
          }));
        } else {
          p.audioEl.srcObject = stream;
          p.audioEl.muted = deafened;
          p.audioEl.play?.().catch(() => {});
          p.audioStream = stream;
          tryStartSpeakingMeter();
        }
      }
    };

    pc.oniceconnectionstatechange = () => {
      recomputeLinkReady();
      if (["failed", "closed"].includes(pc.iceConnectionState)) {
        cleanupPeer(peerId);
      }
    };

    pc.onconnectionstatechange = () => {
      recomputeLinkReady();
    };
  }

  function ensurePeer(peerId) {
    const key = String(peerId);
    if (peers.has(key)) return peers.get(key);

    const audioEl = ensureAudioElement(key);
    const videoEl = ensureVideoElement(key);
    const pc = new RTCPeerConnection({ iceServers });
    wirePeerConnection(key, pc);

    peers.set(key, { pc, audioEl, videoEl, pendingIce: [] });
    return peers.get(key);
  }

  async function flushPendingIce(peerId) {
    const key = String(peerId);
    const p = peers.get(key);
    if (!p || !p.pendingIce?.length) {
      return;
    }
    const { pc } = p;
    const list = p.pendingIce;
    p.pendingIce = [];
    for (const c of list) {
      try {
        await pc.addIceCandidate(c);
      } catch {
        // ignore
      }
    }
  }

  function addLocalTracks(peerId) {
    if (!localStream) return;
    const p = ensurePeer(peerId);
    const { pc } = p;

    const hasAudioSender = pc.getSenders().some((s) => s.track && s.track.kind === "audio");
    if (hasAudioSender) return;

    localStream.getAudioTracks().forEach((t) => {
      try {
        pc.addTrack(t, localStream);
      } catch {
        // ignore
      }
    });
  }

  async function negotiateCaller(peerId) {
    addLocalTracks(peerId);
    const p = ensurePeer(peerId);
    const { pc } = p;

    if (pc.signalingState !== "stable") return;

    const offer = await pc.createOffer({ offerToReceiveAudio: true });
    await pc.setLocalDescription(offer);
    await sendWs({ type: "signal", targetUserId: peerId, signalType: "offer", payload: JSON.stringify(pc.localDescription) });
    await flushPendingIce(peerId);
  }

  function cleanupPeer(peerId) {
    const key = String(peerId);
    const p = peers.get(key);
    if (!p) return;
    try {
      p.pc.onicecandidate = null;
      p.pc.ontrack = null;
      p.pc.oniceconnectionstatechange = null;
      p.pc.onconnectionstatechange = null;
      p.pc.close();
    } catch {
      // ignore
    }
    removeAudioElement(key);
    removeVideoElement(key);
    peers.delete(key);
    setState((prev) => ({
      ...prev,
      screenShareUserIds: (prev.screenShareUserIds || []).filter((x) => String(x) !== String(key))
    }));
    updatePeerMetrics();
  }

  function destroyAllPeers() {
    Array.from(peers.keys()).forEach((id) => cleanupPeer(id));
  }

  function tryStartSpeakingMeter() {
    if (speakingTimer) return;
    // Start meter only after we have at least one audio stream (local or remote).
    speakingTimer = setInterval(() => {
      if (destroyed) return;
      const speaking = [];
      try {
        if (!audioCtx) {
          audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        }
        if (audioCtx.state === "suspended") {
          // Resume in case browser suspended it; join() is user gesture so usually ok.
          audioCtx.resume?.().catch(() => {});
        }
      } catch {
        return;
      }

      const threshold = 0.03; // tuned for speech vs noise

      // local speaking
      try {
        if (localStream) {
          if (!peers._localAnalyser) {
            const src = audioCtx.createMediaStreamSource(localStream);
            const an = audioCtx.createAnalyser();
            an.fftSize = 512;
            src.connect(an);
            peers._localAnalyser = an;
          }
          const an = peers._localAnalyser;
          if (an) {
            const data = new Uint8Array(an.fftSize);
            an.getByteTimeDomainData(data);
            let sum = 0;
            for (let i = 0; i < data.length; i++) {
              const v = (data[i] - 128) / 128;
              sum += v * v;
            }
            const rms = Math.sqrt(sum / data.length);
            if (rms > threshold) speaking.push(String(selfUserId));
          }
        }
      } catch {
        // ignore
      }

      // remote speaking
      peers.forEach((p, pid) => {
        try {
          if (!p.audioStream) return;
          if (!p.analyser) {
            const src = audioCtx.createMediaStreamSource(p.audioStream);
            const an = audioCtx.createAnalyser();
            an.fftSize = 512;
            src.connect(an);
            p.analyser = an;
          }
          const an = p.analyser;
          const data = new Uint8Array(an.fftSize);
          an.getByteTimeDomainData(data);
          let sum = 0;
          for (let i = 0; i < data.length; i++) {
            const v = (data[i] - 128) / 128;
            sum += v * v;
          }
          const rms = Math.sqrt(sum / data.length);
          if (rms > threshold) speaking.push(String(pid));
        } catch {
          // ignore
        }
      });

      setState((prev) => {
        const prevIds = (prev.speakingUserIds || []).map((x) => String(x));
        const nextIds = speaking.filter((x) => x && x !== "null" && x !== "undefined").map((x) => String(x));
        if (prevIds.length === nextIds.length && prevIds.every((x, i) => x === nextIds[i])) {
          return prev;
        }
        return { ...prev, speakingUserIds: nextIds };
      });
    }, 140);
  }

  function isOffererFor(otherUserId) {
    if (selfUserId == null) return false;
    return String(selfUserId) < String(otherUserId);
  }

  async function renegotiateAllPeers() {
    for (const pid of Array.from(peers.keys())) {
      if (isOffererFor(pid)) {
        try {
          await negotiateCaller(pid);
        } catch {
          // ignore
        }
      }
    }
  }

  async function startScreenShare() {
    if (screenTrack) return;
    const ds = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
    screenStream = ds;
    screenTrack = ds.getVideoTracks()?.[0] || null;
    if (!screenTrack) {
      try { ds.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
      screenStream = null;
      throw new Error("Не удалось получить видеопоток экрана");
    }

    screenTrack.onended = () => {
      stopScreenShare().catch(() => {});
    };

    // Attach to each peer; negotiation happens in background.
    peers.forEach((p) => {
      try {
        p.pc.addTrack(screenTrack, screenStream);
      } catch {
        // ignore
      }
    });

    setState((prev) => ({
      ...prev,
      sharingScreen: true,
      screenShareUserIds: Array.from(new Set([...(prev.screenShareUserIds || []), String(selfUserId)]))
    }));
    try {
      await sendWs({ type: "screenShare", roomId, enabled: true });
    } catch {
      // ignore
    }
    await renegotiateAllPeers();
  }

  async function stopScreenShare() {
    if (!screenTrack) {
      setState((prev) => ({
        ...prev,
        sharingScreen: false,
        screenShareUserIds: (prev.screenShareUserIds || []).filter((x) => String(x) !== String(selfUserId))
      }));
      return;
    }
    const tr = screenTrack;
    screenTrack = null;
    const ss = screenStream;
    screenStream = null;

    peers.forEach((p) => {
      try {
        const sender = p.pc.getSenders().find((s) => s.track && s.track.kind === "video");
        if (sender) {
          try { p.pc.removeTrack(sender); } catch { /* ignore */ }
        }
      } catch {
        // ignore
      }
    });

    try {
      tr.stop();
    } catch {
      // ignore
    }
    try {
      ss?.getTracks?.().forEach((t) => t.stop());
    } catch {
      // ignore
    }

    setState((prev) => ({
      ...prev,
      sharingScreen: false,
      screenShareUserIds: (prev.screenShareUserIds || []).filter((x) => String(x) !== String(selfUserId))
    }));
    try {
      await sendWs({ type: "screenShare", roomId, enabled: false });
    } catch {
      // ignore
    }
    await renegotiateAllPeers();
  }

  async function handleSignal(msg) {
    const from = String(msg.fromUserId);
    if (!from) return;

    const raw = JSON.parse(msg.payload);

    if (msg.signalType === "offer") {
      addLocalTracks(from);
      const p = ensurePeer(from);
      const { pc } = p;

      await pc.setRemoteDescription(raw);
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      await sendWs({ type: "signal", targetUserId: from, signalType: "answer", payload: JSON.stringify(pc.localDescription) });
      await flushPendingIce(from);
    } else if (msg.signalType === "answer") {
      const p = ensurePeer(from);
      const { pc } = p;
      await pc.setRemoteDescription(raw);
      await flushPendingIce(from);
    } else if (msg.signalType === "ice") {
      const p = ensurePeer(from);
      const { pc } = p;
      if (pc.remoteDescription) {
        try {
          await pc.addIceCandidate(raw);
        } catch {
          // ignore
        }
      } else {
        if (!p.pendingIce) p.pendingIce = [];
        p.pendingIce.push(raw);
      }
    }

    updatePeerMetrics();
  }

  async function onMessage(ev) {
    let msg;
    try {
      msg = JSON.parse(ev.data);
    } catch {
      return;
    }

    if (msg.type === "pong") {
      return;
    }

    if (msg.type === "joinedRoom" && String(msg.roomId) === String(roomId)) {
      if (onJoinServerAck) {
        const c = onJoinServerAck;
        onJoinServerAck = null;
        c();
      }
    }

    if (msg.type === "roomRoster" && String(msg.roomId) === String(roomId)) {
      const ids = (msg.userIds || []).map((x) => String(x));
      lastRoster = ids;
      setState((prev) => ({ ...prev, rosterUserIds: ids }));
      if (onJoinServerAck) {
        const c = onJoinServerAck;
        onJoinServerAck = null;
        c();
      }
      recomputeLinkReady();
      return;
    }

    if (msg.type === "peerJoined") {
      if (!localStream) return;
      if (selfUserId == null) return;
      const other = String(msg.fromUserId);
      if (other === String(selfUserId)) return;

      // Only one side creates the offer, otherwise two simultaneous offers (SDP "glare") and audio fails.
      if (String(selfUserId) < other) {
        await negotiateCaller(other);
      }
      updatePeerMetrics();
    }

    if (msg.type === "peerLeft") {
      if (msg.fromUserId != null) cleanupPeer(String(msg.fromUserId));
    }

    if (msg.type === "signal") {
      await handleSignal(msg);
    }

    if (msg.type === "error") {
      console.warn("voice error:", msg.payload);
    }
  }

  return {
    async join() {
      localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
      localStream.getAudioTracks().forEach((t) => {
        t.enabled = !muted;
      });

      reconnectFailures = 0;
      let joinTimer;
      await new Promise((resolve, reject) => {
        joinTimer = setTimeout(() => {
          onJoinServerAck = null;
          reject(new Error("Сервер не подтвердил вход в голосовой канал. Повторите попытку."));
        }, 20000);
        onJoinServerAck = () => {
          clearTimeout(joinTimer);
          onJoinServerAck = null;
          resolve();
        };
        openWebSocketAndJoin().catch((e) => {
          clearTimeout(joinTimer);
          onJoinServerAck = null;
          reject(e);
        });
      });
      setState({
        connected: true,
        joining: false,
        room: roomId,
        peers: countPeers(),
        muted,
        remotePeerUserIds: Array.from(peers.keys(), (k) => String(k)),
        deafened: false
      });
      recomputeLinkReady();
    },

    toggleMute() {
      muted = !muted;
      if (localStream) {
        localStream.getAudioTracks().forEach((t) => {
          t.enabled = !muted;
        });
      }
      setState({ muted });
    },

    toggleDeafen() {
      deafened = !deafened;
      applyDeafenToAllAudio();
      setState({ deafened });
    },

    async toggleScreenShare() {
      if (screenTrack) {
        await stopScreenShare();
      } else {
        await startScreenShare();
      }
    },

    destroy() {
      if (destroyed) return;
      destroyed = true;
      deafened = false;
      onJoinServerAck = null;
      lastRoster = [];
      screenTrack = null;
      try { screenStream?.getTracks?.().forEach((t) => t.stop()); } catch { /* ignore */ }
      screenStream = null;
      try {
        if (audioCtx && audioCtx.state !== "closed") audioCtx.close?.();
      } catch {
        // ignore
      }
      audioCtx = null;
      clearVoiceTimers();
      reconnectFailures = 0;

      try {
        sendWs({ type: "leaveRoom" });
      } catch {
        // ignore
      }

      try {
        ws?.close();
      } catch {
        // ignore
      }

      ws = null;

      try {
        localStream?.getTracks().forEach((t) => t.stop());
      } catch {
        // ignore
      }
      localStream = null;

      destroyAllPeers();
      setState({
        connected: false,
        joining: false,
        mediaLinkReady: false,
        room: "",
        peers: 0,
        muted: false,
        deafened: false,
        remotePeerUserIds: [],
        rosterUserIds: []
      });
    }
  };
}

function urlBase64ToUint8Array(base64String) {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const result = reader.result;
      if (typeof result !== "string") {
        reject(new Error("Ошибка чтения файла"));
        return;
      }
      const base64 = result.split(",")[1] || "";
      resolve(base64);
    };
    reader.onerror = () => reject(new Error("Не удалось прочитать файл"));
    reader.readAsDataURL(file);
  });
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
