//! WebSocket client for channel presence.
//!
//! Provides a Leptos hook to manage WebSocket connections for real-time
//! position updates, emotion changes, and member synchronization.

use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;

#[cfg(feature = "hydrate")]
use chattyness_db::models::EmotionState;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
use chattyness_db::ws_messages::{close_codes, ClientMessage};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage};

use super::chat_types::ChatMessage;

/// Duration for fade-out animation in milliseconds.
pub const FADE_DURATION_MS: i64 = 5000;

/// Maximum number of silent reconnection attempts before showing overlay.
pub const MAX_SILENT_RECONNECT_ATTEMPTS: u8 = 3;

/// Delay between silent reconnection attempts in milliseconds.
pub const SILENT_RECONNECT_DELAY_MS: u32 = 1000;

/// A member that is currently fading out after a timeout disconnect.
#[derive(Clone, Debug)]
pub struct FadingMember {
    /// The member data.
    pub member: ChannelMemberWithAvatar,
    /// Timestamp when the fade started (milliseconds since epoch).
    pub fade_start: i64,
    /// Duration of the fade in milliseconds.
    pub fade_duration: i64,
}

/// WebSocket connection state.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WsState {
    /// Attempting to connect.
    Connecting,
    /// Connected and ready.
    Connected,
    /// Disconnected (not connected).
    Disconnected,
    /// Connection error occurred.
    Error,
    /// Silently attempting to reconnect after server timeout.
    /// The u8 is the current attempt number (1-based).
    SilentReconnecting(u8),
}

/// Sender function type for WebSocket messages.
pub type WsSender = Box<dyn Fn(ClientMessage)>;

/// Local stored value type for the sender (non-Send, WASM-compatible).
pub type WsSenderStorage = StoredValue<Option<WsSender>, LocalStorage>;

/// Information about the current channel member (received on Welcome).
/// Note: Guests are now regular users with the 'guest' tag, so all members have a user_id.
#[derive(Clone, Debug)]
pub struct ChannelMemberInfo {
    /// The user's user_id.
    pub user_id: uuid::Uuid,
    /// The user's display name.
    pub display_name: String,
    /// Whether this user is a guest (has the 'guest' tag).
    pub is_guest: bool,
}

/// WebSocket error info for UI display.
#[derive(Clone, Debug)]
pub struct WsError {
    /// Error code from server.
    pub code: String,
    /// Human-readable error message.
    pub message: String,
}

/// Teleport information received from server.
#[derive(Clone, Debug)]
pub struct TeleportInfo {
    /// Scene ID to teleport to.
    pub scene_id: uuid::Uuid,
    /// Scene slug for URL.
    pub scene_slug: String,
}

/// Summon information received from server (moderator summoned this user).
#[derive(Clone, Debug)]
pub struct SummonInfo {
    /// Scene ID to teleport to.
    pub scene_id: uuid::Uuid,
    /// Scene slug for URL.
    pub scene_slug: String,
    /// Display name of the moderator who summoned.
    pub summoned_by: String,
}

/// Close function type for WebSocket (takes close code and reason).
pub type WsCloser = Box<dyn Fn(u16, String)>;

/// Local stored value type for the closer (non-Send, WASM-compatible).
pub type WsCloserStorage = StoredValue<Option<WsCloser>, LocalStorage>;

/// Result of a moderator command.
#[derive(Clone, Debug)]
pub struct ModCommandResultInfo {
    /// Whether the command succeeded.
    pub success: bool,
    /// Human-readable result message.
    pub message: String,
}

/// Member identity update (e.g., guest registered as user).
#[derive(Clone, Debug)]
pub struct MemberIdentityInfo {
    /// User ID of the member.
    pub user_id: uuid::Uuid,
    /// New display name.
    pub display_name: String,
    /// Whether the member is still a guest.
    pub is_guest: bool,
}

/// Consolidated WebSocket event enum.
///
/// All WebSocket events are routed through this enum for a cleaner API.
#[derive(Clone, Debug)]
pub enum WsEvent {
    /// Members list updated.
    MembersUpdated(Vec<ChannelMemberWithAvatar>),
    /// Chat message received.
    ChatMessage(ChatMessage),
    /// Loose props synchronized (initial list).
    LoosePropsSync(Vec<LooseProp>),
    /// A prop was dropped.
    PropDropped(LooseProp),
    /// A prop was picked up (by prop ID).
    PropPickedUp(uuid::Uuid),
    /// A member started fading out (timeout disconnect).
    MemberFading(FadingMember),
    /// Welcome message received with current user info.
    Welcome(ChannelMemberInfo),
    /// Error from server.
    Error(WsError),
    /// Teleport approved - navigate to new scene.
    TeleportApproved(TeleportInfo),
    /// Summoned by moderator.
    Summoned(SummonInfo),
    /// Moderator command result.
    ModCommandResult(ModCommandResultInfo),
    /// Member identity updated (e.g., guest → user).
    MemberIdentityUpdated(MemberIdentityInfo),
}

/// Consolidated internal state to reduce Rc<RefCell<>> proliferation.
#[cfg(feature = "hydrate")]
struct WsInternalState {
    ws: Option<web_sys::WebSocket>,
    members: Vec<ChannelMemberWithAvatar>,
    current_user_id: Option<uuid::Uuid>,
    is_intentional_close: bool,
    heartbeat_handle: Option<gloo_timers::callback::Interval>,
}

#[cfg(feature = "hydrate")]
impl Default for WsInternalState {
    fn default() -> Self {
        Self {
            ws: None,
            members: Vec::new(),
            current_user_id: None,
            is_intentional_close: false,
            heartbeat_handle: None,
        }
    }
}

/// Hook to manage WebSocket connection for a channel.
///
/// Returns a tuple of:
/// - `Signal<WsState>` - The current connection state
/// - `WsSenderStorage` - A stored sender function to send messages
/// - `WsCloserStorage` - A stored close function to close the WebSocket with a specific code
#[cfg(feature = "hydrate")]
pub fn use_channel_websocket(
    realm_slug: Signal<String>,
    channel_id: Signal<Option<uuid::Uuid>>,
    reconnect_trigger: RwSignal<u32>,
    on_event: Callback<WsEvent>,
) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) {
    use std::cell::RefCell;
    use std::rc::Rc;
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, Ordering};
    use wasm_bindgen::{JsCast, closure::Closure};
    use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};

    let (ws_state, set_ws_state) = signal(WsState::Disconnected);

    let state: Rc<RefCell<WsInternalState>> = Rc::new(RefCell::new(WsInternalState::default()));
    // Flag to prevent accessing disposed reactive values after component unmount
    let is_disposed: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));

    // Create a stored sender function (using new_local for WASM single-threaded environment)
    let state_for_send = state.clone();
    let sender: WsSenderStorage =
        StoredValue::new_local(Some(Box::new(move |msg: ClientMessage| {
            let state = state_for_send.borrow();
            if let Some(ws) = state.ws.as_ref() {
                if ws.ready_state() == WebSocket::OPEN {
                    if let Ok(json) = serde_json::to_string(&msg) {
                        #[cfg(debug_assertions)]
                        web_sys::console::log_1(&format!("[WS->Server] {}", json).into());
                        let _ = ws.send_with_str(&json);
                    }
                }
            }
        })));

    // Clone for closer callback (must be done before Effect captures state)
    let state_for_close = state.clone();

    // Set disposed flag on cleanup to prevent accessing disposed reactive values
    let is_disposed_for_cleanup = is_disposed.clone();
    on_cleanup(move || {
        is_disposed_for_cleanup.store(true, Ordering::Relaxed);
    });

    // Effect to manage WebSocket lifecycle
    let state_for_effect = state.clone();
    let is_disposed_for_effect = is_disposed.clone();

    Effect::new(move |_| {
        let slug = realm_slug.get();
        let ch_id = channel_id.get();
        // Track reconnect_trigger to force reconnection when it changes
        let _trigger = reconnect_trigger.get();

        // Cleanup previous connection
        {
            let mut state = state_for_effect.borrow_mut();
            if let Some(old_ws) = state.ws.take() {
                #[cfg(debug_assertions)]
                web_sys::console::log_1(
                    &format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(),
                );
                // Set flag BEFORE closing - guarantees local state even if close code doesn't arrive
                state.is_intentional_close = true;
                // Cancel existing heartbeat
                state.heartbeat_handle = None;
                // Close with SCENE_CHANGE code so onclose handler knows this was intentional
                let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change");
            }
        }

        let Some(ch_id) = ch_id else {
            set_ws_state.set(WsState::Disconnected);
            return;
        };

        if slug.is_empty() {
            set_ws_state.set(WsState::Disconnected);
            return;
        }

        // Construct WebSocket URL
        let window = web_sys::window().unwrap();
        let location = window.location();
        let protocol = if location.protocol().unwrap_or_default() == "https:" {
            "wss:"
        } else {
            "ws:"
        };
        let host = location.host().unwrap_or_default();
        let url = format!(
            "{}//{}/api/realms/{}/channels/{}/ws",
            protocol, host, slug, ch_id
        );

        #[cfg(debug_assertions)]
        web_sys::console::log_1(&format!("[WS] Connecting to: {}", url).into());

        set_ws_state.set(WsState::Connecting);

        let ws = match WebSocket::new(&url) {
            Ok(ws) => ws,
            Err(e) => {
                #[cfg(debug_assertions)]
                web_sys::console::error_1(&format!("[WS] Failed to create: {:?}", e).into());
                set_ws_state.set(WsState::Error);
                return;
            }
        };

        // onopen
        let set_ws_state_open = set_ws_state;
        let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
            #[cfg(debug_assertions)]
            web_sys::console::log_1(&"[WS] Connected".into());
            set_ws_state_open.set(WsState::Connected);
        }) as Box<dyn FnMut(web_sys::Event)>);
        ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
        onopen.forget();

        // onmessage
        let state_for_msg = state_for_effect.clone();
        let on_event_for_msg = on_event.clone();
        let is_disposed_for_msg = is_disposed_for_effect.clone();
        let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
            // Skip if component has been disposed
            if is_disposed_for_msg.load(Ordering::Relaxed) {
                return;
            }
            if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
                let text: String = text.into();
                #[cfg(debug_assertions)]
                web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());

                if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
                    // Check for Welcome message to start heartbeat with server-provided config
                    if let ServerMessage::Welcome {
                        ref config,
                        ref member,
                        ..
                    } = msg
                    {
                        // Track current user ID for MemberLeft filtering
                        state_for_msg.borrow_mut().current_user_id = Some(member.user_id);

                        // Start heartbeat if not already running
                        let needs_heartbeat = state_for_msg.borrow().heartbeat_handle.is_none();
                        if needs_heartbeat {
                            let ping_interval_ms = config.ping_interval_secs * 1000;
                            let state_for_ping = state_for_msg.clone();
                            #[cfg(debug_assertions)]
                            web_sys::console::log_1(
                                &format!(
                                    "[WS] Starting heartbeat with interval {}ms",
                                    ping_interval_ms
                                )
                                .into(),
                            );
                            let heartbeat = gloo_timers::callback::Interval::new(
                                ping_interval_ms as u32,
                                move || {
                                    let state = state_for_ping.borrow();
                                    if let Some(ws) = state.ws.as_ref() {
                                        if ws.ready_state() == WebSocket::OPEN {
                                            if let Ok(json) =
                                                serde_json::to_string(&ClientMessage::Ping)
                                            {
                                                let _ = ws.send_with_str(&json);
                                            }
                                        }
                                    }
                                },
                            );
                            state_for_msg.borrow_mut().heartbeat_handle = Some(heartbeat);
                        }
                        // Call on_welcome callback with current user info
                        let info = ChannelMemberInfo {
                            user_id: member.user_id,
                            display_name: member.display_name.clone(),
                            is_guest: member.is_guest,
                        };
                        on_event_for_msg.run(WsEvent::Welcome(info));
                    }
                    handle_server_message(msg, &state_for_msg, &on_event_for_msg);
                }
            }
        }) as Box<dyn FnMut(MessageEvent)>);
        ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
        onmessage.forget();

        // onerror
        let set_ws_state_err = set_ws_state;
        let ws_state_for_err = ws_state;
        let reconnect_trigger_for_error = reconnect_trigger;
        let is_disposed_for_err = is_disposed_for_effect.clone();
        let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
            // Skip if component has been disposed
            if is_disposed_for_err.load(Ordering::Relaxed) {
                return;
            }
            #[cfg(debug_assertions)]
            web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());

            // Check if we're in silent reconnection mode
            let current_state = ws_state_for_err.get_untracked();
            if let WsState::SilentReconnecting(attempt) = current_state {
                if attempt < MAX_SILENT_RECONNECT_ATTEMPTS {
                    // Try another silent reconnection
                    let next_attempt = attempt + 1;
                    #[cfg(debug_assertions)]
                    web_sys::console::log_1(
                        &format!(
                            "[WS] Silent reconnection attempt {} failed, trying attempt {}",
                            attempt, next_attempt
                        )
                        .into(),
                    );
                    set_ws_state_err.set(WsState::SilentReconnecting(next_attempt));
                    // Schedule next reconnection attempt
                    let reconnect_trigger = reconnect_trigger_for_error;
                    gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || {
                        reconnect_trigger.update(|v| *v = v.wrapping_add(1));
                    })
                    .forget();
                } else {
                    // Max attempts reached, fall back to showing overlay
                    #[cfg(debug_assertions)]
                    web_sys::console::log_1(
                        &"[WS] Silent reconnection failed, showing reconnection overlay".into(),
                    );
                    set_ws_state_err.set(WsState::Error);
                }
            } else {
                set_ws_state_err.set(WsState::Error);
            }
        }) as Box<dyn FnMut(ErrorEvent)>);
        ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
        onerror.forget();

        // onclose
        let state_for_close = state_for_effect.clone();
        let set_ws_state_close = set_ws_state;
        let reconnect_trigger_for_close = reconnect_trigger;
        let is_disposed_for_close = is_disposed_for_effect.clone();
        let onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
            // Skip if component has been disposed
            if is_disposed_for_close.load(Ordering::Relaxed) {
                return;
            }
            let code = e.code();
            #[cfg(debug_assertions)]
            web_sys::console::log_1(
                &format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(),
            );

            // Cancel heartbeat on close
            state_for_close.borrow_mut().heartbeat_handle = None;

            // Check if this was an intentional close
            let is_intentional = state_for_close.borrow().is_intentional_close;

            // Handle based on close code with defense-in-depth using flag
            if code == close_codes::SERVER_TIMEOUT {
                // Server timeout - attempt silent reconnection (highest priority)
                #[cfg(debug_assertions)]
                web_sys::console::log_1(&"[WS] Server timeout, attempting silent reconnection".into());
                set_ws_state_close.set(WsState::SilentReconnecting(1));
                // Schedule reconnection after delay
                let reconnect_trigger = reconnect_trigger_for_close;
                gloo_timers::callback::Timeout::new(SILENT_RECONNECT_DELAY_MS, move || {
                    reconnect_trigger.update(|v| *v = v.wrapping_add(1));
                })
                .forget();
            } else if code == close_codes::SCENE_CHANGE
                || code == close_codes::LOGOUT
                || is_intentional
            {
                // Intentional close (scene change/teleport/logout) - don't show disconnection
                // Check both code AND flag for defense-in-depth (flag is guaranteed local state)
                #[cfg(debug_assertions)]
                web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into());
                // Reset the flag for future connections
                state_for_close.borrow_mut().is_intentional_close = false;
            } else {
                // Other close codes - treat as disconnection
                set_ws_state_close.set(WsState::Disconnected);
            }
        }) as Box<dyn FnMut(CloseEvent)>);
        ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
        onclose.forget();

        state_for_effect.borrow_mut().ws = Some(ws);
    });

    // Create closer function for explicit WebSocket closure (e.g., logout)
    let closer: WsCloserStorage = StoredValue::new_local(Some(Box::new(move |code: u16, reason: String| {
        let mut state = state_for_close.borrow_mut();
        // Set intentional close flag BEFORE closing
        state.is_intentional_close = true;
        // Cancel heartbeat
        state.heartbeat_handle = None;
        // Get the WebSocket (if any) and close it
        if let Some(ws) = state.ws.as_ref() {
            #[cfg(debug_assertions)]
            web_sys::console::log_1(
                &format!("[WS] Closing with code={}, reason={}", code, reason).into(),
            );
            let _ = ws.close_with_code_and_reason(code, &reason);
        }
    })));

    (Signal::derive(move || ws_state.get()), sender, closer)
}

/// Handle a message received from the server.
#[cfg(feature = "hydrate")]
fn handle_server_message(
    msg: ServerMessage,
    state: &std::rc::Rc<std::cell::RefCell<WsInternalState>>,
    on_event: &Callback<WsEvent>,
) {
    // Process message and collect any events to emit AFTER releasing the borrow
    enum PostAction {
        None,
        UpdateMembers(Vec<ChannelMemberWithAvatar>),
        UpdateMembersAndFade(Vec<ChannelMemberWithAvatar>, FadingMember),
        UpdateMembersAndIdentity(Vec<ChannelMemberWithAvatar>, MemberIdentityInfo),
        ChatMessage(ChatMessage),
        LoosePropsSync(Vec<LooseProp>),
        PropDropped(LooseProp),
        PropPickedUp(uuid::Uuid),
        Error(WsError),
        TeleportApproved(TeleportInfo),
        Summoned(SummonInfo),
        ModCommandResult(ModCommandResultInfo),
    }

    let action = {
        let mut state = state.borrow_mut();
        let own_user_id = state.current_user_id;

        match msg {
            ServerMessage::Welcome {
                member: _,
                members: initial_members,
                config: _, // Config is handled in the caller for heartbeat setup
            } => {
                state.members = initial_members;
                PostAction::UpdateMembers(state.members.clone())
            }
            ServerMessage::MemberJoined { member } => {
                // Remove if exists (rejoin case), then add
                state.members.retain(|m| m.member.user_id != member.member.user_id);
                state.members.push(member);
                PostAction::UpdateMembers(state.members.clone())
            }
            ServerMessage::MemberLeft { user_id, reason } => {
                // Check if this is our own MemberLeft due to timeout - ignore it during reconnection
                // so we don't see our own avatar fade out
                let is_self = own_user_id.is_some_and(|id| user_id == id);
                if is_self && reason == DisconnectReason::Timeout {
                    #[cfg(debug_assertions)]
                    web_sys::console::log_1(
                        &"[WS] Ignoring self MemberLeft during reconnection".into(),
                    );
                    return;
                }

                // Find the member before removing
                let leaving_member = state.members
                    .iter()
                    .find(|m| m.member.user_id == user_id)
                    .cloned();

                // Always remove from active members list
                state.members.retain(|m| m.member.user_id != user_id);
                let updated = state.members.clone();

                // For timeout disconnects, trigger fading animation
                if reason == DisconnectReason::Timeout {
                    if let Some(member) = leaving_member {
                        let fading = FadingMember {
                            member,
                            fade_start: js_sys::Date::now() as i64,
                            fade_duration: FADE_DURATION_MS,
                        };
                        PostAction::UpdateMembersAndFade(updated, fading)
                    } else {
                        PostAction::UpdateMembers(updated)
                    }
                } else {
                    PostAction::UpdateMembers(updated)
                }
            }
            ServerMessage::PositionUpdated { user_id, x, y } => {
                if let Some(m) = state.members
                    .iter_mut()
                    .find(|m| m.member.user_id == user_id)
                {
                    m.member.position_x = x;
                    m.member.position_y = y;
                }
                PostAction::UpdateMembers(state.members.clone())
            }
            ServerMessage::EmotionUpdated {
                user_id,
                emotion,
                emotion_layer,
            } => {
                if let Some(m) = state.members
                    .iter_mut()
                    .find(|m| m.member.user_id == user_id)
                {
                    // Parse emotion name to EmotionState
                    m.member.current_emotion = emotion
                        .parse::<EmotionState>()
                        .unwrap_or_default();
                    m.avatar.emotion_layer = emotion_layer;
                }
                PostAction::UpdateMembers(state.members.clone())
            }
            ServerMessage::Pong => {
                // Heartbeat acknowledged - nothing to do
                PostAction::None
            }
            ServerMessage::Error { code, message } => {
                // Always log errors to console (not just debug mode)
                web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
                PostAction::Error(WsError { code, message })
            }
            ServerMessage::ChatMessageReceived {
                message_id,
                user_id,
                display_name,
                content,
                emotion,
                x,
                y,
                timestamp,
                is_whisper,
                is_same_scene,
            } => {
                PostAction::ChatMessage(ChatMessage {
                    message_id,
                    user_id,
                    display_name,
                    content,
                    emotion,
                    x,
                    y,
                    timestamp,
                    is_whisper,
                    is_same_scene,
                    is_system: false,
                })
            }
            ServerMessage::LoosePropsSync { props } => {
                PostAction::LoosePropsSync(props)
            }
            ServerMessage::PropDropped { prop } => {
                PostAction::PropDropped(prop)
            }
            ServerMessage::PropPickedUp { prop_id, .. } => {
                PostAction::PropPickedUp(prop_id)
            }
            ServerMessage::PropExpired { prop_id } => {
                // Treat expired props the same as picked up (remove from display)
                PostAction::PropPickedUp(prop_id)
            }
            ServerMessage::AvatarUpdated { user_id, avatar } => {
                // Find member and update their avatar layers
                if let Some(m) = state.members
                    .iter_mut()
                    .find(|m| m.member.user_id == user_id)
                {
                    m.avatar.skin_layer = avatar.skin_layer.clone();
                    m.avatar.clothes_layer = avatar.clothes_layer.clone();
                    m.avatar.accessories_layer = avatar.accessories_layer.clone();
                    m.avatar.emotion_layer = avatar.emotion_layer.clone();
                }
                PostAction::UpdateMembers(state.members.clone())
            }
            ServerMessage::TeleportApproved {
                scene_id,
                scene_slug,
            } => {
                PostAction::TeleportApproved(TeleportInfo {
                    scene_id,
                    scene_slug,
                })
            }
            ServerMessage::Summoned {
                scene_id,
                scene_slug,
                summoned_by,
            } => {
                PostAction::Summoned(SummonInfo {
                    scene_id,
                    scene_slug,
                    summoned_by,
                })
            }
            ServerMessage::ModCommandResult { success, message } => {
                PostAction::ModCommandResult(ModCommandResultInfo { success, message })
            }
            ServerMessage::MemberIdentityUpdated {
                user_id,
                display_name,
                is_guest,
            } => {
                // Update the internal members list so subsequent updates don't overwrite
                if let Some(member) = state.members
                    .iter_mut()
                    .find(|m| m.member.user_id == user_id)
                {
                    member.member.display_name = display_name.clone();
                    member.member.is_guest = is_guest;
                }
                PostAction::UpdateMembersAndIdentity(
                    state.members.clone(),
                    MemberIdentityInfo {
                        user_id,
                        display_name,
                        is_guest,
                    },
                )
            }
            ServerMessage::AvatarForced {
                user_id,
                avatar,
                reason: _,
                forced_by: _,
            } => {
                // Update the forced user's avatar
                if let Some(m) = state.members.iter_mut().find(|m| m.member.user_id == user_id) {
                    m.avatar.skin_layer = avatar.skin_layer.clone();
                    m.avatar.clothes_layer = avatar.clothes_layer.clone();
                    m.avatar.accessories_layer = avatar.accessories_layer.clone();
                    m.avatar.emotion_layer = avatar.emotion_layer.clone();
                }
                PostAction::UpdateMembers(state.members.clone())
            }
            ServerMessage::AvatarCleared {
                user_id,
                avatar,
                cleared_by: _,
            } => {
                // Restore the user's original avatar
                if let Some(m) = state.members.iter_mut().find(|m| m.member.user_id == user_id) {
                    m.avatar.skin_layer = avatar.skin_layer.clone();
                    m.avatar.clothes_layer = avatar.clothes_layer.clone();
                    m.avatar.accessories_layer = avatar.accessories_layer.clone();
                    m.avatar.emotion_layer = avatar.emotion_layer.clone();
                }
                PostAction::UpdateMembers(state.members.clone())
            }
        }
    }; // state borrow is dropped here

    // Now emit events without holding any borrows
    match action {
        PostAction::None => {}
        PostAction::UpdateMembers(members) => {
            on_event.run(WsEvent::MembersUpdated(members));
        }
        PostAction::UpdateMembersAndFade(members, fading) => {
            on_event.run(WsEvent::MembersUpdated(members));
            on_event.run(WsEvent::MemberFading(fading));
        }
        PostAction::UpdateMembersAndIdentity(members, info) => {
            on_event.run(WsEvent::MembersUpdated(members));
            on_event.run(WsEvent::MemberIdentityUpdated(info));
        }
        PostAction::ChatMessage(msg) => {
            on_event.run(WsEvent::ChatMessage(msg));
        }
        PostAction::LoosePropsSync(props) => {
            on_event.run(WsEvent::LoosePropsSync(props));
        }
        PostAction::PropDropped(prop) => {
            on_event.run(WsEvent::PropDropped(prop));
        }
        PostAction::PropPickedUp(prop_id) => {
            on_event.run(WsEvent::PropPickedUp(prop_id));
        }
        PostAction::Error(err) => {
            on_event.run(WsEvent::Error(err));
        }
        PostAction::TeleportApproved(info) => {
            on_event.run(WsEvent::TeleportApproved(info));
        }
        PostAction::Summoned(info) => {
            on_event.run(WsEvent::Summoned(info));
        }
        PostAction::ModCommandResult(info) => {
            on_event.run(WsEvent::ModCommandResult(info));
        }
    }
}

/// Stub implementation for SSR (server-side rendering).
#[cfg(not(feature = "hydrate"))]
pub fn use_channel_websocket(
    _realm_slug: Signal<String>,
    _channel_id: Signal<Option<uuid::Uuid>>,
    _reconnect_trigger: RwSignal<u32>,
    _on_event: Callback<WsEvent>,
) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) {
    let (ws_state, _) = signal(WsState::Disconnected);
    let sender: WsSenderStorage = StoredValue::new_local(None);
    let closer: WsCloserStorage = StoredValue::new_local(None);
    (Signal::derive(move || ws_state.get()), sender, closer)
}
