%% Copyright (C) 2026 Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.

-module(guild_voice_connection).

-import(guild_voice_unclaimed_account_utils, [parse_unclaimed_error/1]).

-include_lib("fluxer_gateway/include/voice_state.hrl").

-export([voice_state_update/2]).
-export([confirm_voice_connection_from_livekit/2]).
-export([request_voice_token/4]).
-export([request_voice_token/5]).
-export([request_voice_token/6]).
-export([sweep_expired_pending_joins/1]).

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.

-type guild_state() :: map().
-type voice_state() :: map().
-type voice_state_map() :: #{binary() => voice_state()}.
-type pending_voice_connections() :: #{binary() => map()}.
-type context() :: #{
    user_id := integer() | undefined,
    channel_id := integer() | null | undefined,
    session_id := term(),
    connection_id := binary() | undefined,
    raw_connection_id := term(),
    self_mute := boolean(),
    self_deaf := boolean(),
    self_video := boolean(),
    self_stream := boolean(),
    is_mobile := boolean(),
    viewer_stream_keys := term()
}.

-spec voice_state_update(map(), guild_state()) ->
    {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
voice_state_update(Request, State) ->
    Context = build_context(Request),
    case maps:get(user_id, Context) of
        undefined ->
            {reply, gateway_errors:error(voice_invalid_user_id), State};
        UserId ->
            VoiceStates = voice_state_utils:voice_states(State),
            case guild_voice_member:find_member_by_user_id(UserId, State) of
                undefined ->
                    {reply, gateway_errors:error(voice_member_not_found), State};
                Member ->
                    handle_member_voice(Context, Member, VoiceStates, State)
            end
    end.

-spec handle_member_voice(context(), map(), voice_state_map(), guild_state()) ->
    {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_member_voice(Context, Member, VoiceStates, State) ->
    case maps:get(channel_id, Context) of
        undefined ->
            {reply, gateway_errors:error(voice_invalid_channel_id), State};
        null ->
            handle_disconnect(Context, VoiceStates, State);
        ChannelIdValue ->
            handle_voice_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State)
    end.

-spec handle_disconnect(context(), voice_state_map(), guild_state()) ->
    {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_disconnect(Context, VoiceStates, State) ->
    guild_voice_disconnect:handle_voice_disconnect(
        maps:get(raw_connection_id, Context),
        maps:get(session_id, Context),
        maps:get(user_id, Context),
        VoiceStates,
        State
    ).

-spec handle_voice_connect_or_update(
    context(), integer(), map(), voice_state_map(), guild_state()
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_voice_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State) ->
    ConnectionId = maps:get(connection_id, Context),
    Channel = guild_voice_member:find_channel_by_id(ChannelIdValue, State),
    case Channel of
        undefined ->
            {reply, gateway_errors:error(voice_channel_not_found), State};
        _ ->
            case ConnectionId of
                undefined ->
                    handle_new_connection(Context, Member, Channel, VoiceStates, State);
                _ ->
                    handle_update_connection(
                        Context,
                        ChannelIdValue,
                        Member,
                        Channel,
                        VoiceStates,
                        State
                    )
            end
    end.

-spec handle_update_connection(
    context(), integer(), map(), map(), voice_state_map(), guild_state()
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_update_connection(Context, ChannelIdValue, Member, Channel, VoiceStates, State) ->
    ConnectionId = maps:get(connection_id, Context),
    UserId = maps:get(user_id, Context),
    case maps:get(ConnectionId, VoiceStates, undefined) of
        undefined ->
            case
                maybe_restore_pending_connection(
                    ConnectionId, ChannelIdValue, UserId, VoiceStates, State
                )
            of
                {ok, UpdatedVoiceStates, UpdatedState} ->
                    logger:debug(
                        "Restored pending voice connection during update",
                        #{
                            connection_id => ConnectionId,
                            user_id => UserId,
                            channel_id => ChannelIdValue
                        }
                    ),
                    ExistingVoiceState = maps:get(ConnectionId, UpdatedVoiceStates),
                    ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
                    NewChannelIdBin = integer_to_binary(ChannelIdValue),
                    IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin,
                    GuildId = map_utils:get_integer(UpdatedState, id, undefined),
                    ViewerKeyResult =
                        resolve_viewer_stream_keys(
                            Context,
                            GuildId,
                            ChannelIdValue,
                            UpdatedVoiceStates,
                            ExistingVoiceState
                        ),
                    normal_update_connection(
                        Context,
                        ChannelIdValue,
                        Member,
                        Channel,
                        UpdatedVoiceStates,
                        UpdatedState,
                        IsChannelChange,
                        ViewerKeyResult
                    );
                {error, ErrorAtom} ->
                    logger:debug(
                        "Failed to restore pending voice connection during update",
                        #{
                            connection_id => ConnectionId,
                            user_id => UserId,
                            channel_id => ChannelIdValue,
                            error => ErrorAtom
                        }
                    ),
                    {reply, gateway_errors:error(ErrorAtom), State}
            end;
        ExistingVoiceState ->
            case guild_voice_state:user_matches_voice_state(ExistingVoiceState, UserId) of
                false ->
                    {reply, gateway_errors:error(voice_user_mismatch), State};
                true ->
                    ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
                    NewChannelIdBin = integer_to_binary(ChannelIdValue),
                    IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin,
                    GuildId = map_utils:get_integer(State, id, undefined),
                    ViewerKeyResult =
                        resolve_viewer_stream_keys(
                            Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState
                        ),
                    normal_update_connection(
                        Context,
                        ChannelIdValue,
                        Member,
                        Channel,
                        VoiceStates,
                        State,
                        IsChannelChange,
                        ViewerKeyResult
                    )
            end
    end.

-spec maybe_restore_pending_connection(
    binary(),
    integer(),
    integer(),
    voice_state_map(),
    guild_state()
) -> {ok, voice_state_map(), guild_state()} | {error, atom()}.
maybe_restore_pending_connection(ConnectionId, ChannelIdValue, UserId, VoiceStates, State) ->
    PendingConnections = pending_voice_connections(State),
    case maps:get(ConnectionId, PendingConnections, undefined) of
        undefined ->
            {error, voice_connection_not_found};
        PendingData ->
            PendingUserId = pending_get_integer(PendingData, user_id),
            PendingChannelId = pending_get_integer(PendingData, channel_id),
            logger:debug(
                "Checking pending voice connection for restore",
                #{
                    connection_id => ConnectionId,
                    user_id => UserId,
                    channel_id => ChannelIdValue,
                    pending_user_id => PendingUserId,
                    pending_channel_id => PendingChannelId
                }
            ),
            case {PendingUserId, PendingChannelId} of
                {UserId, ChannelIdValue} ->
                    case pending_get_integer(PendingData, expires_at) of
                        ExpiresAt when is_integer(ExpiresAt) ->
                            Now = erlang:system_time(millisecond),
                            case Now >= ExpiresAt of
                                true ->
                                    {error, voice_pending_expired};
                                false ->
                                    restore_pending_connection(
                                        ConnectionId,
                                        PendingConnections,
                                        PendingData,
                                        VoiceStates,
                                        State
                                    )
                            end;
                        _ ->
                            restore_pending_connection(
                                ConnectionId,
                                PendingConnections,
                                PendingData,
                                VoiceStates,
                                State
                            )
                    end;
                _ ->
                    {error, voice_connection_not_found}
            end
    end.

-spec restore_pending_connection(
    binary(),
    pending_voice_connections(),
    map(),
    voice_state_map(),
    guild_state()
) -> {ok, voice_state_map(), guild_state()} | {error, atom()}.
restore_pending_connection(ConnectionId, PendingConnections, PendingData, VoiceStates, State) ->
    VoiceState = resolve_voice_state_from_pending(ConnectionId, PendingData, State, VoiceStates),
    case VoiceState of
        undefined ->
            {error, voice_connection_not_found};
        _ ->
            NewPendingConnections = maps:remove(ConnectionId, PendingConnections),
            StateWithoutPending = maps:put(pending_voice_connections, NewPendingConnections, State),
            UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
            StateWithVoiceStates = maps:put(voice_states, UpdatedVoiceStates, StateWithoutPending),
            StateCleared = clear_virtual_access_flags_from_voice_state(
                VoiceState, StateWithVoiceStates
            ),
            ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
            guild_voice_broadcast:broadcast_voice_state_update(VoiceState, StateCleared, ChannelIdBin),
            {ok, UpdatedVoiceStates, StateCleared}
    end.

-spec normal_update_connection(
    context(),
    integer(),
    map(),
    map(),
    voice_state_map(),
    guild_state(),
    boolean(),
    {ok, term()} | {error, atom()}
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
normal_update_connection(
    Context,
    ChannelIdValue,
    Member,
    Channel,
    VoiceStates,
    State,
    IsChannelChange,
    ViewerKeyResult
) ->
    ConnectionId = maps:get(connection_id, Context),
    UserId = maps:get(user_id, Context),
    PermCheck =
        case IsChannelChange of
            true ->
                guild_voice_permissions:check_voice_permissions_and_limits(
                    UserId, ChannelIdValue, Channel, VoiceStates, State, false
                );
            false ->
                {ok, allowed}
        end,
    case PermCheck of
        {error, _Category, ErrorAtom} ->
            {reply, gateway_errors:error(ErrorAtom), State};
        {ok, allowed} ->
            case check_camera_user_limit(Context, ChannelIdValue, VoiceStates) of
                {error, CameraErrorAtom} ->
                    {reply, gateway_errors:error(CameraErrorAtom), State};
                ok ->
                    case ViewerKeyResult of
                        {error, ErrorAtom} ->
                            {reply, gateway_errors:error(ErrorAtom), State};
                        {ok, ParsedViewerKey} ->
                            case IsChannelChange of
                                true ->
                                    handle_client_channel_move(
                                        Context, ChannelIdValue, Member, ConnectionId,
                                        VoiceStates, State, ParsedViewerKey
                                    );
                                false ->
                                    Flags = voice_state_utils:voice_flags_from_context(Context),
                                    guild_voice_state:update_voice_state_data(
                                        ConnectionId,
                                        integer_to_binary(ChannelIdValue),
                                        Flags,
                                        Member,
                                        maps:get(ConnectionId, VoiceStates),
                                        VoiceStates,
                                        State,
                                        false,
                                        ParsedViewerKey
                                    )
                            end
                    end
            end
    end.

-spec handle_client_channel_move(
    context(), integer(), map(), binary(), voice_state_map(), guild_state(), term()
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_client_channel_move(
    Context, ChannelIdValue, Member, ConnectionId, VoiceStates, State, _ParsedViewerKey
) ->
    UserId = maps:get(user_id, Context),
    SessionId = maps:get(session_id, Context),
    ExistingVoiceState = maps:get(ConnectionId, VoiceStates),
    OldChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
    State0 = guild_virtual_channel_access:mark_pending_join(UserId, ChannelIdValue, State),
    State1 = guild_virtual_channel_access:mark_preserve(UserId, ChannelIdValue, State0),
    State2 = guild_virtual_channel_access:mark_move_pending(UserId, ChannelIdValue, State1),
    NewVoiceStates = maps:remove(ConnectionId, VoiceStates),
    State3 = maps:put(voice_states, NewVoiceStates, State2),
    DisconnectVoiceState = maps:put(<<"channel_id">>, null, ExistingVoiceState),
    guild_voice_broadcast:broadcast_voice_state_update(
        DisconnectVoiceState, State3, OldChannelIdBin
    ),
    case resolve_guild_identity(State3) of
        {error, ErrorAtom} ->
            {reply, gateway_errors:error(ErrorAtom), State3};
        {ok, GuildId, _GuildIdBin} ->
            VoicePermissions = voice_utils:compute_voice_permissions(
                UserId, ChannelIdValue, State3
            ),
            TokenNonce = voice_utils:generate_token_nonce(),
            case request_voice_token(GuildId, ChannelIdValue, UserId, ConnectionId, VoicePermissions, TokenNonce) of
                {ok, TokenData} ->
                    Token = maps:get(token, TokenData),
                    Endpoint = maps:get(endpoint, TokenData),
                    NewConnectionId = maps:get(connection_id, TokenData),
                    SessionIdBin = normalize_session_id(SessionId),
                    ServerMute = maps:get(<<"mute">>, Member, false),
                    ServerDeaf = maps:get(<<"deaf">>, Member, false),
                    Flags = voice_state_utils:voice_flags_from_context(Context),
                    #{
                        self_mute := SelfMuteFlag,
                        self_deaf := SelfDeafFlag,
                        self_video := SelfVideoFlag,
                        self_stream := SelfStreamFlag,
                        is_mobile := IsMobileFlag
                    } = Flags,
                    ChannelIdBin = integer_to_binary(ChannelIdValue),
                    UserIdBin = integer_to_binary(UserId),
                    GuildIdBin2 = integer_to_binary(GuildId),
                    VoiceState0 = guild_voice_state:create_voice_state(
                        GuildIdBin2,
                        ChannelIdBin,
                        UserIdBin,
                        NewConnectionId,
                        ServerMute,
                        ServerDeaf,
                        Flags,
                        []
                    ),
                    VoiceState1 = maybe_attach_session_id(VoiceState0, SessionIdBin),
                    VoiceState = maybe_attach_member(VoiceState1, Member),
                    Now = erlang:system_time(millisecond),
                    PendingMetadata = #{
                        user_id => UserId,
                        guild_id => GuildId,
                        channel_id => ChannelIdValue,
                        session_id => SessionIdBin,
                        self_mute => SelfMuteFlag,
                        self_deaf => SelfDeafFlag,
                        self_video => SelfVideoFlag,
                        self_stream => SelfStreamFlag,
                        is_mobile => IsMobileFlag,
                        server_mute => ServerMute,
                        server_deaf => ServerDeaf,
                        member => Member,
                        viewer_stream_keys => [],
                        voice_state => VoiceState,
                        token_nonce => TokenNonce,
                        created_at => Now,
                        expires_at => Now + 30000
                    },
                    PendingConnections = pending_voice_connections(State3),
                    NewPendingConnections = maps:put(
                        NewConnectionId, PendingMetadata, PendingConnections
                    ),
                    State4 = maps:put(
                        pending_voice_connections, NewPendingConnections, State3
                    ),
                    {reply,
                        #{
                            success => true,
                            needs_token => true,
                            token => Token,
                            endpoint => Endpoint,
                            connection_id => NewConnectionId,
                            voice_state => VoiceState
                        },
                        State4};
                {error, _Reason} ->
                    {reply, gateway_errors:error(voice_token_failed), State3}
            end
    end.

-spec handle_new_connection(context(), map(), map(), voice_state_map(), guild_state()) ->
    {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
handle_new_connection(Context, Member, Channel, VoiceStates, State) ->
    ChannelIdValue = maps:get(channel_id, Context),
    GuildId = map_utils:get_integer(State, id, undefined),
    ViewerKeyResult = resolve_viewer_stream_keys(Context, GuildId, ChannelIdValue, VoiceStates, #{}),
    normal_new_connection(Context, Member, Channel, VoiceStates, State, ViewerKeyResult).

-spec normal_new_connection(
    context(), map(), map(), voice_state_map(), guild_state(), {ok, term()} | {error, atom()}
) -> {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
normal_new_connection(Context, Member, Channel, VoiceStates, State, ViewerKeyResult) ->
    UserId = maps:get(user_id, Context),
    ChannelIdValue = maps:get(channel_id, Context),
    PermCheck = guild_voice_permissions:check_voice_permissions_and_limits(
        UserId, ChannelIdValue, Channel, VoiceStates, State, false
    ),
    case PermCheck of
        {error, _Category, ErrorAtom} ->
            {reply, gateway_errors:error(ErrorAtom), State};
        {ok, allowed} ->
            case check_camera_user_limit(Context, ChannelIdValue, VoiceStates) of
                {error, CameraErrorAtom} ->
                    {reply, gateway_errors:error(CameraErrorAtom), State};
                ok ->
                    case ViewerKeyResult of
                        {error, ErrorAtom} ->
                            {reply, gateway_errors:error(ErrorAtom), State};
                        {ok, ParsedViewerKey} ->
                            get_voice_token_and_create_state(Context, Member, ParsedViewerKey, State)
                    end
            end
    end.

-spec get_voice_token_and_create_state(context(), map(), term(), guild_state()) ->
    {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
get_voice_token_and_create_state(Context, Member, ParsedViewerStreamKey, State) ->
    ChannelIdValue = maps:get(channel_id, Context),
    UserId = maps:get(user_id, Context),
    State0 = guild_virtual_channel_access:clear_pending_join(UserId, ChannelIdValue, State),
    State1 = guild_virtual_channel_access:clear_preserve(UserId, ChannelIdValue, State0),
    State2 = guild_virtual_channel_access:clear_move_pending(UserId, ChannelIdValue, State1),
    case resolve_guild_identity(State2) of
        {error, ErrorAtom} ->
            {reply, gateway_errors:error(ErrorAtom), State2};
        {ok, GuildId, GuildIdBin} ->
            VoicePermissions = voice_utils:compute_voice_permissions(
                UserId, ChannelIdValue, State2
            ),
            TokenNonce = voice_utils:generate_token_nonce(),
            case request_voice_token(GuildId, ChannelIdValue, UserId, null, VoicePermissions, TokenNonce) of
                {ok, TokenData} ->
                    Token = maps:get(token, TokenData),
                    Endpoint = maps:get(endpoint, TokenData),
                    ConnectionId = maps:get(connection_id, TokenData),
                    ChannelIdBin = integer_to_binary(ChannelIdValue),
                    UserIdBin = integer_to_binary(UserId),
                    ServerMute = maps:get(<<"mute">>, Member, false),
                    ServerDeaf = maps:get(<<"deaf">>, Member, false),
                    Flags = voice_state_utils:voice_flags_from_context(Context),
                    #{
                        self_mute := SelfMuteFlag,
                        self_deaf := SelfDeafFlag,
                        self_video := SelfVideoFlag,
                        self_stream := SelfStreamFlag,
                        is_mobile := IsMobileFlag
                    } = Flags,
                    SessionIdValue = maps:get(session_id, Context, undefined),
                    SessionIdBin = normalize_session_id(SessionIdValue),
                    VoiceState0 = guild_voice_state:create_voice_state(
                        GuildIdBin,
                        ChannelIdBin,
                        UserIdBin,
                        ConnectionId,
                        ServerMute,
                        ServerDeaf,
                        Flags,
                        ParsedViewerStreamKey
                    ),
                    VoiceState1 = maybe_attach_session_id(VoiceState0, SessionIdBin),
                    VoiceState = maybe_attach_member(VoiceState1, Member),
                    %% NOTE: We intentionally do NOT add the voice state to voice_states
                    %% or broadcast it yet. The voice state will only be added and
                    %% broadcast when LiveKit confirms the user has actually connected
                    %% via confirm_voice_connection_from_livekit/2.
                    %% This prevents users from appearing in voice channels before
                    %% they're actually connected and ready to communicate.
                    Now = erlang:system_time(millisecond),
                    PendingMetadata = #{
                        user_id => UserId,
                        guild_id => GuildId,
                        channel_id => ChannelIdValue,
                        session_id => SessionIdBin,
                        self_mute => SelfMuteFlag,
                        self_deaf => SelfDeafFlag,
                        self_video => SelfVideoFlag,
                        self_stream => SelfStreamFlag,
                        is_mobile => IsMobileFlag,
                        server_mute => ServerMute,
                        server_deaf => ServerDeaf,
                        member => Member,
                        viewer_stream_keys => ParsedViewerStreamKey,
                        voice_state => VoiceState,
                        token_nonce => TokenNonce,
                        created_at => Now,
                        expires_at => Now + 30000
                    },
                    PendingConnections = pending_voice_connections(State2),
                    NewPendingConnections = maps:put(
                        ConnectionId,
                        PendingMetadata,
                        PendingConnections
                    ),
                    NewState = maps:put(
                        pending_voice_connections, NewPendingConnections, State2
                    ),
                    {reply,
                        #{
                            success => true,
                            token => Token,
                            endpoint => Endpoint,
                            connection_id => ConnectionId,
                            voice_state => VoiceState
                        },
                        NewState};
                {error, _Reason} ->
                    {reply, gateway_errors:error(voice_token_failed), State}
            end
    end.

-spec build_context(map()) -> context().
build_context(Request0) ->
    Request = map_utils:ensure_map(Request0),
    RawConnectionId = maps:get(connection_id, Request, undefined),
    #{
        user_id => normalize_user_id(maps:get(user_id, Request, undefined)),
        channel_id => normalize_channel_id_value(maps:get(channel_id, Request, null)),
        session_id => maps:get(session_id, Request, undefined),
        connection_id => normalize_connection_id(RawConnectionId),
        raw_connection_id => RawConnectionId,
        self_mute => normalize_boolean(maps:get(self_mute, Request, false)),
        self_deaf => normalize_boolean(maps:get(self_deaf, Request, false)),
        self_video => normalize_boolean(maps:get(self_video, Request, false)),
        self_stream => normalize_boolean(maps:get(self_stream, Request, false)),
        is_mobile => normalize_boolean(maps:get(is_mobile, Request, false)),
        viewer_stream_keys => maps:get(viewer_stream_keys, Request, undefined)
    }.

-spec normalize_connection_id(term()) -> binary() | undefined.
normalize_connection_id(undefined) ->
    undefined;
normalize_connection_id(null) ->
    undefined;
normalize_connection_id(ConnectionId) ->
    ConnectionId.

-spec normalize_user_id(term()) -> integer() | undefined.
normalize_user_id(Value) ->
    type_conv:to_integer(Value).

-spec normalize_channel_id_value(term()) -> integer() | null | undefined.
normalize_channel_id_value(null) ->
    null;
normalize_channel_id_value(Value) ->
    type_conv:to_integer(Value).

-spec normalize_boolean(term()) -> boolean().
normalize_boolean(true) -> true;
normalize_boolean(<<"true">>) -> true;
normalize_boolean(false) -> false;
normalize_boolean(<<"false">>) -> false;
normalize_boolean(_) -> false.

-spec maybe_attach_session_id(voice_state(), binary() | undefined) -> voice_state().
maybe_attach_session_id(VoiceState, undefined) ->
    VoiceState;
maybe_attach_session_id(VoiceState, SessionId) when is_binary(SessionId) ->
    maps:put(<<"session_id">>, SessionId, VoiceState).

-spec maybe_attach_member(voice_state(), map()) -> voice_state().
maybe_attach_member(VoiceState, Member) when is_map(Member) ->
    case maps:size(Member) of
        0 -> VoiceState;
        _ -> maps:put(<<"member">>, Member, VoiceState)
    end.

-spec normalize_session_id(term()) -> binary() | undefined.
normalize_session_id(undefined) ->
    undefined;
normalize_session_id(null) ->
    undefined;
normalize_session_id(SessionId) when is_binary(SessionId) ->
    SessionId;
normalize_session_id(SessionId) when is_integer(SessionId) ->
    integer_to_binary(SessionId);
normalize_session_id(SessionId) when is_list(SessionId) ->
    list_to_binary(SessionId);
normalize_session_id(SessionId) ->
    try
        erlang:iolist_to_binary(SessionId)
    catch
        _:_ -> undefined
    end.

-spec resolve_viewer_stream_keys(
    context(), integer() | undefined, integer(), voice_state_map(), voice_state()
) ->
    {ok, list()} | {error, atom()}.
resolve_viewer_stream_keys(Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState) ->
    RawKeys = maps:get(viewer_stream_keys, Context, undefined),
    case RawKeys of
        undefined ->
            {ok, maps:get(<<"viewer_stream_keys">>, ExistingVoiceState, [])};
        null ->
            {ok, []};
        Keys when is_list(Keys) ->
            validate_viewer_stream_keys(Keys, GuildId, ChannelIdValue, VoiceStates, []);
        _ ->
            {error, voice_invalid_state}
    end.

-spec validate_viewer_stream_keys(
    list(), integer() | undefined, integer(), voice_state_map(), list()
) ->
    {ok, list()} | {error, atom()}.
validate_viewer_stream_keys([], _GuildId, _ChannelIdValue, _VoiceStates, Acc) ->
    {ok, lists:reverse(Acc)};
validate_viewer_stream_keys([Key | Rest], GuildId, ChannelIdValue, VoiceStates, Acc) ->
    case validate_single_viewer_stream_key(Key, GuildId, ChannelIdValue, VoiceStates) of
        {ok, ValidKey} ->
            validate_viewer_stream_keys(Rest, GuildId, ChannelIdValue, VoiceStates, [ValidKey | Acc]);
        {error, _} = Error ->
            Error
    end.

-spec validate_single_viewer_stream_key(
    term(), integer() | undefined, integer(), voice_state_map()
) ->
    {ok, binary()} | {error, atom()}.
validate_single_viewer_stream_key(RawKey, _GuildId, _ChannelIdValue, _VoiceStates) when not is_binary(RawKey) ->
    {error, voice_invalid_state};
validate_single_viewer_stream_key(RawKey, GuildId, ChannelIdValue, VoiceStates) ->
    case voice_state_utils:parse_stream_key(RawKey) of
        {error, _} ->
            {error, voice_invalid_state};
        {ok, #{
            scope := guild,
            guild_id := ParsedGuildId,
            channel_id := ParsedChannelId,
            connection_id := StreamConnId
        }} when
            is_integer(ChannelIdValue), ParsedChannelId =:= ChannelIdValue
        ->
            GuildScopeCheck =
                case GuildId of
                    undefined -> ok;
                    ParsedGuildId -> ok;
                    _ -> error
                end,
            case GuildScopeCheck of
                ok ->
                    case maps:get(StreamConnId, VoiceStates, undefined) of
                        undefined ->
                            {error, voice_connection_not_found};
                        StreamVS ->
                            case
                                map_utils:get_integer(StreamVS, <<"channel_id">>, undefined)
                            of
                                ChannelIdValue -> {ok, RawKey};
                                _ -> {error, voice_invalid_state}
                            end
                    end;
                error ->
                    {error, voice_invalid_state}
            end;
        {ok, #{scope := dm, channel_id := ParsedChannelId}} when
            is_integer(ChannelIdValue), ParsedChannelId =:= ChannelIdValue
        ->
            {ok, RawKey};
        _ ->
            {error, voice_invalid_state}
    end.

-spec check_camera_user_limit(context(), integer(), voice_state_map()) -> ok | {error, atom()}.
check_camera_user_limit(Context, ChannelIdValue, VoiceStates) ->
    SelfVideo = maps:get(self_video, Context, false),
    case SelfVideo of
        false ->
            ok;
        true ->
            UserIds = lists:foldl(
                fun({_ConnId, VS}, Acc) ->
                    case map_utils:get_integer(VS, <<"channel_id">>, undefined) of
                        ChannelIdValue ->
                            UserId = map_utils:get_integer(VS, <<"user_id">>, undefined),
                            case UserId of
                                undefined -> Acc;
                                _ -> sets:add_element(UserId, Acc)
                            end;
                        _ ->
                            Acc
                    end
                end,
                sets:new(),
                maps:to_list(VoiceStates)
            ),
            case sets:size(UserIds) > 25 of
                true -> {error, voice_camera_user_limit};
                false -> ok
            end
    end.

-spec resolve_voice_state_from_pending(binary(), map(), guild_state(), voice_state_map()) ->
    voice_state() | undefined.
resolve_voice_state_from_pending(ConnectionId, PendingData, State, VoiceStates) ->
    case maps:get(ConnectionId, VoiceStates, undefined) of
        VoiceState when is_map(VoiceState) ->
            VoiceState;
        _ ->
            case maps:get(voice_state, PendingData, undefined) of
                VoiceState when is_map(VoiceState) ->
                    VoiceState;
                _ ->
                    build_voice_state_from_pending(PendingData, ConnectionId, State)
            end
    end.

-spec build_voice_state_from_pending(map(), binary(), guild_state()) -> voice_state() | undefined.
build_voice_state_from_pending(PendingData, ConnectionId, State) ->
    GuildIdState = map_utils:get_integer(State, id, undefined),
    GuildId0 = pending_get_integer(PendingData, guild_id),
    GuildId =
        case GuildId0 of
            undefined -> GuildIdState;
            _ -> GuildId0
        end,
    ChannelId = pending_get_integer(PendingData, channel_id),
    UserId = pending_get_integer(PendingData, user_id),
    case {GuildId, ChannelId, UserId} of
        {undefined, _, _} ->
            undefined;
        {_, undefined, _} ->
            undefined;
        {_, _, undefined} ->
            undefined;
        {GId, ChId, UId} ->
            GuildIdBin = integer_to_binary(GId),
            ChannelIdBin = integer_to_binary(ChId),
            UserIdBin = integer_to_binary(UId),
            Flags = #{
                self_mute => pending_get_boolean(PendingData, self_mute),
                self_deaf => pending_get_boolean(PendingData, self_deaf),
                self_video => pending_get_boolean(PendingData, self_video),
                self_stream => pending_get_boolean(PendingData, self_stream),
                is_mobile => pending_get_boolean(PendingData, is_mobile)
            },
            ServerMute = pending_get_boolean(PendingData, server_mute),
            ServerDeaf = pending_get_boolean(PendingData, server_deaf),
            ViewerStreamKeys = pending_get_value(PendingData, viewer_stream_keys),
            VoiceState0 = guild_voice_state:create_voice_state(
                GuildIdBin,
                ChannelIdBin,
                UserIdBin,
                ConnectionId,
                ServerMute,
                ServerDeaf,
                Flags,
                ViewerStreamKeys
            ),
            SessionId = pending_get_binary(PendingData, session_id),
            Member = pending_get_map(PendingData, member),
            VoiceState1 = maybe_attach_session_id(VoiceState0, SessionId),
            maybe_attach_member(VoiceState1, Member)
    end.

-spec pending_get_value(map(), atom()) -> term().
pending_get_value(PendingData, Key) ->
    case maps:get(Key, PendingData, undefined) of
        undefined ->
            BinKey = atom_to_binary(Key, utf8),
            maps:get(BinKey, PendingData, undefined);
        Value ->
            Value
    end.

-spec pending_get_integer(map(), atom()) -> integer() | undefined.
pending_get_integer(PendingData, Key) ->
    case pending_get_value(PendingData, Key) of
        undefined -> undefined;
        Value -> type_conv:to_integer(Value)
    end.

-spec pending_get_boolean(map(), atom()) -> boolean().
pending_get_boolean(PendingData, Key) ->
    case pending_get_value(PendingData, Key) of
        true -> true;
        false -> false;
        _ -> false
    end.

-spec pending_get_binary(map(), atom()) -> binary() | undefined.
pending_get_binary(PendingData, Key) ->
    case pending_get_value(PendingData, Key) of
        undefined -> undefined;
        Value -> type_conv:to_binary(Value)
    end.

-spec pending_get_map(map(), atom()) -> map().
pending_get_map(PendingData, Key) ->
    case pending_get_value(PendingData, Key) of
        Map when is_map(Map) -> Map;
        _ -> #{}
    end.

-spec resolve_guild_identity(guild_state()) ->
    {ok, integer(), binary()} | {error, atom()}.
resolve_guild_identity(State) ->
    Data = guild_data(State),
    DataGuildIdBin = maps:get(<<"id">>, Data, undefined),
    StateGuildId = map_utils:get_integer(State, id, undefined),
    GuildMeta = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})),
    GuildMetaIdBin = maps:get(<<"id">>, GuildMeta, undefined),
    resolve_guild_id_priority([
        {DataGuildIdBin, fun normalize_guild_id/1},
        {StateGuildId, fun normalize_guild_id/1},
        {GuildMetaIdBin, fun normalize_guild_id/1}
    ]).

-spec resolve_guild_id_priority([
    {term(), fun((term()) -> {ok, integer(), binary()} | {error, atom()})}
]) ->
    {ok, integer(), binary()} | {error, atom()}.
resolve_guild_id_priority([]) ->
    {error, voice_guild_id_missing};
resolve_guild_id_priority([{undefined, _} | Rest]) ->
    resolve_guild_id_priority(Rest);
resolve_guild_id_priority([{Value, NormalizeFun} | _]) ->
    NormalizeFun(Value).

-spec normalize_guild_id(term()) -> {ok, integer(), binary()} | {error, atom()}.
normalize_guild_id(Value) ->
    case type_conv:to_integer(Value) of
        undefined ->
            {error, voice_invalid_guild_id};
        Int ->
            {ok, Int, guild_id_binary(Value, Int)}
    end.

-spec guild_id_binary(term(), integer()) -> binary().
guild_id_binary(Value, Int) ->
    case type_conv:to_binary(Value) of
        undefined -> integer_to_binary(Int);
        Bin -> Bin
    end.

-spec guild_data(guild_state()) -> map().
guild_data(State) ->
    map_utils:ensure_map(maps:get(data, State, #{})).

-spec confirm_voice_connection_from_livekit(map(), guild_state()) ->
    {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
confirm_voice_connection_from_livekit(Request, State) ->
    ConnectionId = maps:get(connection_id, Request, undefined),
    TokenNonce = maps:get(token_nonce, Request, undefined),
    case ConnectionId of
        undefined ->
            {reply, gateway_errors:error(voice_missing_connection_id), State};
        _ ->
            logger:debug(
                "Confirming voice connection from LiveKit",
                #{connection_id => ConnectionId, token_nonce => TokenNonce}
            ),
            PendingConnections = pending_voice_connections(State),
            case maps:get(ConnectionId, PendingConnections, undefined) of
                undefined ->
                    logger:debug(
                        "No pending voice connection found for LiveKit confirm",
                        #{connection_id => ConnectionId}
                    ),
                    VoiceStates = voice_state_utils:voice_states(State),
                    case maps:get(ConnectionId, VoiceStates, undefined) of
                        VoiceState when is_map(VoiceState) ->
                            {reply, #{success => true}, State};
                        _ ->
                            try_restore_from_recently_disconnected(ConnectionId, State)
                    end;
                PendingData ->
                    logger:debug(
                        "Found pending voice connection for LiveKit confirm",
                        #{
                            connection_id => ConnectionId,
                            pending_user_id => pending_get_integer(PendingData, user_id),
                            pending_channel_id => pending_get_integer(PendingData, channel_id)
                        }
                    ),
                    case validate_pending_nonce_and_expiry(TokenNonce, PendingData) of
                        {error, ErrorAtom} ->
                            logger:debug(
                                "LiveKit confirm rejected due to pending validation",
                                #{connection_id => ConnectionId, error => ErrorAtom}
                            ),
                            {reply, gateway_errors:error(ErrorAtom), State};
                        ok ->
                            VoiceStates = voice_state_utils:voice_states(State),
                            VoiceState = resolve_voice_state_from_pending(
                                ConnectionId, PendingData, State, VoiceStates
                            ),
                            NewPendingConnections = maps:remove(ConnectionId, PendingConnections),
                            StateWithoutPending = maps:put(
                                pending_voice_connections, NewPendingConnections, State
                            ),
                            case VoiceState of
                                undefined ->
                                    {reply, #{success => true}, StateWithoutPending};
                                _ ->
                                    UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
                                    StateWithVoiceStates = maps:put(
                                        voice_states, UpdatedVoiceStates, StateWithoutPending
                                    ),
                                    StateCleared = clear_virtual_access_flags_from_voice_state(
                                        VoiceState, StateWithVoiceStates
                                    ),
                                    ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
                                    guild_voice_broadcast:broadcast_voice_state_update(
                                        VoiceState, StateCleared, ChannelIdBin
                                    ),
                                    {reply, #{success => true}, StateCleared}
                            end
                    end
            end
    end.

-spec validate_pending_nonce_and_expiry(binary() | undefined, map()) -> ok | {error, atom()}.
validate_pending_nonce_and_expiry(TokenNonce, PendingData) ->
    ExpiresAt = maps:get(expires_at, PendingData, undefined),
    Now = erlang:system_time(millisecond),
    case ExpiresAt of
        ExpiresAtVal when is_integer(ExpiresAtVal), Now >= ExpiresAtVal ->
            {error, voice_pending_expired};
        _ ->
            PendingNonce = maps:get(token_nonce, PendingData, undefined),
            case TokenNonce of
                undefined ->
                    ok;
                PendingNonce ->
                    ok;
                _ ->
                    {error, voice_nonce_mismatch}
            end
    end.

-spec try_restore_from_recently_disconnected(binary(), guild_state()) ->
    {reply, map(), guild_state()} | {reply, {error, atom(), atom()}, guild_state()}.
try_restore_from_recently_disconnected(ConnectionId, State) ->
    Cache = guild_voice_disconnect:recently_disconnected_voice_states(State),
    Now = erlang:system_time(millisecond),
    case maps:get(ConnectionId, Cache, undefined) of
        #{voice_state := VoiceState, disconnected_at := DisconnectedAt} when
            (Now - DisconnectedAt) < 60000
        ->
            restore_recently_disconnected(ConnectionId, VoiceState, Cache, State);
        _ ->
            {reply, gateway_errors:error(voice_connection_not_found), State}
    end.

-spec restore_recently_disconnected(binary(), voice_state(), map(), guild_state()) ->
    {reply, map(), guild_state()}.
restore_recently_disconnected(ConnectionId, VoiceState, Cache, State) ->
    VoiceStates = voice_state_utils:voice_states(State),
    UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
    NewCache = maps:remove(ConnectionId, Cache),
    State0 = maps:put(voice_states, UpdatedVoiceStates, State),
    State1 = maps:put(recently_disconnected_voice_states, NewCache, State0),
    ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
    guild_voice_broadcast:broadcast_voice_state_update(VoiceState, State1, ChannelIdBin),
    {reply, #{success => true}, State1}.

-spec clear_virtual_access_flags_from_voice_state(voice_state(), guild_state()) -> guild_state().
clear_virtual_access_flags_from_voice_state(VoiceState, State) when is_map(VoiceState) ->
    UserId = map_utils:get_integer(VoiceState, <<"user_id">>, undefined),
    ChannelId = map_utils:get_integer(VoiceState, <<"channel_id">>, undefined),
    case is_integer(UserId) andalso is_integer(ChannelId) of
        true ->
            State0 = guild_virtual_channel_access:clear_pending_join(UserId, ChannelId, State),
            State1 = guild_virtual_channel_access:clear_preserve(UserId, ChannelId, State0),
            guild_virtual_channel_access:clear_move_pending(UserId, ChannelId, State1);
        false ->
            State
    end.

-spec request_voice_token(integer(), integer(), integer(), map()) ->
    {ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, VoicePermissions) ->
    request_voice_token(GuildId, ChannelId, UserId, null, VoicePermissions).

-spec request_voice_token(integer(), integer(), integer(), binary() | null, map()) ->
    {ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions) ->
    request_voice_token(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions, null).

-spec request_voice_token(integer(), integer(), integer(), binary() | null, map(), binary() | null) ->
    {ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, ConnectionId, VoicePermissions, TokenNonce) ->
    Req = voice_utils:build_voice_token_rpc_request(
        GuildId, ChannelId, UserId, ConnectionId, null, null, VoicePermissions, TokenNonce
    ),
    case rpc_client:call(Req) of
        {ok, Data} ->
            {ok, #{
                token => maps:get(<<"token">>, Data),
                endpoint => maps:get(<<"endpoint">>, Data),
                connection_id => maps:get(<<"connectionId">>, Data)
            }};
        {error, {http_error, _Status, Body}} ->
            case parse_unclaimed_error(Body) of
                true ->
                    {error, voice_unclaimed_account};
                false ->
                    {error, voice_token_failed}
            end;
        {error, _Reason} ->
            {error, voice_token_failed}
    end.

-spec pending_voice_connections(guild_state()) -> pending_voice_connections().
pending_voice_connections(State) ->
    case maps:get(pending_voice_connections, State, undefined) of
        Map when is_map(Map) -> Map;
        _ -> #{}
    end.

-spec sweep_expired_pending_joins(guild_state()) -> guild_state().
sweep_expired_pending_joins(State) ->
    Now = erlang:system_time(millisecond),
    PendingConnections = maps:get(pending_voice_connections, State, #{}),
    {Expired, Remaining} = maps:fold(
        fun(ConnId, Metadata, {ExpAcc, RemAcc}) ->
            ExpiresAt = maps:get(expires_at, Metadata, Now + 999999),
            case Now >= ExpiresAt of
                true -> {[{ConnId, Metadata} | ExpAcc], RemAcc};
                false -> {ExpAcc, maps:put(ConnId, Metadata, RemAcc)}
            end
        end,
        {[], #{}},
        PendingConnections
    ),
    lists:foreach(
        fun({ConnId, Metadata}) ->
            UserId = maps:get(user_id, Metadata, undefined),
            GuildId = maps:get(guild_id, Metadata, undefined),
            ChannelId = maps:get(channel_id, Metadata, undefined),
            case {GuildId, ChannelId, UserId} of
                {GId, CId, UId} when is_integer(GId), is_integer(CId), is_integer(UId) ->
                    spawn(fun() ->
                        guild_voice_disconnect:force_disconnect_participant(GId, CId, UId, ConnId)
                    end);
                _ ->
                    ok
            end
        end,
        Expired
    ),
    StateCleared = lists:foldl(
        fun({_ConnId, Metadata}, AccState) ->
            ExpUserId = maps:get(user_id, Metadata, undefined),
            ExpChannelId = maps:get(channel_id, Metadata, undefined),
            case is_integer(ExpUserId) andalso is_integer(ExpChannelId) of
                true ->
                    S1 = guild_virtual_channel_access:clear_pending_join(
                        ExpUserId, ExpChannelId, AccState
                    ),
                    S2 = guild_virtual_channel_access:clear_preserve(
                        ExpUserId, ExpChannelId, S1
                    ),
                    guild_virtual_channel_access:clear_move_pending(
                        ExpUserId, ExpChannelId, S2
                    );
                false ->
                    AccState
            end
        end,
        State,
        Expired
    ),
    maps:put(pending_voice_connections, Remaining, StateCleared).

-ifdef(TEST).

required_voice_perms() ->
    constants:view_channel_permission() bor constants:connect_permission().

base_test_member(UserId) ->
    #{<<"user">> => #{<<"id">> => integer_to_binary(UserId)}}.

base_test_channel(ChannelId) ->
    #{
        <<"id">> => integer_to_binary(ChannelId),
        <<"type">> => 2,
        <<"user_limit">> => 0
    }.

base_test_state() ->
    #{
        id => 999,
        data => #{
            <<"channels">> => [base_test_channel(100)],
            <<"members">> => [base_test_member(10)]
        },
        voice_states => #{},
        test_perm_fun => fun(_) -> required_voice_perms() end
    }.

replace_channels(State, Channels) ->
    Data0 = maps:get(data, State, #{}),
    Data1 = maps:put(<<"channels">>, Channels, Data0),
    maps:put(data, Data1, State).

replace_guild_id(State, GuildIdValue) ->
    Data0 = maps:get(data, State, #{}),
    Data1 = maps:put(<<"id">>, GuildIdValue, Data0),
    maps:put(data, Data1, State).

replace_guild_meta_id(State, GuildIdValue) ->
    Data0 = maps:get(data, State, #{}),
    Guild0 = maps:get(<<"guild">>, Data0, #{}),
    Guild1 = maps:put(<<"id">>, GuildIdValue, Guild0),
    Data1 = maps:put(<<"guild">>, Guild1, Data0),
    maps:put(data, Data1, State).

build_context_normalizes_fields_test() ->
    Request = #{
        user_id => <<"42">>,
        channel_id => <<"99">>,
        connection_id => <<"conn">>,
        self_mute => true,
        self_deaf => <<"nope">>,
        self_video => true,
        self_stream => false,
        is_mobile => <<"yes">>
    },
    Context = build_context(Request),
    ?assertEqual(42, maps:get(user_id, Context)),
    ?assertEqual(99, maps:get(channel_id, Context)),
    ?assertEqual(<<"conn">>, maps:get(connection_id, Context)),
    ?assertEqual(true, maps:get(self_mute, Context)),
    ?assertEqual(false, maps:get(self_deaf, Context)),
    ?assertEqual(true, maps:get(self_video, Context)),
    ?assertEqual(false, maps:get(self_stream, Context)),
    ?assertEqual(false, maps:get(is_mobile, Context)).

resolve_guild_identity_prefers_data_test() ->
    State = #{
        id => 7,
        data => #{
            <<"id">> => <<"555">>,
            <<"guild">> => #{<<"id">> => <<"111">>}
        }
    },
    ?assertMatch({ok, 555, <<"555">>}, resolve_guild_identity(State)).

normalize_guild_id_invalid_test() ->
    ?assertMatch({error, voice_invalid_guild_id}, normalize_guild_id(foo)).

voice_state_update_invalid_user_id_test() ->
    {reply, {error, validation_error, voice_invalid_user_id}, _} =
        voice_state_update(#{channel_id => null}, #{}).

voice_state_update_member_not_found_test() ->
    State = base_test_state(),
    {reply, {error, not_found, voice_member_not_found}, _} =
        voice_state_update(#{user_id => 99, channel_id => null}, State).

voice_state_update_invalid_channel_id_test() ->
    State = base_test_state(),
    {reply, {error, validation_error, voice_invalid_channel_id}, _} =
        voice_state_update(#{user_id => 10, channel_id => undefined}, State).

voice_state_update_channel_not_found_test() ->
    State = replace_channels(base_test_state(), []),
    {reply, {error, not_found, voice_channel_not_found}, _} =
        voice_state_update(#{user_id => 10, channel_id => 999}, State).

voice_state_update_connection_not_found_test() ->
    State = base_test_state(),
    Request = #{
        user_id => 10,
        channel_id => 100,
        connection_id => <<"missing-conn">>
    },
    {reply, {error, not_found, voice_connection_not_found}, _} =
        voice_state_update(Request, State).

voice_state_update_invalid_viewer_stream_keys_test() ->
    VoiceStates = #{
        <<"conn-1">> => #{
            <<"channel_id">> => <<"100">>,
            <<"user_id">> => <<"10">>
        }
    },
    State = maps:put(voice_states, VoiceStates, base_test_state()),
    Request = #{
        user_id => 10,
        channel_id => 100,
        connection_id => <<"conn-1">>,
        viewer_stream_keys => 123
    },
    {reply, {error, validation_error, voice_invalid_state}, _} =
        voice_state_update(Request, State).

voice_state_update_viewer_stream_keys_missing_connection_test() ->
    VoiceStates = #{
        <<"conn-1">> => #{
            <<"channel_id">> => <<"100">>,
            <<"user_id">> => <<"10">>
        }
    },
    State = maps:put(voice_states, VoiceStates, base_test_state()),
    Request = #{
        user_id => 10,
        channel_id => 100,
        connection_id => <<"conn-1">>,
        viewer_stream_keys => [<<"999:100:missing-conn">>]
    },
    {reply, {error, not_found, voice_connection_not_found}, _} =
        voice_state_update(Request, State).

voice_state_update_guild_id_missing_test() ->
    State0 = base_test_state(),
    State1 = replace_guild_id(State0, undefined),
    State2 = replace_guild_meta_id(State1, undefined),
    State = maps:remove(id, State2),
    Request = #{
        user_id => 10,
        channel_id => 100
    },
    {reply, {error, validation_error, voice_guild_id_missing}, _} =
        voice_state_update(Request, State).

voice_state_update_invalid_guild_id_test() ->
    State0 = base_test_state(),
    State1 = replace_guild_id(State0, <<"nope">>),
    State2 = replace_guild_meta_id(State1, <<"nope">>),
    State = maps:put(id, undefined, State2),
    Request = #{
        user_id => 10,
        channel_id => 100
    },
    {reply, {error, validation_error, voice_invalid_guild_id}, _} =
        voice_state_update(Request, State).

confirm_voice_connection_missing_id_test() ->
    State = base_test_state(),
    {reply, {error, validation_error, voice_missing_connection_id}, _} =
        confirm_voice_connection_from_livekit(#{}, State).

confirm_voice_connection_moves_pending_to_voice_states_test() ->
    VoiceState = #{
        <<"user_id">> => <<"5">>,
        <<"guild_id">> => <<"999">>,
        <<"channel_id">> => <<"100">>
    },
    PendingData = #{
        user_id => 5,
        guild_id => 999,
        channel_id => 100,
        voice_state => VoiceState
    },
    PendingConnections = #{<<"conn1">> => PendingData},
    State = maps:merge(base_test_state(), #{
        pending_voice_connections => PendingConnections,
        voice_states => #{}
    }),
    {reply, #{success := true}, NewState} =
        confirm_voice_connection_from_livekit(#{connection_id => <<"conn1">>}, State),
    NewVoiceStates = maps:get(voice_states, NewState),
    NewPending = maps:get(pending_voice_connections, NewState, #{}),
    ?assert(maps:is_key(<<"conn1">>, NewVoiceStates)),
    ?assertNot(maps:is_key(<<"conn1">>, NewPending)).

confirm_voice_connection_clears_pending_even_without_voice_state_test() ->
    PendingData = #{
        user_id => 5,
        channel_id => 100
    },
    PendingConnections = #{<<"conn1">> => PendingData},
    State = maps:merge(base_test_state(), #{
        pending_voice_connections => PendingConnections,
        voice_states => #{}
    }),
    {reply, #{success := true}, NewState} =
        confirm_voice_connection_from_livekit(#{connection_id => <<"conn1">>}, State),
    NewPending = maps:get(pending_voice_connections, NewState, #{}),
    ?assertNot(maps:is_key(<<"conn1">>, NewPending)).

confirm_voice_connection_not_found_in_pending_test() ->
    State = maps:merge(base_test_state(), #{
        pending_voice_connections => #{}
    }),
    {reply, {error, not_found, voice_connection_not_found}, _} =
        confirm_voice_connection_from_livekit(#{connection_id => <<"missing">>}, State).

confirm_voice_connection_found_in_voice_states_test() ->
    VoiceState = #{
        <<"user_id">> => <<"5">>,
        <<"guild_id">> => <<"999">>,
        <<"channel_id">> => <<"200">>
    },
    State = maps:merge(base_test_state(), #{
        pending_voice_connections => #{},
        voice_states => #{<<"conn1">> => VoiceState}
    }),
    {reply, #{success := true}, NewState} =
        confirm_voice_connection_from_livekit(#{connection_id => <<"conn1">>}, State),
    NewVoiceStates = maps:get(voice_states, NewState),
    ?assert(maps:is_key(<<"conn1">>, NewVoiceStates)),
    ?assertEqual(VoiceState, maps:get(<<"conn1">>, NewVoiceStates)).

resolve_voice_state_from_pending_uses_stored_voice_state_test() ->
    VoiceState = #{
        <<"user_id">> => <<"5">>,
        <<"guild_id">> => <<"999">>,
        <<"channel_id">> => <<"100">>
    },
    PendingData = #{
        user_id => 5,
        guild_id => 999,
        channel_id => 100,
        voice_state => VoiceState
    },
    State = base_test_state(),
    Result = resolve_voice_state_from_pending(<<"conn1">>, PendingData, State, #{}),
    ?assertEqual(VoiceState, Result).

resolve_voice_state_from_pending_prefers_existing_voice_state_test() ->
    ExistingVoiceState = #{
        <<"user_id">> => <<"5">>,
        <<"guild_id">> => <<"999">>,
        <<"channel_id">> => <<"100">>,
        <<"existing">> => true
    },
    PendingVoiceState = #{
        <<"user_id">> => <<"5">>,
        <<"guild_id">> => <<"999">>,
        <<"channel_id">> => <<"100">>,
        <<"existing">> => false
    },
    PendingData = #{
        user_id => 5,
        voice_state => PendingVoiceState
    },
    VoiceStates = #{<<"conn1">> => ExistingVoiceState},
    State = base_test_state(),
    Result = resolve_voice_state_from_pending(<<"conn1">>, PendingData, State, VoiceStates),
    ?assertEqual(ExistingVoiceState, Result).

normalize_boolean_test() ->
    ?assertEqual(true, normalize_boolean(true)),
    ?assertEqual(true, normalize_boolean(<<"true">>)),
    ?assertEqual(false, normalize_boolean(false)),
    ?assertEqual(false, normalize_boolean(<<"false">>)),
    ?assertEqual(false, normalize_boolean(<<"other">>)),
    ?assertEqual(false, normalize_boolean(123)).

normalize_session_id_test() ->
    ?assertEqual(undefined, normalize_session_id(undefined)),
    ?assertEqual(undefined, normalize_session_id(null)),
    ?assertEqual(<<"abc">>, normalize_session_id(<<"abc">>)),
    ?assertEqual(<<"123">>, normalize_session_id(123)),
    ?assertEqual(<<"test">>, normalize_session_id("test")).

pending_get_value_test() ->
    Data = #{key1 => value1, <<"key2">> => value2},
    ?assertEqual(value1, pending_get_value(Data, key1)),
    ?assertEqual(value2, pending_get_value(Data, key2)),
    ?assertEqual(undefined, pending_get_value(Data, missing)).

try_restore_from_recently_disconnected_restores_test() ->
    VS = #{
        <<"user_id">> => <<"5">>,
        <<"guild_id">> => <<"10">>,
        <<"channel_id">> => <<"20">>,
        <<"connection_id">> => <<"conn">>
    },
    Now = erlang:system_time(millisecond),
    Cache = #{<<"conn">> => #{voice_state => VS, disconnected_at => Now - 5000}},
    State = #{
        voice_states => #{},
        recently_disconnected_voice_states => Cache,
        sessions => #{},
        data => #{},
        id => 10
    },
    {reply, #{success := true}, NewState} =
        try_restore_from_recently_disconnected(<<"conn">>, State),
    NewVoiceStates = maps:get(voice_states, NewState),
    ?assert(maps:is_key(<<"conn">>, NewVoiceStates)),
    NewCache = maps:get(recently_disconnected_voice_states, NewState),
    ?assertNot(maps:is_key(<<"conn">>, NewCache)).

try_restore_from_recently_disconnected_expired_test() ->
    VS = #{
        <<"user_id">> => <<"5">>,
        <<"guild_id">> => <<"10">>,
        <<"channel_id">> => <<"20">>,
        <<"connection_id">> => <<"conn">>
    },
    Now = erlang:system_time(millisecond),
    Cache = #{<<"conn">> => #{voice_state => VS, disconnected_at => Now - 70000}},
    State = #{
        voice_states => #{},
        recently_disconnected_voice_states => Cache,
        data => #{}
    },
    {reply, {error, not_found, voice_connection_not_found}, _} =
        try_restore_from_recently_disconnected(<<"conn">>, State).

try_restore_from_recently_disconnected_not_found_test() ->
    State = #{voice_states => #{}, data => #{}},
    {reply, {error, not_found, voice_connection_not_found}, _} =
        try_restore_from_recently_disconnected(<<"conn">>, State).

voice_state_update_connection_user_mismatch_test() ->
    VoiceStates = #{
        <<"conn-1">> => #{
            <<"channel_id">> => <<"100">>,
            <<"user_id">> => <<"20">>
        }
    },
    State = maps:put(voice_states, VoiceStates, base_test_state()),
    Request = #{
        user_id => 10,
        channel_id => 100,
        connection_id => <<"conn-1">>
    },
    {reply, {error, validation_error, voice_user_mismatch}, _} =
        voice_state_update(Request, State).

voice_state_update_connection_owner_match_proceeds_test() ->
    VoiceStates = #{
        <<"conn-1">> => #{
            <<"channel_id">> => <<"100">>,
            <<"user_id">> => <<"10">>
        }
    },
    State = maps:put(voice_states, VoiceStates, base_test_state()),
    Request = #{
        user_id => 10,
        channel_id => 100,
        connection_id => <<"conn-1">>
    },
    case voice_state_update(Request, State) of
        {reply, {error, validation_error, voice_user_mismatch}, _} ->
            error(should_not_get_user_mismatch);
        {reply, _, _} ->
            ok
    end.

validate_pending_nonce_valid_test() ->
    Now = erlang:system_time(millisecond),
    PendingData = #{
        token_nonce => <<"abc123">>,
        created_at => Now - 5000,
        expires_at => Now + 25000
    },
    ?assertEqual(ok, validate_pending_nonce_and_expiry(<<"abc123">>, PendingData)).

validate_pending_nonce_mismatch_test() ->
    Now = erlang:system_time(millisecond),
    PendingData = #{
        token_nonce => <<"abc123">>,
        created_at => Now - 5000,
        expires_at => Now + 25000
    },
    ?assertEqual(
        {error, voice_nonce_mismatch},
        validate_pending_nonce_and_expiry(<<"wrong-nonce">>, PendingData)
    ).

validate_pending_nonce_expired_test() ->
    Now = erlang:system_time(millisecond),
    PendingData = #{
        token_nonce => <<"abc123">>,
        created_at => Now - 35000,
        expires_at => Now - 5000
    },
    ?assertEqual(
        {error, voice_pending_expired},
        validate_pending_nonce_and_expiry(<<"abc123">>, PendingData)
    ).

validate_pending_nonce_undefined_backwards_compat_test() ->
    Now = erlang:system_time(millisecond),
    PendingData = #{
        created_at => Now - 5000,
        expires_at => Now + 25000
    },
    ?assertEqual(ok, validate_pending_nonce_and_expiry(undefined, PendingData)).

validate_pending_nonce_missing_expires_at_test() ->
    PendingData = #{
        token_nonce => <<"abc123">>
    },
    ?assertEqual(ok, validate_pending_nonce_and_expiry(<<"abc123">>, PendingData)).

sweep_expired_pending_joins_removes_expired_test() ->
    Now = erlang:system_time(millisecond),
    ExpiredMetadata = #{
        user_id => 10,
        guild_id => 999,
        channel_id => 100,
        expires_at => Now - 1000
    },
    ValidMetadata = #{
        user_id => 11,
        guild_id => 999,
        channel_id => 101,
        expires_at => Now + 25000
    },
    PendingConnections = #{
        <<"expired-conn">> => ExpiredMetadata,
        <<"valid-conn">> => ValidMetadata
    },
    State = maps:put(pending_voice_connections, PendingConnections, base_test_state()),
    NewState = sweep_expired_pending_joins(State),
    NewPending = maps:get(pending_voice_connections, NewState, #{}),
    ?assertNot(maps:is_key(<<"expired-conn">>, NewPending)),
    ?assert(maps:is_key(<<"valid-conn">>, NewPending)).

sweep_expired_pending_joins_keeps_valid_test() ->
    Now = erlang:system_time(millisecond),
    ValidMetadata1 = #{
        user_id => 10,
        guild_id => 999,
        channel_id => 100,
        expires_at => Now + 25000
    },
    ValidMetadata2 = #{
        user_id => 11,
        guild_id => 999,
        channel_id => 101,
        expires_at => Now + 30000
    },
    PendingConnections = #{
        <<"conn-1">> => ValidMetadata1,
        <<"conn-2">> => ValidMetadata2
    },
    State = maps:put(pending_voice_connections, PendingConnections, base_test_state()),
    NewState = sweep_expired_pending_joins(State),
    NewPending = maps:get(pending_voice_connections, NewState, #{}),
    ?assertEqual(2, maps:size(NewPending)),
    ?assert(maps:is_key(<<"conn-1">>, NewPending)),
    ?assert(maps:is_key(<<"conn-2">>, NewPending)).

sweep_expired_pending_joins_clears_virtual_access_test() ->
    Now = erlang:system_time(millisecond),
    ExpiredMetadata = #{
        user_id => 10,
        guild_id => 999,
        channel_id => 100,
        expires_at => Now - 1000
    },
    PendingConnections = #{<<"expired-conn">> => ExpiredMetadata},
    State = maps:put(pending_voice_connections, PendingConnections, base_test_state()),
    State1 = guild_virtual_channel_access:mark_pending_join(10, 100, State),
    State2 = guild_virtual_channel_access:mark_preserve(10, 100, State1),
    State3 = guild_virtual_channel_access:mark_move_pending(10, 100, State2),
    ?assert(guild_virtual_channel_access:is_pending_join(10, 100, State3)),
    ?assert(guild_virtual_channel_access:has_preserve(10, 100, State3)),
    ?assert(guild_virtual_channel_access:is_move_pending(10, 100, State3)),
    NewState = sweep_expired_pending_joins(State3),
    ?assertNot(guild_virtual_channel_access:is_pending_join(10, 100, NewState)),
    ?assertNot(guild_virtual_channel_access:has_preserve(10, 100, NewState)),
    ?assertNot(guild_virtual_channel_access:is_move_pending(10, 100, NewState)).

sweep_expired_pending_joins_empty_map_test() ->
    State = maps:put(pending_voice_connections, #{}, base_test_state()),
    NewState = sweep_expired_pending_joins(State),
    NewPending = maps:get(pending_voice_connections, NewState, #{}),
    ?assertEqual(0, maps:size(NewPending)).

sweep_expired_pending_joins_missing_user_id_test() ->
    Now = erlang:system_time(millisecond),
    InvalidMetadata = #{
        guild_id => 999,
        channel_id => 100,
        expires_at => Now - 1000
    },
    PendingConnections = #{<<"invalid-conn">> => InvalidMetadata},
    State = maps:put(pending_voice_connections, PendingConnections, base_test_state()),
    NewState = sweep_expired_pending_joins(State),
    NewPending = maps:get(pending_voice_connections, NewState, #{}),
    ?assertNot(maps:is_key(<<"invalid-conn">>, NewPending)).

confirm_voice_connection_validates_nonce_test() ->
    Now = erlang:system_time(millisecond),
    PendingData = #{
        user_id => 5,
        guild_id => 999,
        channel_id => 100,
        token_nonce => <<"valid-nonce">>,
        created_at => Now - 5000,
        expires_at => Now + 25000,
        voice_state => #{
            <<"user_id">> => <<"5">>,
            <<"guild_id">> => <<"999">>,
            <<"channel_id">> => <<"100">>
        }
    },
    PendingConnections = #{<<"conn1">> => PendingData},
    State = maps:merge(base_test_state(), #{
        pending_voice_connections => PendingConnections,
        voice_states => #{}
    }),
    {reply, {error, validation_error, voice_nonce_mismatch}, _} =
        confirm_voice_connection_from_livekit(
            #{connection_id => <<"conn1">>, token_nonce => <<"wrong-nonce">>},
            State
        ).

confirm_voice_connection_validates_expiry_test() ->
    Now = erlang:system_time(millisecond),
    PendingData = #{
        user_id => 5,
        guild_id => 999,
        channel_id => 100,
        token_nonce => <<"valid-nonce">>,
        created_at => Now - 35000,
        expires_at => Now - 5000,
        voice_state => #{
            <<"user_id">> => <<"5">>,
            <<"guild_id">> => <<"999">>,
            <<"channel_id">> => <<"100">>
        }
    },
    PendingConnections = #{<<"conn1">> => PendingData},
    State = maps:merge(base_test_state(), #{
        pending_voice_connections => PendingConnections,
        voice_states => #{}
    }),
    {reply, {error, validation_error, voice_pending_expired}, _} =
        confirm_voice_connection_from_livekit(
            #{connection_id => <<"conn1">>, token_nonce => <<"valid-nonce">>},
            State
        ).

confirm_voice_connection_accepts_valid_nonce_test() ->
    Now = erlang:system_time(millisecond),
    PendingData = #{
        user_id => 5,
        guild_id => 999,
        channel_id => 100,
        token_nonce => <<"valid-nonce">>,
        created_at => Now - 5000,
        expires_at => Now + 25000,
        voice_state => #{
            <<"user_id">> => <<"5">>,
            <<"guild_id">> => <<"999">>,
            <<"channel_id">> => <<"100">>
        }
    },
    PendingConnections = #{<<"conn1">> => PendingData},
    State = maps:merge(base_test_state(), #{
        pending_voice_connections => PendingConnections,
        voice_states => #{}
    }),
    {reply, #{success := true}, NewState} =
        confirm_voice_connection_from_livekit(
            #{connection_id => <<"conn1">>, token_nonce => <<"valid-nonce">>},
            State
        ),
    NewVoiceStates = maps:get(voice_states, NewState),
    ?assert(maps:is_key(<<"conn1">>, NewVoiceStates)).

confirm_voice_connection_accepts_undefined_nonce_backwards_compat_test() ->
    Now = erlang:system_time(millisecond),
    PendingData = #{
        user_id => 5,
        guild_id => 999,
        channel_id => 100,
        created_at => Now - 5000,
        expires_at => Now + 25000,
        voice_state => #{
            <<"user_id">> => <<"5">>,
            <<"guild_id">> => <<"999">>,
            <<"channel_id">> => <<"100">>
        }
    },
    PendingConnections = #{<<"conn1">> => PendingData},
    State = maps:merge(base_test_state(), #{
        pending_voice_connections => PendingConnections,
        voice_states => #{}
    }),
    {reply, #{success := true}, NewState} =
        confirm_voice_connection_from_livekit(#{connection_id => <<"conn1">>}, State),
    NewVoiceStates = maps:get(voice_states, NewState),
    ?assert(maps:is_key(<<"conn1">>, NewVoiceStates)).

-endif.
