import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Room, RoomEvent, VideoPresets, ConnectionQuality, DisconnectReason } from 'livekit-client';
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { findTrackPubs } from '../utils/streamUtils.jsx';
import { VoiceRecorder } from '../utils/voiceRecorder';
import { usePlatform } from '../platform';
import '@livekit/components-styles';

import joinSound from '../assets/sounds/join_call.mp3';
import leaveSound from '../assets/sounds/leave_call.mp3';
import muteSound from '../assets/sounds/mute.mp3';
import unmuteSound from '../assets/sounds/unmute.mp3';
import deafenSound from '../assets/sounds/deafen.mp3';
import undeafenSound from '../assets/sounds/undeafen.mp3';
import viewerJoinSound from '../assets/sounds/screenshare_viewer_join.mp3';
import viewerLeaveSound from '../assets/sounds/screenshare_viewer_leave.mp3';
import screenshareStartSound from '../assets/sounds/screenshare_start.mp3';
import screenshareStopSound from '../assets/sounds/screenshare_stop.mp3';
import cameraOnSound from '../assets/sounds/camera_on.mp3';
import cameraOffSound from '../assets/sounds/camera_off.mp3';

const soundMap = {
    join: joinSound,
    leave: leaveSound,
    mute: muteSound,
    unmute: unmuteSound,
    deafen: deafenSound,
    undeafen: undeafenSound,
    viewer_join: viewerJoinSound,
    viewer_leave: viewerLeaveSound,
    screenshare_start: screenshareStartSound,
    screenshare_stop: screenshareStopSound,
    camera_on: cameraOnSound,
    camera_off: cameraOffSound,
};

const VoiceContext = createContext();

export const useVoice = () => useContext(VoiceContext);

let _suppressAppSounds = false;

function playSound(type) {
    if (_suppressAppSounds) return;
    const src = soundMap[type];
    if (!src) return;
    const audio = new Audio(src);
    audio.volume = 0.5;
    audio.play().catch(e => console.error("Sound play failed", e));
}

function playSoundUrl(url) {
    if (_suppressAppSounds) return;
    const audio = new Audio(url);
    audio.volume = 0.5;
    audio.play().catch(e => console.error("Sound play failed", e));
}

export const VoiceProvider = ({ children }) => {
    const platform = usePlatform();
    const { idle, voiceService } = platform;
    const [activeChannelId, setActiveChannelId] = useState(null);
    const [activeChannelName, setActiveChannelName] = useState(null);
    const [connectionState, setConnectionState] = useState('disconnected');
    const [room, setRoom] = useState(null);
    const [token, setToken] = useState(null);
    const [activeSpeakers, setActiveSpeakers] = useState(new Set());
    const speakerRemovalTimers = useRef(new Map());
    const clearSpeakerTimers = useCallback(() => {
        for (const timer of speakerRemovalTimers.current.values()) {
            clearTimeout(timer);
        }
        speakerRemovalTimers.current.clear();
    }, []);
    const [isMuted, setIsMuted] = useState(false);
    const [isDeafened, setIsDeafened] = useState(false);
    const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
    const [globalOutputVolume, setGlobalOutputVolume] = useState(() =>
        parseInt(localStorage.getItem('voiceOutputVolume') || '100')
    );
    const isMovingRef = useRef(false);
    const isDMCallRef = useRef(false);
    // Flipped to true by `disconnectVoice` when the user presses
    // disconnect *during* the 'connecting' phase. Each major await in
    // `connectToVoice` checks this ref and bails out cleanly so we
    // don't end up connected to a channel the user tried to leave.
    const cancelConnectRef = useRef(false);
    const lastSpokeRef = useRef(Date.now());
    const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
    const [isReconnecting, setIsReconnecting] = useState(false);
    const [connectionQualities, setConnectionQualities] = useState({});

    // Voice recording — crash-safe per-participant audio capture.
    // Only available on Electron (`platform.features.hasRecording`).
    // `recordingError` is surfaced once per failure so UI can toast
    // it; consumers should clear it after reading.
    const [isRecording, setIsRecording] = useState(false);
    const [recordingStartedAt, setRecordingStartedAt] = useState(null);
    const [recordingSessionId, setRecordingSessionId] = useState(null);
    const [recordingError, setRecordingError] = useState(null);
    const voiceRecorderRef = useRef(null);

    const convex = useConvex();

    // Single source of truth for the signed-in user id. All the
    // voice/presence effects below used to read localStorage
    // directly — that worked but wasn't reactive, so a
    // logout-then-login in the same tab could leave effects
    // operating on a stale id until something else triggered a
    // rerender. The `storage` and `brycord:auth-change` listeners
    // keep this state in sync across tabs (the former) and
    // within-tab login/logout (the latter, emitted by
    // hooks/useLogout + the login page).
    const [myUserId, setMyUserId] = useState(
        typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null,
    );
    useEffect(() => {
        if (typeof window === 'undefined') return;
        const sync = () => {
            setMyUserId(
                typeof localStorage !== 'undefined'
                    ? localStorage.getItem('userId')
                    : null,
            );
        };
        window.addEventListener('storage', sync);
        window.addEventListener('brycord:auth-change', sync);
        return () => {
            window.removeEventListener('storage', sync);
            window.removeEventListener('brycord:auth-change', sync);
        };
    }, []);

    // Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
    const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);

    const setWatchingStreamOf = useCallback((identity) => {
        setWatchingStreamOfRaw(identity);
        // Sync to backend
        const userId = myUserId;
        if (userId) {
            convex.mutation(api.voiceState.setWatchingStream, {
                userId,
                ...(identity ? { watchingStream: identity } : {}),
            }).catch(e => console.error('Failed to set watching stream:', e));
        }
        // Play join sound for the viewer starting to watch
        if (identity) {
            playSound('viewer_join');
        }
    }, [convex]);

    const clearWatchingStream = useCallback(() => {
        setWatchingStreamOfRaw(null);
        const userId = myUserId;
        if (userId) {
            convex.mutation(api.voiceState.setWatchingStream, { userId }).catch(
                e => console.error('Failed to clear watching stream:', e)
            );
        }
    }, [convex]);

    // Personal mute state (persisted to localStorage)
    const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => {
        const saved = localStorage.getItem('personallyMutedUsers');
        return new Set(saved ? JSON.parse(saved) : []);
    });

    // Per-user volume state: userId → 0-200 (persisted to localStorage)
    const [userVolumes, setUserVolumes] = useState(() => {
        const saved = localStorage.getItem('userVolumes');
        return saved ? JSON.parse(saved) : {};
    });

    const setUserVolume = useCallback((userId, volume) => {
        setUserVolumes(prev => {
            const next = { ...prev, [userId]: volume };
            localStorage.setItem('userVolumes', JSON.stringify(next));
            return next;
        });
        // Apply volume to LiveKit participant (factoring in global output volume)
        const participant = room?.remoteParticipants?.get(userId);
        const globalVol = globalOutputVolume / 100;
        if (participant) participant.setVolume(Math.min(2, (volume / 100) * globalVol));
        // Sync personal mute state
        if (volume === 0) {
            setPersonallyMutedUsers(prev => {
                const next = new Set(prev);
                next.add(userId);
                localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
                return next;
            });
        } else {
            setPersonallyMutedUsers(prev => {
                if (!prev.has(userId)) return prev;
                const next = new Set(prev);
                next.delete(userId);
                localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
                return next;
            });
        }
    }, [room, globalOutputVolume]);

    const getUserVolume = useCallback((userId) => {
        return userVolumes[userId] ?? 100;
    }, [userVolumes]);

    const togglePersonalMute = (userId) => {
        const globalVol = globalOutputVolume / 100;
        setPersonallyMutedUsers(prev => {
            const next = new Set(prev);
            if (next.has(userId)) {
                next.delete(userId);
                // Restore to stored volume (default 100)
                const vol = userVolumes[userId] ?? 100;
                const restoreVol = vol === 0 ? 100 : vol;
                const participant = room?.remoteParticipants?.get(userId);
                if (participant) participant.setVolume(Math.min(2, (restoreVol / 100) * globalVol));
                // Update stored volume if it was 0
                if (vol === 0) {
                    setUserVolumes(p => {
                        const n = { ...p, [userId]: 100 };
                        localStorage.setItem('userVolumes', JSON.stringify(n));
                        return n;
                    });
                }
            } else {
                next.add(userId);
                const participant = room?.remoteParticipants?.get(userId);
                if (participant) participant.setVolume(0);
            }
            localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
            return next;
        });
    };

    const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);

    const serverMute = async (targetUserId, isServerMuted) => {
        const actorUserId = myUserId;
        if (!actorUserId) return;
        try {
            await convex.mutation(api.voiceState.serverMute, { actorUserId, targetUserId, isServerMuted });
        } catch (e) {
            console.error('Failed to server mute:', e);
        }
    };

    const disconnectUser = async (targetUserId) => {
        const actorUserId = myUserId;
        if (!actorUserId) return;
        try {
            await convex.mutation(api.voiceState.disconnectUser, { actorUserId, targetUserId });
        } catch (e) {
            console.error('Failed to disconnect user:', e);
        }
    };

    const isServerMuted = (userId) => {
        for (const users of Object.values(voiceStates)) {
            const user = users.find(u => u.userId === userId);
            if (user) return !!user.isServerMuted;
        }
        return false;
    };

    const voiceStates = useQuery(api.voiceState.getAll) || {};
    const serverSettings = useQuery(api.serverSettings.get);

    // Subscribe to own join sound URL for self-join playback
    const myJoinSoundUrl = useQuery(
        api.auth.getMyJoinSoundUrl,
        myUserId ? { userId: myUserId } : "skip"
    );

    // Refs for detecting other-user joins via voiceStates changes
    const prevChannelUsersRef = useRef(new Set());
    const otherJoinInitRef = useRef(false);
    const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);

    async function updateVoiceState(fields) {
        const userId = myUserId;
        if (!userId || !activeChannelId) return;
        try {
            await convex.mutation(api.voiceState.updateState, { userId, ...fields });
        } catch (e) {
            console.error('Failed to update voice state:', e);
        }
    }

    const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => {
        if (activeChannelId === channelId) return;
        isDMCallRef.current = isDMCall;
        cancelConnectRef.current = false;

        // Channel hop — we're already in a voice room. Flip the
        // move-in-progress flag BEFORE disconnecting so the old
        // room's RoomEvent.Disconnected handler takes the short
        // "moving" branch: it clears room/token without touching
        // activeChannelId, skips the voiceState.leave mutation, and
        // skips the leave sound. Without this the handler races
        // against the rest of this function — nulling the channel
        // id we're about to set and calling leave() between our
        // disconnect and the new voiceState.join, which looks like
        // "joins, plays sound, drops out" from the user's side.
        const isMove = !!room;
        if (isMove) isMovingRef.current = true;

        if (room) await room.disconnect();

        setActiveChannelId(channelId);
        setActiveChannelName(channelName);
        setConnectionState('connecting');
        window.__inVoiceCall = true;

        // Cancellation check — called after every major async step.
        // If the user pressed disconnect while we were awaiting, roll
        // back: close the partially-built room (if any; its
        // Disconnected handler cleans up state), otherwise reset the
        // pre-room UI state manually. Returns true when the caller
        // should bail.
        const abortIfCancelled = (builtRoom) => {
            if (!cancelConnectRef.current) return false;
            if (builtRoom) {
                builtRoom.disconnect().catch(() => {});
            }
            // Always reset UI state on cancel, even when a room is
            // being torn down. The Disconnected handler's "moving"
            // branch preserves activeChannelId (needed for legit
            // moves), so we explicitly clear it here.
            setActiveChannelId(null);
            setActiveChannelName(null);
            setConnectionState('disconnected');
            setToken(null);
            window.__inVoiceCall = false;
            voiceService?.stopService();
            // If this was a channel hop we flipped isMovingRef on;
            // reset it so a later disconnect doesn't wrongly skip
            // voiceState.leave.
            isMovingRef.current = false;
            // Clean up any stale server-side voice state (the old
            // channel's row survives a cancelled move because the
            // "moving" branch skips voiceState.leave).
            convex.mutation(api.voiceState.leave, { userId }).catch(() => {});
            return true;
        };

        try {
            // Request microphone permission (triggers Android runtime prompt)
            try {
                const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
                stream.getTracks().forEach(t => t.stop());
            } catch (e) {
                console.error('Microphone permission denied:', e);
                setConnectionState('error');
                setActiveChannelId(null);
                return;
            }

            if (abortIfCancelled(null)) return;

            // Prove we control `userId` by signing the (userId, channelId,
            // timestamp) tuple with the Ed25519 key decrypted at login.
            // The server verifies with our public signing key before minting
            // a LiveKit JWT, so voice rooms can't be joined by forging args.
            const signingKey = sessionStorage.getItem('signingKey');
            if (!signingKey) {
                console.error('Missing signing key — cannot authorize voice join');
                setConnectionState('error');
                setActiveChannelId(null);
                return;
            }
            const timestamp = Date.now();
            const message = `voice-token:${userId}:${channelId}:${timestamp}`;
            const signature = await platform.crypto.signMessage(signingKey, message);

            if (abortIfCancelled(null)) return;

            const tokenResult = await convex.action(api.voice.getToken, {
                channelId,
                userId,
                timestamp,
                signature,
            });

            if ('error' in tokenResult) {
                console.error('Voice token rejected:', tokenResult.error);
                setConnectionState('error');
                setActiveChannelId(null);
                return;
            }

            if (abortIfCancelled(null)) return;

            const lkToken = tokenResult.token;
            if (!lkToken) throw new Error('Failed to get token');

            setToken(lkToken);

            // Voice Settings tab (settings/UserSettingsModal.tsx) writes
            // everything to a single `voiceSettings` JSON blob. Read it
            // here so user choices actually flow through to LiveKit.
            // Defaults: echoCancellation off (user preference — can be
            // re-enabled in settings), everything else on.
            let voiceSettings = {
                inputDeviceId: 'default',
                outputDeviceId: 'default',
                videoDeviceId: 'default',
                inputVolume: 100,
                noiseSuppression: true,
                echoCancellation: false,
                autoGainControl: true,
            };
            try {
                const raw = localStorage.getItem('voiceSettings');
                if (raw) {
                    const parsed = JSON.parse(raw);
                    if (parsed && typeof parsed === 'object') {
                        voiceSettings = { ...voiceSettings, ...parsed };
                    }
                }
            } catch {
                /* fall through to defaults */
            }
            const storedInputDevice = voiceSettings.inputDeviceId;
            const storedOutputDevice = voiceSettings.outputDeviceId;

            const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);

            const newRoom = new Room({
              webAudioMix: true,
              adaptiveStream: true,
              dynacast: true,
              autoSubscribe: true,
              rtcConfig: {
                iceServers: [
                  { urls: 'stun:stun.l.google.com:19302' },
                  { urls: 'stun:stun1.l.google.com:19302' },
                ],
              },
              audioCaptureDefaults: {
                autoGainControl: voiceSettings.autoGainControl,
                echoCancellation: voiceSettings.echoCancellation,
                noiseSuppression: voiceSettings.noiseSuppression,
                channelCount: 1,
                sampleRate: 48000,
                ...(storedInputDevice && storedInputDevice !== 'default' ? { deviceId: { exact: storedInputDevice } } : {}),
              },
              videoCaptureDefaults: {
                resolution: VideoPresets.h720.resolution,
              },
              publishDefaults: {
                audioPreset: { maxBitrate: 96_000 },
                dtx: false,
                red: true,
                videoEncoding: VideoPresets.h720.encoding,
                videoCodec: isMobile ? 'vp8' : 'vp9',
                screenShareEncoding: {
                  maxBitrate: 10_000_000,
                  maxFramerate: 60,
                },
                screenShareSimulcastLayers: [
                  { maxBitrate: 2_000_000, maxFramerate: 15, width: 1280, height: 720 },
                ],
              },
            });
            await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);

            // Cancelled during the LiveKit handshake — tear the room
            // back down before we wire any more state around it.
            if (abortIfCancelled(newRoom)) return;

            if (storedOutputDevice && storedOutputDevice !== 'default') {
                await newRoom.switchActiveDevice('audiooutput', storedOutputDevice).catch(e =>
                    console.warn('Failed to switch output device on connect:', e)
                );
            }

            await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);

            if (abortIfCancelled(newRoom)) return;

            setRoom(newRoom);
            setConnectionState('connected');
            // Start native foreground service for background voice on Android
            voiceService?.startService({ channelName, isMuted, isDeafened });
            // Play custom join sound if set, otherwise default
            if (myJoinSoundUrl) {
                playSoundUrl(myJoinSoundUrl);
            } else {
                playSound('join');
            }

            // Register the Disconnected / Reconnecting / etc. handlers
            // BEFORE `voiceState.join`. If the user taps disconnect in
            // the narrow window between `setRoom` and this mutation
            // returning, `disconnectVoice` calls `room.disconnect()` —
            // without the handler bound there's nothing to tear down
            // state. Keeping registration first closes that gap.
            newRoom.on(RoomEvent.Disconnected, async (reason) => {
                console.warn('Voice Room Disconnected. Reason:', reason);
                // If we're being moved, skip leave mutation — we'll reconnect shortly
                if (isMovingRef.current) {
                    setRoom(null);
                    setToken(null);
                    clearSpeakerTimers();
                    setActiveSpeakers(new Set());
                    setConnectionQualities({});
                    return;
                }

                // Auto-reconnect on token expiry
                if (reason === DisconnectReason.TOKEN_EXPIRED) {
                    console.log('Token expired, auto-reconnecting...');
                    setRoom(null);
                    setToken(null);
                    clearSpeakerTimers();
                    setActiveSpeakers(new Set());
                    setConnectionQualities({});
                    try {
                        await connectToVoice(channelId, channelName, userId, isDMCallRef.current);
                    } catch (e) {
                        console.error('Auto-reconnect failed:', e);
                    }
                    return;
                }

                voiceService?.stopService();
                playSound('leave');
                setConnectionState('disconnected');
                setActiveChannelId(null);
                window.__inVoiceCall = false;
                setRoom(null);
                setToken(null);
                clearSpeakerTimers();
                setActiveSpeakers(new Set());
                setConnectionQualities({});

                try {
                    await convex.mutation(api.voiceState.leave, { userId });
                } catch (e) {
                    console.error('Failed to leave voice state:', e);
                }
            });

            newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
                const currentSpeakerIds = new Set(speakers.map(p => p.identity));

                // Track when local user last spoke (for Android AFK detection)
                const localIdentity = newRoom.localParticipant?.identity;
                if (localIdentity && currentSpeakerIds.has(localIdentity)) {
                    lastSpokeRef.current = Date.now();
                }

                // Cancel pending removal timers for anyone who is speaking again
                for (const id of currentSpeakerIds) {
                    const timer = speakerRemovalTimers.current.get(id);
                    if (timer) {
                        clearTimeout(timer);
                        speakerRemovalTimers.current.delete(id);
                    }
                }

                setActiveSpeakers(prev => {
                    const next = new Set(prev);

                    // Add new speakers immediately
                    for (const id of currentSpeakerIds) {
                        next.add(id);
                    }

                    // Schedule delayed removal for speakers no longer in the event
                    for (const id of prev) {
                        if (!currentSpeakerIds.has(id) && !speakerRemovalTimers.current.has(id)) {
                            const timer = setTimeout(() => {
                                speakerRemovalTimers.current.delete(id);
                                setActiveSpeakers(s => {
                                    const updated = new Set(s);
                                    updated.delete(id);
                                    return updated;
                                });
                            }, 300);
                            speakerRemovalTimers.current.set(id, timer);
                        }
                    }

                    return next;
                });
            });

            newRoom.on(RoomEvent.Reconnecting, () => {
                console.warn('Voice room reconnecting...');
                setIsReconnecting(true);
                setConnectionState('reconnecting');
            });

            newRoom.on(RoomEvent.Reconnected, () => {
                console.log('Voice room reconnected');
                setIsReconnecting(false);
                setConnectionState('connected');
            });

            newRoom.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
                setConnectionQualities(prev => ({
                    ...prev,
                    [participant.identity]: quality,
                }));
            });

            // Commit the server-side voice state LAST. Until this
            // resolves the reactive `voiceStates` query still reports
            // us in the old channel, which is why we keep
            // `isMovingRef.current === true` across the mutation —
            // otherwise the reconcile effect below sees "server says
            // A, client says B" and kicks off a spurious reconnect
            // back to A (the bug that caused double-join / double-
            // leave sounds when switching channels).
            await convex.mutation(api.voiceState.join, {
                channelId,
                userId,
                username: localStorage.getItem('username') || 'Unknown',
                isMuted,
                isDeafened,
            });

            // Auto-mute when joining AFK channel
            if (serverSettings?.afkChannelId === channelId) {
                setIsMuted(true);
                await newRoom.localParticipant.setMicrophoneEnabled(false);
                await convex.mutation(api.voiceState.updateState, { userId, isMuted: true });
                voiceService?.updateNotification({ isMuted: true });
            }

            // Now that the server state has been committed and the
            // reactive query will (imminently) show us in `channelId`,
            // it's safe to re-enable the reconcile effect. A subtle
            // point: the query push can land either before or after
            // the mutation promise resolves; both orderings are fine
            // because in either case by the time the effect next
            // reads `voiceStates` it'll either agree with
            // activeChannelId or this flag will still be true.
            isMovingRef.current = false;

        } catch (err) {
            console.error('Voice Connection Failed:', err);
            setConnectionState('error');
            setActiveChannelId(null);
            window.__inVoiceCall = false;
            isMovingRef.current = false;
        }
    };

    // Heartbeat: send periodic heartbeat to prevent ghost voice states
    useEffect(() => {
        if (!activeChannelId) return;
        const userId = myUserId;
        if (!userId) return;

        const sendHeartbeat = () => {
            convex.mutation(api.voiceState.heartbeat, { userId }).catch(e =>
                console.warn('Heartbeat failed:', e)
            );
        };

        // Send immediately, then every 30 seconds
        sendHeartbeat();
        const interval = setInterval(sendHeartbeat, 30_000);
        return () => clearInterval(interval);
    }, [activeChannelId, convex]);

    // Handle notification action buttons (Android foreground service).
    // Capacitor's plugin API has historically shifted between returning
    // a listener handle synchronously and returning a Promise, so we
    // normalize both shapes into the same cleanup code instead of
    // leaving a silent no-op when neither matches.
    useEffect(() => {
        if (!voiceService) return;
        let cancelled = false;
        let resolvedHandle = null;

        const handle = voiceService.addNotificationActionListener((event) => {
            switch (event.action) {
                case 'disconnect':
                    disconnectVoice();
                    break;
                case 'toggleMute':
                    toggleMute();
                    break;
                case 'toggleDeafen':
                    toggleDeafen();
                    break;
            }
        });

        if (handle && typeof handle.then === 'function') {
            handle.then((l) => {
                if (cancelled) {
                    l?.remove?.();
                } else {
                    resolvedHandle = l;
                }
            }).catch(() => { /* nothing to clean up */ });
        } else {
            resolvedHandle = handle;
        }

        return () => {
            cancelled = true;
            try {
                resolvedHandle?.remove?.();
            } catch (e) {
                console.warn('Failed to remove notification listener:', e);
            }
        };
    }, [voiceService, activeChannelId]);

    // Detect when another user moves us to a different voice channel.
    //
    // `connectToVoice` is a plain function (not useCallback), so listing
    // it as a dep would re-run this effect on every render — an infinite
    // reconnect loop. We stash the latest reference in a ref so the
    // effect can call the freshest copy without triggering itself. The
    // audit flagged the old setup for potentially using stale
    // credentials if the move fired mid-render; the ref closes that gap.
    const connectToVoiceRef = useRef(connectToVoice);
    useEffect(() => {
        connectToVoiceRef.current = connectToVoice;
    });

    useEffect(() => {
        if (!myUserId || !activeChannelId || isMovingRef.current) return;

        // Find which channel the server says we're in
        let serverChannelId = null;
        for (const [chId, users] of Object.entries(voiceStates)) {
            if (users.some(u => u.userId === myUserId)) {
                serverChannelId = chId;
                break;
            }
        }

        // If server says we're in a different channel, reconnect
        if (serverChannelId && serverChannelId !== activeChannelId) {
            isMovingRef.current = true;
            const currentRoom = room;
            (async () => {
                try {
                    const channel = await convex.query(api.channels.get, { id: serverChannelId });
                    if (currentRoom) await currentRoom.disconnect();
                    await connectToVoiceRef.current(serverChannelId, channel?.name || 'Voice', myUserId);
                } catch (e) {
                    console.error('Failed to reconnect after move:', e);
                } finally {
                    isMovingRef.current = false;
                }
            })();
        }
    }, [voiceStates, activeChannelId, room, convex, myUserId]);

    // Enforce server mute: force-disable mic when server muted, restore when lifted
    useEffect(() => {
        if (!myUserId || !room) return;
        if (isServerMuted(myUserId)) {
            room.localParticipant.setMicrophoneEnabled(false);
        } else if (!isMuted && !isDeafened) {
            room.localParticipant.setMicrophoneEnabled(true);
        }
    }, [voiceStates, room, myUserId]);

    // Re-apply personal mutes/volumes when room or participants change
    useEffect(() => {
        if (!room) return;
        const globalVol = globalOutputVolume / 100;
        const applyVolumes = () => {
            for (const [identity, participant] of room.remoteParticipants) {
                if (personallyMutedUsers.has(identity)) {
                    participant.setVolume(0);
                } else {
                    const userVol = (userVolumes[identity] ?? 100) / 100;
                    participant.setVolume(Math.min(2, userVol * globalVol));
                }
            }
        };
        applyVolumes();
        room.on(RoomEvent.ParticipantConnected, applyVolumes);
        return () => room.off(RoomEvent.ParticipantConnected, applyVolumes);
    }, [room, personallyMutedUsers, userVolumes, globalOutputVolume]);

    // AFK idle polling: move user to AFK channel when idle exceeds timeout
    useEffect(() => {
        if (!activeChannelId || !serverSettings?.afkChannelId || isInAfkChannel) return;
        if (isDMCallRef.current) return; // Skip AFK for DM calls

        const isCapacitor = !!window.Capacitor?.isNativePlatform?.();

        // On desktop/web, require system idle API; on Capacitor, use lastSpokeRef
        if (!isCapacitor && !idle?.getSystemIdleTime) return;

        const afkTimeout = serverSettings.afkTimeout || 300;
        const interval = setInterval(async () => {
            try {
                let idleSeconds;
                if (isCapacitor) {
                    // On Android, idle = time since user last spoke in voice
                    idleSeconds = Math.floor((Date.now() - lastSpokeRef.current) / 1000);
                } else {
                    idleSeconds = await idle.getSystemIdleTime();
                }

                if (idleSeconds >= afkTimeout) {
                    const userId = myUserId;
                    if (!userId) return;

                    // On Capacitor, also set user status to idle
                    if (isCapacitor) {
                        const signingKey = sessionStorage.getItem('signingKey');
                        if (signingKey) {
                            const authTimestamp = Date.now();
                            const authSignature = await platform.crypto.signMessage(
                                signingKey,
                                `updateStatus:${userId}:idle:${authTimestamp}`,
                            );
                            await convex.action(api.authActions.updateStatus, {
                                userId,
                                status: 'idle',
                                authTimestamp,
                                authSignature,
                            });
                        }
                    }

                    await convex.mutation(api.voiceState.afkMove, {
                        userId,
                        afkChannelId: serverSettings.afkChannelId,
                    });
                    // After server-side move, locally mute
                    setIsMuted(true);
                    if (room) room.localParticipant.setMicrophoneEnabled(false);
                    voiceService?.updateNotification({ isMuted: true });
                }
            } catch (e) {
                console.error('AFK check failed:', e);
            }
        }, 15000);

        return () => clearInterval(interval);
    }, [activeChannelId, serverSettings?.afkChannelId, serverSettings?.afkTimeout, isInAfkChannel]);

    // Detect other users joining the same voice channel and play their join sound
    useEffect(() => {
        if (!activeChannelId) {
            prevChannelUsersRef.current = new Set();
            otherJoinInitRef.current = false;
            return;
        }

        const selfId = myUserId;
        const channelUsers = voiceStates[activeChannelId] || [];
        const currentUserIds = new Set(channelUsers.map(u => u.userId));

        // Guard: ignore transient empty states when we previously had users
        if (currentUserIds.size === 0 && prevChannelUsersRef.current.size > 0) {
            return;
        }

        // Skip the first render after joining to avoid playing sounds for users already in the channel
        if (!otherJoinInitRef.current) {
            otherJoinInitRef.current = true;
            prevChannelUsersRef.current = currentUserIds;
            return;
        }

        const prevIds = prevChannelUsersRef.current;

        // Detect new users (not self)
        for (const uid of currentUserIds) {
            if (uid !== selfId && !prevIds.has(uid)) {
                const userData = channelUsers.find(u => u.userId === uid);
                if (userData?.joinSoundUrl) {
                    playSoundUrl(userData.joinSoundUrl);
                } else {
                    playSound('join');
                }
                break; // one sound per update batch
            }
        }

        prevChannelUsersRef.current = currentUserIds;
    }, [voiceStates, activeChannelId, myUserId]);

    // Manage screen share subscriptions — only subscribe when actively watching
    useEffect(() => {
        if (!room) return;

        const manageSubscriptions = () => {
            let receivingAudio = false;
            for (const p of room.remoteParticipants.values()) {
                const { screenSharePub, screenShareAudioPub } = findTrackPubs(p);

                const shouldSubscribe = watchingStreamOf === p.identity;

                if (screenSharePub && screenSharePub.isSubscribed !== shouldSubscribe) {
                    screenSharePub.setSubscribed(shouldSubscribe);
                }
                if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) {
                    screenShareAudioPub.setSubscribed(shouldSubscribe);
                }

                if (shouldSubscribe && screenShareAudioPub && screenShareAudioPub.isSubscribed) {
                    receivingAudio = true;
                }
            }
            _suppressAppSounds = receivingAudio;
            setIsReceivingScreenShareAudio(receivingAudio);
        };

        manageSubscriptions();

        const onTrackChange = () => manageSubscriptions();
        room.on(RoomEvent.TrackPublished, onTrackChange);
        room.on(RoomEvent.TrackSubscribed, onTrackChange);
        room.on(RoomEvent.TrackUnsubscribed, onTrackChange);

        return () => {
            room.off(RoomEvent.TrackPublished, onTrackChange);
            room.off(RoomEvent.TrackSubscribed, onTrackChange);
            room.off(RoomEvent.TrackUnsubscribed, onTrackChange);
            _suppressAppSounds = false;
            setIsReceivingScreenShareAudio(false);
        };
    }, [room, watchingStreamOf]);

    // Auto-exit if watched participant stops streaming or disconnects
    useEffect(() => {
        if (watchingStreamOf === null || !room) return;

        const checkWatched = () => {
            // Check if participant is still connected
            const participant = room.remoteParticipants.get(watchingStreamOf)
                || (room.localParticipant.identity === watchingStreamOf ? room.localParticipant : null);

            if (!participant) {
                clearWatchingStream();
                return;
            }

            // Check if they're still screen sharing
            const { screenSharePub } = findTrackPubs(participant);
            if (!screenSharePub) {
                clearWatchingStream();
            }
        };

        // Also listen for voiceStates changes — covered by the dependency array re-run
        room.on(RoomEvent.ParticipantDisconnected, checkWatched);
        room.on(RoomEvent.TrackUnpublished, checkWatched);

        return () => {
            room.off(RoomEvent.ParticipantDisconnected, checkWatched);
            room.off(RoomEvent.TrackUnpublished, checkWatched);
        };
    }, [room, watchingStreamOf]);

    // Reset watching state when room disconnects
    useEffect(() => {
        if (!room) {
            clearWatchingStream();
        }
    }, [room]);

    // Detect viewer join/leave for the stream we're watching and play sounds
    const prevViewersRef = useRef(new Set());
    const viewerDetectionInitRef = useRef(false);
    useEffect(() => {
        if (!watchingStreamOf) {
            prevViewersRef.current = new Set();
            viewerDetectionInitRef.current = false;
            return;
        }

        // Collect all users currently watching the same stream
        const currentViewers = new Set();
        for (const users of Object.values(voiceStates)) {
            for (const u of users) {
                if (u.watchingStream === watchingStreamOf && u.userId !== myUserId) {
                    currentViewers.add(u.userId);
                }
            }
        }

        // Skip first render to avoid spurious sounds on load
        if (!viewerDetectionInitRef.current) {
            viewerDetectionInitRef.current = true;
            prevViewersRef.current = currentViewers;
            return;
        }

        const prev = prevViewersRef.current;

        // New viewers joined
        for (const uid of currentViewers) {
            if (!prev.has(uid)) {
                playSound('viewer_join');
                break; // one sound per update batch
            }
        }

        // Viewers left (excluding self)
        for (const uid of prev) {
            if (!currentViewers.has(uid)) {
                playSound('viewer_leave');
                break; // one sound per update batch
            }
        }

        prevViewersRef.current = currentViewers;
    }, [voiceStates, watchingStreamOf, myUserId]);

    // Detect screen-share publications starting / stopping across the
    // active voice channel (including the local user) and play a
    // "stream started" / "stream stopped" SFX on transitions. Reuses
    // the viewer_join / viewer_leave clips since they're already
    // loaded and sound right for the event.
    const prevSharersRef = useRef(new Set());
    const sharerDetectionInitRef = useRef(false);
    useEffect(() => {
        if (!activeChannelId) {
            prevSharersRef.current = new Set();
            sharerDetectionInitRef.current = false;
            return;
        }
        const channelUsers = voiceStates[activeChannelId] || [];
        const currentSharers = new Set();
        for (const u of channelUsers) {
            if (u.isScreenSharing) currentSharers.add(u.userId);
        }
        if (!sharerDetectionInitRef.current) {
            sharerDetectionInitRef.current = true;
            prevSharersRef.current = currentSharers;
            return;
        }
        const prev = prevSharersRef.current;
        // Stream started — someone in the channel is now sharing
        // who wasn't before.
        for (const uid of currentSharers) {
            if (!prev.has(uid)) {
                playSound('screenshare_start');
                break;
            }
        }
        // Stream stopped — someone who was sharing isn't anymore.
        for (const uid of prev) {
            if (!currentSharers.has(uid)) {
                playSound('screenshare_stop');
                break;
            }
        }
        prevSharersRef.current = currentSharers;
    }, [voiceStates, activeChannelId]);

    const disconnectVoice = () => {
        console.log('User manually disconnected voice');
        isDMCallRef.current = false;

        // Mid-connect cancel: `connectToVoice` is awaiting somewhere
        // (mic permission, Convex token action, LiveKit handshake).
        // Flip the cancel ref so the next `abortIfCancelled` check
        // tears things down, and reset visible UI state immediately
        // so the button reflects the user's intent even before the
        // in-flight awaits unwind.
        if (connectionState === 'connecting') {
            cancelConnectRef.current = true;
            setConnectionState('disconnected');
            setActiveChannelId(null);
            setActiveChannelName(null);
            setToken(null);
            window.__inVoiceCall = false;
            voiceService?.stopService();
            // If the LiveKit connect already completed before the
            // cancel fired, the room exists in state and we still
            // need to drop it. The RoomEvent.Disconnected handler
            // will run voiceState.leave and finish cleanup.
            if (room) room.disconnect().catch(() => {});
            return;
        }

        // A user-initiated disconnect always wants the "leave for
        // real" path, even if we're still inside the
        // `connectToVoice(new)` move window (connectionState ===
        // 'connected' but `isMovingRef` hasn't cleared yet because
        // `voiceState.join` is in flight). Clearing the move flag
        // here makes the Disconnected handler take the normal
        // branch: play leave, clear activeChannelId, run
        // voiceState.leave.
        isMovingRef.current = false;
        window.__inVoiceCall = false;
        voiceService?.stopService();
        if (room) room.disconnect();
    };

    const toggleMute = async () => {
        // Block unmute if server muted or in AFK channel
        if (isMuted && myUserId && isServerMuted(myUserId)) return;
        if (isMuted && isInAfkChannel) return;
        const nextState = !isMuted;
        // Flip LiveKit first. If this rejects we bail before committing any
        // UI state — otherwise the user sees "muted" while their mic is
        // still publishing to everyone in the room (a privacy leak, not
        // just a UX nit).
        if (room) {
            try {
                await room.localParticipant.setMicrophoneEnabled(!nextState);
            } catch (e) {
                console.error('Failed to toggle microphone:', e);
                return;
            }
        }
        setIsMuted(nextState);
        playSound(nextState ? 'mute' : 'unmute');
        voiceService?.updateNotification({ isMuted: nextState });
        try {
            await updateVoiceState({ isMuted: nextState });
        } catch (e) {
            // LiveKit is already in the right state, so audio is safe;
            // the server's voice-states row is just stale. Other clients
            // will pick up the correct value from our next successful
            // mutation or heartbeat. Log and move on.
            console.error('Failed to sync mute state to server:', e);
        }
    };

    const toggleDeafen = async () => {
        const nextState = !isDeafened;
        if (room && !isMuted) {
            try {
                await room.localParticipant.setMicrophoneEnabled(!nextState);
            } catch (e) {
                console.error('Failed to toggle microphone for deafen:', e);
                return;
            }
        }
        setIsDeafened(nextState);
        playSound(nextState ? 'deafen' : 'undeafen');
        voiceService?.updateNotification({ isDeafened: nextState });
        try {
            await updateVoiceState({ isDeafened: nextState });
        } catch (e) {
            console.error('Failed to sync deafen state to server:', e);
        }
    };

    // Actually flip the LiveKit screen-share publication on/off. The
    // earlier implementation only toggled local React state + the
    // voiceStates row, so the Share button lit up but no track ever
    // got published. `setScreenShareEnabled` is LiveKit's one-call
    // helper that prompts for `getDisplayMedia`, publishes the
    // resulting track, and tears it down on false.
    const setScreenSharing = async (active) => {
        if (!room) return;
        // Snapshot the screen-share publications *before* disabling so we
        // can explicitly stop the underlying MediaStreamTracks afterwards.
        // LiveKit's `setScreenShareEnabled(false)` unpublishes but doesn't
        // always fully release the getDisplayMedia tracks before returning,
        // which caused intermittent "NotAllowedError: Permission denied"
        // when the user re-shared immediately.
        const toStop = [];
        if (!active) {
            const pubs = room.localParticipant?.trackPublications;
            if (pubs?.forEach) {
                pubs.forEach((pub) => {
                    const src = pub.source ?? pub.track?.source;
                    if (src === 'screen_share' || src === 'screen_share_audio') {
                        if (pub.track?.mediaStreamTrack) {
                            toStop.push(pub.track.mediaStreamTrack);
                        }
                    }
                });
            }
        }
        try {
            await room.localParticipant.setScreenShareEnabled(active, {
                audio: true,
            });
        } catch (e) {
            console.warn('Failed to toggle screen share:', e);
            const published = !!room.localParticipant.getTrackPublication?.(
                'screen_share',
            );
            setIsScreenSharingLocal(published);
            await updateVoiceState({ isScreenSharing: published });
            return;
        }
        if (!active) {
            for (const mst of toStop) {
                try { mst.stop(); } catch { /* already stopped */ }
            }
        }
        setIsScreenSharingLocal(active);
        await updateVoiceState({ isScreenSharing: active });
    };

    // Camera toggle — publishes / unpublishes the local webcam track
    // via LiveKit's `setCameraEnabled` helper. Mirrors the mic and
    // screen-share code paths so the UI can show a single consistent
    // "active" state for each media type.
    const [isCameraOn, setIsCameraOn] = useState(false);
    const setCamera = async (active) => {
        if (!room) return;
        try {
            await room.localParticipant.setCameraEnabled(active);
            setIsCameraOn(active);
            playSound(active ? 'camera_on' : 'camera_off');
        } catch (e) {
            console.warn('Failed to toggle camera:', e);
            const published = !!room.localParticipant.isCameraEnabled;
            setIsCameraOn(published);
        }
    };
    const toggleCamera = () => setCamera(!isCameraOn);

    const switchDevice = useCallback(async (kind, deviceId) => {
        if (!room) return;
        try {
            await room.switchActiveDevice(kind, deviceId);
        } catch (e) {
            console.warn(`Failed to switch ${kind} device:`, e);
        }
    }, [room]);

    // ── Voice recording ─────────────────────────────────────────
    // Starts a `VoiceRecorder` bound to the current room. Returns
    // the session metadata on success so the UI can show a confirm
    // indicator. Gracefully no-ops (with a console warning) on
    // platforms that don't support recording.
    const startRecording = useCallback(async () => {
        if (!platform?.features?.hasRecording || !platform.recording) {
            console.warn('Recording is not available on this platform.');
            return null;
        }
        if (!room) {
            console.warn('Cannot start recording — not connected to a voice channel.');
            return null;
        }
        if (voiceRecorderRef.current) {
            return {
                sessionId: voiceRecorderRef.current.sessionId,
                startedAt: voiceRecorderRef.current.startedAt,
            };
        }
        try {
            // Resolve the user-preferred recording folder (settings
            // writes to localStorage under `voiceSettings`, same
            // bucket the mic/AGC toggles live in).
            let rootDir = null;
            try {
                const raw = localStorage.getItem('voiceSettings');
                if (raw) {
                    const parsed = JSON.parse(raw);
                    if (parsed && typeof parsed.recordingDir === 'string') {
                        rootDir = parsed.recordingDir;
                    }
                }
            } catch {}
            const recorder = new VoiceRecorder({
                platform,
                room,
                channelId: activeChannelId,
                channelName: activeChannelName,
                rootDir,
                onError: (err) => {
                    console.error('Voice recorder error:', err);
                    setRecordingError(err.message || 'Recording error');
                },
            });
            await recorder.start();
            voiceRecorderRef.current = recorder;
            setRecordingSessionId(recorder.sessionId);
            setRecordingStartedAt(recorder.startedAt);
            setIsRecording(true);
            setRecordingError(null);
            return { sessionId: recorder.sessionId, startedAt: recorder.startedAt };
        } catch (err) {
            console.error('Failed to start recording:', err);
            setRecordingError(err?.message || 'Failed to start recording');
            voiceRecorderRef.current = null;
            setIsRecording(false);
            setRecordingSessionId(null);
            setRecordingStartedAt(null);
            return null;
        }
    }, [platform, room, activeChannelId, activeChannelName]);

    const stopRecording = useCallback(async () => {
        const recorder = voiceRecorderRef.current;
        if (!recorder) return;
        voiceRecorderRef.current = null;
        try {
            await recorder.stop();
        } catch (err) {
            console.error('Failed to stop recording cleanly:', err);
            setRecordingError(err?.message || 'Failed to stop recording');
        } finally {
            setIsRecording(false);
            setRecordingSessionId(null);
            setRecordingStartedAt(null);
        }
    }, []);

    // Auto-stop the recorder if the user disconnects from the
    // voice channel — we don't want an orphaned VoiceRecorder
    // holding references to a destroyed LiveKit room.
    useEffect(() => {
        if (!room && voiceRecorderRef.current) {
            void stopRecording();
        }
    }, [room, stopRecording]);


    // Stable callback so the Provider value doesn't churn on a fresh arrow
    // function every render.
    const clearRecordingError = useCallback(() => setRecordingError(null), []);

    // Memoize the provider value so components that subscribe via
    // `useVoice()` don't re-render on every parent render. Inline object
    // literals caused every voice-aware component (sidebar, chat header,
    // user tiles, voice bar) to re-render whenever *anything* upstream
    // changed — a huge perf hit during active voice sessions.
    const value = useMemo(() => ({
        activeChannelId,
        activeChannelName,
        connectionState,
        connectToVoice,
        disconnectVoice,
        room,
        token,
        voiceStates,
        activeSpeakers,
        isMuted,
        isDeafened,
        toggleMute,
        toggleDeafen,
        isScreenSharing,
        setScreenSharing,
        isCameraOn,
        setCamera,
        toggleCamera,
        personallyMutedUsers,
        togglePersonalMute,
        isPersonallyMuted,
        userVolumes,
        setUserVolume,
        getUserVolume,
        serverMute,
        disconnectUser,
        isServerMuted,
        isInAfkChannel,
        serverSettings,
        watchingStreamOf,
        setWatchingStreamOf,
        switchDevice,
        globalOutputVolume,
        setGlobalOutputVolume,
        isReceivingScreenShareAudio,
        isReconnecting,
        connectionQualities,
        isRecording,
        recordingStartedAt,
        recordingSessionId,
        recordingError,
        startRecording,
        stopRecording,
        clearRecordingError,
    }), [
        activeChannelId,
        activeChannelName,
        connectionState,
        connectToVoice,
        disconnectVoice,
        room,
        token,
        voiceStates,
        activeSpeakers,
        isMuted,
        isDeafened,
        toggleMute,
        toggleDeafen,
        isScreenSharing,
        setScreenSharing,
        isCameraOn,
        setCamera,
        toggleCamera,
        personallyMutedUsers,
        togglePersonalMute,
        isPersonallyMuted,
        userVolumes,
        setUserVolume,
        getUserVolume,
        serverMute,
        disconnectUser,
        isServerMuted,
        isInAfkChannel,
        serverSettings,
        watchingStreamOf,
        setWatchingStreamOf,
        switchDevice,
        globalOutputVolume,
        setGlobalOutputVolume,
        isReceivingScreenShareAudio,
        isReconnecting,
        connectionQualities,
        isRecording,
        recordingStartedAt,
        recordingSessionId,
        recordingError,
        startRecording,
        stopRecording,
        clearRecordingError,
    ]);

    return (
        <VoiceContext.Provider value={value}>
            {children}
            {room && (
                <LiveKitRoom
                    room={room}
                    style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
                >
                     <RoomAudioRenderer muted={isDeafened} />
                </LiveKitRoom>
            )}
        </VoiceContext.Provider>
    );
};
