%% 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).

-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]).

-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_key := term()
}.

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

-ifdef(TEST).
-define(LOG_INVALID_GUILD_ID(_Value), ok).
-else.
-define(LOG_INVALID_GUILD_ID(Value),
    logger:warning(
        "[guild_voice_connection] Invalid guild_id value: ~p", [Value]
    )
).
-endif.

voice_state_update(Request, State) ->
    Context = build_context(Request),
    case maps:get(user_id, Context) of
        undefined ->
            gateway_errors:error(voice_invalid_user_id);
        UserId ->
            logger:debug(
                "[guild_voice_connection] Processing voice state update: UserId=~p, ChannelId=~p, "
                "ConnectionId=~p",
                [UserId, maps:get(channel_id, Context), maps:get(connection_id, Context)]
            ),
            VoiceStates = voice_state_utils:voice_states(State),
            case guild_voice_member:find_member_by_user_id(UserId, State) of
                undefined ->
                    logger:warning("[guild_voice_connection] Member not found for UserId: ~p", [
                        UserId
                    ]),
                    gateway_errors:error(voice_member_not_found);
                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()} | {error, atom(), binary()}.
handle_member_voice(Context, Member, VoiceStates, State) ->
    case maps:get(channel_id, Context) of
        undefined ->
            gateway_errors:error(voice_invalid_channel_id);
        null ->
            handle_disconnect(Context, VoiceStates, State);
        ChannelIdValue ->
            handle_voice_connect_or_update(Context, ChannelIdValue, Member, VoiceStates, State)
    end.

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
    ).

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 ->
            logger:warning("[guild_voice_connection] Channel not found: ~p", [ChannelIdValue]),
            gateway_errors:error(voice_channel_not_found);
        _ ->
            case ConnectionId of
                undefined ->
                    handle_new_connection(Context, Member, Channel, VoiceStates, State);
                _ ->
                    handle_update_connection(
                        Context,
                        ChannelIdValue,
                        Member,
                        Channel,
                        VoiceStates,
                        State
                    )
            end
    end.

handle_update_connection(Context, ChannelIdValue, Member, Channel, VoiceStates, State) ->
    ConnectionId = maps:get(connection_id, Context),

    case maps:get(ConnectionId, VoiceStates, undefined) of
        undefined ->
            gateway_errors:error(voice_connection_not_found);
        ExistingVoiceState ->
            ExistingChannelIdBin = maps:get(<<"channel_id">>, ExistingVoiceState, null),
            NewChannelIdBin = integer_to_binary(ChannelIdValue),
            IsChannelChange = ExistingChannelIdBin =/= NewChannelIdBin,
            UserId = maps:get(user_id, Context),
            GuildId = map_utils:get_integer(State, id, undefined),
            ViewerKeyResult =
                resolve_viewer_stream_key(
                    Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState
                ),

            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 ViewerKeyResult of
                        {error, ErrorAtom} ->
                            {reply, gateway_errors:error(ErrorAtom), State};
                        {ok, ParsedViewerKey} ->
                            Flags = voice_state_utils:voice_flags_from_context(Context),
                            guild_voice_state:update_voice_state_data(
                                ConnectionId,
                                NewChannelIdBin,
                                Flags,
                                Member,
                                ExistingVoiceState,
                                VoiceStates,
                                State,
                                false,
                                ParsedViewerKey
                            )
                    end
            end
    end.

handle_new_connection(Context, Member, Channel, VoiceStates, State) ->
    UserId = maps:get(user_id, Context),
    ChannelIdValue = maps:get(channel_id, Context),
    GuildId = map_utils:get_integer(State, id, undefined),
    ViewerKeyResult = resolve_viewer_stream_key(Context, GuildId, ChannelIdValue, VoiceStates, #{}),

    PermCheck = guild_voice_permissions:check_voice_permissions_and_limits(
        UserId, ChannelIdValue, Channel, VoiceStates, State, false
    ),

    case {PermCheck, ViewerKeyResult} of
        {{error, _Category, ErrorAtom}, _} ->
            {reply, gateway_errors:error(ErrorAtom), State};
        {{ok, allowed}, {error, ErrorAtom}} ->
            {reply, gateway_errors:error(ErrorAtom), State};
        {{ok, allowed}, {ok, ParsedViewerKey}} ->
            get_voice_token_and_create_state(Context, Member, ParsedViewerKey, State)
    end.

get_voice_token_and_create_state(Context, Member, ParsedViewerStreamKey, State) ->
    ChannelIdValue = maps:get(channel_id, Context),
    UserId = maps:get(user_id, Context),
    SessionId = maps:get(session_id, Context),

    case resolve_guild_identity(State) of
        {error, ErrorAtom} ->
            {reply, gateway_errors:error(ErrorAtom), State};
        {ok, GuildId, GuildIdBin} ->
            logger:info(
                "[guild_voice_connection] Requesting voice token for GuildId=~p, ChannelId=~p, UserId=~p",
                [GuildId, ChannelIdValue, UserId]
            ),
            VoicePermissions = voice_utils:compute_voice_permissions(UserId, ChannelIdValue, State),
            logger:debug("[guild_voice_connection] Computed voice permissions: ~p", [
                VoicePermissions
            ]),
            case request_voice_token(GuildId, ChannelIdValue, UserId, VoicePermissions) of
                {ok, TokenData} ->
                    logger:debug("[guild_voice_connection] Voice token request succeeded"),
                    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),
                    #voice_flags{
                        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),

                    VoiceStates = voice_state_utils:voice_states(State),
                    NewVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
                    StateWithVoiceStates = maps:put(voice_states, NewVoiceStates, State),

                    guild_voice_broadcast:broadcast_voice_state_update(
                        VoiceState, StateWithVoiceStates, ChannelIdBin
                    ),

                    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_key => ParsedViewerStreamKey
                    },
                    PendingConnections = pending_voice_connections(StateWithVoiceStates),
                    NewPendingConnections = maps:put(
                        ConnectionId,
                        PendingMetadata,
                        PendingConnections
                    ),
                    NewState = maps:put(
                        pending_voice_connections, NewPendingConnections, StateWithVoiceStates
                    ),

                    maybe_broadcast_voice_server_update(
                        SessionId, GuildId, Token, Endpoint, ConnectionId, NewState
                    ),

                    {reply,
                        #{
                            success => true,
                            token => Token,
                            endpoint => Endpoint,
                            connection_id => ConnectionId,
                            voice_state => VoiceState
                        },
                        NewState};
                {error, Reason} ->
                    logger:error("[guild_voice_connection] Failed to request voice token: ~p", [
                        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_key => maps:get(viewer_stream_key, Request, 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.

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

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

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.

resolve_viewer_stream_key(Context, GuildId, ChannelIdValue, VoiceStates, ExistingVoiceState) ->
    RawKey = maps:get(viewer_stream_key, Context, undefined),
    case RawKey of
        undefined ->
            {ok, maps:get(<<"viewer_stream_key">>, ExistingVoiceState, null)};
        null ->
            {ok, null};
        _ when not is_binary(RawKey) ->
            {error, voice_invalid_state};
        _ ->
            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
    end.

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.

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 = #voice_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),
            ViewerStreamKey = pending_get_value(PendingData, viewer_stream_key),
            VoiceState0 = guild_voice_state:create_voice_state(
                GuildIdBin,
                ChannelIdBin,
                UserIdBin,
                ConnectionId,
                ServerMute,
                ServerDeaf,
                Flags,
                ViewerStreamKey
            ),
            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.

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.

pending_get_integer(PendingData, Key) ->
    case pending_get_value(PendingData, Key) of
        undefined -> undefined;
        Value -> type_conv:to_integer(Value)
    end.

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

pending_get_binary(PendingData, Key) ->
    case pending_get_value(PendingData, Key) of
        undefined -> undefined;
        Value -> type_conv:to_binary(Value)
    end.

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

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}
    ]).

resolve_guild_id_priority([]) ->
    logger:error("[guild_voice_connection] Missing guild id in state"),
    {error, voice_guild_id_missing};
resolve_guild_id_priority([{undefined, _} | Rest]) ->
    resolve_guild_id_priority(Rest);
resolve_guild_id_priority([{Value, NormalizeFun} | _]) ->
    NormalizeFun(Value).

normalize_guild_id(Value) ->
    case type_conv:to_integer(Value) of
        undefined ->
            ?LOG_INVALID_GUILD_ID(Value),
            {error, voice_invalid_guild_id};
        Int ->
            {ok, Int, guild_id_binary(Value, Int)}
    end.

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

maybe_broadcast_voice_server_update(undefined, _GuildId, _Token, _Endpoint, _ConnectionId, _State) ->
    ok;
maybe_broadcast_voice_server_update(null, _GuildId, _Token, _Endpoint, _ConnectionId, _State) ->
    ok;
maybe_broadcast_voice_server_update(SessionId, GuildId, Token, Endpoint, ConnectionId, State) ->
    guild_voice_broadcast:broadcast_voice_server_update_to_session(
        GuildId, SessionId, Token, Endpoint, ConnectionId, State
    ).

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

confirm_voice_connection_from_livekit(Request, State) ->
    ConnectionId = maps:get(connection_id, Request, undefined),

    logger:info(
        "[guild_voice_connection] confirm_voice_connection_from_livekit connection_id=~p pending_count=~p",
        [ConnectionId, maps:size(pending_voice_connections(State))]
    ),

    case ConnectionId of
        undefined ->
            gateway_errors:error(voice_missing_connection_id);
        _ ->
            PendingConnections = pending_voice_connections(State),

            case maps:get(ConnectionId, PendingConnections, undefined) of
                undefined ->
                    logger:warning(
                        "[guild_voice_connection] confirm_voice_connection_from_livekit missing pending connection_id=~p",
                        [ConnectionId]
                    ),
                    gateway_errors:error(voice_connection_not_found);
                PendingData ->
                    logger:info(
                        "[guild_voice_connection] Found pending connection_id=~p for guild=~p",
                        [ConnectionId, map_utils:get_integer(State, id, 0)]
                    ),
                    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 ->
                            logger:warning(
                                "[guild_voice_connection] Missing voice_state for confirmed connection ~p",
                                [ConnectionId]
                            ),
                            {reply, #{success => true}, StateWithoutPending};
                        _ ->
                            UpdatedVoiceStates = maps:put(ConnectionId, VoiceState, VoiceStates),
                            StateWithVoiceStates = maps:put(
                                voice_states, UpdatedVoiceStates, StateWithoutPending
                            ),

                            ChannelIdBin = maps:get(<<"channel_id">>, VoiceState, null),
                            UserId = maps:get(<<"user_id">>, VoiceState, <<"unknown">>),
                            GuildId = maps:get(id, StateWithVoiceStates, 0),
                            Sessions = maps:get(sessions, StateWithVoiceStates, #{}),
                            logger:info(
                                "[guild_voice_connection] confirm_voice_connection_from_livekit: "
                                "guild_id=~p user_id=~p channel_id=~p connection_id=~p sessions_count=~p",
                                [GuildId, UserId, ChannelIdBin, ConnectionId, maps:size(Sessions)]
                            ),
                            guild_voice_broadcast:broadcast_voice_state_update(
                                VoiceState, StateWithVoiceStates, ChannelIdBin
                            ),

                            {reply, #{success => true}, StateWithVoiceStates}
                    end
            end
    end.

-spec request_voice_token(integer(), integer(), integer(), map()) ->
    {ok, map()} | {error, term()}.
request_voice_token(GuildId, ChannelId, UserId, VoicePermissions) ->
    Req = voice_utils:build_voice_token_rpc_request(
        GuildId, ChannelId, UserId, null, null, null, VoicePermissions
    ),
    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 ->
                    logger:error("[guild_voice_connection] RPC request failed: ~p", [{http_error, Body}]),
                    {error, voice_token_failed}
            end;
        {error, Reason} ->
            logger:error("[guild_voice_connection] RPC request failed: ~p", [Reason]),
            {error, voice_token_failed}
    end.

parse_unclaimed_error(Body) when is_binary(Body) ->
    try jsx:decode(Body, [return_maps]) of
        #{<<"code">> := <<"UNCLAIMED_ACCOUNT_RESTRICTED">>} ->
            true;
        #{<<"error">> := #{<<"code">> := <<"UNCLAIMED_ACCOUNT_RESTRICTED">>}} ->
            true;
        _ ->
            false
    catch
        _:_ -> false
    end;
parse_unclaimed_error(_) ->
    false.

-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.

-ifdef(TEST).

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() ->
    {error, validation_error, voice_invalid_user_id} =
        voice_state_update(#{channel_id => null}, #{}).

-endif.
