//! Keepalive ping/pong protocol tests for the CRDT sync server.

#[test]
fn keepalive_constants_are_correct() {
    assert_eq!(
        super::super::PING_INTERVAL_SECS,
        30,
        "Ping interval must be 30 seconds"
    );
    assert_eq!(
        super::super::PONG_TIMEOUT_SECS,
        60,
        "Pong timeout must be 60 seconds"
    );
}

#[test]
fn agent_mode_heartbeat_interval_unchanged() {
    assert_eq!(
        crate::agent_mode::SCAN_INTERVAL_SECS,
        15,
        "Agent-mode heartbeat interval must remain 15s"
    );
}

#[test]
fn reconnect_backoff_constants_unchanged() {
    assert_eq!(
        super::super::RENDEZVOUS_ERROR_THRESHOLD,
        10,
        "Backoff threshold must still be 10"
    );
}

#[tokio::test]
async fn server_sends_ping_to_peer_at_interval() {
    use futures::{SinkExt, StreamExt};
    use std::time::Duration;
    use tokio::net::TcpListener;
    use tokio_tungstenite::tungstenite::Message as TMsg;
    use tokio_tungstenite::{accept_async, connect_async};

    let ping_ms = 100u64;
    let timeout_ms = 400u64;

    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    // Server task: keepalive sender with short intervals.
    tokio::spawn(async move {
        let (tcp, _) = listener.accept().await.unwrap();
        let ws = accept_async(tcp).await.unwrap();
        let (mut sink, mut stream) = ws.split();

        let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
        let mut ticker = tokio::time::interval_at(
            tokio::time::Instant::now() + Duration::from_millis(ping_ms),
            Duration::from_millis(ping_ms),
        );

        loop {
            tokio::select! {
                _ = ticker.tick() => {
                    if tokio::time::Instant::now() >= pong_deadline { break; }
                    if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { break; }
                }
                frame = stream.next() => {
                    match frame {
                        Some(Ok(TMsg::Pong(_))) => {
                            pong_deadline = tokio::time::Instant::now()
                                + Duration::from_millis(timeout_ms);
                        }
                        None | Some(Err(_)) => break,
                        _ => {}
                    }
                }
            }
        }
    });

    let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap();
    let (_sink_c, mut stream_c) = ws_client.split();

    // Wait for more than one ping interval.
    tokio::time::sleep(Duration::from_millis(ping_ms * 2)).await;

    // Client should receive a Ping from the server.
    let frame = tokio::time::timeout(Duration::from_millis(200), stream_c.next()).await;
    let got_ping = matches!(frame, Ok(Some(Ok(TMsg::Ping(_)))));
    assert!(
        got_ping,
        "Client must receive a Ping frame from the server after the ping interval"
    );
}

#[tokio::test]
async fn client_sends_ping_to_server_at_interval() {
    use futures::{SinkExt, StreamExt};
    use std::time::Duration;
    use tokio::net::TcpListener;
    use tokio_tungstenite::tungstenite::Message as TMsg;
    use tokio_tungstenite::{accept_async, connect_async};

    let ping_ms = 100u64;
    let timeout_ms = 400u64;

    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let (ping_tx, ping_rx) = tokio::sync::oneshot::channel::<()>();

    // Server task: wait for the first Ping the client sends.
    tokio::spawn(async move {
        let (tcp, _) = listener.accept().await.unwrap();
        let ws = accept_async(tcp).await.unwrap();
        let (_sink, mut stream) = ws.split();
        loop {
            match stream.next().await {
                Some(Ok(TMsg::Ping(_))) => {
                    let _ = ping_tx.send(());
                    break;
                }
                Some(Ok(_)) => continue,
                _ => break,
            }
        }
    });

    let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap();
    let (mut sink_c, mut stream_c) = ws_client.split();

    // Client keepalive task.
    tokio::spawn(async move {
        let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
        let mut ticker = tokio::time::interval_at(
            tokio::time::Instant::now() + Duration::from_millis(ping_ms),
            Duration::from_millis(ping_ms),
        );
        loop {
            tokio::select! {
                _ = ticker.tick() => {
                    if tokio::time::Instant::now() >= pong_deadline { break; }
                    if sink_c.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { break; }
                }
                frame = stream_c.next() => {
                    match frame {
                        Some(Ok(TMsg::Pong(_))) => {
                            pong_deadline = tokio::time::Instant::now()
                                + Duration::from_millis(timeout_ms);
                        }
                        None | Some(Err(_)) => break,
                        _ => {}
                    }
                }
            }
        }
    });

    let result = tokio::time::timeout(Duration::from_millis(ping_ms * 3), ping_rx).await;
    assert!(
        result.is_ok(),
        "Server must receive a Ping from the client after the ping interval"
    );
}

#[tokio::test]
async fn keepalive_disconnects_when_pong_withheld() {
    use futures::{SinkExt, StreamExt};
    use std::time::Duration;
    use tokio::net::TcpListener;
    use tokio_tungstenite::tungstenite::Message as TMsg;
    use tokio_tungstenite::{accept_async, connect_async};

    let ping_ms = 100u64;
    let timeout_ms = 250u64;

    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let (done_tx, done_rx) = tokio::sync::oneshot::channel::<bool>();

    // Server: sends Pings, never receives Pong (client swallows all).
    tokio::spawn(async move {
        let (tcp, _) = listener.accept().await.unwrap();
        let ws = accept_async(tcp).await.unwrap();
        let (mut sink, mut stream) = ws.split();

        let pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
        let mut ticker = tokio::time::interval_at(
            tokio::time::Instant::now() + Duration::from_millis(ping_ms),
            Duration::from_millis(ping_ms),
        );

        let timed_out = loop {
            tokio::select! {
                _ = ticker.tick() => {
                    if tokio::time::Instant::now() >= pong_deadline { break true; }
                    if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() {
                        break false;
                    }
                }
                frame = stream.next() => {
                    match frame {
                        Some(Ok(_)) => {} // swallow — no Pong sent
                        _ => break false,
                    }
                }
            }
        };
        let _ = done_tx.send(timed_out);
    });

    // Client: connect but never respond to Pings.
    let (_ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap();

    let result =
        tokio::time::timeout(Duration::from_millis(timeout_ms + ping_ms * 3), done_rx).await;
    let timed_out = result
        .expect("Server must report within expected wall-clock time")
        .expect("oneshot intact");

    assert!(
        timed_out,
        "Server must disconnect on keepalive timeout when Pong is withheld"
    );
}

#[tokio::test]
async fn keepalive_connection_survives_with_pong_responses() {
    use futures::{SinkExt, StreamExt};
    use std::time::Duration;
    use tokio::net::TcpListener;
    use tokio_tungstenite::tungstenite::Message as TMsg;
    use tokio_tungstenite::{accept_async, connect_async};

    let ping_ms = 100u64;
    // Generous timeout so the server's pong-deadline cannot fire within the
    // test window even under CI scheduler jitter (the previous 250ms value
    // gave only ~60ms of slack past the test's await window, which was a
    // recurring source of flakes on CI).
    let timeout_ms = 2000u64;

    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let (result_tx, result_rx) = tokio::sync::oneshot::channel::<bool>();

    // Server: sends Pings, resets deadline on Pong.
    tokio::spawn(async move {
        let (tcp, _) = listener.accept().await.unwrap();
        let ws = accept_async(tcp).await.unwrap();
        let (mut sink, mut stream) = ws.split();

        let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
        let mut ticker = tokio::time::interval_at(
            tokio::time::Instant::now() + Duration::from_millis(ping_ms),
            Duration::from_millis(ping_ms),
        );

        let timed_out = loop {
            tokio::select! {
                _ = ticker.tick() => {
                    if tokio::time::Instant::now() >= pong_deadline { break true; }
                    if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() {
                        break false;
                    }
                }
                frame = stream.next() => {
                    match frame {
                        Some(Ok(TMsg::Pong(_))) => {
                            pong_deadline = tokio::time::Instant::now()
                                + Duration::from_millis(timeout_ms);
                        }
                        None | Some(Err(_)) => break false, // clean close
                        _ => {}
                    }
                }
            }
        };
        let _ = result_tx.send(timed_out);
    });

    let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap();
    let (mut sink_c, mut stream_c) = ws_client.split();

    // Client: respond to every Ping with Pong for several intervals.
    let respond_task = tokio::spawn(async move {
        while let Some(Ok(msg)) = stream_c.next().await {
            if let TMsg::Ping(data) = msg
                && sink_c.send(TMsg::Pong(data)).await.is_err()
            {
                break;
            }
        }
    });

    // Run for a few intervals, then drop the client.
    tokio::time::sleep(Duration::from_millis(ping_ms * 3)).await;
    respond_task.abort();

    let result = tokio::time::timeout(Duration::from_millis(200), result_rx).await;
    let timed_out = result.unwrap_or(Ok(false)).unwrap_or(false);
    assert!(
        !timed_out,
        "Server must NOT timeout when the client responds to Pings with Pongs"
    );
}

#[tokio::test]
async fn two_node_pong_swallow_causes_disconnect_within_timeout() {
    use futures::{SinkExt, StreamExt};
    use std::time::Duration;
    use tokio::net::TcpListener;
    use tokio_tungstenite::tungstenite::Message as TMsg;
    use tokio_tungstenite::{accept_async, connect_async};

    let ping_ms = 100u64;
    let timeout_ms = 250u64;

    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    // Node A (listener): sends Pings, never receives Pong.
    let (a_done_tx, a_done_rx) = tokio::sync::oneshot::channel::<bool>();
    tokio::spawn(async move {
        let (tcp, _) = listener.accept().await.unwrap();
        let ws = accept_async(tcp).await.unwrap();
        let (mut sink, mut stream) = ws.split();

        let pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms);
        let mut ticker = tokio::time::interval_at(
            tokio::time::Instant::now() + Duration::from_millis(ping_ms),
            Duration::from_millis(ping_ms),
        );

        let timed_out = loop {
            tokio::select! {
                _ = ticker.tick() => {
                    if tokio::time::Instant::now() >= pong_deadline { break true; }
                    if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() {
                        break false;
                    }
                }
                frame = stream.next() => {
                    match frame {
                        Some(Ok(_)) => {} // swallow all frames
                        _ => break false,
                    }
                }
            }
        };
        let _ = a_done_tx.send(timed_out);
    });

    // Node B: connects, drains frames silently (swallows Pings, never pongs).
    let (ws_b, _) = connect_async(format!("ws://{addr}")).await.unwrap();
    let (_sink_b, mut stream_b) = ws_b.split();
    tokio::spawn(async move { while let Some(Ok(_)) = stream_b.next().await {} });

    let result =
        tokio::time::timeout(Duration::from_millis(timeout_ms + ping_ms * 3), a_done_rx).await;
    let timed_out = result
        .expect("Node A must report within expected wall-clock time")
        .expect("channel intact");

    assert!(
        timed_out,
        "Node A must disconnect due to keepalive timeout when Node B swallows Pongs"
    );
}
