use crate::{
    ChartAnnotation, ChartPoint, GridRow, GridRowsWindow, GridState, InboxItem, JsInteropDispatch,
    PubSubCommand, RuntimeCommand, RuntimeEvent, ServerMessage, StreamBatchOperation,
    StreamPosition, Toast,
};
use std::collections::BTreeMap;
use std::fmt;
use uuid::Uuid;

/// Opaque LiveView session identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SessionId(String);

impl SessionId {
    /// Generate a new random session id.
    pub fn new() -> Self {
        Self(Uuid::new_v4().to_string())
    }

    pub(crate) fn from_string(value: String) -> Self {
        Self(value)
    }

    /// Borrow the id as a string slice.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl Default for SessionId {
    fn default() -> Self {
        Self::new()
    }
}

impl fmt::Display for SessionId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// Mutable context passed into LiveView lifecycle callbacks.
#[derive(Debug, Clone)]
pub struct Context {
    session_id: SessionId,
    target_dom_id: String,
    route_path: String,
    route_params: BTreeMap<String, String>,
    tenant_id: Option<String>,
    connected: bool,
    render_after_event: bool,
    render_cadence_override_ms: Option<u64>,
    pushes: Vec<ServerMessage>,
    pubsub: Vec<PubSubCommand>,
    runtime: Vec<RuntimeCommand>,
}

impl Context {
    /// Create context for a new LiveView session.
    pub fn new(target_dom_id: impl Into<String>) -> Self {
        Self::new_with_session_id(SessionId::new(), target_dom_id)
    }

    pub(crate) fn new_with_session_id(
        session_id: SessionId,
        target_dom_id: impl Into<String>,
    ) -> Self {
        Self {
            session_id,
            target_dom_id: target_dom_id.into(),
            route_path: "/".to_string(),
            route_params: BTreeMap::new(),
            tenant_id: None,
            connected: false,
            render_after_event: true,
            render_cadence_override_ms: None,
            pushes: Vec::new(),
            pubsub: Vec::new(),
            runtime: Vec::new(),
        }
    }

    /// The current session id.
    pub fn session_id(&self) -> &SessionId {
        &self.session_id
    }

    /// The DOM id targeted by patches.
    pub fn target_dom_id(&self) -> &str {
        &self.target_dom_id
    }

    /// Current request path for this LiveView session.
    pub fn route_path(&self) -> &str {
        &self.route_path
    }

    /// Current route params matched by the transport adapter.
    pub fn route_params(&self) -> &BTreeMap<String, String> {
        &self.route_params
    }

    /// Borrow one current route param.
    pub fn route_param(&self, key: &str) -> Option<&str> {
        self.route_params.get(key).map(String::as_str)
    }

    /// Tenant context associated with this session.
    pub fn tenant_id(&self) -> Option<&str> {
        self.tenant_id.as_deref()
    }

    /// Set tenant context associated with this session.
    pub fn set_tenant_id(&mut self, tenant_id: impl Into<String>) {
        self.tenant_id = normalize_tenant_id(Some(tenant_id.into()));
    }

    /// Replace tenant context associated with this session.
    pub fn set_tenant_id_optional(&mut self, tenant_id: Option<String>) {
        self.tenant_id = normalize_tenant_id(tenant_id);
    }

    /// Clear tenant context associated with this session.
    pub fn clear_tenant_id(&mut self) {
        self.tenant_id = None;
    }

    pub(crate) fn set_route(&mut self, path: impl Into<String>, params: BTreeMap<String, String>) {
        self.route_path = path.into();
        self.route_params = params;
    }

    /// Whether this context is currently attached to a WebSocket.
    pub fn is_connected(&self) -> bool {
        self.connected
    }

    /// Mark this context as connected or disconnected.
    pub fn set_connected(&mut self, connected: bool) {
        self.connected = connected;
    }

    /// Queue an imperative server message.
    pub fn push_message(&mut self, message: ServerMessage) {
        self.pushes.push(message);
    }

    /// Subscribe this session to an in-process PubSub topic.
    pub fn subscribe(&mut self, topic: impl Into<String>) {
        self.pubsub.push(PubSubCommand::Subscribe {
            topic: topic.into(),
        });
    }

    /// Broadcast server messages to all subscribers on a topic.
    pub fn broadcast(&mut self, topic: impl Into<String>, messages: Vec<ServerMessage>) {
        self.skip_render();
        self.pubsub.push(PubSubCommand::Broadcast {
            topic: topic.into(),
            messages,
        });
    }

    /// Broadcast one server message to all subscribers on a topic.
    pub fn broadcast_message(&mut self, topic: impl Into<String>, message: ServerMessage) {
        self.broadcast(topic, vec![message]);
    }

    /// Schedule one synthetic event to be dispatched after `delay_ms`.
    pub fn schedule_once(
        &mut self,
        id: impl Into<String>,
        delay_ms: u64,
        event: impl Into<String>,
        value: impl Into<serde_json::Value>,
    ) {
        self.skip_render();
        self.runtime.push(RuntimeCommand::ScheduleOnce {
            id: id.into(),
            delay_ms,
            dispatch: RuntimeEvent::new(event, value),
        });
    }

    /// Schedule one synthetic scoped event to be dispatched after `delay_ms`.
    pub fn schedule_once_to(
        &mut self,
        id: impl Into<String>,
        delay_ms: u64,
        target: impl Into<String>,
        event: impl Into<String>,
        value: impl Into<serde_json::Value>,
    ) {
        self.skip_render();
        self.runtime.push(RuntimeCommand::ScheduleOnce {
            id: id.into(),
            delay_ms,
            dispatch: RuntimeEvent::with_target(target, event, value),
        });
    }

    /// Schedule a recurring synthetic event every `every_ms`.
    pub fn schedule_interval(
        &mut self,
        id: impl Into<String>,
        every_ms: u64,
        event: impl Into<String>,
        value: impl Into<serde_json::Value>,
    ) {
        self.skip_render();
        self.runtime.push(RuntimeCommand::ScheduleInterval {
            id: id.into(),
            every_ms,
            dispatch: RuntimeEvent::new(event, value),
        });
    }

    /// Schedule a recurring scoped synthetic event every `every_ms`.
    pub fn schedule_interval_to(
        &mut self,
        id: impl Into<String>,
        every_ms: u64,
        target: impl Into<String>,
        event: impl Into<String>,
        value: impl Into<serde_json::Value>,
    ) {
        self.skip_render();
        self.runtime.push(RuntimeCommand::ScheduleInterval {
            id: id.into(),
            every_ms,
            dispatch: RuntimeEvent::with_target(target, event, value),
        });
    }

    /// Cancel a previously scheduled runtime command by id.
    pub fn cancel_schedule(&mut self, id: impl Into<String>) {
        self.skip_render();
        self.runtime.push(RuntimeCommand::Cancel { id: id.into() });
    }

    /// Request a root render after the current event.
    pub fn request_render(&mut self) {
        self.render_after_event = true;
    }

    /// Override root render cadence for this session in milliseconds.
    ///
    /// `0` keeps immediate render behavior.
    pub fn set_render_cadence_ms(&mut self, cadence_ms: u64) {
        self.render_cadence_override_ms = Some(cadence_ms);
    }

    /// Clear any session-level render cadence override.
    pub fn clear_render_cadence_override(&mut self) {
        self.render_cadence_override_ms = None;
    }

    /// Suppress the automatic root render after the current event.
    pub fn skip_render(&mut self) {
        self.render_after_event = false;
    }

    /// Queue a browser redirect.
    pub fn redirect(&mut self, to: impl Into<String>) {
        self.push_message(ServerMessage::Redirect { to: to.into() });
    }

    /// Queue a URL patch without replacing the current LiveView.
    pub fn patch_url(&mut self, to: impl Into<String>) {
        self.push_message(ServerMessage::PatchUrl { to: to.into() });
    }

    /// Queue an internal LiveView navigation.
    pub fn navigate(&mut self, to: impl Into<String>) {
        self.push_message(ServerMessage::Navigate { to: to.into() });
    }

    /// Queue insertion of one item into a browser stream.
    pub fn stream_insert(
        &mut self,
        target: impl Into<String>,
        id: impl Into<String>,
        html: impl Into<String>,
        at: StreamPosition,
    ) {
        self.skip_render();
        self.push_message(ServerMessage::StreamInsert {
            target: target.into(),
            id: id.into(),
            html: html.into(),
            at,
        });
    }

    /// Queue append of one item into a browser stream.
    pub fn stream_append(
        &mut self,
        target: impl Into<String>,
        id: impl Into<String>,
        html: impl Into<String>,
    ) {
        self.stream_insert(target, id, html, StreamPosition::Append);
    }

    /// Queue prepend of one item into a browser stream.
    pub fn stream_prepend(
        &mut self,
        target: impl Into<String>,
        id: impl Into<String>,
        html: impl Into<String>,
    ) {
        self.stream_insert(target, id, html, StreamPosition::Prepend);
    }

    /// Queue deletion of one item from a browser stream.
    pub fn stream_delete(&mut self, target: impl Into<String>, id: impl Into<String>) {
        self.skip_render();
        self.push_message(ServerMessage::StreamDelete {
            target: target.into(),
            id: id.into(),
        });
    }

    /// Queue many stream operations as one protocol message.
    pub fn stream_batch(
        &mut self,
        target: impl Into<String>,
        operations: Vec<StreamBatchOperation>,
    ) {
        if operations.is_empty() {
            return;
        }
        self.skip_render();
        self.push_message(ServerMessage::StreamBatch {
            target: target.into(),
            operations,
        });
    }

    /// Queue append of many items into a browser stream.
    pub fn stream_append_many(&mut self, target: impl Into<String>, items: Vec<(String, String)>) {
        if items.is_empty() {
            return;
        }
        self.stream_batch(
            target,
            items
                .into_iter()
                .map(|(id, html)| StreamBatchOperation::Insert {
                    id,
                    html,
                    at: StreamPosition::Append,
                })
                .collect(),
        );
    }

    /// Queue prepend of many items into a browser stream.
    pub fn stream_prepend_many(&mut self, target: impl Into<String>, items: Vec<(String, String)>) {
        if items.is_empty() {
            return;
        }
        self.stream_batch(
            target,
            items
                .into_iter()
                .map(|(id, html)| StreamBatchOperation::Insert {
                    id,
                    html,
                    at: StreamPosition::Prepend,
                })
                .collect(),
        );
    }

    /// Queue append of one point into a browser-managed chart series.
    pub fn chart_series_append(
        &mut self,
        chart: impl Into<String>,
        series: impl Into<String>,
        point: ChartPoint,
    ) {
        self.skip_render();
        self.push_message(ServerMessage::ChartSeriesAppend {
            chart: chart.into(),
            series: series.into(),
            point,
        });
    }

    /// Queue append of many points into one browser-managed chart series.
    pub fn chart_series_append_many(
        &mut self,
        chart: impl Into<String>,
        series: impl Into<String>,
        points: Vec<ChartPoint>,
    ) {
        if points.is_empty() {
            return;
        }
        self.skip_render();
        self.push_message(ServerMessage::ChartSeriesAppendMany {
            chart: chart.into(),
            series: series.into(),
            points,
        });
    }

    /// Queue replacement of one chart series with a full point set.
    pub fn chart_series_replace(
        &mut self,
        chart: impl Into<String>,
        series: impl Into<String>,
        points: Vec<ChartPoint>,
    ) {
        self.skip_render();
        self.push_message(ServerMessage::ChartSeriesReplace {
            chart: chart.into(),
            series: series.into(),
            points,
        });
    }

    /// Queue clearing of all series data for one chart.
    pub fn chart_reset(&mut self, chart: impl Into<String>) {
        self.skip_render();
        self.push_message(ServerMessage::ChartReset {
            chart: chart.into(),
        });
    }

    /// Queue insert or replacement of one chart annotation.
    pub fn chart_annotation_upsert(
        &mut self,
        chart: impl Into<String>,
        annotation: ChartAnnotation,
    ) {
        self.skip_render();
        self.push_message(ServerMessage::ChartAnnotationUpsert {
            chart: chart.into(),
            annotation,
        });
    }

    /// Queue deletion of one chart annotation.
    pub fn chart_annotation_delete(&mut self, chart: impl Into<String>, id: impl Into<String>) {
        self.skip_render();
        self.push_message(ServerMessage::ChartAnnotationDelete {
            chart: chart.into(),
            id: id.into(),
        });
    }

    /// Queue one toast notification for browser display.
    pub fn toast_push(&mut self, toast: Toast) {
        self.skip_render();
        self.push_message(ServerMessage::ToastPush { toast });
    }

    /// Queue dismissal of one toast notification.
    pub fn toast_dismiss(&mut self, id: impl Into<String>) {
        self.skip_render();
        self.push_message(ServerMessage::ToastDismiss { id: id.into() });
    }

    /// Queue insert or replacement of one inbox item.
    pub fn inbox_upsert(&mut self, item: InboxItem) {
        self.skip_render();
        self.push_message(ServerMessage::InboxUpsert { item });
    }

    /// Queue deletion of one inbox item.
    pub fn inbox_delete(&mut self, id: impl Into<String>) {
        self.skip_render();
        self.push_message(ServerMessage::InboxDelete { id: id.into() });
    }

    /// Queue a full enterprise-grid snapshot update.
    pub fn grid_replace(&mut self, grid: impl Into<String>, state: GridState) {
        self.skip_render();
        self.push_message(ServerMessage::GridReplace {
            grid: grid.into(),
            state,
        });
    }

    /// Queue replacement of one active enterprise-grid row window.
    pub fn grid_rows_replace(
        &mut self,
        grid: impl Into<String>,
        offset: usize,
        total_rows: usize,
        rows: Vec<GridRow>,
    ) {
        self.skip_render();
        self.push_message(ServerMessage::GridRowsReplace {
            grid: grid.into(),
            window: GridRowsWindow {
                offset,
                total_rows,
                rows,
            },
        });
    }

    /// Dispatch one browser interop event to `document`.
    pub fn interop_dispatch(
        &mut self,
        event: impl Into<String>,
        detail: impl Into<serde_json::Value>,
    ) {
        self.interop_dispatch_to(None::<String>, event, detail);
    }

    /// Dispatch one browser interop event to a specific target id.
    pub fn interop_dispatch_to(
        &mut self,
        target: impl Into<Option<String>>,
        event: impl Into<String>,
        detail: impl Into<serde_json::Value>,
    ) {
        self.skip_render();
        self.push_message(ServerMessage::InteropDispatch {
            dispatch: JsInteropDispatch {
                target: target.into(),
                event: event.into(),
                detail: detail.into(),
                bubbles: true,
            },
        });
    }

    /// Dispatch one WebRTC signaling payload.
    pub fn webrtc_signal(&mut self, detail: impl Into<serde_json::Value>) {
        self.interop_dispatch("shelly:webrtc-signal", detail);
    }

    /// Dispatch one WebRTC signaling payload to a specific target id.
    pub fn webrtc_signal_to(
        &mut self,
        target: impl Into<String>,
        detail: impl Into<serde_json::Value>,
    ) {
        self.interop_dispatch_to(Some(target.into()), "shelly:webrtc-signal", detail);
    }

    pub(crate) fn drain_pushes(&mut self) -> Vec<ServerMessage> {
        std::mem::take(&mut self.pushes)
    }

    pub(crate) fn drain_pubsub_commands(&mut self) -> Vec<PubSubCommand> {
        std::mem::take(&mut self.pubsub)
    }

    pub(crate) fn drain_runtime_commands(&mut self) -> Vec<RuntimeCommand> {
        std::mem::take(&mut self.runtime)
    }

    pub(crate) fn schedule_internal_once(
        &mut self,
        id: impl Into<String>,
        delay_ms: u64,
        event: impl Into<String>,
        value: impl Into<serde_json::Value>,
    ) {
        self.runtime.push(RuntimeCommand::ScheduleOnce {
            id: id.into(),
            delay_ms,
            dispatch: RuntimeEvent::new(event, value),
        });
    }

    pub(crate) fn cancel_schedule_internal(&mut self, id: impl Into<String>) {
        self.runtime.push(RuntimeCommand::Cancel { id: id.into() });
    }

    pub(crate) fn take_render_after_event(&mut self) -> bool {
        let render = self.render_after_event;
        self.render_after_event = true;
        render
    }

    pub(crate) fn render_cadence_override_ms(&self) -> Option<u64> {
        self.render_cadence_override_ms
    }
}

fn normalize_tenant_id(tenant_id: Option<String>) -> Option<String> {
    tenant_id
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

#[cfg(test)]
mod tests {
    use super::Context;
    use crate::{
        ChartAnnotation, ChartPoint, GridColumn, GridPinned, GridRow, GridRowsWindow,
        GridSavedView, GridSort, GridSortDirection, GridState, InboxItem, JsInteropDispatch,
        PubSubCommand, RuntimeCommand, RuntimeEvent, ServerMessage, StreamBatchOperation,
        StreamPosition, Toast, ToastLevel,
    };
    use serde_json::json;

    #[test]
    fn context_can_queue_redirects() {
        let mut ctx = Context::new("root");
        ctx.redirect("/next");

        assert_eq!(
            ctx.drain_pushes(),
            vec![ServerMessage::Redirect {
                to: "/next".to_string()
            }]
        );
    }

    #[test]
    fn context_tracks_route_params() {
        let mut ctx = Context::new("root");
        ctx.set_route(
            "/pages/intro",
            [("slug".to_string(), "intro".to_string())].into(),
        );

        assert_eq!(ctx.route_path(), "/pages/intro");
        assert_eq!(ctx.route_param("slug"), Some("intro"));
        assert_eq!(ctx.route_param("missing"), None);
    }

    #[test]
    fn context_tracks_tenant_context() {
        let mut ctx = Context::new("root");
        assert_eq!(ctx.tenant_id(), None);

        ctx.set_tenant_id("tenant-a");
        assert_eq!(ctx.tenant_id(), Some("tenant-a"));

        ctx.set_tenant_id_optional(Some("   ".to_string()));
        assert_eq!(ctx.tenant_id(), None);

        ctx.set_tenant_id_optional(Some("tenant-b".to_string()));
        assert_eq!(ctx.tenant_id(), Some("tenant-b"));

        ctx.clear_tenant_id();
        assert_eq!(ctx.tenant_id(), None);
    }

    #[test]
    fn context_can_queue_navigation_messages() {
        let mut ctx = Context::new("root");
        ctx.patch_url("/pages/intro");
        ctx.navigate("/users/1");

        assert_eq!(
            ctx.drain_pushes(),
            vec![
                ServerMessage::PatchUrl {
                    to: "/pages/intro".to_string()
                },
                ServerMessage::Navigate {
                    to: "/users/1".to_string()
                },
            ]
        );
    }

    #[test]
    fn context_can_queue_stream_messages_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.stream_append("messages", "msg-1", "<li id=\"msg-1\">hi</li>");
        ctx.stream_prepend("messages", "msg-0", "<li id=\"msg-0\">first</li>");
        ctx.stream_delete("messages", "msg-1");

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![
                ServerMessage::StreamInsert {
                    target: "messages".to_string(),
                    id: "msg-1".to_string(),
                    html: "<li id=\"msg-1\">hi</li>".to_string(),
                    at: StreamPosition::Append,
                },
                ServerMessage::StreamInsert {
                    target: "messages".to_string(),
                    id: "msg-0".to_string(),
                    html: "<li id=\"msg-0\">first</li>".to_string(),
                    at: StreamPosition::Prepend,
                },
                ServerMessage::StreamDelete {
                    target: "messages".to_string(),
                    id: "msg-1".to_string(),
                },
            ]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_set_and_clear_render_cadence_override() {
        let mut ctx = Context::new("root");
        assert_eq!(ctx.render_cadence_override_ms(), None);

        ctx.set_render_cadence_ms(24);
        assert_eq!(ctx.render_cadence_override_ms(), Some(24));

        ctx.clear_render_cadence_override();
        assert_eq!(ctx.render_cadence_override_ms(), None);
    }

    #[test]
    fn context_can_queue_stream_batch_messages_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.stream_batch(
            "messages",
            vec![
                StreamBatchOperation::Insert {
                    id: "msg-2".to_string(),
                    html: "<li id=\"msg-2\">batch</li>".to_string(),
                    at: StreamPosition::Append,
                },
                StreamBatchOperation::Delete {
                    id: "msg-1".to_string(),
                },
            ],
        );

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![ServerMessage::StreamBatch {
                target: "messages".to_string(),
                operations: vec![
                    StreamBatchOperation::Insert {
                        id: "msg-2".to_string(),
                        html: "<li id=\"msg-2\">batch</li>".to_string(),
                        at: StreamPosition::Append,
                    },
                    StreamBatchOperation::Delete {
                        id: "msg-1".to_string(),
                    },
                ],
            }]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_pubsub_commands_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.subscribe("chat:lobby");
        ctx.broadcast_message(
            "chat:lobby",
            ServerMessage::StreamDelete {
                target: "messages".to_string(),
                id: "msg-1".to_string(),
            },
        );

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pubsub_commands(),
            vec![
                PubSubCommand::Subscribe {
                    topic: "chat:lobby".to_string()
                },
                PubSubCommand::Broadcast {
                    topic: "chat:lobby".to_string(),
                    messages: vec![ServerMessage::StreamDelete {
                        target: "messages".to_string(),
                        id: "msg-1".to_string(),
                    }],
                }
            ]
        );
    }

    #[test]
    fn context_can_queue_runtime_commands_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.schedule_once("retry-once", 250, "load_more", json!({"cursor": "a1"}));
        ctx.schedule_interval_to(
            "heartbeat",
            1000,
            "grid",
            "tick",
            json!({"source": "timer"}),
        );
        ctx.cancel_schedule("retry-once");

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_runtime_commands(),
            vec![
                RuntimeCommand::ScheduleOnce {
                    id: "retry-once".to_string(),
                    delay_ms: 250,
                    dispatch: RuntimeEvent::new("load_more", json!({"cursor": "a1"})),
                },
                RuntimeCommand::ScheduleInterval {
                    id: "heartbeat".to_string(),
                    every_ms: 1000,
                    dispatch: RuntimeEvent::with_target("grid", "tick", json!({"source": "timer"}),),
                },
                RuntimeCommand::Cancel {
                    id: "retry-once".to_string(),
                },
            ]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_chart_stream_message_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.chart_series_append("traffic", "requests", ChartPoint { x: 1.0, y: 2.5 });

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![ServerMessage::ChartSeriesAppend {
                chart: "traffic".to_string(),
                series: "requests".to_string(),
                point: ChartPoint { x: 1.0, y: 2.5 },
            }]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_chart_series_replace_and_reset_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.chart_series_replace(
            "traffic",
            "requests",
            vec![ChartPoint { x: 1.0, y: 2.5 }, ChartPoint { x: 2.0, y: 3.0 }],
        );
        ctx.chart_reset("traffic");

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![
                ServerMessage::ChartSeriesReplace {
                    chart: "traffic".to_string(),
                    series: "requests".to_string(),
                    points: vec![ChartPoint { x: 1.0, y: 2.5 }, ChartPoint { x: 2.0, y: 3.0 }],
                },
                ServerMessage::ChartReset {
                    chart: "traffic".to_string(),
                },
            ]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_chart_append_many_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.chart_series_append_many(
            "traffic",
            "requests",
            vec![ChartPoint { x: 3.0, y: 4.0 }, ChartPoint { x: 4.0, y: 5.0 }],
        );

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![ServerMessage::ChartSeriesAppendMany {
                chart: "traffic".to_string(),
                series: "requests".to_string(),
                points: vec![ChartPoint { x: 3.0, y: 4.0 }, ChartPoint { x: 4.0, y: 5.0 }],
            }]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_chart_annotation_messages_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.chart_annotation_upsert(
            "traffic",
            ChartAnnotation {
                id: "release-1".to_string(),
                x: 18.0,
                label: "Deploy".to_string(),
            },
        );
        ctx.chart_annotation_delete("traffic", "release-1");

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![
                ServerMessage::ChartAnnotationUpsert {
                    chart: "traffic".to_string(),
                    annotation: ChartAnnotation {
                        id: "release-1".to_string(),
                        x: 18.0,
                        label: "Deploy".to_string(),
                    },
                },
                ServerMessage::ChartAnnotationDelete {
                    chart: "traffic".to_string(),
                    id: "release-1".to_string(),
                },
            ]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_toast_and_inbox_messages_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.toast_push(Toast {
            id: "toast-1".to_string(),
            level: ToastLevel::Success,
            title: Some("Saved".to_string()),
            message: "Profile updated".to_string(),
            ttl_ms: Some(2000),
        });
        ctx.toast_dismiss("toast-1");
        ctx.inbox_upsert(InboxItem {
            id: "msg-1".to_string(),
            title: "Welcome".to_string(),
            body: "Thanks for joining".to_string(),
            read: false,
            inserted_at: None,
        });
        ctx.inbox_delete("msg-1");

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![
                ServerMessage::ToastPush {
                    toast: Toast {
                        id: "toast-1".to_string(),
                        level: ToastLevel::Success,
                        title: Some("Saved".to_string()),
                        message: "Profile updated".to_string(),
                        ttl_ms: Some(2000),
                    },
                },
                ServerMessage::ToastDismiss {
                    id: "toast-1".to_string(),
                },
                ServerMessage::InboxUpsert {
                    item: InboxItem {
                        id: "msg-1".to_string(),
                        title: "Welcome".to_string(),
                        body: "Thanks for joining".to_string(),
                        read: false,
                        inserted_at: None,
                    },
                },
                ServerMessage::InboxDelete {
                    id: "msg-1".to_string(),
                },
            ]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_interop_and_webrtc_messages_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.interop_dispatch_to(
            Some("peer-a".to_string()),
            "map:pan_to",
            json!({"lat": 42.0, "lng": -71.0}),
        );
        ctx.webrtc_signal(json!({"kind": "offer", "sdp": "v=0..."}));

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![
                ServerMessage::InteropDispatch {
                    dispatch: JsInteropDispatch {
                        target: Some("peer-a".to_string()),
                        event: "map:pan_to".to_string(),
                        detail: json!({"lat": 42.0, "lng": -71.0}),
                        bubbles: true,
                    },
                },
                ServerMessage::InteropDispatch {
                    dispatch: JsInteropDispatch {
                        target: None,
                        event: "shelly:webrtc-signal".to_string(),
                        detail: json!({"kind": "offer", "sdp": "v=0..."}),
                        bubbles: true,
                    },
                },
            ]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_grid_replace_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.grid_replace(
            "enterprise-grid",
            GridState {
                columns: vec![GridColumn {
                    id: "name".to_string(),
                    label: "Name".to_string(),
                    width_px: Some(220),
                    min_width_px: Some(100),
                    pinned: GridPinned::Left,
                    sortable: true,
                    resizable: true,
                    editable: false,
                }],
                rows: vec![GridRow {
                    id: "row-1".to_string(),
                    cells: std::iter::once(("name".to_string(), json!("Ada"))).collect(),
                    group: Some("Engineering".to_string()),
                }],
                total_rows: 2000,
                offset: 0,
                limit: 200,
                views: vec![GridSavedView {
                    id: "eng".to_string(),
                    label: "Engineering".to_string(),
                }],
                active_view: Some("eng".to_string()),
                group_by: Some("department".to_string()),
                query: Some("ada".to_string()),
                sort: Some(GridSort {
                    column: "name".to_string(),
                    direction: GridSortDirection::Asc,
                }),
            },
        );

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![ServerMessage::GridReplace {
                grid: "enterprise-grid".to_string(),
                state: GridState {
                    columns: vec![GridColumn {
                        id: "name".to_string(),
                        label: "Name".to_string(),
                        width_px: Some(220),
                        min_width_px: Some(100),
                        pinned: GridPinned::Left,
                        sortable: true,
                        resizable: true,
                        editable: false,
                    }],
                    rows: vec![GridRow {
                        id: "row-1".to_string(),
                        cells: std::iter::once(("name".to_string(), json!("Ada"))).collect(),
                        group: Some("Engineering".to_string()),
                    }],
                    total_rows: 2000,
                    offset: 0,
                    limit: 200,
                    views: vec![GridSavedView {
                        id: "eng".to_string(),
                        label: "Engineering".to_string(),
                    }],
                    active_view: Some("eng".to_string()),
                    group_by: Some("department".to_string()),
                    query: Some("ada".to_string()),
                    sort: Some(GridSort {
                        column: "name".to_string(),
                        direction: GridSortDirection::Asc,
                    }),
                },
            }]
        );
        assert!(ctx.take_render_after_event());
    }

    #[test]
    fn context_can_queue_grid_window_replace_without_root_render() {
        let mut ctx = Context::new("root");
        ctx.grid_rows_replace(
            "enterprise-grid",
            400,
            1000,
            vec![GridRow {
                id: "acct-401".to_string(),
                cells: serde_json::Map::from_iter([
                    ("name".to_string(), json!("Acme North")),
                    ("arr".to_string(), json!(166000)),
                ]),
                group: Some("Enterprise".to_string()),
            }],
        );

        assert!(!ctx.take_render_after_event());
        assert_eq!(
            ctx.drain_pushes(),
            vec![ServerMessage::GridRowsReplace {
                grid: "enterprise-grid".to_string(),
                window: GridRowsWindow {
                    offset: 400,
                    total_rows: 1000,
                    rows: vec![GridRow {
                        id: "acct-401".to_string(),
                        cells: serde_json::Map::from_iter([
                            ("name".to_string(), json!("Acme North")),
                            ("arr".to_string(), json!(166000)),
                        ]),
                        group: Some("Enterprise".to_string()),
                    }],
                },
            }]
        );
        assert!(ctx.take_render_after_event());
    }
}
