use crate::{Event, ShellyError};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

pub const PROTOCOL_VERSION_V1: &str = "shelly/1";

/// Message received from the browser runtime.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
    /// Initial protocol negotiation from the browser runtime.
    Connect {
        protocol: String,
        #[serde(default)]
        session_id: Option<String>,
        #[serde(default)]
        last_revision: Option<u64>,
        #[serde(default)]
        resume_token: Option<String>,
        #[serde(default)]
        tenant_id: Option<String>,
        #[serde(default)]
        trace_id: Option<String>,
        #[serde(default)]
        span_id: Option<String>,
        #[serde(default)]
        parent_span_id: Option<String>,
        #[serde(default)]
        correlation_id: Option<String>,
        #[serde(default)]
        request_id: Option<String>,
    },

    /// Browser event captured by the Shelly JavaScript client.
    Event {
        event: String,
        target: Option<String>,
        #[serde(default)]
        value: Value,
        #[serde(default)]
        metadata: Map<String, Value>,
    },

    /// Health check from the browser.
    Ping { nonce: Option<String> },

    /// Request an internal URL patch on the current LiveView route.
    PatchUrl { to: String },

    /// Request an internal navigation to another LiveView route.
    Navigate { to: String },

    /// Negotiate a new upload before sending chunks.
    UploadStart {
        upload_id: String,
        event: String,
        target: Option<String>,
        name: String,
        size: u64,
        content_type: Option<String>,
    },

    /// Send one base64-encoded upload chunk.
    UploadChunk {
        upload_id: String,
        offset: u64,
        data: String,
    },

    /// Finish an upload after all chunks have been sent.
    UploadComplete { upload_id: String },
}

impl TryFrom<ClientMessage> for Event {
    type Error = ShellyError;

    fn try_from(message: ClientMessage) -> Result<Self, Self::Error> {
        match message {
            ClientMessage::Connect { .. } => Err(ShellyError::InvalidMessage(
                "connect cannot be converted into a LiveView event".to_string(),
            )),
            ClientMessage::Event {
                event,
                target,
                value,
                metadata,
            } => Ok(Event {
                name: event,
                target,
                value,
                metadata,
            }),
            ClientMessage::Ping { .. } => Err(ShellyError::InvalidMessage(
                "ping cannot be converted into a LiveView event".to_string(),
            )),
            ClientMessage::PatchUrl { .. } => Err(ShellyError::InvalidMessage(
                "patch_url cannot be converted into a LiveView event".to_string(),
            )),
            ClientMessage::Navigate { .. } => Err(ShellyError::InvalidMessage(
                "navigate cannot be converted into a LiveView event".to_string(),
            )),
            ClientMessage::UploadStart { .. }
            | ClientMessage::UploadChunk { .. }
            | ClientMessage::UploadComplete { .. } => Err(ShellyError::InvalidMessage(
                "upload protocol messages cannot be converted into LiveView events".to_string(),
            )),
        }
    }
}

/// Message sent from the Rust runtime to the browser runtime.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
    /// Sent when the WebSocket is established.
    Hello {
        session_id: String,
        target: String,
        revision: u64,
        protocol: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        server_revision: Option<u64>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        resume_status: Option<ResumeStatus>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        resume_reason: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        resume_token: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        resume_expires_in_ms: Option<u64>,
    },

    /// Replace or morph a target node's inner content.
    Patch {
        target: String,
        html: String,
        revision: u64,
    },

    /// Replace changed dynamic slots within a previously patched template.
    Diff {
        target: String,
        revision: u64,
        slots: Vec<DynamicSlotPatch>,
    },

    /// Insert one rendered item into a browser stream.
    StreamInsert {
        target: String,
        id: String,
        html: String,
        at: StreamPosition,
    },

    /// Delete one rendered item from a browser stream.
    StreamDelete { target: String, id: String },

    /// Apply many stream operations in one message.
    StreamBatch {
        target: String,
        operations: Vec<StreamBatchOperation>,
    },

    /// Append one point into a browser-managed chart series.
    ChartSeriesAppend {
        chart: String,
        series: String,
        point: ChartPoint,
    },

    /// Append many points into one browser-managed chart series.
    ChartSeriesAppendMany {
        chart: String,
        series: String,
        points: Vec<ChartPoint>,
    },

    /// Replace one chart series with a full point set.
    ChartSeriesReplace {
        chart: String,
        series: String,
        points: Vec<ChartPoint>,
    },

    /// Clear all chart series data for one chart.
    ChartReset { chart: String },

    /// Insert or replace one chart annotation.
    ChartAnnotationUpsert {
        chart: String,
        annotation: ChartAnnotation,
    },

    /// Delete one chart annotation.
    ChartAnnotationDelete { chart: String, id: String },

    /// Push one toast notification.
    ToastPush { toast: Toast },

    /// Dismiss one toast notification by id.
    ToastDismiss { id: String },

    /// Insert or replace one inbox item.
    InboxUpsert { item: InboxItem },

    /// Delete one inbox item.
    InboxDelete { id: String },

    /// Replace one enterprise grid state snapshot.
    GridReplace { grid: String, state: GridState },

    /// Replace only the active row window of one enterprise grid.
    GridRowsReplace {
        grid: String,
        window: GridRowsWindow,
    },

    /// Dispatch one browser-side interop event.
    InteropDispatch { dispatch: JsInteropDispatch },

    /// Reply to a ping.
    Pong { nonce: Option<String> },

    /// Navigate the browser to another URL.
    Redirect { to: String },

    /// Patch the browser URL using history.pushState without replacing the view.
    PatchUrl { to: String },

    /// Navigate to another internal LiveView route using history.pushState.
    Navigate { to: String },

    /// Report upload transfer progress.
    UploadProgress {
        upload_id: String,
        received: u64,
        total: u64,
    },

    /// Report a completed upload.
    UploadComplete {
        upload_id: String,
        name: String,
        size: u64,
        content_type: Option<String>,
    },

    /// Report an upload-specific validation or transport error.
    UploadError {
        upload_id: String,
        message: String,
        code: Option<String>,
    },

    /// Report a recoverable server-side error.
    Error {
        message: String,
        code: Option<String>,
    },
}

/// Dynamic slot update sent by a `diff` server message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DynamicSlotPatch {
    pub index: usize,
    pub html: String,
}

/// Resume handshake outcome communicated in `hello`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResumeStatus {
    Fresh,
    Resumed,
    Fallback,
}

/// One numeric chart point.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ChartPoint {
    pub x: f64,
    pub y: f64,
}

/// One chart annotation marker.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChartAnnotation {
    pub id: String,
    pub x: f64,
    pub label: String,
}

/// One toast message shown by the browser runtime.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Toast {
    pub id: String,
    pub level: ToastLevel,
    pub title: Option<String>,
    pub message: String,
    pub ttl_ms: Option<u64>,
}

/// Visual severity for a toast.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToastLevel {
    Info,
    Success,
    Warning,
    Error,
}

/// One inbox item shown by the browser runtime.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InboxItem {
    pub id: String,
    pub title: String,
    pub body: String,
    pub read: bool,
    pub inserted_at: Option<String>,
}

/// Full browser-managed data-grid state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridState {
    #[serde(default)]
    pub columns: Vec<GridColumn>,
    #[serde(default)]
    pub rows: Vec<GridRow>,
    pub total_rows: usize,
    pub offset: usize,
    pub limit: usize,
    #[serde(default)]
    pub views: Vec<GridSavedView>,
    pub active_view: Option<String>,
    pub group_by: Option<String>,
    pub query: Option<String>,
    pub sort: Option<GridSort>,
}

/// Active window rows for one grid snapshot.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridRowsWindow {
    pub offset: usize,
    pub total_rows: usize,
    #[serde(default)]
    pub rows: Vec<GridRow>,
}

/// One grid column descriptor.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridColumn {
    pub id: String,
    pub label: String,
    pub width_px: Option<u16>,
    pub min_width_px: Option<u16>,
    #[serde(default)]
    pub pinned: GridPinned,
    #[serde(default = "default_true")]
    pub sortable: bool,
    #[serde(default = "default_true")]
    pub resizable: bool,
    #[serde(default)]
    pub editable: bool,
}

/// Grid column pin side.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum GridPinned {
    Left,
    Right,
    #[default]
    None,
}

/// One grid row record.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridRow {
    pub id: String,
    #[serde(default)]
    pub cells: Map<String, Value>,
    pub group: Option<String>,
}

/// One saved grid view.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GridSavedView {
    pub id: String,
    pub label: String,
}

/// Active grid sort definition.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GridSort {
    pub column: String,
    pub direction: GridSortDirection,
}

/// Grid sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GridSortDirection {
    Asc,
    Desc,
}

/// One browser interop dispatch instruction.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JsInteropDispatch {
    pub target: Option<String>,
    pub event: String,
    #[serde(default)]
    pub detail: Value,
    #[serde(default = "default_true")]
    pub bubbles: bool,
}

fn default_true() -> bool {
    true
}

/// Position used by a `stream_insert` server message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamPosition {
    Append,
    Prepend,
}

/// One stream operation used by `stream_batch`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum StreamBatchOperation {
    Insert {
        id: String,
        html: String,
        at: StreamPosition,
    },
    Delete {
        id: String,
    },
}

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

    #[test]
    fn decodes_client_event() {
        let raw = r#"{
            "type": "event",
            "event": "inc",
            "target": "counter",
            "value": {"step": 1},
            "metadata": {"tag": "BUTTON"}
        }"#;

        let message: ClientMessage = serde_json::from_str(raw).unwrap();
        let event = Event::try_from(message).unwrap();

        assert_eq!(event.name, "inc");
        assert_eq!(event.target.as_deref(), Some("counter"));
        assert_eq!(event.value, json!({"step": 1}));
        assert_eq!(event.metadata.get("tag"), Some(&json!("BUTTON")));
    }

    #[test]
    fn decodes_protocol_connect() {
        let message: ClientMessage =
            serde_json::from_value(json!({"type": "connect", "protocol": "shelly/1"})).unwrap();

        assert_eq!(
            message,
            ClientMessage::Connect {
                protocol: "shelly/1".to_string(),
                session_id: None,
                last_revision: None,
                resume_token: None,
                tenant_id: None,
                trace_id: None,
                span_id: None,
                parent_span_id: None,
                correlation_id: None,
                request_id: None,
            }
        );
    }

    #[test]
    fn decodes_protocol_connect_resume_hints() {
        let message: ClientMessage = serde_json::from_value(json!({
            "type": "connect",
            "protocol": "shelly/1",
            "session_id": "sid-1",
            "last_revision": 7
        }))
        .unwrap();

        assert_eq!(
            message,
            ClientMessage::Connect {
                protocol: "shelly/1".to_string(),
                session_id: Some("sid-1".to_string()),
                last_revision: Some(7),
                resume_token: None,
                tenant_id: None,
                trace_id: None,
                span_id: None,
                parent_span_id: None,
                correlation_id: None,
                request_id: None,
            }
        );
    }

    #[test]
    fn decodes_protocol_connect_resume_token() {
        let message: ClientMessage = serde_json::from_value(json!({
            "type": "connect",
            "protocol": "shelly/1",
            "session_id": "sid-1",
            "last_revision": 7,
            "resume_token": "resume-token-1"
        }))
        .unwrap();

        assert_eq!(
            message,
            ClientMessage::Connect {
                protocol: "shelly/1".to_string(),
                session_id: Some("sid-1".to_string()),
                last_revision: Some(7),
                resume_token: Some("resume-token-1".to_string()),
                tenant_id: None,
                trace_id: None,
                span_id: None,
                parent_span_id: None,
                correlation_id: None,
                request_id: None,
            }
        );
    }

    #[test]
    fn decodes_protocol_connect_correlation_fields() {
        let message: ClientMessage = serde_json::from_value(json!({
            "type": "connect",
            "protocol": "shelly/1",
            "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
            "span_id": "00f067aa0ba902b7",
            "parent_span_id": "89abcdef01234567",
            "correlation_id": "corr-123",
            "request_id": "req-123"
        }))
        .unwrap();

        assert_eq!(
            message,
            ClientMessage::Connect {
                protocol: "shelly/1".to_string(),
                session_id: None,
                last_revision: None,
                resume_token: None,
                tenant_id: None,
                trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
                span_id: Some("00f067aa0ba902b7".to_string()),
                parent_span_id: Some("89abcdef01234567".to_string()),
                correlation_id: Some("corr-123".to_string()),
                request_id: Some("req-123".to_string()),
            }
        );
    }

    #[test]
    fn decodes_protocol_connect_tenant_context() {
        let message: ClientMessage = serde_json::from_value(json!({
            "type": "connect",
            "protocol": "shelly/1",
            "tenant_id": "tenant-a"
        }))
        .unwrap();

        assert_eq!(
            message,
            ClientMessage::Connect {
                protocol: "shelly/1".to_string(),
                session_id: None,
                last_revision: None,
                resume_token: None,
                tenant_id: Some("tenant-a".to_string()),
                trace_id: None,
                span_id: None,
                parent_span_id: None,
                correlation_id: None,
                request_id: None,
            }
        );
    }

    #[test]
    fn decodes_navigation_requests() {
        assert_eq!(
            serde_json::from_value::<ClientMessage>(json!({
                "type": "patch_url",
                "to": "/pages/intro"
            }))
            .unwrap(),
            ClientMessage::PatchUrl {
                to: "/pages/intro".to_string()
            }
        );
        assert_eq!(
            serde_json::from_value::<ClientMessage>(json!({
                "type": "navigate",
                "to": "/users/1"
            }))
            .unwrap(),
            ClientMessage::Navigate {
                to: "/users/1".to_string()
            }
        );
    }

    #[test]
    fn decodes_upload_requests() {
        assert_eq!(
            serde_json::from_value::<ClientMessage>(json!({
                "type": "upload_start",
                "upload_id": "up-1",
                "event": "uploaded",
                "target": "file",
                "name": "notes.txt",
                "size": 12,
                "content_type": "text/plain"
            }))
            .unwrap(),
            ClientMessage::UploadStart {
                upload_id: "up-1".to_string(),
                event: "uploaded".to_string(),
                target: Some("file".to_string()),
                name: "notes.txt".to_string(),
                size: 12,
                content_type: Some("text/plain".to_string()),
            }
        );
        assert_eq!(
            serde_json::from_value::<ClientMessage>(json!({
                "type": "upload_chunk",
                "upload_id": "up-1",
                "offset": 0,
                "data": "aGVsbG8="
            }))
            .unwrap(),
            ClientMessage::UploadChunk {
                upload_id: "up-1".to_string(),
                offset: 0,
                data: "aGVsbG8=".to_string(),
            }
        );
        assert_eq!(
            serde_json::from_value::<ClientMessage>(json!({
                "type": "upload_complete",
                "upload_id": "up-1"
            }))
            .unwrap(),
            ClientMessage::UploadComplete {
                upload_id: "up-1".to_string()
            }
        );
    }

    #[test]
    fn encodes_server_patch() {
        let message = ServerMessage::Patch {
            target: "shelly-root".to_string(),
            html: "<p>ok</p>".to_string(),
            revision: 2,
        };

        let encoded = serde_json::to_value(message).unwrap();
        assert_eq!(encoded["type"], "patch");
        assert_eq!(encoded["target"], "shelly-root");
        assert_eq!(encoded["revision"], 2);
    }

    #[test]
    fn encodes_server_diff() {
        let message = ServerMessage::Diff {
            target: "shelly-root".to_string(),
            revision: 3,
            slots: vec![DynamicSlotPatch {
                index: 0,
                html: "2".to_string(),
            }],
        };

        let encoded = serde_json::to_value(message).unwrap();
        assert_eq!(
            encoded,
            json!({
                "type": "diff",
                "target": "shelly-root",
                "revision": 3,
                "slots": [{"index": 0, "html": "2"}]
            })
        );
    }

    #[test]
    fn encodes_stream_messages() {
        assert_eq!(
            serde_json::to_value(ServerMessage::StreamInsert {
                target: "messages".to_string(),
                id: "msg-1".to_string(),
                html: "<li id=\"msg-1\">hi</li>".to_string(),
                at: StreamPosition::Append,
            })
            .unwrap(),
            json!({
                "type": "stream_insert",
                "target": "messages",
                "id": "msg-1",
                "html": "<li id=\"msg-1\">hi</li>",
                "at": "append"
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::StreamDelete {
                target: "messages".to_string(),
                id: "msg-1".to_string(),
            })
            .unwrap(),
            json!({
                "type": "stream_delete",
                "target": "messages",
                "id": "msg-1"
            })
        );
        assert_eq!(
            serde_json::to_value(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(),
                    },
                ],
            })
            .unwrap(),
            json!({
                "type": "stream_batch",
                "target": "messages",
                "operations": [
                    {
                        "op": "insert",
                        "id": "msg-2",
                        "html": "<li id=\"msg-2\">batch</li>",
                        "at": "append"
                    },
                    {
                        "op": "delete",
                        "id": "msg-1"
                    }
                ]
            })
        );
    }

    #[test]
    fn encodes_chart_messages() {
        assert_eq!(
            serde_json::to_value(ServerMessage::ChartSeriesAppend {
                chart: "traffic-chart".to_string(),
                series: "requests".to_string(),
                point: ChartPoint { x: 4.0, y: 11.5 },
            })
            .unwrap(),
            json!({
                "type": "chart_series_append",
                "chart": "traffic-chart",
                "series": "requests",
                "point": {
                    "x": 4.0,
                    "y": 11.5
                }
            })
        );

        assert_eq!(
            serde_json::to_value(ServerMessage::ChartSeriesReplace {
                chart: "traffic-chart".to_string(),
                series: "requests".to_string(),
                points: vec![
                    ChartPoint { x: 1.0, y: 10.0 },
                    ChartPoint { x: 2.0, y: 11.5 }
                ],
            })
            .unwrap(),
            json!({
                "type": "chart_series_replace",
                "chart": "traffic-chart",
                "series": "requests",
                "points": [
                    {"x": 1.0, "y": 10.0},
                    {"x": 2.0, "y": 11.5}
                ]
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::ChartSeriesAppendMany {
                chart: "traffic-chart".to_string(),
                series: "requests".to_string(),
                points: vec![
                    ChartPoint { x: 3.0, y: 10.75 },
                    ChartPoint { x: 4.0, y: 11.5 }
                ],
            })
            .unwrap(),
            json!({
                "type": "chart_series_append_many",
                "chart": "traffic-chart",
                "series": "requests",
                "points": [
                    {"x": 3.0, "y": 10.75},
                    {"x": 4.0, "y": 11.5}
                ]
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::ChartReset {
                chart: "traffic-chart".to_string(),
            })
            .unwrap(),
            json!({
                "type": "chart_reset",
                "chart": "traffic-chart"
            })
        );

        assert_eq!(
            serde_json::to_value(ServerMessage::ChartAnnotationUpsert {
                chart: "traffic-chart".to_string(),
                annotation: ChartAnnotation {
                    id: "release-1".to_string(),
                    x: 18.0,
                    label: "Deploy".to_string(),
                },
            })
            .unwrap(),
            json!({
                "type": "chart_annotation_upsert",
                "chart": "traffic-chart",
                "annotation": {
                    "id": "release-1",
                    "x": 18.0,
                    "label": "Deploy"
                }
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::ChartAnnotationDelete {
                chart: "traffic-chart".to_string(),
                id: "release-1".to_string(),
            })
            .unwrap(),
            json!({
                "type": "chart_annotation_delete",
                "chart": "traffic-chart",
                "id": "release-1"
            })
        );
    }

    #[test]
    fn encodes_toast_and_inbox_messages() {
        assert_eq!(
            serde_json::to_value(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(2500),
                },
            })
            .unwrap(),
            json!({
                "type": "toast_push",
                "toast": {
                    "id": "toast-1",
                    "level": "success",
                    "title": "Saved",
                    "message": "Profile updated",
                    "ttl_ms": 2500
                }
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::ToastDismiss {
                id: "toast-1".to_string(),
            })
            .unwrap(),
            json!({
                "type": "toast_dismiss",
                "id": "toast-1"
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::InboxUpsert {
                item: InboxItem {
                    id: "msg-1".to_string(),
                    title: "Welcome".to_string(),
                    body: "Thanks for joining".to_string(),
                    read: false,
                    inserted_at: Some("2026-05-05T12:00:00Z".to_string()),
                },
            })
            .unwrap(),
            json!({
                "type": "inbox_upsert",
                "item": {
                    "id": "msg-1",
                    "title": "Welcome",
                    "body": "Thanks for joining",
                    "read": false,
                    "inserted_at": "2026-05-05T12:00:00Z"
                }
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::InboxDelete {
                id: "msg-1".to_string(),
            })
            .unwrap(),
            json!({
                "type": "inbox_delete",
                "id": "msg-1"
            })
        );

        assert_eq!(
            serde_json::to_value(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(120),
                            pinned: GridPinned::Left,
                            sortable: true,
                            resizable: true,
                            editable: false,
                        },
                        GridColumn {
                            id: "arr".to_string(),
                            label: "ARR".to_string(),
                            width_px: Some(120),
                            min_width_px: Some(80),
                            pinned: GridPinned::None,
                            sortable: true,
                            resizable: true,
                            editable: true,
                        },
                    ],
                    rows: vec![GridRow {
                        id: "acct-1".to_string(),
                        cells: Map::from_iter([
                            ("name".to_string(), json!("Acme")),
                            ("arr".to_string(), json!(125000)),
                        ]),
                        group: Some("Enterprise".to_string()),
                    }],
                    total_rows: 1000,
                    offset: 0,
                    limit: 100,
                    views: vec![GridSavedView {
                        id: "ops".to_string(),
                        label: "Ops".to_string(),
                    }],
                    active_view: Some("ops".to_string()),
                    group_by: Some("segment".to_string()),
                    query: Some("acme".to_string()),
                    sort: Some(GridSort {
                        column: "arr".to_string(),
                        direction: GridSortDirection::Desc,
                    }),
                },
            })
            .unwrap(),
            json!({
                "type": "grid_replace",
                "grid": "enterprise-grid",
                "state": {
                    "columns": [
                        {
                            "id": "name",
                            "label": "Name",
                            "width_px": 220,
                            "min_width_px": 120,
                            "pinned": "left",
                            "sortable": true,
                            "resizable": true,
                            "editable": false
                        },
                        {
                            "id": "arr",
                            "label": "ARR",
                            "width_px": 120,
                            "min_width_px": 80,
                            "pinned": "none",
                            "sortable": true,
                            "resizable": true,
                            "editable": true
                        }
                    ],
                    "rows": [
                        {
                            "id": "acct-1",
                            "cells": {
                                "name": "Acme",
                                "arr": 125000
                            },
                            "group": "Enterprise"
                        }
                    ],
                    "total_rows": 1000,
                    "offset": 0,
                    "limit": 100,
                    "views": [
                        {
                            "id": "ops",
                            "label": "Ops"
                        }
                    ],
                    "active_view": "ops",
                    "group_by": "segment",
                    "query": "acme",
                    "sort": {
                        "column": "arr",
                        "direction": "desc"
                    }
                }
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::GridRowsReplace {
                grid: "enterprise-grid".to_string(),
                window: GridRowsWindow {
                    offset: 400,
                    total_rows: 1000,
                    rows: vec![GridRow {
                        id: "acct-401".to_string(),
                        cells: Map::from_iter([
                            ("name".to_string(), json!("Acme North")),
                            ("arr".to_string(), json!(166000)),
                        ]),
                        group: Some("Enterprise".to_string()),
                    }],
                },
            })
            .unwrap(),
            json!({
                "type": "grid_rows_replace",
                "grid": "enterprise-grid",
                "window": {
                    "offset": 400,
                    "total_rows": 1000,
                    "rows": [
                        {
                            "id": "acct-401",
                            "cells": {
                                "name": "Acme North",
                                "arr": 166000
                            },
                            "group": "Enterprise"
                        }
                    ]
                }
            })
        );

        assert_eq!(
            serde_json::to_value(ServerMessage::InteropDispatch {
                dispatch: JsInteropDispatch {
                    target: Some("peer-a".to_string()),
                    event: "shelly:webrtc-signal".to_string(),
                    detail: json!({"kind": "offer", "sdp": "v=0..."}),
                    bubbles: true,
                },
            })
            .unwrap(),
            json!({
                "type": "interop_dispatch",
                "dispatch": {
                    "target": "peer-a",
                    "event": "shelly:webrtc-signal",
                    "detail": {"kind": "offer", "sdp": "v=0..."},
                    "bubbles": true
                }
            })
        );
    }

    #[test]
    fn encodes_navigation_messages() {
        assert_eq!(
            serde_json::to_value(ServerMessage::PatchUrl {
                to: "/pages/intro".to_string(),
            })
            .unwrap(),
            json!({"type": "patch_url", "to": "/pages/intro"})
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::Navigate {
                to: "/users/1".to_string(),
            })
            .unwrap(),
            json!({"type": "navigate", "to": "/users/1"})
        );
    }

    #[test]
    fn encodes_upload_status_messages() {
        assert_eq!(
            serde_json::to_value(ServerMessage::UploadProgress {
                upload_id: "up-1".to_string(),
                received: 5,
                total: 10,
            })
            .unwrap(),
            json!({
                "type": "upload_progress",
                "upload_id": "up-1",
                "received": 5,
                "total": 10
            })
        );
        assert_eq!(
            serde_json::to_value(ServerMessage::UploadError {
                upload_id: "up-1".to_string(),
                message: "too large".to_string(),
                code: Some("upload_too_large".to_string()),
            })
            .unwrap(),
            json!({
                "type": "upload_error",
                "upload_id": "up-1",
                "message": "too large",
                "code": "upload_too_large"
            })
        );
    }

    #[test]
    fn rejects_unknown_client_message_type() {
        let decoded = serde_json::from_value::<ClientMessage>(json!({
            "type": "do_the_thing",
            "payload": {}
        }));
        assert!(decoded.is_err());
    }

    #[test]
    fn rejects_event_without_event_name() {
        let decoded = serde_json::from_value::<ClientMessage>(json!({
            "type": "event",
            "target": "counter",
            "value": {"step": 1}
        }));
        assert!(decoded.is_err());
    }

    #[test]
    fn rejects_upload_chunk_without_required_fields() {
        let missing_data = serde_json::from_value::<ClientMessage>(json!({
            "type": "upload_chunk",
            "upload_id": "up-1",
            "offset": 0
        }));
        assert!(missing_data.is_err());

        let missing_offset = serde_json::from_value::<ClientMessage>(json!({
            "type": "upload_chunk",
            "upload_id": "up-1",
            "data": "aGVsbG8="
        }));
        assert!(missing_offset.is_err());
    }
}
