use std::net::Ipv4Addr;
use std::time::Instant;

mod common;
use common::TestRtc;
use common::init_crypto_default;
use common::init_log;
use common::negotiate;
use common::progress;
use common::{extract_sctp_init, remove_sctp_init, replace_sctp_init};
use str0m::Rtc;
use str0m::change::SdpOffer;
use str0m::format::Codec;
use str0m::format::CodecSpec;
use str0m::format::FormatParams;
use str0m::format::PayloadParams;
use str0m::media::Direction;
use str0m::media::Frequency;
use str0m::media::MediaKind;
use str0m::rtp::{Extension, ExtensionMap};
use str0m::{Event, RtcError};
use tracing::Span;
use tracing::info_span;

#[test]
pub fn change_default_pt() {
    init_log();
    init_crypto_default();

    // First proposed PT is 100, R side adjusts its default from 102 -> 100
    let (l, r) = with_params(
        //
        info_span!("L"),
        &[opus(100)],
        info_span!("R"),
        &[opus(102)],
    );

    // Test left side.
    assert_eq!(&[opus(100)], &**l.codec_config());
    assert!(l.codec_config()[0]._is_locked());

    // Test right side.
    assert_eq!(&[opus(100)], &**r.codec_config());
    assert!(r.codec_config()[0]._is_locked());
}

#[test]
pub fn answer_change_order() {
    init_log();
    init_crypto_default();

    // First proposed PT are 100/102, but R side has a different order.
    let (l, r) = with_params(
        //
        info_span!("L"),
        &[vp8(100), h264(102)],
        info_span!("R"),
        &[h264(96), vp8(98)],
    );

    let mid = l._mids()[0];

    // Test left side.
    assert_eq!(&[vp8(100), h264(102)], &**l.codec_config());
    assert!(l.codec_config().iter().all(|p| p._is_locked()));
    assert_eq!(
        l.media(mid).unwrap().remote_pts(),
        // R side has expressed its preference order, but amended the PT to match the OFFER.
        &[102.into(), 100.into()]
    );

    // Test right side.
    assert_eq!(&[h264(102), vp8(100)], &**r.codec_config());
    assert!(r.codec_config().iter().all(|p| p._is_locked()));
    assert_eq!(
        r.media(mid).unwrap().remote_pts(),
        // OFFER straight up.
        &[100.into(), 102.into()]
    );
}

#[test]
pub fn answer_narrow() {
    init_log();
    init_crypto_default();

    // First proposed PT are 100/102, the R side removes unsupported ones.
    let (l, r) = with_params(
        //
        info_span!("L"),
        &[vp8(100), h264(102)],
        info_span!("R"),
        &[h264(96)],
    );

    let mid = l._mids()[0];

    // Test left side.
    assert_eq!(&[vp8(100), h264(102)], &**l.codec_config());
    assert_eq!(
        l.codec_config()
            .iter()
            .map(|p| p._is_locked())
            .collect::<Vec<_>>(),
        // VP8 is not locked, H264 is
        vec![false, true]
    );
    assert_eq!(
        l.media(mid).unwrap().remote_pts(),
        // R side has narrowed the remote_pts to only the supported.
        &[102.into()]
    );

    // Test right side. The PT of h264 is updated with what L OFFERed.
    assert_eq!(&[h264(102)], &**r.codec_config());
    assert!(r.codec_config().iter().all(|p| p._is_locked()));
    assert_eq!(
        r.media(mid).unwrap().remote_pts(),
        // OFFER straight up.
        &[102.into()]
    );
}

#[test]
pub fn answer_no_match() {
    init_log();
    init_crypto_default();

    // L has one codec, and that is not matched by R. This should disable the m-line.
    let mut l = build_params(info_span!("L"), &[vp8(100)]);
    let mut r = build_params(info_span!("R"), &[h264(96)]);

    // Create offer from L
    let (offer, pending) = l.span.in_scope(|| {
        let mut change = l.rtc.sdp_api();
        change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
        change.apply().unwrap()
    });

    // R accepts the offer and generates an answer
    let answer = r
        .span
        .in_scope(|| r.rtc.sdp_api().accept_offer(offer).unwrap());

    // Check that the answer SDP has port 0 for the rejected m-line
    let answer_sdp = answer.to_sdp_string();
    assert!(
        answer_sdp.contains("m=video 0 "),
        "Expected rejected m-line with port 0, got:\n{}",
        answer_sdp
    );

    // Rejected m-line should not be in the BUNDLE group
    assert!(
        !answer_sdp.contains("a=group:BUNDLE 0"),
        "Rejected m-line should not be in BUNDLE group:\n{}",
        answer_sdp
    );

    // L accepts the answer
    l.span.in_scope(|| {
        l.rtc.sdp_api().accept_answer(pending, answer).unwrap();
    });

    let mid = l._mids()[0];

    // Test left side. Nothing has changed. The codec is not locked.
    assert_eq!(&[vp8(100)], &**l.codec_config());
    assert!(!l.codec_config()[0]._is_locked());
    // No remote PTs.
    assert_eq!(l.media(mid).unwrap().remote_pts(), &[]);

    // After receiving a rejected answer (port=0), direction should be Inactive.
    assert_eq!(
        l.media(mid).unwrap().direction(),
        Direction::Inactive,
        "Offerer's m-line should be Inactive after receiving rejected answer"
    );

    // Test right side. Nothing has changed. The codec is not locked.
    assert_eq!(&[h264(96)], &**r.codec_config());
    assert!(!r.codec_config()[0]._is_locked());
    // No remote PTs.
    assert_eq!(r.media(mid).unwrap().remote_pts(), &[]);
}

#[test]
pub fn answer_different_pt_to_offer() {
    init_log();
    init_crypto_default();

    // This test case checks a scenario happening with Firefox.
    // 1. SDP -> FF: OFFER to sendonly VP8 PT 96.
    // 2. FF -> SDP: ANSWER to recvonly VP8 PT 96. (confirming the desired according to spec).
    // 3. FF -> SDP: OFFER to sendonly VP8 PT 120. This is legal, since PT 120 is just a suggestion.
    // 4. SDP -> FF: ANSWER <assert> we need to force PT 96 (and not remap to 120).

    // L has one codec, and that is not matched by R. This should disable the m-line.
    let (mut l, mut r) = with_params(
        //
        info_span!("L"),
        &[vp8(96)],
        info_span!("R"),
        &[vp8(120)],
    );

    // Both sides are 96.
    assert_eq!(&[vp8(96)], &**l.codec_config());
    assert_eq!(&[vp8(96)], &**r.codec_config());

    let mut change = r.sdp_api();
    change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None);
    let (offer, _pending) = change.apply().unwrap();

    let sdp = offer.to_sdp_string();

    let mut split = sdp.split("m=video 9 UDP/TLS/RTP/SAVPF 96");
    let prelude = split.next().unwrap();
    let mline_1 = split.next().unwrap();
    let mline_2 = split.next().unwrap();

    // m=video 9 UDP/TLS/RTP/SAVPF 96

    // a=rtpmap:96 VP8/90000
    // a=rtcp-fb:96 transport-cc
    // a=rtcp-fb:96 ccm fir
    // a=rtcp-fb:96 nack
    // a=rtcp-fb:96 nack pli

    let mline_2 = mline_2.replace("a=rtpmap:96", "a=rtpmap:120");
    let mline_2 = mline_2.replace("a=rtcp-fb:96", "a=rtcp-fb:120");

    let munged = format!(
        "{}m=video 9 UDP/TLS/RTP/SAVPF 96{}m=video 9 UDP/TLS/RTP/SAVPF 120{}",
        prelude, mline_1, mline_2
    );

    let offer = SdpOffer::from_sdp_string(&munged).unwrap();

    let answer = l.sdp_api().accept_offer(offer).unwrap();

    // L remains 96.
    assert_eq!(&[vp8(96)], &**l.codec_config());

    let sdp = answer.to_sdp_string();

    println!("{}", sdp);

    // All 3 m-lines should be with 96, ignoring the 120.
    let mut split = sdp.split("m=video 9 UDP/TLS/RTP/SAVPF 96");
    split.next().expect("SDP answer prelude"); // prelude
    split.next().expect("First m-line"); // m-line 1
    split.next().expect("Second m-line"); // m-line 2
}

#[test]
fn answer_remaps() {
    init_log();
    init_crypto_default();

    use Extension::*;

    let exts_l = ExtensionMap::standard();
    let mut exts_r = ExtensionMap::empty();

    // Not same number as the default.
    exts_r.set(14, TransportSequenceNumber);
    exts_r.set(12, AudioLevel);

    // This negotiates a video track.
    let (l, r) = with_exts(exts_l, exts_r);

    let v_l: Vec<_> = l._exts().iter_video().collect();
    let v_r: Vec<_> = r._exts().iter_video().collect();
    let a_l: Vec<_> = l._exts().iter_audio().collect();
    let a_r: Vec<_> = r._exts().iter_audio().collect();

    // L locks 3 and changes it from 14
    // R keeps 3 and changes it from 14.
    assert_eq!(
        v_l,
        vec![
            (2, &AbsoluteSendTime),
            (3, &TransportSequenceNumber),
            (4, &RtpMid),
            (10, &RtpStreamId),
            (11, &RepairedRtpStreamId),
            (13, &VideoOrientation)
        ]
    );
    assert_eq!(v_r, vec![(3, &TransportSequenceNumber)]);

    // L audio exts are left untouched (the defaults), also ones shared with video.
    // R audio exts are left untouched.
    assert_eq!(
        a_l,
        vec![
            (1, &AudioLevel),
            (2, &AbsoluteSendTime),
            (3, &TransportSequenceNumber),
            (4, &RtpMid),
            (10, &RtpStreamId),
            (11, &RepairedRtpStreamId)
        ]
    );
    assert_eq!(a_r, vec![(3, &TransportSequenceNumber), (12, &AudioLevel)]);
}

#[test]
fn offers_unsupported_extension() {
    init_log();
    init_crypto_default();

    use Extension::*;

    let mut exts_l = ExtensionMap::empty();
    let mut exts_r = ExtensionMap::empty();

    // They agree on VideoOrientation, but R has different number
    // L has introduces an extension R doesn't support.
    exts_l.set(3, VideoOrientation);
    exts_l.set(8, ColorSpace);

    exts_r.set(5, VideoOrientation);

    // This negotiates a video track.
    let (l, r) = with_exts(exts_l, exts_r);

    assert_eq!(
        l._exts().iter_video().collect::<Vec<_>>(),
        vec![(3, &VideoOrientation), (8, &ColorSpace)]
    );
    assert_eq!(
        r._exts().iter_video().collect::<Vec<_>>(),
        vec![(3, &VideoOrientation)]
    );

    let mid = l._mids()[0];
    let m_l = l.media(mid).unwrap();
    let m_r = r.media(mid).unwrap();

    // L media did not get R unsupported ext, despite that being configured on session.
    assert_eq!(
        m_l.remote_extmap().iter_video().collect::<Vec<_>>(),
        vec![(3, &VideoOrientation)]
    );

    // R media didn't get the unsupported ext.
    assert_eq!(
        m_r.remote_extmap().iter_video().collect::<Vec<_>>(),
        vec![(3, &VideoOrientation)]
    );
}

#[test]
fn non_media_creator_cannot_change_inactive_to_recvonly() {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let (mut l, mut r) = (
        TestRtc::new_with_rtc(
            info_span!("L"),
            Rtc::builder().clear_codecs().enable_vp8(true).build(now),
        ),
        TestRtc::new_with_rtc(
            info_span!("R"),
            Rtc::builder().clear_codecs().enable_vp8(true).build(now),
        ),
    );

    negotiate(&mut l, &mut r, |change| {
        change.add_media(MediaKind::Video, Direction::Inactive, None, None, None);
    });
    let mid = r._mids()[0];
    let m_r = r.media(mid).unwrap();
    assert_eq!(m_r.direction(), Direction::Inactive);

    negotiate(&mut r, &mut l, |change| {
        change.set_direction(mid, Direction::RecvOnly);
    });

    // r didn't open the media and isn't allowed to change it from inactive to recvonly.
    let m_r = r.media(mid).unwrap();
    assert_eq!(m_r.direction(), Direction::Inactive);

    let m_l = l.media(mid).unwrap();
    assert_eq!(m_l.direction(), Direction::Inactive);
}

#[test]
fn media_creator_can_change_inactive_to_recvonly() {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let (mut l, mut r) = (
        TestRtc::new_with_rtc(
            info_span!("L"),
            Rtc::builder().clear_codecs().enable_vp8(true).build(now),
        ),
        TestRtc::new_with_rtc(
            info_span!("R"),
            Rtc::builder().clear_codecs().enable_vp8(true).build(now),
        ),
    );

    negotiate(&mut l, &mut r, |change| {
        change.add_media(MediaKind::Video, Direction::Inactive, None, None, None);
    });
    let mid = r._mids()[0];
    let m_r = r.media(mid).unwrap();
    assert_eq!(m_r.direction(), Direction::Inactive);

    negotiate(&mut l, &mut r, |change| {
        change.set_direction(mid, Direction::RecvOnly);
    });

    // r didn't open the media and isn't allowed to change it from inactive to recvonly.
    let m_l = l.media(mid).unwrap();
    assert_eq!(m_l.direction(), Direction::RecvOnly);

    let m_r = r.media(mid).unwrap();
    assert_eq!(m_r.direction(), Direction::SendOnly);
}

/// Test that max-bundle offers (where secondary m-lines have port=0) are handled correctly.
/// In max-bundle format (RFC 8843), m-lines after the first one use port=0 to indicate
/// they share transport with the first m-line. This is NOT a rejection.
#[test]
fn max_bundle_offer_accepted() {
    init_log();
    init_crypto_default();

    // Create an Rtc that supports both opus and VP8
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder()
            .clear_codecs()
            .enable_opus(true)
            .enable_vp8(true)
            .build(Instant::now()),
    );

    // This is a max-bundle offer where the video m-line has port=0
    // to indicate it shares transport with the audio m-line.
    let max_bundle_offer = "\
        v=0\r\n\
        o=- 123456789 2 IN IP4 127.0.0.1\r\n\
        s=-\r\n\
        t=0 0\r\n\
        a=group:BUNDLE 0 1\r\n\
        a=msid-semantic:WMS *\r\n\
        a=fingerprint:sha-256 00:00:00:00:00:00:00:00\
        :00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\
        :00:00:00:00:00:00:00:00:00\r\n\
        a=ice-ufrag:testufrag\r\n\
        a=ice-pwd:testpassword12345678\r\n\
        a=setup:actpass\r\n\
        m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n\
        c=IN IP4 0.0.0.0\r\n\
        a=mid:0\r\n\
        a=sendrecv\r\n\
        a=rtcp-mux\r\n\
        a=rtpmap:111 opus/48000/2\r\n\
        a=fmtp:111 minptime=10;useinbandfec=1\r\n\
        m=video 0 UDP/TLS/RTP/SAVPF 96\r\n\
        c=IN IP4 0.0.0.0\r\n\
        a=mid:1\r\n\
        a=sendrecv\r\n\
        a=rtcp-mux\r\n\
        a=rtpmap:96 VP8/90000\r\n\
        ";

    // Parse and accept the max-bundle offer
    let offer = SdpOffer::from_sdp_string(max_bundle_offer).expect("should parse");
    let answer = r.rtc.sdp_api().accept_offer(offer).expect("should accept");

    // Verify the answer includes both m-lines in the BUNDLE group
    let answer_sdp = answer.to_sdp_string();
    assert!(
        answer_sdp.contains("a=group:BUNDLE 0 1"),
        "Answer should have both MIDs in BUNDLE group, got:\n{}",
        answer_sdp
    );

    // Both m-lines should be active (not rejected)
    let mids = r._mids();
    assert_eq!(mids.len(), 2, "Should have 2 media lines");

    let audio = r.media(mids[0]).unwrap();
    assert_eq!(
        audio.direction(),
        Direction::SendRecv,
        "Audio should be SendRecv, not disabled"
    );

    let video = r.media(mids[1]).unwrap();
    assert_eq!(
        video.direction(),
        Direction::SendRecv,
        "Video should be SendRecv even though offer had port=0 (max-bundle)"
    );
}

// ---------------------------------------------------------------------------
// SNAP (SCTP Negotiation Acceleration Protocol) tests
// ---------------------------------------------------------------------------

/// Both sides enable SNAP - offer and answer must contain `a=sctp-init`,
/// and a data-channel message round-trips successfully.
#[test]
fn snap_sdp_both_sides_enabled() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    // Manual offer/answer so we can inspect the SDP.
    let mut change = l.sdp_api();
    let cid = change.add_channel("snap-test".into());
    let (offer, pending) = change.apply().unwrap();

    let offer_sdp = offer.to_sdp_string();
    assert!(
        extract_sctp_init(&offer_sdp).is_some(),
        "Offer must contain a=sctp-init when SNAP is enabled"
    );

    let answer = r.rtc.sdp_api().accept_offer(offer)?;
    let answer_sdp = answer.to_sdp_string();
    assert!(
        extract_sctp_init(&answer_sdp).is_some(),
        "Answer must contain a=sctp-init when both sides enable SNAP"
    );

    l.rtc.sdp_api().accept_answer(pending, answer)?;

    // Drive to connected.
    for _ in 0..1000 {
        if l.is_connected() && r.is_connected() {
            break;
        }
        progress(&mut l, &mut r)?;
    }

    // Send a data-channel message and verify receipt.
    l.channel(cid).unwrap().write(false, b"hello-snap").unwrap();

    for _ in 0..200 {
        progress(&mut l, &mut r)?;
    }

    let received = r
        .events
        .iter()
        .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"hello-snap"));
    assert!(received, "Right side must receive data-channel message");

    Ok(())
}

/// Only the offerer enables SNAP. The answerer should reciprocate by including
/// `a=sctp-init` in its answer (per the draft, an endpoint that receives
/// `a=sctp-init` should respond with one).
#[test]
fn snap_sdp_offerer_only_enabled() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().build(now), // SNAP not explicitly enabled
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    let mut change = l.sdp_api();
    let cid = change.add_channel("snap-offerer-only".into());
    let (offer, pending) = change.apply().unwrap();

    let offer_sdp = offer.to_sdp_string();
    assert!(
        extract_sctp_init(&offer_sdp).is_some(),
        "Offer must contain a=sctp-init"
    );

    let answer = r.rtc.sdp_api().accept_offer(offer)?;
    let answer_sdp = answer.to_sdp_string();
    assert!(
        extract_sctp_init(&answer_sdp).is_some(),
        "Answerer should reciprocate a=sctp-init even without snap_enabled"
    );

    l.rtc.sdp_api().accept_answer(pending, answer)?;

    // Drive to connected and verify data channel works.
    for _ in 0..1000 {
        if l.is_connected() && r.is_connected() {
            break;
        }
        progress(&mut l, &mut r)?;
    }

    l.channel(cid)
        .unwrap()
        .write(false, b"offerer-snap")
        .unwrap();

    for _ in 0..200 {
        progress(&mut l, &mut r)?;
    }

    let received = r
        .events
        .iter()
        .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"offerer-snap"));
    assert!(received, "Data channel must work with offerer-only SNAP");

    Ok(())
}

#[test]
fn snap_sdp_missing_answer_falls_back_to_regular_handshake() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    let mut change = l.sdp_api();
    let cid = change.add_channel("snap-fallback".into());
    let (offer, pending) = change.apply().unwrap();
    let offer_sdp = offer.to_sdp_string();

    assert!(
        extract_sctp_init(&offer_sdp).is_some(),
        "Offer must contain a=sctp-init"
    );

    let stripped_offer = remove_sctp_init(&offer_sdp);
    let stripped_offer = SdpOffer::from_sdp_string(&stripped_offer).unwrap();

    let answer = r.rtc.sdp_api().accept_offer(stripped_offer)?;
    let answer_sdp = answer.to_sdp_string();
    assert!(
        extract_sctp_init(&answer_sdp).is_none(),
        "Answer must omit a=sctp-init when the remote does not negotiate SNAP"
    );

    l.rtc.sdp_api().accept_answer(pending, answer)?;

    let mut ready = false;
    for _ in 0..200 {
        if l.is_connected() && r.is_connected() && l.channel(cid).is_some() {
            ready = true;
            break;
        }
        progress(&mut l, &mut r)?;
    }

    assert!(
        ready,
        "Fallback path must establish both peers and the local data channel"
    );

    l.channel(cid)
        .expect("local channel to exist after fallback")
        .write(false, b"snap-fallback-msg")
        .unwrap();

    for _ in 0..200 {
        progress(&mut l, &mut r)?;
    }

    let received = r
        .events
        .iter()
        .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"snap-fallback-msg"));
    assert!(
        received,
        "Data channel must work after SNAP falls back to regular SCTP"
    );

    Ok(())
}

/// Neither side enables SNAP - SDP must NOT contain `a=sctp-init`, and the
/// normal SCTP 4-way handshake is used instead.
#[test]
fn snap_sdp_neither_enabled() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(info_span!("L"), Rtc::builder().build(now));
    let mut r = TestRtc::new_with_rtc(info_span!("R"), Rtc::builder().build(now));

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    let mut change = l.sdp_api();
    let cid = change.add_channel("no-snap".into());
    let (offer, pending) = change.apply().unwrap();

    let offer_sdp = offer.to_sdp_string();
    assert!(
        extract_sctp_init(&offer_sdp).is_none(),
        "Offer must NOT contain a=sctp-init when SNAP is disabled"
    );

    let answer = r.rtc.sdp_api().accept_offer(offer)?;
    let answer_sdp = answer.to_sdp_string();
    assert!(
        extract_sctp_init(&answer_sdp).is_none(),
        "Answer must NOT contain a=sctp-init when SNAP is disabled"
    );

    l.rtc.sdp_api().accept_answer(pending, answer)?;

    // Normal handshake should still work.
    for _ in 0..1000 {
        if l.is_connected() && r.is_connected() {
            break;
        }
        progress(&mut l, &mut r)?;
    }

    l.channel(cid)
        .unwrap()
        .write(false, b"no-snap-msg")
        .unwrap();

    for _ in 0..200 {
        progress(&mut l, &mut r)?;
    }

    let received = r
        .events
        .iter()
        .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"no-snap-msg"));
    assert!(received, "Data channel must work without SNAP");

    Ok(())
}

/// Section 5.6: Re-offer after SNAP establishment must re-send the same `a=sctp-init`
/// values. Verify that a re-negotiation preserves the cached `sctp-init` and
/// the data channel keeps working.
#[test]
fn snap_sdp_renegotiation_preserves_sctp_init() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    // Initial offer/answer with SNAP.
    let mut change = l.sdp_api();
    let cid = change.add_channel("snap-renego".into());
    let (offer, pending) = change.apply().unwrap();

    let initial_offer_init =
        extract_sctp_init(&offer.to_sdp_string()).expect("Initial offer must contain a=sctp-init");

    let answer = r.rtc.sdp_api().accept_offer(offer)?;
    let initial_answer_init = extract_sctp_init(&answer.to_sdp_string())
        .expect("Initial answer must contain a=sctp-init");

    l.rtc.sdp_api().accept_answer(pending, answer)?;

    // Drive to connected.
    for _ in 0..1000 {
        if l.is_connected() && r.is_connected() {
            break;
        }
        progress(&mut l, &mut r)?;
    }

    // Send a message before re-negotiation.
    l.channel(cid)
        .unwrap()
        .write(false, b"before-renego")
        .unwrap();
    for _ in 0..100 {
        progress(&mut l, &mut r)?;
    }

    // Re-offer: add a video track to trigger re-negotiation.
    let mut change = l.sdp_api();
    change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
    let (re_offer, re_pending) = change.apply().unwrap();

    let re_offer_init = extract_sctp_init(&re_offer.to_sdp_string());
    assert_eq!(
        re_offer_init.as_deref(),
        Some(initial_offer_init.as_str()),
        "Re-offer must contain the same a=sctp-init as the initial offer (Section 5.6)"
    );

    let re_answer = r.rtc.sdp_api().accept_offer(re_offer)?;
    let re_answer_init = extract_sctp_init(&re_answer.to_sdp_string());
    assert_eq!(
        re_answer_init.as_deref(),
        Some(initial_answer_init.as_str()),
        "Re-answer must contain the same a=sctp-init as the initial answer (Section 5.6)"
    );

    l.rtc.sdp_api().accept_answer(re_pending, re_answer)?;

    // Verify data channel still works after re-negotiation.
    l.channel(cid)
        .unwrap()
        .write(false, b"after-renego")
        .unwrap();
    for _ in 0..200 {
        progress(&mut l, &mut r)?;
    }

    let received_after = r
        .events
        .iter()
        .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"after-renego"));
    assert!(
        received_after,
        "Data channel must survive re-negotiation with SNAP"
    );

    Ok(())
}

#[test]
fn snap_sdp_renegotiation_changed_sctp_init_rejected() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    let mut change = l.sdp_api();
    change.add_channel("snap-change-reject".into());
    let (offer, pending) = change.apply().unwrap();
    let answer = r.rtc.sdp_api().accept_offer(offer)?;
    l.rtc.sdp_api().accept_answer(pending, answer)?;

    for _ in 0..1000 {
        if l.is_connected() && r.is_connected() {
            break;
        }
        progress(&mut l, &mut r)?;
    }

    let mut change = l.sdp_api();
    change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
    let (re_offer, _re_pending) = change.apply().unwrap();

    let re_offer_sdp = re_offer.to_sdp_string();
    let tampered = replace_sctp_init(&re_offer_sdp, "AQ==");
    let tampered_offer = SdpOffer::from_sdp_string(&tampered).unwrap();

    let err = r.rtc.sdp_api().accept_offer(tampered_offer).unwrap_err();
    assert!(
        err.to_string()
            .contains("Changed a=sctp-init for existing SCTP association"),
        "unexpected error: {err}"
    );

    Ok(())
}

#[test]
fn snap_sdp_renegotiation_missing_sctp_init_rejected() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    let mut change = l.sdp_api();
    change.add_channel("snap-missing-reject".into());
    let (offer, pending) = change.apply().unwrap();
    let answer = r.rtc.sdp_api().accept_offer(offer)?;
    l.rtc.sdp_api().accept_answer(pending, answer)?;

    for _ in 0..1000 {
        if l.is_connected() && r.is_connected() {
            break;
        }
        progress(&mut l, &mut r)?;
    }

    let mut change = l.sdp_api();
    change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
    let (re_offer, re_pending) = change.apply().unwrap();

    let re_answer = r.rtc.sdp_api().accept_offer(re_offer)?;
    let re_answer_sdp = re_answer.to_sdp_string();
    let tampered = remove_sctp_init(&re_answer_sdp);
    let tampered_answer = str0m::change::SdpAnswer::from_sdp_string(&tampered).unwrap();

    let err = l
        .rtc
        .sdp_api()
        .accept_answer(re_pending, tampered_answer)
        .unwrap_err();
    assert!(
        err.to_string()
            .contains("Missing a=sctp-init for established SNAP SCTP association"),
        "unexpected error: {err}"
    );

    Ok(())
}

/// Only the answerer enables SNAP - the offer has no `a=sctp-init`, so the
/// answerer must NOT inject one either. Normal 4-way handshake is used.
#[test]
fn snap_sdp_answerer_only_enabled() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().build(now), // SNAP disabled
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().set_snap_enabled(true).build(now), // SNAP enabled
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    let mut change = l.sdp_api();
    let cid = change.add_channel("answerer-only".into());
    let (offer, pending) = change.apply().unwrap();

    let offer_sdp = offer.to_sdp_string();
    assert!(
        extract_sctp_init(&offer_sdp).is_none(),
        "Offer must NOT contain a=sctp-init when offerer has SNAP disabled"
    );

    let answer = r.rtc.sdp_api().accept_offer(offer)?;
    let answer_sdp = answer.to_sdp_string();
    assert!(
        extract_sctp_init(&answer_sdp).is_none(),
        "Answer must NOT contain a=sctp-init when the offer didn't include one"
    );

    l.rtc.sdp_api().accept_answer(pending, answer)?;

    // Normal handshake should work.
    for _ in 0..1000 {
        if l.is_connected() && r.is_connected() {
            break;
        }
        progress(&mut l, &mut r)?;
    }

    l.channel(cid)
        .unwrap()
        .write(false, b"answerer-only-msg")
        .unwrap();

    for _ in 0..200 {
        progress(&mut l, &mut r)?;
    }

    let received = r
        .events
        .iter()
        .any(|(_, e)| matches!(e, Event::ChannelData(data) if data.data == b"answerer-only-msg"));
    assert!(
        received,
        "Data channel must work when only answerer has SNAP enabled"
    );

    Ok(())
}

/// Malformed base64 in `a=sctp-init` is silently ignored by the SDP parser
/// (treated as an unknown attribute). The answerer should proceed without SNAP.
#[test]
fn snap_sdp_malformed_base64_ignored() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    let mut change = l.sdp_api();
    let _cid = change.add_channel("bad-b64".into());
    let (offer, _pending) = change.apply().unwrap();

    let offer_sdp = offer.to_sdp_string();
    // Replace the valid base64 with garbage - it's parsed as a string, but fails
    // to decode at runtime, causing SNAP to gracefully degrade.
    let tampered = replace_sctp_init(&offer_sdp, "!!!not-valid-base64!!!");
    let tampered_offer = SdpOffer::from_sdp_string(&tampered).unwrap();

    // The answerer should NOT reciprocate since the sctp-init failed to decode.
    let answer = r.rtc.sdp_api().accept_offer(tampered_offer)?;
    let answer_sdp = answer.to_sdp_string();
    assert!(
        extract_sctp_init(&answer_sdp).is_none(),
        "Answer must NOT contain a=sctp-init when the offer's was malformed"
    );

    Ok(())
}

/// Reverse direction: R re-offers with a tampered `a=sctp-init` and L
/// (the original offerer) rejects it when processing the answer.
#[test]
fn snap_sdp_renegotiation_reverse_direction_changed_sctp_init_rejected() -> Result<(), RtcError> {
    init_log();
    init_crypto_default();

    let now = Instant::now();
    let mut l = TestRtc::new_with_rtc(
        info_span!("L"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );
    let mut r = TestRtc::new_with_rtc(
        info_span!("R"),
        Rtc::builder().set_snap_enabled(true).build(now),
    );

    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());

    // Initial offer/answer from L -> R.
    let mut change = l.sdp_api();
    change.add_channel("snap-reverse".into());
    let (offer, pending) = change.apply().unwrap();
    let answer = r.rtc.sdp_api().accept_offer(offer)?;
    l.rtc.sdp_api().accept_answer(pending, answer)?;

    // Drive to connected.
    for _ in 0..1000 {
        if l.is_connected() && r.is_connected() {
            break;
        }
        progress(&mut l, &mut r)?;
    }

    // R re-offers (reverse direction).
    let mut change = r.sdp_api();
    change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
    let (re_offer, re_pending) = change.apply().unwrap();

    // L answers the re-offer normally.
    let re_answer = l.rtc.sdp_api().accept_offer(re_offer)?;

    // Tamper with the answer's sctp-init before R processes it.
    let re_answer_sdp = re_answer.to_sdp_string();
    let tampered = replace_sctp_init(&re_answer_sdp, "AQ==");
    let tampered_answer = str0m::change::SdpAnswer::from_sdp_string(&tampered).unwrap();

    let err = r
        .rtc
        .sdp_api()
        .accept_answer(re_pending, tampered_answer)
        .unwrap_err();
    assert!(
        err.to_string()
            .contains("Changed a=sctp-init for existing SCTP association"),
        "unexpected error: {err}"
    );

    Ok(())
}

fn with_params(
    span_l: Span,
    params_l: &[PayloadParams],
    span_r: Span,
    params_r: &[PayloadParams],
) -> (TestRtc, TestRtc) {
    let mut l = build_params(span_l, params_l);
    let mut r = build_params(span_r, params_r);

    let kind = params_l
        .first()
        .map(|p| p.spec().codec.kind())
        .unwrap_or(MediaKind::Audio);

    negotiate(&mut l, &mut r, |change| {
        change.add_media(kind, Direction::SendRecv, None, None, None);
    });

    (l, r)
}

fn with_exts(exts_l: ExtensionMap, exts_r: ExtensionMap) -> (TestRtc, TestRtc) {
    let mut l = build_exts(info_span!("L"), exts_l);
    let mut r = build_exts(info_span!("R"), exts_r);

    negotiate(&mut l, &mut r, |change| {
        change.add_media(MediaKind::Video, Direction::SendRecv, None, None, None);
    });

    (l, r)
}

fn build_params(span: Span, params: &[PayloadParams]) -> TestRtc {
    let mut b = Rtc::builder().clear_codecs();
    let config = b.codec_config();
    for p in params {
        config.add_config(
            p.pt(),
            p.resend(),
            p.spec().codec,
            p.spec().clock_rate,
            p.spec().channels,
            p.spec().format,
        );
    }
    let rtc = b.build(Instant::now());

    TestRtc::new_with_rtc(span, rtc)
}

fn build_exts(span: Span, exts: ExtensionMap) -> TestRtc {
    let mut b = Rtc::builder().clear_codecs();
    b = b.enable_vp8(true);
    let e = b.extension_map();
    *e = exts;
    let rtc = b.build(Instant::now());

    TestRtc::new_with_rtc(span, rtc)
}

fn opus(pt: u8) -> PayloadParams {
    PayloadParams::new(
        pt.into(),
        None,
        CodecSpec {
            codec: Codec::Opus,
            channels: Some(2),
            clock_rate: Frequency::FORTY_EIGHT_KHZ,
            format: FormatParams::default(),
        },
    )
}

fn vp8(pt: u8) -> PayloadParams {
    PayloadParams::new(
        pt.into(),
        None,
        CodecSpec {
            codec: Codec::Vp8,
            channels: None,
            clock_rate: Frequency::NINETY_KHZ,
            format: FormatParams::default(),
        },
    )
}

fn h264(pt: u8) -> PayloadParams {
    PayloadParams::new(
        pt.into(),
        None,
        CodecSpec {
            codec: Codec::H264,
            channels: None,
            clock_rate: Frequency::NINETY_KHZ,
            format: FormatParams::default(),
        },
    )
}
