//! Tunnel frame protocol — mirrors `houston-relay/src/types.ts`.
//!
//! Every message between the desktop engine and the relay Durable Object
//! is a JSON object with a `kind` discriminator. Keep this in lockstep
//! with the TypeScript twin — both sides must agree on every variant.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelFrame {
    HttpRequest(HttpRequestFrame),
    HttpResponse(HttpResponseFrame),
    WsOpen(WsOpenFrame),
    WsOpenAck(WsOpenAckFrame),
    WsMessage(WsMessageFrame),
    WsClose(WsCloseFrame),
    PairRequest(PairRequestFrame),
    PairResponse(PairResponseFrame),
    Ping(PingFrame),
    Pong(PongFrame),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpRequestFrame {
    pub req_id: String,
    pub method: String,
    pub path: String,
    pub headers: HashMap<String, String>,
    /// base64; None for empty body.
    pub body: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpResponseFrame {
    pub req_id: String,
    pub status: u16,
    pub headers: HashMap<String, String>,
    pub body: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WsOpenFrame {
    pub ws_id: String,
    pub path: String,
    pub headers: HashMap<String, String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WsOpenAckFrame {
    pub ws_id: String,
    pub ok: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<u16>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WsMessageFrame {
    pub ws_id: String,
    /// "c2s" (mobile→engine) or "s2c" (engine→mobile).
    pub dir: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub binary: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WsCloseFrame {
    pub ws_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub code: Option<u16>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PairRequestFrame {
    pub req_id: String,
    pub code: String,
    pub device_label: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PairResponseFrame {
    pub req_id: String,
    pub ok: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub engine_token: Option<String>,
    /// Debug hint. Clients should switch on [`code`] instead.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// Machine-readable failure classification. Mirror of
    /// `houston-relay/src/types.ts::PairErrorCode`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub code: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PingFrame {
    pub ts: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PongFrame {
    pub ts: i64,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn http_request_roundtrip() {
        let frame = TunnelFrame::HttpRequest(HttpRequestFrame {
            req_id: "r1".into(),
            method: "GET".into(),
            path: "/v1/health".into(),
            headers: HashMap::new(),
            body: None,
        });
        let s = serde_json::to_string(&frame).unwrap();
        assert!(s.contains("\"kind\":\"http_request\""));
        let parsed: TunnelFrame = serde_json::from_str(&s).unwrap();
        matches!(parsed, TunnelFrame::HttpRequest(_));
    }

    #[test]
    fn ws_message_serde_uses_camel_case_dir() {
        let frame = TunnelFrame::WsMessage(WsMessageFrame {
            ws_id: "w1".into(),
            dir: "s2c".into(),
            text: Some("hello".into()),
            binary: None,
        });
        let s = serde_json::to_string(&frame).unwrap();
        assert!(s.contains("\"wsId\":\"w1\""));
        assert!(s.contains("\"dir\":\"s2c\""));
    }

    #[test]
    fn pair_request_matches_relay_shape() {
        let s = r#"{"kind":"pair_request","reqId":"r","code":"abc-123","deviceLabel":"iPhone"}"#;
        let parsed: TunnelFrame = serde_json::from_str(s).unwrap();
        matches!(parsed, TunnelFrame::PairRequest(_));
    }

    #[test]
    fn ping_roundtrip() {
        let s = serde_json::to_string(&TunnelFrame::Ping(PingFrame { ts: 42 })).unwrap();
        assert!(s.contains("\"kind\":\"ping\""));
    }
}
