//! Phase 3.5 integration tests for the dual-path QUIC race.
//!
//! The race takes a role (Acceptor or Dialer), a peer_direct_addr,
//! a relay_addr, and two SNI strings, then returns whichever QUIC
//! handshake completes first wrapped in a `QuinnTransport`. These
//! tests validate that:
//!
//! 1. On loopback with two real clients playing A + D roles, the
//!    direct path wins (fewer hops than relay).
//! 2. When the direct peer is dead (nothing listening) but the
//!    relay is up, the relay wins within the fallback window.
//! 3. When both paths are dead, the race errors cleanly rather
//!    than hanging forever.
//!
//! The "relay" in these tests is a minimal mock that just accepts
//! an incoming QUIC connection and drops it — we don't need any
//! protocol handling, just a TCP-ish listen-and-accept.

use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;

use wzp_client::dual_path::{PeerCandidates, WinningPath, race};
use wzp_client::reflect::Role;
use wzp_transport::{create_endpoint, server_config};

/// Spin up a "relay-ish" mock server on loopback that accepts
/// incoming QUIC connections and does nothing with them. Used to
/// give the relay branch of the race a real target to dial.
/// Returns the bound address + a join handle (kept alive to keep
/// the endpoint up).
async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) {
    let _ = rustls::crypto::ring::default_provider().install_default();
    let (sc, _cert_der) = server_config();
    let bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
    let ep = create_endpoint(bind, Some(sc)).expect("relay endpoint");
    let addr = ep.local_addr().expect("local_addr");

    let handle = tokio::spawn(async move {
        // Accept loop — hold the connection alive for a short
        // while so the race result isn't killed by the peer
        // closing before the winning transport is returned.
        while let Some(incoming) = ep.accept().await {
            if let Ok(_conn) = incoming.await {
                tokio::time::sleep(Duration::from_secs(5)).await;
            }
        }
    });
    (addr, handle)
}

// -----------------------------------------------------------------------
// Test 1: direct path wins when both sides are up
// -----------------------------------------------------------------------
//
// Spawn a mock relay, then set up a two-client test where one
// client plays the Acceptor role and the other plays the Dialer
// role. The Dialer's `peer_direct_addr` is the Acceptor's listen
// address. Because the direct path is a single loopback hop and
// the relay dial also terminates on loopback, both complete
// essentially instantly — the `biased` tokio::select in race()
// should pick direct.

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dual_path_direct_wins_on_loopback() {
    let _ = rustls::crypto::ring::default_provider().install_default();
    let (relay_addr, _relay_handle) = spawn_mock_relay().await;

    // Acceptor task: run race(Role::Acceptor, peer_addr_placeholder, ...).
    // Since the acceptor doesn't dial, the peer_direct_addr arg is
    // unused on the direct branch but we still pass a placeholder
    // because the API takes one. Use a stub addr that would error
    // if it were ever dialed — proving the Acceptor really doesn't
    // reach it.
    let unused_addr: SocketAddr = "127.0.0.1:2".parse().unwrap();

    // We can't race both sides in the same task because each race
    // call has its own direct endpoint that needs to talk to the
    // OTHER side's endpoint. So spawn the Acceptor in a task and
    // let it expose its listen addr via a oneshot back to the test,
    // then run the Dialer in the test's main task.
    //
    // There's a chicken-and-egg issue: the Acceptor's listen addr
    // is only known after race() creates its endpoint. To avoid
    // reaching into race()'s internals, we instead play a slight
    // trick: create the Acceptor's endpoint ourselves (outside
    // race()) to learn its addr, spin up an accept loop on it
    // ourselves, and pass THAT addr as the Dialer's peer addr.
    // This tests the Dialer->Acceptor handshake end-to-end without
    // running the full race() on both sides.

    let (sc, _cert_der) = server_config();
    let acceptor_bind: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into();
    let acceptor_ep = create_endpoint(acceptor_bind, Some(sc)).expect("acceptor ep");
    let acceptor_listen_addr = acceptor_ep.local_addr().expect("acceptor addr");

    // Drop the external acceptor after the test finishes, not
    // before — spawn a dedicated accept task.
    let acceptor_accept_task = tokio::spawn(async move {
        // Accept one connection and hold it for a while so the
        // Dialer side can complete its QUIC handshake.
        if let Some(incoming) = acceptor_ep.accept().await {
            if let Ok(_conn) = incoming.await {
                tokio::time::sleep(Duration::from_secs(5)).await;
            }
        }
    });

    // Now run the Dialer in the race — peer_direct_addr = acceptor's
    // listen addr. The relay is the mock from above. Direct path
    // should win.
    let result = race(
        Role::Dialer,
        PeerCandidates {
            reflexive: Some(acceptor_listen_addr),
            local: Vec::new(),
            mapped: None,
        },
        relay_addr,
        "test-room".into(),
        "call-test".into(),
        None, // own_reflexive: not needed in tests
        None, // Phase 5: tests use fresh endpoints (no shared signal)
        None, // Phase 7: no IPv6 endpoint in tests
    )
    .await
    .expect("race must succeed");

    assert!(
        result.direct_transport.is_some(),
        "direct transport should be available"
    );
    assert_eq!(
        result.local_winner,
        WinningPath::Direct,
        "direct should win on loopback"
    );

    // Cancel the acceptor accept task so the test finishes.
    acceptor_accept_task.abort();
    // Suppress unused-var warning for the placeholder.
    let _ = unused_addr;
}

// -----------------------------------------------------------------------
// Test 2: relay wins when the direct peer is dead
// -----------------------------------------------------------------------
//
// Dialer role, peer_direct_addr = a port nothing is listening on,
// relay is the working mock. Direct dial will sit waiting for a
// QUIC handshake that never comes; the 2s direct timeout kicks in
// and the relay path wins the fallback.

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dual_path_relay_wins_when_direct_is_dead() {
    let _ = rustls::crypto::ring::default_provider().install_default();
    let (relay_addr, _relay_handle) = spawn_mock_relay().await;

    // A port that nothing is listening on — dead direct target.
    // Port 1 on loopback is almost never bound and UDP packets to
    // it will be dropped silently, so the QUIC handshake times out.
    let dead_peer: SocketAddr = "127.0.0.1:1".parse().unwrap();

    let result = race(
        Role::Dialer,
        PeerCandidates {
            reflexive: Some(dead_peer),
            local: Vec::new(),
            mapped: None,
        },
        relay_addr,
        "test-room".into(),
        "call-test".into(),
        None, // own_reflexive: not needed in tests
        None, // Phase 5: tests use fresh endpoints (no shared signal)
        None, // Phase 7: no IPv6 endpoint in tests
    )
    .await
    .expect("race must succeed via relay fallback");

    assert!(
        result.relay_transport.is_some(),
        "relay transport should be available"
    );
    assert_eq!(
        result.local_winner,
        WinningPath::Relay,
        "relay should win when direct dial has nowhere to land"
    );
}

// -----------------------------------------------------------------------
// Test 3: race errors cleanly when both paths are dead
// -----------------------------------------------------------------------
//
// Dialer role, peer_direct_addr = dead, relay_addr = dead.
// Expected: race returns an Err within ~7s (2s direct timeout +
// 5s relay timeout fallback).

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dual_path_errors_cleanly_when_both_paths_dead() {
    let _ = rustls::crypto::ring::default_provider().install_default();

    let dead_peer: SocketAddr = "127.0.0.1:1".parse().unwrap();
    let dead_relay: SocketAddr = "127.0.0.1:2".parse().unwrap();

    let start = std::time::Instant::now();
    let result = race(
        Role::Dialer,
        PeerCandidates {
            reflexive: Some(dead_peer),
            local: Vec::new(),
            mapped: None,
        },
        dead_relay,
        "test-room".into(),
        "call-test".into(),
        None, // own_reflexive: not needed in tests
        None, // Phase 5: tests use fresh endpoints (no shared signal)
        None, // Phase 7: no IPv6 endpoint in tests
    )
    .await;
    let elapsed = start.elapsed();

    assert!(result.is_err(), "both-dead must return Err");
    // Upper bound: direct 2s timeout + relay 5s fallback + small
    // slack for scheduling. If this blows, something is looping.
    assert!(
        elapsed < Duration::from_secs(10),
        "race took too long to give up: {:?}",
        elapsed
    );
}
