//! Device-level relay delivery tests.
//!
//! These tests use real DeviceHarness instances with the Messages app
//! and local test relays to verify event delivery through the full app
//! stack, without going through the UI message-sending path.

mod harness;

use std::collections::BTreeSet;
use std::time::{Duration, Instant};

use enostr::FullKeypair;
use harness::fixtures::{seed_local_dm_relay_list, test_config};
use harness::ui::{open_conversation_via_ui, send_message_via_ui};
use harness::{
    build_messages_device, init_tracing, publish_note_via_device, step_device_group,
    wait_for_device_giftwrap_subs_ready, wait_for_device_messages, LocalRelayExt, TEST_TIMEOUT,
};
use harness::{build_messages_device_with_relays, wait_for_device_group_messages};
use nostr::{Filter as NostrFilter, Kind as NostrKind};
use nostr_relay_builder::{
    prelude::{MemoryDatabase, MemoryDatabaseOptions, NostrEventsDatabase},
    LocalRelay, RelayBuilder,
};
use nostrdb::{Filter, NoteBuilder};
use notedeck_testing::negentropy_relay::{run_memory_negentropy_relay, MemoryNegentropyRelay};

async fn wait_for_relay_text_note(
    relay_db: &MemoryDatabase,
    content: &str,
    timeout: Duration,
    context: &str,
) {
    let deadline = Instant::now() + timeout;
    let filter = NostrFilter::new().kind(NostrKind::TextNote);

    loop {
        let events = relay_db
            .query(vec![filter.clone()])
            .await
            .expect("query relay text notes");
        if events.iter().any(|event| event.content == content) {
            return;
        }

        if Instant::now() >= deadline {
            let actual: Vec<&str> = events.iter().map(|event| event.content.as_str()).collect();
            panic!("{context}: expected {content:?}, actual {actual:?}");
        }

        tokio::time::sleep(Duration::from_millis(10)).await;
    }
}

async fn run_local_relay(context: &str) -> LocalRelay {
    LocalRelay::run(RelayBuilder::default())
        .await
        .expect(context)
}

async fn run_giftwrap_relay(context: &str) -> MemoryNegentropyRelay {
    // Giftwrap account subs send `NEG-OPEN`; plain `LocalRelay` rejects that
    // frame, so giftwrap delivery tests need the NIP-77-aware test relay.
    run_memory_negentropy_relay().await.expect(context)
}

async fn run_giftwrap_relay_pair() -> (MemoryNegentropyRelay, MemoryNegentropyRelay, String, String)
{
    let relay_a = run_giftwrap_relay("start relay a").await;
    let relay_b = run_giftwrap_relay("start relay b").await;
    let relay_a_url = relay_a.relay.url().to_owned();
    let relay_b_url = relay_b.relay.url().to_owned();
    (relay_a, relay_b, relay_a_url, relay_b_url)
}

// ==================== Thread Leak Helpers ====================

fn thread_names() -> std::collections::BTreeMap<String, usize> {
    let mut counts = std::collections::BTreeMap::new();
    let Ok(entries) = std::fs::read_dir("/proc/self/task") else {
        return counts;
    };
    for entry in entries.flatten() {
        let comm_path = entry.path().join("comm");
        let name = std::fs::read_to_string(comm_path)
            .unwrap_or_default()
            .trim()
            .to_string();
        *counts.entry(name).or_insert(0) += 1;
    }
    counts
}

/// Verify that creating and dropping Notedeck components doesn't leak threads.
/// Only meaningful on Linux where /proc/self/task is available.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn thread_leak_isolation() {
    init_tracing();

    // Skip on non-Linux (no /proc/self/task)
    if std::fs::read_dir("/proc/self/task").is_err() {
        return;
    }

    // Tokio may finish starting runtime workers after the test body begins.
    // Force that startup before taking leak baselines so later component drops
    // are compared against a stable test-runtime thread set.
    let warmups: Vec<_> = (0..8)
        .map(|_| {
            tokio::spawn(async {
                tokio::time::sleep(Duration::from_millis(1)).await;
            })
        })
        .collect();
    for warmup in warmups {
        warmup.await.expect("runtime warmup task");
    }
    std::thread::sleep(Duration::from_millis(100));

    let egui_ctx = egui::Context::default();

    #[derive(Clone, Default)]
    struct MockWakeup;
    impl enostr::Wakeup for MockWakeup {
        fn wake(&self) {}
    }

    fn assert_no_leaks(label: &str, before: &std::collections::BTreeMap<String, usize>) {
        let after = thread_names();
        let mut leaks = Vec::new();
        for (name, count) in &after {
            let bc = before.get(name).unwrap_or(&0);
            if count > bc {
                leaks.push(format!("{}(+{})", name, count - bc));
            }
        }
        let total: usize = after.values().sum();
        assert!(
            leaks.is_empty(),
            "{label}: leaked threads: {}",
            leaks.join(", ")
        );
        eprintln!("  {label}: ok (threads={total})");
    }

    // Step 1: Bare Ndb (proven clean)
    let b = thread_names();
    {
        let tmp = tempfile::TempDir::new().unwrap();
        let db = tmp.path().join("db");
        std::fs::create_dir_all(&db).unwrap();
        let cfg = test_config().set_ingester_threads(2);
        let _ndb = nostrdb::Ndb::new(db.to_str().unwrap(), &cfg).unwrap();
    }
    std::thread::sleep(Duration::from_secs(1));
    assert_no_leaks("Step 1: bare Ndb", &b);

    // Step 2: Ndb + OutboxPool (relay connection)
    let b = thread_names();
    {
        let tmp = tempfile::TempDir::new().unwrap();
        let db = tmp.path().join("db");
        std::fs::create_dir_all(&db).unwrap();
        let cfg = test_config().set_ingester_threads(2);
        let _ndb = nostrdb::Ndb::new(db.to_str().unwrap(), &cfg).unwrap();
        let _pool = enostr::OutboxPool::default();
    }
    std::thread::sleep(Duration::from_secs(1));
    assert_no_leaks("Step 2: Ndb + OutboxPool", &b);

    // Step 3: Ndb + OutboxPool + connect to relay
    let relay = run_local_relay("start relay").await;
    let relay_url = relay.url().to_owned();
    let b = thread_names();
    {
        let tmp = tempfile::TempDir::new().unwrap();
        let db = tmp.path().join("db");
        std::fs::create_dir_all(&db).unwrap();
        let cfg = test_config().set_ingester_threads(2);
        let _ndb = nostrdb::Ndb::new(db.to_str().unwrap(), &cfg).unwrap();
        let mut pool = enostr::OutboxPool::default();
        {
            let mut session = pool.start_session(MockWakeup);
            session.subscribe(
                vec![Filter::new().kinds([1]).build()],
                enostr::RelayUrlPkgs::new(
                    [enostr::NormRelayUrl::new(&relay_url).unwrap()]
                        .into_iter()
                        .collect(),
                ),
            );
        }
        // pump until connected
        for _ in 0..50 {
            pool.try_recv(|_| {});
            tokio::time::sleep(Duration::from_millis(10)).await;
        }
        drop(pool);
    }
    std::thread::sleep(Duration::from_secs(1));
    assert_no_leaks("Step 3: Ndb + OutboxPool + relay", &b);

    // Step 4: Ndb + JobPool
    let b = thread_names();
    {
        let tmp = tempfile::TempDir::new().unwrap();
        let db = tmp.path().join("db");
        std::fs::create_dir_all(&db).unwrap();
        let cfg = test_config().set_ingester_threads(2);
        let _ndb = nostrdb::Ndb::new(db.to_str().unwrap(), &cfg).unwrap();
        let _job_pool = notedeck::jobs::JobPool::default();
    }
    std::thread::sleep(Duration::from_secs(1));
    assert_no_leaks("Step 4: Ndb + JobPool", &b);

    // Step 5: Full Notedeck::init (no relay, no app)
    let b = thread_names();
    {
        let tmp = tempfile::TempDir::new().unwrap();
        let args = vec!["notedeck".to_string(), "--test".to_string()];
        let ctx = notedeck::Notedeck::init(&egui_ctx, tmp.path(), &args);
        drop(ctx);
    }
    std::thread::sleep(Duration::from_secs(1));
    assert_no_leaks("Step 5: Notedeck::init (no relay)", &b);

    // Step 6: Full Notedeck::init WITH relay
    let b = thread_names();
    {
        let tmp = tempfile::TempDir::new().unwrap();
        let args = vec![
            "notedeck".to_string(),
            "--test".to_string(),
            "--relay".to_string(),
            relay_url.clone(),
        ];
        let ctx = notedeck::Notedeck::init(&egui_ctx, tmp.path(), &args);
        drop(ctx);
    }
    std::thread::sleep(Duration::from_secs(1));
    assert_no_leaks("Step 6: Notedeck::init (with relay)", &b);

    // Step 7: Full DeviceHarness (no app)
    let b = thread_names();
    {
        let account = FullKeypair::generate();
        let device = notedeck_testing::device::build_device_with_relays(
            &[&relay_url],
            &account,
            Box::new(|_, _| {}),
        );
        drop(device);
    }
    std::thread::sleep(Duration::from_secs(1));
    assert_no_leaks("Step 7: DeviceHarness (no app)", &b);

    relay.shutdown_and_wait().await;
}

// ==================== Plain Kind-1 Event Delivery ====================

/// Publish a kind-1 note through one device, then verify the relay receives it.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn device_kind1_publish_receive() {
    init_tracing();

    let relay_db = MemoryDatabase::with_opts(MemoryDatabaseOptions {
        events: true,
        ..Default::default()
    });
    let relay = LocalRelay::run(RelayBuilder::default().database(relay_db.clone()))
        .await
        .expect("start relay");
    let relay_url = relay.url().to_owned();

    let sender_kp = FullKeypair::generate();

    let mut sender = build_messages_device(&relay_url, &sender_kp);

    sender.step();
    std::thread::sleep(Duration::from_millis(100));
    sender.step();

    let note = NoteBuilder::new()
        .kind(1)
        .content("device-relay-test")
        .sign(&sender_kp.secret_key.secret_bytes())
        .build()
        .expect("build note");

    publish_note_via_device(&mut sender, &note);

    wait_for_relay_text_note(
        &relay_db,
        "device-relay-test",
        TEST_TIMEOUT,
        "relay should receive published kind-1 note",
    )
    .await;

    relay.shutdown_and_wait().await;
}

// ==================== Giftwrap Delivery ====================

/// Two devices on same account: send a DM giftwrap via one, verify the other
/// receives it through the relay's giftwrap subscription.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn device_giftwrap_delivery() {
    init_tracing();

    let relay = run_giftwrap_relay("start relay").await;
    let relay_url = relay.relay.url().to_owned();

    let recipient = FullKeypair::generate();
    let sender = FullKeypair::generate();

    let mut sender_device = build_messages_device(&relay_url, &sender);
    let mut device_a = build_messages_device(&relay_url, &recipient);
    let mut device_b = build_messages_device(&relay_url, &recipient);

    // Seed DM relay lists so the sender knows where to publish
    seed_local_dm_relay_list(&mut sender_device, &sender, &relay_url);
    seed_local_dm_relay_list(&mut sender_device, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut device_a, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut device_b, &recipient, &relay_url);

    // Warm up
    sender_device.step();
    step_device_group(&mut [&mut device_a, &mut device_b]);
    std::thread::sleep(Duration::from_millis(100));
    sender_device.step();
    step_device_group(&mut [&mut device_a, &mut device_b]);

    // Wait for giftwrap subscriptions to be ready on all devices
    wait_for_device_giftwrap_subs_ready(
        &mut [&mut sender_device, &mut device_a, &mut device_b],
        TEST_TIMEOUT,
    );

    // Build a giftwrap note and publish through sender
    let giftwrap = harness::fixtures::build_backdated_giftwrap_note(
        &sender,
        &recipient,
        "giftwrap-delivery-test",
        notedeck::unix_time_secs() as u64,
    );
    publish_note_via_device(&mut sender_device, &giftwrap);

    // Verify both devices eventually see the message
    let expected: BTreeSet<String> = BTreeSet::from(["giftwrap-delivery-test".to_string()]);

    // device_a should get the message (it's the same account as recipient)
    wait_for_device_messages(
        &mut device_a,
        &expected,
        TEST_TIMEOUT,
        "device_a should receive giftwrap via relay",
    );

    // device_b should also get it
    wait_for_device_messages(
        &mut device_b,
        &expected,
        TEST_TIMEOUT,
        "device_b should receive giftwrap via relay",
    );
}

/// Same as above but with 6 messages sent in quick succession (burst pattern).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn device_giftwrap_burst_delivery() {
    init_tracing();

    let relay = run_giftwrap_relay("start relay").await;
    let relay_url = relay.relay.url().to_owned();

    let recipient = FullKeypair::generate();
    let sender = FullKeypair::generate();

    let mut sender_device = build_messages_device(&relay_url, &sender);
    let mut receiver = build_messages_device(&relay_url, &recipient);

    seed_local_dm_relay_list(&mut sender_device, &sender, &relay_url);
    seed_local_dm_relay_list(&mut sender_device, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut receiver, &recipient, &relay_url);

    sender_device.step();
    receiver.step();
    std::thread::sleep(Duration::from_millis(100));
    sender_device.step();
    receiver.step();

    wait_for_device_giftwrap_subs_ready(&mut [&mut sender_device, &mut receiver], TEST_TIMEOUT);

    // Burst-send 6 giftwraps with no delay between them
    let n = 6;
    let mut expected = BTreeSet::new();
    let now = notedeck::unix_time_secs() as u64;
    for i in 0..n {
        let msg = format!("burst-gw-{i}");
        expected.insert(msg.clone());
        let giftwrap =
            harness::fixtures::build_backdated_giftwrap_note(&sender, &recipient, &msg, now + i);
        publish_note_via_device(&mut sender_device, &giftwrap);
    }

    // Verify receiver gets all 6
    wait_for_device_messages(
        &mut receiver,
        &expected,
        TEST_TIMEOUT,
        "receiver should get all 6 burst giftwraps",
    );
}

/// Multiple receivers: burst-send giftwraps, verify all receive all messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn device_giftwrap_burst_multiple_receivers() {
    init_tracing();

    let relay = run_giftwrap_relay("start relay").await;
    let relay_url = relay.relay.url().to_owned();

    let recipient = FullKeypair::generate();
    let sender = FullKeypair::generate();

    let mut sender_device = build_messages_device(&relay_url, &sender);
    let mut dev_a = build_messages_device(&relay_url, &recipient);
    let mut dev_b = build_messages_device(&relay_url, &recipient);
    let mut dev_c = build_messages_device(&relay_url, &recipient);

    seed_local_dm_relay_list(&mut sender_device, &sender, &relay_url);
    seed_local_dm_relay_list(&mut sender_device, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut dev_a, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut dev_b, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut dev_c, &recipient, &relay_url);

    sender_device.step();
    step_device_group(&mut [&mut dev_a, &mut dev_b, &mut dev_c]);
    std::thread::sleep(Duration::from_millis(100));
    sender_device.step();
    step_device_group(&mut [&mut dev_a, &mut dev_b, &mut dev_c]);

    wait_for_device_giftwrap_subs_ready(
        &mut [&mut sender_device, &mut dev_a, &mut dev_b, &mut dev_c],
        TEST_TIMEOUT,
    );

    let n = 6;
    let mut expected = BTreeSet::new();
    let now = notedeck::unix_time_secs() as u64;
    for i in 0..n {
        let msg = format!("multi-recv-burst-{i}");
        expected.insert(msg.clone());
        let giftwrap =
            harness::fixtures::build_backdated_giftwrap_note(&sender, &recipient, &msg, now + i);
        publish_note_via_device(&mut sender_device, &giftwrap);
    }

    for (name, device) in [("a", &mut dev_a), ("b", &mut dev_b), ("c", &mut dev_c)] {
        wait_for_device_messages(
            device,
            &expected,
            TEST_TIMEOUT,
            &format!("device {name} should get all {n} giftwraps"),
        );
    }
}

// ==================== UI Send Path ====================

/// Use the actual UI path (open_conversation_via_ui + send_message_via_ui)
/// to send messages and verify delivery — this mirrors the E2E test pattern.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn device_ui_send_single_message() {
    init_tracing();

    let relay = run_giftwrap_relay("start relay").await;
    let relay_url = relay.relay.url().to_owned();

    let recipient = FullKeypair::generate();
    let sender = FullKeypair::generate();
    let recipient_npub = recipient.pubkey.npub().expect("npub");

    let mut sender_device = build_messages_device(&relay_url, &sender);
    let mut receiver = build_messages_device(&relay_url, &recipient);

    seed_local_dm_relay_list(&mut sender_device, &sender, &relay_url);
    seed_local_dm_relay_list(&mut sender_device, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut receiver, &recipient, &relay_url);

    sender_device.step();
    receiver.step();
    std::thread::sleep(Duration::from_millis(100));
    sender_device.step();
    receiver.step();

    wait_for_device_giftwrap_subs_ready(&mut [&mut sender_device, &mut receiver], TEST_TIMEOUT);

    // Open conversation on sender and send via UI
    open_conversation_via_ui(&mut sender_device, &recipient_npub);
    send_message_via_ui(&mut sender_device, "ui-send-test");

    let expected = BTreeSet::from(["ui-send-test".to_string()]);

    wait_for_device_messages(
        &mut receiver,
        &expected,
        TEST_TIMEOUT,
        "receiver should get UI-sent message",
    );
}

/// UI send path with burst of 6 messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn device_ui_send_burst() {
    init_tracing();

    let relay = run_giftwrap_relay("start relay").await;
    let relay_url = relay.relay.url().to_owned();

    let recipient = FullKeypair::generate();
    let sender = FullKeypair::generate();
    let recipient_npub = recipient.pubkey.npub().expect("npub");

    let mut sender_device = build_messages_device(&relay_url, &sender);
    let mut receiver = build_messages_device(&relay_url, &recipient);

    seed_local_dm_relay_list(&mut sender_device, &sender, &relay_url);
    seed_local_dm_relay_list(&mut sender_device, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut receiver, &recipient, &relay_url);

    sender_device.step();
    receiver.step();
    std::thread::sleep(Duration::from_millis(100));
    sender_device.step();
    receiver.step();

    wait_for_device_giftwrap_subs_ready(&mut [&mut sender_device, &mut receiver], TEST_TIMEOUT);

    open_conversation_via_ui(&mut sender_device, &recipient_npub);

    let n = 6;
    let mut expected = BTreeSet::new();
    for i in 0..n {
        let msg = format!("ui-burst-{i}");
        expected.insert(msg.clone());
        send_message_via_ui(&mut sender_device, &msg);
    }

    wait_for_device_messages(
        &mut receiver,
        &expected,
        TEST_TIMEOUT,
        "receiver should get all 6 UI-sent burst messages",
    );
}

/// UI send burst with multiple receivers (same account, different devices).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn device_ui_send_burst_multiple_receivers() {
    init_tracing();

    let relay = run_giftwrap_relay("start relay").await;
    let relay_url = relay.relay.url().to_owned();

    let recipient = FullKeypair::generate();
    let sender = FullKeypair::generate();
    let recipient_npub = recipient.pubkey.npub().expect("npub");

    let mut sender_device = build_messages_device(&relay_url, &sender);
    let mut dev_a = build_messages_device(&relay_url, &recipient);
    let mut dev_b = build_messages_device(&relay_url, &recipient);
    let mut dev_c = build_messages_device(&relay_url, &recipient);

    seed_local_dm_relay_list(&mut sender_device, &sender, &relay_url);
    seed_local_dm_relay_list(&mut sender_device, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut dev_a, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut dev_b, &recipient, &relay_url);
    seed_local_dm_relay_list(&mut dev_c, &recipient, &relay_url);

    sender_device.step();
    step_device_group(&mut [&mut dev_a, &mut dev_b, &mut dev_c]);
    std::thread::sleep(Duration::from_millis(100));
    sender_device.step();
    step_device_group(&mut [&mut dev_a, &mut dev_b, &mut dev_c]);

    wait_for_device_giftwrap_subs_ready(
        &mut [&mut sender_device, &mut dev_a, &mut dev_b, &mut dev_c],
        TEST_TIMEOUT,
    );

    open_conversation_via_ui(&mut sender_device, &recipient_npub);

    let n = 6;
    let mut expected = BTreeSet::new();
    for i in 0..n {
        let msg = format!("ui-multi-burst-{i}");
        expected.insert(msg.clone());
        send_message_via_ui(&mut sender_device, &msg);
    }

    for (name, device) in [("a", &mut dev_a), ("b", &mut dev_b), ("c", &mut dev_c)] {
        wait_for_device_messages(
            device,
            &expected,
            TEST_TIMEOUT,
            &format!("device {name} should get all {n} UI-sent messages"),
        );
    }
}

// ==================== Multi-Relay Routing ====================

/// Reproduces the failing E2E scenario: two same-account sender devices
/// connected to two relays, sending to different recipients routed through
/// different relays. Both sender devices should see all messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn multi_relay_same_account_cross_delivery() {
    init_tracing();

    let (_relay_a, _relay_b, relay_a_url, relay_b_url) = run_giftwrap_relay_pair().await;

    let sender = FullKeypair::generate();
    let recipient_a = FullKeypair::generate();
    let recipient_b = FullKeypair::generate();

    // Both sender devices connected to BOTH relays
    let mut sender_primary =
        build_messages_device_with_relays(&[&relay_a_url, &relay_b_url], &sender);
    let mut sender_peer = build_messages_device_with_relays(&[&relay_a_url, &relay_b_url], &sender);

    // Recipient devices on their respective relays
    let mut recipient_device_a = build_messages_device(&relay_a_url, &recipient_a);
    let mut recipient_device_b = build_messages_device(&relay_b_url, &recipient_b);

    // Seed DM relay lists
    for sender_device in [&mut sender_primary, &mut sender_peer] {
        harness::fixtures::seed_local_dm_relay_list_with_relays(
            sender_device,
            &sender,
            &[&relay_a_url, &relay_b_url],
            None,
        );
        harness::fixtures::seed_local_dm_relay_list_with_relays(
            sender_device,
            &recipient_a,
            &[&relay_a_url],
            None,
        );
        harness::fixtures::seed_local_dm_relay_list_with_relays(
            sender_device,
            &recipient_b,
            &[&relay_b_url],
            None,
        );
        harness::fixtures::seed_local_profile_metadata(sender_device, &recipient_a, "recip-a");
        harness::fixtures::seed_local_profile_metadata(sender_device, &recipient_b, "recip-b");
    }
    seed_local_dm_relay_list(&mut recipient_device_a, &recipient_a, &relay_a_url);
    seed_local_dm_relay_list(&mut recipient_device_b, &recipient_b, &relay_b_url);

    // Warm up
    step_device_group(&mut [
        &mut sender_primary,
        &mut sender_peer,
        &mut recipient_device_a,
        &mut recipient_device_b,
    ]);
    std::thread::sleep(Duration::from_millis(100));
    step_device_group(&mut [
        &mut sender_primary,
        &mut sender_peer,
        &mut recipient_device_a,
        &mut recipient_device_b,
    ]);

    wait_for_device_giftwrap_subs_ready(
        &mut [
            &mut sender_primary,
            &mut sender_peer,
            &mut recipient_device_a,
            &mut recipient_device_b,
        ],
        TEST_TIMEOUT,
    );

    // sender_primary sends to recipient_a (routed via relay_a)
    harness::ui::send_direct_message(
        &mut sender_primary,
        &recipient_a.pubkey.npub().expect("npub"),
        "msg-via-relay-a",
    );
    // Keep stepping ALL senders so BroadcastCache flushes to all relays
    harness::wait_for_device_messages_while_flushing(
        &mut recipient_device_a,
        &BTreeSet::from(["msg-via-relay-a".to_string()]),
        TEST_TIMEOUT,
        "recipient_a should get message via relay_a",
        &mut [&mut sender_primary, &mut sender_peer],
    );

    // sender_peer sends to recipient_b (routed via relay_b)
    harness::ui::send_direct_message(
        &mut sender_peer,
        &recipient_b.pubkey.npub().expect("npub"),
        "msg-via-relay-b",
    );
    harness::wait_for_device_messages_while_flushing(
        &mut recipient_device_b,
        &BTreeSet::from(["msg-via-relay-b".to_string()]),
        TEST_TIMEOUT,
        "recipient_b should get message via relay_b",
        &mut [&mut sender_primary, &mut sender_peer],
    );

    // Both sender devices should see BOTH messages
    let expected_sender =
        BTreeSet::from(["msg-via-relay-a".to_string(), "msg-via-relay-b".to_string()]);

    wait_for_device_group_messages(
        &mut [&mut sender_primary, &mut sender_peer],
        &expected_sender,
        TEST_TIMEOUT,
        "both sender devices should see messages from both relays",
    );
}

/// Publish via Explicit relay type (same as send_conversation_message) without UI.
/// This isolates whether the Explicit relay publishing path itself has issues.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn multi_relay_explicit_publish_no_ui() {
    init_tracing();

    let (_relay_a, _relay_b, relay_a_url, relay_b_url) = run_giftwrap_relay_pair().await;

    let sender = FullKeypair::generate();
    let recipient = FullKeypair::generate();

    let mut sender_device =
        build_messages_device_with_relays(&[&relay_a_url, &relay_b_url], &sender);
    let mut recv_a = build_messages_device(&relay_a_url, &recipient);
    let mut recv_b = build_messages_device(&relay_b_url, &recipient);

    seed_local_dm_relay_list(&mut recv_a, &recipient, &relay_a_url);
    seed_local_dm_relay_list(&mut recv_b, &recipient, &relay_b_url);
    harness::fixtures::seed_local_dm_relay_list_with_relays(
        &mut sender_device,
        &sender,
        &[&relay_a_url, &relay_b_url],
        None,
    );

    step_device_group(&mut [&mut sender_device, &mut recv_a, &mut recv_b]);
    std::thread::sleep(Duration::from_millis(100));
    step_device_group(&mut [&mut sender_device, &mut recv_a, &mut recv_b]);

    wait_for_device_giftwrap_subs_ready(
        &mut [&mut sender_device, &mut recv_a, &mut recv_b],
        TEST_TIMEOUT,
    );

    // Publish a giftwrap using EXPLICIT relay type (same path as send_conversation_message)
    let now = notedeck::unix_time_secs() as u64;
    let giftwrap = harness::fixtures::build_backdated_giftwrap_note(
        &sender,
        &recipient,
        "explicit-publish-test",
        now,
    );
    {
        let ctx = sender_device.ctx.clone();
        let app_ctx = &mut sender_device.state_mut().notedeck.app_context(&ctx);
        let relay_a_norm = enostr::NormRelayUrl::new(&relay_a_url).expect("norm a");
        let relay_b_norm = enostr::NormRelayUrl::new(&relay_b_url).expect("norm b");
        let explicit_relays = vec![
            enostr::RelayId::Websocket(relay_a_norm),
            enostr::RelayId::Websocket(relay_b_norm),
        ];
        let mut publisher = app_ctx.remote.publisher(app_ctx.accounts);
        publisher.publish_note(&giftwrap, notedeck::RelayType::Explicit(explicit_relays));
    }
    // Step to flush
    for _ in 0..10 {
        sender_device.step();
        std::thread::sleep(Duration::from_millis(10));
    }

    let expected = BTreeSet::from(["explicit-publish-test".to_string()]);
    harness::wait_for_device_messages_while_flushing(
        &mut recv_a,
        &expected,
        TEST_TIMEOUT,
        "recv_a should get explicit-published giftwrap",
        &mut [&mut sender_device],
    );
    harness::wait_for_device_messages_while_flushing(
        &mut recv_b,
        &expected,
        TEST_TIMEOUT,
        "recv_b should get explicit-published giftwrap",
        &mut [&mut sender_device],
    );
}

/// Same multi-relay scenario but using publish_note_via_device (no UI) to isolate
/// whether the failure is in the UI interaction or the relay publishing.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn multi_relay_no_ui_cross_delivery() {
    init_tracing();

    let (_relay_a, _relay_b, relay_a_url, relay_b_url) = run_giftwrap_relay_pair().await;

    let sender = FullKeypair::generate();
    let recipient = FullKeypair::generate();

    // Sender device connected to BOTH relays
    let mut sender_device =
        build_messages_device_with_relays(&[&relay_a_url, &relay_b_url], &sender);
    // Two receiver devices (same account) each on one relay
    let mut recv_a = build_messages_device(&relay_a_url, &recipient);
    let mut recv_b = build_messages_device(&relay_b_url, &recipient);

    seed_local_dm_relay_list(&mut recv_a, &recipient, &relay_a_url);
    seed_local_dm_relay_list(&mut recv_b, &recipient, &relay_b_url);
    harness::fixtures::seed_local_dm_relay_list_with_relays(
        &mut sender_device,
        &sender,
        &[&relay_a_url, &relay_b_url],
        None,
    );
    harness::fixtures::seed_local_dm_relay_list_with_relays(
        &mut sender_device,
        &recipient,
        &[&relay_a_url, &relay_b_url],
        None,
    );

    step_device_group(&mut [&mut sender_device, &mut recv_a, &mut recv_b]);
    std::thread::sleep(Duration::from_millis(100));
    step_device_group(&mut [&mut sender_device, &mut recv_a, &mut recv_b]);

    wait_for_device_giftwrap_subs_ready(
        &mut [&mut sender_device, &mut recv_a, &mut recv_b],
        TEST_TIMEOUT,
    );

    // Publish a giftwrap addressed to recipient via sender device (no UI)
    let now = notedeck::unix_time_secs() as u64;
    let giftwrap = harness::fixtures::build_backdated_giftwrap_note(
        &sender,
        &recipient,
        "no-ui-multi-relay",
        now,
    );
    publish_note_via_device(&mut sender_device, &giftwrap);

    // Step sender to flush
    for _ in 0..10 {
        sender_device.step();
        std::thread::sleep(Duration::from_millis(10));
    }

    // Both receivers should see the message
    let expected = BTreeSet::from(["no-ui-multi-relay".to_string()]);
    harness::wait_for_device_messages_while_flushing(
        &mut recv_a,
        &expected,
        TEST_TIMEOUT,
        "recv_a should get no-ui giftwrap",
        &mut [&mut sender_device],
    );
    harness::wait_for_device_messages_while_flushing(
        &mut recv_b,
        &expected,
        TEST_TIMEOUT,
        "recv_b should get no-ui giftwrap",
        &mut [&mut sender_device],
    );
}

/// Multi-relay with UI send but ONLY one sender device — isolates whether
/// the issue is from having two sender devices or from multi-relay publishing.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn multi_relay_single_sender_ui() {
    init_tracing();

    let (_relay_a, _relay_b, relay_a_url, relay_b_url) = run_giftwrap_relay_pair().await;

    let sender = FullKeypair::generate();
    let recipient = FullKeypair::generate();
    let recipient_npub = recipient.pubkey.npub().expect("npub");

    let mut sender_device =
        build_messages_device_with_relays(&[&relay_a_url, &relay_b_url], &sender);
    let mut recv_a = build_messages_device(&relay_a_url, &recipient);
    let mut recv_b = build_messages_device(&relay_b_url, &recipient);

    harness::fixtures::seed_local_dm_relay_list_with_relays(
        &mut sender_device,
        &sender,
        &[&relay_a_url, &relay_b_url],
        None,
    );
    harness::fixtures::seed_local_dm_relay_list_with_relays(
        &mut sender_device,
        &recipient,
        &[&relay_a_url, &relay_b_url],
        None,
    );
    // Seed consistent two-relay DM relay list on all receiver devices
    // (matches what the sender has — same account should advertise the same list)
    harness::fixtures::seed_local_dm_relay_list_with_relays(
        &mut recv_a,
        &recipient,
        &[&relay_a_url, &relay_b_url],
        None,
    );
    harness::fixtures::seed_local_dm_relay_list_with_relays(
        &mut recv_b,
        &recipient,
        &[&relay_a_url, &relay_b_url],
        None,
    );

    step_device_group(&mut [&mut sender_device, &mut recv_a, &mut recv_b]);
    std::thread::sleep(Duration::from_millis(100));
    step_device_group(&mut [&mut sender_device, &mut recv_a, &mut recv_b]);

    wait_for_device_giftwrap_subs_ready(
        &mut [&mut sender_device, &mut recv_a, &mut recv_b],
        TEST_TIMEOUT,
    );

    // Send via UI
    open_conversation_via_ui(&mut sender_device, &recipient_npub);

    send_message_via_ui(&mut sender_device, "ui-multi-relay-test");

    // Step sender to flush
    for _ in 0..10 {
        sender_device.step();
        std::thread::sleep(Duration::from_millis(10));
    }

    let expected = BTreeSet::from(["ui-multi-relay-test".to_string()]);
    harness::wait_for_device_messages_while_flushing(
        &mut recv_a,
        &expected,
        TEST_TIMEOUT,
        "recv_a should get UI message via relay_a",
        &mut [&mut sender_device],
    );
    harness::wait_for_device_messages_while_flushing(
        &mut recv_b,
        &expected,
        TEST_TIMEOUT,
        "recv_b should get UI message via relay_b",
        &mut [&mut sender_device],
    );
}
