use std::{
    collections::BTreeMap,
    time::{Duration, Instant},
};

use serde_json::Value;

use crate::{
    discord::{
        ActivityEmoji, ActivityInfo, ActivityKind, ChannelInfo, ChannelNotificationOverrideInfo,
        ChannelRecipientInfo, CustomEmojiInfo, FriendStatus, GuildFolder,
        GuildNotificationSettingsInfo, MemberInfo, NotificationLevel, PresenceStatus,
        ReadStateInfo, RoleInfo,
        events::default_avatar_url,
        events::{AppEvent, AttachmentUpdate},
        ids::{
            Id,
            marker::{
                ChannelMarker, EmojiMarker, GuildMarker, MessageMarker, RoleMarker, UserMarker,
            },
        },
    },
    logging,
};

mod channels;
mod messages;

pub(crate) use channels::parse_channel_info;
use channels::parse_channel_recipient_info;
pub(crate) use messages::parse_message_info;
use messages::{
    parse_attachments, parse_embeds, parse_mentions, parse_poll_info, parse_poll_result_embed,
    parse_reaction_emoji, parse_sticker_names,
};

/// Best-effort fallback that rebuilds the dashboard's domain events directly
/// from the raw gateway payload. We only extract the fields the UI consumes,
/// and skip anything we can't model. Returns an iterable so a single payload
/// (e.g. `GUILD_CREATE`) can produce multiple downstream events.
pub(super) fn parse_user_account_event(raw: &str) -> Vec<AppEvent> {
    let Ok(value) = serde_json::from_str::<Value>(raw) else {
        return Vec::new();
    };
    let Some(event_type) = value.get("t").and_then(Value::as_str) else {
        return Vec::new();
    };
    let Some(data) = value.get("d") else {
        return Vec::new();
    };

    match event_type {
        "READY" => parse_ready(data),
        "READY_SUPPLEMENTAL" => parse_ready_supplemental(data),
        "GUILD_CREATE" => {
            let started = Instant::now();
            let result = parse_guild_create(data).into_iter().collect();
            logging::timing("gateway", "fallback guild_create parse", started.elapsed());
            result
        }
        "GUILD_UPDATE" => parse_guild_update(data).into_iter().collect(),
        "GUILD_EMOJIS_UPDATE" => parse_guild_emojis_update(data).into_iter().collect(),
        "GUILD_DELETE" => parse_guild_delete(data).into_iter().collect(),
        "CHANNEL_CREATE" | "CHANNEL_UPDATE" | "THREAD_CREATE" | "THREAD_UPDATE" => {
            parse_channel_upsert(data).into_iter().collect()
        }
        "CHANNEL_DELETE" | "THREAD_DELETE" => parse_channel_delete(data).into_iter().collect(),
        "THREAD_LIST_SYNC" => parse_thread_list_sync(data),
        "MESSAGE_CREATE" => parse_message_create(data).into_iter().collect(),
        "MESSAGE_UPDATE" => parse_message_update(data).into_iter().collect(),
        "MESSAGE_DELETE" => parse_message_delete(data).into_iter().collect(),
        "MESSAGE_REACTION_ADD" => parse_message_reaction_add(data).into_iter().collect(),
        "MESSAGE_REACTION_REMOVE" => parse_message_reaction_remove(data).into_iter().collect(),
        "MESSAGE_REACTION_REMOVE_ALL" => parse_message_reaction_remove_all(data)
            .into_iter()
            .collect(),
        "MESSAGE_REACTION_REMOVE_EMOJI" => parse_message_reaction_remove_emoji(data)
            .into_iter()
            .collect(),
        "MESSAGE_ACK" => parse_message_ack(data).into_iter().collect(),
        "USER_GUILD_SETTINGS_UPDATE" => {
            parse_user_guild_settings_update(data).into_iter().collect()
        }
        "GUILD_MEMBER_ADD" => parse_member_add(data).into_iter().collect(),
        "GUILD_MEMBER_UPDATE" => parse_member_upsert(data).into_iter().collect(),
        "GUILD_MEMBER_LIST_UPDATE" => parse_member_list_update(data),
        "GUILD_MEMBERS_CHUNK" => parse_member_chunk(data),
        "RELATIONSHIP_ADD" => parse_relationship_add(data).into_iter().collect(),
        "RELATIONSHIP_REMOVE" => parse_relationship_remove(data).into_iter().collect(),
        "GUILD_MEMBER_REMOVE" => parse_member_remove(data).into_iter().collect(),
        "PRESENCE_UPDATE" => parse_presence_update(data),
        "TYPING_START" => parse_typing_start(data).into_iter().collect(),
        _ => Vec::new(),
    }
}

/// User-account READY embeds the full guild list under `d.guilds`. Bots get a
/// stub list of unavailable guilds and a separate `GUILD_CREATE` per guild,
/// but user accounts never send standalone GUILD_CREATEs, so we emit a
/// synthetic GuildCreate for each entry inline.
fn parse_ready(data: &Value) -> Vec<AppEvent> {
    let total_started = Instant::now();
    let mut events = Vec::new();
    let mut stats = ReadyTimingStats::default();
    let mut current_user = None;
    let mut current_user_id = None;
    let mut current_user_status = None;

    let user_started = Instant::now();
    if let Some(user) = data.get("user") {
        let user_id = user.get("id").and_then(parse_id::<UserMarker>);
        let name = user
            .get("global_name")
            .and_then(Value::as_str)
            .filter(|value| !value.is_empty())
            .or_else(|| user.get("username").and_then(Value::as_str))
            .unwrap_or("unknown")
            .to_owned();
        events.push(AppEvent::Ready {
            user: name,
            user_id,
        });
        if let Some(can_use_animated_custom_emojis) =
            parse_user_can_use_animated_custom_emojis(user)
        {
            events.push(AppEvent::CurrentUserCapabilities {
                can_use_animated_custom_emojis,
            });
        }
        current_user_id = user_id;
        current_user = parse_channel_recipient_info(user);
        current_user_status = parse_current_user_session_status(data);
        if let (Some(user), Some(status)) = (current_user.as_mut(), current_user_status) {
            user.status = Some(status);
        }
    }
    stats.user = user_started.elapsed();

    let guilds_started = Instant::now();
    if let Some(guilds) = data.get("guilds").and_then(Value::as_array) {
        stats.guilds = guilds.len();
        for guild in guilds {
            stats.guild_channels += channel_array_len(guild, "channels");
            stats.guild_channels += channel_array_len(guild, "threads");
            stats.guild_members += guild
                .get("members")
                .and_then(Value::as_array)
                .map(Vec::len)
                .unwrap_or_default();
            stats.guild_presences += guild
                .get("presences")
                .and_then(Value::as_array)
                .map(Vec::len)
                .unwrap_or_default();
            if let Some(event) = parse_guild_create(guild) {
                events.push(event);
            }
        }
    }
    events.extend(parse_merged_member_events(data));
    stats.guilds_duration = guilds_started.elapsed();

    let mut merged_presences = parse_merged_presences(data);
    if let Some(presences) = data.get("presences").and_then(Value::as_array) {
        merged_presences.extend(
            presences
                .iter()
                .filter_map(parse_presence_entry)
                .map(|(user_id, status, activities)| (user_id, (status, activities))),
        );
    }

    // With DEDUPE_USER_OBJECTS in capabilities (bit 4), Discord ships every
    // referenced user once at the top of READY's `users` array and replaces
    // each private channel's full `recipients` array with `recipient_ids`.
    // Index those users by id once so DM hydration below is O(1) per
    // recipient.
    let users_by_id: BTreeMap<Id<UserMarker>, &Value> = data
        .get("users")
        .and_then(Value::as_array)
        .map(|users| {
            users
                .iter()
                .filter_map(|user| {
                    let id = parse_id::<UserMarker>(user.get("id")?)?;
                    Some((id, user))
                })
                .collect()
        })
        .unwrap_or_default();

    // User-account READY also lists DM and group-DM channels under
    // `private_channels`. They have no `guild_id` and never come through
    // `GUILD_CREATE`, so we surface them as standalone channel upserts.
    let private_channels_started = Instant::now();
    if let Some(privates) = data.get("private_channels").and_then(Value::as_array) {
        stats.private_channels = privates.len();
        for channel in privates {
            if let Some(mut info) = parse_channel_info(channel, None) {
                hydrate_dm_recipients_from_ids(&mut info, channel, &users_by_id);
                apply_recipient_presences(&mut info, &merged_presences);
                add_current_user_to_group_dm(&mut info, current_user.as_ref());
                events.push(AppEvent::ChannelUpsert(info));
            }
        }
    }
    stats.private_channels_duration = private_channels_started.elapsed();

    if let (Some(user_id), Some(status)) = (current_user_id, current_user_status) {
        events.push(AppEvent::UserPresenceUpdate {
            user_id,
            status,
            activities: Vec::new(),
        });
    }

    // User-account READY ships the friend list as `relationships`. Capture
    // it as a single event so the profile popup can show friend / pending /
    // blocked badges without an extra REST round trip.
    if let Some(relationships) = data.get("relationships").and_then(Value::as_array) {
        let parsed: Vec<(Id<UserMarker>, FriendStatus)> = relationships
            .iter()
            .filter_map(parse_relationship_entry)
            .collect();
        if !parsed.is_empty() {
            events.push(AppEvent::RelationshipsLoaded {
                relationships: parsed,
            });
        }
    }

    // VERSIONED_READ_STATES wraps the array as `{ entries, version, partial }`;
    // older shards send a bare array. Accept both.
    if let Some(entries) = data
        .get("read_state")
        .and_then(|node| node.get("entries").or(Some(node)))
        .and_then(Value::as_array)
    {
        let parsed: Vec<ReadStateInfo> =
            entries.iter().filter_map(parse_read_state_entry).collect();
        if !parsed.is_empty() {
            events.push(AppEvent::ReadStateInit { entries: parsed });
        }
    }

    if let Some(settings) = parse_user_guild_settings_entries(data.get("user_guild_settings")) {
        events.push(AppEvent::UserGuildNotificationSettingsInit { settings });
    }

    // Guild folder ordering and grouping live in the legacy `user_settings`
    // payload (the modern `user_settings_proto` blob is base64+protobuf and is
    // skipped for now). When present, every guild appears in some folder —
    // either an explicit one or a single-guild "container" with `id == null`.
    let folders_started = Instant::now();
    if let Some(folders) = data
        .get("user_settings")
        .and_then(|settings| settings.get("guild_folders"))
        .and_then(Value::as_array)
    {
        let folders: Vec<GuildFolder> = folders.iter().filter_map(parse_guild_folder).collect();
        stats.folders = folders.len();
        if !folders.is_empty() {
            events.push(AppEvent::GuildFoldersUpdate { folders });
        }
    }
    stats.folders_duration = folders_started.elapsed();
    stats.total = total_started.elapsed();

    log_ready_stats(&stats);

    events
}

fn parse_ready_supplemental(data: &Value) -> Vec<AppEvent> {
    let mut events = parse_supplemental_guild_events(data);
    events.extend(parse_merged_member_events(data));
    events.extend(parse_merged_presences(data).into_iter().map(
        |(user_id, (status, activities))| AppEvent::UserPresenceUpdate {
            user_id,
            status,
            activities,
        },
    ));
    events
}

fn parse_merged_member_events(data: &Value) -> Vec<AppEvent> {
    let Some(guilds) = data.get("guilds").and_then(Value::as_array) else {
        return Vec::new();
    };
    let Some(merged_members) = data.get("merged_members").and_then(Value::as_array) else {
        return Vec::new();
    };

    guilds
        .iter()
        .zip(merged_members)
        .flat_map(|(guild, members)| {
            let Some(guild_id) = guild.get("id").and_then(parse_id::<GuildMarker>) else {
                return Vec::new();
            };
            members
                .as_array()
                .map(|members| guild_member_upsert_events(guild_id, members))
                .unwrap_or_default()
        })
        .collect()
}

fn parse_supplemental_guild_events(data: &Value) -> Vec<AppEvent> {
    let Some(guilds) = data.get("guilds").and_then(Value::as_array) else {
        return Vec::new();
    };

    let mut events = Vec::new();
    for guild in guilds {
        let Some(guild_id) = guild.get("id").and_then(parse_id::<GuildMarker>) else {
            continue;
        };
        if let Some(roles) = guild.get("roles").and_then(Value::as_array) {
            let roles: Vec<RoleInfo> = roles.iter().filter_map(parse_role_info).collect();
            if !roles.is_empty() {
                events.push(AppEvent::GuildRolesUpdate { guild_id, roles });
            }
        }
        if let Some(channels) = guild.get("channels").and_then(Value::as_array) {
            events.extend(
                channels
                    .iter()
                    .filter_map(|channel| parse_channel_info(channel, Some(guild_id)))
                    .map(AppEvent::ChannelUpsert),
            );
        }
        if let Some(threads) = guild.get("threads").and_then(Value::as_array) {
            events.extend(
                threads
                    .iter()
                    .filter_map(|channel| parse_channel_info(channel, Some(guild_id)))
                    .map(AppEvent::ChannelUpsert),
            );
        }
        if let Some(members) = guild.get("members").and_then(Value::as_array) {
            events.extend(guild_member_upsert_events(guild_id, members));
        }
        if let Some(member) = guild.get("member").and_then(parse_member_info) {
            events.push(AppEvent::GuildMemberUpsert { guild_id, member });
        }
        if let Some(presences) = guild.get("presences").and_then(Value::as_array) {
            events.extend(presences.iter().filter_map(parse_presence_entry).map(
                |(user_id, status, activities)| AppEvent::PresenceUpdate {
                    guild_id,
                    user_id,
                    status,
                    activities,
                },
            ));
        }
    }
    events
}

fn guild_member_upsert_events(guild_id: Id<GuildMarker>, members: &[Value]) -> Vec<AppEvent> {
    members
        .iter()
        .filter_map(parse_member_info)
        .map(|member| AppEvent::GuildMemberUpsert { guild_id, member })
        .collect()
}

#[derive(Default)]
struct ReadyTimingStats {
    guilds: usize,
    guild_channels: usize,
    guild_members: usize,
    guild_presences: usize,
    private_channels: usize,
    folders: usize,
    user: Duration,
    guilds_duration: Duration,
    private_channels_duration: Duration,
    folders_duration: Duration,
    total: Duration,
}

fn log_ready_stats(stats: &ReadyTimingStats) {
    logging::timing(
        "gateway",
        format!(
            "ready user={:.2}ms guilds={:.2}ms private_channels={:.2}ms folders={:.2}ms counts guilds={} channels={} members={} presences={} private_channels={} folders={}",
            ms(stats.user),
            ms(stats.guilds_duration),
            ms(stats.private_channels_duration),
            ms(stats.folders_duration),
            stats.guilds,
            stats.guild_channels,
            stats.guild_members,
            stats.guild_presences,
            stats.private_channels,
            stats.folders,
        ),
        stats.total,
    );
}

fn ms(duration: Duration) -> f64 {
    duration.as_secs_f64() * 1_000.0
}

fn parse_guild_folder(value: &Value) -> Option<GuildFolder> {
    let guild_ids: Vec<Id<GuildMarker>> = value
        .get("guild_ids")?
        .as_array()?
        .iter()
        .filter_map(parse_id::<GuildMarker>)
        .collect();
    if guild_ids.is_empty() {
        return None;
    }

    let id = value.get("id").and_then(Value::as_u64);
    let name = value
        .get("name")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .map(str::to_owned);
    let color = value.get("color").and_then(Value::as_u64).map(|c| c as u32);

    Some(GuildFolder {
        id,
        name,
        color,
        guild_ids,
    })
}

type MergedPresences = BTreeMap<Id<UserMarker>, (PresenceStatus, Vec<ActivityInfo>)>;

fn parse_merged_presences(data: &Value) -> MergedPresences {
    let mut presences = MergedPresences::new();
    if let Some(merged) = data.get("merged_presences") {
        collect_presence_entries(merged, &mut presences);
    }
    presences
}

fn parse_current_user_session_status(data: &Value) -> Option<PresenceStatus> {
    data.get("sessions")
        .and_then(Value::as_array)
        .and_then(|sessions| {
            sessions.iter().find_map(|session| {
                let status = session
                    .get("status")
                    .and_then(Value::as_str)
                    .map(parse_status)?;
                (status != PresenceStatus::Unknown).then_some(status)
            })
        })
}

fn collect_presence_entries(value: &Value, presences: &mut MergedPresences) {
    if let Some((user_id, status, activities)) = parse_presence_entry(value) {
        presences.insert(user_id, (status, activities));
        return;
    }

    if let Some(items) = value.as_array() {
        for item in items {
            collect_presence_entries(item, presences);
        }
    } else if let Some(object) = value.as_object() {
        for item in object.values() {
            collect_presence_entries(item, presences);
        }
    }
}

fn apply_recipient_presences(channel: &mut ChannelInfo, presences: &MergedPresences) {
    let Some(recipients) = channel.recipients.as_mut() else {
        return;
    };
    for recipient in recipients {
        if let Some((status, _)) = presences.get(&recipient.user_id) {
            recipient.status = Some(*status);
        }
    }
}

/// Resolves a private channel's `recipient_ids` against READY's deduplicated
/// `users` array. With `DEDUPE_USER_OBJECTS` enabled Discord no longer
/// inlines the full recipient objects in private channels, so without this
/// step DM rows render as `dm-{channel_id}` and the recipient sidebar is
/// empty.
fn hydrate_dm_recipients_from_ids(
    channel: &mut ChannelInfo,
    raw: &Value,
    users_by_id: &BTreeMap<Id<UserMarker>, &Value>,
) {
    if !matches!(channel.kind.as_str(), "dm" | "group-dm") {
        return;
    }
    if channel
        .recipients
        .as_ref()
        .is_some_and(|recipients| !recipients.is_empty())
    {
        return;
    }
    let Some(ids) = raw.get("recipient_ids").and_then(Value::as_array) else {
        return;
    };
    let resolved: Vec<ChannelRecipientInfo> = ids
        .iter()
        .filter_map(parse_id::<UserMarker>)
        .filter_map(|user_id| {
            let user = users_by_id.get(&user_id)?;
            parse_channel_recipient_info(user)
        })
        .collect();
    if resolved.is_empty() {
        return;
    }
    // The previous `parse_channel_info` couldn't see the recipients, so its
    // name was a synthetic `dm-{channel_id}`. Rebuild the human-readable
    // label now using the same global_name → username preference the rest of
    // the parser uses.
    let synthetic_label = format!("dm-{}", channel.channel_id.get());
    if channel.name == synthetic_label {
        channel.name = resolved
            .iter()
            .map(|recipient| recipient.display_name.as_str())
            .collect::<Vec<_>>()
            .join(", ");
    }
    channel.recipients = Some(resolved);
}

fn add_current_user_to_group_dm(
    channel: &mut ChannelInfo,
    current_user: Option<&ChannelRecipientInfo>,
) {
    if channel.kind != "group-dm" {
        return;
    }
    let Some(current_user) = current_user else {
        return;
    };
    let Some(recipients) = channel.recipients.as_mut() else {
        return;
    };
    if recipients
        .iter()
        .any(|recipient| recipient.user_id == current_user.user_id)
    {
        return;
    }
    recipients.push(current_user.clone());
}

fn parse_guild_create(data: &Value) -> Option<AppEvent> {
    let guild_id = parse_id::<GuildMarker>(data.get("id")?)?;
    // With user-account `capabilities` containing LAZY_USER_NOTIFICATIONS
    // (bit 0), Discord nests the guild's name / icon / owner_id under a
    // `properties` sub-object instead of placing them at the root. Fall back
    // to that location so guilds don't all render as "unknown".
    let properties = data.get("properties");
    let lookup = |key: &str| -> Option<&Value> {
        data.get(key)
            .or_else(|| properties.and_then(|p| p.get(key)))
    };
    let name = lookup("name")
        .and_then(Value::as_str)
        .unwrap_or("unknown")
        .to_owned();

    let mut channels: Vec<ChannelInfo> = data
        .get("channels")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(|channel| parse_channel_info(channel, Some(guild_id)))
                .collect()
        })
        .unwrap_or_default();
    if let Some(threads) = data.get("threads").and_then(Value::as_array) {
        channels.extend(
            threads
                .iter()
                .filter_map(|channel| parse_channel_info(channel, Some(guild_id))),
        );
    }

    let members = data
        .get("members")
        .and_then(Value::as_array)
        .map(|items| items.iter().filter_map(parse_member_info).collect())
        .unwrap_or_default();
    let member_count = data.get("member_count").and_then(Value::as_u64);

    // Activities reach state via PresenceUpdate events, not GuildCreate.
    let presences = data
        .get("presences")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(parse_presence_entry)
                .map(|(user_id, status, _activities)| (user_id, status))
                .collect()
        })
        .unwrap_or_default();

    let roles = data
        .get("roles")
        .and_then(Value::as_array)
        .map(|items| items.iter().filter_map(parse_role_info).collect())
        .unwrap_or_default();

    let emojis = data
        .get("emojis")
        .and_then(Value::as_array)
        .map(|items| items.iter().filter_map(parse_custom_emoji).collect())
        .unwrap_or_default();

    let owner_id = lookup("owner_id").and_then(parse_id::<UserMarker>);

    Some(AppEvent::GuildCreate {
        guild_id,
        name,
        member_count,
        owner_id,
        channels,
        members,
        presences,
        roles,
        emojis,
    })
}

fn parse_role_info(value: &Value) -> Option<RoleInfo> {
    let id = parse_id::<RoleMarker>(value.get("id")?)?;
    let name = value
        .get("name")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())?
        .to_owned();
    let color = value
        .get("colors")
        .and_then(|colors| colors.get("primary_color"))
        .and_then(Value::as_u64)
        .or_else(|| value.get("color").and_then(Value::as_u64))
        .and_then(|value| u32::try_from(value).ok())
        .filter(|value| *value != 0);
    let position = value.get("position").and_then(Value::as_i64).unwrap_or(0);
    let hoist = value.get("hoist").and_then(Value::as_bool).unwrap_or(false);
    // Discord serializes `permissions` as a string-encoded 64-bit bitfield.
    // Numeric form is also accepted as a fallback for older payloads / tests.
    let permissions = value
        .get("permissions")
        .and_then(|value| {
            value
                .as_str()
                .and_then(|s| s.parse::<u64>().ok())
                .or_else(|| value.as_u64())
        })
        .unwrap_or(0);

    Some(RoleInfo {
        id,
        name,
        color,
        position,
        hoist,
        permissions,
    })
}

fn parse_custom_emoji(value: &Value) -> Option<CustomEmojiInfo> {
    let id = parse_id::<EmojiMarker>(value.get("id")?)?;
    let name = value
        .get("name")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())?
        .to_owned();
    let animated = value
        .get("animated")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let available = value
        .get("available")
        .and_then(Value::as_bool)
        .unwrap_or(true);

    Some(CustomEmojiInfo {
        id,
        name,
        animated,
        available,
    })
}

fn parse_user_can_use_animated_custom_emojis(user: &Value) -> Option<bool> {
    // Discord exposes `premium_type` on the current user object. Any non-zero
    // value represents a Nitro tier that can use animated custom emojis.
    user.get("premium_type")
        .and_then(Value::as_u64)
        .map(|premium_type| premium_type != 0)
}

fn parse_guild_emojis_update(data: &Value) -> Option<AppEvent> {
    let guild_id = parse_id::<GuildMarker>(data.get("guild_id")?)?;
    let emojis = data
        .get("emojis")
        .and_then(Value::as_array)
        .map(|items| items.iter().filter_map(parse_custom_emoji).collect())
        .unwrap_or_default();

    Some(AppEvent::GuildEmojisUpdate { guild_id, emojis })
}

fn parse_guild_update(data: &Value) -> Option<AppEvent> {
    let guild_id = parse_id::<GuildMarker>(data.get("id")?)?;
    // Same lazy-mode caveat as `parse_guild_create`: with capabilities such
    // as LAZY_USER_NOTIFICATIONS enabled, name/owner_id can ride inside a
    // `properties` sub-object instead of at the root.
    let properties = data.get("properties");
    let lookup = |key: &str| -> Option<&Value> {
        data.get(key)
            .or_else(|| properties.and_then(|p| p.get(key)))
    };
    let name = lookup("name")
        .and_then(Value::as_str)
        .unwrap_or("unknown")
        .to_owned();
    let emojis = data
        .get("emojis")
        .and_then(Value::as_array)
        .map(|items| items.iter().filter_map(parse_custom_emoji).collect());
    let roles = data
        .get("roles")
        .and_then(Value::as_array)
        .map(|items| items.iter().filter_map(parse_role_info).collect());
    let owner_id = lookup("owner_id").and_then(parse_id::<UserMarker>);
    Some(AppEvent::GuildUpdate {
        guild_id,
        name,
        owner_id,
        roles,
        emojis,
    })
}

fn parse_guild_delete(data: &Value) -> Option<AppEvent> {
    let guild_id = parse_id::<GuildMarker>(data.get("id")?)?;
    Some(AppEvent::GuildDelete { guild_id })
}

fn parse_channel_upsert(data: &Value) -> Option<AppEvent> {
    let info = parse_channel_info(data, None)?;
    Some(AppEvent::ChannelUpsert(info))
}

fn parse_channel_delete(data: &Value) -> Option<AppEvent> {
    let channel_id = parse_id::<ChannelMarker>(data.get("id")?)?;
    let guild_id = data.get("guild_id").and_then(parse_id::<GuildMarker>);
    Some(AppEvent::ChannelDelete {
        guild_id,
        channel_id,
    })
}

fn parse_thread_list_sync(data: &Value) -> Vec<AppEvent> {
    let guild_id = data.get("guild_id").and_then(parse_id::<GuildMarker>);
    data.get("threads")
        .and_then(Value::as_array)
        .map(|threads| {
            threads
                .iter()
                .filter_map(|thread| parse_channel_info(thread, guild_id))
                .map(AppEvent::ChannelUpsert)
                .collect()
        })
        .unwrap_or_default()
}

fn channel_array_len(value: &Value, field: &str) -> usize {
    value
        .get(field)
        .and_then(Value::as_array)
        .map(Vec::len)
        .unwrap_or_default()
}

fn parse_message_create(data: &Value) -> Option<AppEvent> {
    let message = parse_message_info(data)?;
    Some(AppEvent::MessageCreate {
        guild_id: message.guild_id,
        channel_id: message.channel_id,
        message_id: message.message_id,
        author_id: message.author_id,
        author: message.author,
        author_avatar_url: message.author_avatar_url,
        author_role_ids: message.author_role_ids,
        message_kind: message.message_kind,
        reference: message.reference,
        reply: message.reply,
        poll: message.poll,
        content: message.content,
        sticker_names: message.sticker_names,
        mentions: message.mentions,
        attachments: message.attachments,
        embeds: message.embeds,
        forwarded_snapshots: message.forwarded_snapshots,
    })
}

fn parse_message_update(data: &Value) -> Option<AppEvent> {
    let channel_id = parse_id::<ChannelMarker>(data.get("channel_id")?)?;
    let message_id = parse_id::<MessageMarker>(data.get("id")?)?;
    let guild_id = data.get("guild_id").and_then(parse_id::<GuildMarker>);
    let content = data
        .get("content")
        .and_then(Value::as_str)
        .map(str::to_owned);
    let sticker_names = data
        .get("sticker_items")
        .map(|value| parse_sticker_names(Some(value)));
    let attachments = if data.get("attachments").is_some() {
        AttachmentUpdate::Replace(parse_attachments(data.get("attachments")))
    } else {
        AttachmentUpdate::Unchanged
    };
    let poll = data
        .get("poll")
        .and_then(parse_poll_info)
        .or_else(|| parse_poll_result_embed(data.get("embeds")));
    let embeds = data.get("embeds").map(|value| parse_embeds(Some(value)));
    let mentions = data
        .get("mentions")
        .map(|value| parse_mentions(Some(value)));
    let edited_timestamp = data
        .get("edited_timestamp")
        .and_then(Value::as_str)
        .map(str::to_owned);
    Some(AppEvent::MessageUpdate {
        guild_id,
        channel_id,
        message_id,
        poll,
        content,
        sticker_names,
        mentions,
        attachments,
        embeds,
        edited_timestamp,
    })
}

fn parse_message_delete(data: &Value) -> Option<AppEvent> {
    let channel_id = parse_id::<ChannelMarker>(data.get("channel_id")?)?;
    let message_id = parse_id::<MessageMarker>(data.get("id")?)?;
    let guild_id = data.get("guild_id").and_then(parse_id::<GuildMarker>);
    Some(AppEvent::MessageDelete {
        guild_id,
        channel_id,
        message_id,
    })
}

fn parse_read_state_entry(value: &Value) -> Option<ReadStateInfo> {
    let channel_id = parse_id::<ChannelMarker>(value.get("id")?)?;
    Some(ReadStateInfo {
        channel_id,
        last_acked_message_id: value
            .get("last_message_id")
            .and_then(parse_id::<MessageMarker>),
        mention_count: value
            .get("mention_count")
            .and_then(Value::as_u64)
            .unwrap_or(0) as u32,
    })
}

fn parse_user_guild_settings_update(data: &Value) -> Option<AppEvent> {
    parse_user_guild_notification_settings(data)
        .map(|settings| AppEvent::UserGuildNotificationSettingsUpdate { settings })
}

fn parse_user_guild_settings_entries(
    value: Option<&Value>,
) -> Option<Vec<GuildNotificationSettingsInfo>> {
    let entries = value
        .and_then(|node| node.get("entries").or(Some(node)))
        .and_then(Value::as_array)?;
    let settings: Vec<GuildNotificationSettingsInfo> = entries
        .iter()
        .filter_map(parse_user_guild_notification_settings)
        .collect();
    (!settings.is_empty()).then_some(settings)
}

fn parse_user_guild_notification_settings(value: &Value) -> Option<GuildNotificationSettingsInfo> {
    let guild_id = parse_user_guild_settings_guild_id(value.get("guild_id"))?;
    let channel_overrides = parse_channel_notification_overrides(value.get("channel_overrides"));

    Some(GuildNotificationSettingsInfo {
        guild_id,
        message_notifications: parse_notification_level(value.get("message_notifications")),
        muted: value.get("muted").and_then(Value::as_bool).unwrap_or(false),
        mute_end_time: parse_mute_end_time(value),
        suppress_everyone: value
            .get("suppress_everyone")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        suppress_roles: value
            .get("suppress_roles")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        channel_overrides,
    })
}

fn parse_user_guild_settings_guild_id(value: Option<&Value>) -> Option<Option<Id<GuildMarker>>> {
    match value {
        Some(value) if value.is_null() => Some(None),
        Some(value) if value.as_str() == Some("@me") => Some(None),
        Some(value) => parse_id::<GuildMarker>(value).map(Some),
        None => Some(None),
    }
}

fn parse_channel_notification_overrides(
    value: Option<&Value>,
) -> Vec<ChannelNotificationOverrideInfo> {
    match value {
        Some(Value::Array(overrides)) => overrides
            .iter()
            .filter_map(parse_channel_notification_override)
            .collect(),
        Some(Value::Object(overrides)) => overrides
            .iter()
            .filter_map(|(channel_id, override_value)| {
                parse_channel_notification_override_with_key(channel_id, override_value)
            })
            .collect(),
        _ => Vec::new(),
    }
}

fn parse_channel_notification_override(value: &Value) -> Option<ChannelNotificationOverrideInfo> {
    Some(ChannelNotificationOverrideInfo {
        channel_id: value
            .get("channel_id")
            .and_then(parse_id::<ChannelMarker>)?,
        message_notifications: parse_notification_level(value.get("message_notifications")),
        muted: value.get("muted").and_then(Value::as_bool).unwrap_or(false),
        mute_end_time: parse_mute_end_time(value),
    })
}

fn parse_channel_notification_override_with_key(
    channel_id: &str,
    value: &Value,
) -> Option<ChannelNotificationOverrideInfo> {
    Some(ChannelNotificationOverrideInfo {
        channel_id: channel_id.parse::<u64>().ok().and_then(Id::new_checked)?,
        message_notifications: parse_notification_level(value.get("message_notifications")),
        muted: value.get("muted").and_then(Value::as_bool).unwrap_or(false),
        mute_end_time: parse_mute_end_time(value),
    })
}

fn parse_notification_level(value: Option<&Value>) -> Option<NotificationLevel> {
    value
        .and_then(Value::as_u64)
        .and_then(NotificationLevel::from_code)
}

fn parse_mute_end_time(value: &Value) -> Option<String> {
    value
        .get("mute_config")
        .and_then(|config| config.get("end_time"))
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .map(str::to_owned)
}

fn parse_message_ack(data: &Value) -> Option<AppEvent> {
    Some(AppEvent::MessageAck {
        channel_id: parse_id::<ChannelMarker>(data.get("channel_id")?)?,
        message_id: parse_id::<MessageMarker>(data.get("message_id")?)?,
        mention_count: data
            .get("mention_count")
            .and_then(Value::as_u64)
            .unwrap_or(0) as u32,
    })
}

fn parse_message_reaction_add(data: &Value) -> Option<AppEvent> {
    Some(AppEvent::MessageReactionAdd {
        guild_id: data.get("guild_id").and_then(parse_id::<GuildMarker>),
        channel_id: parse_id::<ChannelMarker>(data.get("channel_id")?)?,
        message_id: parse_id::<MessageMarker>(data.get("message_id")?)?,
        user_id: parse_id::<UserMarker>(data.get("user_id")?)?,
        emoji: parse_reaction_emoji(data.get("emoji")?)?,
    })
}

fn parse_message_reaction_remove(data: &Value) -> Option<AppEvent> {
    Some(AppEvent::MessageReactionRemove {
        guild_id: data.get("guild_id").and_then(parse_id::<GuildMarker>),
        channel_id: parse_id::<ChannelMarker>(data.get("channel_id")?)?,
        message_id: parse_id::<MessageMarker>(data.get("message_id")?)?,
        user_id: parse_id::<UserMarker>(data.get("user_id")?)?,
        emoji: parse_reaction_emoji(data.get("emoji")?)?,
    })
}

fn parse_message_reaction_remove_all(data: &Value) -> Option<AppEvent> {
    Some(AppEvent::MessageReactionRemoveAll {
        guild_id: data.get("guild_id").and_then(parse_id::<GuildMarker>),
        channel_id: parse_id::<ChannelMarker>(data.get("channel_id")?)?,
        message_id: parse_id::<MessageMarker>(data.get("message_id")?)?,
    })
}

fn parse_message_reaction_remove_emoji(data: &Value) -> Option<AppEvent> {
    Some(AppEvent::MessageReactionRemoveEmoji {
        guild_id: data.get("guild_id").and_then(parse_id::<GuildMarker>),
        channel_id: parse_id::<ChannelMarker>(data.get("channel_id")?)?,
        message_id: parse_id::<MessageMarker>(data.get("message_id")?)?,
        emoji: parse_reaction_emoji(data.get("emoji")?)?,
    })
}

fn parse_member_upsert(data: &Value) -> Option<AppEvent> {
    let guild_id = parse_id::<GuildMarker>(data.get("guild_id")?)?;
    let member = parse_member_info(data)?;
    Some(AppEvent::GuildMemberUpsert { guild_id, member })
}

fn parse_member_add(data: &Value) -> Option<AppEvent> {
    let guild_id = parse_id::<GuildMarker>(data.get("guild_id")?)?;
    let member = parse_member_info(data)?;
    Some(AppEvent::GuildMemberAdd { guild_id, member })
}

fn parse_member_chunk(data: &Value) -> Vec<AppEvent> {
    let Some(guild_id) = data.get("guild_id").and_then(parse_id::<GuildMarker>) else {
        return Vec::new();
    };

    let mut events: Vec<AppEvent> = data
        .get("members")
        .and_then(Value::as_array)
        .map(|members| {
            members
                .iter()
                .filter_map(parse_member_info)
                .map(|member| AppEvent::GuildMemberUpsert { guild_id, member })
                .collect()
        })
        .unwrap_or_default();

    if let Some(presences) = data.get("presences").and_then(Value::as_array) {
        events.extend(presences.iter().filter_map(parse_presence_entry).map(
            |(user_id, status, activities)| AppEvent::PresenceUpdate {
                guild_id,
                user_id,
                status,
                activities,
            },
        ));
    }

    events
}

fn parse_member_list_update(data: &Value) -> Vec<AppEvent> {
    let Some(guild_id) = data.get("guild_id").and_then(parse_id::<GuildMarker>) else {
        return Vec::new();
    };
    let Some(ops) = data.get("ops").and_then(Value::as_array) else {
        return Vec::new();
    };

    let mut events = Vec::new();

    if let Some(groups) = data.get("groups").and_then(Value::as_array) {
        let online = groups
            .iter()
            .filter(|g| g.get("id").and_then(Value::as_str) != Some("offline"))
            .filter_map(|g| g.get("count").and_then(Value::as_u64))
            .map(|c| c as u32)
            .sum();
        events.push(AppEvent::GuildMemberListCounts { guild_id, online });
    }

    // A single GUILD_MEMBER_LIST_UPDATE event can carry SYNC ops for several
    // ranges (e.g. `[0,99]` plus `[100,199]`). We previously dropped every
    // SYNC whose range did not start at zero, which left members past the
    // first chunk invisible in larger guilds.
    for op in ops {
        match op.get("op").and_then(Value::as_str) {
            Some("SYNC") => {
                if let Some(items) = op.get("items").and_then(Value::as_array) {
                    for item in items {
                        events.extend(parse_member_list_item(guild_id, item));
                    }
                }
            }
            Some("INSERT" | "UPDATE") => {
                if let Some(item) = op.get("item") {
                    events.extend(parse_member_list_item(guild_id, item));
                }
            }
            _ => {}
        }
    }

    events
}

fn parse_member_list_item(guild_id: Id<GuildMarker>, item: &Value) -> Vec<AppEvent> {
    let Some(member) = item
        .get("member")
        .or_else(|| item.get("user").map(|_| item))
    else {
        return Vec::new();
    };
    let Some(member_info) = parse_member_info(member) else {
        return Vec::new();
    };
    let user_id = member_info.user_id;
    let presence = member.get("presence");
    let status = presence
        .and_then(|presence| presence.get("status"))
        .and_then(Value::as_str)
        .map(parse_status);
    let activities = presence.map(parse_activities).unwrap_or_default();

    let mut events = vec![AppEvent::GuildMemberUpsert {
        guild_id,
        member: member_info,
    }];
    if let Some(status) = status {
        events.push(AppEvent::PresenceUpdate {
            guild_id,
            user_id,
            status,
            activities,
        });
    }
    events
}

fn parse_member_remove(data: &Value) -> Option<AppEvent> {
    let guild_id = parse_id::<GuildMarker>(data.get("guild_id")?)?;
    let user = data.get("user")?;
    let user_id = parse_id::<UserMarker>(user.get("id")?)?;
    Some(AppEvent::GuildMemberRemove { guild_id, user_id })
}

fn parse_presence_update(data: &Value) -> Vec<AppEvent> {
    let Some((user_id, status, activities)) = parse_presence_entry(data) else {
        return Vec::new();
    };
    if let Some(guild_id) = data.get("guild_id").and_then(parse_id::<GuildMarker>) {
        vec![AppEvent::PresenceUpdate {
            guild_id,
            user_id,
            status,
            activities,
        }]
    } else {
        vec![AppEvent::UserPresenceUpdate {
            user_id,
            status,
            activities,
        }]
    }
}

/// Discord's TYPING_START shape: `{ channel_id, guild_id?, user_id,
/// timestamp, member? }`. Guild channels carry the typer's user_id directly,
/// while DMs sometimes only embed it under `member.user.id`. We accept both
/// and ignore the timestamp (state stamps its own Instant on receive).
fn parse_typing_start(data: &Value) -> Option<AppEvent> {
    let channel_id = parse_id::<ChannelMarker>(data.get("channel_id")?)?;
    let user_id = data
        .get("user_id")
        .and_then(parse_id::<UserMarker>)
        .or_else(|| {
            data.get("member")
                .and_then(|member| member.get("user"))
                .and_then(|user| user.get("id"))
                .and_then(parse_id::<UserMarker>)
        })?;
    Some(AppEvent::TypingStart {
        channel_id,
        user_id,
    })
}

fn parse_member_info(value: &Value) -> Option<MemberInfo> {
    let user = value.get("user");
    let user_id = user
        .and_then(|user| user.get("id"))
        .or_else(|| value.get("user_id"))
        .or_else(|| value.get("id"))
        .and_then(parse_id::<UserMarker>)?;
    let nick = value
        .get("nick")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty());
    let global_name = user
        .and_then(|user| user.get("global_name"))
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty());
    let username = user
        .and_then(|user| user.get("username"))
        .and_then(Value::as_str);
    let display_name = nick
        .or(global_name)
        .or(username)
        .unwrap_or("unknown")
        .to_owned();
    let is_bot = user
        .and_then(|user| user.get("bot"))
        .and_then(Value::as_bool)
        .unwrap_or(false);
    Some(MemberInfo {
        user_id,
        display_name,
        username: username.map(str::to_owned),
        is_bot,
        avatar_url: user.and_then(|user| raw_user_avatar_url(user_id, user)),
        role_ids: value
            .get("roles")
            .and_then(Value::as_array)
            .map(|roles| roles.iter().filter_map(parse_id::<RoleMarker>).collect())
            .unwrap_or_default(),
    })
}

fn raw_user_avatar_url(user_id: Id<UserMarker>, user: &Value) -> Option<String> {
    let avatar = user
        .get("avatar")
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty());
    Some(match avatar {
        Some(hash) => {
            let extension = if hash.starts_with("a_") { "gif" } else { "png" };
            format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.{extension}")
        }
        None => default_avatar_url(user_id, raw_discriminator(user).unwrap_or(0)),
    })
}

fn raw_discriminator(user: &Value) -> Option<u16> {
    user.get("discriminator").and_then(|value| {
        value
            .as_str()
            .and_then(|value| value.parse::<u16>().ok())
            .or_else(|| value.as_u64().and_then(|value| u16::try_from(value).ok()))
    })
}

fn parse_presence_entry(
    value: &Value,
) -> Option<(Id<UserMarker>, PresenceStatus, Vec<ActivityInfo>)> {
    let user_id = presence_user_id(value)?;
    let status = value
        .get("status")
        .and_then(Value::as_str)
        .map(parse_status)?;
    let activities = parse_activities(value);
    Some((user_id, status, activities))
}

fn parse_activities(value: &Value) -> Vec<ActivityInfo> {
    value
        .get("activities")
        .and_then(Value::as_array)
        .map(|items| items.iter().filter_map(parse_activity).collect())
        .unwrap_or_default()
}

fn parse_activity(value: &Value) -> Option<ActivityInfo> {
    let kind = ActivityKind::from_code(value.get("type").and_then(Value::as_u64).unwrap_or(0));
    let name = value
        .get("name")
        .and_then(Value::as_str)
        .map(str::to_owned)
        .unwrap_or_default();

    let details = value
        .get("details")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .map(str::to_owned);
    let state = value
        .get("state")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .map(str::to_owned);
    let url = value
        .get("url")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .map(str::to_owned);
    let application_id = value
        .get("application_id")
        .and_then(|node| {
            node.as_str()
                .map(str::to_owned)
                .or_else(|| node.as_u64().map(|n| n.to_string()))
        })
        .filter(|s| !s.is_empty());
    let emoji = value.get("emoji").and_then(parse_activity_emoji);

    if kind == ActivityKind::Unknown && name.is_empty() && state.is_none() && emoji.is_none() {
        return None;
    }

    Some(ActivityInfo {
        kind,
        name,
        details,
        state,
        url,
        application_id,
        emoji,
    })
}

fn parse_activity_emoji(value: &Value) -> Option<ActivityEmoji> {
    let name = value
        .get("name")
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())?
        .to_owned();
    let id = value.get("id").and_then(parse_id::<EmojiMarker>);
    let animated = value
        .get("animated")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    Some(ActivityEmoji { name, id, animated })
}

fn presence_user_id(value: &Value) -> Option<Id<UserMarker>> {
    value
        .get("user")
        .and_then(|user| user.get("id"))
        .or_else(|| value.get("user_id"))
        .or_else(|| value.get("id"))
        .and_then(parse_id::<UserMarker>)
}

fn parse_relationship_add(data: &Value) -> Option<AppEvent> {
    let (user_id, status) = parse_relationship_entry(data)?;
    Some(AppEvent::RelationshipUpsert { user_id, status })
}

fn parse_relationship_remove(data: &Value) -> Option<AppEvent> {
    let user_id = data
        .get("id")
        .and_then(parse_id::<UserMarker>)
        .or_else(|| {
            data.get("user")
                .and_then(|user| user.get("id"))
                .and_then(parse_id::<UserMarker>)
        })?;
    Some(AppEvent::RelationshipRemove { user_id })
}

fn parse_relationship_entry(value: &Value) -> Option<(Id<UserMarker>, FriendStatus)> {
    // READY's `relationships` array uses ids on the entry itself for the
    // target user. Older shards may nest it under `user.id`; check both.
    let user_id = value
        .get("id")
        .and_then(parse_id::<UserMarker>)
        .or_else(|| {
            value
                .get("user")
                .and_then(|user| user.get("id"))
                .and_then(parse_id::<UserMarker>)
        })?;
    let kind = value.get("type").and_then(Value::as_u64)?;
    let status = match kind {
        1 => FriendStatus::Friend,
        2 => FriendStatus::Blocked,
        3 => FriendStatus::IncomingRequest,
        4 => FriendStatus::OutgoingRequest,
        _ => return None,
    };
    Some((user_id, status))
}

fn parse_status(value: &str) -> PresenceStatus {
    match value {
        "online" => PresenceStatus::Online,
        "idle" => PresenceStatus::Idle,
        "dnd" => PresenceStatus::DoNotDisturb,
        "offline" | "invisible" => PresenceStatus::Offline,
        _ => PresenceStatus::Unknown,
    }
}

fn parse_id<M>(value: &Value) -> Option<Id<M>> {
    value
        .as_str()
        .and_then(|value| value.parse::<u64>().ok())
        .or_else(|| value.as_u64())
        .and_then(Id::new_checked)
}
#[cfg(test)]
mod tests {
    use crate::discord::ids::Id;
    use serde_json::json;

    use super::{
        parse_channel_info, parse_guild_create, parse_guild_emojis_update, parse_guild_update,
        parse_message_create, parse_message_info, parse_message_update, parse_user_account_event,
    };
    use crate::discord::{
        ActivityKind, AppEvent, AttachmentUpdate, ChannelVisibilityStats, DiscordState,
        FriendStatus, MentionInfo, MessageKind, NotificationLevel, PollAnswerInfo, PollInfo,
        PresenceStatus, ReactionEmoji, ReplyInfo,
    };

    #[test]
    fn raw_member_list_update_populates_members_and_presence() {
        let events = parse_user_account_event(
            &json!({
                "t": "GUILD_MEMBER_LIST_UPDATE",
                "d": {
                    "guild_id": "10",
                    "ops": [{
                        "op": "SYNC",
                        "range": [0, 99],
                        "items": [{
                            "member": {
                                "user": {
                                    "id": "20",
                                    "username": "alice",
                                    "global_name": "Alice"
                                },
                                "nick": "Alice Nick",
                                "roles": ["30"],
                                "presence": { "status": "idle" }
                            }
                        }]
                    }]
                }
            })
            .to_string(),
        );

        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::GuildMemberUpsert { guild_id, member }
                if *guild_id == Id::new(10)
                    && member.user_id == Id::new(20)
                    && member.display_name == "Alice Nick"
                    && member.role_ids == vec![Id::new(30)]
        )));
        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::PresenceUpdate { guild_id, user_id, status, .. }
                if *guild_id == Id::new(10)
                    && *user_id == Id::new(20)
                    && *status == PresenceStatus::Idle
        )));
    }

    #[test]
    fn raw_member_list_update_processes_all_sync_ranges() {
        // Discord can ship more than one SYNC chunk in a single
        // GUILD_MEMBER_LIST_UPDATE — e.g. range [0,99] plus [100,199] — and
        // we need members from every chunk, not just the first.
        let events = parse_user_account_event(
            &json!({
                "t": "GUILD_MEMBER_LIST_UPDATE",
                "d": {
                    "guild_id": "10",
                    "ops": [
                        {
                            "op": "SYNC",
                            "range": [0, 99],
                            "items": [{
                                "member": {
                                    "user": { "id": "20", "username": "alice" },
                                    "roles": [],
                                    "presence": { "status": "online" }
                                }
                            }]
                        },
                        {
                            "op": "SYNC",
                            "range": [100, 199],
                            "items": [{
                                "member": {
                                    "user": { "id": "21", "username": "bob" },
                                    "roles": [],
                                    "presence": { "status": "idle" }
                                }
                            }]
                        }
                    ]
                }
            })
            .to_string(),
        );

        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::GuildMemberUpsert { guild_id, member }
                if *guild_id == Id::new(10) && member.user_id == Id::new(20)
        )));
        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::GuildMemberUpsert { guild_id, member }
                if *guild_id == Id::new(10) && member.user_id == Id::new(21)
        )));
        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::PresenceUpdate { user_id, status, .. }
                if *user_id == Id::new(21) && *status == PresenceStatus::Idle
        )));
    }

    #[test]
    fn raw_member_list_update_handles_insert_and_update_items() {
        let events = parse_user_account_event(
            &json!({
                "t": "GUILD_MEMBER_LIST_UPDATE",
                "d": {
                    "guild_id": "10",
                    "ops": [
                        {
                            "op": "INSERT",
                            "item": {
                                "member": {
                                    "user": {
                                        "id": "20",
                                        "username": "alice"
                                    },
                                    "roles": [],
                                    "presence": { "status": "online" }
                                }
                            }
                        },
                        {
                            "op": "UPDATE",
                            "item": {
                                "member": {
                                    "user": {
                                        "id": "30",
                                        "username": "bob"
                                    },
                                    "roles": [],
                                    "presence": { "status": "dnd" }
                                }
                            }
                        }
                    ]
                }
            })
            .to_string(),
        );

        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::PresenceUpdate { guild_id, user_id, status, .. }
                if *guild_id == Id::new(10)
                    && *user_id == Id::new(20)
                    && *status == PresenceStatus::Online
        )));
        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::PresenceUpdate { guild_id, user_id, status, .. }
                if *guild_id == Id::new(10)
                    && *user_id == Id::new(30)
                    && *status == PresenceStatus::DoNotDisturb
        )));
    }

    #[test]
    fn relationship_add_emits_friend_upsert() {
        let events = parse_user_account_event(
            &json!({
                "t": "RELATIONSHIP_ADD",
                "d": {
                    "id": "20",
                    "type": 1,
                    "user": {"id": "20", "username": "alice"}
                }
            })
            .to_string(),
        );
        assert_eq!(events.len(), 1);
        assert!(matches!(
            &events[0],
            AppEvent::RelationshipUpsert { user_id, status }
                if *user_id == Id::new(20) && *status == FriendStatus::Friend
        ));
    }

    #[test]
    fn relationship_remove_emits_event() {
        let events = parse_user_account_event(
            &json!({
                "t": "RELATIONSHIP_REMOVE",
                "d": {"id": "20", "type": 3}
            })
            .to_string(),
        );
        assert_eq!(events.len(), 1);
        assert!(matches!(
            &events[0],
            AppEvent::RelationshipRemove { user_id } if *user_id == Id::new(20)
        ));
    }

    #[test]
    fn channel_parser_keeps_last_message_id() {
        let channel = parse_channel_info(
            &json!({
                "id": "10",
                "type": 1,
                "last_message_id": "99",
                "recipients": [{ "username": "neo" }]
            }),
            None,
        )
        .expect("dm channel should parse");

        assert_eq!(channel.last_message_id.map(|id| id.get()), Some(99));
    }

    #[test]
    fn raw_ready_parser_adds_current_user_to_group_dm_recipients() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": {
                        "id": "99",
                        "username": "neo"
                    },
                    "sessions": [{ "status": "idle" }],
                    "guilds": [],
                    "merged_presences": {
                        "friends": [
                            { "user": { "id": "20" }, "status": "online" },
                            { "user": { "id": "30" }, "status": "idle" }
                        ]
                    },
                    "private_channels": [{
                        "id": "10",
                        "type": 3,
                        "name": "project chat",
                        "recipients": [
                            {
                                "id": "20",
                                "username": "alice",
                                "global_name": "Alice",
                                "bot": false
                            },
                            {
                                "id": "30",
                                "username": "helper-bot",
                                "bot": true
                            }
                        ]
                    }]
                }
            })
            .to_string(),
        );

        let channel = events
            .iter()
            .find_map(|event| match event {
                AppEvent::ChannelUpsert(channel) => Some(channel),
                _ => None,
            })
            .expect("ready should emit a private channel upsert");
        let recipients = channel
            .recipients
            .as_ref()
            .expect("group dm should carry recipients");

        assert_eq!(channel.kind, "group-dm");
        assert_eq!(recipients.len(), 3);
        assert_eq!(recipients[0].user_id, Id::new(20));
        assert_eq!(recipients[0].display_name, "Alice");
        assert!(!recipients[0].is_bot);
        assert_eq!(recipients[0].status, Some(PresenceStatus::Online));
        assert_eq!(recipients[1].display_name, "helper-bot");
        assert!(recipients[1].is_bot);
        assert_eq!(recipients[1].status, Some(PresenceStatus::Idle));
        assert_eq!(recipients[2].user_id, Id::new(99));
        assert_eq!(recipients[2].display_name, "neo");
        assert_eq!(recipients[2].status, Some(PresenceStatus::Idle));
        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::UserPresenceUpdate { user_id, status, .. }
                if *user_id == Id::new(99) && *status == PresenceStatus::Idle
        )));
    }

    #[test]
    fn raw_ready_parser_exposes_current_user_premium_capability() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": {
                        "id": "99",
                        "username": "neo",
                        "premium_type": 0
                    },
                    "guilds": []
                }
            })
            .to_string(),
        );

        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::CurrentUserCapabilities {
                can_use_animated_custom_emojis: false
            }
        )));
    }

    #[test]
    fn raw_ready_parser_applies_guild_merged_presence_to_dm_recipient() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": {
                        "id": "99",
                        "username": "neo"
                    },
                    "guilds": [],
                    "merged_presences": {
                        "friends": [],
                        "guilds": [[
                            { "user_id": "20", "status": "idle" }
                        ]]
                    },
                    "private_channels": [{
                        "id": "10",
                        "type": 1,
                        "recipients": [{
                            "id": "20",
                            "username": "alice"
                        }]
                    }]
                }
            })
            .to_string(),
        );

        let channel = events
            .iter()
            .find_map(|event| match event {
                AppEvent::ChannelUpsert(channel) => Some(channel),
                _ => None,
            })
            .expect("ready should emit a private channel upsert");
        let recipients = channel
            .recipients
            .as_ref()
            .expect("dm should carry recipients");

        assert_eq!(channel.kind, "dm");
        assert_eq!(recipients[0].user_id, Id::new(20));
        assert_eq!(recipients[0].status, Some(PresenceStatus::Idle));
    }

    #[test]
    fn raw_ready_parser_applies_top_level_presence_to_dm_recipient() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": {
                        "id": "99",
                        "username": "neo"
                    },
                    "guilds": [],
                    "presences": [{
                        "user": { "id": "20" },
                        "status": "online"
                    }],
                    "private_channels": [{
                        "id": "10",
                        "type": 1,
                        "recipients": [{
                            "id": "20",
                            "username": "alice"
                        }]
                    }]
                }
            })
            .to_string(),
        );

        let channel = events
            .iter()
            .find_map(|event| match event {
                AppEvent::ChannelUpsert(channel) => Some(channel),
                _ => None,
            })
            .expect("ready should emit a private channel upsert");
        let recipients = channel
            .recipients
            .as_ref()
            .expect("dm should carry recipients");

        assert_eq!(recipients[0].user_id, Id::new(20));
        assert_eq!(recipients[0].status, Some(PresenceStatus::Online));
    }

    #[test]
    fn raw_ready_supplemental_updates_user_presences() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY_SUPPLEMENTAL",
                "d": {
                    "merged_presences": {
                        "friends": [
                            { "user_id": "20", "status": "online" }
                        ],
                        "guilds": [[
                            { "user_id": "30", "status": "idle" }
                        ]]
                    }
                }
            })
            .to_string(),
        );

        assert_eq!(events.len(), 2);
        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::UserPresenceUpdate { user_id, status, .. }
                if *user_id == Id::new(20) && *status == PresenceStatus::Online
        )));
        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::UserPresenceUpdate { user_id, status, .. }
                if *user_id == Id::new(30) && *status == PresenceStatus::Idle
        )));
    }

    #[test]
    fn raw_presence_update_extracts_activities() {
        let events = parse_user_account_event(
            &json!({
                "t": "PRESENCE_UPDATE",
                "d": {
                    "guild_id": "10",
                    "user": { "id": "20" },
                    "status": "online",
                    "activities": [
                        {
                            "type": 4,
                            "name": "Custom Status",
                            "state": "Coding hard",
                            "emoji": { "name": "🦀" }
                        },
                        {
                            "type": 2,
                            "name": "Spotify",
                            "details": "Bohemian Rhapsody",
                            "state": "Queen"
                        },
                        {
                            "type": 0,
                            "name": "Concord"
                        }
                    ]
                }
            })
            .to_string(),
        );

        let activities = events
            .iter()
            .find_map(|event| match event {
                AppEvent::PresenceUpdate { activities, .. } => Some(activities),
                _ => None,
            })
            .expect("PRESENCE_UPDATE should produce a PresenceUpdate event");

        assert_eq!(activities.len(), 3);
        assert_eq!(activities[0].kind, ActivityKind::Custom);
        assert_eq!(activities[0].state.as_deref(), Some("Coding hard"));
        assert_eq!(
            activities[0].emoji.as_ref().map(|e| e.name.as_str()),
            Some("🦀")
        );
        assert_eq!(activities[1].kind, ActivityKind::Listening);
        assert_eq!(activities[1].name, "Spotify");
        assert_eq!(activities[1].details.as_deref(), Some("Bohemian Rhapsody"));
        assert_eq!(activities[1].state.as_deref(), Some("Queen"));
        assert_eq!(activities[2].kind, ActivityKind::Playing);
        assert_eq!(activities[2].name, "Concord");
    }

    #[test]
    fn raw_presence_update_without_guild_id_emits_user_event_with_activities() {
        let events = parse_user_account_event(
            &json!({
                "t": "PRESENCE_UPDATE",
                "d": {
                    "user": { "id": "20" },
                    "status": "dnd",
                    "activities": [
                        { "type": 1, "name": "Twitch", "url": "https://twitch.tv/foo" }
                    ]
                }
            })
            .to_string(),
        );

        let activities = events
            .iter()
            .find_map(|event| match event {
                AppEvent::UserPresenceUpdate { activities, .. } => Some(activities),
                _ => None,
            })
            .expect("PRESENCE_UPDATE without guild_id should produce a UserPresenceUpdate");

        assert_eq!(activities.len(), 1);
        assert_eq!(activities[0].kind, ActivityKind::Streaming);
        assert_eq!(activities[0].name, "Twitch");
        assert_eq!(activities[0].url.as_deref(), Some("https://twitch.tv/foo"));
    }

    #[test]
    fn raw_ready_supplemental_updates_merged_member_roles() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY_SUPPLEMENTAL",
                "d": {
                    "guilds": [{ "id": "1" }],
                    "merged_members": [[{
                        "user_id": "10",
                        "roles": ["20"]
                    }]]
                }
            })
            .to_string(),
        );

        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::GuildMemberUpsert { guild_id, member }
                if *guild_id == Id::new(1)
                    && member.user_id == Id::new(10)
                    && member.role_ids == vec![Id::new(20)]
        )));
    }

    #[test]
    fn raw_ready_supplemental_aligns_merged_members_by_guild_index() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY_SUPPLEMENTAL",
                "d": {
                    "guilds": [{ "id": "1" }, { "id": "2" }],
                    "merged_members": [[{
                        "user_id": "10",
                        "roles": ["20"]
                    }], [{
                        "user_id": "10",
                        "roles": ["30"]
                    }]]
                }
            })
            .to_string(),
        );

        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::GuildMemberUpsert { guild_id, member }
                if *guild_id == Id::new(1)
                    && member.user_id == Id::new(10)
                    && member.role_ids == vec![Id::new(20)]
        )));
        assert!(events.iter().any(|event| matches!(
            event,
            AppEvent::GuildMemberUpsert { guild_id, member }
                if *guild_id == Id::new(2)
                    && member.user_id == Id::new(10)
                    && member.role_ids == vec![Id::new(30)]
        )));
    }

    #[test]
    fn raw_ready_supplemental_member_roles_hide_role_denied_channel() {
        let ready_events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": { "id": "10", "username": "me" },
                    "guilds": [{
                        "id": "1",
                        "name": "guild",
                        "owner_id": "11",
                        "channels": [{
                            "id": "2",
                            "type": 0,
                            "name": "staff-hidden",
                            "permission_overwrites": [{
                                "id": "20",
                                "type": 0,
                                "allow": "0",
                                "deny": "1024"
                            }]
                        }],
                        "members": [],
                        "presences": [],
                        "roles": [],
                        "emojis": []
                    }],
                    "private_channels": []
                }
            })
            .to_string(),
        );
        let supplemental_events = parse_user_account_event(
            &json!({
                "t": "READY_SUPPLEMENTAL",
                "d": {
                    "guilds": [{
                        "id": "1",
                        "roles": [{
                            "id": "1",
                            "name": "@everyone",
                            "permissions": "1024",
                            "position": 0,
                            "hoist": false
                        }, {
                            "id": "20",
                            "name": "Staff",
                            "permissions": "0",
                            "position": 1,
                            "hoist": false
                        }]
                    }],
                    "merged_members": [[{
                        "user_id": "10",
                        "roles": ["20"]
                    }]]
                }
            })
            .to_string(),
        );
        let mut state = DiscordState::default();
        for event in ready_events.iter().chain(supplemental_events.iter()) {
            state.apply_event(event);
        }

        assert_eq!(
            state.channel_visibility_stats(Some(Id::new(1))),
            ChannelVisibilityStats {
                visible: 0,
                hidden: 1,
            }
        );
        assert!(
            state
                .viewable_channels_for_guild(Some(Id::new(1)))
                .is_empty()
        );
    }

    #[test]
    fn raw_ready_supplemental_accepts_bare_id_presence_entries() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY_SUPPLEMENTAL",
                "d": {
                    "merged_presences": {
                        "friends": [
                            { "id": "20", "status": "online" }
                        ]
                    }
                }
            })
            .to_string(),
        );

        assert!(matches!(
            events.as_slice(),
            [AppEvent::UserPresenceUpdate { user_id, status, .. }]
                if *user_id == Id::new(20) && *status == PresenceStatus::Online
        ));
    }

    #[test]
    fn raw_ready_supplemental_ignores_non_presence_ids() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY_SUPPLEMENTAL",
                "d": {
                    "merged_presences": {
                        "friends": [],
                        "metadata": { "id": "20" }
                    }
                }
            })
            .to_string(),
        );

        assert!(events.is_empty());
    }

    #[test]
    fn raw_presence_update_without_guild_updates_user_presence() {
        let events = parse_user_account_event(
            &json!({
                "t": "PRESENCE_UPDATE",
                "d": {
                    "user": { "id": "20" },
                    "status": "dnd"
                }
            })
            .to_string(),
        );

        assert!(matches!(
            events.as_slice(),
            [AppEvent::UserPresenceUpdate { user_id, status, .. }]
                if *user_id == Id::new(20) && *status == PresenceStatus::DoNotDisturb
        ));
    }

    #[test]
    fn raw_presence_update_accepts_user_id_field() {
        let events = parse_user_account_event(
            &json!({
                "t": "PRESENCE_UPDATE",
                "d": {
                    "user_id": "20",
                    "status": "online"
                }
            })
            .to_string(),
        );

        assert!(matches!(
            events.as_slice(),
            [AppEvent::UserPresenceUpdate { user_id, status, .. }]
                if *user_id == Id::new(20) && *status == PresenceStatus::Online
        ));
    }

    #[test]
    fn thread_channel_parser_keeps_counts_and_status() {
        let channel = parse_channel_info(
            &json!({
                "id": "10",
                "guild_id": "1",
                "parent_id": "2",
                "type": 11,
                "name": "release notes",
                "message_count": 12,
                "total_message_sent": 14,
                "thread_metadata": { "archived": true, "locked": false }
            }),
            None,
        )
        .expect("thread channel should parse");

        assert_eq!(channel.kind, "GuildPublicThread");
        assert_eq!(channel.message_count, Some(12));
        assert_eq!(channel.total_message_sent, Some(14));
        assert_eq!(channel.thread_archived, Some(true));
        assert_eq!(channel.thread_locked, Some(false));
    }

    #[test]
    fn raw_thread_create_upserts_thread_channel() {
        let events = parse_user_account_event(
            &json!({
                "t": "THREAD_CREATE",
                "d": thread_payload(10, "release notes")
            })
            .to_string(),
        );

        assert!(matches!(
            events.as_slice(),
            [AppEvent::ChannelUpsert(channel)]
                if channel.channel_id == Id::new(10)
                    && channel.guild_id == Some(Id::new(1))
                    && channel.parent_id == Some(Id::new(2))
                    && channel.name == "release notes"
                    && channel.kind == "GuildPublicThread"
                    && channel.message_count == Some(12)
                    && channel.total_message_sent == Some(14)
                    && channel.thread_archived == Some(false)
                    && channel.thread_locked == Some(false)
        ));
    }

    #[test]
    fn raw_thread_update_upserts_thread_channel() {
        let events = parse_user_account_event(
            &json!({
                "t": "THREAD_UPDATE",
                "d": thread_payload(10, "renamed thread")
            })
            .to_string(),
        );

        assert!(matches!(
            events.as_slice(),
            [AppEvent::ChannelUpsert(channel)]
                if channel.channel_id == Id::new(10)
                    && channel.name == "renamed thread"
                    && channel.kind == "GuildPublicThread"
        ));
    }

    #[test]
    fn raw_thread_delete_removes_thread_channel() {
        let events = parse_user_account_event(
            &json!({
                "t": "THREAD_DELETE",
                "d": {
                    "id": "10",
                    "guild_id": "1",
                    "parent_id": "2",
                    "type": 11
                }
            })
            .to_string(),
        );

        assert!(matches!(
            events.as_slice(),
            [AppEvent::ChannelDelete { guild_id, channel_id }]
                if *guild_id == Some(Id::new(1)) && *channel_id == Id::new(10)
        ));
    }

    #[test]
    fn raw_thread_list_sync_upserts_all_threads() {
        let events = parse_user_account_event(
            &json!({
                "t": "THREAD_LIST_SYNC",
                "d": {
                    "guild_id": "1",
                    "channel_ids": ["2"],
                    "threads": [
                        thread_payload(10, "release notes"),
                        thread_payload(11, "bug reports")
                    ],
                    "members": []
                }
            })
            .to_string(),
        );

        assert_eq!(events.len(), 2);
        assert!(matches!(
            &events[0],
            AppEvent::ChannelUpsert(channel)
                if channel.channel_id == Id::new(10) && channel.name == "release notes"
        ));
        assert!(matches!(
            &events[1],
            AppEvent::ChannelUpsert(channel)
                if channel.channel_id == Id::new(11) && channel.name == "bug reports"
        ));
    }

    #[test]
    fn message_update_parser_distinguishes_absent_and_empty_attachments() {
        let cases = [
            (
                json!({
                    "id": "20",
                    "channel_id": "10",
                    "content": "edited"
                }),
                false,
            ),
            (
                json!({
                    "id": "20",
                    "channel_id": "10",
                    "content": "edited",
                    "attachments": []
                }),
                true,
            ),
        ];

        for (payload, clears_attachments) in cases {
            let event = parse_message_update(&payload).expect("message update should parse");
            let AppEvent::MessageUpdate { attachments, .. } = event else {
                panic!("expected message update event");
            };
            if clears_attachments {
                assert!(
                    matches!(attachments, AttachmentUpdate::Replace(values) if values.is_empty())
                );
            } else {
                assert!(matches!(attachments, AttachmentUpdate::Unchanged));
            }
        }
    }

    #[test]
    fn guild_create_parser_keeps_custom_emojis() {
        let event = parse_guild_create(&json!({
            "id": "1",
            "name": "guild",
            "member_count": 123,
            "channels": [],
            "members": [],
            "presences": [],
            "emojis": [
                {
                    "id": "50",
                    "name": "party",
                    "animated": true,
                    "available": true
                },
                {
                    "id": "51",
                    "name": "sleep",
                    "available": false
                }
            ]
        }))
        .expect("guild create should parse");

        let AppEvent::GuildCreate {
            member_count,
            emojis,
            ..
        } = event
        else {
            panic!("expected guild create event");
        };
        assert_eq!(member_count, Some(123));
        assert_eq!(emojis.len(), 2);
        assert_eq!(emojis[0].id, Id::new(50));
        assert_eq!(emojis[0].name, "party");
        assert!(emojis[0].animated);
        assert!(emojis[0].available);
        assert!(!emojis[1].available);
    }

    #[test]
    fn guild_create_parser_keeps_roles() {
        let event = parse_guild_create(&json!({
            "id": "1",
            "name": "guild",
            "channels": [],
            "members": [],
            "presences": [],
            "roles": [{
                "id": "90",
                "name": "Admin",
                "color": 16755200,
                "position": 10,
                "hoist": true
            }],
            "emojis": []
        }))
        .expect("guild create should parse");

        let AppEvent::GuildCreate { roles, .. } = event else {
            panic!("expected guild create event");
        };

        assert_eq!(roles.len(), 1);
        assert_eq!(roles[0].id, Id::new(90));
        assert_eq!(roles[0].name, "Admin");
        assert_eq!(roles[0].color, Some(16755200));
        assert_eq!(roles[0].position, 10);
        assert!(roles[0].hoist);
    }

    #[test]
    fn guild_create_parser_keeps_string_permission_bitfields() {
        let event = parse_guild_create(&json!({
            "id": "1",
            "name": "guild",
            "channels": [],
            "members": [],
            "presences": [],
            "roles": [{
                "id": "1",
                "name": "@everyone",
                "permissions": "1024",
                "position": 0,
                "hoist": false
            }],
            "emojis": []
        }))
        .expect("guild create should parse");

        let AppEvent::GuildCreate { roles, .. } = event else {
            panic!("expected guild create event");
        };

        assert_eq!(roles[0].permissions, 0x400);
    }

    #[test]
    fn guild_create_parser_accepts_member_user_id_without_nested_user() {
        let event = parse_guild_create(&json!({
            "id": "1",
            "name": "guild",
            "channels": [],
            "members": [{
                "user_id": "10",
                "roles": [20]
            }],
            "presences": [],
            "roles": [],
            "emojis": []
        }))
        .expect("guild create should parse");

        let AppEvent::GuildCreate { members, .. } = event else {
            panic!("expected guild create event");
        };

        assert_eq!(members.len(), 1);
        assert_eq!(members[0].user_id, Id::new(10));
        assert_eq!(members[0].role_ids, vec![Id::new(20)]);
    }

    #[test]
    fn raw_guild_create_with_thin_current_member_hides_denied_channel() {
        let event = parse_guild_create(&json!({
            "id": "1",
            "name": "guild",
            "owner_id": "11",
            "channels": [{
                "id": "2",
                "type": 0,
                "name": "secret",
                "permission_overwrites": [{
                    "id": "1",
                    "type": 0,
                    "allow": "0",
                    "deny": "1024"
                }]
            }],
            "members": [{
                "user_id": "10",
                "roles": []
            }],
            "presences": [],
            "roles": [{
                "id": "1",
                "name": "@everyone",
                "permissions": "1024",
                "position": 0,
                "hoist": false
            }],
            "emojis": []
        }))
        .expect("guild create should parse");
        let mut state = DiscordState::default();
        state.apply_event(&AppEvent::Ready {
            user: "me".to_owned(),
            user_id: Some(Id::new(10)),
        });
        state.apply_event(&event);

        assert_eq!(
            state.channel_visibility_stats(Some(Id::new(1))),
            ChannelVisibilityStats {
                visible: 0,
                hidden: 1,
            }
        );
        assert!(
            state
                .viewable_channels_for_guild(Some(Id::new(1)))
                .is_empty()
        );
    }

    #[test]
    fn raw_guild_create_with_thin_current_member_keeps_role_based_access() {
        let event = parse_guild_create(&json!({
            "id": "1",
            "name": "guild",
            "owner_id": "11",
            "channels": [{
                "id": "2",
                "type": 0,
                "name": "staff",
                "permission_overwrites": [{
                    "id": "1",
                    "type": 0,
                    "allow": "0",
                    "deny": "1024"
                }, {
                    "id": "20",
                    "type": 0,
                    "allow": "1024",
                    "deny": "0"
                }]
            }],
            "members": [{
                "user_id": "10",
                "roles": [20]
            }],
            "presences": [],
            "roles": [{
                "id": "1",
                "name": "@everyone",
                "permissions": "1024",
                "position": 0,
                "hoist": false
            }, {
                "id": "20",
                "name": "Staff",
                "permissions": "0",
                "position": 1,
                "hoist": false
            }],
            "emojis": []
        }))
        .expect("guild create should parse");
        let mut state = DiscordState::default();
        state.apply_event(&AppEvent::Ready {
            user: "me".to_owned(),
            user_id: Some(Id::new(10)),
        });
        state.apply_event(&event);

        assert_eq!(
            state.channel_visibility_stats(Some(Id::new(1))),
            ChannelVisibilityStats {
                visible: 1,
                hidden: 0,
            }
        );
        assert_eq!(state.viewable_channels_for_guild(Some(Id::new(1))).len(), 1);
    }

    #[test]
    fn guild_create_parser_keeps_active_threads() {
        let event = parse_guild_create(&json!({
            "id": "1",
            "name": "guild",
            "channels": [],
            "threads": [thread_payload(10, "release notes")],
            "members": [],
            "presences": [],
            "emojis": []
        }))
        .expect("guild create should parse");

        let AppEvent::GuildCreate { channels, .. } = event else {
            panic!("expected guild create event");
        };

        assert_eq!(channels.len(), 1);
        assert_eq!(channels[0].channel_id, Id::new(10));
        assert_eq!(channels[0].kind, "GuildPublicThread");
        assert_eq!(channels[0].name, "release notes");
    }

    #[test]
    fn raw_member_chunk_upserts_members_and_presences() {
        let events = parse_user_account_event(
            &json!({
                "t": "GUILD_MEMBERS_CHUNK",
                "d": {
                    "guild_id": "1",
                    "chunk_index": 0,
                    "chunk_count": 1,
                    "members": [
                        {
                            "nick": "Alice Nick",
                            "user": {
                                "id": "10",
                                "username": "alice",
                                "global_name": "Alice Global",
                                "avatar": "avatarhash"
                            }
                        },
                        {
                            "user": {
                                "id": "20",
                                "username": "bob",
                                "bot": true
                            }
                        }
                    ],
                    "presences": [
                        { "user": { "id": "10" }, "status": "online" },
                        { "user": { "id": "20" }, "status": "idle" }
                    ]
                }
            })
            .to_string(),
        );

        assert_eq!(events.len(), 4);
        assert!(matches!(
            &events[0],
            AppEvent::GuildMemberUpsert { guild_id, member }
                if *guild_id == Id::new(1)
                    && member.user_id == Id::new(10)
                    && member.display_name == "Alice Nick"
                    && !member.is_bot
        ));
        assert!(matches!(
            &events[1],
            AppEvent::GuildMemberUpsert { guild_id, member }
                if *guild_id == Id::new(1)
                    && member.user_id == Id::new(20)
                    && member.display_name == "bob"
                    && member.is_bot
        ));
        assert!(matches!(
            &events[2],
            AppEvent::PresenceUpdate { guild_id, user_id, status, .. }
                if *guild_id == Id::new(1)
                    && *user_id == Id::new(10)
                    && *status == PresenceStatus::Online
        ));
        assert!(matches!(
            &events[3],
            AppEvent::PresenceUpdate { guild_id, user_id, status, .. }
                if *guild_id == Id::new(1)
                    && *user_id == Id::new(20)
                    && *status == PresenceStatus::Idle
        ));
    }

    #[test]
    fn raw_member_add_keeps_real_join_semantics() {
        let events = parse_user_account_event(
            &json!({
                "t": "GUILD_MEMBER_ADD",
                "d": {
                    "guild_id": "1",
                    "nick": "Alice Nick",
                    "user": {
                        "id": "10",
                        "username": "alice"
                    }
                }
            })
            .to_string(),
        );

        assert_eq!(events.len(), 1);
        assert!(matches!(
            &events[0],
            AppEvent::GuildMemberAdd { guild_id, member }
                if *guild_id == Id::new(1)
                    && member.user_id == Id::new(10)
                    && member.display_name == "Alice Nick"
        ));
    }

    #[test]
    fn raw_ready_parser_keeps_guild_custom_emojis() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": {
                        "id": "99",
                        "username": "neo"
                    },
                    "guilds": [{
                        "id": "1",
                        "name": "guild",
                        "channels": [],
                        "members": [],
                        "presences": [],
                        "emojis": [{
                            "id": "50",
                            "name": "party_time",
                            "animated": true,
                            "available": true
                        }]
                    }],
                    "private_channels": []
                }
            })
            .to_string(),
        );

        let guild_create = events
            .iter()
            .find_map(|event| match event {
                AppEvent::GuildCreate { emojis, .. } => Some(emojis),
                _ => None,
            })
            .expect("ready should emit a guild create event");

        assert_eq!(guild_create.len(), 1);
        assert_eq!(guild_create[0].id, Id::new(50));
        assert_eq!(guild_create[0].name, "party_time");
        assert!(guild_create[0].animated);
        assert!(guild_create[0].available);
    }

    #[test]
    fn guild_emojis_update_parser_replaces_custom_emojis() {
        let event = parse_guild_emojis_update(&json!({
            "guild_id": "1",
            "emojis": [
                {
                    "id": "60",
                    "name": "wave",
                    "animated": false,
                    "available": true
                }
            ]
        }))
        .expect("guild emojis update should parse");

        let AppEvent::GuildEmojisUpdate { guild_id, emojis } = event else {
            panic!("expected guild emojis update event");
        };
        assert_eq!(guild_id, Id::new(1));
        assert_eq!(emojis.len(), 1);
        assert_eq!(emojis[0].id, Id::new(60));
        assert_eq!(emojis[0].name, "wave");
        assert!(emojis[0].available);
    }

    #[test]
    fn guild_update_parser_keeps_custom_emojis_when_present() {
        let event = parse_guild_update(&json!({
            "id": "1",
            "name": "guild renamed",
            "emojis": [{
                "id": "70",
                "name": "dance",
                "animated": true,
                "available": true
            }]
        }))
        .expect("guild update should parse");

        let AppEvent::GuildUpdate {
            guild_id,
            name,
            roles,
            emojis,
            ..
        } = event
        else {
            panic!("expected guild update event");
        };
        assert_eq!(guild_id, Id::new(1));
        assert_eq!(name, "guild renamed");
        assert_eq!(roles, None);
        let emojis = emojis.expect("emoji field should be preserved when present");
        assert_eq!(emojis.len(), 1);
        assert_eq!(emojis[0].id, Id::new(70));
        assert_eq!(emojis[0].name, "dance");
        assert!(emojis[0].animated);
    }

    #[test]
    fn guild_update_parser_distinguishes_missing_custom_emojis() {
        let event = parse_guild_update(&json!({
            "id": "1",
            "name": "guild renamed"
        }))
        .expect("guild update should parse");

        let AppEvent::GuildUpdate { roles, emojis, .. } = event else {
            panic!("expected guild update event");
        };
        assert_eq!(roles, None);
        assert_eq!(emojis, None);
    }

    #[test]
    fn message_update_parser_keeps_mentions_when_present() {
        let event = parse_message_update(&json!({
            "id": "20",
            "channel_id": "10",
            "content": "edited <@40>",
            "mentions": [{ "id": "40", "username": "alice" }]
        }))
        .expect("message update should parse");

        let AppEvent::MessageUpdate { mentions, .. } = event else {
            panic!("expected message update event");
        };
        assert_eq!(mentions, Some(vec![mention_info(40, "alice")]));
    }

    #[test]
    fn message_update_parser_keeps_poll_results() {
        let event = parse_message_update(&json!({
            "id": "20",
            "channel_id": "10",
            "poll": {
                "question": { "text": "오늘 뭐 먹지?" },
                "answers": [
                    { "answer_id": 1, "poll_media": { "text": "김치찌개" } },
                    { "answer_id": 2, "poll_media": { "text": "라멘" } }
                ],
                "results": {
                    "is_finalized": true,
                    "answer_counts": [
                        { "id": 1, "count": 5, "me_voted": true },
                        { "id": 2, "count": 3, "me_voted": false }
                    ]
                }
            }
        }))
        .expect("message update should parse");

        let AppEvent::MessageUpdate { poll, .. } = event else {
            panic!("expected message update event");
        };
        let poll = poll.expect("poll payload should be kept");
        assert_eq!(poll.results_finalized, Some(true));
        assert_eq!(poll.answers[0].vote_count, Some(5));
        assert!(poll.answers[0].me_voted);
    }

    #[test]
    fn message_reaction_add_dispatch_parses_reaction_event() {
        let events = parse_user_account_event(
            &json!({
                "t": "MESSAGE_REACTION_ADD",
                "d": {
                    "guild_id": "1",
                    "channel_id": "10",
                    "message_id": "20",
                    "user_id": "30",
                    "emoji": { "name": "👍" }
                }
            })
            .to_string(),
        );

        assert_eq!(events.len(), 1);
        let AppEvent::MessageReactionAdd {
            guild_id,
            channel_id,
            message_id,
            user_id,
            emoji,
        } = &events[0]
        else {
            panic!("expected message reaction add event");
        };
        assert_eq!(*guild_id, Some(Id::new(1)));
        assert_eq!(*channel_id, Id::new(10));
        assert_eq!(*message_id, Id::new(20));
        assert_eq!(*user_id, Id::new(30));
        assert_eq!(emoji, &ReactionEmoji::Unicode("👍".to_owned()));
    }

    #[test]
    fn message_reaction_remove_dispatch_parses_custom_reaction_event() {
        let events = parse_user_account_event(
            &json!({
                "t": "MESSAGE_REACTION_REMOVE",
                "d": {
                    "channel_id": "10",
                    "message_id": "20",
                    "user_id": "30",
                    "emoji": {
                        "id": "40",
                        "name": "party",
                        "animated": true
                    }
                }
            })
            .to_string(),
        );

        assert_eq!(events.len(), 1);
        let AppEvent::MessageReactionRemove {
            guild_id,
            channel_id,
            message_id,
            user_id,
            emoji,
        } = &events[0]
        else {
            panic!("expected message reaction remove event");
        };
        assert_eq!(*guild_id, None);
        assert_eq!(*channel_id, Id::new(10));
        assert_eq!(*message_id, Id::new(20));
        assert_eq!(*user_id, Id::new(30));
        assert_eq!(
            emoji,
            &ReactionEmoji::Custom {
                id: Id::new(40),
                name: Some("party".to_owned()),
                animated: true,
            }
        );
    }

    #[test]
    fn message_reaction_remove_all_dispatch_parses_clear_event() {
        let events = parse_user_account_event(
            &json!({
                "t": "MESSAGE_REACTION_REMOVE_ALL",
                "d": {
                    "guild_id": "1",
                    "channel_id": "10",
                    "message_id": "20"
                }
            })
            .to_string(),
        );

        assert_eq!(events.len(), 1);
        let AppEvent::MessageReactionRemoveAll {
            guild_id,
            channel_id,
            message_id,
        } = &events[0]
        else {
            panic!("expected message reaction remove all event");
        };
        assert_eq!(*guild_id, Some(Id::new(1)));
        assert_eq!(*channel_id, Id::new(10));
        assert_eq!(*message_id, Id::new(20));
    }

    #[test]
    fn message_reaction_remove_emoji_dispatch_parses_clear_emoji_event() {
        let events = parse_user_account_event(
            &json!({
                "t": "MESSAGE_REACTION_REMOVE_EMOJI",
                "d": {
                    "channel_id": "10",
                    "message_id": "20",
                    "emoji": { "name": "👍" }
                }
            })
            .to_string(),
        );

        assert_eq!(events.len(), 1);
        let AppEvent::MessageReactionRemoveEmoji {
            guild_id,
            channel_id,
            message_id,
            emoji,
        } = &events[0]
        else {
            panic!("expected message reaction remove emoji event");
        };
        assert_eq!(*guild_id, None);
        assert_eq!(*channel_id, Id::new(10));
        assert_eq!(*message_id, Id::new(20));
        assert_eq!(emoji, &ReactionEmoji::Unicode("👍".to_owned()));
    }

    #[test]
    fn message_create_parser_keeps_image_attachments() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "content": "",
            "attachments": [{
                "id": "40",
                "filename": "cat.png",
                "url": "https://cdn.discordapp.com/cat.png",
                "proxy_url": "https://media.discordapp.net/cat.png",
                "content_type": "image/png",
                "size": 2048,
                "width": 640,
                "height": 480,
                "description": "cat"
            }]
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { attachments, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(attachments.len(), 1);
        assert_eq!(attachments[0].filename, "cat.png");
        assert_eq!(attachments[0].content_type.as_deref(), Some("image/png"));
        assert_eq!(attachments[0].width, Some(640));
        assert_eq!(attachments[0].height, Some(480));
    }

    #[test]
    fn message_create_parser_keeps_regular_embeds() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "content": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
            "embeds": [{
                "type": "video",
                "color": 16711680,
                "provider": { "name": "YouTube" },
                "title": "Example Video",
                "description": "A video description",
                "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
                "thumbnail": {
                    "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
                    "proxy_url": "https://images-ext-1.discordapp.net/external/thumb/hash/https/i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
                    "width": 480,
                    "height": 360
                },
                "image": {
                    "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
                    "proxy_url": "https://images-ext-2.discordapp.net/external/image/hash/https/i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
                    "width": 1280,
                    "height": 720
                },
                "video": { "url": "https://www.youtube.com/embed/dQw4w9WgXcQ" }
            }]
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { embeds, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(embeds.len(), 1);
        assert_eq!(embeds[0].color, Some(16711680));
        assert_eq!(embeds[0].provider_name.as_deref(), Some("YouTube"));
        assert_eq!(embeds[0].title.as_deref(), Some("Example Video"));
        assert_eq!(
            embeds[0].thumbnail_url.as_deref(),
            Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg")
        );
        assert_eq!(
            embeds[0].thumbnail_proxy_url.as_deref(),
            Some(
                "https://images-ext-1.discordapp.net/external/thumb/hash/https/i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
            )
        );
        assert_eq!(embeds[0].thumbnail_width, Some(480));
        assert_eq!(embeds[0].thumbnail_height, Some(360));
        assert_eq!(
            embeds[0].image_url.as_deref(),
            Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg")
        );
        assert_eq!(
            embeds[0].image_proxy_url.as_deref(),
            Some(
                "https://images-ext-2.discordapp.net/external/image/hash/https/i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
            )
        );
        assert_eq!(embeds[0].image_width, Some(1280));
        assert_eq!(embeds[0].image_height, Some(720));
        assert_eq!(
            embeds[0].video_url.as_deref(),
            Some("https://www.youtube.com/embed/dQw4w9WgXcQ")
        );
    }

    #[test]
    fn message_create_parser_keeps_message_type() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "type": 19,
            "content": "reply",
            "attachments": []
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { message_kind, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(message_kind, MessageKind::new(19));
    }

    #[test]
    fn message_create_parser_prefers_member_nick_for_author() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "guild_id": "1",
            "author": { "id": "30", "global_name": "global", "username": "neo" },
            "member": { "nick": "server alias" },
            "content": "hello",
            "attachments": []
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { author, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(author, "server alias");
    }

    #[test]
    fn message_info_parser_keeps_author_role_ids_from_member_payload() {
        let message = parse_message_info(&json!({
            "id": "20",
            "channel_id": "10",
            "guild_id": "1",
            "author": { "id": "30", "username": "neo" },
            "member": { "roles": ["90", "91"] },
            "content": "hello",
            "attachments": []
        }))
        .expect("message should parse");

        assert_eq!(message.author_role_ids, vec![Id::new(90), Id::new(91)]);
    }

    #[test]
    fn message_create_parser_builds_author_avatar_url() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": {
                "id": "30",
                "username": "neo",
                "avatar": "a_avatarhash"
            },
            "content": "hello",
            "attachments": []
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate {
            author_avatar_url, ..
        } = event
        else {
            panic!("expected message create event");
        };
        assert_eq!(
            author_avatar_url.as_deref(),
            Some("https://cdn.discordapp.com/avatars/30/a_avatarhash.gif")
        );
    }

    #[test]
    fn message_create_parser_falls_back_to_global_name_without_member() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "global_name": "global alias", "username": "neo" },
            "content": "hello",
            "attachments": []
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate {
            author, guild_id, ..
        } = event
        else {
            panic!("expected message create event");
        };
        assert_eq!(guild_id, None);
        assert_eq!(author, "global alias");
    }

    #[test]
    fn message_create_parser_keeps_mention_display_names() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "content": "hello <@40> <@41> <@42>",
            "mentions": [
                {
                    "id": "40",
                    "username": "alpha",
                    "global_name": "Alpha Global",
                    "member": { "nick": "Alpha Nick" }
                },
                {
                    "id": "41",
                    "username": "beta",
                    "global_name": "Beta Global"
                },
                {
                    "id": "42",
                    "username": "gamma"
                }
            ],
            "attachments": []
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { mentions, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(
            mentions,
            vec![
                mention_info_with_nick(40, "Alpha Nick"),
                mention_info(41, "Beta Global"),
                mention_info(42, "gamma"),
            ]
        );
    }

    #[test]
    fn message_create_parser_keeps_reply_preview() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "type": 19,
            "content": "reply",
            "attachments": [],
            "referenced_message": {
                "id": "19",
                "channel_id": "10",
                "author": { "id": "31", "global_name": "Alex", "username": "alex" },
                "content": "잘되는군",
                "attachments": []
            }
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { reply, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(
            reply,
            Some(ReplyInfo {
                author: "Alex".to_owned(),
                content: Some("잘되는군".to_owned()),
                sticker_names: Vec::new(),
                mentions: Vec::new(),
            })
        );
    }

    #[test]
    fn message_create_parser_keeps_reply_mentions() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "type": 19,
            "content": "reply",
            "attachments": [],
            "referenced_message": {
                "id": "19",
                "channel_id": "10",
                "author": { "id": "31", "username": "alex" },
                "content": "hello <@40>",
                "mentions": [{ "id": "40", "username": "alice" }],
                "attachments": []
            }
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { reply, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(
            reply.and_then(|reply| reply.mentions.into_iter().next()),
            Some(mention_info(40, "alice"))
        );
    }

    #[test]
    fn message_create_parser_keeps_poll_payload() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "type": 0,
            "content": "",
            "attachments": [],
            "poll": {
                "question": { "text": "오늘 뭐 먹지?" },
                "answers": [
                    { "answer_id": 1, "poll_media": { "text": "김치찌개" } },
                    { "answer_id": 2, "poll_media": { "text": "라멘" } }
                ],
                "results": {
                    "is_finalized": false,
                    "answer_counts": [
                        { "id": 1, "count": 2, "me_voted": true },
                        { "id": 2, "count": 1, "me_voted": false }
                    ]
                },
                "allow_multiselect": true
            }
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { poll, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(
            poll,
            Some(PollInfo {
                question: "오늘 뭐 먹지?".to_owned(),
                answers: vec![
                    PollAnswerInfo {
                        answer_id: 1,
                        text: "김치찌개".to_owned(),
                        vote_count: Some(2),
                        me_voted: true,
                    },
                    PollAnswerInfo {
                        answer_id: 2,
                        text: "라멘".to_owned(),
                        vote_count: Some(1),
                        me_voted: false,
                    },
                ],
                allow_multiselect: true,
                results_finalized: Some(false),
                total_votes: Some(3),
            })
        );
    }

    #[test]
    fn message_create_parser_keeps_poll_result_embed() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "type": 46,
            "content": "",
            "attachments": [],
            "embeds": [{
                "type": "poll_result",
                "fields": [
                    { "name": "poll_question_text", "value": "오늘 뭐 먹지?" },
                    { "name": "victor_answer_id", "value": "1" },
                    { "name": "victor_answer_text", "value": "김치찌개" },
                    { "name": "victor_answer_votes", "value": "5" },
                    { "name": "total_votes", "value": "7" }
                ]
            }]
        }))
        .expect("poll result message should parse");

        let AppEvent::MessageCreate { poll, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(
            poll.expect("poll result should map to poll info")
                .total_votes,
            Some(7)
        );
    }

    #[test]
    fn message_create_parser_uses_proxy_url_when_url_is_missing() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "content": "",
            "attachments": [{
                "id": "40",
                "filename": "cat.png",
                "proxy_url": "https://media.discordapp.net/cat.png",
                "content_type": "image/png"
            }]
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { attachments, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(attachments.len(), 1);
        assert_eq!(attachments[0].url, "https://media.discordapp.net/cat.png");
        assert_eq!(
            attachments[0].proxy_url,
            "https://media.discordapp.net/cat.png"
        );
    }

    #[test]
    fn message_create_parser_keeps_video_attachment_metadata() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "content": "",
            "attachments": [{
                "id": "40",
                "filename": "clip.mp4",
                "url": "https://cdn.discordapp.com/clip.mp4",
                "proxy_url": "https://media.discordapp.net/clip.mp4",
                "content_type": "video/mp4",
                "size": 78364758,
                "width": 1920,
                "height": 1080,
                "description": "clip"
            }]
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate { attachments, .. } = event else {
            panic!("expected message create event");
        };
        assert_eq!(attachments.len(), 1);
        assert_eq!(attachments[0].filename, "clip.mp4");
        assert_eq!(attachments[0].content_type.as_deref(), Some("video/mp4"));
        assert_eq!(attachments[0].size, 78_364_758);
        assert_eq!(attachments[0].width, Some(1920));
        assert_eq!(attachments[0].height, Some(1080));
    }

    #[test]
    fn message_create_parser_preserves_content_and_sticker_names() {
        let cases = [
            (
                "",
                vec![json!({ "id": "11", "name": "Wave", "format_type": 1 })],
                vec!["Wave"],
            ),
            (
                "hello",
                vec![
                    json!({ "id": "11", "name": "Wave", "format_type": 1 }),
                    json!({ "id": "12", "name": "Heart", "format_type": 1 }),
                ],
                vec!["Wave", "Heart"],
            ),
        ];

        for (raw_content, sticker_items, expected_stickers) in cases {
            let event = parse_message_create(&json!({
                "id": "20",
                "channel_id": "10",
                "author": { "id": "30", "username": "neo" },
                "content": raw_content,
                "sticker_items": sticker_items
            }))
            .expect("message create should parse");
            let AppEvent::MessageCreate {
                content,
                sticker_names,
                ..
            } = event
            else {
                panic!("expected message create event");
            };
            assert_eq!(content.as_deref(), Some(raw_content));
            assert_eq!(
                sticker_names,
                expected_stickers
                    .into_iter()
                    .map(str::to_owned)
                    .collect::<Vec<_>>()
            );
        }
    }

    #[test]
    fn message_create_parser_keeps_forwarded_snapshot_fields() {
        let event = parse_message_create(&json!({
            "id": "20",
            "channel_id": "10",
            "author": { "id": "30", "username": "neo" },
            "content": "",
            "attachments": [],
            "message_reference": { "channel_id": "11" },
            "message_snapshots": [{
                "message": {
                    "content": "hello <@40>",
                    "timestamp": "2026-04-30T12:34:56.000000+00:00",
                    "mentions": [{ "id": "40", "username": "alice" }],
                    "attachments": [{
                        "id": "41",
                        "filename": "cat.png",
                        "url": "https://cdn.discordapp.com/cat.png",
                        "proxy_url": "https://media.discordapp.net/cat.png",
                        "content_type": "image/png",
                        "size": 2048,
                        "width": 640,
                        "height": 480
                    }],
                    "sticker_items": [
                        { "id": "42", "name": "Wave", "format_type": 1 }
                    ]
                }
            }, {
                "message": {
                    "content": ""
                }
            }]
        }))
        .expect("message create should parse");

        let AppEvent::MessageCreate {
            forwarded_snapshots,
            ..
        } = event
        else {
            panic!("expected message create event");
        };
        assert_eq!(forwarded_snapshots.len(), 2);
        assert_eq!(
            forwarded_snapshots[0].content.as_deref(),
            Some("hello <@40>")
        );
        assert_eq!(forwarded_snapshots[0].source_channel_id, Some(Id::new(11)));
        assert_eq!(
            forwarded_snapshots[0].timestamp.as_deref(),
            Some("2026-04-30T12:34:56.000000+00:00")
        );
        assert_eq!(
            forwarded_snapshots[0].mentions,
            vec![mention_info(40, "alice")]
        );
        assert_eq!(
            forwarded_snapshots[0].sticker_names,
            vec!["Wave".to_owned()]
        );
        assert_eq!(forwarded_snapshots[0].attachments.len(), 1);
        assert_eq!(forwarded_snapshots[0].attachments[0].filename, "cat.png");
        assert_eq!(forwarded_snapshots[1].content.as_deref(), Some(""));
    }

    fn mention_info(user_id: u64, display_name: &str) -> MentionInfo {
        MentionInfo {
            user_id: Id::new(user_id),
            guild_nick: None,
            display_name: display_name.to_owned(),
        }
    }

    fn mention_info_with_nick(user_id: u64, nick: &str) -> MentionInfo {
        MentionInfo {
            user_id: Id::new(user_id),
            guild_nick: Some(nick.to_owned()),
            display_name: nick.to_owned(),
        }
    }

    fn thread_payload(id: u64, name: &str) -> serde_json::Value {
        json!({
            "id": id.to_string(),
            "guild_id": "1",
            "parent_id": "2",
            "type": 11,
            "name": name,
            "message_count": 12,
            "total_message_sent": 14,
            "thread_metadata": { "archived": false, "locked": false }
        })
    }

    #[test]
    fn parse_guild_create_reads_name_from_lazy_properties_object() {
        // With user-account capabilities containing LAZY_USER_NOTIFICATIONS,
        // Discord nests guild metadata under `properties` instead of placing
        // `name` / `owner_id` at the root. Concord must look in both places
        // or every guild renders as "unknown".
        let event = parse_guild_create(&json!({
            "id": "100",
            "member_count": 7,
            "channels": [],
            "roles": [],
            "emojis": [],
            "properties": {
                "name": "Lazy Server",
                "owner_id": "42",
            },
        }))
        .expect("guild_create payload should map");

        let AppEvent::GuildCreate {
            guild_id,
            name,
            owner_id,
            member_count,
            ..
        } = event
        else {
            panic!("expected GuildCreate event");
        };
        assert_eq!(guild_id, Id::new(100));
        assert_eq!(name, "Lazy Server");
        assert_eq!(owner_id, Some(Id::new(42)));
        assert_eq!(member_count, Some(7));
    }

    #[test]
    fn parse_guild_create_prefers_root_name_when_both_locations_set() {
        // Guard against future Discord shape drift: if both root-level and
        // nested name are present, the root wins (matches what the official
        // client does).
        let event = parse_guild_create(&json!({
            "id": "100",
            "name": "Root Name",
            "properties": {"name": "Properties Name"},
        }))
        .expect("guild_create payload should map");

        let AppEvent::GuildCreate { name, .. } = event else {
            panic!("expected GuildCreate event");
        };
        assert_eq!(name, "Root Name");
    }

    #[test]
    fn typing_start_extracts_channel_and_user_from_dm_payload() {
        // DM TYPING_START omits guild_id and embeds user_id directly.
        let events = parse_user_account_event(
            &json!({
                "t": "TYPING_START",
                "d": {
                    "channel_id": "12345",
                    "user_id": "99",
                    "timestamp": 1_700_000_000
                }
            })
            .to_string(),
        );
        assert!(matches!(
            events.as_slice(),
            [AppEvent::TypingStart { channel_id, user_id }]
                if *channel_id == Id::new(12345) && *user_id == Id::new(99)
        ));
    }

    #[test]
    fn typing_start_falls_back_to_member_user_id_when_top_level_missing() {
        // Some guild TYPING_START payloads only embed the user id under
        // `member.user.id`. Make sure we still surface the typer.
        let events = parse_user_account_event(
            &json!({
                "t": "TYPING_START",
                "d": {
                    "channel_id": "55",
                    "guild_id": "77",
                    "member": {
                        "user": { "id": "42" }
                    },
                    "timestamp": 1_700_000_000
                }
            })
            .to_string(),
        );
        assert!(matches!(
            events.as_slice(),
            [AppEvent::TypingStart { channel_id, user_id }]
                if *channel_id == Id::new(55) && *user_id == Id::new(42)
        ));
    }

    #[test]
    fn ready_hydrates_dm_recipients_from_dedupe_user_ids() {
        // With DEDUPE_USER_OBJECTS in capabilities, READY puts users at the
        // top level once and each private channel only carries
        // `recipient_ids`. The dashboard must still show the peer's name
        // and not `dm-{channel_id}`.
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": { "id": "10", "username": "me" },
                    "users": [
                        {
                            "id": "20",
                            "username": "asdf",
                            "global_name": "global",
                            "discriminator": "0",
                        }
                    ],
                    "private_channels": [
                        {
                            "id": "12345",
                            "type": 1,
                            "recipient_ids": ["20"]
                        }
                    ]
                }
            })
            .to_string(),
        );

        let dm = events
            .iter()
            .find_map(|event| match event {
                AppEvent::ChannelUpsert(info) if info.kind == "dm" => Some(info),
                _ => None,
            })
            .expect("dm channel upsert should be emitted");
        assert_eq!(dm.name, "global");
        let recipients = dm.recipients.as_ref().expect("recipients hydrated");
        assert_eq!(recipients.len(), 1);
        assert_eq!(recipients[0].user_id, Id::new(20));
        assert_eq!(recipients[0].display_name, "global");
        assert_eq!(recipients[0].username.as_deref(), Some("asdf"));
    }

    #[test]
    fn message_ack_carries_channel_message_and_mention_count() {
        let events = parse_user_account_event(
            &json!({
                "t": "MESSAGE_ACK",
                "d": {
                    "channel_id": "42",
                    "message_id": "99",
                    "mention_count": 2,
                }
            })
            .to_string(),
        );

        match events.as_slice() {
            [
                AppEvent::MessageAck {
                    channel_id,
                    message_id,
                    mention_count,
                },
            ] => {
                assert_eq!(*channel_id, Id::new(42));
                assert_eq!(*message_id, Id::new(99));
                assert_eq!(*mention_count, 2);
            }
            other => panic!("expected one MessageAck, got {other:?}"),
        }
    }

    #[test]
    fn ready_payload_emits_read_state_init_with_ack_pointers() {
        // Minimal READY: a `user`, an empty guild list (so the test stays
        // light), and a `read_state.entries[]` array with two channels.
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": { "id": "1", "username": "neo" },
                    "guilds": [],
                    "read_state": {
                        "entries": [
                            { "id": "11", "last_message_id": "20", "mention_count": 0 },
                            { "id": "12", "last_message_id": "30", "mention_count": 4 },
                        ]
                    }
                }
            })
            .to_string(),
        );

        let entries = events
            .iter()
            .find_map(|event| match event {
                AppEvent::ReadStateInit { entries } => Some(entries.clone()),
                _ => None,
            })
            .expect("READY should emit a ReadStateInit");
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].channel_id, Id::new(11));
        assert_eq!(entries[0].last_acked_message_id, Some(Id::new(20)));
        assert_eq!(entries[0].mention_count, 0);
        assert_eq!(entries[1].channel_id, Id::new(12));
        assert_eq!(entries[1].mention_count, 4);
    }

    #[test]
    fn ready_payload_treats_zero_read_state_ack_pointer_as_absent() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": { "id": "1", "username": "neo" },
                    "guilds": [],
                    "read_state": {
                        "entries": [
                            { "id": "11", "last_message_id": "0", "mention_count": 0 },
                            { "id": "12", "last_message_id": 0, "mention_count": 1 },
                        ]
                    }
                }
            })
            .to_string(),
        );

        let entries = events
            .iter()
            .find_map(|event| match event {
                AppEvent::ReadStateInit { entries } => Some(entries.clone()),
                _ => None,
            })
            .expect("READY should emit a ReadStateInit");
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].channel_id, Id::new(11));
        assert_eq!(entries[0].last_acked_message_id, None);
        assert_eq!(entries[0].mention_count, 0);
        assert_eq!(entries[1].channel_id, Id::new(12));
        assert_eq!(entries[1].last_acked_message_id, None);
        assert_eq!(entries[1].mention_count, 1);
    }

    #[test]
    fn ready_payload_emits_user_guild_notification_settings() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": { "id": "1", "username": "neo" },
                    "guilds": [],
                    "user_guild_settings": {
                        "entries": [{
                            "guild_id": "10",
                            "message_notifications": 1,
                            "muted": false,
                            "suppress_everyone": true,
                            "suppress_roles": true,
                            "channel_overrides": [{
                                "channel_id": "20",
                                "message_notifications": 0,
                                "muted": true,
                                "mute_config": { "end_time": "2099-01-01T00:00:00.000Z" }
                            }]
                        }]
                    }
                }
            })
            .to_string(),
        );

        let settings = events
            .iter()
            .find_map(|event| match event {
                AppEvent::UserGuildNotificationSettingsInit { settings } => Some(settings),
                _ => None,
            })
            .expect("READY should emit user guild notification settings");
        assert_eq!(settings.len(), 1);
        assert_eq!(settings[0].guild_id, Some(Id::new(10)));
        assert_eq!(
            settings[0].message_notifications,
            Some(NotificationLevel::OnlyMentions)
        );
        assert!(settings[0].suppress_everyone);
        assert!(settings[0].suppress_roles);
        assert_eq!(settings[0].channel_overrides.len(), 1);
        assert_eq!(settings[0].channel_overrides[0].channel_id, Id::new(20));
        assert_eq!(
            settings[0].channel_overrides[0].message_notifications,
            Some(NotificationLevel::AllMessages)
        );
        assert!(settings[0].channel_overrides[0].muted);
    }

    #[test]
    fn user_guild_settings_update_emits_single_update_event() {
        let events = parse_user_account_event(
            &json!({
                "t": "USER_GUILD_SETTINGS_UPDATE",
                "d": {
                    "guild_id": "10",
                    "message_notifications": 2,
                    "muted": true,
                    "mute_config": { "end_time": "2099-01-01T00:00:00.000Z" },
                    "channel_overrides": []
                }
            })
            .to_string(),
        );

        match events.as_slice() {
            [AppEvent::UserGuildNotificationSettingsUpdate { settings }] => {
                assert_eq!(settings.guild_id, Some(Id::new(10)));
                assert_eq!(
                    settings.message_notifications,
                    Some(NotificationLevel::NoMessages)
                );
                assert!(settings.muted);
            }
            other => panic!("expected one UserGuildNotificationSettingsUpdate, got {other:?}"),
        }
    }

    #[test]
    fn ready_payload_parses_private_channel_notification_settings() {
        let events = parse_user_account_event(
            &json!({
                "t": "READY",
                "d": {
                    "user": { "id": "1", "username": "neo" },
                    "guilds": [],
                    "user_guild_settings": {
                        "entries": [{
                            "guild_id": null,
                            "message_notifications": 1,
                            "channel_overrides": {
                                "20": {
                                    "message_notifications": 2,
                                    "muted": true,
                                    "mute_config": null
                                }
                            }
                        }]
                    }
                }
            })
            .to_string(),
        );

        let settings = events
            .iter()
            .find_map(|event| match event {
                AppEvent::UserGuildNotificationSettingsInit { settings } => Some(settings),
                _ => None,
            })
            .expect("READY should emit private channel notification settings");
        assert_eq!(settings.len(), 1);
        assert_eq!(settings[0].guild_id, None);
        assert_eq!(
            settings[0].message_notifications,
            Some(NotificationLevel::OnlyMentions)
        );
        assert_eq!(settings[0].channel_overrides.len(), 1);
        assert_eq!(settings[0].channel_overrides[0].channel_id, Id::new(20));
        assert_eq!(
            settings[0].channel_overrides[0].message_notifications,
            Some(NotificationLevel::NoMessages)
        );
        assert!(settings[0].channel_overrides[0].muted);
    }

    #[test]
    fn parse_guild_update_reads_name_from_lazy_properties_object() {
        let event = parse_guild_update(&json!({
            "id": "100",
            "properties": {
                "name": "Renamed Lazy",
                "owner_id": "9",
            },
        }))
        .expect("guild_update payload should map");

        let AppEvent::GuildUpdate {
            guild_id,
            name,
            owner_id,
            ..
        } = event
        else {
            panic!("expected GuildUpdate event");
        };
        assert_eq!(guild_id, Id::new(100));
        assert_eq!(name, "Renamed Lazy");
        assert_eq!(owner_id, Some(Id::new(9)));
    }
}
